skillrepo 4.6.0 → 4.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.6.0",
3
+ "version": "4.8.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": {
package/src/lib/sync.mjs CHANGED
@@ -31,6 +31,11 @@
31
31
  * version + content SHAs of every skill written in the last
32
32
  * successful sync. Unblocks per-skill drift detection in `list`
33
33
  * (#1555) and any future `status`-style command.
34
+ * Later (#1911) v2 also carries `cliVersion` — the version of the
35
+ * CLI that last wrote the state file. Additive, not a schema bump:
36
+ * absence is meaningful (a state file with no `cliVersion` was
37
+ * written by a pre-#1911 CLI and triggers the one-time membership
38
+ * heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
34
39
  *
35
40
  * Forward/backward compatibility
36
41
  * ------------------------------
@@ -69,7 +74,14 @@
69
74
  * render accurate user messages (see
70
75
  * init.mjs step 7).
71
76
  * @property {string} [syncedAt]
72
- * @property {string} syncedAt - ISO timestamp from the server response
77
+ * @property {string} syncedAt - ISO timestamp of the sync: the server
78
+ * response `syncedAt` on a 200, or the
79
+ * previously-cached sync timestamp on a
80
+ * 304 (the server sends no body). NOTE:
81
+ * this is distinct from the re-assertion
82
+ * receipt's own timestamp on a 304, which
83
+ * is the CLI's "now" (a liveness signal),
84
+ * not this cached value.
73
85
  *
74
86
  * @typedef {Object} SyncedSkillEntry
75
87
  * @property {string} version - Version of the skill as synced (e.g. "1.4.0").
@@ -81,13 +93,20 @@
81
93
  * @property {number} schemaVersion
82
94
  * @property {string|null} etag
83
95
  * @property {string} syncedAt
96
+ * @property {string} [cliVersion] - v2+ (#1911). Version of the CLI that last
97
+ * wrote this state. Absent on files written
98
+ * by a pre-#1911 CLI; absence triggers the
99
+ * one-time membership heal.
84
100
  * @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
85
101
  */
86
102
 
87
103
  import { existsSync, readFileSync } from "node:fs";
88
104
  import { join } from "node:path";
89
105
 
106
+ import semver from "semver";
107
+
90
108
  import { getLibrary, postSyncReceipt } from "./http.mjs";
109
+ import { getCliVersion } from "./cli-version.mjs";
91
110
  import {
92
111
  writeSkillDir,
93
112
  removeSkillDir,
@@ -111,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
111
130
  */
112
131
  export const LAST_SYNC_SCHEMA_VERSION = 2;
113
132
 
133
+ /**
134
+ * One-time heal floor (#1911).
135
+ *
136
+ * A `.last-sync` written by a CLI BELOW this version predates the
137
+ * server-side delta-membership fix. Such a state file can be permanently
138
+ * missing skills that were added to the library but never delivered: the
139
+ * old delta filter keyed only on `skills.updatedAt`, so a skill whose
140
+ * content predated the user's `since` was skipped even though the library
141
+ * ETag advanced (count++). The client then cached the advanced ETag and
142
+ * every subsequent `update` short-circuited on a 304 — the skill could
143
+ * never arrive via delta.
144
+ *
145
+ * The server fix makes FUTURE adds correct, but it cannot rescue a client
146
+ * that already cached the advanced ETag (it still matches → 304). So the
147
+ * FIRST sync performed by a CLI at/above this floor forces ONE full fetch
148
+ * (drops `If-None-Match` AND `since`), re-fetching the entire library to
149
+ * fill any gap, then resumes normal delta. `writeLastSync` stamps the
150
+ * running CLI version on every write, so the heal fires at most once per
151
+ * upgrade.
152
+ *
153
+ * MUST equal the CLI version that ships this fix (see packages/cli/package.json).
154
+ */
155
+ export const FULL_RESYNC_FLOOR = "4.8.0";
156
+
157
+ /**
158
+ * True when a `.last-sync`'s recorded writer version is below the heal
159
+ * floor — i.e. the state file was written before the #1911 fix and may be
160
+ * missing never-delivered skills. A missing or unparseable version is
161
+ * treated as below the floor (older CLIs never stamped `cliVersion`), so
162
+ * every pre-fix state file heals exactly once.
163
+ *
164
+ * @param {unknown} recordedVersion - `.last-sync`'s `cliVersion` field.
165
+ * @param {string} floor - The heal floor (a valid semver string).
166
+ * @returns {boolean}
167
+ */
168
+ export function cliVersionBelowFloor(recordedVersion, floor) {
169
+ if (typeof recordedVersion !== "string" || !semver.valid(recordedVersion)) {
170
+ return true;
171
+ }
172
+ return semver.lt(recordedVersion, floor);
173
+ }
174
+
175
+ /**
176
+ * The running CLI's version, or null if it cannot be read. `getCliVersion`
177
+ * throws on a malformed/absent package.json (a broken tarball); persisting
178
+ * last-sync state must never fail on that, so we soft-handle to null here.
179
+ * A null stamp simply means the next run treats this state as pre-floor and
180
+ * heals again — safe, just one extra full fetch in the (production-
181
+ * impossible) broken-tarball case.
182
+ *
183
+ * @returns {string | null}
184
+ */
185
+ function safeCliVersion() {
186
+ try {
187
+ return getCliVersion();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
114
193
  /**
115
194
  * Check whether every skill in the `.last-sync` baseline already exists
116
195
  * under every placement target the current sync would write to. Returns
@@ -290,17 +369,26 @@ export function readLastSync() {
290
369
  * sync"; consumers should treat absent entries as "unknown" rather
291
370
  * than as "missing from library."
292
371
  *
372
+ * Always stamps `cliVersion` with the running CLI's version (#1911). This
373
+ * is the writer-version record the heal gate (`cliVersionBelowFloor`) reads
374
+ * on the next run; a state file written by the current CLI is therefore
375
+ * never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
376
+ * only by tests to simulate a pre-fix writer); production callers omit it.
377
+ *
293
378
  * @param {object} state
294
379
  * @param {string | null} [state.etag]
295
380
  * @param {string} [state.syncedAt]
296
381
  * @param {Record<string, SyncedSkillEntry>} [state.skills]
382
+ * @param {string | null} [state.cliVersion] - Override the writer-version
383
+ * stamp. Defaults to the running CLI's version.
297
384
  */
298
- export function writeLastSync({ etag, syncedAt, skills } = {}) {
385
+ export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
299
386
  const path = globalLastSyncPath();
300
387
  const body = {
301
388
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
302
389
  etag: etag ?? null,
303
390
  syncedAt: syncedAt ?? new Date().toISOString(),
391
+ cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
304
392
  skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
305
393
  };
306
394
  try {
@@ -383,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
383
471
  etag: prior?.etag ?? null,
384
472
  syncedAt: prior?.syncedAt,
385
473
  skills: updated,
474
+ // Preserve the prior writer-version too (#1911). `add`/`get` fetch a
475
+ // SINGLE skill — they do NOT perform the full-library reconciliation
476
+ // the membership heal needs, so they must NOT advance the heal gate.
477
+ // Letting `writeLastSync` default-stamp the running version here would
478
+ // let a stuck user "spend" their one-time heal on a single-skill add,
479
+ // leaving every OTHER never-delivered skill stuck forever. `?? null`
480
+ // carries a pre-fix file's absent version forward as an explicit null
481
+ // so the next `update` still heals. Same rationale as etag/syncedAt.
482
+ cliVersion: prior?.cliVersion ?? null,
386
483
  });
387
484
  }
388
485
 
@@ -406,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
406
503
  etag: prior.etag ?? null,
407
504
  syncedAt: prior.syncedAt,
408
505
  skills: rest,
506
+ // Preserve the prior writer-version (#1911) — `remove` is not a full
507
+ // reconciliation, so it must not advance the one-time heal gate.
508
+ // See upsertLastSyncEntry for the full rationale.
509
+ cliVersion: prior.cliVersion ?? null,
409
510
  });
410
511
  }
411
512
 
@@ -417,7 +518,8 @@ export function deleteLastSyncEntry(owner, name) {
417
518
  * 2. Read the last-sync state file
418
519
  * 3. Call GET /api/v1/library with `If-None-Match` (if we have a
419
520
  * cached ETag) AND `since` (always, for delta semantics)
420
- * 4. Short-circuit on 304 return `notModified: true`
521
+ * 4. On 304 — re-assert the on-disk state via a receipt (liveness +
522
+ * heal) and short-circuit with `notModified: true`
421
523
  * 5. For each skill in the response:
422
524
  * a. Write it via `writeSkillDir` — overwrites if changed
423
525
  * b. Skip writing skills with `filesIncomplete: true` (see comment)
@@ -425,7 +527,9 @@ export function deleteLastSyncEntry(owner, name) {
425
527
  * a. Call `removeSkillDir` — deletes from all configured targets
426
528
  * 7. Persist the new ETag (if the response was complete) for the
427
529
  * next sync
428
- * 8. Return the summary
530
+ * 8. Re-assert the full on-disk state via a sync receipt (#1832) —
531
+ * the confirmed-write signal that sets team sync state
532
+ * 9. Return the summary
429
533
  *
430
534
  * Error handling: any thrown error from the network or filesystem
431
535
  * layer propagates unchanged. The caller (a command) decides how to
@@ -517,25 +621,64 @@ export async function runSync(options) {
517
621
  const placementsComplete =
518
622
  lastSync?.etag &&
519
623
  placementsAreComplete(lastSync.skills, vendors, global);
520
- if (placementsComplete) {
624
+
625
+ // One-time membership-heal (#1911). A `.last-sync` written by a CLI below
626
+ // the heal floor predates the server delta-membership fix and may be
627
+ // permanently missing skills that were added to the library but never
628
+ // delivered (their content predated `since`, so the old delta skipped
629
+ // them while the ETag still advanced — the client cached the advanced
630
+ // ETag and every later sync 304'd). The server fix can't rescue such a
631
+ // client: its cached ETag still matches, so it keeps getting 304s. Force
632
+ // ONE full fetch (drop BOTH conditional headers) so the entire library
633
+ // is re-fetched and the gap is filled. `writeLastSync` stamps the running
634
+ // version on the resulting write, so this fires at most once per upgrade.
635
+ //
636
+ // Mixed-version note (benign, do NOT "fix" with a different mechanism): if
637
+ // a pre-4.8.0 CLI shares this same global `.last-sync` and runs after a
638
+ // 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
639
+ // the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
640
+ // is idempotent — and self-resolves once the older install is upgraded.
641
+ const needsFullResync =
642
+ !!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
643
+
644
+ if (placementsComplete && !needsFullResync) {
521
645
  opts.ifNoneMatch = lastSync.etag;
522
646
  }
523
- if (lastSync?.syncedAt && placementsComplete) {
647
+ if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
524
648
  opts.since = lastSync.syncedAt;
525
649
  }
526
650
 
527
651
  // Track whether this is a full or delta sync BEFORE the network
528
- // call, for the returned summary's `fullSync` field. A "full" sync
529
- // is one where no `since` was sent which is exactly when no
530
- // prior last-sync state existed. The distinction matters to
531
- // consumers (init.mjs) that need to tell "empty library" from
532
- // "nothing changed since last sync" in the zero-counters case.
652
+ // call, for the returned summary's `fullSync` field. The documented
653
+ // semantic is "no PRIOR last-sync state existed"NOT "no `since` was
654
+ // sent". A forced full re-fetch (placement-incomplete, or the #1911
655
+ // membership heal) still reports `fullSync:false` because prior state
656
+ // existed; only a genuine first sync reports true. Consumers (init.mjs)
657
+ // rely on this to tell "empty library" from "nothing changed since last
658
+ // sync" in the zero-counters case. Locked by sync.test.mjs.
533
659
  const fullSync = !lastSync?.syncedAt;
534
660
 
535
661
  const result = await getLibrary(serverUrl, apiKey, opts);
536
662
 
537
663
  // Step 4: 304 short-circuit
538
664
  if (result.notModified) {
665
+ // Re-assert the on-disk state even though nothing changed (#1832).
666
+ // The library is unchanged, so what's on disk is exactly the prior
667
+ // `.last-sync` skills map. This receipt:
668
+ // • is the liveness signal that REPLACES the GET-side 304 heartbeat
669
+ // for receipt-era servers (>= 4.7.0 CLIs no longer trigger it), so
670
+ // a deliberately-stable library doesn't drift to "stale"; and
671
+ // • heals any delivery the server missed (a receipt POST that failed
672
+ // on a prior sync), correcting drift in the safe direction.
673
+ // Timestamp is the CLI's "now" so freshness advances; the server's
674
+ // 5-minute future tolerance absorbs ordinary clock skew. Best-effort.
675
+ await reportReceipt(
676
+ serverUrl,
677
+ apiKey,
678
+ receiptEntriesFromSkillsMap(lastSync?.skills),
679
+ new Date().toISOString(),
680
+ stderr,
681
+ );
539
682
  return {
540
683
  added: 0,
541
684
  updated: 0,
@@ -606,11 +749,6 @@ export async function runSync(options) {
606
749
  let anyIncomplete = false;
607
750
  /** @type {Record<string, SyncedSkillEntry>} */
608
751
  const skillsMap = {};
609
- // Skills actually written to disk this sync, for the confirmed-write
610
- // receipt (#1832). Only what we WRITE this round — carry-forward skills
611
- // were reported on the sync that wrote them.
612
- /** @type {{owner: string, name: string, version: string}[]} */
613
- const writtenThisSync = [];
614
752
  // Carry forward entries from the previous sync for skills we did
615
753
  // NOT re-fetch this round. A delta sync that touched 2 of 50
616
754
  // skills must keep the other 48's metadata — otherwise the
@@ -674,17 +812,6 @@ export async function runSync(options) {
674
812
  syncedAt: result.syncedAt,
675
813
  };
676
814
  }
677
-
678
- // Record this write for the sync receipt (#1832). Only entries with a
679
- // real version label can resolve to a delivery server-side, so skip
680
- // null/empty versions (a skill with no current published version).
681
- if (typeof skill.version === "string" && skill.version !== "") {
682
- writtenThisSync.push({
683
- owner: skill.owner,
684
- name: skill.name,
685
- version: skill.version,
686
- });
687
- }
688
815
  }
689
816
 
690
817
  // Step 7: persist new ETag + skills map (only if the response was
@@ -712,33 +839,92 @@ export async function runSync(options) {
712
839
  }
713
840
  }
714
841
 
715
- // Post a sync receipt for the skills actually written this sync
716
- // (#1832). This confirmed-write signal — not the fetch itself — is what
717
- // sets team sync state. Independent of the ETag persist above: skills
718
- // skipped for `filesIncomplete` are excluded from `writtenThisSync`, so
719
- // a partial sync still reports exactly what landed on disk. Best-effort:
720
- // the files are already written and the server dedups replays, so a
721
- // failed receipt is a non-fatal warning, never a sync failure.
722
- if (writtenThisSync.length > 0) {
723
- try {
724
- await postSyncReceipt(serverUrl, apiKey, {
725
- skills: writtenThisSync,
726
- syncedAt: result.syncedAt,
727
- });
728
- } catch (err) {
729
- stderr.write(
730
- ` warning: failed to report sync receipt (${err.message}). ` +
731
- `Your skills are synced locally; team sync status may lag until ` +
732
- `your next sync.\n`,
733
- );
734
- }
735
- }
842
+ // Re-assert the full on-disk state via a sync receipt (#1832). This
843
+ // confirmed-write signal — not the fetch itself — is what sets team
844
+ // sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
845
+ // carry-forward skills + the skills written this round), not just what
846
+ // changed, so the receipt heals any delivery the server missed (a prior
847
+ // receipt POST that failed) and re-stamps freshness, replacing the
848
+ // GET-side heartbeat. `skillsMap` already reflects exactly what is on
849
+ // disk: skills skipped for `filesIncomplete` keep their prior carry-
850
+ // forward version (still on disk) and tombstoned skills were deleted
851
+ // from the map, so a partial or delta sync still re-asserts precisely
852
+ // what landed on disk. The server dedups (user, skill, version, UTC
853
+ // day), so re-asserting unchanged versions doesn't bloat the event log.
854
+ // Best-effort: files are already written, so a failed receipt is a
855
+ // non-fatal warning, never a sync failure.
856
+ await reportReceipt(
857
+ serverUrl,
858
+ apiKey,
859
+ receiptEntriesFromSkillsMap(skillsMap),
860
+ result.syncedAt,
861
+ stderr,
862
+ );
736
863
 
737
864
  return summary;
738
865
  }
739
866
 
740
867
  // ── Internals ──────────────────────────────────────────────────────────
741
868
 
869
+ /**
870
+ * Build sync-receipt entries from a `.last-sync` skills map (#1832).
871
+ *
872
+ * Each key is `"<owner>/<name>"`; the value carries the version label of
873
+ * the skill as written to disk. The returned `[{ owner, name, version }]`
874
+ * array is the CLI's assertion of what it actually has on disk — the
875
+ * evidence the server treats as the single source of truth for sync
876
+ * state. Entries with no version label are skipped: a skill with no
877
+ * published version can't resolve to a delivery server-side. Malformed
878
+ * keys (no `/`, empty owner/name) are skipped defensively rather than
879
+ * producing a bogus receipt entry.
880
+ *
881
+ * @param {Record<string, {version?: string}> | null | undefined} skillsMap
882
+ * @returns {{owner: string, name: string, version: string}[]}
883
+ */
884
+ function receiptEntriesFromSkillsMap(skillsMap) {
885
+ /** @type {{owner: string, name: string, version: string}[]} */
886
+ const entries = [];
887
+ if (!skillsMap || typeof skillsMap !== "object") return entries;
888
+ for (const [key, entry] of Object.entries(skillsMap)) {
889
+ if (!entry || typeof entry !== "object") continue;
890
+ const version = typeof entry.version === "string" ? entry.version : "";
891
+ if (version === "") continue;
892
+ const slashAt = key.indexOf("/");
893
+ if (slashAt < 0) continue;
894
+ const owner = key.slice(0, slashAt);
895
+ const name = key.slice(slashAt + 1);
896
+ if (!owner || !name) continue;
897
+ entries.push({ owner, name, version });
898
+ }
899
+ return entries;
900
+ }
901
+
902
+ /**
903
+ * Post a sync receipt, best-effort (#1832). A no-op when `skills` is
904
+ * empty (nothing on disk to assert — e.g. an empty library). The skill
905
+ * files are already on disk and the server dedups replays, so a failed
906
+ * receipt is a non-fatal warning surfaced via the injected stderr
907
+ * stream — never a sync failure.
908
+ *
909
+ * @param {string} serverUrl
910
+ * @param {string} apiKey
911
+ * @param {{owner: string, name: string, version: string}[]} skills
912
+ * @param {string} syncedAt - ISO timestamp for the delivery + dedup key.
913
+ * @param {NodeJS.WritableStream} stderr
914
+ */
915
+ async function reportReceipt(serverUrl, apiKey, skills, syncedAt, stderr) {
916
+ if (skills.length === 0) return;
917
+ try {
918
+ await postSyncReceipt(serverUrl, apiKey, { skills, syncedAt });
919
+ } catch (err) {
920
+ stderr.write(
921
+ ` warning: failed to report sync receipt (${err.message}). ` +
922
+ `Your skills are synced locally; team sync status may lag until ` +
923
+ `your next sync.\n`,
924
+ );
925
+ }
926
+ }
927
+
742
928
  /**
743
929
  * Check whether ANY of the configured placement targets already
744
930
  * contains a directory for the given skill name. Used to distinguish
@@ -571,21 +571,29 @@ export function createMockServer(initialPayload, options = {}) {
571
571
  // newly-detected vendor placements empty forever. Mirroring the
572
572
  // filter here means any future regression of that class fails
573
573
  // at test time. See src/lib/queries/library.ts in the server
574
- // for the production behavior (`gt(skills.updatedAt, since)`).
574
+ // for the production behavior. As of #1911 the production delta is
575
+ // `or(gt(skills.updatedAt, since), gt(skillLibraryItems.addedAt, since))`
576
+ // — a membership add delivers a skill whose CONTENT predates `since`.
577
+ // Mirror BOTH arms here, otherwise no CLI-layer test can exercise the
578
+ // addedAt branch and a server-side revert of it would pass every CLI
579
+ // test. Fixture skills may carry an optional `addedAt` ISO string to
580
+ // drive that arm; absent → only the updatedAt arm applies.
575
581
  let respondedSkills = libraryResponse.skills ?? [];
576
582
  if (sinceParam && Array.isArray(respondedSkills)) {
577
583
  const sinceDate = new Date(sinceParam);
578
584
  if (!Number.isNaN(sinceDate.getTime())) {
579
- respondedSkills = respondedSkills.filter((s) => {
580
- // updatedAt is a string ISO timestamp in the fixture skills.
581
- // Missing/invalid treat as "older than since" → exclude
582
- // (matches the production semantic of "not modified in
583
- // the window").
584
- if (typeof s.updatedAt !== "string") return false;
585
- const updated = new Date(s.updatedAt);
586
- if (Number.isNaN(updated.getTime())) return false;
587
- return updated.getTime() > sinceDate.getTime();
588
- });
585
+ const afterSince = (iso) => {
586
+ // Missing/invalid treat as "older than since" (excluded by
587
+ // this arm), matching the production semantic of "not modified
588
+ // / not added in the window".
589
+ if (typeof iso !== "string") return false;
590
+ const t = new Date(iso);
591
+ if (Number.isNaN(t.getTime())) return false;
592
+ return t.getTime() > sinceDate.getTime();
593
+ };
594
+ respondedSkills = respondedSkills.filter(
595
+ (s) => afterSince(s.updatedAt) || afterSince(s.addedAt),
596
+ );
589
597
  }
590
598
  }
591
599
 
@@ -38,7 +38,11 @@ import {
38
38
  runSync,
39
39
  readLastSync,
40
40
  writeLastSync,
41
+ upsertLastSyncEntry,
42
+ deleteLastSyncEntry,
41
43
  placementsAreComplete,
44
+ cliVersionBelowFloor,
45
+ FULL_RESYNC_FLOOR,
42
46
  LAST_SYNC_SCHEMA_VERSION,
43
47
  } from "../../lib/sync.mjs";
44
48
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
@@ -263,6 +267,22 @@ describe("readLastSync / writeLastSync", () => {
263
267
  writeLastSync({ etag: "x", syncedAt: "x" });
264
268
  assert.ok(existsSync(globalLastSyncPath()));
265
269
  });
270
+
271
+ it("stamps cliVersion with the running CLI version by default (#1911)", () => {
272
+ // Every write records the writer's version so the heal gate
273
+ // (cliVersionBelowFloor) can tell on the next run whether the state
274
+ // predates the #1911 fix. getCliVersion() reads the real
275
+ // packages/cli/package.json, so this is a genuine semver.
276
+ writeLastSync({ etag: '"v"', syncedAt: "x" });
277
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
278
+ assert.match(onDisk.cliVersion, /^\d+\.\d+\.\d+/);
279
+ });
280
+
281
+ it("honors an explicit cliVersion override (test seam for pre-fix state) (#1911)", () => {
282
+ writeLastSync({ etag: '"v"', syncedAt: "x", cliVersion: "4.7.0" });
283
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
284
+ assert.equal(onDisk.cliVersion, "4.7.0");
285
+ });
266
286
  });
267
287
 
268
288
  // ── runSync — empty library ────────────────────────────────────────────
@@ -415,7 +435,7 @@ describe("runSync — sync receipts (#1832)", () => {
415
435
  }
416
436
  });
417
437
 
418
- it("does NOT post a receipt on a 304 (nothing written)", async () => {
438
+ it("re-asserts the on-disk state on a 304 (liveness + heal), not nothing", async () => {
419
439
  server.setEtag('"v1"');
420
440
  server.setLibraryResponse({
421
441
  skills: [makeSkill("pdf-helper")],
@@ -427,14 +447,120 @@ describe("runSync — sync receipts (#1832)", () => {
427
447
  assert.equal(server.getReceiptRequestCount(), 1);
428
448
  server.resetReceipts();
429
449
 
430
- // Second sync 304-short-circuits (etag matches, placement present).
450
+ // Second sync 304-short-circuits (etag matches, placement present) but
451
+ // STILL posts a receipt re-asserting the on-disk set. This is the
452
+ // liveness signal that replaces the GET-side 304 heartbeat for
453
+ // receipt-era servers, and it heals any delivery a prior failed
454
+ // receipt POST lost (#1832 PR B).
431
455
  const result = await runSync({
432
456
  serverUrl,
433
457
  apiKey: VALID_KEY,
434
458
  vendors: ["claudeCode"],
435
459
  });
436
460
  assert.equal(result.notModified, true);
437
- assert.equal(server.getReceiptRequestCount(), 0);
461
+ assert.equal(server.getReceiptRequestCount(), 1);
462
+ const receipt = server.getLastReceipt();
463
+ assert.deepEqual(
464
+ receipt.skills.map((s) => s.name),
465
+ ["pdf-helper"],
466
+ );
467
+ assert.equal(receipt.skills[0].version, "1.0.0");
468
+ // syncedAt on a 304 is the CLI's "now" (the server sends no body),
469
+ // so freshness advances — not the stale first-sync timestamp.
470
+ assert.ok(
471
+ Number.isFinite(new Date(receipt.syncedAt).getTime()),
472
+ "304 receipt carries a valid ISO syncedAt",
473
+ );
474
+ assert.notEqual(
475
+ receipt.syncedAt,
476
+ "2025-06-01T00:00:00Z",
477
+ "304 receipt uses the CLI's now, not the prior sync timestamp",
478
+ );
479
+ // Stronger than "not the fixture": prove the timestamp is wall-clock
480
+ // FRESH. A regression that stamped the receipt with any stale cached
481
+ // value (e.g. lastSync.syncedAt) could differ from the fixture and
482
+ // still pass the notEqual above — but it would fail this. The liveness
483
+ // signal that replaces the 304 heartbeat must advance every sync.
484
+ assert.ok(
485
+ Date.now() - new Date(receipt.syncedAt).getTime() < 60_000,
486
+ "304 receipt syncedAt must be wall-clock fresh (within the last minute)",
487
+ );
488
+ });
489
+
490
+ it("re-asserts the full on-disk set (carry-forward + newly written) on a delta sync", async () => {
491
+ server.setEtag('"v1"');
492
+ server.setLibraryResponse({
493
+ skills: [makeSkill("alpha"), makeSkill("beta")],
494
+ removals: [],
495
+ syncedAt: "2025-01-01T00:00:00Z",
496
+ });
497
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
498
+ server.resetReceipts();
499
+
500
+ // Library changes: only `gamma` is new this round; alpha+beta are
501
+ // unchanged and arrive only via the carry-forward `.last-sync` map.
502
+ server.setEtag('"v2"');
503
+ server.setLibraryResponse({
504
+ skills: [makeSkill("gamma")],
505
+ removals: [],
506
+ syncedAt: "2025-01-02T00:00:00Z",
507
+ });
508
+ const result = await runSync({
509
+ serverUrl,
510
+ apiKey: VALID_KEY,
511
+ vendors: ["claudeCode"],
512
+ });
513
+
514
+ assert.equal(result.notModified, false);
515
+ assert.equal(server.getReceiptRequestCount(), 1);
516
+ const receipt = server.getLastReceipt();
517
+ // The receipt re-asserts EVERYTHING on disk, not just the changed
518
+ // skill — that is what heals server drift and keeps freshness honest.
519
+ assert.deepEqual(
520
+ receipt.skills.map((s) => s.name).sort(),
521
+ ["alpha", "beta", "gamma"],
522
+ );
523
+ // A 200 uses the server's response syncedAt for the delivery time.
524
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
525
+ });
526
+
527
+ it("re-asserts carry-forward on a 200 delta that writes nothing new", async () => {
528
+ // Distinct from the 304 short-circuit: here the ETag changes (a real
529
+ // 200) but the response carries no skills to write, so nothing new
530
+ // lands on disk. The receipt must still re-assert the full carry-
531
+ // forward set — the 200 path's re-assertion is driven by the on-disk
532
+ // `.last-sync` map, not by what was written this round.
533
+ server.setEtag('"v1"');
534
+ server.setLibraryResponse({
535
+ skills: [makeSkill("alpha"), makeSkill("beta")],
536
+ removals: [],
537
+ syncedAt: "2025-01-01T00:00:00Z",
538
+ });
539
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
540
+ server.resetReceipts();
541
+
542
+ server.setEtag('"v2"');
543
+ server.setLibraryResponse({
544
+ skills: [],
545
+ removals: [],
546
+ syncedAt: "2025-01-02T00:00:00Z",
547
+ });
548
+ const result = await runSync({
549
+ serverUrl,
550
+ apiKey: VALID_KEY,
551
+ vendors: ["claudeCode"],
552
+ });
553
+
554
+ assert.equal(result.notModified, false);
555
+ assert.equal(result.added, 0);
556
+ assert.equal(result.updated, 0);
557
+ assert.equal(server.getReceiptRequestCount(), 1);
558
+ const receipt = server.getLastReceipt();
559
+ assert.deepEqual(
560
+ receipt.skills.map((s) => s.name).sort(),
561
+ ["alpha", "beta"],
562
+ );
563
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
438
564
  });
439
565
 
440
566
  it("excludes a filesIncomplete skill from the receipt", async () => {
@@ -454,9 +580,48 @@ describe("runSync — sync receipts (#1832)", () => {
454
580
  );
455
581
  });
456
582
 
457
- it("does NOT post a receipt when ALL skills are filesIncomplete", async () => {
458
- // Distinct branch from the mixed case above: `writtenThisSync` stays
459
- // empty, so the `writtenThisSync.length > 0` guard skips the POST.
583
+ it("re-asserts the OLD on-disk version when a newer version fails to inline (filesIncomplete carry-forward)", async () => {
584
+ // First sync writes pdf-helper@1.0.0.
585
+ server.setEtag('"v1"');
586
+ server.setLibraryResponse({
587
+ skills: [makeSkill("pdf-helper")],
588
+ removals: [],
589
+ syncedAt: "2025-01-01T00:00:00Z",
590
+ });
591
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
592
+ server.resetReceipts();
593
+
594
+ // The server tries to deliver pdf-helper@1.1.0 but its files fail to
595
+ // inline. The CLI skips the write (1.0.0 stays on disk) and must
596
+ // re-assert the OLD version it still has — not the version it failed
597
+ // to write, and not nothing.
598
+ const newer = makeSkill("pdf-helper");
599
+ newer.version = "1.1.0";
600
+ newer.filesIncomplete = true;
601
+ server.setEtag('"v2"');
602
+ server.setLibraryResponse({
603
+ skills: [newer],
604
+ removals: [],
605
+ syncedAt: "2025-01-02T00:00:00Z",
606
+ });
607
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
608
+
609
+ assert.equal(server.getReceiptRequestCount(), 1);
610
+ const receipt = server.getLastReceipt();
611
+ assert.deepEqual(receipt.skills, [
612
+ { owner: "alice", name: "pdf-helper", version: "1.0.0" },
613
+ ]);
614
+ // Freshness still advances on a partial sync: the receipt carries the
615
+ // SERVER's 200 response syncedAt (the fresh sync time), not the stale
616
+ // carry-forward entry's own recorded syncedAt.
617
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
618
+ });
619
+
620
+ it("does NOT post a receipt when ALL skills are filesIncomplete (nothing on disk to assert)", async () => {
621
+ // A first sync where every skill is filesIncomplete writes nothing
622
+ // and has no carry-forward `.last-sync` baseline, so the post-sync
623
+ // on-disk set is empty and `reportReceipt`'s empty-skills guard skips
624
+ // the POST.
460
625
  const a = makeSkill("inc-a");
461
626
  a.filesIncomplete = true;
462
627
  const b = makeSkill("inc-b");
@@ -821,10 +986,41 @@ describe("runSync — tombstones", () => {
821
986
  });
822
987
  assert.equal(result.removed, 1);
823
988
  assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
824
- // A tombstone-only sync writes nothing posts no receipt (#1832).
989
+ // The `.last-sync` baseline was reset (rm above) and the only skill
990
+ // was tombstoned, so the post-sync on-disk set is empty → the
991
+ // re-assertion receipt has nothing to send and is skipped (#1832).
825
992
  assert.equal(server.getReceiptRequestCount(), 0);
826
993
  });
827
994
 
995
+ it("re-asserts surviving skills but excludes a tombstoned one", async () => {
996
+ server.setEtag('"v1"');
997
+ server.setLibraryResponse({
998
+ skills: [makeSkill("keep"), makeSkill("drop")],
999
+ removals: [],
1000
+ syncedAt: "2025-01-01T00:00:00Z",
1001
+ });
1002
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1003
+ server.resetReceipts();
1004
+
1005
+ // `drop` is tombstoned; `keep` stays on disk and must be re-asserted.
1006
+ server.setEtag('"v2"');
1007
+ server.setLibraryResponse({
1008
+ skills: [],
1009
+ removals: [
1010
+ { owner: "alice", name: "drop", removedAt: "2025-01-02T00:00:00Z" },
1011
+ ],
1012
+ syncedAt: "2025-01-02T00:00:00Z",
1013
+ });
1014
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1015
+
1016
+ assert.equal(server.getReceiptRequestCount(), 1);
1017
+ const receipt = server.getLastReceipt();
1018
+ assert.deepEqual(
1019
+ receipt.skills.map((s) => s.name),
1020
+ ["keep"],
1021
+ );
1022
+ });
1023
+
828
1024
  it("applies removals BEFORE writes (re-add scenario)", async () => {
829
1025
  // The server returns a tombstone AND a skill with the same name
830
1026
  // (the user removed and re-added in the same window). The CLI
@@ -950,6 +1146,286 @@ describe("runSync — ETag round-trip", () => {
950
1146
 
951
1147
  // ── runSync — argument validation ──────────────────────────────────────
952
1148
 
1149
+ describe("runSync — one-time membership heal (#1911)", () => {
1150
+ beforeEach(setupServer);
1151
+ afterEach(teardownServer);
1152
+
1153
+ it("cliVersionBelowFloor: missing/invalid/older → true; equal/newer → false", () => {
1154
+ assert.equal(cliVersionBelowFloor(undefined, "4.8.0"), true);
1155
+ assert.equal(cliVersionBelowFloor(null, "4.8.0"), true);
1156
+ assert.equal(cliVersionBelowFloor("", "4.8.0"), true);
1157
+ assert.equal(cliVersionBelowFloor("not-semver", "4.8.0"), true);
1158
+ assert.equal(cliVersionBelowFloor("4.7.0", "4.8.0"), true);
1159
+ assert.equal(cliVersionBelowFloor("4.7.9", "4.8.0"), true);
1160
+ assert.equal(cliVersionBelowFloor("4.8.0", "4.8.0"), false);
1161
+ assert.equal(cliVersionBelowFloor("4.8.1", "4.8.0"), false);
1162
+ assert.equal(cliVersionBelowFloor("5.0.0", "4.8.0"), false);
1163
+ });
1164
+
1165
+ it("FULL_RESYNC_FLOOR equals the shipped CLI version", () => {
1166
+ // The floor MUST equal the version that ships this fix; otherwise the
1167
+ // running CLI's own writes would stamp a version below the floor and
1168
+ // it would re-heal on every run. Lock them together.
1169
+ const pkg = JSON.parse(
1170
+ readFileSync(new URL("../../../package.json", import.meta.url), "utf-8"),
1171
+ );
1172
+ assert.equal(FULL_RESYNC_FLOOR, pkg.version);
1173
+ });
1174
+
1175
+ it("a pre-fix .last-sync forces ONE full re-fetch, then resumes delta", async () => {
1176
+ server.setEtag('"v1"');
1177
+ server.setLibraryResponse({
1178
+ skills: [makeSkill("healme")],
1179
+ removals: [],
1180
+ syncedAt: "2025-01-01T00:00:00Z",
1181
+ });
1182
+
1183
+ // 1) Normal sync by the current CLI → disk + .last-sync stamped with
1184
+ // the current (>= floor) version.
1185
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1186
+
1187
+ // 2) Rewrite .last-sync to mimic a PRE-#1911 writer: identical
1188
+ // etag/skills/syncedAt — so placements are complete and a 304 WOULD
1189
+ // normally fire — but stamped below the heal floor.
1190
+ const prior = readLastSync();
1191
+ writeLastSync({
1192
+ etag: prior.etag,
1193
+ syncedAt: prior.syncedAt,
1194
+ skills: prior.skills,
1195
+ cliVersion: "4.7.0",
1196
+ });
1197
+ server.resetLibraryInspection();
1198
+
1199
+ // 3) Heal: must force a FULL fetch — NEITHER conditional header sent —
1200
+ // despite complete placements and a matching etag.
1201
+ const heal = await runSync({
1202
+ serverUrl,
1203
+ apiKey: VALID_KEY,
1204
+ vendors: ["claudeCode"],
1205
+ });
1206
+ assert.equal(
1207
+ server.getLastLibraryIfNoneMatch(),
1208
+ null,
1209
+ "heal must NOT send If-None-Match",
1210
+ );
1211
+ assert.equal(server.getLastLibrarySince(), null, "heal must NOT send since");
1212
+ assert.equal(heal.notModified, false, "heal performs a real fetch, not a 304");
1213
+
1214
+ // 4) The heal write re-stamped the current version → the next sync
1215
+ // resumes normal delta (sends the conditional, gets a 304).
1216
+ const after = readLastSync();
1217
+ assert.equal(
1218
+ cliVersionBelowFloor(after.cliVersion, FULL_RESYNC_FLOOR),
1219
+ false,
1220
+ "heal must restamp cliVersion at/above the floor",
1221
+ );
1222
+ server.resetLibraryInspection();
1223
+ const resumed = await runSync({
1224
+ serverUrl,
1225
+ apiKey: VALID_KEY,
1226
+ vendors: ["claudeCode"],
1227
+ });
1228
+ assert.equal(
1229
+ server.getLastLibraryIfNoneMatch(),
1230
+ '"v1"',
1231
+ "post-heal sync sends If-None-Match",
1232
+ );
1233
+ assert.equal(
1234
+ resumed.notModified,
1235
+ true,
1236
+ "post-heal sync short-circuits on 304",
1237
+ );
1238
+ });
1239
+
1240
+ it("does NOT force a re-fetch when .last-sync was written at/above the floor", async () => {
1241
+ server.setEtag('"v1"');
1242
+ server.setLibraryResponse({
1243
+ skills: [makeSkill("steady")],
1244
+ removals: [],
1245
+ syncedAt: "2025-01-01T00:00:00Z",
1246
+ });
1247
+ // First sync stamps the current (>= floor) version.
1248
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1249
+ server.resetLibraryInspection();
1250
+
1251
+ const second = await runSync({
1252
+ serverUrl,
1253
+ apiKey: VALID_KEY,
1254
+ vendors: ["claudeCode"],
1255
+ });
1256
+ assert.equal(
1257
+ server.getLastLibraryIfNoneMatch(),
1258
+ '"v1"',
1259
+ "steady-state sync still sends the conditional (no spurious heal)",
1260
+ );
1261
+ assert.equal(second.notModified, true, "no heal → normal 304");
1262
+ });
1263
+
1264
+ it("heals a REAL pre-fix v2 .last-sync (cliVersion key absent, not just old)", async () => {
1265
+ // The actual on-disk shape for users still on < 4.8.0 is a v2 file with
1266
+ // NO `cliVersion` key at all (writeLastSync didn't emit it pre-#1911) —
1267
+ // distinct from an explicit "4.7.0". Prove the heal fires for that shape
1268
+ // end-to-end through runSync (the unit test of cliVersionBelowFloor(undefined)
1269
+ // covers the gate in isolation; this covers the full path).
1270
+ server.setEtag('"v1"');
1271
+ server.setLibraryResponse({
1272
+ skills: [makeSkill("healme2")],
1273
+ removals: [],
1274
+ syncedAt: "2025-01-01T00:00:00Z",
1275
+ });
1276
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1277
+ const prior = readLastSync();
1278
+
1279
+ // Rewrite as a genuine pre-#1911 v2 file: cliVersion key entirely absent.
1280
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
1281
+ writeFileSync(
1282
+ globalLastSyncPath(),
1283
+ JSON.stringify({
1284
+ schemaVersion: 2,
1285
+ etag: prior.etag,
1286
+ syncedAt: prior.syncedAt,
1287
+ skills: prior.skills,
1288
+ }),
1289
+ );
1290
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
1291
+ assert.equal(
1292
+ Object.prototype.hasOwnProperty.call(onDisk, "cliVersion"),
1293
+ false,
1294
+ "precondition: cliVersion must be ABSENT, not old",
1295
+ );
1296
+ server.resetLibraryInspection();
1297
+
1298
+ const heal = await runSync({
1299
+ serverUrl,
1300
+ apiKey: VALID_KEY,
1301
+ vendors: ["claudeCode"],
1302
+ });
1303
+ assert.equal(server.getLastLibraryIfNoneMatch(), null, "absent cliVersion must heal");
1304
+ assert.equal(server.getLastLibrarySince(), null, "absent cliVersion must heal");
1305
+ assert.equal(heal.notModified, false);
1306
+ assert.equal(
1307
+ cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
1308
+ false,
1309
+ "heal restamps a real version so it won't re-heal",
1310
+ );
1311
+ });
1312
+
1313
+ it("delta delivers a skill whose content predates `since` but was added after it", async () => {
1314
+ // The CLI-layer counterpart of the server fix: a steady-state DELTA sync
1315
+ // (since sent, NOT a heal) must still receive a skill whose updatedAt is
1316
+ // older than `since` when its addedAt is newer. The mock mirrors the
1317
+ // server's `or(updatedAt, addedAt)` filter so a server-side revert of the
1318
+ // addedAt branch would fail here, not silently pass.
1319
+ const S = "2025-06-01T00:00:00Z";
1320
+ server.setEtag('"e1"');
1321
+ server.setLibraryResponse({
1322
+ skills: [{ ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" }],
1323
+ removals: [],
1324
+ syncedAt: S,
1325
+ });
1326
+ // Baseline sync: puts `base` on disk, stamps current version (no heal).
1327
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1328
+
1329
+ // A new skill enters the library: OLD content (updatedAt < S), just ADDED
1330
+ // (addedAt > S). Library etag changes (not a 304).
1331
+ server.setEtag('"e2"');
1332
+ server.setLibraryResponse({
1333
+ skills: [
1334
+ { ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" },
1335
+ {
1336
+ ...makeSkill("added-old"),
1337
+ updatedAt: "2025-01-01T00:00:00Z",
1338
+ addedAt: "2025-12-01T00:00:00Z",
1339
+ },
1340
+ ],
1341
+ removals: [],
1342
+ syncedAt: "2025-12-02T00:00:00Z",
1343
+ });
1344
+ server.resetLibraryInspection();
1345
+ const result = await runSync({
1346
+ serverUrl,
1347
+ apiKey: VALID_KEY,
1348
+ vendors: ["claudeCode"],
1349
+ });
1350
+
1351
+ assert.equal(server.getLastLibrarySince(), S, "must be a DELTA (since sent), not a heal");
1352
+ assert.ok(
1353
+ existsSync(join(resolvePlacementDir("claudeProject", "added-old"), "SKILL.md")),
1354
+ "addedAt-only skill must be delivered via the membership branch",
1355
+ );
1356
+ assert.equal(result.added, 1, "the added-old skill counts as newly added");
1357
+ });
1358
+
1359
+ it("upsertLastSyncEntry preserves a pre-fix cliVersion so the heal isn't burned", () => {
1360
+ // A stuck pre-fix client whose FIRST 4.8.0 command is `add`/`get`
1361
+ // (→ upsertLastSyncEntry) must NOT have its heal-gate version advanced —
1362
+ // add fetches ONE skill, not the full library, so other stuck skills
1363
+ // remain missing and the next `update` must still heal.
1364
+ writeLastSync({
1365
+ etag: '"e"',
1366
+ syncedAt: "2025-01-01T00:00:00Z",
1367
+ skills: {},
1368
+ cliVersion: "4.7.0",
1369
+ });
1370
+ upsertLastSyncEntry({
1371
+ owner: "alice",
1372
+ name: "added",
1373
+ version: "1.0.0",
1374
+ files: [
1375
+ { path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
1376
+ ],
1377
+ });
1378
+ assert.equal(
1379
+ readLastSync().cliVersion,
1380
+ "4.7.0",
1381
+ "add/get must not advance the heal-gate version",
1382
+ );
1383
+ });
1384
+
1385
+ it("upsertLastSyncEntry carries an ABSENT cliVersion forward (still below floor)", () => {
1386
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
1387
+ writeFileSync(
1388
+ globalLastSyncPath(),
1389
+ JSON.stringify({ schemaVersion: 2, etag: '"e"', syncedAt: "s", skills: {} }),
1390
+ );
1391
+ upsertLastSyncEntry({
1392
+ owner: "alice",
1393
+ name: "added",
1394
+ version: "1.0.0",
1395
+ files: [
1396
+ { path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
1397
+ ],
1398
+ });
1399
+ assert.equal(
1400
+ cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
1401
+ true,
1402
+ "absent cliVersion stays below floor → next update still heals",
1403
+ );
1404
+ });
1405
+
1406
+ it("deleteLastSyncEntry preserves a pre-fix cliVersion", () => {
1407
+ writeLastSync({
1408
+ etag: '"e"',
1409
+ syncedAt: "s",
1410
+ skills: {
1411
+ "alice/x": {
1412
+ version: "1.0.0",
1413
+ skillMdSha256: "a".repeat(64),
1414
+ filesSha256: "b".repeat(64),
1415
+ syncedAt: "s",
1416
+ },
1417
+ },
1418
+ cliVersion: "4.7.0",
1419
+ });
1420
+ deleteLastSyncEntry("alice", "x");
1421
+ assert.equal(
1422
+ readLastSync().cliVersion,
1423
+ "4.7.0",
1424
+ "remove must not advance the heal-gate version",
1425
+ );
1426
+ });
1427
+ });
1428
+
953
1429
  describe("runSync — input validation", () => {
954
1430
  beforeEach(setupServer);
955
1431
  afterEach(teardownServer);