sigrank 0.16.0 → 0.16.1

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/preflight.mjs CHANGED
@@ -28,63 +28,129 @@ const GATE_LIMITS = {
28
28
  MIN_CACHE_WRITE_RATIO: 0.5,
29
29
  MIN_INPUT_SHARE_FRAC: 0.0003,
30
30
  MAX_CADENCE_PER_MIN: 15,
31
- }
31
+ };
32
32
 
33
33
  /**
34
34
  * Run plausibility checks against a payload's raw_telemetry.
35
35
  * Returns array of issues: { severity, code, detail }
36
36
  */
37
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
38
+ const out = [];
39
+ const pillars =
40
+ rt.tokens_input_fresh +
41
+ rt.tokens_output +
42
+ rt.tokens_cache_read +
43
+ rt.tokens_cache_creation;
40
44
 
41
45
  if (rt.tokens_total > 0) {
42
- const tol = Math.max(1, rt.tokens_total * GATE_LIMITS.TOTALS_TOLERANCE_FRAC)
46
+ const tol = Math.max(
47
+ 1,
48
+ rt.tokens_total * GATE_LIMITS.TOTALS_TOLERANCE_FRAC,
49
+ );
43
50
  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})` })
51
+ out.push({
52
+ severity: "reject",
53
+ code: "totals_inconsistent",
54
+ detail: `Σ4 pillars (${pillars}) ≠ tokens_total (${rt.tokens_total})`,
55
+ });
45
56
  }
46
57
  if (rt.sessions_count === 0) {
47
- out.push({ severity: 'reject', code: 'tokens_without_sessions', detail: `tokens_total ${rt.tokens_total} with sessions_count 0` })
58
+ out.push({
59
+ severity: "reject",
60
+ code: "tokens_without_sessions",
61
+ detail: `tokens_total ${rt.tokens_total} with sessions_count 0`,
62
+ });
48
63
  }
49
64
  }
50
65
  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}` })
66
+ out.push({
67
+ severity: "reject",
68
+ code: "turns_lt_sessions",
69
+ detail: `turns_total ${rt.turns_total} < sessions_count ${rt.sessions_count}`,
70
+ });
52
71
  }
53
72
  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` })
73
+ out.push({
74
+ severity: "reject",
75
+ code: "output_without_turns",
76
+ detail: `tokens_output ${rt.tokens_output} with turns_total 0`,
77
+ });
55
78
  }
56
79
 
57
80
  if (window) {
58
- const spanMin = (Date.parse(window.end) - Date.parse(window.start)) / 60_000
81
+ const spanMin =
82
+ (Date.parse(window.end) - Date.parse(window.start)) / 60_000;
59
83
  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` })
84
+ out.push({
85
+ severity: "flag",
86
+ code: "active_exceeds_window",
87
+ detail: `active_minutes_est ${rt.active_minutes_est} > window span ${Math.round(spanMin)}m`,
88
+ });
61
89
  }
62
90
  }
63
91
 
64
- const outPerMin = rt.tokens_output / Math.max(rt.active_minutes_est, 1)
92
+ const outPerMin = rt.tokens_output / Math.max(rt.active_minutes_est, 1);
65
93
  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}` })
94
+ out.push({
95
+ severity: "flag",
96
+ code: "implausible_output_rate",
97
+ detail: `${Math.round(outPerMin)} output tok/min > ${GATE_LIMITS.MAX_OUTPUT_TOKENS_PER_MIN}`,
98
+ });
67
99
  }
68
100
 
69
101
  // Cross-field ratio checks (defense-in-depth — also in the plausibility gate)
70
102
  // Bounds tightened (deviewreview3): original 100:1 + 50/min were too loose.
71
103
  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)` })
104
+ out.push({
105
+ severity: "flag",
106
+ code: "cache_without_creation",
107
+ detail: `${rt.tokens_cache_read} cache_read with 0 cache_creation (impossible cascade)`,
108
+ });
73
109
  }
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)` })
110
+ if (
111
+ rt.tokens_cache_creation > 0 &&
112
+ rt.tokens_cache_read / rt.tokens_cache_creation >
113
+ GATE_LIMITS.MAX_CACHE_REUSE_RATIO
114
+ ) {
115
+ out.push({
116
+ severity: "flag",
117
+ code: "extreme_cache_ratio",
118
+ detail: `cache_read/cache_creation = ${(rt.tokens_cache_read / rt.tokens_cache_creation).toFixed(1)}:1 (real max ~30:1)`,
119
+ });
76
120
  }
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)` })
121
+ if (
122
+ rt.tokens_output > 1_000 &&
123
+ rt.tokens_cache_creation / rt.tokens_output <
124
+ GATE_LIMITS.MIN_CACHE_WRITE_RATIO
125
+ ) {
126
+ out.push({
127
+ severity: "flag",
128
+ code: "low_cache_write_ratio",
129
+ detail: `cache_creation/output = ${(rt.tokens_cache_creation / rt.tokens_output).toFixed(2)}:1 (real min ~1.5:1)`,
130
+ });
79
131
  }
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%)` })
132
+ if (
133
+ pillars > 10_000 &&
134
+ rt.tokens_input_fresh / pillars < GATE_LIMITS.MIN_INPUT_SHARE_FRAC
135
+ ) {
136
+ out.push({
137
+ severity: "flag",
138
+ code: "implausible_input_share",
139
+ detail: `input is ${((rt.tokens_input_fresh / pillars) * 100).toFixed(3)}% of total (real min ~0.3%)`,
140
+ });
82
141
  }
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)` })
142
+ if (
143
+ rt.active_minutes_est > 0 &&
144
+ rt.turns_total / rt.active_minutes_est > GATE_LIMITS.MAX_CADENCE_PER_MIN
145
+ ) {
146
+ out.push({
147
+ severity: "flag",
148
+ code: "implausible_cadence",
149
+ detail: `${(rt.turns_total / rt.active_minutes_est).toFixed(1)} turns/min (real: 0.5-10)`,
150
+ });
85
151
  }
86
152
 
87
- return out
153
+ return out;
88
154
  }
89
155
 
90
156
  // ── full preflight (plausibility only — NO battery) ─────────────────────────
@@ -100,10 +166,10 @@ export function plausibilityCheck(rt, window) {
100
166
  * "guaranteed to pass all server gates."
101
167
  */
102
168
  export function preflight(payload) {
103
- const issues = plausibilityCheck(payload.raw_telemetry, payload.window)
169
+ const issues = plausibilityCheck(payload.raw_telemetry, payload.window);
104
170
 
105
- const hasReject = issues.some((i) => i.severity === 'reject')
106
- const hasFlag = issues.some((i) => i.severity === 'flag')
171
+ const hasReject = issues.some((i) => i.severity === "reject");
172
+ const hasFlag = issues.some((i) => i.severity === "flag");
107
173
 
108
174
  return {
109
175
  pass: issues.length === 0,
@@ -111,9 +177,15 @@ export function preflight(payload) {
111
177
  wouldDowngrade: hasFlag,
112
178
  issues,
113
179
  summary: hasReject
114
- ? `REJECT: ${issues.filter((i) => i.severity === 'reject').map((i) => i.code).join(', ')}`
180
+ ? `REJECT: ${issues
181
+ .filter((i) => i.severity === "reject")
182
+ .map((i) => i.code)
183
+ .join(", ")}`
115
184
  : 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
- }
185
+ ? `FLAG (verified → flagged, not ranked): ${issues
186
+ .filter((i) => i.severity === "flag")
187
+ .map((i) => i.code)
188
+ .join(", ")}`
189
+ : "clean — passes plausibility gate (server runs additional proprietary checks)",
190
+ };
119
191
  }
package/sign.mjs CHANGED
@@ -12,54 +12,64 @@
12
12
  // The canon-parity fixture (tests/fixtures/canon_parity.json) + sign.test.mjs + the
13
13
  // sigrank-app canon_parity gate together lock this byte-for-byte.
14
14
 
15
- import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto'
15
+ import {
16
+ createHash,
17
+ createPrivateKey,
18
+ createPublicKey,
19
+ sign as edSign,
20
+ verify as edVerify,
21
+ } from "node:crypto";
16
22
 
17
23
  /** Agent fields excluded from the canonical (signed/hashed) body — they derive from it. */
18
- const DERIVED_AGENT_FIELDS = ['signature', 'snapshot_hash']
24
+ const DERIVED_AGENT_FIELDS = ["signature", "snapshot_hash"];
19
25
 
20
26
  /** Recursively sort object keys (matches the server's Object.keys(...).sort()); arrays keep order. */
21
27
  function sortDeep(v) {
22
- if (Array.isArray(v)) return v.map(sortDeep)
23
- if (v && typeof v === 'object') {
24
- const out = {}
25
- for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k])
26
- return out
28
+ if (Array.isArray(v)) return v.map(sortDeep);
29
+ if (v && typeof v === "object") {
30
+ const out = {};
31
+ for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
32
+ return out;
27
33
  }
28
- return v
34
+ return v;
29
35
  }
30
36
 
31
37
  /** Canonical JSON string the agent signs/hashes (derived agent fields stripped). */
32
38
  export function canonicalJson(payload) {
33
- const clone = JSON.parse(JSON.stringify(payload))
34
- const agent = clone.agent
35
- if (agent && typeof agent === 'object') {
36
- for (const f of DERIVED_AGENT_FIELDS) delete agent[f]
39
+ const clone = JSON.parse(JSON.stringify(payload));
40
+ const agent = clone.agent;
41
+ if (agent && typeof agent === "object") {
42
+ for (const f of DERIVED_AGENT_FIELDS) delete agent[f];
37
43
  }
38
- return JSON.stringify(sortDeep(clone))
44
+ return JSON.stringify(sortDeep(clone));
39
45
  }
40
46
 
41
47
  /** UTF-8 bytes of the canonical JSON — the exact bytes that get signed. */
42
48
  export function canonicalBytes(payload) {
43
- return Buffer.from(canonicalJson(payload), 'utf-8')
49
+ return Buffer.from(canonicalJson(payload), "utf-8");
44
50
  }
45
51
 
46
52
  /** "sha256:<hex>" over the canonical bytes (matches the server snapshotHash). */
47
53
  export function snapshotHash(payload) {
48
- return `sha256:${createHash('sha256').update(canonicalBytes(payload)).digest('hex')}`
54
+ return `sha256:${createHash("sha256").update(canonicalBytes(payload)).digest("hex")}`;
49
55
  }
50
56
 
51
57
  /** SPKI DER prefix for a raw 32-byte ed25519 public key. */
52
- const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
58
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
53
59
 
54
60
  /** Build a node KeyObject from an "ed25519:<base64>" (or bare base64) public key, or null. */
55
61
  function publicKeyFrom(pk) {
56
62
  try {
57
- const body = pk.startsWith('ed25519:') ? pk.slice('ed25519:'.length) : pk
58
- const raw = Buffer.from(body, 'base64')
59
- if (raw.length !== 32) return null
60
- return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' })
63
+ const body = pk.startsWith("ed25519:") ? pk.slice("ed25519:".length) : pk;
64
+ const raw = Buffer.from(body, "base64");
65
+ if (raw.length !== 32) return null;
66
+ return createPublicKey({
67
+ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
68
+ format: "der",
69
+ type: "spki",
70
+ });
61
71
  } catch {
62
- return null
72
+ return null;
63
73
  }
64
74
  }
65
75
 
@@ -68,8 +78,12 @@ function publicKeyFrom(pk) {
68
78
  * The returned string is the X-Agent-Signature header value.
69
79
  */
70
80
  export function signPayload(payload, privateKeyPkcs8B64) {
71
- const key = createPrivateKey({ key: Buffer.from(privateKeyPkcs8B64, 'base64'), format: 'der', type: 'pkcs8' })
72
- return edSign(null, canonicalBytes(payload), key).toString('base64')
81
+ const key = createPrivateKey({
82
+ key: Buffer.from(privateKeyPkcs8B64, "base64"),
83
+ format: "der",
84
+ type: "pkcs8",
85
+ });
86
+ return edSign(null, canonicalBytes(payload), key).toString("base64");
73
87
  }
74
88
 
75
89
  /**
@@ -77,11 +91,16 @@ export function signPayload(payload, privateKeyPkcs8B64) {
77
91
  * and as a pre-send sanity check. Returns false on any malformation, never throws.
78
92
  */
79
93
  export function verifyPayload(payload, signatureB64, publicKey) {
80
- const key = publicKeyFrom(publicKey)
81
- if (!key) return false
94
+ const key = publicKeyFrom(publicKey);
95
+ if (!key) return false;
82
96
  try {
83
- return edVerify(null, canonicalBytes(payload), key, Buffer.from(signatureB64, 'base64'))
97
+ return edVerify(
98
+ null,
99
+ canonicalBytes(payload),
100
+ key,
101
+ Buffer.from(signatureB64, "base64"),
102
+ );
84
103
  } catch {
85
- return false
104
+ return false;
86
105
  }
87
106
  }
package/submit.mjs CHANGED
@@ -9,55 +9,78 @@
9
9
  // TODO(tokenpull): emit real sessions_count / turns_total / active_minutes per window so
10
10
  // the display signa_rate reflects true session shape (the ranked Υ is already exact).
11
11
 
12
- import { snapshotHash, signPayload } from './sign.mjs'
13
- import { preflight } from './preflight.mjs'
12
+ import { snapshotHash, signPayload } from "./sign.mjs";
13
+ import { preflight } from "./preflight.mjs";
14
14
 
15
- const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
16
- const WINDOW_SPAN_DAYS = { '7d': 7, '30d': 30, '90d': 90, all_time: 3650 }
17
- const PLATFORM_ENUM = new Set(['claude', 'chatgpt', 'gemini', 'pi', 'codex', 'multi', 'other'])
15
+ const WINDOW_TYPE = { "7d": "7d", "30d": "30d", "90d": "90d", all: "all_time" };
16
+ const WINDOW_SPAN_DAYS = { "7d": 7, "30d": 30, "90d": 90, all_time: 3650 };
17
+ const PLATFORM_ENUM = new Set([
18
+ "claude",
19
+ "chatgpt",
20
+ "gemini",
21
+ "pi",
22
+ "codex",
23
+ "multi",
24
+ "other",
25
+ ]);
18
26
  // Provenance tag only — the board ranks by Υ (no RS.xx weights, §0.1).
19
- const RULESET_VERSION = 'sigrank-token-1'
20
- const DAY_MS = 86_400_000
27
+ const RULESET_VERSION = "sigrank-token-1";
28
+ const DAY_MS = 86_400_000;
21
29
 
22
- const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x))
30
+ const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
23
31
  const round = (n, dp) => {
24
- const f = 10 ** dp
25
- return Math.round(n * f) / f
26
- }
32
+ const f = 10 ** dp;
33
+ return Math.round(n * f) / f;
34
+ };
27
35
 
28
36
  /** Map any tokenpull platform to the Schema 1.0 platform.primary enum (others → 'other'). */
29
37
  export function toPlatformPrimary(platform) {
30
- return PLATFORM_ENUM.has(platform) ? platform : 'other'
38
+ return PLATFORM_ENUM.has(platform) ? platform : "other";
31
39
  }
32
40
 
33
41
  /**
34
42
  * buildPayload — Schema 1.0 payload from a tokenpull window + enrolled identity.
35
43
  * Pure (given opts.now); agent.snapshot_hash is computed last (canonical strips it).
36
44
  */
37
- export function buildPayload(windowKey, pillars, messages, identity, platform, opts = {}) {
38
- const windowType = WINDOW_TYPE[windowKey] || windowKey
39
- const spanDays = WINDOW_SPAN_DAYS[windowType] || 30
40
- const nowMs = opts.now ?? Date.now()
45
+ export function buildPayload(
46
+ windowKey,
47
+ pillars,
48
+ messages,
49
+ identity,
50
+ platform,
51
+ opts = {},
52
+ ) {
53
+ const windowType = WINDOW_TYPE[windowKey] || windowKey;
54
+ const spanDays = WINDOW_SPAN_DAYS[windowType] || 30;
55
+ const nowMs = opts.now ?? Date.now();
41
56
 
42
- const input = Math.max(0, Math.round(pillars?.input || 0))
43
- const output = Math.max(0, Math.round(pillars?.output || 0))
44
- const cacheCreate = Math.max(0, Math.round(pillars?.cacheCreate || 0))
45
- const cacheRead = Math.max(0, Math.round(pillars?.cacheRead || 0))
46
- const tokensTotal = input + output + cacheCreate + cacheRead
57
+ const input = Math.max(0, Math.round(pillars?.input || 0));
58
+ const output = Math.max(0, Math.round(pillars?.output || 0));
59
+ const cacheCreate = Math.max(0, Math.round(pillars?.cacheCreate || 0));
60
+ const cacheRead = Math.max(0, Math.round(pillars?.cacheRead || 0));
61
+ const tokensTotal = input + output + cacheCreate + cacheRead;
47
62
 
48
63
  // Proxies — plausibility-clean (see file header).
49
- const msgs = Math.max(0, Math.round(messages || 0))
50
- const turnsTotal = Math.max(output > 0 ? 1 : 0, msgs)
51
- const sessionsCount = tokensTotal > 0 ? clamp(Math.round(msgs / 8) || 1, 1, Math.max(1, turnsTotal)) : 0
52
- const spanMinutes = spanDays * 1440
53
- const minActive = Math.ceil(output / 19000) + 1 // keep output/active under the 20k/min flag
54
- const activeMinutes = clamp(Math.max(msgs, minActive), 1, Math.max(1, spanMinutes - 1))
64
+ const msgs = Math.max(0, Math.round(messages || 0));
65
+ const turnsTotal = Math.max(output > 0 ? 1 : 0, msgs);
66
+ const sessionsCount =
67
+ tokensTotal > 0
68
+ ? clamp(Math.round(msgs / 8) || 1, 1, Math.max(1, turnsTotal))
69
+ : 0;
70
+ const spanMinutes = spanDays * 1440;
71
+ const minActive = Math.ceil(output / 19000) + 1; // keep output/active under the 20k/min flag
72
+ const activeMinutes = clamp(
73
+ Math.max(msgs, minActive),
74
+ 1,
75
+ Math.max(1, spanMinutes - 1),
76
+ );
55
77
 
56
- const compressionRatio = input + output > 0 ? clamp(output / (input + output), 0, 1) : 0
57
- const sessionDepth = sessionsCount > 0 ? turnsTotal / sessionsCount : 0
78
+ const compressionRatio =
79
+ input + output > 0 ? clamp(output / (input + output), 0, 1) : 0;
80
+ const sessionDepth = sessionsCount > 0 ? turnsTotal / sessionsCount : 0;
58
81
 
59
82
  const payload = {
60
- schema_version: '1.0',
83
+ schema_version: "1.0",
61
84
  codename: identity.codename,
62
85
  device_id: identity.device_id,
63
86
  submitted_at: new Date(nowMs).toISOString(),
@@ -70,7 +93,11 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
70
93
  core_metrics: {
71
94
  compression_ratio: round(compressionRatio, 4),
72
95
  prompt_complexity: round(clamp(sessionDepth, 0, 100), 2),
73
- cross_thread_score: clamp(Math.round(cacheRead / Math.max(cacheCreate, 1)), 0, 100000),
96
+ cross_thread_score: clamp(
97
+ Math.round(cacheRead / Math.max(cacheCreate, 1)),
98
+ 0,
99
+ 100000,
100
+ ),
74
101
  session_depth_avg: round(Math.max(0, sessionDepth), 2),
75
102
  // M.05 token_throughput muted 2026-06-26 — word-era metric the web already
76
103
  // mutes (RS01 tt→0) + strips. Send null (RPC p_token_throughput is nullable
@@ -80,7 +107,10 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
80
107
  background_metrics: {
81
108
  message_volume: msgs,
82
109
  account_age_days: Math.max(1, Math.round(opts.accountAgeDays ?? 365)),
83
- total_messages_lifetime: Math.max(msgs, Math.round(opts.totalMessages ?? msgs)),
110
+ total_messages_lifetime: Math.max(
111
+ msgs,
112
+ Math.round(opts.totalMessages ?? msgs),
113
+ ),
84
114
  },
85
115
  raw_telemetry: {
86
116
  sessions_count: sessionsCount,
@@ -92,24 +122,24 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
92
122
  tokens_cache_creation: cacheCreate,
93
123
  active_minutes_est: activeMinutes,
94
124
  },
95
- tier: 'free',
125
+ tier: "free",
96
126
  agent: {
97
127
  version: identity.agent_version,
98
128
  ruleset_version: RULESET_VERSION,
99
- snapshot_hash: '',
129
+ snapshot_hash: "",
100
130
  public_key: identity.public_key,
101
131
  },
102
- }
132
+ };
103
133
 
104
134
  // Cascade Report block (Phase 1 — mode detection + badges + health).
105
135
  // Computed locally (pure math), submitted alongside the 4 token pillars.
106
136
  // The server stores it as-is — does NOT recompute modes.
107
137
  // Weekly granularity is the privacy boundary — daily modes never leave the machine.
108
- if (opts.report && typeof opts.report === 'object') {
109
- payload.report = opts.report
138
+ if (opts.report && typeof opts.report === "object") {
139
+ payload.report = opts.report;
110
140
  }
111
- payload.agent.snapshot_hash = snapshotHash(payload)
112
- return payload
141
+ payload.agent.snapshot_hash = snapshotHash(payload);
142
+ return payload;
113
143
  }
114
144
 
115
145
  /**
@@ -117,103 +147,139 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
117
147
  * Returns a structured result; never throws on a network error. opts: { apiBase,
118
148
  * fetchImpl, platform, now } (fetchImpl lets the caller inject a timeout-wrapped fetch).
119
149
  */
120
- export async function submitSignedWindow(windowKey, pillars, messages, identity, opts = {}) {
150
+ export async function submitSignedWindow(
151
+ windowKey,
152
+ pillars,
153
+ messages,
154
+ identity,
155
+ opts = {},
156
+ ) {
121
157
  if (!identity?.codename || !identity?.operator_id) {
122
- return { status: 'not_enrolled', reason: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to bind this device first.' }
158
+ return {
159
+ status: "not_enrolled",
160
+ reason: "not_enrolled",
161
+ detail: "Run `npx sigrank-mcp enroll` to bind this device first.",
162
+ };
123
163
  }
124
164
  if (!identity?.private_key_pkcs8_b64) {
125
- return { status: 'error', reason: 'no_key', detail: 'No local signing key — re-run `npx sigrank-mcp enroll`.' }
165
+ return {
166
+ status: "error",
167
+ reason: "no_key",
168
+ detail: "No local signing key — re-run `npx sigrank-mcp enroll`.",
169
+ };
126
170
  }
127
171
 
128
- const apiBase = opts.apiBase || process.env.SIGRANK_API_BASE || 'https://signalaf.com'
172
+ const apiBase =
173
+ opts.apiBase || process.env.SIGRANK_API_BASE || "https://signalaf.com";
129
174
  // FIX M: default a 15s AbortController timeout when the caller doesn't inject a
130
175
  // fetchImpl. The TUI path already wraps fetch (callTool → doFetch, 10s), but the
131
176
  // legacy CLI `watch --submit` + the CLI default-view [S] call submitSignedWindow
132
177
  // with no fetchImpl → bare fetch → a hung POST blocks forever. This default fixes
133
178
  // it everywhere at once. (15s is generous for a signed POST to /api/v1/snapshots.)
134
- const fetchImpl = opts.fetchImpl || ((url, init = {}) => {
135
- const ctrl = new AbortController()
136
- const timer = setTimeout(() => ctrl.abort(), 15_000)
137
- return fetch(url, { ...init, signal: init.signal || ctrl.signal }).finally(() => clearTimeout(timer))
138
- })
139
- const payload = buildPayload(windowKey, pillars, messages, identity, opts.platform || 'claude', opts)
179
+ const fetchImpl =
180
+ opts.fetchImpl ||
181
+ ((url, init = {}) => {
182
+ const ctrl = new AbortController();
183
+ const timer = setTimeout(() => ctrl.abort(), 15_000);
184
+ return fetch(url, {
185
+ ...init,
186
+ signal: init.signal || ctrl.signal,
187
+ }).finally(() => clearTimeout(timer));
188
+ });
189
+ const payload = buildPayload(
190
+ windowKey,
191
+ pillars,
192
+ messages,
193
+ identity,
194
+ opts.platform || "claude",
195
+ opts,
196
+ );
140
197
 
141
198
  // Preflight: run the same anti-gaming checks the server will run, locally.
142
199
  // If the payload would be rejected or downgraded, warn the operator BEFORE
143
200
  // submitting. The server always runs its own checks — this is a preview.
144
- const pre = preflight(payload)
201
+ const pre = preflight(payload);
145
202
  if (!pre.pass && !opts.skipPreflight) {
146
203
  if (pre.wouldReject) {
147
204
  return {
148
- status: 'preflight_rejected',
205
+ status: "preflight_rejected",
149
206
  window: WINDOW_TYPE[windowKey] || windowKey,
150
207
  preflight: pre,
151
208
  detail: `Submission would be REJECTED by the server: ${pre.summary}. Fix the issue or re-run with skipPreflight.`,
152
- }
209
+ };
153
210
  }
154
211
  // Flags downgrade verified → flagged (not ranked). Warn but don't block
155
212
  // unless the caller explicitly asked for strict mode.
156
213
  if (opts.strictPreflight) {
157
214
  return {
158
- status: 'preflight_flagged',
215
+ status: "preflight_flagged",
159
216
  window: WINDOW_TYPE[windowKey] || windowKey,
160
217
  preflight: pre,
161
218
  detail: `Submission would be DOWNGRADED (not ranked): ${pre.summary}. Fix the issue or re-run with skipPreflight.`,
162
- }
219
+ };
163
220
  }
164
221
  // Non-strict: attach the preflight warning to the result but proceed.
165
222
  }
166
223
 
167
- const signature = signPayload(payload, identity.private_key_pkcs8_b64)
224
+ const signature = signPayload(payload, identity.private_key_pkcs8_b64);
168
225
 
169
226
  // Dry run: build + sign exactly as a real publish, then STOP before the POST.
170
227
  // Returns the exact payload that would be sent — the privacy proof ("token
171
228
  // counts only") is inspectable, and nothing touches the network.
172
229
  if (opts.dryRun) {
173
230
  return {
174
- status: 'dry_run',
231
+ status: "dry_run",
175
232
  window: WINDOW_TYPE[windowKey] || windowKey,
176
233
  would_post: `${apiBase}/api/v1/snapshots`,
177
234
  payload,
178
235
  signature,
179
236
  preflight: pre,
180
- detail: 'Nothing sent. Re-run without dry_run to publish.',
181
- }
237
+ detail: "Nothing sent. Re-run without dry_run to publish.",
238
+ };
182
239
  }
183
240
 
184
- let res
185
- let ack
241
+ let res;
242
+ let ack;
186
243
  try {
187
244
  res = await fetchImpl(`${apiBase}/api/v1/snapshots`, {
188
- method: 'POST',
245
+ method: "POST",
189
246
  headers: {
190
- 'content-type': 'application/json',
191
- accept: 'application/json',
192
- 'x-agent-signature': signature,
247
+ "content-type": "application/json",
248
+ accept: "application/json",
249
+ "x-agent-signature": signature,
193
250
  },
194
251
  body: JSON.stringify(payload),
195
- })
252
+ });
196
253
  try {
197
- ack = await res.json()
254
+ ack = await res.json();
198
255
  } catch {
199
- ack = {}
256
+ ack = {};
200
257
  }
201
258
  } catch (e) {
202
- return { status: 'error', reason: 'network', detail: e.message, window: WINDOW_TYPE[windowKey] || windowKey }
259
+ return {
260
+ status: "error",
261
+ reason: "network",
262
+ detail: e.message,
263
+ window: WINDOW_TYPE[windowKey] || windowKey,
264
+ };
203
265
  }
204
266
 
205
267
  return {
206
- status: res.ok ? ack.status || 'received' : 'error',
268
+ status: res.ok ? ack.status || "received" : "error",
207
269
  httpStatus: res.status,
208
270
  window: WINDOW_TYPE[windowKey] || windowKey,
209
271
  verification_tier: ack.verification_tier ?? null,
210
272
  persisted: ack.persisted ?? null,
211
273
  // ranked = actually on the board (verified + written), not just "received".
212
274
  // An unenrolled/revoked device gets HTTP 202 received but is NEVER ranked.
213
- ranked: !!(res.ok && ack.verification_tier === 'verified' && ack.persisted === true),
275
+ ranked: !!(
276
+ res.ok &&
277
+ ack.verification_tier === "verified" &&
278
+ ack.persisted === true
279
+ ),
214
280
  snapshot_hash: payload.agent.snapshot_hash,
215
281
  reason: res.ok ? null : ack.reason || ack.status || `http_${res.status}`,
216
282
  detail: ack.detail ?? null,
217
283
  preflight: pre,
218
- }
284
+ };
219
285
  }