sigrank 0.15.4 → 0.16.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
@@ -12,10 +12,14 @@ For all builders, burners and 10xers.
12
12
 
13
13
  [![npm version](https://img.shields.io/npm/v/sigrank.svg?style=flat-square&color=gold)](https://www.npmjs.com/package/sigrank)
14
14
  [![CI](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/ci.yml)
15
+ [![CodeQL](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/codeql.yml)
16
+ [![audit](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://github.com/SunrisesIllNeverSee/sigrank-mcp/actions/workflows/dependency-audit.yml)
17
+ [![Dependabot](https://img.shields.io/badge/Dependabot-enabled-025E8C?style=flat-square&logo=dependabot)](https://github.com/SunrisesIllNeverSee/sigrank-mcp/network/dependencies)
15
18
  [![license](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](./LICENSE)
16
19
  [![platform](https://img.shields.io/badge/platform-node-grey.svg?style=flat-square)](https://nodejs.org)
17
20
  [![live](https://img.shields.io/badge/board-signalaf.com-gold.svg?style=flat-square)](https://signalaf.com)
18
21
  [![SunrisesIllNeverSee/sigrank-mcp MCP server](https://glama.ai/mcp/servers/SunrisesIllNeverSee/sigrank-mcp/badges/score.svg)](https://glama.ai/mcp/servers/SunrisesIllNeverSee/sigrank-mcp)
22
+ [![Smithery](https://smithery.ai/badge/burnmydays/sigrank-mcp)](https://smithery.ai/servers/burnmydays/sigrank-mcp)
19
23
 
20
24
  </div>
21
25
 
@@ -72,13 +76,44 @@ sigrank
72
76
  **Repo:** [`SunrisesIllNeverSee/sigrank-mcp`](https://github.com/SunrisesIllNeverSee/sigrank-mcp)
73
77
  **Site:** [signalaf.com](https://signalaf.com)
74
78
  **npm:** [sigrank](https://www.npmjs.com/package/sigrank)
79
+ **Smithery:** [smithery.ai/servers/burnmydays/sigrank-mcp](https://smithery.ai/servers/burnmydays/sigrank-mcp)
80
+ **Glama:** [glama.ai/mcp/servers/SunrisesIllNeverSee/sigrank-mcp](https://glama.ai/mcp/servers/SunrisesIllNeverSee/sigrank-mcp)
81
+
82
+ ---
83
+
84
+ ## Install via Smithery
85
+
86
+ SigRank is available on [Smithery](https://smithery.ai/servers/burnmydays/sigrank-mcp) as a stdio MCP bundle — one-click install for Claude Desktop, Cursor, and other MCP clients.
87
+
88
+ ### Smithery CLI
89
+
90
+ ```bash
91
+ # Install Smithery CLI
92
+ npm install -g smithery
93
+
94
+ # Connect to SigRank (downloads the MCPB bundle locally)
95
+ smithery mcp add burnmydays/sigrank-mcp --id sigrank
96
+
97
+ # List available tools
98
+ smithery tool list sigrank
99
+
100
+ # Call a tool
101
+ smithery tool call sigrank get_leaderboard '{}'
102
+ smithery tool call sigrank rank_paste '{"text": "1000000 500000 50000 800000"}'
103
+ ```
104
+
105
+ ### Claude Desktop (via Smithery)
106
+
107
+ 1. Go to [smithery.ai/servers/burnmydays/sigrank-mcp](https://smithery.ai/servers/burnmydays/sigrank-mcp)
108
+ 2. Click **Install**
109
+ 3. Smithery handles the rest — no manual config editing
75
110
 
76
111
  ---
77
112
 
78
113
  ## Commands
79
114
 
80
115
  ```
81
- ⊙ SigRank CLI v0.14.2
116
+ ⊙ SigRank CLI v0.15.5
82
117
 
83
118
  Default (no args)
84
119
  sigrank unified dashboard: cascade + token pillars + board
@@ -93,11 +128,12 @@ Commands
93
128
  compare --platform codex compare for a specific platform
94
129
  tui full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect
95
130
  tui --platform codex TUI with a different default platform
96
- watch live tune meter — re-reads local logs every 30s
97
- watch --window 7d watch a specific window
131
+ watch live tune meter — ALL active platforms × all windows, every 30s
132
+ watch --platform codex watch only one platform (optional filter)
133
+ watch --window 7d watch only one window (optional filter)
98
134
 
99
135
  Options
100
- --window 7d · 30d · 90d · all (default: 30d for board, 7d for watch)
136
+ --window 7d · 30d · 90d · all (default: 30d for board; all windows for watch)
101
137
  --platform claude · codex · amp · gemini · opencode · goose · …
102
138
  --refresh poll interval in seconds (default: 30)
103
139
  --once print once and exit (board only)
@@ -188,6 +224,10 @@ Or if installed globally:
188
224
  | `watch_tokenpull` | `{platform?, interval_s?}` | One cascade snapshot per call (interval_s advisory) |
189
225
  | `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
226
  | `enroll` | `{code, device_label?}` | Bind this device with a connect code from signalaf.com → Settings |
227
+ | `diagnose_cascade` | `{text?}` | Diagnoses where your token cascade is leaking efficiency — ranked findings with severity + estimated Υ impact |
228
+ | `simulate_change` | `{text?, changes}` | Prescriptive "what if" — test proposed pillar changes and see the exact Υ delta + class change before committing |
229
+ | `suggest_improvements` | `{text?}` | Generates ranked, simulated improvement suggestions — tests strategies and returns them sorted by Υ yield impact |
230
+ | `self_improve` | `{text?}` | One-click optimize: diagnoses, suggests, and simulates the best change in a single call |
191
231
 
192
232
  ---
193
233
 
@@ -277,11 +317,12 @@ All adapters are token-only (no message content, no cost fields, no credentials)
277
317
  ## Dev / test
278
318
 
279
319
  ```bash
280
- node test.mjs # 29 unit tests (no network, no fs writes)
320
+ node test.mjs # 13 test groups, 200 assertions (no network, no fs writes)
321
+ node sign.test.mjs # ed25519 signing + canon parity
281
322
  node index.mjs # stdio MCP server directly (pipe to MCP client)
282
323
  ```
283
324
 
284
- Tests verify (29 assertions):
325
+ Tests verify (13 groups, 200 assertions):
285
326
  - `rank_paste` canon: MO§ES `(1251211, 11296121, 128196310, 2555179769)` → Υ 18436.98 · TRANSMITTER
286
327
  - `submit_paste` preview (no codename) + POST shape (injected fetch, no live writes)
287
328
  - `tokenpull` dedup, window slicing, 4-window pillars (mock adapter)
@@ -289,8 +330,12 @@ Tests verify (29 assertions):
289
330
  - `tokenpullCodex` io_ratio conversion per-window
290
331
  - Adapter registry (15 platforms) + per-adapter shape contracts
291
332
  - `rank_windows` 4-window paste scoring, partial input, no-network
292
- - `watch_tokenpull` cascade snapshot, interval_s, TODO(AUTH.WIRE) stub
333
+ - `watch_tokenpull` cascade snapshot, interval_s, submit path
334
+ - `enroll` posts identity (public key only), maps 201 enrolled + 410 code_invalid
335
+ - `submit_verified` signs Schema 1.0, server-verifiable
336
+ - `simulate_change` relative + absolute deltas, quadratic penalty, JSON input
293
337
  - Hardening: div-by-zero guards, parsePillars warnings, fetch timeout, EXCLUDE_TOOLING regex, narrate safety
338
+ - `sign.test.mjs` ed25519 round-trip + canonical 926-byte payload parity
294
339
 
295
340
  ---
296
341
 
@@ -310,4 +355,6 @@ Tests verify (29 assertions):
310
355
  | `submit.mjs` | Verified submit flow (signs + POSTs to board) |
311
356
  | `sign.mjs` | Schema 1.0 signing (X-Agent-Signature) |
312
357
  | `narrate.mjs` | Deterministic prose narration card |
358
+ | `preflight.mjs` | Plausibility checks (Benford, bounds, anomaly detection) |
313
359
  | `test.mjs` | Unit tests (no external deps) |
360
+ | `sign.test.mjs` | ed25519 signing + canon parity test |
package/adapters.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  import { readdir, readFile } from 'node:fs/promises'
36
- import { join, extname } from 'node:path'
36
+ import { join, dirname } from 'node:path'
37
37
  import { homedir } from 'node:os'
38
38
  import { execFile as execFileCb } from 'node:child_process'
39
39
  import { promisify } from 'node:util'
@@ -212,10 +212,6 @@ export const openclawAdapter = {
212
212
  defaultRoot: () => {
213
213
  const env = process.env.OPENCLAW_DIR
214
214
  if (env) return env.split(',')[0].trim()
215
- for (const d of ['.openclaw', '.clawdbot', '.moltbot', '.moldbot']) {
216
- const p = join(homedir(), d)
217
- return p // return first; walkFiles silently skips missing
218
- }
219
215
  return join(homedir(), '.openclaw')
220
216
  },
221
217
  async *messages(root) {
@@ -245,6 +241,9 @@ export const openclawAdapter = {
245
241
  // ~/.factory/sessions/**/*.settings.json
246
242
  // Fields: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, thinking_tokens
247
243
  // Thinking tokens → output. Per-settings-file (session-level granularity).
244
+ // CUMULATIVE-COLUMN GUARD: settings files contain session-level totals. If the same session
245
+ // appears in multiple files, summing double-counts. We set both id and sid to the session id
246
+ // so tokenpull()'s (sid|id) keep-last dedup collapses duplicates to the latest values.
248
247
  export const droidAdapter = {
249
248
  platform: 'droid',
250
249
  defaultRoot: () => join(homedir(), '.factory', 'sessions'),
@@ -260,7 +259,8 @@ export const droidAdapter = {
260
259
  const cacheCreate = Number(s.cache_creation_tokens || 0)
261
260
  const cacheRead = Number(s.cache_read_tokens || 0)
262
261
  if (input + output + cacheCreate + cacheRead === 0) continue
263
- yield { id: null, sid: s.session_id || s.id || null, ts: s.updated_at || s.created_at || null, input, output, cacheCreate, cacheRead, file: path }
262
+ const sessionId = String(s.session_id || s.id || '')
263
+ yield { id: sessionId, sid: sessionId, ts: s.updated_at || s.created_at || null, input, output, cacheCreate, cacheRead, file: path }
264
264
  }
265
265
  }
266
266
  },
@@ -345,7 +345,7 @@ export const copilotAdapter = {
345
345
  defaultRoot: () => join(homedir(), '.copilot', 'otel'),
346
346
  async *messages(root) {
347
347
  const dir = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH
348
- ? require('node:path').dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
348
+ ? dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
349
349
  : root
350
350
  for await (const path of walkFiles(dir, isJsonl)) {
351
351
  const text = await readUtf8(path)
@@ -387,6 +387,10 @@ export const opencodeAdapter = {
387
387
  // SQLite: sessions.db at standard Goose data roots or $GOOSE_PATH_ROOT/data/sessions/sessions.db
388
388
  // Columns: accumulated_input_tokens (or input_tokens), accumulated_output_tokens (or output_tokens),
389
389
  // accumulated_total_tokens (or total_tokens). NO cache fields. Reasoning = total-input-output.
390
+ // CUMULATIVE-COLUMN GUARD: accumulated_* columns are per-session running totals. If the sessions
391
+ // table has multiple rows per session id, summing them downstream double-counts. We set both sid
392
+ // and id to the session id and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup
393
+ // collapses duplicate session rows to the latest (max accumulated) values.
390
394
  export const gooseAdapter = {
391
395
  platform: 'goose',
392
396
  estimated: true, // no cacheCreate or cacheRead
@@ -405,14 +409,17 @@ export const gooseAdapter = {
405
409
  join(homedir(), '.local', 'share', 'Block', 'goose', 'sessions', 'sessions.db'),
406
410
  ]
407
411
  for (const db of dbCandidates) {
408
- const rows = await sqliteJson(db, 'SELECT * FROM sessions')
412
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last
413
+ // (tokenpull keep-last dedup picks the final row = max accumulated values).
414
+ const rows = await sqliteJson(db, 'SELECT * FROM sessions ORDER BY updated_at')
409
415
  for (const row of rows) {
410
416
  const input = Number(row.accumulated_input_tokens || row.input_tokens || 0)
411
417
  const output = Number(row.accumulated_output_tokens || row.output_tokens || 0)
412
418
  const total = Number(row.accumulated_total_tokens || row.total_tokens || 0)
413
419
  const reasoning = Math.max(0, total - input - output) // folded into output
414
420
  if (input + output === 0) continue
415
- yield { id: String(row.id || ''), sid: null, ts: row.created_at || row.updated_at || null, input, output: output + reasoning, cacheCreate: 0, cacheRead: 0, file: db }
421
+ const sessionId = String(row.id || row.session_id || '')
422
+ yield { id: sessionId, sid: sessionId, ts: row.created_at || row.updated_at || null, input, output: output + reasoning, cacheCreate: 0, cacheRead: 0, file: db }
416
423
  }
417
424
  }
418
425
  },
@@ -441,13 +448,18 @@ export const kiloAdapter = {
441
448
  // ── 13. Hermes Agent ─────────────────────────────────────────────────────────
442
449
  // SQLite: ~/.hermes/state.db
443
450
  // Per-session rows: input, output, cache_read, cache_write (=cacheCreate), reasoning_tokens → output
451
+ // CUMULATIVE-COLUMN GUARD: session rows may contain cumulative totals. If the same session
452
+ // appears in multiple rows, summing double-counts. We set both id and sid to the session id
453
+ // and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup collapses duplicates
454
+ // to the latest values.
444
455
  export const hermesAdapter = {
445
456
  platform: 'hermes',
446
457
  defaultRoot: () => join(homedir(), '.hermes'),
447
458
  async *messages(root) {
448
459
  for (const r of roots('HERMES_HOME', root)) {
449
460
  const dbPath = join(r, 'state.db')
450
- const rows = await sqliteJson(dbPath, 'SELECT * FROM sessions')
461
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last.
462
+ const rows = await sqliteJson(dbPath, 'SELECT * FROM sessions ORDER BY updated_at')
451
463
  for (const row of rows) {
452
464
  const input = Number(row.input || 0)
453
465
  const reasoning = Number(row.reasoning_tokens || 0)
@@ -455,7 +467,8 @@ export const hermesAdapter = {
455
467
  const cacheCreate = Number(row.cache_write || row.cache_creation || 0)
456
468
  const cacheRead = Number(row.cache_read || 0)
457
469
  if (input + output + cacheCreate + cacheRead === 0) continue
458
- yield { id: String(row.id || ''), sid: null, ts: row.created_at || row.updated_at || null, input, output, cacheCreate, cacheRead, file: dbPath }
470
+ const sessionId = String(row.id || row.session_id || '')
471
+ yield { id: sessionId, sid: sessionId, ts: row.created_at || row.updated_at || null, input, output, cacheCreate, cacheRead, file: dbPath }
459
472
  }
460
473
  }
461
474
  },
package/badges.mjs ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * badges.mjs — pure badge computation for the Cascade Report System.
3
+ *
4
+ * 8 launch badges. Pure functions — take history + current pillars, return
5
+ * badge collection + progress. No deps, no transport, no network.
6
+ *
7
+ * Badge types:
8
+ * Permanent — once earned, always earned (First Spark, Cascade Engine, etc.)
9
+ * Current — must be maintained (Cascade Streak, Top 10)
10
+ *
11
+ * Badge states:
12
+ * earned — condition met, badge is in the collection
13
+ * in_progress — condition not yet met, but progress is trackable
14
+ * locked — condition not met, no progress to show (not returned)
15
+ */
16
+
17
+ /**
18
+ * Compute all 8 launch badges.
19
+ *
20
+ * @param {object} params
21
+ * @param {object} params.pillars — current { input, output, cacheCreate, cacheRead }
22
+ * @param {object} params.cascade — current cascade result (from cascade())
23
+ * @param {array} params.history — array of daily snapshots { date, mode, yield, pillars }
24
+ * @param {boolean} params.isVerified — whether the operator submitted via signed agent
25
+ * @param {number|null} params.rank — current leaderboard rank (null if not ranked)
26
+ * @returns {object} { earned_this_week, in_progress, collection }
27
+ */
28
+ export function computeBadges({ pillars, cascade: cas, history = [], isVerified = false, rank = null }) {
29
+ const cr = Number(pillars?.cacheRead || 0)
30
+ const i = Number(pillars?.input || 0)
31
+ const leverage = cas?.leverage ?? (i > 0 ? cr / i : 0)
32
+
33
+ const earned = []
34
+ const inProgress = []
35
+ const collection = []
36
+
37
+ // 1. First Spark — cacheRead > input (1× leverage)
38
+ if (leverage > 1) {
39
+ earned.push({ id: 'first_spark', label: 'First Spark', icon: '◈', condition: '1× cache leverage', date_earned: _latestDate(history) })
40
+ collection.push('first_spark')
41
+ } else if (cr > 0) {
42
+ inProgress.push({ id: 'first_spark', label: 'First Spark', icon: '◈', progress: Math.min(leverage, 1), target: 1, display: `${leverage.toFixed(1)}× / 1×` })
43
+ }
44
+
45
+ // 2. Cascade Engine — cacheRead > 10× input
46
+ if (leverage > 10) {
47
+ earned.push({ id: 'cascade_engine', label: 'Cascade Engine', icon: '⚡', condition: '10× cache leverage', date_earned: _latestDate(history) })
48
+ collection.push('cascade_engine')
49
+ } else if (leverage > 1) {
50
+ inProgress.push({ id: 'cascade_engine', label: 'Cascade Engine', icon: '⚡', progress: Math.min(leverage, 10), target: 10, display: `${leverage.toFixed(1)}× / 10×` })
51
+ }
52
+
53
+ // 3. Chain Reaction — cacheRead > 100× input
54
+ if (leverage > 100) {
55
+ earned.push({ id: 'chain_reaction', label: 'Chain Reaction', icon: '⚡', condition: '100× cache leverage', date_earned: _latestDate(history) })
56
+ collection.push('chain_reaction')
57
+ } else if (leverage > 10) {
58
+ inProgress.push({ id: 'chain_reaction', label: 'Chain Reaction', icon: '⚡', progress: Math.min(leverage, 100), target: 100, display: `${leverage.toFixed(1)}× / 100×` })
59
+ }
60
+
61
+ // 4. Foundation — 3 completed BUILD→MAINTAIN arcs
62
+ const arcs = _countBuildMaintainArcs(history)
63
+ if (arcs >= 3) {
64
+ earned.push({ id: 'foundation', label: 'Foundation', icon: '🏗️', condition: '3 BUILD→MAINTAIN arcs', date_earned: _latestDate(history) })
65
+ collection.push('foundation')
66
+ } else {
67
+ inProgress.push({ id: 'foundation', label: 'Foundation', icon: '🏗️', progress: arcs, target: 3, display: `${arcs}/3 arcs` })
68
+ }
69
+
70
+ // 5. Phoenix — returned to MAINTAIN within 1 day of disruption, 5×
71
+ const phoenixCount = _countPhoenixReturns(history)
72
+ if (phoenixCount >= 5) {
73
+ earned.push({ id: 'phoenix', label: 'Phoenix', icon: '🔥', condition: '5× MAINTAIN recovery within 1 day', date_earned: _latestDate(history) })
74
+ collection.push('phoenix')
75
+ } else {
76
+ inProgress.push({ id: 'phoenix', label: 'Phoenix', icon: '🔥', progress: phoenixCount, target: 5, display: `${phoenixCount}/5 recoveries` })
77
+ }
78
+
79
+ // 6. Verified — submitted via signed agent
80
+ if (isVerified) {
81
+ earned.push({ id: 'verified', label: 'Verified', icon: '✓', condition: 'Submitted via signed agent', date_earned: _latestDate(history) })
82
+ collection.push('verified')
83
+ }
84
+ // No in-progress for verified — it's binary
85
+
86
+ // 7. Cascade Streak 🔥 — 7 consecutive days MAINTAIN (current badge)
87
+ const streak = _countMaintainStreak(history)
88
+ if (streak >= 7) {
89
+ earned.push({ id: 'cascade_streak', label: 'Cascade Streak', icon: '🔥', condition: '7 consecutive days MAINTAIN', date_earned: _latestDate(history), current: true })
90
+ collection.push('cascade_streak')
91
+ } else {
92
+ inProgress.push({ id: 'cascade_streak', label: 'Cascade Streak', icon: '🔥', progress: streak, target: 7, display: `${streak}/7 days` })
93
+ }
94
+
95
+ // 8. Top 10 🏆 — currently in top 10 (server-side, but we include it if rank is provided)
96
+ if (rank !== null && rank <= 10) {
97
+ earned.push({ id: 'top_10', label: 'Top 10', icon: '🏆', condition: `Currently #${rank}`, date_earned: _latestDate(history), current: true })
98
+ collection.push('top_10')
99
+ }
100
+ // No in-progress for top 10 — it's a live position, not a progress badge
101
+
102
+ return {
103
+ earned_this_week: earned.map(b => b.id),
104
+ in_progress: inProgress,
105
+ collection,
106
+ }
107
+ }
108
+
109
+ // ── Helpers ─────────────────────────────────────────────────────────────────
110
+
111
+ function _latestDate(history) {
112
+ if (!history || history.length === 0) return null
113
+ return history[history.length - 1]?.date || null
114
+ }
115
+
116
+ /**
117
+ * Count completed BUILD→MAINTAIN arcs in history.
118
+ * An arc = a BUILD phase followed by a MAINTAIN phase.
119
+ */
120
+ function _countBuildMaintainArcs(history) {
121
+ if (!history || history.length < 2) return 0
122
+ let arcs = 0
123
+ let wasBuild = false
124
+ for (const h of history) {
125
+ if (h.mode === 'BUILD') {
126
+ wasBuild = true
127
+ } else if (h.mode === 'MAINTAIN' && wasBuild) {
128
+ arcs++
129
+ wasBuild = false
130
+ }
131
+ }
132
+ return arcs
133
+ }
134
+
135
+ /**
136
+ * Count Phoenix returns — MAINTAIN within 1 day of a non-MAINTAIN day.
137
+ */
138
+ function _countPhoenixReturns(history) {
139
+ if (!history || history.length < 2) return 0
140
+ let count = 0
141
+ for (let idx = 1; idx < history.length; idx++) {
142
+ if (history[idx].mode === 'MAINTAIN' && history[idx - 1].mode !== 'MAINTAIN') {
143
+ count++
144
+ }
145
+ }
146
+ return count
147
+ }
148
+
149
+ /**
150
+ * Count current consecutive MAINTAIN days (ending at the last entry).
151
+ */
152
+ function _countMaintainStreak(history) {
153
+ if (!history || history.length === 0) return 0
154
+ let streak = 0
155
+ for (let idx = history.length - 1; idx >= 0; idx--) {
156
+ if (history[idx].mode === 'MAINTAIN') {
157
+ streak++
158
+ } else {
159
+ break
160
+ }
161
+ }
162
+ return streak
163
+ }
package/cascade.mjs CHANGED
@@ -57,6 +57,7 @@ export function cascade({ input, output, cacheCreate, cacheRead }) {
57
57
  velocity: round(velocity, 3),
58
58
  dev10x: round(dev10x, 2),
59
59
  class: classify(yield_, dev10x),
60
+ mode: detectMode({ input: i, output: o, cacheCreate: cw, cacheRead: cr }),
60
61
  }
61
62
  if (warnings.length > 0) result.warnings = warnings
62
63
  return result
@@ -75,6 +76,74 @@ export function classify(yieldVal, dev10x) {
75
76
  return 'IGNITER'
76
77
  }
77
78
 
79
+ /**
80
+ * detectMode — classify an operator's current working mode from 4 token pillars.
81
+ *
82
+ * Pure ratio math, first-match-wins (same pattern as classify() — descending
83
+ * cuts preserve edge semantics). MAINTAIN checked first (high leverage is
84
+ * strongest signal), then DEBUG (low velocity is distinctive), then EDIT,
85
+ * then BUILD as fallback.
86
+ *
87
+ * Modes:
88
+ * BUILD — high input, low/zero cacheRead, output rising (greenfield)
89
+ * EDIT — high input, low cacheRead, high output (polishing)
90
+ * DEBUG — high input, low output, low cacheRead (investigating)
91
+ * MAINTAIN — low input, high cacheRead, high output (compounding)
92
+ * IDLE — near-zero tokens (not working)
93
+ *
94
+ * Returns { mode, confidence }.
95
+ */
96
+ export function detectMode({ input, output, cacheCreate, cacheRead }) {
97
+ const i = Number(input), o = Number(output), cw = Number(cacheCreate), cr = Number(cacheRead)
98
+ const total = i + o + cw + cr
99
+
100
+ // IDLE: near-zero tokens
101
+ if (total < 1000) return { mode: 'IDLE', confidence: 1.0 }
102
+
103
+ const leverage = i > 0 ? cr / i : 0
104
+ const velocity = i > 0 ? o / i : 0
105
+ const input_share = total > 0 ? i / total : 0
106
+
107
+ // MAINTAIN: high leverage + high velocity (the cascade is compounding)
108
+ if (leverage > 10 && velocity > 1) return { mode: 'MAINTAIN', confidence: 0.9 }
109
+ if (leverage > 3 && velocity > 0.5) return { mode: 'MAINTAIN', confidence: 0.7 }
110
+
111
+ // DEBUG: low velocity + high input share (reading, not producing)
112
+ if (velocity < 0.3 && input_share > 0.5) return { mode: 'DEBUG', confidence: 0.8 }
113
+
114
+ // EDIT: high input share + high velocity (fresh input but producing)
115
+ if (input_share > 0.4 && velocity > 0.5) return { mode: 'EDIT', confidence: 0.7 }
116
+
117
+ // DEBUG (secondary): high input share + low velocity
118
+ if (input_share > 0.4 && velocity < 0.5) return { mode: 'DEBUG', confidence: 0.6 }
119
+
120
+ // BUILD: fallback (high input, no cache reuse yet)
121
+ return { mode: 'BUILD', confidence: 0.6 }
122
+ }
123
+
124
+ /**
125
+ * Expected yield per mode — global defaults used before personal baselines
126
+ * exist (needs 7+ days of history). Used by the quality score computation.
127
+ */
128
+ export const MODE_EXPECTED_YIELD = {
129
+ BUILD: 15,
130
+ EDIT: 45,
131
+ DEBUG: 10,
132
+ MAINTAIN: 5000,
133
+ IDLE: 0,
134
+ }
135
+
136
+ /**
137
+ * qualityScore — actual yield relative to mode expectation.
138
+ * Fixes the "debug is bad" problem: a DEBUG session at 80% quality is good.
139
+ * Returns a number 0+ (can exceed 1.0 if outperforming the expected yield).
140
+ */
141
+ export function qualityScore(actualYield, mode) {
142
+ const expected = MODE_EXPECTED_YIELD[mode] ?? 1
143
+ if (expected === 0) return actualYield === 0 ? 1.0 : 0
144
+ return actualYield / expected
145
+ }
146
+
78
147
  /**
79
148
  * Extract the 4 pillars from pasted text: JSON object OR 4 whitespace numbers.
80
149
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "scripts": {
25
25
  "start": "node index.mjs",
26
- "test": "node test.mjs && node sign.test.mjs"
26
+ "test": "node test.mjs && node sign.test.mjs && node --test __tests__/cascade.test.mjs __tests__/badges.test.mjs"
27
27
  },
28
28
  "files": [
29
29
  "index.mjs",
@@ -38,6 +38,8 @@
38
38
  "keystore.mjs",
39
39
  "submit.mjs",
40
40
  "connect.mjs",
41
+ "badges.mjs",
42
+ "preflight.mjs",
41
43
  "banner.svg",
42
44
  "README.md",
43
45
  "LICENSE"
package/preflight.mjs ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * preflight.mjs — local plausibility pre-checks for the MCP submit path.
3
+ *
4
+ * Mirrors ONLY the server's public plausibility gate (gates.ts:86-137) —
5
+ * totals consistency, turns/sessions ratios, output rate, cache ratio,
6
+ * cadence. These are integrity guards labeled "NOT the proprietary RS.xx"
7
+ * and are safe to replicate in an open agent.
8
+ *
9
+ * The proprietary battery (Benford / cadence / contamination) is SERVER-ONLY
10
+ * and must NOT be shipped in the agent. The server always runs its own
11
+ * battery after the POST — this preflight is a client-side preview of the
12
+ * public plausibility checks only, not a replacement for the full gate chain.
13
+ *
14
+ * If preflight flags, the operator sees WHY their submission would be
15
+ * rejected or flagged, and can fix the issue or submit anyway.
16
+ *
17
+ * Source of truth: lib/ingest/gates.ts (plausibilityGate). This port must
18
+ * stay in sync with the plausibility gate ONLY.
19
+ */
20
+
21
+ // ── plausibility checks (from gates.ts:86-137) ──────────────────────────────
22
+
23
+ const GATE_LIMITS = {
24
+ TOTALS_TOLERANCE_FRAC: 0.01,
25
+ MAX_OUTPUT_TOKENS_PER_MIN: 20_000,
26
+ // Tightened range-plausibility bounds (deviewreview3)
27
+ MAX_CACHE_REUSE_RATIO: 35,
28
+ MIN_CACHE_WRITE_RATIO: 0.5,
29
+ MIN_INPUT_SHARE_FRAC: 0.0003,
30
+ MAX_CADENCE_PER_MIN: 15,
31
+ }
32
+
33
+ /**
34
+ * Run plausibility checks against a payload's raw_telemetry.
35
+ * Returns array of issues: { severity, code, detail }
36
+ */
37
+ export function plausibilityCheck(rt, window) {
38
+ const out = []
39
+ const pillars = rt.tokens_input_fresh + rt.tokens_output + rt.tokens_cache_read + rt.tokens_cache_creation
40
+
41
+ if (rt.tokens_total > 0) {
42
+ const tol = Math.max(1, rt.tokens_total * GATE_LIMITS.TOTALS_TOLERANCE_FRAC)
43
+ if (Math.abs(pillars - rt.tokens_total) > tol) {
44
+ out.push({ severity: 'reject', code: 'totals_inconsistent', detail: `Σ4 pillars (${pillars}) ≠ tokens_total (${rt.tokens_total})` })
45
+ }
46
+ if (rt.sessions_count === 0) {
47
+ out.push({ severity: 'reject', code: 'tokens_without_sessions', detail: `tokens_total ${rt.tokens_total} with sessions_count 0` })
48
+ }
49
+ }
50
+ if (rt.turns_total < rt.sessions_count) {
51
+ out.push({ severity: 'reject', code: 'turns_lt_sessions', detail: `turns_total ${rt.turns_total} < sessions_count ${rt.sessions_count}` })
52
+ }
53
+ if (rt.tokens_output > 0 && rt.turns_total === 0) {
54
+ out.push({ severity: 'reject', code: 'output_without_turns', detail: `tokens_output ${rt.tokens_output} with turns_total 0` })
55
+ }
56
+
57
+ if (window) {
58
+ const spanMin = (Date.parse(window.end) - Date.parse(window.start)) / 60_000
59
+ if (Number.isFinite(spanMin) && rt.active_minutes_est > spanMin + 1) {
60
+ out.push({ severity: 'flag', code: 'active_exceeds_window', detail: `active_minutes_est ${rt.active_minutes_est} > window span ${Math.round(spanMin)}m` })
61
+ }
62
+ }
63
+
64
+ const outPerMin = rt.tokens_output / Math.max(rt.active_minutes_est, 1)
65
+ if (outPerMin > GATE_LIMITS.MAX_OUTPUT_TOKENS_PER_MIN) {
66
+ out.push({ severity: 'flag', code: 'implausible_output_rate', detail: `${Math.round(outPerMin)} output tok/min > ${GATE_LIMITS.MAX_OUTPUT_TOKENS_PER_MIN}` })
67
+ }
68
+
69
+ // Cross-field ratio checks (defense-in-depth — also in the plausibility gate)
70
+ // Bounds tightened (deviewreview3): original 100:1 + 50/min were too loose.
71
+ if (rt.tokens_cache_read > 1_000 && rt.tokens_cache_creation === 0) {
72
+ out.push({ severity: 'flag', code: 'cache_without_creation', detail: `${rt.tokens_cache_read} cache_read with 0 cache_creation (impossible cascade)` })
73
+ }
74
+ if (rt.tokens_cache_creation > 0 && rt.tokens_cache_read / rt.tokens_cache_creation > GATE_LIMITS.MAX_CACHE_REUSE_RATIO) {
75
+ out.push({ severity: 'flag', code: 'extreme_cache_ratio', detail: `cache_read/cache_creation = ${(rt.tokens_cache_read / rt.tokens_cache_creation).toFixed(1)}:1 (real max ~30:1)` })
76
+ }
77
+ if (rt.tokens_output > 1_000 && rt.tokens_cache_creation / rt.tokens_output < GATE_LIMITS.MIN_CACHE_WRITE_RATIO) {
78
+ out.push({ severity: 'flag', code: 'low_cache_write_ratio', detail: `cache_creation/output = ${(rt.tokens_cache_creation / rt.tokens_output).toFixed(2)}:1 (real min ~1.5:1)` })
79
+ }
80
+ if (pillars > 10_000 && rt.tokens_input_fresh / pillars < GATE_LIMITS.MIN_INPUT_SHARE_FRAC) {
81
+ out.push({ severity: 'flag', code: 'implausible_input_share', detail: `input is ${(rt.tokens_input_fresh / pillars * 100).toFixed(3)}% of total (real min ~0.3%)` })
82
+ }
83
+ if (rt.active_minutes_est > 0 && rt.turns_total / rt.active_minutes_est > GATE_LIMITS.MAX_CADENCE_PER_MIN) {
84
+ out.push({ severity: 'flag', code: 'implausible_cadence', detail: `${(rt.turns_total / rt.active_minutes_est).toFixed(1)} turns/min (real: 0.5-10)` })
85
+ }
86
+
87
+ return out
88
+ }
89
+
90
+ // ── full preflight (plausibility only — NO battery) ─────────────────────────
91
+
92
+ /**
93
+ * Run preflight plausibility checks against a payload. Returns:
94
+ * { pass: true, issues: [] } — clean submission
95
+ * { pass: false, issues: [...], wouldDowngrade } — would be flagged/rejected
96
+ *
97
+ * NOTE: this only mirrors the PUBLIC plausibility gate. The server runs
98
+ * additional proprietary checks (the battery) that this preflight does NOT
99
+ * replicate. A "pass" here means "passes the plausibility gate," not
100
+ * "guaranteed to pass all server gates."
101
+ */
102
+ export function preflight(payload) {
103
+ const issues = plausibilityCheck(payload.raw_telemetry, payload.window)
104
+
105
+ const hasReject = issues.some((i) => i.severity === 'reject')
106
+ const hasFlag = issues.some((i) => i.severity === 'flag')
107
+
108
+ return {
109
+ pass: issues.length === 0,
110
+ wouldReject: hasReject,
111
+ wouldDowngrade: hasFlag,
112
+ issues,
113
+ summary: hasReject
114
+ ? `REJECT: ${issues.filter((i) => i.severity === 'reject').map((i) => i.code).join(', ')}`
115
+ : hasFlag
116
+ ? `FLAG (verified → flagged, not ranked): ${issues.filter((i) => i.severity === 'flag').map((i) => i.code).join(', ')}`
117
+ : 'clean — passes plausibility gate (server runs additional proprietary checks)',
118
+ }
119
+ }