election-vibe 0.1.1 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/dist/cli.js +61 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,6 +27,14 @@ On Tuesday, after the polls close:
27
27
 
28
28
  Without installing, put `npx election-vibe` where `elections` is (`npx election-vibe collect`, `npx election-vibe watch`). The statusline and the plugin need the global install.
29
29
 
30
+ ### One-step setup with Claude
31
+
32
+ In Claude Code (the terminal CLI, or its desktop app), you can hand the whole setup to Claude. Paste this prompt and Claude installs the package, starts the collector, asks which races you care about, and follows them:
33
+
34
+ > Set up election-vibe for election night. If you cannot run commands on my computer, say so and stop. Install it with `npm i -g election-vibe` (it needs Node 22 or later; help me get that first if it is missing). Add its Claude Code plugin: `claude plugin marketplace add "$(npm root -g)/election-vibe"`, then `claude plugin install election-vibe@election-vibe --config store=production`. Start the collector by running `elections collect` in a terminal tab and leave it running. Then ask me which state I vote in and which races I care about (one question at a time, with answer options when you can), look them up with `elections find`, and follow them with `elections follow <id>`. If a race needs a district I do not know, point me to my county registrar's lookup instead of guessing. When everything is set, tell me to run /reload-plugins so the results line and the /elections pane appear. Results are unofficial: never call a race yourself, and present a check mark only as the named source's call.
35
+
36
+ The collector runs while that terminal tab stays open; every other surface reads its local snapshot. On default permission settings Claude asks you to approve the install steps; that is expected. The prompt sets up the plugin (Option B below); [In Claude Code](#in-claude-code) describes what it shows and the statusline alternative.
37
+
30
38
  ## What you will see
31
39
 
32
40
  Printed on Sunday, Nov 1, 2026, 9:39 PM PST, from real reads of the sources. Before the polls close, every contest looks like this: no counts yet, and the time its polls close.
@@ -133,6 +141,8 @@ elections list --state CA --level measure # every California measure, statewide
133
141
 
134
142
  Two ways, both reading the same local snapshot. Pick one: in a terminal, using both shows the same line twice (once in the statusline, once above the prompt). Both need the global install (`npm i -g election-vibe`); `npx` leaves nothing installed for them to run.
135
143
 
144
+ Setup can also be a single pasted prompt: see [One-step setup with Claude](#one-step-setup-with-claude).
145
+
136
146
  ### Option A: the statusline only
137
147
 
138
148
  `prompt` prints one line for a statusline: your followed contests (or, with none followed, the hot races), one shown and the rest counted (`+N`). A source call or a withdrawn call from the last five minutes is shown first. It reads the local snapshot only: it never fetches, never starts the collector, and never prints an error (with no snapshot it says `no results yet · run: elections collect`). Before any of those contests' polls close it says when the first one does; a snapshot more than 10 minutes old is called out first (`⚠ results 14m old`). Same words as the board: "leading" is never a win, and ✓ marks only a named source's call. `--width N` (default 80), `--color` for color, `--store rehearsal` for the test store.
@@ -302,6 +312,8 @@ To remove it all, in this order (the plugin lives inside the package's folder, s
302
312
 
303
313
  **Does collection stop on November 4?** No: the collector runs until you stop it. November 4 is how long the tool is supported; see [Operating window](#operating-window).
304
314
 
315
+ **Why does `collect --once` take so long?** It is not stuck. `--once` reads everything that is due, once, then exits. On a fresh install that is every contest the collector reads from civicAPI (471, plus one for each followed California contest that civicAPI also carries), one request at a time at about one per second, so the single pass takes about 10 minutes and prints its one log line at the end. Plain `elections collect` paces itself (at most 9 civicAPI requests per pass, then a 10-second wait before the next pass) and is the normal way to run it. For a quick connectivity check, `elections collect --once --civic-limit 2` bounds the pass to two civicAPI requests and exits in seconds.
316
+
305
317
  **Does this run on Windows?** Not tested. This release was built and tested on macOS. The CLI uses only Node, but the plugin assumes a macOS or Linux shell path, and nothing here has been tried on Windows or Linux.
306
318
 
307
319
  **Does `race` treat the comma as part of the query?** Yes. `race` takes its words as one phrase, punctuation included, and looks for it in titles and ids: `"palo alto, transactions"` matches Palo Alto's Measure J because its title contains that phrase. `find` is the one that matches word by word.
package/dist/cli.js CHANGED
@@ -216,7 +216,7 @@ var REHEARSAL_SENTENCE = {
216
216
 
217
217
  // src/http.ts
218
218
  import { gunzipSync } from "zlib";
219
- var USER_AGENT = `ElectionsCompanion/${"0.1.1"} (unofficial results reader; local, no telemetry)`;
219
+ var USER_AGENT = `ElectionsCompanion/${"0.1.3"} (unofficial results reader; local, no telemetry)`;
220
220
  function retryAfterMs(header, nowMs) {
221
221
  if (!header) return void 0;
222
222
  const t = header.trim();
@@ -1943,6 +1943,8 @@ var CIVIC_BLANKET_FLOOR_SEC = 900;
1943
1943
  var THROTTLE_FLOOR_MS = 5 * 6e4;
1944
1944
  var BACKOFF_MAX_MS = 30 * 6e4;
1945
1945
  var MAX_EVENTS = 2e3;
1946
+ var CLOSE_SPREAD_MS = 6e4;
1947
+ var READ_JITTER_SHARE = 0.1;
1946
1948
  var CLARITY_RECHECK_MS = 30 * 6e4;
1947
1949
  var LockLost = class extends Error {
1948
1950
  constructor(report) {
@@ -2001,10 +2003,40 @@ function newerRecord(a, b) {
2001
2003
  function blocked(s, nowMs) {
2002
2004
  return !!s.deniedAt || !!s.cooldownUntil && Date.parse(s.cooldownUntil) > nowMs;
2003
2005
  }
2004
- function sourceCadenceMs(src, contests, nowMs, rehearsal = false) {
2006
+ var closeOffsets = /* @__PURE__ */ new WeakMap();
2007
+ function closeOffsetMs(deps, id) {
2008
+ let run = closeOffsets.get(deps);
2009
+ if (!run) {
2010
+ run = /* @__PURE__ */ new Map();
2011
+ closeOffsets.set(deps, run);
2012
+ }
2013
+ let o = run.get(id);
2014
+ if (o === void 0) {
2015
+ const r = deps.random();
2016
+ o = r >= 0 && r < 1 ? Math.floor(r * CLOSE_SPREAD_MS) : 0;
2017
+ run.set(id, o);
2018
+ }
2019
+ return o;
2020
+ }
2021
+ var readJitters = /* @__PURE__ */ new WeakMap();
2022
+ function readJitterMs(deps, id, after, cadenceMs) {
2023
+ let run = readJitters.get(deps);
2024
+ if (!run) {
2025
+ run = /* @__PURE__ */ new Map();
2026
+ readJitters.set(deps, run);
2027
+ }
2028
+ let held = run.get(id);
2029
+ if (!held || held.after !== after) {
2030
+ const r = deps.random();
2031
+ held = { after, share: r >= 0 && r < 1 ? r : 0 };
2032
+ run.set(id, held);
2033
+ }
2034
+ return Math.floor(held.share * READ_JITTER_SHARE * cadenceMs);
2035
+ }
2036
+ function sourceCadenceMs(src, contests, nowMs, rehearsal, offsetMs, jitterMs) {
2005
2037
  if (rehearsal) return src.cadenceSec * 1e3;
2006
- const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) <= nowMs);
2007
- return (anyClosed ? src.cadenceSec : src.preCloseCadenceSec) * 1e3;
2038
+ const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) + offsetMs <= nowMs);
2039
+ return anyClosed ? src.cadenceSec * 1e3 + jitterMs() : src.preCloseCadenceSec * 1e3;
2008
2040
  }
2009
2041
  function due(s, cadenceMs, nowMs) {
2010
2042
  if (blocked(s, nowMs)) return false;
@@ -2139,7 +2171,7 @@ function sealSources(raw) {
2139
2171
  }
2140
2172
  return out;
2141
2173
  }
2142
- function civicQueue(registry, src, config, last, nowMs) {
2174
+ function civicQueue(registry, src, config, last, nowMs, closeOffsetMs2 = 0, jitterMs = () => 0) {
2143
2175
  const blanketSec = Math.max(CIVIC_BLANKET_FLOOR_SEC, config.blanketSec ?? Number(src.config.blanketSec ?? 1800));
2144
2176
  const out = [];
2145
2177
  for (const def of registry) {
@@ -2147,10 +2179,10 @@ function civicQueue(registry, src, config, last, nowMs) {
2147
2179
  if (!m2) continue;
2148
2180
  const watch = config.follow.includes(def.id) || (def.presets ?? []).includes("hot");
2149
2181
  if (m2.role !== "primary" && !watch) continue;
2150
- const preClose = nowMs < Date.parse(def.pollsClose);
2182
+ const preClose = nowMs < Date.parse(def.pollsClose) + closeOffsetMs2;
2151
2183
  const cadence = (preClose ? src.preCloseCadenceSec : watch ? src.cadenceSec : blanketSec) * 1e3;
2152
2184
  const prev = last[m2.key];
2153
- if (prev !== void 0 && nowMs - prev < cadence) continue;
2185
+ if (prev !== void 0 && nowMs - prev < cadence + (preClose ? 0 : jitterMs(m2.key, prev, cadence))) continue;
2154
2186
  out.push({ def, key: m2.key, tier: watch ? "watch" : "blanket", age: prev === void 0 ? Number.POSITIVE_INFINITY : nowMs - prev });
2155
2187
  }
2156
2188
  out.sort((a, b) => a.tier === b.tier ? b.age - a.age : a.tier === "watch" ? -1 : 1);
@@ -2239,7 +2271,15 @@ async function cycle(deps, opts) {
2239
2271
  if (src.kind === "civicapi") continue;
2240
2272
  const approval = approvals[src.id];
2241
2273
  const approvedSinceAttempt = !!approval && !!s.lastAttemptAt && Date.parse(approval.approvedAt) > Date.parse(s.lastAttemptAt);
2242
- if (!(approvedSinceAttempt && !blocked(s, t0)) && !due(s, sourceCadenceMs(src, deps.registry, t0, opts.admissionDisabled === true), t0)) {
2274
+ const cadenceMs = sourceCadenceMs(
2275
+ src,
2276
+ deps.registry,
2277
+ t0,
2278
+ opts.admissionDisabled === true,
2279
+ closeOffsetMs(deps, src.id),
2280
+ () => readJitterMs(deps, src.id, s.lastAttemptAt, src.cadenceSec * 1e3)
2281
+ );
2282
+ if (!(approvedSinceAttempt && !blocked(s, t0)) && !due(s, cadenceMs, t0)) {
2243
2283
  report.outcomes[src.id] = "skipped";
2244
2284
  continue;
2245
2285
  }
@@ -2497,7 +2537,15 @@ async function cycle(deps, opts) {
2497
2537
  const civic = deps.sources.find((s) => s.kind === "civicapi");
2498
2538
  if (civic && !config.disabledSources.includes(civic.id)) {
2499
2539
  const minInterval = Number(civic.config.minIntervalMs ?? 1100);
2500
- const queue = civicQueue(deps.registry, civic, config, cstate.civicLast, deps.now());
2540
+ const queue = civicQueue(
2541
+ deps.registry,
2542
+ civic,
2543
+ config,
2544
+ cstate.civicLast,
2545
+ deps.now(),
2546
+ opts.admissionDisabled === true ? 0 : closeOffsetMs(deps, civic.id),
2547
+ opts.admissionDisabled === true ? void 0 : (key, prev, cadenceMs) => readJitterMs(deps, `civic:${key}`, prev, cadenceMs)
2548
+ );
2501
2549
  const limit = opts.civicLimit ?? queue.length;
2502
2550
  let n = 0;
2503
2551
  for (const item of queue) {
@@ -2508,7 +2556,9 @@ async function cycle(deps, opts) {
2508
2556
  log("civicapi: hourly ceiling reached; the rest waits");
2509
2557
  break;
2510
2558
  }
2511
- if (n > 0) await deps.sleep(minInterval + Math.round(deps.random() * 250));
2559
+ const draw = deps.random();
2560
+ const spread = draw >= 0 && draw < 1 ? Math.round(draw * 250) : 0;
2561
+ await deps.sleep(n > 0 ? minInterval + spread : spread);
2512
2562
  n++;
2513
2563
  cstate.civicLast[item.key] = deps.now();
2514
2564
  const r = await get(civic, `${civic.config.base}/race/${item.key}`);
@@ -46285,7 +46335,7 @@ function storeOf(p) {
46285
46335
  }
46286
46336
  async function main(argv, io) {
46287
46337
  if (argv[0] === "--version" || argv[0] === "version") {
46288
- io.out("0.1.1");
46338
+ io.out("0.1.3");
46289
46339
  return 0;
46290
46340
  }
46291
46341
  const p = parseArgs(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "election-vibe",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "US election night, Nov 3, 2026, in your terminal and in Claude Code: unofficial results from official free sources and civicAPI, kept on your machine. Never calls a race. Unofficial results. Not an election authority. Not affiliated with any government, campaign, or party.",
5
5
  "keywords": [
6
6
  "election",