skillrepo 4.8.4 → 4.9.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
@@ -370,7 +370,7 @@ By default `skillrepo init` prompts you to install this hook. If you said no (or
370
370
 
371
371
  #### Auto-refresh hooks for other agents
372
372
 
373
- For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library refreshes on every session start without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
373
+ For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library stays current across your sessions without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
374
374
 
375
375
  | Agent | Hook config path | Notes |
376
376
  |-------|------------------|-------|
@@ -393,6 +393,8 @@ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents
393
393
 
394
394
  **On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
395
395
 
396
+ **Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled (it always queries the server live), though it doesn't reset the window.
397
+
396
398
  Flags:
397
399
 
398
400
  - `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.8.4",
3
+ "version": "4.9.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,10 @@
20
20
  * full contract. When this flag is absent, the
21
21
  * command behaves as before (exit non-zero on
22
22
  * network / auth / disk failures).
23
+ * Throttled (#2174): within MIN_SYNC_INTERVAL_MS of the
24
+ * last sync attempt this makes ZERO network calls (the
25
+ * hook fires every session; a bare `update` is never
26
+ * throttled).
23
27
  *
24
28
  * v4.1.0 silent mode (#1240):
25
29
  * --silent Suppress stdout: write `{}` on success, propagate
@@ -144,6 +148,11 @@ export async function runUpdate(argv, io = {}) {
144
148
  apiKey: flags.apiKey,
145
149
  vendors,
146
150
  global: flags.global,
151
+ // Throttle SessionStart syncs (#2174): this hook fires on every
152
+ // Claude Code session, so within MIN_SYNC_INTERVAL_MS of the last
153
+ // attempt runSync short-circuits with zero network calls. ONLY hook
154
+ // invocations pass this — a bare `skillrepo update` always syncs.
155
+ throttle: true,
147
156
  io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
148
157
  });
149
158
  const total = summary.added + summary.updated + summary.removed;
@@ -218,6 +227,10 @@ export async function runUpdate(argv, io = {}) {
218
227
  apiKey: flags.apiKey,
219
228
  vendors,
220
229
  global: flags.global,
230
+ // Throttle the cohort SessionStart hook too (#2174) — same reasoning
231
+ // as --session-hook: it fires every session, so skip the network
232
+ // within MIN_SYNC_INTERVAL_MS of the last attempt.
233
+ throttle: true,
221
234
  // sync.mjs surfaces non-fatal warnings (e.g. failed to persist
222
235
  // last-sync state) via stderr; preserve that channel so a real
223
236
  // operator running `update --silent` from a terminal can still
package/src/lib/sync.mjs CHANGED
@@ -36,20 +36,34 @@
36
36
  * absence is meaningful (a state file with no `cliVersion` was
37
37
  * written by a pre-#1911 CLI and triggers the one-time membership
38
38
  * heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
39
+ * v3 — Adds `lastAttemptAt` (#2174): an ISO timestamp of the last
40
+ * network sync ATTEMPT, powering the SessionStart staleness
41
+ * throttle in `runSync`. Stamped on the 304 short-circuit and on
42
+ * every successful write; NOT stamped on a throttled skip (so a
43
+ * run of quick restarts can't push the clock forward and starve
44
+ * the sync). A v2 file (no `lastAttemptAt`) reads as "never
45
+ * attempted" and syncs — additive, mirroring how absent
46
+ * `cliVersion` was handled.
39
47
  *
40
48
  * Forward/backward compatibility
41
49
  * ------------------------------
42
- * • Old CLI reading a v2 file unknown schemaVersion nullfull
43
- * syncwrites v1 file. User loses per-skill metadata until they
44
- * upgrade again.
45
- * New CLI reading a v1 file → in-memory migration to a v2 shape
46
- * with the existing `etag` + `syncedAt` preserved and an EMPTY
47
- * `skills` map. The next successful sync writes a v2 file on
48
- * disk. This is safe because an empty map means "we don't know
50
+ * • Old CLI reading a NEWER file (e.g. a v2 CLI reading v3) unknown
51
+ * schemaVersionnull full sync writes its own older shape. The
52
+ * user loses the newer fields until they upgrade again. Harmless: a
53
+ * full re-fetch is idempotent.
54
+ * New CLI reading a v1 file in-memory migration to the current
55
+ * shape with the existing `etag` + `syncedAt` preserved and an EMPTY
56
+ * `skills` map. The next successful sync writes a current-schema file
57
+ * on disk. This is safe because an empty map means "we don't know
49
58
  * per-skill state from this sync" — callers must treat absent
50
59
  * entries as unknown, not as "missing from library." Preserving
51
60
  * the etag also keeps the 304 short-circuit working, so an
52
61
  * upgrade-then-no-changes path stays cheap.
62
+ * • New CLI reading a v2 file → accepted in place (v2 is v3 minus
63
+ * `lastAttemptAt`): etag/syncedAt/skills/cliVersion are preserved so
64
+ * the first post-upgrade sync stays a cheap 304, and the absent
65
+ * `lastAttemptAt` makes the throttle treat it as "never attempted"
66
+ * so that first sync proceeds and then stamps a v3 file.
53
67
  * • Future unknown schemaVersion → null → full sync. Forward-compat
54
68
  * without crashing.
55
69
  *
@@ -58,6 +72,18 @@
58
72
  * @property {number} updated - Skills overwritten on disk
59
73
  * @property {number} removed - Tombstones applied
60
74
  * @property {boolean} notModified - True if 304 short-circuit fired
75
+ * @property {boolean} [throttled] - True when the SessionStart staleness
76
+ * throttle skipped this run entirely — no
77
+ * library GET, no receipt POST (#2174).
78
+ * Only set on the throttled early-exit;
79
+ * absent (falsy) on every other path.
80
+ * NOTE: no production caller branches on
81
+ * this today (hook printers key on the
82
+ * zero counters) — it is the contract
83
+ * discriminator that lets tests tell a
84
+ * throttled skip from a 304, mirroring
85
+ * the `minSyncIntervalMs` test-seam
86
+ * pattern.
61
87
  * @property {boolean | null} fullSync - True if this was a full (non-delta)
62
88
  * sync — i.e. no prior `.last-sync` state
63
89
  * existed so no `since` was sent. False if
@@ -73,7 +99,6 @@
73
99
  * Consumers must distinguish these to
74
100
  * render accurate user messages (see
75
101
  * init.mjs step 7).
76
- * @property {string} [syncedAt]
77
102
  * @property {string} syncedAt - ISO timestamp of the sync: the server
78
103
  * response `syncedAt` on a 200, or the
79
104
  * previously-cached sync timestamp on a
@@ -97,6 +122,10 @@
97
122
  * wrote this state. Absent on files written
98
123
  * by a pre-#1911 CLI; absence triggers the
99
124
  * one-time membership heal.
125
+ * @property {string} [lastAttemptAt] - v3+ (#2174). ISO timestamp of the last
126
+ * network sync attempt. Absent on a v2 file
127
+ * (or any pre-v3 writer) → the throttle
128
+ * treats it as "never attempted" and syncs.
100
129
  * @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
101
130
  */
102
131
 
@@ -127,8 +156,49 @@ import { computeSkillShas } from "./crypto-shas.mjs";
127
156
  *
128
157
  * v2 (#1553): adds `skills` map keyed by `"<owner>/<name>"` →
129
158
  * `{ version, skillMdSha256, filesSha256, syncedAt }`.
159
+ * v3 (#2174): adds `lastAttemptAt` — an ISO timestamp of the last
160
+ * network sync ATTEMPT (stamped on the 304 short-circuit and on every
161
+ * successful write, NOT on a throttled skip). Powers the SessionStart
162
+ * staleness throttle. Additive: a v2 file (no `lastAttemptAt`) reads as
163
+ * "never attempted" and syncs, so the bump is forward-compatible and a
164
+ * v2 file is accepted in place — its etag/skills are preserved (no
165
+ * forced re-fetch, unlike the v1→v2 migration).
130
166
  */
131
- export const LAST_SYNC_SCHEMA_VERSION = 2;
167
+ export const LAST_SYNC_SCHEMA_VERSION = 3;
168
+
169
+ /**
170
+ * Client-side SessionStart sync throttle window (#2174).
171
+ *
172
+ * A hook-triggered `skillrepo update` (the `--session-hook` and
173
+ * `--silent` cohort hooks) skips the network entirely — NO library GET,
174
+ * NO sync-receipt POST — when the last sync ATTEMPT was less than this
175
+ * long ago. This caps the highest-QPS uncacheable vector: the library
176
+ * sync fires on every editor session, and at 100k users the per-request
177
+ * cost (auth + rate-limit + per-account ETag query + heartbeat, then a
178
+ * second request for the receipt) dominates even on a 304. See #2170.
179
+ *
180
+ * SCOPE: hook invocations ONLY. A bare interactive `skillrepo update`
181
+ * always syncs (explicit intent), and it too stamps `lastAttemptAt`, so
182
+ * a manual sync resets the window for subsequent hook syncs.
183
+ *
184
+ * FRESHNESS: this bounds how stale a hook-skipped session can be to at
185
+ * most this window, measured from the last ACTUAL sync — NOT from a
186
+ * publish time, and NOT between raw session starts (a skipped session
187
+ * does not advance the clock, so continuous restarts can't starve the
188
+ * sync). Sessions spaced further apart than this window always sync.
189
+ * The SAME bound applies to LOCAL damage, not just server-side changes:
190
+ * a throttled skip returns before the placement-presence check below,
191
+ * so a skill directory the user deleted/corrupted on disk is repaired
192
+ * on the next non-throttled sync — up to this window later, or
193
+ * immediately via an interactive `update` (never throttled). Pre-#2174
194
+ * every session start repaired placements immediately; "my skill
195
+ * briefly disappeared after I deleted its folder" within this window
196
+ * is expected behavior, not a placement-check bug.
197
+ *
198
+ * 15 minutes (#2174, product-confirmed). Override per-call via
199
+ * `runSync({ minSyncIntervalMs })` — a test seam used to pin the boundary.
200
+ */
201
+ export const MIN_SYNC_INTERVAL_MS = 15 * 60 * 1000;
132
202
 
133
203
  /**
134
204
  * One-time heal floor (#1911).
@@ -317,11 +387,12 @@ export function readLastSync() {
317
387
  return null;
318
388
  }
319
389
 
320
- // v1 → v2 in-memory migration. Preserve the etag and syncedAt so
321
- // delta sync still benefits from the prior state; start with an
322
- // empty `skills` map. The next successful runSync writes v2 to
323
- // disk. We do NOT write here readers should be pure with
324
- // respect to the filesystem.
390
+ // v1 → current-schema in-memory migration. Preserve the etag and
391
+ // syncedAt so delta sync still benefits from the prior state; start
392
+ // with an empty `skills` map (and no `lastAttemptAt`, so the throttle
393
+ // treats this as "never attempted"). The next successful runSync
394
+ // writes the current schema to disk. We do NOT write here — readers
395
+ // should be pure with respect to the filesystem.
325
396
  if (parsed.schemaVersion === 1) {
326
397
  return {
327
398
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
@@ -331,7 +402,24 @@ export function readLastSync() {
331
402
  };
332
403
  }
333
404
 
334
- if (parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION) {
405
+ // Accept the current schema AND the immediately-prior v2 (which is v3
406
+ // minus `lastAttemptAt`, #2174). A v2 file is read IN PLACE — its
407
+ // etag/syncedAt/skills/cliVersion are preserved, so the first
408
+ // post-upgrade sync stays a cheap 304 rather than a forced re-fetch;
409
+ // the absent `lastAttemptAt` makes the throttle treat it as "never
410
+ // attempted" so that first sync still proceeds, then writes a v3 file.
411
+ // Any OTHER version (v1 is migrated above; a future v4+ is unknown) →
412
+ // null → full sync. Forward-compat without crashing.
413
+ //
414
+ // MAINTAINER: the `!== 2` literal is the "accept the immediately-prior
415
+ // schema in place" branch — it is NOT auto-derived from
416
+ // LAST_SYNC_SCHEMA_VERSION. The next bump (v3 → v4) must add its own
417
+ // `!== 3` branch here (accept v3 in place; v2 then falls through to a
418
+ // full sync), the same way this branch was added for v2 → v3.
419
+ if (
420
+ parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION &&
421
+ parsed.schemaVersion !== 2
422
+ ) {
335
423
  return null;
336
424
  }
337
425
 
@@ -351,6 +439,16 @@ export function readLastSync() {
351
439
  parsed.skills = {};
352
440
  }
353
441
 
442
+ // `lastAttemptAt` (v3+, #2174) must be an ISO string to be usable by
443
+ // the throttle. A v2 file omits it; a malformed value (number, object,
444
+ // truncated write) is coerced to absent so the throttle treats the
445
+ // state as "never attempted" and syncs rather than trusting a bogus
446
+ // clock. `Date.parse` in runSync is a second guard, but normalizing
447
+ // here keeps the returned shape honest for every caller.
448
+ if (typeof parsed.lastAttemptAt !== "string") {
449
+ delete parsed.lastAttemptAt;
450
+ }
451
+
354
452
  return parsed;
355
453
  }
356
454
 
@@ -375,19 +473,31 @@ export function readLastSync() {
375
473
  * never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
376
474
  * only by tests to simulate a pre-fix writer); production callers omit it.
377
475
  *
476
+ * v3 (#2174): `lastAttemptAt` is the ISO timestamp of the last network
477
+ * sync attempt, used by the throttle. It is NOT auto-stamped: callers pass
478
+ * it explicitly so intent is unambiguous. `runSync` passes "now" on the 304
479
+ * and successful-write paths; the single-skill helpers (`upsertLastSyncEntry`
480
+ * / `deleteLastSyncEntry`) preserve the prior value verbatim because a
481
+ * single-skill add/remove is not a full-library sync and must not advance
482
+ * the throttle clock. Omitting it persists `null` — an explicit "never
483
+ * attempted" that never wrongly throttles the next sync.
484
+ *
378
485
  * @param {object} state
379
486
  * @param {string | null} [state.etag]
380
487
  * @param {string} [state.syncedAt]
381
488
  * @param {Record<string, SyncedSkillEntry>} [state.skills]
382
489
  * @param {string | null} [state.cliVersion] - Override the writer-version
383
490
  * stamp. Defaults to the running CLI's version.
491
+ * @param {string | null} [state.lastAttemptAt] - ISO timestamp of the last
492
+ * sync attempt (#2174). Omitting it persists `null`.
384
493
  */
385
- export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
494
+ export function writeLastSync({ etag, syncedAt, skills, cliVersion, lastAttemptAt } = {}) {
386
495
  const path = globalLastSyncPath();
387
496
  const body = {
388
497
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
389
498
  etag: etag ?? null,
390
499
  syncedAt: syncedAt ?? new Date().toISOString(),
500
+ lastAttemptAt: lastAttemptAt ?? null,
391
501
  cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
392
502
  skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
393
503
  };
@@ -480,6 +590,11 @@ export function upsertLastSyncEntry(skill) {
480
590
  // carries a pre-fix file's absent version forward as an explicit null
481
591
  // so the next `update` still heals. Same rationale as etag/syncedAt.
482
592
  cliVersion: prior?.cliVersion ?? null,
593
+ // Preserve the throttle clock (#2174) — a single-skill `add`/`get` is
594
+ // not a full-library sync, so it must NOT advance `lastAttemptAt` (that
595
+ // would wrongly throttle the next SessionStart sync). Same rationale as
596
+ // cliVersion above.
597
+ lastAttemptAt: prior?.lastAttemptAt ?? null,
483
598
  });
484
599
  }
485
600
 
@@ -507,6 +622,9 @@ export function deleteLastSyncEntry(owner, name) {
507
622
  // reconciliation, so it must not advance the one-time heal gate.
508
623
  // See upsertLastSyncEntry for the full rationale.
509
624
  cliVersion: prior.cliVersion ?? null,
625
+ // Preserve the throttle clock (#2174) — `remove` is not a full sync,
626
+ // so it must not advance `lastAttemptAt`. Same rationale as cliVersion.
627
+ lastAttemptAt: prior.lastAttemptAt ?? null,
510
628
  });
511
629
  }
512
630
 
@@ -514,12 +632,16 @@ export function deleteLastSyncEntry(owner, name) {
514
632
  * Run a library sync against the server.
515
633
  *
516
634
  * Strategy:
517
- * 1. Clean up any orphan .tmp/.old directories from a crashed prior run
518
- * 2. Read the last-sync state file
635
+ * 1. Read the last-sync state, then apply the SessionStart throttle
636
+ * (#2174): a hook-triggered sync (`throttle: true`) within
637
+ * MIN_SYNC_INTERVAL_MS of the last attempt short-circuits HERE with
638
+ * ZERO network calls. A bare interactive `update` never throttles.
639
+ * 2. Clean up any orphan .tmp/.old directories from a crashed prior run
519
640
  * 3. Call GET /api/v1/library with `If-None-Match` (if we have a
520
641
  * cached ETag) AND `since` (always, for delta semantics)
521
642
  * 4. On 304 — re-assert the on-disk state via a receipt (liveness +
522
- * heal) and short-circuit with `notModified: true`
643
+ * heal), stamp the throttle clock (`lastAttemptAt`), and short-circuit
644
+ * with `notModified: true`
523
645
  * 5. For each skill in the response:
524
646
  * a. Write it via `writeSkillDir` — overwrites if changed
525
647
  * b. Skip writing skills with `filesIncomplete: true` (see comment)
@@ -546,10 +668,20 @@ export function deleteLastSyncEntry(owner, name) {
546
668
  * when `writeLastSync` fails uses io.stderr so tests that
547
669
  * inject a capture stream don't lose that output.
548
670
  * @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
671
+ * @param {boolean} [options.throttle] - Hook-triggered syncs ONLY
672
+ * (update.mjs's --session-hook / --silent branches) pass
673
+ * true. When the last attempt was within the interval, the
674
+ * whole sync short-circuits with zero network calls and a
675
+ * `throttled: true` summary (#2174). A bare interactive
676
+ * `skillrepo update` omits this and always syncs.
677
+ * @param {number} [options.minSyncIntervalMs] - Override the throttle window
678
+ * (defaults to MIN_SYNC_INTERVAL_MS). A test seam to pin the
679
+ * boundary; production callers omit it.
549
680
  * @returns {Promise<SyncSummary>}
550
681
  */
551
682
  export async function runSync(options) {
552
- const { serverUrl, apiKey, vendors, global, io } = options;
683
+ const { serverUrl, apiKey, vendors, global, io, throttle, minSyncIntervalMs } =
684
+ options;
553
685
  // Coalesce both `undefined` AND `null` to {}. Destructuring with
554
686
  // `io = {}` only handles `undefined`, so an explicit `io: null`
555
687
  // would otherwise blow up at the .stderr access below. Both
@@ -564,12 +696,70 @@ export async function runSync(options) {
564
696
  throw validationError("runSync: apiKey is required");
565
697
  }
566
698
 
567
- // Step 1: clean orphans from prior crashes BEFORE doing any new writes
568
- cleanupOrphans({ vendors, global });
569
-
570
- // Step 2: read prior state
699
+ // Read prior state FIRST the throttle decision below needs it, and
700
+ // reading one small JSON file is far cheaper than the orphan scan.
701
+ // Reordered ahead of cleanupOrphans (#2174) so a throttled hook sync
702
+ // does no filesystem walking either.
571
703
  const lastSync = readLastSync();
572
704
 
705
+ // SessionStart staleness throttle (#2174). Hook-triggered syncs
706
+ // (`throttle: true`, set ONLY by update.mjs's --session-hook and
707
+ // --silent branches) skip ALL network work — no library GET, no receipt
708
+ // POST — when the last ATTEMPT was within the interval. A bare
709
+ // interactive `skillrepo update` passes no `throttle`, so it always
710
+ // syncs (explicit intent). `lastAttemptAt` is absent on a fresh install
711
+ // and on a v2 file (never attempted) → those never throttle. A skipped
712
+ // run does NOT re-stamp `lastAttemptAt` (only an actual sync does), so
713
+ // continuous restarts can't push the clock forward and starve the sync:
714
+ // the window is measured from the last REAL sync, not the last session.
715
+ const intervalMs =
716
+ typeof minSyncIntervalMs === "number" && minSyncIntervalMs >= 0
717
+ ? minSyncIntervalMs
718
+ : MIN_SYNC_INTERVAL_MS;
719
+ if (throttle && lastSync && typeof lastSync.lastAttemptAt === "string") {
720
+ const lastAttemptMs = Date.parse(lastSync.lastAttemptAt);
721
+ const now = Date.now();
722
+ const sinceLastAttemptMs = now - lastAttemptMs;
723
+ // Guard BOTH ends of the window. A FUTURE `lastAttemptAt` (negative
724
+ // delta — backward clock jump, VM snapshot restore, hand-edited or
725
+ // corrupt state file) must SYNC, not throttle: a plain `< intervalMs`
726
+ // check stays true until the wall clock catches up to the bogus
727
+ // stamp, silently disabling the hook sync for that whole span (hook
728
+ // modes are contract-silent, so the user would never see it).
729
+ // Syncing instead re-stamps a sane "now" — one pass self-heals.
730
+ if (
731
+ Number.isFinite(lastAttemptMs) &&
732
+ sinceLastAttemptMs >= 0 &&
733
+ sinceLastAttemptMs < intervalMs
734
+ ) {
735
+ // SKILLREPO_VERBOSE gives the acceptance check a human-observable
736
+ // signal alongside the request-count spy — one dim stderr line only;
737
+ // stdout stays clean for the hook contracts.
738
+ if (process.env.SKILLREPO_VERBOSE) {
739
+ const agoS = Math.round(sinceLastAttemptMs / 1000);
740
+ const winS = Math.round(intervalMs / 1000);
741
+ stderr.write(
742
+ ` [skillrepo] sync throttled: last attempt ${agoS}s ago < ${winS}s window; skipping network.\n`,
743
+ );
744
+ }
745
+ return {
746
+ added: 0,
747
+ updated: 0,
748
+ removed: 0,
749
+ notModified: false,
750
+ throttled: true,
751
+ // Prior state existed (we had a lastAttemptAt) → never a first
752
+ // sync. Surface the cached syncedAt so consumers that read it
753
+ // (e.g. init.mjs) see the last known-good sync time.
754
+ fullSync: false,
755
+ syncedAt: lastSync.syncedAt ?? new Date(now).toISOString(),
756
+ };
757
+ }
758
+ }
759
+
760
+ // Step 2: clean orphans from prior crashes BEFORE doing any new writes
761
+ cleanupOrphans({ vendors, global });
762
+
573
763
  // Step 3: fetch with conditional headers
574
764
  const opts = {};
575
765
  // The ETag short-circuit is only safe when EVERY skill in
@@ -638,6 +828,20 @@ export async function runSync(options) {
638
828
  // 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
639
829
  // the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
640
830
  // is idempotent — and self-resolves once the older install is upgraded.
831
+ //
832
+ // Same-machine SPLIT-FLEET churn (#2174 rollout, tracked via #2181): the
833
+ // cohort hooks run `npx --yes skillrepo update --silent` (npm `latest` on
834
+ // every session), while the Claude Code hook pins the globally-installed
835
+ // binary. Until that global install is upgraded past the v3 schema bump,
836
+ // the two alternate writers ping-pong `.last-sync`: the ≥4.9.0 npx run
837
+ // writes schemaVersion 3; the ≤4.8.x pinned binary doesn't recognize v3 →
838
+ // null → FULL non-conditional fetch → writes v2 back (dropping
839
+ // `lastAttemptAt`), and the next npx run re-upgrades. Consequences while
840
+ // the split persists: the pinned binary never gets a 304 short-circuit
841
+ // and the throttle keeps getting disarmed — bounded by the once-daily
842
+ // upgrade nudge (bin/skillrepo.mjs), self-resolving on `npm install -g
843
+ // skillrepo@latest`. Harmless to data (full re-fetch is idempotent);
844
+ // do NOT add a compat shim for it here.
641
845
  const needsFullResync =
642
846
  !!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
643
847
 
@@ -679,6 +883,31 @@ export async function runSync(options) {
679
883
  new Date().toISOString(),
680
884
  stderr,
681
885
  );
886
+
887
+ // Stamp the throttle clock (#2174). LOAD-BEARING: a deliberately-
888
+ // stable library is a 304 on every session, so if we didn't advance
889
+ // `lastAttemptAt` here the throttle would never re-arm and every future
890
+ // hook sync would hit the network again — defeating the whole feature.
891
+ // Preserve etag/syncedAt/skills verbatim (the library is unchanged; only
892
+ // the attempt clock moves) and preserve cliVersion — a 304 is NOT a
893
+ // membership heal, and reaching a 304 already implies we sent
894
+ // If-None-Match, i.e. cliVersion was >= the heal floor. Best-effort: a
895
+ // failed stamp just means the next hook sync isn't throttled, never a
896
+ // sync failure.
897
+ try {
898
+ writeLastSync({
899
+ etag: lastSync?.etag ?? null,
900
+ syncedAt: lastSync?.syncedAt,
901
+ skills: lastSync?.skills ?? {},
902
+ cliVersion: lastSync?.cliVersion ?? null,
903
+ lastAttemptAt: new Date().toISOString(),
904
+ });
905
+ } catch (err) {
906
+ stderr.write(
907
+ ` warning: failed to persist last-sync state (${err.message}). ` +
908
+ `Next sync will not be throttled.\n`,
909
+ );
910
+ }
682
911
  return {
683
912
  added: 0,
684
913
  updated: 0,
@@ -827,6 +1056,11 @@ export async function runSync(options) {
827
1056
  etag: result.etag,
828
1057
  syncedAt: result.syncedAt,
829
1058
  skills: skillsMap,
1059
+ // Stamp the throttle clock on every successful sync (#2174). Gated
1060
+ // by the same `!anyIncomplete` guard as the ETag: a partial payload
1061
+ // does NOT advance the clock, so a broken sync is retried on the
1062
+ // next session rather than throttled away.
1063
+ lastAttemptAt: new Date().toISOString(),
830
1064
  });
831
1065
  } catch (err) {
832
1066
  // Non-fatal — the skills are on disk, we just won't get the
@@ -18,7 +18,9 @@ import {
18
18
  writeLastSync,
19
19
  FULL_RESYNC_FLOOR,
20
20
  cliVersionBelowFloor,
21
+ LAST_SYNC_SCHEMA_VERSION,
21
22
  } from "../../lib/sync.mjs";
23
+ import { globalLastSyncPath } from "../../lib/paths.mjs";
22
24
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
23
25
  import { createMockServer } from "../e2e/mock-server.mjs";
24
26
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
@@ -689,3 +691,167 @@ describe("runUpdate — #1911 membership heal lands the skill on disk", () => {
689
691
  );
690
692
  });
691
693
  });
694
+
695
+ // ── SessionStart throttle through the `update` command (#2174) ─────────
696
+ //
697
+ // The throttle lives in runSync, but what matters at the product boundary
698
+ // is that `skillrepo update --session-hook` (Claude Code) and the
699
+ // `--silent` cohort hook make ZERO HTTP calls when re-run within the
700
+ // window, while a bare interactive `skillrepo update` always syncs. These
701
+ // drive the real command entry point against the mock server's request
702
+ // spies — the acceptance criterion, end to end.
703
+
704
+ describe("runUpdate — SessionStart throttle (#2174)", () => {
705
+ beforeEach(setup);
706
+ afterEach(teardown);
707
+
708
+ it("ACCEPTANCE: two `update --session-hook` runs within the window → the 2nd makes zero HTTP calls", async () => {
709
+ server.setEtag('"v1"');
710
+ server.setLibraryResponse({
711
+ skills: [makeSkill("throttle-me")],
712
+ removals: [],
713
+ syncedAt: "2025-01-01T00:00:00Z",
714
+ });
715
+
716
+ // Session 1: the hook runs a real sync (no prior attempt).
717
+ await runUpdate(
718
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
719
+ { stdout },
720
+ );
721
+ assert.ok(server.getLibraryRequestCount() >= 1, "session 1 hit the library GET");
722
+ assert.match(stdout.text(), /Library synced/, "session 1 reports the sync");
723
+
724
+ // Session 2 (same 15-min window): the hook must make NO HTTP calls.
725
+ server.resetLibraryInspection();
726
+ server.resetReceipts();
727
+ stdout.clear();
728
+ await runUpdate(
729
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
730
+ { stdout },
731
+ );
732
+
733
+ assert.equal(
734
+ server.getLibraryRequestCount(),
735
+ 0,
736
+ "throttled session makes NO library GET",
737
+ );
738
+ assert.equal(
739
+ server.getReceiptRequestCount(),
740
+ 0,
741
+ "throttled session makes NO receipt POST",
742
+ );
743
+ assert.equal(
744
+ stdout.text(),
745
+ "",
746
+ "throttled session-hook stays silent (nothing changed to report)",
747
+ );
748
+ });
749
+
750
+ it("a bare interactive `skillrepo update` always syncs, even seconds after a hook sync", async () => {
751
+ server.setEtag('"v1"');
752
+ server.setLibraryResponse({
753
+ skills: [makeSkill("fresh")],
754
+ removals: [],
755
+ syncedAt: "2025-01-01T00:00:00Z",
756
+ });
757
+ // Hook sync arms the throttle clock.
758
+ await runUpdate(
759
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
760
+ { stdout },
761
+ );
762
+ server.resetLibraryInspection();
763
+ server.resetReceipts();
764
+ stdout.clear();
765
+
766
+ // Interactive `update` (no hook flag) must contact the server despite
767
+ // the fresh clock — explicit intent always syncs.
768
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
769
+ assert.ok(
770
+ server.getLibraryRequestCount() >= 1,
771
+ "interactive update is never throttled",
772
+ );
773
+ assert.match(
774
+ stdout.text(),
775
+ /up to date/,
776
+ "interactive 304 prints the up-to-date line",
777
+ );
778
+ });
779
+
780
+ it("the --silent cohort hook throttles too (2nd run: zero HTTP, still emits `{}`)", async () => {
781
+ server.setEtag('"v1"');
782
+ server.setLibraryResponse({
783
+ skills: [makeSkill("cohort")],
784
+ removals: [],
785
+ syncedAt: "2025-01-01T00:00:00Z",
786
+ });
787
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
788
+ server.resetLibraryInspection();
789
+ server.resetReceipts();
790
+ stdout.clear();
791
+
792
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
793
+ assert.equal(
794
+ server.getLibraryRequestCount(),
795
+ 0,
796
+ "throttled --silent makes NO library GET",
797
+ );
798
+ assert.equal(
799
+ server.getReceiptRequestCount(),
800
+ 0,
801
+ "throttled --silent makes NO receipt POST",
802
+ );
803
+ // The JSON hook contract still holds: stdout is exactly `{}` even when
804
+ // the sync was throttled away.
805
+ assert.equal(stdout.text(), "{}\n");
806
+ });
807
+
808
+ it("upgrade path: a real v2 .last-sync (etag + skills, no lastAttemptAt) → first hook sync proceeds and upgrades to v3", async () => {
809
+ // Every existing install upgrading to 4.9.0 hits exactly this state:
810
+ // a v2 file with a genuine etag/skills baseline written by the old
811
+ // CLI. The first --session-hook run after upgrade must NOT throttle
812
+ // (no lastAttemptAt = never attempted), must keep the cheap 304 (etag
813
+ // preserved in place), and must leave a v3 file with the stamp.
814
+ server.setEtag('"v1"');
815
+ server.setLibraryResponse({
816
+ skills: [makeSkill("veteran")],
817
+ removals: [],
818
+ syncedAt: "2025-01-01T00:00:00Z",
819
+ });
820
+ // Arm a fully-populated state via a real sync, then rewrite it as the
821
+ // v2 shape a pre-#2174 CLI would have left behind.
822
+ await runUpdate(
823
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
824
+ { stdout },
825
+ );
826
+ const prior = readLastSync();
827
+ const v2File = {
828
+ schemaVersion: 2,
829
+ etag: prior.etag,
830
+ syncedAt: prior.syncedAt,
831
+ cliVersion: prior.cliVersion,
832
+ skills: prior.skills,
833
+ };
834
+ writeFileSync(globalLastSyncPath(), JSON.stringify(v2File, null, 2) + "\n");
835
+ server.resetLibraryInspection();
836
+ server.resetReceipts();
837
+ stdout.clear();
838
+
839
+ await runUpdate(
840
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
841
+ { stdout },
842
+ );
843
+ assert.ok(
844
+ server.getLibraryRequestCount() >= 1,
845
+ "first post-upgrade hook sync is NOT throttled (v2 = never attempted)",
846
+ );
847
+ assert.equal(
848
+ server.getLastLibraryIfNoneMatch(),
849
+ prior.etag,
850
+ "the v2 etag was accepted in place — the post-upgrade sync stays a cheap 304",
851
+ );
852
+ const after = readLastSync();
853
+ assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION, "file upgraded to v3");
854
+ assert.equal(typeof after.lastAttemptAt, "string", "throttle clock stamped");
855
+ assert.deepEqual(after.skills, prior.skills, "per-skill SHA baseline preserved");
856
+ });
857
+ });
@@ -57,7 +57,7 @@ import { runAdd } from "../../commands/add.mjs";
57
57
  import { runRemove } from "../../commands/remove.mjs";
58
58
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
59
59
  import { globalLastSyncPath } from "../../lib/paths.mjs";
60
- import { readLastSync } from "../../lib/sync.mjs";
60
+ import { readLastSync, LAST_SYNC_SCHEMA_VERSION } from "../../lib/sync.mjs";
61
61
  import { createMockServer } from "../e2e/mock-server.mjs";
62
62
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
63
63
  import {
@@ -391,11 +391,11 @@ describe("update → list cross-command contract (#1574)", () => {
391
391
  // is the only way to verify the v2 file that lands on disk has the
392
392
  // expected shape AND the ETag carry-forward took effect.
393
393
 
394
- describe("v1 → v2 .last-sync migration round-trip", () => {
394
+ describe("v1 → current-schema .last-sync migration round-trip", () => {
395
395
  beforeEach(setup);
396
396
  afterEach(teardown);
397
397
 
398
- it("reads v1 .last-sync, performs sync, writes v2 with SHA map populated", async () => {
398
+ it("reads v1 .last-sync, performs sync, writes the current schema with SHA map populated", async () => {
399
399
  // Seed a v1 state file at the documented path. Both fields are
400
400
  // strings — that's the v1 shape `readLastSync` migrates from.
401
401
  const v1Path = globalLastSyncPath();
@@ -421,14 +421,18 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
421
421
  // in readLastSync and the v2 write at the end of runSync ──
422
422
  await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
423
423
 
424
- // The on-disk file must now be v2 and carry the per-skill SHA
425
- // map. The pre-existing v1 etag/syncedAt are NOT preserved
424
+ // The on-disk file must now carry the CURRENT schema and the per-skill
425
+ // SHA map. The pre-existing v1 etag/syncedAt are NOT preserved
426
426
  // because the sync actually completed and got a fresh ETag from
427
427
  // the server. Carry-forward applies to the per-skill SHA map
428
428
  // (which was empty in v1), not to the library ETag itself.
429
429
  const afterRaw = readFileSync(v1Path, "utf-8");
430
430
  const after = JSON.parse(afterRaw);
431
- assert.equal(after.schemaVersion, 2, "must persist as v2");
431
+ assert.equal(
432
+ after.schemaVersion,
433
+ LAST_SYNC_SCHEMA_VERSION,
434
+ "must persist as the current schema (v1 migrates forward on write)",
435
+ );
432
436
  assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
433
437
  assert.ok(
434
438
  after.skills && typeof after.skills === "object",
@@ -502,11 +506,11 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
502
506
  "v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
503
507
  );
504
508
 
505
- // After the recovery sync, the on-disk file is v2 with the
506
- // skills map populated. The skill is on disk in the claudeProject
507
- // placement — the user-visible recovery.
509
+ // After the recovery sync, the on-disk file carries the current
510
+ // schema with the skills map populated. The skill is on disk in the
511
+ // claudeProject placement — the user-visible recovery.
508
512
  const after = readLastSync();
509
- assert.equal(after.schemaVersion, 2);
513
+ assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
510
514
  assert.ok(after.skills["alice/unmigrated"]);
511
515
  assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
512
516
 
@@ -1014,7 +1018,7 @@ describe("additional coverage from production-readiness audit", () => {
1014
1018
  "runUpdate must survive a corrupt .last-sync and complete the sync",
1015
1019
  );
1016
1020
  const after = readLastSync();
1017
- assert.equal(after.schemaVersion, 2);
1021
+ assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
1018
1022
  assert.equal(after.etag, '"fresh"');
1019
1023
  assert.ok(after.skills["alice/recovered"]);
1020
1024
  });
@@ -44,6 +44,7 @@ import {
44
44
  cliVersionBelowFloor,
45
45
  FULL_RESYNC_FLOOR,
46
46
  LAST_SYNC_SCHEMA_VERSION,
47
+ MIN_SYNC_INTERVAL_MS,
47
48
  } from "../../lib/sync.mjs";
48
49
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
49
50
  import { globalLastSyncPath } from "../../lib/paths.mjs";
@@ -124,11 +125,11 @@ describe("readLastSync / writeLastSync", () => {
124
125
  assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
125
126
  });
126
127
 
127
- it("writes v2 schema (LAST_SYNC_SCHEMA_VERSION === 2)", () => {
128
- // Locks the constant so a future bump back to 1, or accidental
129
- // change, fails loudly. The whole epic depends on v2 being the
130
- // current schema and v1 files producing null (full-sync trigger).
131
- assert.equal(LAST_SYNC_SCHEMA_VERSION, 2);
128
+ it("writes v3 schema (LAST_SYNC_SCHEMA_VERSION === 3)", () => {
129
+ // Locks the constant so an accidental change fails loudly. #2174
130
+ // bumped it 2 3 (adds `lastAttemptAt`). v1 files migrate in-memory,
131
+ // v2 files are accepted in place, unknown future versions read null.
132
+ assert.equal(LAST_SYNC_SCHEMA_VERSION, 3);
132
133
  });
133
134
 
134
135
  it("persists an empty skills map by default", () => {
@@ -164,7 +165,7 @@ describe("readLastSync / writeLastSync", () => {
164
165
  assert.deepEqual(result.skills, skills);
165
166
  });
166
167
 
167
- it("an old v1 file migrates in-memory to a v2 shape with empty skills", () => {
168
+ it("an old v1 file migrates in-memory to the current-schema shape with empty skills", () => {
168
169
  // Backward compat: a user upgrading from a v1-era CLI to v2 still
169
170
  // has a v1 `.last-sync` on disk. Returning null would force a
170
171
  // full sync on every command (the etag is lost), which is
@@ -235,9 +236,9 @@ describe("readLastSync / writeLastSync", () => {
235
236
  });
236
237
 
237
238
  it("a file with an unknown schemaVersion still reads as null", () => {
238
- // Forward compat: a v3 or v999 file (from a newer CLI we don't
239
- // understand) reads as null, triggering a full sync. We only
240
- // migrate KNOWN older shapes, not arbitrary unknown ones.
239
+ // Forward compat: a v4 or v999 file (from a newer CLI we don't
240
+ // understand) reads as null, triggering a full sync. We only accept
241
+ // KNOWN shapes (v1 migrate, v2/v3 in place), not arbitrary unknown ones.
241
242
  mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
242
243
  writeFileSync(
243
244
  globalLastSyncPath(),
@@ -2046,3 +2047,490 @@ describe("placementsAreComplete — direct unit coverage", () => {
2046
2047
  assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
2047
2048
  });
2048
2049
  });
2050
+
2051
+ // ── .last-sync — lastAttemptAt (v3, #2174) ─────────────────────────────
2052
+
2053
+ describe("readLastSync / writeLastSync — lastAttemptAt (v3, #2174)", () => {
2054
+ beforeEach(setupServer);
2055
+ afterEach(teardownServer);
2056
+
2057
+ it("round-trips a lastAttemptAt ISO string", () => {
2058
+ writeLastSync({
2059
+ etag: '"e"',
2060
+ syncedAt: "s",
2061
+ lastAttemptAt: "2025-01-01T00:00:00.000Z",
2062
+ });
2063
+ assert.equal(readLastSync().lastAttemptAt, "2025-01-01T00:00:00.000Z");
2064
+ });
2065
+
2066
+ it("persists lastAttemptAt: null when omitted, and reads it back as ABSENT", () => {
2067
+ // Omitting the field writes explicit null on disk; readLastSync
2068
+ // normalizes a non-string to absent so the throttle sees "never
2069
+ // attempted" rather than a bogus clock value.
2070
+ writeLastSync({ etag: '"e"', syncedAt: "s" });
2071
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
2072
+ assert.equal(onDisk.lastAttemptAt, null);
2073
+ assert.equal("lastAttemptAt" in readLastSync(), false);
2074
+ });
2075
+
2076
+ it("coerces a non-string lastAttemptAt to absent (bogus clock ignored)", () => {
2077
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
2078
+ writeFileSync(
2079
+ globalLastSyncPath(),
2080
+ JSON.stringify({
2081
+ schemaVersion: 3,
2082
+ etag: "x",
2083
+ syncedAt: "x",
2084
+ skills: {},
2085
+ lastAttemptAt: 12345,
2086
+ }),
2087
+ );
2088
+ assert.equal("lastAttemptAt" in readLastSync(), false);
2089
+ });
2090
+
2091
+ it("accepts a v2 file in place (no lastAttemptAt) — etag + skills preserved", () => {
2092
+ // A v2 file written by a pre-#2174 CLI must be read in place: etag and
2093
+ // skills preserved (so the first post-upgrade sync stays a cheap 304),
2094
+ // lastAttemptAt absent (the throttle treats it as "never attempted").
2095
+ // This is the additive-forward-compat contract, distinct from the
2096
+ // v1→v2 migration which forces a re-fetch via an empty skills map.
2097
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
2098
+ const v2 = {
2099
+ schemaVersion: 2,
2100
+ etag: '"v2etag"',
2101
+ syncedAt: "2025-01-01T00:00:00Z",
2102
+ cliVersion: "4.8.1",
2103
+ skills: {
2104
+ "alice/x": {
2105
+ version: "1.0.0",
2106
+ skillMdSha256: "a".repeat(64),
2107
+ filesSha256: "b".repeat(64),
2108
+ syncedAt: "2025-01-01T00:00:00Z",
2109
+ },
2110
+ },
2111
+ };
2112
+ writeFileSync(globalLastSyncPath(), JSON.stringify(v2));
2113
+ const read = readLastSync();
2114
+ assert.ok(read, "a v2 file must be accepted, not null");
2115
+ assert.equal(read.etag, '"v2etag"');
2116
+ assert.deepEqual(Object.keys(read.skills), ["alice/x"]);
2117
+ assert.equal(
2118
+ "lastAttemptAt" in read,
2119
+ false,
2120
+ "v2 has no lastAttemptAt → the throttle treats it as never attempted",
2121
+ );
2122
+ });
2123
+
2124
+ it("upsertLastSyncEntry preserves the prior lastAttemptAt (single-skill add is not a full sync)", () => {
2125
+ writeLastSync({
2126
+ etag: '"e"',
2127
+ syncedAt: "s",
2128
+ skills: {},
2129
+ lastAttemptAt: "2025-02-02T00:00:00.000Z",
2130
+ });
2131
+ upsertLastSyncEntry({
2132
+ owner: "alice",
2133
+ name: "added",
2134
+ version: "1.0.0",
2135
+ files: [
2136
+ {
2137
+ path: "SKILL.md",
2138
+ content: "---\nname: added\ndescription: d\n---\nbody\n",
2139
+ },
2140
+ ],
2141
+ });
2142
+ assert.equal(
2143
+ readLastSync().lastAttemptAt,
2144
+ "2025-02-02T00:00:00.000Z",
2145
+ "a single-skill add must NOT advance the throttle clock",
2146
+ );
2147
+ });
2148
+
2149
+ it("deleteLastSyncEntry preserves the prior lastAttemptAt", () => {
2150
+ writeLastSync({
2151
+ etag: '"e"',
2152
+ syncedAt: "s",
2153
+ skills: {
2154
+ "alice/gone": {
2155
+ version: "1.0.0",
2156
+ skillMdSha256: "a".repeat(64),
2157
+ filesSha256: "b".repeat(64),
2158
+ syncedAt: "s",
2159
+ },
2160
+ },
2161
+ lastAttemptAt: "2025-03-03T00:00:00.000Z",
2162
+ });
2163
+ deleteLastSyncEntry("alice", "gone");
2164
+ assert.equal(readLastSync().lastAttemptAt, "2025-03-03T00:00:00.000Z");
2165
+ });
2166
+ });
2167
+
2168
+ // ── runSync — SessionStart staleness throttle (#2174) ──────────────────
2169
+ //
2170
+ // A hook-triggered sync (`throttle: true`, passed ONLY by update.mjs's
2171
+ // --session-hook / --silent branches) skips ALL network work — no library
2172
+ // GET, no receipt POST — when the last ATTEMPT was within the window. A
2173
+ // bare interactive sync (no `throttle`) always runs. `lastAttemptAt` is
2174
+ // stamped on the 304 path and every successful write, NOT on a throttled
2175
+ // skip, so the window is measured from the last REAL sync — a run of quick
2176
+ // restarts can't push the clock forward and starve the sync.
2177
+
2178
+ describe("runSync — SessionStart throttle (#2174)", () => {
2179
+ beforeEach(setupServer);
2180
+ afterEach(teardownServer);
2181
+
2182
+ it("locks the default window at 15 minutes (a change must be deliberate)", () => {
2183
+ assert.equal(MIN_SYNC_INTERVAL_MS, 15 * 60 * 1000);
2184
+ });
2185
+
2186
+ it("ACCEPTANCE: two throttled syncs within the window → the 2nd makes ZERO HTTP calls", async () => {
2187
+ server.setEtag('"v1"');
2188
+ server.setLibraryResponse({
2189
+ skills: [makeSkill("pdf-helper")],
2190
+ removals: [],
2191
+ syncedAt: "2025-01-01T00:00:00Z",
2192
+ });
2193
+
2194
+ // Run 1 (hook-triggered): a real sync — no prior attempt, so it is NOT
2195
+ // throttled. It stamps lastAttemptAt = now.
2196
+ const first = await runSync({
2197
+ serverUrl,
2198
+ apiKey: VALID_KEY,
2199
+ vendors: ["claudeCode"],
2200
+ throttle: true,
2201
+ });
2202
+ assert.equal(first.throttled ?? false, false, "first run is a real sync");
2203
+ assert.ok(server.getLibraryRequestCount() >= 1, "first run hit the library GET");
2204
+
2205
+ // Reset the request-count spies, then run again immediately. Within the
2206
+ // 15-min window the throttle MUST skip both the library GET and the
2207
+ // receipt POST — zero HTTP, the acceptance criterion.
2208
+ server.resetLibraryInspection();
2209
+ server.resetReceipts();
2210
+ const second = await runSync({
2211
+ serverUrl,
2212
+ apiKey: VALID_KEY,
2213
+ vendors: ["claudeCode"],
2214
+ throttle: true,
2215
+ });
2216
+
2217
+ assert.equal(second.throttled, true, "second run within the window is throttled");
2218
+ assert.equal(server.getLibraryRequestCount(), 0, "throttled run makes NO library GET");
2219
+ assert.equal(server.getReceiptRequestCount(), 0, "throttled run makes NO receipt POST");
2220
+ assert.equal(
2221
+ second.notModified,
2222
+ false,
2223
+ "throttled is distinct from a 304 — we never contacted the server",
2224
+ );
2225
+ });
2226
+
2227
+ it("a bare interactive sync (no throttle) ALWAYS syncs, even within the window", async () => {
2228
+ server.setEtag('"v1"');
2229
+ server.setLibraryResponse({
2230
+ skills: [makeSkill("x")],
2231
+ removals: [],
2232
+ syncedAt: "2025-01-01T00:00:00Z",
2233
+ });
2234
+ // Arm the clock with a hook sync.
2235
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2236
+ server.resetLibraryInspection();
2237
+ server.resetReceipts();
2238
+ // Interactive: `throttle` omitted → must hit the network despite a
2239
+ // fresh lastAttemptAt.
2240
+ const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
2241
+ assert.equal(res.throttled ?? false, false);
2242
+ assert.ok(
2243
+ server.getLibraryRequestCount() >= 1,
2244
+ "an explicit interactive sync always contacts the server",
2245
+ );
2246
+ });
2247
+
2248
+ it("does NOT throttle a fresh install (no lastAttemptAt yet)", async () => {
2249
+ server.setEtag('"v1"');
2250
+ server.setLibraryResponse({
2251
+ skills: [makeSkill("x")],
2252
+ removals: [],
2253
+ syncedAt: "2025-01-01T00:00:00Z",
2254
+ });
2255
+ // No prior .last-sync → lastAttemptAt absent → never attempted → the
2256
+ // very first hook sync proceeds.
2257
+ const res = await runSync({
2258
+ serverUrl,
2259
+ apiKey: VALID_KEY,
2260
+ vendors: ["claudeCode"],
2261
+ throttle: true,
2262
+ });
2263
+ assert.equal(res.throttled ?? false, false);
2264
+ assert.ok(server.getLibraryRequestCount() >= 1);
2265
+ });
2266
+
2267
+ it("throttle expires: an attempt older than the window syncs again", async () => {
2268
+ server.setEtag('"v1"');
2269
+ server.setLibraryResponse({
2270
+ skills: [makeSkill("x")],
2271
+ removals: [],
2272
+ syncedAt: "2025-01-01T00:00:00Z",
2273
+ });
2274
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2275
+
2276
+ // Backdate lastAttemptAt to just past the window.
2277
+ const s = readLastSync();
2278
+ writeLastSync({
2279
+ etag: s.etag,
2280
+ syncedAt: s.syncedAt,
2281
+ skills: s.skills,
2282
+ cliVersion: s.cliVersion,
2283
+ lastAttemptAt: new Date(Date.now() - MIN_SYNC_INTERVAL_MS - 1000).toISOString(),
2284
+ });
2285
+ server.resetLibraryInspection();
2286
+ server.resetReceipts();
2287
+
2288
+ const res = await runSync({
2289
+ serverUrl,
2290
+ apiKey: VALID_KEY,
2291
+ vendors: ["claudeCode"],
2292
+ throttle: true,
2293
+ });
2294
+ assert.equal(res.throttled ?? false, false, "past the window → not throttled");
2295
+ assert.ok(server.getLibraryRequestCount() >= 1, "expired window re-contacts the server");
2296
+ });
2297
+
2298
+ it("minSyncIntervalMs override pins the boundary (0 disables the throttle)", async () => {
2299
+ server.setEtag('"v1"');
2300
+ server.setLibraryResponse({
2301
+ skills: [makeSkill("x")],
2302
+ removals: [],
2303
+ syncedAt: "2025-01-01T00:00:00Z",
2304
+ });
2305
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2306
+ server.resetLibraryInspection();
2307
+ // interval 0 → `now - last < 0` is never true → always syncs.
2308
+ const res = await runSync({
2309
+ serverUrl,
2310
+ apiKey: VALID_KEY,
2311
+ vendors: ["claudeCode"],
2312
+ throttle: true,
2313
+ minSyncIntervalMs: 0,
2314
+ });
2315
+ assert.equal(res.throttled ?? false, false);
2316
+ assert.ok(server.getLibraryRequestCount() >= 1);
2317
+ });
2318
+
2319
+ it("brackets the window: a 1h-old attempt throttles under a 2h interval, syncs under a 30-min one", async () => {
2320
+ server.setEtag('"v1"');
2321
+ server.setLibraryResponse({
2322
+ skills: [makeSkill("x")],
2323
+ removals: [],
2324
+ syncedAt: "2025-01-01T00:00:00Z",
2325
+ });
2326
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2327
+ const s = readLastSync();
2328
+ writeLastSync({
2329
+ etag: s.etag,
2330
+ syncedAt: s.syncedAt,
2331
+ skills: s.skills,
2332
+ cliVersion: s.cliVersion,
2333
+ lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
2334
+ });
2335
+
2336
+ // Inside a 2h window → throttled (zero HTTP). A throttled skip does
2337
+ // not re-stamp, so the same 1h-old attempt then drives the second leg.
2338
+ server.resetLibraryInspection();
2339
+ server.resetReceipts();
2340
+ const inside = await runSync({
2341
+ serverUrl,
2342
+ apiKey: VALID_KEY,
2343
+ vendors: ["claudeCode"],
2344
+ throttle: true,
2345
+ minSyncIntervalMs: 2 * 60 * 60 * 1000,
2346
+ });
2347
+ assert.equal(inside.throttled, true, "1h elapsed < 2h window → throttled");
2348
+ assert.equal(server.getLibraryRequestCount(), 0);
2349
+
2350
+ // Outside a 30-min window → syncs.
2351
+ const outside = await runSync({
2352
+ serverUrl,
2353
+ apiKey: VALID_KEY,
2354
+ vendors: ["claudeCode"],
2355
+ throttle: true,
2356
+ minSyncIntervalMs: 30 * 60 * 1000,
2357
+ });
2358
+ assert.equal(outside.throttled ?? false, false, "1h elapsed >= 30-min window → syncs");
2359
+ assert.ok(server.getLibraryRequestCount() >= 1);
2360
+ });
2361
+
2362
+ it("a FUTURE lastAttemptAt (backward clock jump) syncs and self-heals instead of throttling until the clock catches up", async () => {
2363
+ server.setEtag('"v1"');
2364
+ server.setLibraryResponse({
2365
+ skills: [makeSkill("x")],
2366
+ removals: [],
2367
+ syncedAt: "2025-01-01T00:00:00Z",
2368
+ });
2369
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2370
+
2371
+ // A backward clock jump (NTP correction, VM snapshot restore) leaves a
2372
+ // stamp that is now in the FUTURE. `now - lastAttemptMs` goes negative —
2373
+ // an unguarded `< intervalMs` would throttle every hook sync until the
2374
+ // wall clock catches up, silently (hook modes are contract-silent).
2375
+ const s = readLastSync();
2376
+ writeLastSync({
2377
+ etag: s.etag,
2378
+ syncedAt: s.syncedAt,
2379
+ skills: s.skills,
2380
+ cliVersion: s.cliVersion,
2381
+ lastAttemptAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
2382
+ });
2383
+ server.resetLibraryInspection();
2384
+ server.resetReceipts();
2385
+
2386
+ const res = await runSync({
2387
+ serverUrl,
2388
+ apiKey: VALID_KEY,
2389
+ vendors: ["claudeCode"],
2390
+ throttle: true,
2391
+ });
2392
+ assert.equal(res.throttled ?? false, false, "a future stamp must NOT throttle");
2393
+ assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
2394
+ // The sync (304 path here) re-stamps a sane "now" — the bogus future
2395
+ // value is gone after ONE pass; the next window measures from reality.
2396
+ const healed = readLastSync().lastAttemptAt;
2397
+ assert.ok(
2398
+ new Date(healed).getTime() <= Date.now(),
2399
+ "the sync self-healed the stamp back to a non-future time",
2400
+ );
2401
+ });
2402
+
2403
+ it("a malformed lastAttemptAt string ('not-a-date') is not trusted — the hook sync proceeds", async () => {
2404
+ server.setEtag('"v1"');
2405
+ server.setLibraryResponse({
2406
+ skills: [makeSkill("x")],
2407
+ removals: [],
2408
+ syncedAt: "2025-01-01T00:00:00Z",
2409
+ });
2410
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2411
+
2412
+ // readLastSync only normalizes NON-string values to absent; a string
2413
+ // that fails Date.parse reaches runSync's Number.isFinite guard. This
2414
+ // pins that guard: bogus clock → sync (safe direction), never throttle.
2415
+ const s = readLastSync();
2416
+ writeLastSync({
2417
+ etag: s.etag,
2418
+ syncedAt: s.syncedAt,
2419
+ skills: s.skills,
2420
+ cliVersion: s.cliVersion,
2421
+ lastAttemptAt: "not-a-date",
2422
+ });
2423
+ server.resetLibraryInspection();
2424
+ server.resetReceipts();
2425
+
2426
+ const res = await runSync({
2427
+ serverUrl,
2428
+ apiKey: VALID_KEY,
2429
+ vendors: ["claudeCode"],
2430
+ throttle: true,
2431
+ });
2432
+ assert.equal(res.throttled ?? false, false, "unparseable stamp must NOT throttle");
2433
+ assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
2434
+ const healed = readLastSync().lastAttemptAt;
2435
+ assert.ok(
2436
+ typeof healed === "string" && Number.isFinite(Date.parse(healed)),
2437
+ "the sync replaced the malformed stamp with a valid timestamp",
2438
+ );
2439
+ });
2440
+
2441
+ it("304 path stamps lastAttemptAt — LOAD-BEARING so the throttle re-arms every steady-state session", async () => {
2442
+ server.setEtag('"v1"');
2443
+ server.setLibraryResponse({
2444
+ skills: [makeSkill("x")],
2445
+ removals: [],
2446
+ syncedAt: "2025-01-01T00:00:00Z",
2447
+ });
2448
+ // First (interactive) sync stamps lastAttemptAt and the etag.
2449
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
2450
+ assert.equal(
2451
+ typeof readLastSync().lastAttemptAt,
2452
+ "string",
2453
+ "first sync stamped lastAttemptAt",
2454
+ );
2455
+
2456
+ // Backdate so the next sync is NOT throttled but WILL 304 (etag matches,
2457
+ // placement present).
2458
+ const s = readLastSync();
2459
+ writeLastSync({
2460
+ etag: s.etag,
2461
+ syncedAt: s.syncedAt,
2462
+ skills: s.skills,
2463
+ cliVersion: s.cliVersion,
2464
+ lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
2465
+ });
2466
+
2467
+ // Snapshot the full pre-304 state — the re-stamp must move ONLY the
2468
+ // attempt clock. etag/skills/cliVersion wiped here (e.g. a future
2469
+ // edit letting `skills` default to {}) would silently destroy every
2470
+ // user's per-skill SHA cache on their next up-to-date sync.
2471
+ const before = readLastSync();
2472
+
2473
+ const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
2474
+ assert.equal(res.notModified, true, "etag match → 304");
2475
+ const after = readLastSync();
2476
+ assert.equal(typeof after.lastAttemptAt, "string");
2477
+ assert.ok(
2478
+ Date.now() - new Date(after.lastAttemptAt).getTime() < 60_000,
2479
+ "the 304 path re-stamped lastAttemptAt to a fresh time (throttle re-arms)",
2480
+ );
2481
+ assert.equal(after.etag, before.etag, "304 re-stamp preserves the etag verbatim");
2482
+ assert.deepEqual(
2483
+ after.skills,
2484
+ before.skills,
2485
+ "304 re-stamp preserves the per-skill SHA map verbatim",
2486
+ );
2487
+ assert.equal(
2488
+ after.cliVersion,
2489
+ before.cliVersion,
2490
+ "304 re-stamp preserves cliVersion (a 304 is not a membership heal)",
2491
+ );
2492
+ assert.equal(after.syncedAt, before.syncedAt, "304 re-stamp preserves syncedAt");
2493
+ });
2494
+
2495
+ it("does NOT stamp lastAttemptAt on a filesIncomplete sync (a broken sync is retried, not throttled)", async () => {
2496
+ const inc = makeSkill("inc");
2497
+ inc.filesIncomplete = true;
2498
+ server.setEtag('"v1"');
2499
+ server.setLibraryResponse({ skills: [inc], removals: [], syncedAt: "x" });
2500
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2501
+ // filesIncomplete → the whole state-file write is skipped → no
2502
+ // lastAttemptAt persisted → the next hook sync is NOT throttled.
2503
+ assert.equal(readLastSync(), null, "no state persisted on filesIncomplete");
2504
+ });
2505
+
2506
+ it("emits a SKILLREPO_VERBOSE stderr line when it throttles", async () => {
2507
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
2508
+ const stderr = createCaptureStream();
2509
+ server.setEtag('"v1"');
2510
+ server.setLibraryResponse({
2511
+ skills: [makeSkill("x")],
2512
+ removals: [],
2513
+ syncedAt: "2025-01-01T00:00:00Z",
2514
+ });
2515
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
2516
+ server.resetLibraryInspection();
2517
+
2518
+ const prevVerbose = process.env.SKILLREPO_VERBOSE;
2519
+ process.env.SKILLREPO_VERBOSE = "1";
2520
+ try {
2521
+ const res = await runSync({
2522
+ serverUrl,
2523
+ apiKey: VALID_KEY,
2524
+ vendors: ["claudeCode"],
2525
+ throttle: true,
2526
+ io: { stderr },
2527
+ });
2528
+ assert.equal(res.throttled, true);
2529
+ } finally {
2530
+ if (prevVerbose === undefined) delete process.env.SKILLREPO_VERBOSE;
2531
+ else process.env.SKILLREPO_VERBOSE = prevVerbose;
2532
+ }
2533
+ assert.match(stderr.text(), /throttled/);
2534
+ assert.equal(server.getLibraryRequestCount(), 0, "verbose throttle still makes no GET");
2535
+ });
2536
+ });