skillrepo 4.8.3 → 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.3",
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/http.mjs CHANGED
@@ -370,6 +370,25 @@ async function mapErrorResponse(res, url) {
370
370
  if (res.status === 404) {
371
371
  return null; // Caller decides
372
372
  }
373
+ if (res.status === 422 && code === "handle_required") {
374
+ // #2310: the push would CREATE a skill while the account's Author ID
375
+ // is still an unchosen placeholder. Line 1 is the server's message
376
+ // ("Set your Author ID before adding skills."); the hint points at
377
+ // the Settings surface that unblocks it. Derive the base URL from
378
+ // the request URL's origin (same posture as the 401 auth hint above)
379
+ // so staging users get a staging link, with the prod host as the
380
+ // parse-failure fallback.
381
+ let settingsBase = "https://skillrepo.dev";
382
+ try {
383
+ settingsBase = new URL(url).origin;
384
+ } catch {
385
+ // URL parse failure shouldn't happen — `safeFetch` already
386
+ // validated. Fall back to the prod default.
387
+ }
388
+ return validationError(message, {
389
+ hint: `Set it in Settings → Account Profile: ${settingsBase}/app/settings`,
390
+ });
391
+ }
373
392
  if (res.status === 429) {
374
393
  // 429 hits mapErrorResponse in two cases: (1) a read-only
375
394
  // endpoint's retry budget was exhausted (safeFetch with
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
@@ -220,7 +220,7 @@ describe("runPublish — error paths", () => {
220
220
  server.setPublishResponse("alice", "my-skill", {
221
221
  status: 422,
222
222
  body: {
223
- error: "Choose your Author ID before publishing.",
223
+ error: "Set your Author ID before publishing.",
224
224
  code: "namespace_unset",
225
225
  },
226
226
  });
@@ -453,6 +453,30 @@ describe("runPush — server error mapping", () => {
453
453
  );
454
454
  });
455
455
 
456
+ it("maps server 422 handle_required to EXIT_VALIDATION with the Settings hint (#2310, #2345)", async () => {
457
+ // The dedicated #2310 branch in mapErrorResponse: friendly message
458
+ // plus the Settings pointer. Previously pinned only at the
459
+ // http.test.mjs layer against hand-built JSON — this runs the whole
460
+ // runPush chain against the same wire shape the server emits
461
+ // (route.ts:724: `{ error, code: "handle_required" }`).
462
+ file("SKILL.md", VALID_SKILL_MD);
463
+ server.setPushResponse({
464
+ status: 422,
465
+ body: {
466
+ error: "Set your Author ID before adding skills.",
467
+ code: "handle_required",
468
+ },
469
+ });
470
+ await assert.rejects(
471
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
472
+ (err) =>
473
+ err instanceof CliError &&
474
+ err.exitCode === EXIT_VALIDATION &&
475
+ /author id/i.test(err.message) &&
476
+ /settings/i.test(err.hint ?? ""),
477
+ );
478
+ });
479
+
456
480
  it("maps server 413 payload_too_large to EXIT_VALIDATION (generic 4xx)", async () => {
457
481
  // 413 has NO explicit branch in mapErrorResponse (only 401/403/404/
458
482
  // 429/5xx are special-cased). It therefore falls through to the