@twinsearth/w2m-dsh-plugin 1.0.9 → 1.0.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.0.10] — 2026-10-17
6
+
7
+ **`w2m_memory {"action":"diff"}`: compare this machine's collective memory against the fleet's current blob,
8
+ and write nothing.**
9
+
10
+ ### Merge is still not implemented, deliberately
11
+
12
+ The question merge depends on has not been answered:
13
+
14
+ > **When two machines in one fleet assert different things about the same fact, who wins?**
15
+
16
+ That is a product decision about what the fleet's memory *is*, not an implementation detail, and shipping a merge
17
+ before its rule is how a fleet silently loses one machine's memory. So this release ships **the half that needs no
18
+ decision** -- and names the other half as open rather than leaving it implied.
19
+
20
+ ### What `diff` answers
21
+
22
+ * **Is my document the newest one?** `same: true/false`, decided by **whole-document hash equality**.
23
+ * **What would I be overwriting?** `preview.local_only` and `preview.fleet_only`.
24
+ * **Is the fleet's document even there?** Three distinct absence states, each named: the fleet never published; the
25
+ pointer names a blob that eviction removed; the blob exists and verified.
26
+
27
+ ### Four decisions worth recording
28
+
29
+ **It writes nothing.** No `.orig`, no `.rej`, no silent merge. Comparing is not a licence to edit a document this
30
+ machine owns.
31
+
32
+ **`same` is hash equality, not "no line differences".** Two documents with identical line sets in a different order
33
+ are **not** the same, and callling them the same would hide an order change -- which in a markdown document is a
34
+ structural change.
35
+
36
+ **A set comparison, not a diff.** A line-oriented diff would report "moved" versus "changed" and then want a merge
37
+ policy. **A set comparison cannot resolve anything, which is exactly the property this step requires.**
38
+
39
+ **The fleet copy is re-hashed before it is compared.** Comparing unverified content would launder it: the report
40
+ would give a hash-verified-looking answer about bytes nobody checked. A blob that fails its own name raises
41
+ `W2M_BAD_BLOB` and is never compared.
42
+
43
+ Trailing whitespace is normalised (it is noise); leading whitespace is preserved (it is markdown structure).
44
+
45
+ ### Verified
46
+
47
+ The comparison rule is pinned directly: `local_only` / `fleet_only` separate correctly, trailing whitespace creates
48
+ no difference, and leading whitespace does. Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**; pre-flight
49
+ **GREEN, 15 of 15 checks**.
50
+
51
+ ### NOT verified
52
+
53
+ The `diff` action **end to end** -- the same gap as `publish`/`pull`/`latest`: type-correct and lint-clean, but
54
+ no test executes it, because that needs a profile with a `fleet-memory.md` and a live relay.
55
+
56
+ ### Open: #2b
57
+
58
+ **Append-merge with conflict markers, and the marker's existence is the refusal** -- a document containing markers is
59
+ one a human must not trust until resolved. Last-writer-wins is rejected: it loses data silently, and the project's
60
+ own eviction rule already refuses "delete the only copy of a fact" in spirit. **That recommendation is in
61
+ `FLEET-SYNC-2-DESIGN.md` and is not implemented, because it encodes a rule that is the operator's to choose.**
62
+
5
63
  ## [1.0.9] — 2026-10-17
6
64
 
7
65
  **Discovery: a machine can now ask the relay which blob its fleet published last, instead of a human relaying a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.0.9",
4
+ "version": "1.0.10",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '1.0.9';
211
+ const PLUGIN_VERSION = '1.0.10';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -5141,13 +5141,14 @@ export async function apply(ctx, config = {}) {
5141
5141
  */
5142
5142
  action: {
5143
5143
  type: 'string',
5144
- enum: ['read', 'publish', 'pull', 'latest'],
5144
+ enum: ['read', 'publish', 'pull', 'latest', 'diff'],
5145
5145
  description:
5146
5146
  'read (default) = answer from memory. publish = upload the curated collective memory '
5147
5147
  + '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
5148
- + 'relay which blob this fleet published last (the discovery half of the channel). pull = fetch one blob '
5149
- + 'by SHA-256 -- or by "latest" -- and return its text, re-hashing what came back against the name it was '
5150
- + 'asked for. None of these modifies the local file.',
5148
+ + 'relay which blob this fleet published last. pull = fetch one blob by SHA-256 -- or by "latest" -- and '
5149
+ + 'return its text, re-hashing it. diff = compare the local document against the fleet current blob and '
5150
+ + 'report what differs, **writing nothing**: merge has no conflict rule yet, so this action deliberately '
5151
+ + 'resolves nothing. None of these modifies the local file.',
5151
5152
  default: 'read',
5152
5153
  },
5153
5154
  hash: {
@@ -5464,6 +5465,150 @@ export async function apply(ctx, config = {}) {
5464
5465
  });
5465
5466
  }
5466
5467
 
5468
+ /**
5469
+ * v1.0.10 (#2a): **compare** this machine's collective memory against the fleet's current blob. Writes nothing.
5470
+ *
5471
+ * ## Why this exists before merge does
5472
+ *
5473
+ * "Merge" needs a conflict rule -- *when two machines assert different facts, who wins?* -- and that is a
5474
+ * product decision, not an implementation detail. Shipping a merge before its rule is how a fleet silently
5475
+ * loses one machine's memory.
5476
+ *
5477
+ * What needs no decision is the half that **prevents harm**: telling a caller whether their local document is
5478
+ * the newest one, and what would be lost if they published over the fleet's copy. That is this action.
5479
+ *
5480
+ * ## Why a set comparison instead of a diff
5481
+ *
5482
+ * `same` is decided by **whole-document hash equality**, not by the absence of line differences: two documents
5483
+ * with identical line sets in a different order are not the same, and reporting them as the same would hide an
5484
+ * order change. The line sets are then reported as `local_only` / `fleet_only` -- deliberately *not* a diff,
5485
+ * because a diff would report "moved" versus "changed" and then want a merge policy. **A set comparison cannot
5486
+ * resolve anything, which is the property this step needs.**
5487
+ *
5488
+ * ## Why it re-hashes the fleet copy
5489
+ *
5490
+ * Comparing unverified content would launder it: the report would give a hash-verified-looking answer about
5491
+ * bytes nobody checked. A blob that fails its own name is reported as unverified, never compared.
5492
+ */
5493
+ if (typeof args?.action === 'string' && args.action === 'diff') {
5494
+ const file = collectivePath();
5495
+ if (file === null) {
5496
+ throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to compare collective memory from', {
5497
+ hint: 'the collective layer lives beside the profile that mounted this plugin',
5498
+ });
5499
+ }
5500
+ let localBytes;
5501
+ try {
5502
+ localBytes = await fs.readFile(file);
5503
+ } catch {
5504
+ throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to compare at ${file}`, {
5505
+ hint: 'write measured facts to it first; this action compares, it does not create',
5506
+ });
5507
+ }
5508
+ const localHash = createHash('sha256').update(localBytes).digest('hex');
5509
+ const base = requireRabbit(cfg);
5510
+ const token = await resolveToken(cfg);
5511
+
5512
+ const pointer = await request({
5513
+ rabbitUrl: base,
5514
+ token,
5515
+ pathname: '/v1/fleet/blobs/latest',
5516
+ signal: exec?.signal,
5517
+ });
5518
+ if (pointer.status === 404) {
5519
+ // No fleet document yet: nothing to differ from. Reported as a state, not as an error.
5520
+ return JSON.stringify({
5521
+ ok: true,
5522
+ same: false,
5523
+ local: { hash: localHash, bytes: localBytes.length, file },
5524
+ fleet: { present: false, reason: 'this fleet has published no blob yet' },
5525
+ merged: false,
5526
+ wrote_anything: false,
5527
+ });
5528
+ }
5529
+ if (pointer.status !== 200) {
5530
+ throw new W2MError(
5531
+ 'W2M_RABBIT_REFUSED',
5532
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
5533
+ { http_status: pointer.status, detail: pointer.error ?? null },
5534
+ );
5535
+ }
5536
+ const fleetHash = String(pointer.json?.hash ?? '');
5537
+ const fleetRes = await request({
5538
+ rabbitUrl: base,
5539
+ token,
5540
+ pathname: `/v1/fleet/blobs/${fleetHash}`,
5541
+ accept: 'application/octet-stream',
5542
+ signal: exec?.signal,
5543
+ });
5544
+ if (fleetRes.status === 404) {
5545
+ // The pointer is a hint (see #3): eviction can take the document it names.
5546
+ return JSON.stringify({
5547
+ ok: true,
5548
+ same: false,
5549
+ local: { hash: localHash, bytes: localBytes.length, file },
5550
+ fleet: {
5551
+ present: false,
5552
+ pointed_at: fleetHash,
5553
+ reason: 'the pointer names a blob that no longer exists (oldest-first eviction)',
5554
+ },
5555
+ merged: false,
5556
+ wrote_anything: false,
5557
+ });
5558
+ }
5559
+ if (fleetRes.status !== 200) {
5560
+ throw new W2MError(
5561
+ 'W2M_RABBIT_REFUSED',
5562
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/${fleetHash} answered HTTP ${fleetRes.status}`,
5563
+ { http_status: fleetRes.status, detail: fleetRes.error ?? null },
5564
+ );
5565
+ }
5566
+ const fleetText = fleetRes.text ?? '';
5567
+ const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
5568
+ if (fleetActual !== fleetHash) {
5569
+ throw new W2MError(
5570
+ 'W2M_BAD_BLOB',
5571
+ `W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`,
5572
+ {
5573
+ hint: 'refusing to compare unverified content -- a comparison over bytes nobody checked would '
5574
+ + 'launder them',
5575
+ expected: fleetHash,
5576
+ actual: fleetActual,
5577
+ },
5578
+ );
5579
+ }
5580
+
5581
+ const localText = localBytes.toString('utf8');
5582
+ // Trailing whitespace only: leading whitespace is markdown structure, not noise.
5583
+ const norm = (text) => new Set(text.split('\n').map((line) => line.replace(/\s+$/, '')));
5584
+ const localLines = norm(localText);
5585
+ const fleetLines = norm(fleetText);
5586
+ const localOnly = [...localLines].filter((line) => !fleetLines.has(line));
5587
+ const fleetOnly = [...fleetLines].filter((line) => !localLines.has(line));
5588
+ return JSON.stringify({
5589
+ ok: true,
5590
+ same: localHash === fleetHash,
5591
+ local: { hash: localHash, bytes: localBytes.length, lines: localLines.size, file },
5592
+ fleet: {
5593
+ present: true,
5594
+ hash: fleetHash,
5595
+ bytes: Buffer.byteLength(fleetText, 'utf8'),
5596
+ lines: fleetLines.size,
5597
+ published_at: pointer.json?.published_at ?? null,
5598
+ published_by: pointer.json?.published_by ?? null,
5599
+ },
5600
+ differing_lines: localOnly.length + fleetOnly.length,
5601
+ preview: {
5602
+ local_only: localOnly.slice(0, 20),
5603
+ fleet_only: fleetOnly.slice(0, 20),
5604
+ truncated: localOnly.length > 20 || fleetOnly.length > 20,
5605
+ },
5606
+ merged: false,
5607
+ wrote_anything: false,
5608
+ note: 'a comparison only. Merge has no conflict rule yet, so nothing is resolved and nothing is written.',
5609
+ });
5610
+ }
5611
+
5467
5612
  const limitRaw = Number(args?.limit);
5468
5613
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
5469
5614