skillrepo 4.17.0 → 4.17.1

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.17.0",
3
+ "version": "4.17.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library, synced across your dev environment",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,12 @@ export function formatLibraryJson(rows, metaByKey) {
46
46
  return {
47
47
  owner: r.owner,
48
48
  name: r.name,
49
+ // #2824 R1/R4 — `version` is the INSTALLED (on-disk) version;
50
+ // `servedVersion` is the server's served/available version. Distinct,
51
+ // correctly-labeled fields (pre-#2824 the top-level `version` was the
52
+ // server's, with no installed-version field at all).
49
53
  version: r.version,
54
+ servedVersion: r.servedVersion ?? null,
50
55
  description: meta?.description ?? null,
51
56
  updatedAt: meta?.updatedAt ?? null,
52
57
  filesIncomplete: meta?.filesIncomplete ?? false,
@@ -101,7 +106,10 @@ export function formatSkillsetJson({ skillsetRef, repoName, rows, metaByKey, com
101
106
  return {
102
107
  owner: r.owner,
103
108
  name: r.name,
109
+ // #2824 R1/R4 — installed (disk) version + the server's served/
110
+ // available version as distinct fields, mirroring the whole-library JSON.
104
111
  version: r.version,
112
+ servedVersion: r.servedVersion ?? null,
105
113
  // description/updatedAt mirror the whole-library JSON (they come
106
114
  // from the probe's manifest response) so both views' machine
107
115
  // output matches too, not just the tables.
@@ -472,15 +480,28 @@ export function renderSignal(row, useGlyphs, variant = "library") {
472
480
  case SIGNAL.EDITED:
473
481
  return { chip: g("✎ edited", "EDITED"), suffix: "local changes — sync won't overwrite" };
474
482
  case SIGNAL.SERVING: {
475
- // Governance rollback (#2787 SERVING): the row's own `version` is the
476
- // served fallback; `governance.recalledVersion` is what was pulled.
483
+ // Governance rollback (#2787 SERVING). The Version column shows the
484
+ // INSTALLED version on disk (#2824 R1); this reason names what was
485
+ // recalled (`governance.recalledVersion`) and what the SERVER now serves
486
+ // (`row.servedVersion`). It reads `servedVersion`, NOT `row.version` —
487
+ // `row.version` is the disk version, not the rollback.
477
488
  const recalled = row.governance?.recalledVersion ?? null;
478
- const served = row.version ?? null;
489
+ const served = row.servedVersion ?? null;
490
+ // "run update" ONLY when the installed version still differs from the
491
+ // served rollback — i.e. you still hold the recalled version and syncing
492
+ // actually changes something. Once you are synced to the rollback
493
+ // (installed === served), SERVING persists to flag the rollback, but
494
+ // there is nothing to update — a "run update" CTA there is a no-op that
495
+ // erodes trust in the guidance (reviewer finding, #2824). This is the
496
+ // common steady state AFTER a user complies with a recall.
497
+ const onRollback = served !== null && served === (row.version ?? null);
479
498
  return {
480
499
  chip: g("⚠ serving", "SERVING"),
481
500
  suffix:
482
501
  recalled && served
483
- ? `${recalled} recalled — rolled back to ${served}`
502
+ ? onRollback
503
+ ? `${recalled} recalled — server serves ${served}`
504
+ : `${recalled} recalled — server serves ${served} — run update`
484
505
  : "a newer version was recalled — serving a rollback",
485
506
  };
486
507
  }
@@ -495,7 +516,9 @@ export function renderSignal(row, useGlyphs, variant = "library") {
495
516
  // declared repo already flags as non-compliant and re-syncs.)
496
517
  return { chip: g("⚠ withdrawn", "WITHDRAWN"), suffix: "no longer served — remove?" };
497
518
  case SIGNAL.BEHIND: {
498
- const v = row.version ?? null;
519
+ // The "available" version is what the SERVER serves (#2824 R2), NOT
520
+ // `row.version` — that is the older installed version on disk.
521
+ const v = row.servedVersion ?? null;
499
522
  return {
500
523
  chip: g("↑ behind", "BEHIND"),
501
524
  suffix: v ? `${v} available — run update` : "a newer version is available — run update",
@@ -565,7 +588,15 @@ function computeColWidths(terminalColumns) {
565
588
  return [skillCol, versionCol, stateCol, updatedCol, descCol];
566
589
  }
567
590
 
568
- function truncate(s, n) {
591
+ /**
592
+ * Cap a string at `n` visible chars, ellipsizing the overflow. Exported for
593
+ * direct unit testing (#2824 audit): the integration table test cannot guard
594
+ * this — cli-table3's own `wordWrap` produces an ellipsis and drops the full
595
+ * string regardless of whether this ran, so removing it left every table
596
+ * assertion green. This is a real cap (it bounds ROW HEIGHT for very long
597
+ * descriptions before cli-table3 wraps), so it is unit-tested on its own.
598
+ */
599
+ export function truncate(s, n) {
569
600
  if (s.length <= n) return s;
570
601
  return s.slice(0, n - 1) + "…";
571
602
  }
@@ -290,7 +290,11 @@ export function classifyRows({
290
290
  finalizeRow({
291
291
  owner: exp.owner,
292
292
  name: exp.name,
293
+ // Served but guard-refused → nothing of ours on disk. Show the
294
+ // declared version (what it WOULD be), marked not-installed by the
295
+ // classification. No server-truth lookup happens on this branch.
293
296
  version: exp.version ?? null,
297
+ servedVersion: null,
294
298
  classification: CLASSIFICATION.NOT_DELIVERED,
295
299
  local: LOCAL_STATE.MISSING,
296
300
  governance: null,
@@ -313,7 +317,12 @@ export function classifyRows({
313
317
  finalizeRow({
314
318
  owner: exp.owner,
315
319
  name: exp.name,
320
+ // Not on disk → there is no installed version to report. The spec's
321
+ // not-installed rule shows the server/expected version instead; the
322
+ // MISSING classification is what marks it not-installed. `version`
323
+ // and `servedVersion` coincide here — both are the server's.
316
324
  version: server?.servedVersion ?? exp.version ?? baseline?.version ?? null,
325
+ servedVersion: server?.servedVersion ?? null,
317
326
  classification: CLASSIFICATION.MISSING,
318
327
  local: LOCAL_STATE.MISSING,
319
328
  governance: server?.governance ?? null,
@@ -333,7 +342,15 @@ export function classifyRows({
333
342
  finalizeRow({
334
343
  owner: exp.owner,
335
344
  name: exp.name,
336
- version: server?.servedVersion ?? exp.version ?? baseline?.version ?? null,
345
+ // #2824 R1 — THE fix. The headline version is the INSTALLED version:
346
+ // the `.last-sync` baseline (what the last sync wrote to disk). It is
347
+ // NEVER the server's served version, which would read false for a
348
+ // recalled-not-synced / behind / restored-not-synced row (you would be
349
+ // told you are on a version you are not). `null` when there is no
350
+ // baseline (an on-disk-but-never-synced row, which `local` already
351
+ // reports as MISSING) — we do not fabricate a version from the server.
352
+ version: baseline?.version ?? null,
353
+ servedVersion: server?.servedVersion ?? null,
337
354
  classification: CLASSIFICATION.TRACKED,
338
355
  local,
339
356
  governance: server?.governance ?? null,
@@ -364,7 +381,10 @@ export function classifyRows({
364
381
  finalizeRow({
365
382
  owner: orphan.owner,
366
383
  name,
384
+ // A disowned orphan's only version truth is what's on disk (baseline);
385
+ // the server no longer serves it, so there is no served version.
367
386
  version: orphan.baseline.version ?? null,
387
+ servedVersion: null,
368
388
  classification: CLASSIFICATION.ORPHAN,
369
389
  local,
370
390
  governance: null,
@@ -384,7 +404,10 @@ export function classifyRows({
384
404
  finalizeRow({
385
405
  owner: null,
386
406
  name,
407
+ // Hand-authored / never CLI-managed → no baseline, no library entry, so
408
+ // no known version from either side.
387
409
  version: null,
410
+ servedVersion: null,
388
411
  classification: CLASSIFICATION.FOREIGN,
389
412
  local: LOCAL_STATE.PRESENT,
390
413
  governance: null,
@@ -24,6 +24,7 @@ import { join } from "node:path";
24
24
  import { tmpdir } from "node:os";
25
25
 
26
26
  import { runList } from "../../commands/list.mjs";
27
+ import { truncate } from "../../commands/list-render.mjs";
27
28
  import { EXIT_AUTH } from "../../lib/errors.mjs";
28
29
  import { computeSkillShas } from "../../lib/crypto-shas.mjs";
29
30
  import { globalLastSyncPath, globalGovernanceSeenPath } from "../../lib/paths.mjs";
@@ -121,6 +122,29 @@ function baselineEntry(version, files) {
121
122
  return { version, skillMdSha256: shas.skillMdSha256, filesSha256: shas.filesSha256, syncedAt: "x" };
122
123
  }
123
124
 
125
+ // ════════════════════════════════════════════════════════════════════════
126
+ // truncate helper (unit) — guards the description cap directly (#2824 audit)
127
+ // ════════════════════════════════════════════════════════════════════════
128
+ //
129
+ // The integration "truncates a long description" test CANNOT guard truncate():
130
+ // cli-table3's wordWrap emits an ellipsis and drops the contiguous long string
131
+ // on its own, so removing truncate left that test green. This unit test asserts
132
+ // truncate's actual contract, independent of the table renderer.
133
+ describe("truncate", () => {
134
+ it("returns short strings unchanged", () => {
135
+ assert.equal(truncate("hello", 60), "hello");
136
+ assert.equal(truncate("x".repeat(60), 60), "x".repeat(60));
137
+ });
138
+
139
+ it("caps an over-long string at n chars, ending in a single ellipsis", () => {
140
+ const out = truncate("x".repeat(120), 60);
141
+ assert.equal(out.length, 60, "capped to exactly n visible chars");
142
+ assert.ok(out.endsWith("…"), "the overflow is marked with an ellipsis");
143
+ assert.equal(out, "x".repeat(59) + "…");
144
+ assert.ok(!out.includes("x".repeat(60)), "the full run is not present");
145
+ });
146
+ });
147
+
124
148
  // ════════════════════════════════════════════════════════════════════════
125
149
  // Whole-library view
126
150
  // ════════════════════════════════════════════════════════════════════════
@@ -199,7 +223,11 @@ describe("runList — library row taxonomy", () => {
199
223
  assert.match(out, /all healthy|library in sync/);
200
224
  });
201
225
 
202
- it("behind: server served version is newer than the baseline", async () => {
226
+ it("behind: Version column shows the INSTALLED version, the available version is context", async () => {
227
+ // disk/baseline = 1.0.0; the server has moved to 2.0.0. The Version column
228
+ // must show 1.0.0 (what you have) — the row must not read as "you're on
229
+ // 2.0.0". The newer 2.0.0 is named only in the attention block. (#2824
230
+ // R1/R2/R3; pre-fix the Version cell showed the server's 2.0.0.)
203
231
  const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("aging") }];
204
232
  seedClaudeSkillOnDisk("aging", files); // disk matches baseline v1.0.0 → local present
205
233
  seedLastSync({ skills: { "alice/aging": baselineEntry("1.0.0", files) } });
@@ -208,7 +236,13 @@ describe("runList — library row taxonomy", () => {
208
236
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
209
237
  const out = stdout.text();
210
238
  assert.match(out, /\bBEHIND\b/);
211
- assert.match(out, /2\.0\.0 available run update/);
239
+ // R3 — the row's own Version cell (the boxed table line, not the block) shows
240
+ // the disk version and never the server's newer version.
241
+ const boxedRow = out.split("\n").find((l) => l.includes("aging") && l.includes("│"));
242
+ assert.ok(boxedRow, "the aging row renders in the table");
243
+ assert.match(boxedRow, /\b1\.0\.0\b/, "R1/R3: Version column = installed (disk) version");
244
+ assert.doesNotMatch(boxedRow, /\b2\.0\.0\b/, "the Version cell never shows the server's newer version");
245
+ assert.match(out, /2\.0\.0 available — run update/, "R2: the available version is named in the attention block");
212
246
  assert.match(out, /need.? attention/);
213
247
  });
214
248
 
@@ -228,10 +262,17 @@ describe("runList — library row taxonomy", () => {
228
262
  assert.match(out, /OVERWRITES local edits/);
229
263
  });
230
264
 
231
- it("serving: governance reports a recall fallback", async () => {
265
+ it("serving (recalled-not-synced): Version column shows the INSTALLED recalled version, the served rollback is context", async () => {
266
+ // THE real recall-not-synced state (#2824 R1/R2). You synced 2.0.0; the team
267
+ // recalled 2.0.0 and the server now serves the 1.9.0 rollback — but 2.0.0 is
268
+ // still on disk (the file your agent loads). The Version column must show
269
+ // 2.0.0 (what you have); the block names what the server serves (1.9.0) and
270
+ // what to do. Pre-fix the column showed 1.9.0 — the row read false. The
271
+ // fixture DIVERGES disk (2.0.0) from served (1.9.0); the old test set both
272
+ // to 1.9.0, so it could never tell "shows disk" from "shows served".
232
273
  const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("sec") }];
233
274
  seedClaudeSkillOnDisk("sec", files);
234
- seedLastSync({ skills: { "alice/sec": baselineEntry("1.9.0", files) } });
275
+ seedLastSync({ skills: { "alice/sec": baselineEntry("2.0.0", files) } });
235
276
  server.setLibraryResponse({
236
277
  skills: [
237
278
  makeSkill("alice", "sec", "1.9.0", {
@@ -245,18 +286,57 @@ describe("runList — library row taxonomy", () => {
245
286
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
246
287
  const out = stdout.text();
247
288
  assert.match(out, /\bSERVING\b/);
248
- // #2807: the served fallback is not green, and the reason names what was
249
- // recalled and the version rolled back to (doc §4 copy, verbatim).
250
- assert.match(out, /2\.0\.0 recalled rolled back to 1\.9\.0/);
289
+ // R3 the row's own Version cell shows the installed 2.0.0, never the
290
+ // server's 1.9.0 rollback.
291
+ const boxedRow = out.split("\n").find((l) => l.includes("sec") && l.includes("│"));
292
+ assert.ok(boxedRow, "the sec row renders in the table");
293
+ assert.match(boxedRow, /\b2\.0\.0\b/, "R1/R3: Version column = the installed (recalled) version on disk");
294
+ assert.doesNotMatch(boxedRow, /\b1\.9\.0\b/, "the Version cell never shows the server's served rollback");
295
+ // R2 — the attention block names what was recalled, what the server serves
296
+ // now, and what to do.
297
+ assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0 — run update/);
251
298
  assert.doesNotMatch(out, /\bLATEST\b|✓ ok/, "no green for a non-latest served version");
252
299
  });
253
300
 
301
+ it("serving (recalled-SYNCED): already on the rollback → states the recall but NO no-op 'run update'", async () => {
302
+ // The steady state AFTER a user complies with a recall: they ran update and
303
+ // now hold the 1.9.0 rollback (disk == served == 1.9.0), but 2.0.0 is still
304
+ // recalled so governance stays `serving`. This test's fixture is disk ==
305
+ // served BY DESIGN — that is the sync-state condition being tested (the CTA
306
+ // logic), NOT the version display (covered by the recalled-not-synced test
307
+ // above with disk ≠ served). SERVING must still flag the rollback, but must
308
+ // NOT tell the user to "run update" — updating changes nothing here.
309
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("sec") }];
310
+ seedClaudeSkillOnDisk("sec", files);
311
+ seedLastSync({ skills: { "alice/sec": baselineEntry("1.9.0", files) } });
312
+ server.setLibraryResponse({
313
+ skills: [
314
+ makeSkill("alice", "sec", "1.9.0", {
315
+ governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" },
316
+ }),
317
+ ],
318
+ removals: [],
319
+ syncedAt: "x",
320
+ });
321
+
322
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
323
+ const out = stdout.text();
324
+ assert.match(out, /\bSERVING\b/, "the rollback is still flagged");
325
+ assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0/, "the recall + served version are stated");
326
+ assert.doesNotMatch(out, /run update/, "no no-op CTA: you are already on the served rollback");
327
+ });
328
+
254
329
  it("missing: in the library, not on disk", async () => {
255
330
  server.setLibraryResponse({ skills: [makeSkill("alice", "ghost", "1.0.0")], removals: [], syncedAt: "x" });
256
331
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
257
332
  const out = stdout.text();
258
333
  assert.match(out, /\bMISSING\b/);
259
334
  assert.match(out, /in your library, not installed/);
335
+ // Not-installed shows the server/expected version (spec), marked
336
+ // not-installed by the MISSING chip — the Version cell is not left blank.
337
+ const boxedRow = out.split("\n").find((l) => l.includes("ghost") && l.includes("│"));
338
+ assert.ok(boxedRow, "the missing row renders in the table");
339
+ assert.match(boxedRow, /\b1\.0\.0\b/, "not-installed: the server/expected version is shown in the Version cell");
260
340
  });
261
341
 
262
342
  it("foreign: on disk, not in the library, no baseline — no longer hidden", async () => {
@@ -379,6 +459,13 @@ describe("runList — library row taxonomy", () => {
379
459
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
380
460
  const out = stdout.text();
381
461
  assert.match(out, /@alice\/recalled/, "the stranded orphan is finally visible");
462
+ // R1/R3 at the OUTPUT level (#2824 audit): the orphan is the one non-tracked
463
+ // row that carries a REAL on-disk baseline version (the stranded #2804 copy
464
+ // the agent still loads), so its Version cell must show that disk version —
465
+ // 1.2.0 — not blank. The engine field is unit-tested; this guards the render.
466
+ const boxedRow = out.split("\n").find((l) => l.includes("recalled") && l.includes("│"));
467
+ assert.ok(boxedRow, "the orphan row renders in the table");
468
+ assert.match(boxedRow, /\b1\.2\.0\b/, "R1: the orphan's Version cell shows the installed (disk) version");
382
469
  // #2807 + owner rule 2026-08-26: a server-disowned orphan renders as
383
470
  // cause-neutral WITHDRAWN, NEVER `recalled` — absence alone is not a
384
471
  // verified recall (could be an unpublish / unshare / skillset re-scope).
@@ -422,6 +509,12 @@ describe("runList — library row taxonomy", () => {
422
509
  assert.equal(row.signal, "behind");
423
510
  assert.deepEqual(row.governance, { status: "latest", head: "2.0.0" });
424
511
  assert.ok(Array.isArray(row.placements) && row.placements[0].vendor === "claudeCode");
512
+ // #2824 R4 — the disk version and the server's served version are DISTINCT,
513
+ // correctly-labeled fields. disk = 1.0.0 (baseline), served = 2.0.0. Pre-fix
514
+ // the top-level `version` was the server's (2.0.0) and `servedVersion` did
515
+ // not exist.
516
+ assert.equal(row.version, "1.0.0", "R1/R4: top-level version is the installed (disk) version");
517
+ assert.equal(row.servedVersion, "2.0.0", "R4: the server's served/available version is a distinct labeled field");
425
518
  });
426
519
  });
427
520
 
@@ -647,7 +740,16 @@ describe("runList — skillset-declared view", () => {
647
740
  });
648
741
 
649
742
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
650
- assert.match(stdout.text(), /\bBEHIND\b/, "the restore is visible even inside the sync throttle window");
743
+ const out = stdout.text();
744
+ assert.match(out, /\bBEHIND\b/, "the restore is visible even inside the sync throttle window");
745
+ // #2824 R1/R2/R3 — the row shows the INSTALLED 1.0.0 (the rollback still on
746
+ // disk); the restored 2.0.0 is named only as context. disk (1.0.0) ≠ served
747
+ // (2.0.0), so the Version cell distinguishes the two.
748
+ const boxedRow = out.split("\n").find((l) => l.includes("svc") && l.includes("│"));
749
+ assert.ok(boxedRow, "the svc row renders in the table");
750
+ assert.match(boxedRow, /\b1\.0\.0\b/, "R1/R3: Version column = installed (disk) version");
751
+ assert.doesNotMatch(boxedRow, /\b2\.0\.0\b/, "the Version cell never shows the restored server version");
752
+ assert.match(out, /2\.0\.0 available — run update/, "R2: the restored version is named as context");
651
753
  });
652
754
 
653
755
  it("probe failure degrades with the SAME offline guard as the whole-library view", async () => {
@@ -676,18 +778,20 @@ describe("runList — skillset-declared view", () => {
676
778
  assert.doesNotMatch(out, /needs? attention/, "a synced repo is not all-attention on a probe hiccup");
677
779
  });
678
780
 
679
- it("--json carries the freshness axis and stays a top-level object", async () => {
781
+ it("--json carries the freshness axis + distinct installed/served versions, top-level object", async () => {
680
782
  declareSkillset();
783
+ // Disk/baseline = 1.0.0; the server has moved to 2.0.0 → BEHIND. disk ≠
784
+ // served so the two version fields are distinguishable (#2824 R4). (A
785
+ // steady-state up-to-date member is covered by the delivered-set test
786
+ // above; this one is deliberately divergent.)
681
787
  writeRepoSyncState(process.cwd(), {
682
788
  repoName: "checkout",
683
789
  skillsetRef: "acme/backend-core",
684
790
  etag: "ss-1",
685
791
  resolved: [deliveredMember("acme", "svc", "1.0.0", [{ path: "SKILL.md", content: SKILL_MD_BODY("svc") }])],
686
792
  });
687
- // Unconditional 200 (#2807): the member is served at its baseline
688
- // version, so freshness resolves to up-to-date off the body.
689
793
  server.setSkillsetResponse({
690
- skills: [makeSkill("acme", "svc", "1.0.0")],
794
+ skills: [makeSkill("acme", "svc", "2.0.0", { governance: { status: "latest", head: "2.0.0" } })],
691
795
  removals: [],
692
796
  syncedAt: "x",
693
797
  skillset: { name: "backend-core", updatedAt: "x", skippedExtras: [] },
@@ -698,8 +802,12 @@ describe("runList — skillset-declared view", () => {
698
802
  assert.equal(json.skillset.ref, "acme/backend-core");
699
803
  assert.equal(json.skillset.synced, true);
700
804
  const svc = json.skills.find((s) => s.name === "svc");
701
- assert.equal(svc.freshness, "up-to-date");
805
+ assert.equal(svc.freshness, "behind");
702
806
  assert.equal(svc.classification, "tracked");
807
+ // R4 — the installed (disk) version and the server's served version are
808
+ // distinct, correctly-labeled fields in the skillset JSON too.
809
+ assert.equal(svc.version, "1.0.0", "R1/R4: version is the installed (disk) version");
810
+ assert.equal(svc.servedVersion, "2.0.0", "R4: servedVersion is the server's served/available version");
703
811
  // description/updatedAt now ride the skillset JSON too (probe metadata).
704
812
  assert.equal(svc.description, "svc description");
705
813
  });
@@ -740,7 +848,13 @@ describe("runList — skillset-declared view", () => {
740
848
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
741
849
  const out = stdout.text();
742
850
  assert.match(out, /NOT DELIVERED/);
743
- assert.match(out, /edited locally/);
851
+ // Row-scoped (#2824 audit): the not-delivered REASON detail must render on
852
+ // `locked`'s OWN attention line. A bare `/edited locally/` match is
853
+ // confirm-the-code — the compliance summary ALSO prints "N members edited
854
+ // locally" (foreign-content.mjs), so it passes even if the not-delivered
855
+ // reason mapping is wrong. Scope it to the member's row to actually guard it.
856
+ const lockedLine = out.split("\n").find((l) => l.includes("/locked") && l.includes("edited locally"));
857
+ assert.ok(lockedLine, "the not-delivered member's reason detail renders on its own attention row, not just in the compliance summary");
744
858
  });
745
859
 
746
860
  it("renders each not-delivered refusal reason with its matching detail", async () => {
@@ -786,14 +900,17 @@ describe("runList — skillset-declared view", () => {
786
900
  assert.ok(!stdout.text().includes(""), "raw ANSI from the state file must be escaped");
787
901
  });
788
902
 
789
- it("governance: a serving member speaks the same SERVING vocabulary + shows probe metadata", async () => {
903
+ it("governance (recalled-not-synced): serving member shows the INSTALLED version + names the served rollback", async () => {
790
904
  declareSkillset();
791
905
  const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("svc") }];
906
+ // Disk/baseline = 2.0.0 (the recalled version the CLI last synced); the
907
+ // server now serves the 1.9.0 rollback. disk ≠ served, the real Fault-A
908
+ // state — the old fixture set the baseline to 1.9.0 too, masking the bug.
792
909
  writeRepoSyncState(process.cwd(), {
793
910
  repoName: "checkout",
794
911
  skillsetRef: "acme/backend-core",
795
912
  etag: "ss-1",
796
- resolved: [deliveredMember("acme", "svc", "1.9.0", files)],
913
+ resolved: [deliveredMember("acme", "svc", "2.0.0", files)],
797
914
  });
798
915
  // The unconditional 200 carries governance (recall flag on, server-side)
799
916
  // + description/updatedAt: svc serves 1.9.0 because 2.0.0 was recalled.
@@ -812,7 +929,12 @@ describe("runList — skillset-declared view", () => {
812
929
  await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
813
930
  const out = stdout.text();
814
931
  assert.match(out, /\bSERVING\b/, "the skillset view speaks the whole-library view's governance word");
815
- assert.match(out, /2\.0\.0 recalled rolled back to 1\.9\.0/, "the recall reason renders in the shared block");
932
+ // R3the Version cell shows the installed 2.0.0, never the served 1.9.0.
933
+ const boxedRow = out.split("\n").find((l) => l.includes("svc") && l.includes("│"));
934
+ assert.ok(boxedRow, "the svc row renders in the table");
935
+ assert.match(boxedRow, /\b2\.0\.0\b/, "R1/R3: Version column = the installed (recalled) version on disk");
936
+ assert.doesNotMatch(boxedRow, /\b1\.9\.0\b/, "the Version cell never shows the server's served rollback");
937
+ assert.match(out, /2\.0\.0 recalled — server serves 1\.9\.0 — run update/, "the recall reason renders in the shared block");
816
938
  assert.match(out, /deploys the service/, "the Description column populates from the probe (5-column table, like library)");
817
939
  assert.doesNotMatch(out, /all members delivered and current/, "a serving member is not treated as healthy");
818
940
  });
@@ -613,6 +613,13 @@ describe("update → mutate → list: drift verdicts on real persisted state", (
613
613
  await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
614
614
  const [item] = JSON.parse(stdout.text());
615
615
  assert.equal(item.state, "stale", "library moved ahead → `stale`");
616
+ // #2824 R1/R4 at the REAL entry point (update then list, genuinely
617
+ // persisted .last-sync): `.last-sync` recorded 1.0.0 on disk; the server
618
+ // now serves 1.1.0. `version` is the INSTALLED 1.0.0 (what the agent
619
+ // loads), `servedVersion` the available 1.1.0. Pre-#2824 `version` was
620
+ // 1.1.0 (the server's) — this asserts against that exact regression.
621
+ assert.equal(item.version, "1.0.0", "R1: installed (disk) version from the real .last-sync");
622
+ assert.equal(item.servedVersion, "1.1.0", "R4: the server's served version as a distinct field");
616
623
  });
617
624
 
618
625
  it("MISSING after update + manual delete: removing a placement makes list show `missing`", async () => {
@@ -15,7 +15,17 @@
15
15
  import { describe, it } from "node:test";
16
16
  import assert from "node:assert/strict";
17
17
 
18
- import { computeSkillState, rollupState, SKILL_STATE } from "../../lib/drift.mjs";
18
+ import {
19
+ computeSkillState,
20
+ rollupState,
21
+ SKILL_STATE,
22
+ computeFreshness,
23
+ computeLocalIntegrity,
24
+ rollupLocal,
25
+ versionIsNewer,
26
+ FRESHNESS,
27
+ LOCAL_STATE,
28
+ } from "../../lib/drift.mjs";
19
29
 
20
30
  // Test fixtures — keep them readable. SHAs use distinct repeated
21
31
  // characters so an assertion comparing the wrong field is obvious.
@@ -287,3 +297,131 @@ describe("rollupState", () => {
287
297
  );
288
298
  });
289
299
  });
300
+
301
+ // ── computeFreshness (#2806) ─────────────────────────────────────────────
302
+ //
303
+ // The freshness axis is baseline(disk) vs the server's SERVED version. It is
304
+ // load-bearing for #2824: it must fire BEHIND only when the server moved
305
+ // FORWARD, and stay UP_TO_DATE when the server rolled BACK (a recall
306
+ // fallback) — otherwise a recalled-not-synced row would read "behind" and
307
+ // prompt an "available" version that is actually older. These direct tests
308
+ // were missing (the axis had only indirect coverage via the list engine).
309
+
310
+ describe("computeFreshness", () => {
311
+ it("BEHIND when the served version is strictly newer than the baseline (server moved forward)", () => {
312
+ assert.equal(
313
+ computeFreshness({ baselineVersion: "1.0.0", servedVersion: "2.0.0" }),
314
+ FRESHNESS.BEHIND,
315
+ );
316
+ });
317
+
318
+ it("UP_TO_DATE when served equals the baseline", () => {
319
+ assert.equal(
320
+ computeFreshness({ baselineVersion: "1.0.0", servedVersion: "1.0.0" }),
321
+ FRESHNESS.UP_TO_DATE,
322
+ );
323
+ });
324
+
325
+ it("UP_TO_DATE (never BEHIND) when the server ROLLED BACK below the baseline — the recall-fallback case", () => {
326
+ // Disk holds 2.0.0 (the recalled version); the server serves the 1.9.0
327
+ // rollback. The server did NOT move forward, so freshness is UP_TO_DATE —
328
+ // the SERVING governance signal, not freshness, is what surfaces the
329
+ // recall. If this returned BEHIND, `list` would offer "1.9.0 available",
330
+ // i.e. tell you to "update" DOWN to the rollback via the freshness path.
331
+ assert.equal(
332
+ computeFreshness({ baselineVersion: "2.0.0", servedVersion: "1.9.0" }),
333
+ FRESHNESS.UP_TO_DATE,
334
+ );
335
+ });
336
+
337
+ it("NOT_CHECKED when the served version is absent (server not consulted) — never a false UP_TO_DATE", () => {
338
+ assert.equal(
339
+ computeFreshness({ baselineVersion: "1.0.0", servedVersion: null }),
340
+ FRESHNESS.NOT_CHECKED,
341
+ );
342
+ assert.equal(
343
+ computeFreshness({ baselineVersion: "1.0.0", servedVersion: "" }),
344
+ FRESHNESS.NOT_CHECKED,
345
+ );
346
+ });
347
+
348
+ it("BEHIND for a non-semver served label that differs from the baseline (string fallback, safe direction)", () => {
349
+ assert.equal(
350
+ computeFreshness({ baselineVersion: "beta", servedVersion: "rc" }),
351
+ FRESHNESS.BEHIND,
352
+ );
353
+ });
354
+ });
355
+
356
+ // ── versionIsNewer (#2806) — shared ordering rule ────────────────────────
357
+
358
+ describe("versionIsNewer", () => {
359
+ it("semver: strict greater-than only", () => {
360
+ assert.equal(versionIsNewer("2.0.0", "1.0.0"), true);
361
+ assert.equal(versionIsNewer("1.0.0", "1.0.0"), false);
362
+ assert.equal(versionIsNewer("1.0.0", "2.0.0"), false);
363
+ });
364
+
365
+ it("non-semver: any difference reads newer (bias toward prompting an update, never masking one)", () => {
366
+ assert.equal(versionIsNewer("rc", "beta"), true);
367
+ assert.equal(versionIsNewer("beta", "beta"), false);
368
+ });
369
+
370
+ it("absent/empty candidate or reference is inconclusive → false", () => {
371
+ assert.equal(versionIsNewer(null, "1.0.0"), false);
372
+ assert.equal(versionIsNewer("1.0.0", null), false);
373
+ assert.equal(versionIsNewer("", "1.0.0"), false);
374
+ });
375
+ });
376
+
377
+ // ── computeLocalIntegrity + rollupLocal (#2806) ──────────────────────────
378
+
379
+ describe("computeLocalIntegrity", () => {
380
+ it("PRESENT when both SHAs match the baseline", () => {
381
+ assert.equal(
382
+ computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_A, SHA_B) }),
383
+ LOCAL_STATE.PRESENT,
384
+ );
385
+ });
386
+
387
+ it("EDITED when either SHA differs from the baseline", () => {
388
+ assert.equal(
389
+ computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_C, SHA_B) }),
390
+ LOCAL_STATE.EDITED,
391
+ );
392
+ assert.equal(
393
+ computeLocalIntegrity({ baseline: BASELINE, placement: localPresence(SHA_A, SHA_D) }),
394
+ LOCAL_STATE.EDITED,
395
+ );
396
+ });
397
+
398
+ it("MISSING when not on disk, or on disk with no baseline to verify against", () => {
399
+ assert.equal(
400
+ computeLocalIntegrity({ baseline: BASELINE, placement: { present: false, skillMdSha256: null, filesSha256: null } }),
401
+ LOCAL_STATE.MISSING,
402
+ );
403
+ assert.equal(
404
+ computeLocalIntegrity({ baseline: null, placement: localPresence(SHA_A, SHA_B) }),
405
+ LOCAL_STATE.MISSING,
406
+ );
407
+ });
408
+
409
+ it("EDITED (never PRESENT) when a disk SHA is null — cannot verify → conservative", () => {
410
+ assert.equal(
411
+ computeLocalIntegrity({ baseline: BASELINE, placement: { present: true, skillMdSha256: null, filesSha256: SHA_B } }),
412
+ LOCAL_STATE.EDITED,
413
+ );
414
+ });
415
+ });
416
+
417
+ describe("rollupLocal", () => {
418
+ it("precedence missing > edited > present", () => {
419
+ assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.EDITED, LOCAL_STATE.MISSING]), LOCAL_STATE.MISSING);
420
+ assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.EDITED]), LOCAL_STATE.EDITED);
421
+ assert.equal(rollupLocal([LOCAL_STATE.PRESENT, LOCAL_STATE.PRESENT]), LOCAL_STATE.PRESENT);
422
+ });
423
+
424
+ it("empty array is MISSING (nothing to vouch for) — the conservative floor", () => {
425
+ assert.equal(rollupLocal([]), LOCAL_STATE.MISSING);
426
+ });
427
+ });
@@ -84,11 +84,24 @@ describe("buildDiskByName", () => {
84
84
  });
85
85
 
86
86
  // ── tracked rows (on disk ∩ expected) ───────────────────────────────────
87
+ //
88
+ // DISK-TRUTH INVARIANT (#2824 R1): `row.version` is the INSTALLED version —
89
+ // the `.last-sync` baseline (what the last sync wrote to disk) — NEVER the
90
+ // server's served version, which rides `row.servedVersion` as context (R2/R4).
91
+ // Every fixture below DIVERGES disk ≠ served so "show disk" and "show server"
92
+ // produce different output; the ONE exception is the explicitly-labelled
93
+ // steady-state test (a healthy LATEST row is disk == served by definition).
94
+ // Each `version` assertion therefore FAILS on the pre-#2824 build (which put
95
+ // `servedVersion` in `version`) and passes after — red-first, per
96
+ // docs/CLI-LIST-TEST-STANDARD.md.
87
97
 
88
98
  describe("classifyRows — tracked", () => {
89
99
  const detected = ["claudeCode"];
90
100
 
91
- it("present + latest + up-to-date collapses to ok (silent)", () => {
101
+ it("STEADY STATE (the one allowed disk==served): present + latest + up-to-date ok, version is installed", () => {
102
+ // The ONLY test permitted to set disk === served: a healthy LATEST row is
103
+ // disk==served BY DEFINITION (you hold exactly what is served). Labelled so
104
+ // it can never be mistaken for a version-distinguishing test.
92
105
  const rows = classifyRows({
93
106
  diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
94
107
  detectedVendorKeys: detected,
@@ -103,23 +116,37 @@ describe("classifyRows — tracked", () => {
103
116
  assert.equal(r.freshness, FRESHNESS.UP_TO_DATE);
104
117
  assert.equal(r.governance.status, "latest");
105
118
  assert.equal(r.signal, SIGNAL.OK);
119
+ assert.equal(r.version, "1.0.0", "installed (disk) version");
120
+ assert.equal(r.servedVersion, "1.0.0", "served == installed in the steady state");
106
121
  });
107
122
 
108
- it("edited contentlocal edited, signal edited", () => {
123
+ it("recalled-not-synced: disk holds the recalled version, server serves the rollback version is DISK, servedVersion is the rollback", () => {
124
+ // THE canonical Fault-A state (#2824 R1/R2). You synced 2.0.0; the team
125
+ // then recalled 2.0.0 and the server now serves the 1.9.0 rollback — but
126
+ // 2.0.0 is still the file on disk (and still what the agent loads). `list`
127
+ // must report 2.0.0 (what you have), never 1.9.0 (what the server serves).
128
+ // Pre-#2824 this row reported 1.9.0 → this assertion fails on that build.
109
129
  const rows = classifyRows({
110
- diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_EDIT, SHA_B)])),
130
+ diskByName: buildDiskByName(placementsMap([placement("claudeCode", "sec", SHA_A, SHA_B)])),
111
131
  detectedVendorKeys: detected,
112
- expected: [{ owner: "alice", name: "pdf", version: "1.0.0" }],
113
- baselineByKey: baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
114
- serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "1.0.0", governance: { status: "latest", head: "1.0.0" } } }),
132
+ expected: [{ owner: "alice", name: "sec", version: "1.9.0" }],
133
+ baselineByKey: baseline({ "alice/sec": { version: "2.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
134
+ serverTruth: serverTruth200({
135
+ "alice/sec": { servedVersion: "1.9.0", governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" } },
136
+ }),
115
137
  variant: "library",
116
138
  });
117
- const r = rowByName(rows, "pdf");
118
- assert.equal(r.local, LOCAL_STATE.EDITED);
119
- assert.equal(r.signal, SIGNAL.EDITED);
139
+ const r = rowByName(rows, "sec");
140
+ assert.equal(r.version, "2.0.0", "R1: the INSTALLED (disk/baseline) version, NOT the server's 1.9.0 rollback");
141
+ assert.equal(r.servedVersion, "1.9.0", "R2/R4: the server's served rollback is carried as distinct context");
142
+ assert.equal(r.local, LOCAL_STATE.PRESENT, "the on-disk 2.0.0 matches its baseline");
143
+ assert.equal(r.freshness, FRESHNESS.UP_TO_DATE, "the server rolled BACK, it did not move forward — never 'behind'");
144
+ assert.equal(r.governance.status, "serving");
145
+ assert.equal(r.governance.recalledVersion, "2.0.0");
146
+ assert.equal(r.signal, SIGNAL.SERVING);
120
147
  });
121
148
 
122
- it("server moved forward → freshness behind, signal behind", () => {
149
+ it("behind: server moved forward → version is DISK, servedVersion is the newer available version", () => {
123
150
  const rows = classifyRows({
124
151
  diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
125
152
  detectedVendorKeys: detected,
@@ -129,27 +156,29 @@ describe("classifyRows — tracked", () => {
129
156
  variant: "library",
130
157
  });
131
158
  const r = rowByName(rows, "pdf");
159
+ assert.equal(r.version, "1.0.0", "R1: installed (disk) version, NOT the newer served 2.0.0");
160
+ assert.equal(r.servedVersion, "2.0.0", "R2: the available version rides servedVersion as context");
132
161
  assert.equal(r.freshness, FRESHNESS.BEHIND);
133
162
  assert.equal(r.signal, SIGNAL.BEHIND);
134
163
  });
135
164
 
136
- it("serving governance (recall fallback) surfaces as serving", () => {
165
+ it("edited content local edited, signal edited, version stays DISK", () => {
137
166
  const rows = classifyRows({
138
- diskByName: buildDiskByName(placementsMap([placement("claudeCode", "sec", SHA_A, SHA_B)])),
167
+ diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_EDIT, SHA_B)])),
139
168
  detectedVendorKeys: detected,
140
- expected: [{ owner: "alice", name: "sec", version: "1.9.0" }],
141
- baselineByKey: baseline({ "alice/sec": { version: "1.9.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
142
- serverTruth: serverTruth200({
143
- "alice/sec": { servedVersion: "1.9.0", governance: { status: "serving", head: "2.0.0", recalledVersion: "2.0.0" } },
144
- }),
169
+ expected: [{ owner: "alice", name: "pdf", version: "2.0.0" }],
170
+ baselineByKey: baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
171
+ serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "2.0.0", governance: { status: "latest", head: "2.0.0" } } }),
145
172
  variant: "library",
146
173
  });
147
- const r = rowByName(rows, "sec");
148
- assert.equal(r.governance.status, "serving");
149
- assert.equal(r.signal, SIGNAL.SERVING);
174
+ const r = rowByName(rows, "pdf");
175
+ assert.equal(r.local, LOCAL_STATE.EDITED);
176
+ assert.equal(r.signal, SIGNAL.EDITED, "edited is more actionable than behind");
177
+ assert.equal(r.version, "1.0.0", "R1: installed (disk) version even on an edited, behind row");
178
+ assert.equal(r.servedVersion, "2.0.0");
150
179
  });
151
180
 
152
- it("edited outranks behind + serving in the collapsed signal", () => {
181
+ it("edited outranks behind + serving in the collapsed signal, version stays DISK", () => {
153
182
  const rows = classifyRows({
154
183
  diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_EDIT, SHA_B)])),
155
184
  detectedVendorKeys: detected,
@@ -164,21 +193,25 @@ describe("classifyRows — tracked", () => {
164
193
  assert.equal(r.local, LOCAL_STATE.EDITED);
165
194
  assert.equal(r.freshness, FRESHNESS.BEHIND);
166
195
  assert.equal(r.signal, SIGNAL.EDITED, "edited is more actionable than behind/serving");
196
+ assert.equal(r.version, "1.0.0", "R1: installed (disk) version regardless of the collapsed signal");
197
+ assert.equal(r.servedVersion, "2.0.0");
167
198
  });
168
199
 
169
- it("present at one vendor, absent at another → local missing (worst-wins)", () => {
200
+ it("present at one vendor, absent at another → local missing (worst-wins), version is DISK", () => {
170
201
  const rows = classifyRows({
171
202
  diskByName: buildDiskByName(placementsMap([placement("claudeCode", "pdf", SHA_A, SHA_B)])),
172
203
  detectedVendorKeys: ["claudeCode", "cursor"],
173
- expected: [{ owner: "alice", name: "pdf", version: "1.0.0" }],
204
+ expected: [{ owner: "alice", name: "pdf", version: "2.0.0" }],
174
205
  baselineByKey: baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } }),
175
- serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "1.0.0", governance: { status: "latest", head: "1.0.0" } } }),
206
+ serverTruth: serverTruth200({ "alice/pdf": { servedVersion: "2.0.0", governance: { status: "latest", head: "2.0.0" } } }),
176
207
  variant: "library",
177
208
  });
178
209
  const r = rowByName(rows, "pdf");
179
210
  assert.equal(r.local, LOCAL_STATE.MISSING);
180
211
  assert.equal(r.placements.length, 2);
181
212
  assert.equal(r.placements.find((p) => p.vendor === "cursor").present, false);
213
+ assert.equal(r.version, "1.0.0", "R1: installed (disk) version");
214
+ assert.equal(r.servedVersion, "2.0.0");
182
215
  });
183
216
  });
184
217
 
@@ -190,17 +223,24 @@ describe("classifyRows — server-truth modes", () => {
190
223
  const expected = [{ owner: "alice", name: "pdf", version: "1.0.0" }];
191
224
  const base = () => baseline({ "alice/pdf": { version: "1.0.0", skillMdSha256: SHA_A, filesSha256: SHA_B } });
192
225
 
193
- it("server not consulted (null) → freshness not-checked, never up-to-date", () => {
226
+ it("server not consulted (null) → freshness not-checked, version is still DISK, servedVersion null", () => {
194
227
  const rows = classifyRows({ diskByName: disk(), detectedVendorKeys: detected, expected, baselineByKey: base(), serverTruth: null, variant: "library" });
195
228
  const r = rowByName(rows, "pdf");
196
229
  assert.equal(r.freshness, FRESHNESS.NOT_CHECKED);
197
230
  assert.equal(r.governance, null);
198
231
  assert.equal(r.signal, SIGNAL.NOT_CHECKED);
232
+ // Offline still shows the on-disk version (the local axis needs no network);
233
+ // the server's version is unknown → null, never a fabricated value.
234
+ assert.equal(r.version, "1.0.0", "installed (disk) version, offline");
235
+ assert.equal(r.servedVersion, null, "server not consulted → no served version");
199
236
  });
200
237
 
201
- it("checked:false (offline probe) → not-checked", () => {
238
+ it("checked:false (offline probe) → not-checked, version DISK, servedVersion null", () => {
202
239
  const rows = classifyRows({ diskByName: disk(), detectedVendorKeys: detected, expected, baselineByKey: base(), serverTruth: { checked: false, byKey: null }, variant: "library" });
203
- assert.equal(rowByName(rows, "pdf").freshness, FRESHNESS.NOT_CHECKED);
240
+ const r = rowByName(rows, "pdf");
241
+ assert.equal(r.freshness, FRESHNESS.NOT_CHECKED);
242
+ assert.equal(r.version, "1.0.0", "installed (disk) version, offline probe");
243
+ assert.equal(r.servedVersion, null);
204
244
  });
205
245
  });
206
246
 
@@ -220,6 +260,11 @@ describe("classifyRows — missing / not-delivered", () => {
220
260
  assert.equal(r.classification, CLASSIFICATION.MISSING);
221
261
  assert.equal(r.local, LOCAL_STATE.MISSING);
222
262
  assert.equal(r.signal, SIGNAL.MISSING);
263
+ // Not installed → there is no disk version. The spec's not-installed rule
264
+ // shows the server/expected version instead; the MISSING classification is
265
+ // what marks it not-installed, so this is not a "false installed version".
266
+ assert.equal(r.version, "1.0.0", "not-installed: the server/expected version is shown");
267
+ assert.equal(r.servedVersion, "1.0.0");
223
268
  });
224
269
 
225
270
  it("skillset served-but-unwritten → not-delivered with reason", () => {
@@ -235,6 +280,9 @@ describe("classifyRows — missing / not-delivered", () => {
235
280
  assert.equal(r.classification, CLASSIFICATION.NOT_DELIVERED);
236
281
  assert.equal(r.reason, "modified");
237
282
  assert.equal(r.signal, SIGNAL.NOT_DELIVERED);
283
+ // Not on disk (guard-refused) → the declared version is shown, not installed.
284
+ assert.equal(r.version, "1.0.0", "the declared/expected version for a not-delivered member");
285
+ assert.equal(r.servedVersion, null, "no server-truth lookup on the not-delivered branch");
238
286
  });
239
287
  });
240
288
 
@@ -255,6 +303,9 @@ describe("classifyRows — foreign / orphan", () => {
255
303
  assert.equal(r.owner, null);
256
304
  assert.equal(r.local, LOCAL_STATE.PRESENT);
257
305
  assert.equal(r.signal, SIGNAL.FOREIGN);
306
+ // No baseline and not from the library → no known version either way.
307
+ assert.equal(r.version, null, "a hand-authored dir has no known version");
308
+ assert.equal(r.servedVersion, null);
258
309
  });
259
310
 
260
311
  it("on disk + in baseline but NOT served → orphan (#2804)", () => {
@@ -271,7 +322,8 @@ describe("classifyRows — foreign / orphan", () => {
271
322
  const r = rowByName(rows, "recalled");
272
323
  assert.equal(r.classification, CLASSIFICATION.ORPHAN);
273
324
  assert.equal(r.owner, "alice");
274
- assert.equal(r.version, "1.2.0");
325
+ assert.equal(r.version, "1.2.0", "R1: the version on disk (baseline), the only truth for a disowned orphan");
326
+ assert.equal(r.servedVersion, null, "the server no longer serves it → no served version");
275
327
  assert.equal(r.local, LOCAL_STATE.PRESENT);
276
328
  assert.equal(r.signal, SIGNAL.ORPHAN);
277
329
  });
@@ -289,6 +341,8 @@ describe("classifyRows — foreign / orphan", () => {
289
341
  assert.equal(r.classification, CLASSIFICATION.ORPHAN);
290
342
  assert.equal(r.local, LOCAL_STATE.EDITED);
291
343
  assert.equal(r.signal, SIGNAL.ORPHAN, "orphan outranks edited");
344
+ assert.equal(r.version, "1.2.0", "R1: still the on-disk baseline version, even when edited");
345
+ assert.equal(r.servedVersion, null);
292
346
  });
293
347
  });
294
348