sigrank 0.0.181 → 0.0.183

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
@@ -332,28 +332,29 @@ Verifier numbers (ccusage/tokscale for codex) show **raw uncached input** (`inpu
332
332
 
333
333
  ## Platform adapters
334
334
 
335
- All adapters are token-only (no message content, no cost fields, no credentials).
336
-
337
- | Platform | Path | Notes |
338
- | ------------------ | ---------------------------------------------- | --------------------------------------------------------------- |
339
- | Claude Code | ✅ `~/.claude/projects` | Native; dedup by `(session_id, message_id)`; subagents included |
340
- | Codex | ✅ `~/.codex/sessions` | Estimated input via `io_ratio`; verified vs ccusage |
341
- | Devin CLI | ✅ `~/.local/share/devin/cli/sessions.db` | Estimated input via `io_ratio`; SQLite; same split as Codex |
342
- | Amp | ✅ `~/.local/share/amp/threads` | Full 4-pillar; per-message |
343
- | Kimi | ✅ `~/.kimi/sessions` | Full 4-pillar; `StatusUpdate` lines only |
344
- | pi-agent | ✅ `~/.pi/agent/sessions` | Full 4-pillar; per-message JSONL |
345
- | OpenClaw | ✅ `~/.openclaw` | Full 4-pillar; per-message JSONL |
346
- | Droid | ✅ `~/.factory/sessions/*.settings.json` | Full 4-pillar; thinking→output |
347
- | Codebuff | ✅ `~/.config/manicode` | Full 4-pillar; `chat-messages.json` |
348
- | Hermes | ✅ `~/.hermes/state.db` | Full 4-pillar; SQLite; reasoning→output |
349
- | Kilo | ✅ `~/.local/share/kilo/kilo.db` | Full 4-pillar; SQLite |
350
- | Qwen | ✅ `~/.qwen/projects` | `cacheCreate=0` estimated; thought→output |
351
- | Goose | ✅ `~/.local/share/goose/sessions/sessions.db` | `cacheCreate=cacheRead=0` estimated; SQLite |
352
- | Gemini CLI | ✅ `~/.gemini/tmp` | `cacheCreate=0` estimated; cache extracted from input field |
353
- | GitHub Copilot CLI | ✅ `~/.copilot/otel` | OTel JSONL; requires `COPILOT_OTEL_ENABLED=true` |
354
- | OpenCode | ⚠️ `~/.local/share/opencode` | Raw token counts not persisted in log format |
355
- | Cursor | 🔜 | Chat log path TBD |
356
- | Windsurf | 🔜 | Session logs at `~/.codeium/windsurf/` |
335
+ All adapters are token-only (no message content, no cost fields, no credentials). The table below is regenerated from the adapter file headers in `adapters/index.mjs` + `adapters/tokenpull.mjs` so the path, pillar shape, and notes match the actual code (a prior revision had drifted on Qwen/Codex/Copilot).
336
+
337
+ | Platform | Path | Pillar shape & notes |
338
+ | ------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
339
+ | Claude Code | ✅ `~/.claude/projects` (recursive, incl. `subagents/`) | Native 4-pillar; dedup by `(session_id, message_id)` — final snapshot wins |
340
+ | Codex | ✅ `~/.codex/sessions` (+ `archived_sessions`) | `input_tokens` incl. cached → `input` + `cacheCreate` split window-level via `io_ratio`; `cacheRead` native (`cached_input_tokens`); `reasoning_output`→output; verified vs ccusage (~1%) |
341
+ | Devin CLI | ✅ `~/.local/share/devin/cli/sessions.db` | SQLite; same `io_ratio` split as Codex (Alpha 2.0 default); `cacheRead` native |
342
+ | Amp | ✅ `~/.local/share/amp/threads` | Native 4-pillar; per-message |
343
+ | Kimi | ✅ `~/.kimi/sessions` | Native 4-pillar; `StatusUpdate` lines only |
344
+ | pi-agent | ✅ `~/.pi/agent/sessions` | Native 4-pillar; per-message JSONL |
345
+ | OpenClaw | ✅ `~/.openclaw` | Native 4-pillar; per-message JSONL |
346
+ | Droid | ✅ `~/.factory/sessions/*.settings.json` | Native 4-pillar; `thinking_tokens`→output |
347
+ | Codebuff | ✅ `~/.config/manicode` | Native 4-pillar; `chat-messages.json` |
348
+ | Hermes | ✅ `~/.hermes/state.db` | Native 4-pillar; SQLite; `reasoning_tokens`→output |
349
+ | Kilo | ✅ `~/.local/share/kilo/kilo.db` | Native 4-pillar; SQLite |
350
+ | Qwen | ✅ `~/.qwen/projects` | Estimated (`cacheCreate=0` — no field in logs); `cacheRead` from `cachedContentTokenCount`; `thoughtsTokenCount`→output |
351
+ | Goose | ✅ `~/.local/share/goose/sessions/sessions.db` (or `$GOOSE_PATH_ROOT/data/sessions/sessions.db`) | Estimated (`cacheCreate=cacheRead=0` — no cache fields); reasoning = `total−input−output`→output; cumulative-column dedup by session id |
352
+ | Gemini CLI | ✅ `~/.gemini/tmp` | Estimated (`cacheCreate=0`); `cacheRead` from `cached` field; `thought`→output; `input` = `input−cached` (fresh) |
353
+ | GitHub Copilot CLI | ✅ `~/.copilot/otel` | Native 4-pillar (OTel spans: `llm.token_count.{prompt,completion,cache_creation,cache_read}`); requires `COPILOT_OTEL_ENABLED=true` + `COPILOT_OTEL_EXPORTER_TYPE=file` set **before** session start |
354
+ | OpenCode | ⚠️ `~/.local/share/opencode` | Data gap — logs store `cost:0` and derive tokens via LiteLLM at runtime; raw token counts not persisted. No pillars readable with current format |
355
+ | Other (user JSON) | ✅ `$SIGRANK_OTHER_PATH` | User-supplied JSON `{ "windows": { "all": {input,output,cacheCreate,cacheRead} } }`; all-time only (no timestamps) |
356
+ | Cursor | 🔜 | Chat log path TBD |
357
+ | Windsurf | 🔜 | Session logs at `~/.codeium/windsurf/` |
357
358
 
358
359
  `estimated=true` means one or more pillars are derived, not native. The server re-scores all submitted pillars authoritatively; local preview Υ is indicative only.
359
360
 
@@ -51,8 +51,11 @@ const DAY_MS = 86_400_000; // shared with tokenpull.mjs but kept local to avoid
51
51
 
52
52
  // ── File-system helpers ───────────────────────────────────────────────────────
53
53
 
54
- /** Recursively yield every file whose name matches `pred` under dir (skips symlink dirs). */
55
- async function* walkFiles(dir, pred, counter = { n: 0 }, max = 10_000) {
54
+ /** Recursively yield every file whose name matches `pred` under dir (skips
55
+ * symlink dirs, stops after `max` files). Exported so adapters/tokenpull.mjs
56
+ * can reuse it instead of maintaining a second near-identical walker
57
+ * (_walkJsonl) — the two had drifted on the symlink-skip + max-files guards. */
58
+ export async function* walkFiles(dir, pred, counter = { n: 0 }, max = 10_000) {
56
59
  if (counter.n >= max) return;
57
60
  let entries;
58
61
  try {
@@ -743,7 +746,9 @@ export const hermesAdapter = {
743
746
  // Same combined-input problem as Codex: input_tokens INCLUDES cache write, so we
744
747
  // yield { ts, output, cacheRead, uncached } and let tokenpullCodex() do the
745
748
  // ioRatio split (input = output × ioRatio, cacheCreate = uncached − input).
746
- // ioRatio comes from Claude (Beta) or the 7:1:2 average (Alpha = 0.5).
749
+ // ioRatio comes from Claude (Beta = operator's Claude input/output ratio) or
750
+ // the Alpha 2.0 default (matches Codex; the "7:1:2 average → 0.5" note in a
751
+ // prior revision was wrong — tokenpullAny defaults ioRatio to 2.0 for both).
747
752
  export const devinAdapter = {
748
753
  platform: "devin",
749
754
  defaultRoot: () => join(homedir(), ".local", "share", "devin", "cli"),
@@ -795,9 +800,22 @@ export const devinAdapter = {
795
800
  // }
796
801
  // }
797
802
  //
798
- // The adapter yields one "message" per window with the summed pillars. Since
799
- // there's no per-message timestamp, all data lands in the "all" window only
800
- // (same as null-timestamp records in other adapters).
803
+ // The adapter yields ONE message carrying the all-time pillars. The generic
804
+ // tokenpull aggregator buckets by timestamp, and these records carry no
805
+ // timestamp (ts: null → "all" window only, same as null-ts records in other
806
+ // adapters). Yielding one message PER window key — as a prior version did —
807
+ // caused the "all" total to be the SUM of every window entry (all + 30d + 7d
808
+ // …), i.e. a multi-count. This is the only user-facing wrong-number bug in
809
+ // ingestion: an operator who filled in all four windows saw an "all" Υ
810
+ // computed from 2–4× the real pillars.
811
+ //
812
+ // Fix: yield exactly one record. If the JSON has an "all" entry, use it
813
+ // verbatim; otherwise sum the provided windows into a synthetic all-time
814
+ // total. The 7d/30d/90d entries are accepted (so the file format stays
815
+ // forward-compatible) but are NOT yielded as separate records — the generic
816
+ // aggregator has no way to route a timestamp-less record into a bounded
817
+ // window, so emitting them would only re-introduce the overcount. An operator
818
+ // who wants windowed data must use a real adapter that carries timestamps.
801
819
  export const otherAdapter = {
802
820
  platform: "other",
803
821
  defaultRoot: () => process.env.SIGRANK_OTHER_PATH || "",
@@ -822,23 +840,38 @@ export const otherAdapter = {
822
840
  throw new Error(`other adapter: invalid JSON in ${filePath}`);
823
841
  }
824
842
  const windows = data.windows || {};
825
- for (const [win, pillars] of Object.entries(windows)) {
826
- const input = Number(pillars.input || 0);
827
- const output = Number(pillars.output || 0);
828
- const cacheCreate = Number(pillars.cacheCreate || pillars.cache_create || 0);
829
- const cacheRead = Number(pillars.cacheRead || pillars.cache_read || 0);
830
- if (input + output + cacheCreate + cacheRead === 0) continue;
831
- yield {
832
- id: `other:${win}`,
833
- sid: `other-${win}`,
834
- ts: null, // no timestamp → lands in "all" window only
835
- input,
836
- output,
837
- cacheCreate,
838
- cacheRead,
839
- file: filePath,
840
- };
843
+ const norm = (p) => ({
844
+ input: Number(p?.input || 0),
845
+ output: Number(p?.output || 0),
846
+ cacheCreate: Number(p?.cacheCreate || p?.cache_create || 0),
847
+ cacheRead: Number(p?.cacheRead || p?.cache_read || 0),
848
+ });
849
+ let all;
850
+ if (windows.all) {
851
+ // Explicit all-time entry → use verbatim (do NOT add 7d/30d/90d on top).
852
+ all = norm(windows.all);
853
+ } else {
854
+ // No "all" key → fold the provided windows into a synthetic all-time.
855
+ all = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
856
+ for (const pillars of Object.values(windows)) {
857
+ const n = norm(pillars);
858
+ all.input += n.input;
859
+ all.output += n.output;
860
+ all.cacheCreate += n.cacheCreate;
861
+ all.cacheRead += n.cacheRead;
862
+ }
841
863
  }
864
+ if (all.input + all.output + all.cacheCreate + all.cacheRead === 0) return;
865
+ yield {
866
+ id: `other:all`,
867
+ sid: `other-all`,
868
+ ts: null, // no timestamp → lands in "all" window only
869
+ input: all.input,
870
+ output: all.output,
871
+ cacheCreate: all.cacheCreate,
872
+ cacheRead: all.cacheRead,
873
+ file: filePath,
874
+ };
842
875
  },
843
876
  };
844
877
 
@@ -15,13 +15,13 @@
15
15
  * the same { platform, defaultRoot(), messages(root) } contract — Claude is just the first.
16
16
  */
17
17
 
18
- import { readdir, readFile, lstat } from "node:fs/promises";
18
+ import { readdir, readFile } from "node:fs/promises";
19
19
  import { existsSync } from "node:fs";
20
20
  import { execFile } from "node:child_process";
21
21
  import { join, dirname } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { fileURLToPath } from "node:url";
24
- import { ADAPTERS } from "./index.mjs";
24
+ import { ADAPTERS, walkFiles } from "./index.mjs";
25
25
 
26
26
  const DAY_MS = 86_400_000;
27
27
 
@@ -87,34 +87,26 @@ const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000;
87
87
  * under-counts input (sub-agent runs are input-heavy). token-dashboard's rglob.
88
88
  *
89
89
  * Hardened: skips symlinked directories (prevents circular traversal) and stops
90
- * after MAX_JSONL_FILES files (prevents OOM on pathological trees). */
90
+ * after MAX_JSONL_FILES files (prevents OOM on pathological trees).
91
+ *
92
+ * Pass 2: now a thin sorted wrapper over the shared walkFiles() from
93
+ * adapters/index.mjs so the two walkers can't drift on the symlink-skip +
94
+ * max-files guards again. The sort (entries within each directory) is preserved
95
+ * by sorting the yielded paths; walkFiles already recurses in readdir order. */
91
96
  async function* _walkJsonl(dir, _counter = { n: 0 }) {
92
- if (_counter.n >= MAX_JSONL_FILES) return;
93
- let entries;
94
- try {
95
- entries = await readdir(dir, { withFileTypes: true });
96
- } catch {
97
- return;
98
- }
99
- for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
100
- if (_counter.n >= MAX_JSONL_FILES) return;
101
- const full = join(dir, e.name);
102
- if (e.isDirectory()) {
103
- // Skip symlinked directories — prevents circular traversal on machines where
104
- // ~/.claude/projects/ contains a symlink to a large or self-referential tree.
105
- let stat;
106
- try {
107
- stat = await lstat(full);
108
- } catch {
109
- continue;
110
- }
111
- if (stat.isSymbolicLink()) continue;
112
- yield* _walkJsonl(full, _counter);
113
- } else if (e.isFile() && e.name.endsWith(".jsonl")) {
114
- _counter.n++;
115
- yield full;
116
- }
97
+ // walkFiles yields in readdir order; we collect + sort to keep the
98
+ // deterministic ordering the previous implementation guaranteed.
99
+ const paths = [];
100
+ for await (const p of walkFiles(
101
+ dir,
102
+ (n) => n.endsWith(".jsonl"),
103
+ _counter,
104
+ MAX_JSONL_FILES,
105
+ )) {
106
+ paths.push(p);
117
107
  }
108
+ paths.sort((a, b) => (a < b ? -1 : 1));
109
+ for (const p of paths) yield p;
118
110
  }
119
111
 
120
112
  /**
@@ -61,24 +61,129 @@ export function cascade({ input, output, cacheCreate, cacheRead }) {
61
61
  leverage: round(leverage, 1),
62
62
  velocity: round(velocity, 3),
63
63
  dev10x: round(dev10x, 2),
64
- class: classify(yield_, dev10x),
64
+ class: classify(total),
65
65
  mode: detectMode({ input: i, output: o, cacheCreate: cw, cacheRead: cr }),
66
66
  };
67
67
  if (warnings.length > 0) result.warnings = warnings;
68
68
  return result;
69
69
  }
70
70
 
71
- /** MVP class tiering from Υ + 10xDEV (canon assigns from cascade SNR + 10xDEV; this
72
- * is the open-MVP approximation — proprietary threshold cuts stay server-side). */
73
- export function classify(yieldVal, dev10x) {
74
- if (yieldVal >= 1000 || dev10x >= 3) return "TRANSMITTER";
75
- if (dev10x >= 1.45) return "ARCH+";
76
- if (dev10x >= 1.35) return "ARCH";
77
- if (dev10x >= 1.2) return "POWER";
78
- if (dev10x >= 1.0) return "BASE";
79
- if (dev10x >= 0) return "SEEKER";
80
- if (dev10x >= -0.3) return "REFINER";
81
- return "IGNITER";
71
+ /**
72
+ * CLASS_TIERS — the 8 base tier names (K.01–K.08) for display: glyph, color,
73
+ * meaning. SINGLE SOURCE OF TRUTH for tier-level display info.
74
+ *
75
+ * The permanent class is an EXPERIENCE ladder keyed on TOTAL TOKENS. Each tier
76
+ * has 3 sub-stages (I/II/III) — 24 stages total. The 24 thresholds live in
77
+ * RS05_CLASS_THRESHOLDS below. classify() returns the full sub-stage string
78
+ * (e.g. "REFINER II"). Use tierOf() to extract the base tier name.
79
+ *
80
+ * TRANSMITTER is NOT on this ladder — it is a temporary peak badge (RS.08).
81
+ * The client does not evaluate the badge (owner is still calibrating it);
82
+ * the server does it on read.
83
+ *
84
+ * Mirrors the server's canon-ids.ts CLASS_TIERS (display) + ruleset.ts
85
+ * RS05_CLASS_THRESHOLDS (thresholds). Order is descending (ARCH+ → IGNITER).
86
+ */
87
+ export const CLASS_TIERS = [
88
+ "ARCH+",
89
+ "ARCH",
90
+ "POWER",
91
+ "BASE",
92
+ "SEEKER",
93
+ "REFINER",
94
+ "BEARER",
95
+ "IGNITER",
96
+ ];
97
+
98
+ /**
99
+ * SIGNAL_CLASSES — the full 24 sub-stage names (8 tiers × 3 sub-stages I/II/III).
100
+ * Mirrors the server's SIGNAL_CLASSES set in lib/board/mappers.ts. The API
101
+ * returns class_tier as one of these 24 values. classify() returns one of these
102
+ * (or UNCLASSED). Use this for schema enums and validation.
103
+ */
104
+ export const SIGNAL_CLASSES = [
105
+ "ARCH+ I", "ARCH+ II", "ARCH+ III",
106
+ "ARCH I", "ARCH II", "ARCH III",
107
+ "POWER I", "POWER II", "POWER III",
108
+ "BASE I", "BASE II", "BASE III",
109
+ "SEEKER I", "SEEKER II", "SEEKER III",
110
+ "REFINER I", "REFINER II", "REFINER III",
111
+ "BEARER I", "BEARER II", "BEARER III",
112
+ "IGNITER I", "IGNITER II", "IGNITER III",
113
+ ];
114
+
115
+ /**
116
+ * RS05_CLASS_THRESHOLDS — 24 total-token breakpoints (8 tiers × 3 sub-stages).
117
+ * Mirrors the server's lib/analytics/ruleset.ts RS05_CLASS_THRESHOLDS exactly.
118
+ * Calibrated from the HCM cut (1,626 operators) using target distribution
119
+ * (Option C): IGNITER 10%, BEARER 12.5%, REFINER 15%, SEEKER 22.5%, BASE 20%,
120
+ * POWER 15%, ARCH 5%, ARCH+ 0% (aspirational). Each tier split into 3
121
+ * equal-population sub-stages. Update this one constant to stay in parity
122
+ * when the owner tweaks server-side.
123
+ */
124
+ export const RS05_CLASS_THRESHOLDS = [
125
+ { class: "ARCH+ I", totalMin: 7068201104627 },
126
+ { class: "ARCH+ II", totalMin: 7068201104627 },
127
+ { class: "ARCH+ III", totalMin: 7068201104627 },
128
+ { class: "ARCH I", totalMin: 186207267611 },
129
+ { class: "ARCH II", totalMin: 98543134083 },
130
+ { class: "ARCH III", totalMin: 68766193943 },
131
+ { class: "POWER I", totalMin: 39958782379 },
132
+ { class: "POWER II", totalMin: 26955905621 },
133
+ { class: "POWER III", totalMin: 19141226889 },
134
+ { class: "BASE I", totalMin: 13960345961 },
135
+ { class: "BASE II", totalMin: 10189224970 },
136
+ { class: "BASE III", totalMin: 7747041813 },
137
+ { class: "SEEKER I", totalMin: 5446673659 },
138
+ { class: "SEEKER II", totalMin: 4014577247 },
139
+ { class: "SEEKER III", totalMin: 2961798768 },
140
+ { class: "REFINER I", totalMin: 2358346840 },
141
+ { class: "REFINER II", totalMin: 1845750357 },
142
+ { class: "REFINER III", totalMin: 1334876308 },
143
+ { class: "BEARER I", totalMin: 984078167 },
144
+ { class: "BEARER II", totalMin: 714619043 },
145
+ { class: "BEARER III", totalMin: 431702990 },
146
+ { class: "IGNITER I", totalMin: 216393332 },
147
+ { class: "IGNITER II", totalMin: 88999166 },
148
+ { class: "IGNITER III", totalMin: 0 },
149
+ ];
150
+
151
+ /** The degenerate-case class returned when totalTokens is null/undefined
152
+ * (all-zero / empty session). Distinct from IGNITER III (which is a real
153
+ * low-experience tier) so callers can tell "no data" from "bottom tier". */
154
+ export const UNCLASSED = "UNCLASSED";
155
+
156
+ /** Extract the base tier name from a sub-stage string (e.g. "ARCH+ I" → "ARCH+").
157
+ * Mirrors the server's tierOf() in components/sigrank/types.ts. Returns the
158
+ * input as-is if it's not a sub-stage string (e.g. UNCLASSED). */
159
+ export function tierOf(cls) {
160
+ if (cls === UNCLASSED || cls == null) return cls;
161
+ const parts = String(cls).split(" ");
162
+ if (parts.length >= 2 && ["I", "II", "III"].includes(parts[parts.length - 1])) {
163
+ return parts.slice(0, -1).join(" ");
164
+ }
165
+ return cls;
166
+ }
167
+
168
+ /** Extract the sub-stage from a sub-stage string (e.g. "ARCH+ I" → "I").
169
+ * Mirrors the server's stageOf(). Returns null for non-sub-stage strings. */
170
+ export function stageOf(cls) {
171
+ if (cls === UNCLASSED || cls == null) return null;
172
+ const parts = String(cls).split(" ");
173
+ const stage = parts[parts.length - 1];
174
+ return stage === "I" || stage === "II" || stage === "III" ? stage : null;
175
+ }
176
+
177
+ /** Classify an operator's experience stage from total tokens (input + output +
178
+ * cacheCreate + cacheRead). Descending first-match-wins scan over
179
+ * RS05_CLASS_THRESHOLDS — mirrors the server's assignClass(totalTokens).
180
+ * Returns a full sub-stage string (e.g. "REFINER II") or UNCLASSED. */
181
+ export function classify(totalTokens) {
182
+ if (totalTokens == null || !Number.isFinite(totalTokens)) return UNCLASSED;
183
+ for (const t of RS05_CLASS_THRESHOLDS) {
184
+ if (totalTokens >= t.totalMin) return t.class;
185
+ }
186
+ return "IGNITER III";
82
187
  }
83
188
 
84
189
  /**
@@ -23,6 +23,7 @@ import {
23
23
  copyFileSync,
24
24
  } from "node:fs";
25
25
  import { generateKeyPairSync, randomUUID } from "node:crypto";
26
+ import { agentVersion } from "../lib/pkg-version.mjs";
26
27
 
27
28
  const DIR = join(homedir(), ".sigrank-mcp");
28
29
  const PATH = join(DIR, "identity.json");
@@ -32,18 +33,6 @@ const MAX_BACKUPS = 5;
32
33
  /** SPKI DER prefix length for an ed25519 public key (12 bytes before the raw 32). */
33
34
  const SPKI_PREFIX_LEN = 12;
34
35
 
35
- /** Resolve this package's version for the agent_version stamp (best-effort). */
36
- function agentVersion() {
37
- try {
38
- const pkg = JSON.parse(
39
- readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
40
- );
41
- return `sigrank-mcp/${pkg.version}`;
42
- } catch {
43
- return "sigrank-mcp";
44
- }
45
- }
46
-
47
36
  export function keystorePath() {
48
37
  return PATH;
49
38
  }
@@ -0,0 +1,111 @@
1
+ // lib/constants.mjs — repo-wide constants that were previously inlined as
2
+ // literals across multiple files. Centralizing them here means a version bump
3
+ // or a client-map update lands in exactly one place.
4
+ //
5
+ // TERMS_VERSION / PRIVACY_VERSION — embedded in every enroll + submit
6
+ // payload (tools/enroll.mjs, tools/submit-paste.mjs,
7
+ // tools/tokenpull-submit.mjs, submit/index.mjs). They were inlined as
8
+ // "2026-07-21" in four files; a terms rev would have required finding
9
+ // and editing all four. Now: one export, imported everywhere.
10
+ //
11
+ // TOKSCALE_CLIENT_MAP — maps tokscale's raw client names to our platform
12
+ // IDs. Was duplicated verbatim in tools/index.mjs and
13
+ // tools/tokscale-breakdown.mjs (and again in tokscale_analytics.mjs);
14
+ // the three copies had already started to drift. One export here.
15
+ //
16
+ // isRankedAck — the "actually on the board" predicate (verified + persisted).
17
+ // Was inlined as `ack.verification_tier === "verified" && ack.persisted
18
+ // === true` in submit-paste, tokenpull-submit, and submit/index.mjs, with
19
+ // tokenpull-submit's multi-platform branch using a DIFFERENT looser
20
+ // predicate (`ack?.ranked ?? ack?.accepted ?? false`). One predicate here
21
+ // closes that loophole so every submit path agrees on what "ranked" means.
22
+
23
+ /** Current Terms of Service version the client attests to. */
24
+ export const TERMS_VERSION = "2026-07-21";
25
+
26
+ /** Current Privacy Policy version the client attests to. */
27
+ export const PRIVACY_VERSION = "2026-07-21";
28
+
29
+ /**
30
+ * The leaderboard sort metric the client requests from the server. Every
31
+ * leaderboard fetch site (tools/get-leaderboard.mjs, presentation/cli.mjs
32
+ * fetchBoard, presentation/tui.mjs, compare-self / optimize-efficiency /
33
+ * get-best-operator / compare-operators) MUST use this value — the server
34
+ * ranks by `yield_` and a different metric string would either 400 or return
35
+ * an unsorted board. Centralized here so a future metric rename lands in one
36
+ * place; the contract test in __tests__/contract/leaderboard-metric-contract.test.mjs
37
+ * guards against intra-repo drift and (in CI) cross-repo drift vs sigrank-app.
38
+ */
39
+ export const LEADERBOARD_METRIC = "yield_";
40
+
41
+ /**
42
+ * Map a raw tokscale `client` string to our canonical platform ID.
43
+ * Values mapped to `null` are skipped (synthetic / non-attributable).
44
+ * Values mapped to `"other"` have no native adapter and bucket together.
45
+ */
46
+ export const TOKSCALE_CLIENT_MAP = {
47
+ claude: "claude",
48
+ codex: "codex",
49
+ "devin-cli": "devin",
50
+ "devin-desktop": "devin",
51
+ gemini: "gemini",
52
+ amp: "amp",
53
+ kimi: "kimi",
54
+ qwen: "qwen",
55
+ goose: "goose",
56
+ kilo: "kilo",
57
+ kilocode: "kilo",
58
+ hermes: "hermes",
59
+ droid: "droid",
60
+ codebuff: "codebuff",
61
+ copilot: "copilot",
62
+ opencode: "opencode",
63
+ openclaw: "openclaw",
64
+ pi: "pi",
65
+ cursor: "other",
66
+ roocode: "other",
67
+ mux: "other",
68
+ crush: "other",
69
+ antigravity: "other",
70
+ "antigravity-cli": "other",
71
+ zed: "other",
72
+ kiro: "other",
73
+ trae: "other",
74
+ warp: "other",
75
+ cline: "other",
76
+ gjc: "other",
77
+ grok: "other",
78
+ jcode: "other",
79
+ commandcode: "other",
80
+ micode: "other",
81
+ junie: "other",
82
+ zcode: "other",
83
+ opencodereview: "other",
84
+ codebuddy: "other",
85
+ workbuddy: "other",
86
+ synthetic: null,
87
+ };
88
+
89
+ /**
90
+ * The "actually on the board" predicate. A submission is ranked only when the
91
+ * server returns HTTP ok AND verification_tier === "verified" AND persisted
92
+ * === true. An unenrolled/revoked device gets 202 but is NEVER ranked.
93
+ *
94
+ * Use this everywhere a submit path decides whether to surface `ranked: true`.
95
+ * The previous tokenpull-submit multi-platform branch used a looser
96
+ * `ack?.ranked ?? ack?.accepted ?? false` which would mark a non-persisted
97
+ * 202 as ranked — this predicate closes that loophole.
98
+ *
99
+ * @param {{ ok: boolean }} res the fetch Response (or { ok } shim)
100
+ * @param {Record<string, unknown>} ack the parsed JSON ack body
101
+ * @returns {boolean}
102
+ */
103
+ export function isRankedAck(res, ack) {
104
+ return !!(
105
+ res &&
106
+ res.ok &&
107
+ ack &&
108
+ ack.verification_tier === "verified" &&
109
+ ack.persisted === true
110
+ );
111
+ }
@@ -0,0 +1,59 @@
1
+ // lib/pkg-version.mjs — single source of truth for the package version.
2
+ //
3
+ // Resolves the repo-root package.json by walking up from this module until it
4
+ // finds the package named "sigrank". This is robust to callers in any
5
+ // subdirectory (identity/, submit/, presentation/, tools/, scripts/) — the
6
+ // previous per-caller `new URL("./package.json", import.meta.url)` lookups
7
+ // resolved relative to the CALLER's own directory and hit non-existent files,
8
+ // falling back to a bare string. The agent_version stamp rides inside the
9
+ // SIGNED submit payload (agent.agent_version), so a wrong/missing version
10
+ // there is a high-priority bug — it survived because no test guarded it.
11
+ //
12
+ // One implementation, imported everywhere. Never throws: returns "unknown" on
13
+ // any failure so a missing/renamed package.json never breaks a submit.
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ /** The published package name (package.json "name"). Used to identify the
20
+ * repo-root package.json unambiguously past any nested package.json. */
21
+ const REPO_PKG_NAME = "sigrank";
22
+
23
+ /** Resolve the repo-root package version (best-effort).
24
+ * Returns the version string (e.g. "0.0.181") or "unknown" on any failure. */
25
+ export function pkgVersion() {
26
+ try {
27
+ let dir = dirname(fileURLToPath(import.meta.url));
28
+ for (let i = 0; i < 20; i++) {
29
+ const pkgPath = join(dir, "package.json");
30
+ let pkg;
31
+ try {
32
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
33
+ } catch {
34
+ // no package.json here — walk up
35
+ const parent = dirname(dir);
36
+ if (parent === dir) break;
37
+ dir = parent;
38
+ continue;
39
+ }
40
+ if (pkg.name === REPO_PKG_NAME) return pkg.version || "unknown";
41
+ // Found a package.json but it isn't ours (e.g. a nested dep) — keep walking.
42
+ const parent = dirname(dir);
43
+ if (parent === dir) {
44
+ // Reached the filesystem root without matching; use whatever we found.
45
+ return pkg.version || "unknown";
46
+ }
47
+ dir = parent;
48
+ }
49
+ return "unknown";
50
+ } catch {
51
+ return "unknown";
52
+ }
53
+ }
54
+
55
+ /** The full agent-version stamp, e.g. "sigrank-mcp/0.0.181".
56
+ * This is the value embedded in the signed payload's agent.agent_version. */
57
+ export function agentVersion() {
58
+ return `sigrank-mcp/${pkgVersion()}`;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.0.181",
3
+ "version": "0.0.183",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "index.mjs",
35
+ "lib/",
35
36
  "tools/",
36
37
  "analytics/",
37
38
  "identity/",