wicked-bus 1.0.0 → 1.1.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
@@ -125,6 +125,8 @@ Auto-detects installed CLIs and copies skills. Available skills:
125
125
  | `wicked-bus/subscribe` | Consume events |
126
126
  | `wicked-bus/naming` | Event naming conventions |
127
127
  | `wicked-bus/query` | Query and debug |
128
+ | `wicked-bus/status` | Bus health and diagnostics |
129
+ | `wicked-bus/update` | Check for and install updates |
128
130
 
129
131
  ## Why wicked-bus?
130
132
 
package/commands/cli.js CHANGED
@@ -6,9 +6,11 @@
6
6
 
7
7
  import { WBError, EXIT_CODES } from '../lib/errors.js';
8
8
 
9
- // Argument parser
9
+ // Argument parser. Returns flags + positional args (anything that isn't --flag
10
+ // or its value). Positional args are needed for subcommands like `dlq list`.
10
11
  function parseArgs(argv) {
11
12
  const args = {};
13
+ const positional = [];
12
14
  for (let i = 0; i < argv.length; i++) {
13
15
  if (argv[i].startsWith('--')) {
14
16
  const key = argv[i].slice(2);
@@ -17,8 +19,11 @@ function parseArgs(argv) {
17
19
  } else {
18
20
  args[key] = argv[++i];
19
21
  }
22
+ } else {
23
+ positional.push(argv[i]);
20
24
  }
21
25
  }
26
+ args._positional = positional;
22
27
  return args;
23
28
  }
24
29
 
@@ -28,6 +33,7 @@ function printUsage() {
28
33
  commands: [
29
34
  'init', 'emit', 'subscribe', 'status', 'replay',
30
35
  'cleanup', 'register', 'deregister', 'list', 'ack',
36
+ 'dlq',
31
37
  ],
32
38
  global_flags: ['--db-path <path>', '--json', '--log-level <level>'],
33
39
  };
@@ -117,6 +123,11 @@ async function main() {
117
123
  await cmdAck(args, globals);
118
124
  break;
119
125
  }
126
+ case 'dlq': {
127
+ const { cmdDlq } = await import('./cmd-dlq.js');
128
+ await cmdDlq(args, globals, args._positional || []);
129
+ break;
130
+ }
120
131
  default:
121
132
  printUsage();
122
133
  process.exit(command ? 1 : 0);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * wicked-bus dlq command — list, replay, drop dead-lettered events.
3
+ */
4
+
5
+ import { loadConfig } from '../lib/config.js';
6
+ import { openDb } from '../lib/db.js';
7
+ import { listDeadLetters, replayDeadLetter, dropDeadLetter } from '../lib/dlq.js';
8
+ import { WBError } from '../lib/errors.js';
9
+
10
+ export async function cmdDlq(args, globals, positional = []) {
11
+ const subcommand = positional[0];
12
+
13
+ if (!subcommand) {
14
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
15
+ message: 'dlq requires a subcommand: list | replay | drop',
16
+ reason: 'missing dlq subcommand',
17
+ });
18
+ }
19
+
20
+ const configOverrides = {};
21
+ if (globals.db_path) configOverrides.db_path = globals.db_path;
22
+ if (globals.log_level) configOverrides.log_level = globals.log_level;
23
+
24
+ const config = loadConfig(configOverrides);
25
+ const db = openDb(config);
26
+
27
+ try {
28
+ switch (subcommand) {
29
+ case 'list': {
30
+ const opts = {};
31
+ if (args.plugin) opts.plugin = args.plugin;
32
+ if (args['cursor-id']) opts.cursorId = args['cursor-id'];
33
+ if (args.limit) opts.limit = parseInt(args.limit, 10);
34
+ const rows = listDeadLetters(db, opts);
35
+ process.stdout.write(JSON.stringify({ dead_letters: rows, count: rows.length }) + '\n');
36
+ return;
37
+ }
38
+
39
+ case 'replay': {
40
+ const dlId = parseDlId(args);
41
+ if (args['dry-run']) {
42
+ const row = db.prepare('SELECT * FROM dead_letters WHERE dl_id = ?').get(dlId);
43
+ if (!row) {
44
+ throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
45
+ message: `Dead letter not found: ${dlId}`,
46
+ dl_id: dlId,
47
+ reason: 'dead letter row not found',
48
+ });
49
+ }
50
+ process.stdout.write(JSON.stringify({
51
+ dry_run: true,
52
+ would_replay: {
53
+ dl_id: row.dl_id,
54
+ event_id: row.event_id,
55
+ event_type: row.event_type,
56
+ domain: row.domain,
57
+ attempts: row.attempts,
58
+ last_error: row.last_error,
59
+ },
60
+ }) + '\n');
61
+ return;
62
+ }
63
+ const result = replayDeadLetter(db, dlId);
64
+ process.stdout.write(JSON.stringify(result) + '\n');
65
+ return;
66
+ }
67
+
68
+ case 'drop': {
69
+ const dlId = parseDlId(args);
70
+ const result = dropDeadLetter(db, dlId);
71
+ process.stdout.write(JSON.stringify(result) + '\n');
72
+ return;
73
+ }
74
+
75
+ default:
76
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
77
+ message: `Unknown dlq subcommand: ${subcommand}`,
78
+ reason: 'unknown dlq subcommand',
79
+ });
80
+ }
81
+ } finally {
82
+ db.close();
83
+ }
84
+ }
85
+
86
+ function parseDlId(args) {
87
+ const raw = args['dl-id'];
88
+ if (raw == null || raw === true) {
89
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
90
+ message: '--dl-id <number> is required',
91
+ reason: 'missing --dl-id',
92
+ });
93
+ }
94
+ const dlId = parseInt(raw, 10);
95
+ if (!Number.isInteger(dlId) || dlId <= 0) {
96
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
97
+ message: `--dl-id must be a positive integer, got: ${raw}`,
98
+ reason: 'invalid --dl-id',
99
+ });
100
+ }
101
+ return dlId;
102
+ }
package/install.mjs CHANGED
@@ -60,19 +60,24 @@ if (pathArg) {
60
60
  }
61
61
 
62
62
  // Copy skills to each target CLI
63
- const skillDirs = readdirSync(skillsSource).filter((d) => !d.startsWith("."));
63
+ // Repo structure: skills/wicked-bus/{name}/SKILL.md (nested namespace)
64
+ // Installed structure: {cli}/skills/wicked-bus-{name}/SKILL.md (flat, one level deep)
65
+ // CLI skill discovery only scans one level deep under the skills directory.
66
+ const namespace = "wicked-bus";
67
+ const namespaceSrc = join(skillsSource, namespace);
68
+ const subSkills = readdirSync(namespaceSrc).filter((d) => !d.startsWith("."));
64
69
 
65
70
  for (const target of targets) {
66
71
  console.log(`Installing to ${target.name} (${target.dir})...`);
67
72
  mkdirSync(target.dir, { recursive: true });
68
73
 
69
- for (const skill of skillDirs) {
70
- const src = join(skillsSource, skill);
71
- const dest = join(target.dir, skill);
74
+ for (const skill of subSkills) {
75
+ const src = join(namespaceSrc, skill);
76
+ const dest = join(target.dir, `${namespace}-${skill}`);
72
77
  cpSync(src, dest, { recursive: true });
73
78
  }
74
79
 
75
- console.log(` ${skillDirs.length} skills installed`);
80
+ console.log(` ${subSkills.length} skills installed`);
76
81
  }
77
82
 
78
83
  console.log(`\nwicked-bus skills installed! Available skills:`);
@@ -81,3 +86,5 @@ console.log(` wicked-bus/emit — Publish events`);
81
86
  console.log(` wicked-bus/subscribe — Consume events`);
82
87
  console.log(` wicked-bus/naming — Event naming conventions`);
83
88
  console.log(` wicked-bus/query — Query and debug the bus`);
89
+ console.log(` wicked-bus/status — Bus health and diagnostics`);
90
+ console.log(` wicked-bus/update — Check for and install updates`);
package/lib/db.js CHANGED
@@ -12,7 +12,7 @@ import { resolveDbPath, ensureDataDir } from './paths.js';
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const require = createRequire(import.meta.url);
14
14
  const SCHEMA_SQL_PATH = join(__dirname, 'schema.sql');
15
- const MAX_SUPPORTED_SCHEMA_VERSION = 1;
15
+ const MAX_SUPPORTED_SCHEMA_VERSION = 2;
16
16
 
17
17
  /**
18
18
  * Open (or create) the SQLite database, apply PRAGMAs and schema.
package/lib/dlq.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Dead-letter queue inspection and operator controls.
3
+ * @module lib/dlq
4
+ *
5
+ * Read-only listing, replay request, and drop. The managed subscribe() helper
6
+ * in lib/subscribe.js owns the corresponding write paths (DLQ insertion on
7
+ * retry exhaustion, replay drain on each poll cycle).
8
+ */
9
+
10
+ import { WBError } from './errors.js';
11
+
12
+ /**
13
+ * List dead-lettered events, most recent first.
14
+ *
15
+ * Caveat: dead_letters rows are denormalized snapshots of the originating
16
+ * event taken at DLQ time. The original row in `events` may have been swept
17
+ * by `dedup_expires_at` (24h default) by the time the DLQ entry is read, so
18
+ * the returned `payload` / `event_type` / `domain` / `subdomain` reflect the
19
+ * event as it existed when it failed, not the current state of `events`.
20
+ *
21
+ * @param {import('better-sqlite3').Database} db
22
+ * @param {object} [opts]
23
+ * @param {string} [opts.plugin] - Filter to a single subscriber plugin
24
+ * @param {string} [opts.cursorId] - Filter to a single cursor
25
+ * @param {number} [opts.limit=100] - Max rows to return
26
+ * @returns {object[]} Dead letter rows with `payload` parsed from JSON
27
+ */
28
+ export function listDeadLetters(db, opts = {}) {
29
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
30
+
31
+ const conditions = [];
32
+ const params = { limit };
33
+
34
+ if (opts.plugin) {
35
+ conditions.push(`dl.subscription_id IN (
36
+ SELECT subscription_id FROM subscriptions WHERE plugin = :plugin
37
+ )`);
38
+ params.plugin = opts.plugin;
39
+ }
40
+
41
+ if (opts.cursorId) {
42
+ conditions.push('dl.cursor_id = :cursor_id');
43
+ params.cursor_id = opts.cursorId;
44
+ }
45
+
46
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
47
+
48
+ const sql = `
49
+ SELECT
50
+ dl.dl_id,
51
+ dl.cursor_id,
52
+ dl.subscription_id,
53
+ dl.event_id,
54
+ dl.event_type,
55
+ dl.domain,
56
+ dl.subdomain,
57
+ dl.payload,
58
+ dl.emitted_at,
59
+ dl.attempts,
60
+ dl.last_error,
61
+ dl.dead_lettered_at,
62
+ dl.replay_requested_at,
63
+ s.plugin
64
+ FROM dead_letters dl
65
+ LEFT JOIN subscriptions s ON s.subscription_id = dl.subscription_id
66
+ ${where}
67
+ ORDER BY dl.dead_lettered_at DESC, dl.dl_id DESC
68
+ LIMIT :limit
69
+ `;
70
+
71
+ const rows = db.prepare(sql).all(params);
72
+
73
+ return rows.map(row => ({
74
+ ...row,
75
+ payload: parsePayload(row.payload),
76
+ }));
77
+ }
78
+
79
+ /**
80
+ * Mark a dead-lettered event for replay. The next tick of the managed
81
+ * subscribe() loop for this cursor will drain pending replays before normal
82
+ * polling. Replay is a single attempt — no automatic retry. On success the
83
+ * DLQ row is deleted; on failure `replay_requested_at` is cleared and
84
+ * `attempts` / `last_error` are updated so the operator can re-inspect.
85
+ *
86
+ * Caveat: if the handler re-emits during replay, the original event's
87
+ * `idempotency_key` may already have been swept from `events` (24h
88
+ * `dedup_expires_at`), so the re-emission will not be deduped against the
89
+ * original. Replay is for recovery after fixing a bug, not transparent retry.
90
+ *
91
+ * @param {import('better-sqlite3').Database} db
92
+ * @param {number} dlId - dl_id of the dead_letters row to replay
93
+ * @returns {{ replayed: boolean, dl_id: number, replay_requested_at: number }}
94
+ */
95
+ export function replayDeadLetter(db, dlId) {
96
+ const now = Date.now();
97
+ const result = db.prepare(`
98
+ UPDATE dead_letters
99
+ SET replay_requested_at = ?
100
+ WHERE dl_id = ?
101
+ `).run(now, dlId);
102
+
103
+ if (result.changes === 0) {
104
+ throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
105
+ message: `Dead letter not found: ${dlId}`,
106
+ dl_id: dlId,
107
+ reason: 'dead letter row not found',
108
+ });
109
+ }
110
+
111
+ return { replayed: true, dl_id: dlId, replay_requested_at: now };
112
+ }
113
+
114
+ /**
115
+ * Permanently drop a dead-lettered event. Use this when an event is
116
+ * unrecoverable and the operator does not want it consuming DLQ slots.
117
+ *
118
+ * @param {import('better-sqlite3').Database} db
119
+ * @param {number} dlId
120
+ * @returns {{ dropped: boolean, dl_id: number }}
121
+ */
122
+ export function dropDeadLetter(db, dlId) {
123
+ const result = db.prepare('DELETE FROM dead_letters WHERE dl_id = ?').run(dlId);
124
+ if (result.changes === 0) {
125
+ throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
126
+ message: `Dead letter not found: ${dlId}`,
127
+ dl_id: dlId,
128
+ reason: 'dead letter row not found',
129
+ });
130
+ }
131
+ return { dropped: true, dl_id: dlId };
132
+ }
133
+
134
+ function parsePayload(raw) {
135
+ if (raw == null) return null;
136
+ try {
137
+ return JSON.parse(raw);
138
+ } catch (err) {
139
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
140
+ message: 'dead_letters row has malformed JSON payload',
141
+ reason: err.message,
142
+ });
143
+ }
144
+ }
package/lib/index.js CHANGED
@@ -10,4 +10,6 @@ export { openDb } from './db.js';
10
10
  export { loadConfig } from './config.js';
11
11
  export { resolveDataDir, ensureDataDir, resolveDbPath } from './paths.js';
12
12
  export { startSweep, runSweep } from './sweep.js';
13
+ export { listDeadLetters, replayDeadLetter, dropDeadLetter } from './dlq.js';
14
+ export { subscribe } from './subscribe.js';
13
15
  export { WBError, ERROR_CODES, EXIT_CODES } from './errors.js';
package/lib/schema.sql CHANGED
@@ -57,6 +57,55 @@ CREATE INDEX IF NOT EXISTS idx_cursors_subscription_id ON cursors(subscription_i
57
57
  CREATE INDEX IF NOT EXISTS idx_cursors_active
58
58
  ON cursors(subscription_id) WHERE deregistered_at IS NULL;
59
59
 
60
+ -- dead_letters: events that exhausted retries for a specific cursor.
61
+ -- Physically separate from events so poll()'s WB-003 MIN(event_id) check stays
62
+ -- correct. Denormalized so rows survive the 24h dedup_expires_at sweep of the
63
+ -- originating event. No automatic TTL — operator-managed via dlq subcommands.
64
+ CREATE TABLE IF NOT EXISTS dead_letters (
65
+ dl_id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ cursor_id TEXT NOT NULL
67
+ REFERENCES cursors(cursor_id) ON DELETE RESTRICT,
68
+ subscription_id TEXT NOT NULL
69
+ REFERENCES subscriptions(subscription_id) ON DELETE RESTRICT,
70
+ event_id INTEGER NOT NULL,
71
+ event_type TEXT NOT NULL,
72
+ domain TEXT NOT NULL,
73
+ subdomain TEXT NOT NULL DEFAULT '',
74
+ payload TEXT NOT NULL,
75
+ emitted_at INTEGER NOT NULL,
76
+ attempts INTEGER NOT NULL,
77
+ last_error TEXT,
78
+ dead_lettered_at INTEGER NOT NULL,
79
+ -- Replay queue marker set by replayDeadLetter(). The managed subscribe
80
+ -- loop drains rows with non-null replay_requested_at before polling new
81
+ -- events. Cleared on replay success (row deleted) or failure (row
82
+ -- updated with new attempts/last_error so the operator can re-inspect).
83
+ replay_requested_at INTEGER
84
+ );
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_cursor_id ON dead_letters(cursor_id);
87
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_subscription_id ON dead_letters(subscription_id);
88
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_dead_lettered_at ON dead_letters(dead_lettered_at);
89
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_event_id ON dead_letters(event_id);
90
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_replay_pending
91
+ ON dead_letters(cursor_id, replay_requested_at)
92
+ WHERE replay_requested_at IS NOT NULL;
93
+
94
+ -- delivery_attempts: per-cursor retry state for events currently in flight.
95
+ -- Created on first handler failure, deleted on successful ack or DLQ transition.
96
+ -- Survives process restarts so the retry counter is restored on the next poll.
97
+ CREATE TABLE IF NOT EXISTS delivery_attempts (
98
+ cursor_id TEXT NOT NULL
99
+ REFERENCES cursors(cursor_id) ON DELETE CASCADE,
100
+ event_id INTEGER NOT NULL,
101
+ attempts INTEGER NOT NULL DEFAULT 1,
102
+ last_attempt_at INTEGER NOT NULL,
103
+ last_error TEXT,
104
+ PRIMARY KEY (cursor_id, event_id)
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_delivery_attempts_cursor_id ON delivery_attempts(cursor_id);
108
+
60
109
  -- schema_migrations
61
110
  CREATE TABLE IF NOT EXISTS schema_migrations (
62
111
  version INTEGER PRIMARY KEY,
@@ -66,3 +115,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
66
115
 
67
116
  INSERT OR IGNORE INTO schema_migrations(version, applied_at, description)
68
117
  VALUES (1, unixepoch() * 1000, 'initial schema');
118
+
119
+ INSERT OR IGNORE INTO schema_migrations(version, applied_at, description)
120
+ VALUES (2, unixepoch() * 1000, 'add dead_letters and delivery_attempts tables');
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Managed long-running subscriber helper.
3
+ * @module lib/subscribe
4
+ *
5
+ * Layered on top of register/poll/ack — handles the poll loop, error
6
+ * isolation, retry/backoff, dead-lettering, lifecycle, lag introspection,
7
+ * and replay drain.
8
+ *
9
+ * Design notes:
10
+ * - Serial per subscription: events are processed one at a time in cursor
11
+ * order. The next poll does not start until the current batch is drained.
12
+ * - Retry state lives in `delivery_attempts` so it survives process restarts.
13
+ * On the next poll after a crash, the loop reads the existing attempt count
14
+ * and resumes from where it left off.
15
+ * - On exhaustion, the event is copied (denormalized) into `dead_letters` and
16
+ * the cursor is acked past it. The original `events` row may be swept by
17
+ * the 24h `dedup_expires_at` later — the DLQ row is self-contained.
18
+ * - `stop()` cancels any in-flight backoff timer, dead-letters the sleeping
19
+ * event, and acks the cursor. Cursor cleanliness beats avoiding an
20
+ * unexpected DLQ entry the operator can replay.
21
+ * - `replayDeadLetter()` sets `replay_requested_at`. The loop drains pending
22
+ * replays before each normal poll — success deletes the DLQ row, failure
23
+ * clears `replay_requested_at` and updates `attempts` / `last_error`.
24
+ *
25
+ * Caveats:
26
+ * - At-least-once delivery + retries means the handler may be invoked more
27
+ * than once for the same logical event. Handlers must be idempotent.
28
+ * - If a DLQ entry is replayed and the handler re-emits as part of recovery,
29
+ * the original `idempotency_key` may already have been swept from `events`
30
+ * (24h `dedup_expires_at`). The re-emission will not be deduped against
31
+ * the original. Replay is for recovery, not for transparent retry.
32
+ */
33
+
34
+ import { poll, ack } from './poll.js';
35
+ import { register } from './register.js';
36
+
37
+ const DEFAULTS = Object.freeze({
38
+ pollIntervalMs: 15000,
39
+ batchSize: 50,
40
+ maxRetries: 0,
41
+ backoffMs: 1000,
42
+ lagIntervalMs: 60000,
43
+ cursor_init: 'latest',
44
+ });
45
+
46
+ /**
47
+ * Resume an existing subscription by (plugin, filter), or register a new one.
48
+ * Internal — not exported. Direct callers of register() keep the original
49
+ * "always create a new UUID" semantics.
50
+ *
51
+ * @param {import('better-sqlite3').Database} db
52
+ * @param {object} opts
53
+ * @returns {{ subscription_id: string, cursor_id: string, created: boolean }}
54
+ */
55
+ function registerOrResume(db, opts) {
56
+ const existing = db.prepare(`
57
+ SELECT s.subscription_id, c.cursor_id
58
+ FROM subscriptions s
59
+ INNER JOIN cursors c ON c.subscription_id = s.subscription_id
60
+ WHERE s.plugin = ?
61
+ AND s.role = 'subscriber'
62
+ AND s.event_type_filter = ?
63
+ AND s.deregistered_at IS NULL
64
+ AND c.deregistered_at IS NULL
65
+ ORDER BY s.registered_at DESC
66
+ LIMIT 1
67
+ `).get(opts.plugin, opts.filter);
68
+
69
+ if (existing) {
70
+ return {
71
+ subscription_id: existing.subscription_id,
72
+ cursor_id: existing.cursor_id,
73
+ created: false,
74
+ };
75
+ }
76
+
77
+ const fresh = register(db, {
78
+ plugin: opts.plugin,
79
+ role: 'subscriber',
80
+ filter: opts.filter,
81
+ cursor_init: opts.cursor_init,
82
+ });
83
+
84
+ return {
85
+ subscription_id: fresh.subscription_id,
86
+ cursor_id: fresh.cursor_id,
87
+ created: true,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Normalize backoffMs into an array of length maxRetries with last-element
93
+ * repeat semantics. A single number becomes a constant array.
94
+ */
95
+ function normalizeBackoff(input, maxRetries) {
96
+ if (maxRetries <= 0) return [];
97
+ const source = Array.isArray(input) ? input : [input ?? DEFAULTS.backoffMs];
98
+ if (source.length === 0) return new Array(maxRetries).fill(DEFAULTS.backoffMs);
99
+ const out = new Array(maxRetries);
100
+ for (let i = 0; i < maxRetries; i++) {
101
+ out[i] = source[Math.min(i, source.length - 1)];
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Read the current attempt count for an in-flight retry, or 0 if none.
108
+ */
109
+ function getAttempts(db, cursorId, eventId) {
110
+ const row = db.prepare(
111
+ 'SELECT attempts FROM delivery_attempts WHERE cursor_id = ? AND event_id = ?'
112
+ ).get(cursorId, eventId);
113
+ return row ? row.attempts : 0;
114
+ }
115
+
116
+ function upsertDeliveryAttempt(db, cursorId, eventId, attempts, lastError) {
117
+ db.prepare(`
118
+ INSERT INTO delivery_attempts (cursor_id, event_id, attempts, last_attempt_at, last_error)
119
+ VALUES (?, ?, ?, ?, ?)
120
+ ON CONFLICT(cursor_id, event_id) DO UPDATE SET
121
+ attempts = excluded.attempts,
122
+ last_attempt_at = excluded.last_attempt_at,
123
+ last_error = excluded.last_error
124
+ `).run(cursorId, eventId, attempts, Date.now(), lastError ?? null);
125
+ }
126
+
127
+ function deleteDeliveryAttempt(db, cursorId, eventId) {
128
+ db.prepare(
129
+ 'DELETE FROM delivery_attempts WHERE cursor_id = ? AND event_id = ?'
130
+ ).run(cursorId, eventId);
131
+ }
132
+
133
+ function moveToDeadLetter(db, cursorId, subscriptionId, event, attempts, reason) {
134
+ db.prepare(`
135
+ INSERT INTO dead_letters (
136
+ cursor_id, subscription_id, event_id, event_type, domain, subdomain,
137
+ payload, emitted_at, attempts, last_error, dead_lettered_at
138
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
139
+ `).run(
140
+ cursorId,
141
+ subscriptionId,
142
+ event.event_id,
143
+ event.event_type,
144
+ event.domain,
145
+ event.subdomain ?? '',
146
+ typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload),
147
+ event.emitted_at,
148
+ attempts,
149
+ reason ?? null,
150
+ Date.now()
151
+ );
152
+ }
153
+
154
+ function parseEvent(row) {
155
+ let payload = row.payload;
156
+ if (typeof payload === 'string') {
157
+ try { payload = JSON.parse(payload); } catch (_) { /* leave as string */ }
158
+ }
159
+ return { ...row, payload };
160
+ }
161
+
162
+ /**
163
+ * Compute lag for a cursor — how far behind the head of the matching stream.
164
+ * @returns {{ cursor_lag: number, oldest_unacked_age_ms: number|null, dlq_count: number }}
165
+ */
166
+ function computeLag(db, cursorId) {
167
+ const cursor = db.prepare(
168
+ 'SELECT * FROM cursors WHERE cursor_id = ?'
169
+ ).get(cursorId);
170
+ if (!cursor) {
171
+ return { cursor_lag: 0, oldest_unacked_age_ms: null, dlq_count: 0 };
172
+ }
173
+
174
+ const sub = db.prepare(
175
+ 'SELECT * FROM subscriptions WHERE subscription_id = ?'
176
+ ).get(cursor.subscription_id);
177
+ if (!sub) {
178
+ return { cursor_lag: 0, oldest_unacked_age_ms: null, dlq_count: 0 };
179
+ }
180
+
181
+ // Reuse poll() filter compilation by counting matching events ahead of cursor.
182
+ // We approximate: count rows where event_id > last_event_id and not expired.
183
+ // Filter matching is handled in a sub-query below.
184
+ const head = db.prepare(
185
+ 'SELECT MAX(event_id) as max_id FROM events'
186
+ ).get();
187
+ const maxId = head && head.max_id != null ? head.max_id : 0;
188
+ const cursorLag = Math.max(0, maxId - cursor.last_event_id);
189
+
190
+ // Oldest unacked age is best-effort: emitted_at of the lowest unacked event
191
+ // that the cursor would actually receive on next poll. We skip the filter
192
+ // join here for simplicity and use the same filtered poll() shape via
193
+ // event_id > last_event_id and expires_at > now.
194
+ const now = Date.now();
195
+ const oldest = db.prepare(`
196
+ SELECT MIN(emitted_at) as oldest
197
+ FROM events
198
+ WHERE event_id > ? AND expires_at > ?
199
+ `).get(cursor.last_event_id, now);
200
+ const oldestUnackedAgeMs = oldest && oldest.oldest != null ? now - oldest.oldest : null;
201
+
202
+ const dlq = db.prepare(
203
+ 'SELECT COUNT(*) as c FROM dead_letters WHERE cursor_id = ?'
204
+ ).get(cursorId);
205
+ const dlqCount = dlq ? dlq.c : 0;
206
+
207
+ return {
208
+ cursor_lag: cursorLag,
209
+ oldest_unacked_age_ms: oldestUnackedAgeMs,
210
+ dlq_count: dlqCount,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Subscribe to events with a managed loop, retry, DLQ, and lifecycle.
216
+ *
217
+ * @param {object} opts
218
+ * @param {import('better-sqlite3').Database} opts.db - Open DB handle (required)
219
+ * @param {string} opts.plugin - Subscriber plugin identity
220
+ * @param {string} opts.filter - Event type filter (e.g. 'wicked.fact.extracted.*')
221
+ * @param {(event: object) => void|Promise<void>} opts.handler - Event handler; throws to retry
222
+ * @param {'latest'|'oldest'} [opts.cursor_init='latest'] - Only used on first registration
223
+ * @param {number} [opts.pollIntervalMs=15000]
224
+ * @param {number} [opts.batchSize=50]
225
+ * @param {number} [opts.maxRetries=0] - 0 = fail-fast (advance cursor on first failure)
226
+ * @param {number|number[]} [opts.backoffMs=1000] - Number = constant; array repeats last element
227
+ * @param {number} [opts.lagIntervalMs=60000] - onLag callback cadence (independent of poll)
228
+ * @param {(err: Error, event: object) => void} [opts.onError]
229
+ * @param {(event: object, reason: string) => void} [opts.onDeadLetter]
230
+ * @param {(lag: object) => void} [opts.onLag]
231
+ *
232
+ * @returns {{ stop: () => Promise<void>, getLag: () => object, cursor_id: string, subscription_id: string }}
233
+ */
234
+ export function subscribe(opts) {
235
+ if (!opts || !opts.db) throw new TypeError('subscribe: opts.db is required');
236
+ if (!opts.plugin) throw new TypeError('subscribe: opts.plugin is required');
237
+ if (!opts.filter) throw new TypeError('subscribe: opts.filter is required');
238
+ if (typeof opts.handler !== 'function') {
239
+ throw new TypeError('subscribe: opts.handler must be a function');
240
+ }
241
+
242
+ const db = opts.db;
243
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
244
+ const batchSize = opts.batchSize ?? DEFAULTS.batchSize;
245
+ const maxRetries = opts.maxRetries ?? DEFAULTS.maxRetries;
246
+ const backoffMs = normalizeBackoff(opts.backoffMs, maxRetries);
247
+ const lagIntervalMs = opts.lagIntervalMs ?? DEFAULTS.lagIntervalMs;
248
+
249
+ const { subscription_id, cursor_id } = registerOrResume(db, {
250
+ plugin: opts.plugin,
251
+ filter: opts.filter,
252
+ cursor_init: opts.cursor_init || DEFAULTS.cursor_init,
253
+ });
254
+
255
+ // ── Loop state ────────────────────────────────────────────────────────────
256
+ let stopping = false;
257
+ let stopPromise = null;
258
+ let resolveStop = null;
259
+ let pollTimer = null;
260
+ let lagTimer = null;
261
+ let backoffTimer = null;
262
+ let cancelBackoff = null;
263
+ let loopActive = false;
264
+
265
+ function safeCallback(cb, ...args) {
266
+ if (typeof cb !== 'function') return;
267
+ try { cb(...args); } catch (_) { /* user callback errors are swallowed */ }
268
+ }
269
+
270
+ function sleepCancelable(ms) {
271
+ return new Promise(resolve => {
272
+ backoffTimer = setTimeout(() => {
273
+ backoffTimer = null;
274
+ cancelBackoff = null;
275
+ resolve(true);
276
+ }, ms);
277
+ cancelBackoff = () => {
278
+ if (backoffTimer) {
279
+ clearTimeout(backoffTimer);
280
+ backoffTimer = null;
281
+ }
282
+ cancelBackoff = null;
283
+ resolve(false);
284
+ };
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Process a single event with retry/DLQ semantics.
290
+ * Returns true when the cursor was advanced (success or DLQ), false if the
291
+ * loop should bail without advancing (only on shutdown mid-handler).
292
+ */
293
+ async function processEvent(event) {
294
+ let attempts = getAttempts(db, cursor_id, event.event_id);
295
+ const parsed = parseEvent(event);
296
+
297
+ // eslint-disable-next-line no-constant-condition
298
+ while (true) {
299
+ if (stopping && attempts > 0) {
300
+ // Mid-retry shutdown: DLQ and advance.
301
+ moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, 'shutdown during backoff');
302
+ deleteDeliveryAttempt(db, cursor_id, event.event_id);
303
+ ack(db, cursor_id, event.event_id);
304
+ safeCallback(opts.onDeadLetter, parsed, 'shutdown during backoff');
305
+ return true;
306
+ }
307
+
308
+ try {
309
+ await opts.handler(parsed);
310
+ deleteDeliveryAttempt(db, cursor_id, event.event_id);
311
+ ack(db, cursor_id, event.event_id);
312
+ return true;
313
+ } catch (err) {
314
+ attempts += 1;
315
+ safeCallback(opts.onError, err, parsed);
316
+
317
+ if (attempts > maxRetries) {
318
+ moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, err.message);
319
+ deleteDeliveryAttempt(db, cursor_id, event.event_id);
320
+ ack(db, cursor_id, event.event_id);
321
+ safeCallback(opts.onDeadLetter, parsed, err.message);
322
+ return true;
323
+ }
324
+
325
+ upsertDeliveryAttempt(db, cursor_id, event.event_id, attempts, err.message);
326
+ const sleepMs = backoffMs[Math.min(attempts - 1, backoffMs.length - 1)];
327
+ const completed = await sleepCancelable(sleepMs);
328
+ if (!completed) {
329
+ // stop() interrupted the backoff
330
+ moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, 'shutdown during backoff');
331
+ deleteDeliveryAttempt(db, cursor_id, event.event_id);
332
+ ack(db, cursor_id, event.event_id);
333
+ safeCallback(opts.onDeadLetter, parsed, 'shutdown during backoff');
334
+ return true;
335
+ }
336
+ // loop again — retry handler
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Drain pending replays for this cursor before normal polling.
343
+ * Each replay is a single attempt — no retry semantics. Success deletes the
344
+ * DLQ row; failure clears replay_requested_at and updates attempts / last_error.
345
+ */
346
+ async function drainReplays() {
347
+ while (!stopping) {
348
+ const row = db.prepare(`
349
+ SELECT * FROM dead_letters
350
+ WHERE cursor_id = ? AND replay_requested_at IS NOT NULL
351
+ ORDER BY dl_id ASC
352
+ LIMIT 1
353
+ `).get(cursor_id);
354
+ if (!row) return;
355
+
356
+ const parsed = parseEvent(row);
357
+ try {
358
+ await opts.handler(parsed);
359
+ db.prepare('DELETE FROM dead_letters WHERE dl_id = ?').run(row.dl_id);
360
+ } catch (err) {
361
+ safeCallback(opts.onError, err, parsed);
362
+ db.prepare(`
363
+ UPDATE dead_letters
364
+ SET replay_requested_at = NULL,
365
+ attempts = attempts + 1,
366
+ last_error = ?
367
+ WHERE dl_id = ?
368
+ `).run(err.message, row.dl_id);
369
+ // Stop draining on failure; operator can re-replay after fixing
370
+ return;
371
+ }
372
+ }
373
+ }
374
+
375
+ async function tick() {
376
+ if (stopping || loopActive) return;
377
+ loopActive = true;
378
+ try {
379
+ await drainReplays();
380
+ if (stopping) return;
381
+
382
+ const events = poll(db, cursor_id, { batchSize });
383
+ for (const event of events) {
384
+ if (stopping) break;
385
+ await processEvent(event);
386
+ }
387
+ } catch (err) {
388
+ // Polling errors (WB-003, WB-006) bubble through onError so operators
389
+ // see them. The loop continues — the next tick will retry.
390
+ safeCallback(opts.onError, err, null);
391
+ } finally {
392
+ loopActive = false;
393
+ if (!stopping) {
394
+ pollTimer = setTimeout(tick, pollIntervalMs);
395
+ } else if (resolveStop) {
396
+ finalizeStop();
397
+ }
398
+ }
399
+ }
400
+
401
+ function finalizeStop() {
402
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
403
+ if (lagTimer) { clearInterval(lagTimer); lagTimer = null; }
404
+ if (resolveStop) {
405
+ const r = resolveStop;
406
+ resolveStop = null;
407
+ r();
408
+ }
409
+ }
410
+
411
+ async function stop() {
412
+ if (stopPromise) return stopPromise;
413
+ stopping = true;
414
+ stopPromise = new Promise(resolve => { resolveStop = resolve; });
415
+
416
+ if (cancelBackoff) cancelBackoff();
417
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
418
+ if (lagTimer) { clearInterval(lagTimer); lagTimer = null; }
419
+
420
+ if (!loopActive) {
421
+ // Nothing in flight — resolve immediately
422
+ finalizeStop();
423
+ }
424
+ // Otherwise tick()'s finally block will resolve once the in-flight handler
425
+ // (or backoff cancellation) completes.
426
+
427
+ return stopPromise;
428
+ }
429
+
430
+ function getLag() {
431
+ return computeLag(db, cursor_id);
432
+ }
433
+
434
+ // ── Start the loop ────────────────────────────────────────────────────────
435
+ // First tick on next macrotask so the caller can attach handlers / store the
436
+ // returned handle before the loop begins.
437
+ pollTimer = setTimeout(tick, 0);
438
+
439
+ if (typeof opts.onLag === 'function') {
440
+ lagTimer = setInterval(() => {
441
+ safeCallback(opts.onLag, computeLag(db, cursor_id));
442
+ }, lagIntervalMs);
443
+ // Don't keep the event loop alive for lag callbacks alone
444
+ if (lagTimer.unref) lagTimer.unref();
445
+ }
446
+
447
+ return { stop, getLag, cursor_id, subscription_id };
448
+ }
449
+
450
+ // Internal export for testing / advanced callers that want resume semantics
451
+ // without the full managed loop.
452
+ export { registerOrResume };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-bus",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight, local-first SQLite event bus for AI agents and developer tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,5 +52,13 @@
52
52
  "cursor-poll",
53
53
  "at-least-once"
54
54
  ],
55
- "license": "MIT"
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/mikeparcewski/wicked-bus.git"
59
+ },
60
+ "homepage": "https://github.com/mikeparcewski/wicked-bus#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/mikeparcewski/wicked-bus/issues"
63
+ }
56
64
  }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: wicked-bus:emit
2
3
  description: Emit events to the wicked-bus. Use when publishing events from a plugin, logging activity to the bus, or integrating a new system with the event bridge. Covers both programmatic (Node.js) and CLI usage.
3
4
  ---
4
5
 
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: wicked-bus:init
2
3
  description: Initialize wicked-bus or connect to an existing instance. Use when setting up the bus for the first time, checking if it's running, or configuring a project to use it. Auto-triggered when any wicked-bus skill detects no config.
3
4
  ---
4
5
 
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: wicked-bus:naming
2
3
  description: Guide for naming wicked-bus events — helps choose event_type, domain, and subdomain when emitting events. Use when creating new events, integrating a plugin with the bus, or reviewing event naming for consistency.
3
4
  ---
4
5
 
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: wicked-bus:query
2
3
  description: Query and debug the wicked-bus. Use when checking bus health, inspecting events, debugging delivery issues, tracing event flow, or investigating why a subscriber isn't receiving events. Covers status, replay, and direct SQLite queries.
3
4
  ---
4
5
 
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: wicked-bus:status
3
+ description: |
4
+ Show wicked-bus health, statistics, and diagnostics. Event counts,
5
+ subscriber lag, provider list, database size, and configuration.
6
+
7
+ Use when: "bus status", "is the bus healthy", "how many events",
8
+ "show bus stats", or when diagnosing delivery issues.
9
+ ---
10
+
11
+ # wicked-bus:status
12
+
13
+ Show the current state of the wicked-bus.
14
+
15
+ ## When to use
16
+
17
+ - User asks about bus health or status
18
+ - Before debugging delivery issues
19
+ - Checking if the bus is initialized and has data
20
+ - Monitoring subscriber lag
21
+
22
+ ## Process
23
+
24
+ ### Step 1: Check if bus is initialized
25
+
26
+ ```bash
27
+ npx wicked-bus status 2>/dev/null
28
+ ```
29
+
30
+ If this fails, the bus isn't initialized. Suggest running `wicked-bus/init`.
31
+
32
+ ### Step 2: Parse and display status
33
+
34
+ The `status` command returns JSON. Display it in a readable format:
35
+
36
+ ```markdown
37
+ ## wicked-bus Status
38
+
39
+ **Database**: {db_path}
40
+ **Events**: {total_events} total, {active_events} active (not expired)
41
+ **Providers**: {provider_count} registered
42
+ **Subscribers**: {subscriber_count} active
43
+
44
+ ### Subscriber Lag
45
+ | Plugin | Filter | Cursor | Latest | Lag |
46
+ |--------|--------|--------|--------|-----|
47
+ | {plugin} | {filter} | {last_event_id} | {max_event_id} | {lag} |
48
+ ```
49
+
50
+ ### Step 3: Extended diagnostics (if requested)
51
+
52
+ If the user wants deeper diagnostics, query SQLite directly:
53
+
54
+ **Event distribution by type:**
55
+ ```bash
56
+ sqlite3 ~/.something-wicked/wicked-bus/bus.db \
57
+ "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 20;"
58
+ ```
59
+
60
+ **Event distribution by domain:**
61
+ ```bash
62
+ sqlite3 ~/.something-wicked/wicked-bus/bus.db \
63
+ "SELECT domain, COUNT(*) as count FROM events GROUP BY domain ORDER BY count DESC;"
64
+ ```
65
+
66
+ **Database size:**
67
+ ```bash
68
+ ls -lh ~/.something-wicked/wicked-bus/bus.db
69
+ ```
70
+
71
+ **WAL size (if checkpointing is lagging):**
72
+ ```bash
73
+ ls -lh ~/.something-wicked/wicked-bus/bus.db-wal 2>/dev/null
74
+ ```
75
+
76
+ **Events per hour (last 24h):**
77
+ ```bash
78
+ sqlite3 ~/.something-wicked/wicked-bus/bus.db \
79
+ "SELECT strftime('%Y-%m-%d %H:00', emitted_at/1000, 'unixepoch') as hour, COUNT(*) as count FROM events WHERE emitted_at > (strftime('%s','now')-86400)*1000 GROUP BY hour ORDER BY hour;"
80
+ ```
81
+
82
+ **Oldest and newest events:**
83
+ ```bash
84
+ sqlite3 ~/.something-wicked/wicked-bus/bus.db \
85
+ "SELECT 'oldest' as which, event_id, event_type, datetime(emitted_at/1000, 'unixepoch') as time FROM events ORDER BY event_id ASC LIMIT 1 UNION ALL SELECT 'newest', event_id, event_type, datetime(emitted_at/1000, 'unixepoch') FROM events ORDER BY event_id DESC LIMIT 1;"
86
+ ```
87
+
88
+ **Deregistered subscriptions:**
89
+ ```bash
90
+ npx wicked-bus list --include-deregistered --json
91
+ ```
92
+
93
+ **Configuration:**
94
+ ```bash
95
+ cat ~/.something-wicked/wicked-bus/config.json
96
+ ```
97
+
98
+ If `WICKED_BUS_DATA_DIR` is set, use that path instead of the default.
99
+
100
+ ### Step 4: Health warnings
101
+
102
+ Flag these issues if detected:
103
+
104
+ - **High lag** (cursor > 100 events behind): subscriber may be failing to poll
105
+ - **No recent events** (nothing in last 24h): producers may have stopped
106
+ - **Large WAL file** (> 10 MB): checkpointing may be blocked
107
+ - **Deregistered subscribers with active cursors**: orphaned cursors consuming space
108
+ - **Events near dedup_expires_at**: about to be swept — subscribers should poll soon
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: wicked-bus:subscribe
2
3
  description: Subscribe to wicked-bus events. Use when consuming events from the bus, setting up event listeners, polling for new events, or integrating as a subscriber. Covers registration, polling, acknowledgment, and filter patterns.
3
4
  ---
4
5
 
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: wicked-bus:update
3
+ description: |
4
+ Check for and install wicked-bus updates. Compares installed version against
5
+ npm registry, updates skills across all detected CLIs.
6
+
7
+ Use when: "update wicked-bus", "check for bus updates", "wicked-bus update",
8
+ or periodically to stay current.
9
+ ---
10
+
11
+ # wicked-bus:update
12
+
13
+ Check for and install updates to wicked-bus and its skills.
14
+
15
+ ## Cross-Platform Notes
16
+
17
+ Commands work on macOS, Linux, and Windows. Use agent-native tools
18
+ (Read, Write, Grep, Glob) over shell commands when possible.
19
+
20
+ ## When to use
21
+
22
+ - User asks to update or check for updates
23
+ - After encountering unexpected behavior that might be fixed in a newer version
24
+ - Periodically (suggest checking monthly)
25
+
26
+ ## Process
27
+
28
+ ### Step 1: Check current installed version
29
+
30
+ Check both global and local installations:
31
+
32
+ ```bash
33
+ npm list -g wicked-bus --json 2>/dev/null | python3 -c "
34
+ import json, sys
35
+ try:
36
+ d = json.load(sys.stdin)
37
+ deps = d.get('dependencies', {})
38
+ v = deps.get('wicked-bus', {}).get('version', 'not installed')
39
+ print(v)
40
+ except Exception:
41
+ print('not installed')
42
+ " 2>/dev/null || python -c "
43
+ import json, sys
44
+ try:
45
+ d = json.load(sys.stdin)
46
+ deps = d.get('dependencies', {})
47
+ v = deps.get('wicked-bus', {}).get('version', 'not installed')
48
+ print(v)
49
+ except Exception:
50
+ print('not installed')
51
+ "
52
+ ```
53
+
54
+ Also check local (project-level) install:
55
+ ```bash
56
+ npm list wicked-bus --json 2>/dev/null
57
+ ```
58
+
59
+ ### Step 2: Check latest version on npm
60
+
61
+ ```bash
62
+ npm view wicked-bus version 2>/dev/null
63
+ ```
64
+
65
+ ### Step 3: Compare versions
66
+
67
+ If installed version matches latest:
68
+ "wicked-bus is up to date (v{version})."
69
+
70
+ If an update is available:
71
+ "wicked-bus v{new} is available (you have v{current}). Update now?"
72
+
73
+ ### Step 4: Update (if user approves)
74
+
75
+ For global install:
76
+ ```bash
77
+ npm install -g wicked-bus@latest 2>&1
78
+ ```
79
+
80
+ For local (project) install:
81
+ ```bash
82
+ npm install wicked-bus@latest 2>&1
83
+ ```
84
+
85
+ If `EACCES` / permission denied:
86
+ - macOS/Linux: `sudo npm install -g wicked-bus@latest`
87
+ - Windows: re-run shell as Administrator
88
+ - Report the failure — do NOT silently skip
89
+
90
+ ### Step 5: Refresh skills in all CLIs
91
+
92
+ After updating the package, run the installer to copy updated skills:
93
+
94
+ ```bash
95
+ npx wicked-bus-install
96
+ ```
97
+
98
+ Or with a specific CLI target:
99
+ ```bash
100
+ npx wicked-bus-install --cli=claude
101
+ ```
102
+
103
+ ### Step 6: Verify
104
+
105
+ Re-run the Step 1 version check. Confirm the version matches latest.
106
+
107
+ If it still shows the old version:
108
+ 1. Check `which wicked-bus` (macOS/Linux) or `where wicked-bus` (Windows)
109
+ 2. Clear npm cache: `npm cache clean --force`
110
+ 3. Check if nvm/fnm/volta is pinning a stale copy
111
+
112
+ ### Step 7: Report
113
+
114
+ ```
115
+ wicked-bus updated: v{old} → v{new}
116
+ Skills refreshed in {N} CLIs: {list}
117
+ ```
118
+
119
+ ## Version check without updating
120
+
121
+ If the user just wants to check, stop after Step 3 and report
122
+ current vs. available version.