@twinsearth/w2m-dsh-plugin 1.0.9 → 1.0.11

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,124 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.0.11] — 2026-10-17
6
+
7
+ **`w2m_memory {"action":"merge"}`: merge this machine's collective memory with the fleet's current blob, under the
8
+ operator's conflict rule, and still write nothing.**
9
+
10
+ ### The rule, which is the operator's decision
11
+
12
+ > **默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。**
13
+ >
14
+ > Default: the newest wins. But if it is a repair of the default flow, the most complete and most thoroughly verified
15
+ > one has final edit authority.
16
+
17
+ Two layers, and the second is the one that is easy to lose: **a declared repair outranks recency**, so a repaired
18
+ fact is not overwritten by a later-written older understanding. Between two repairs, the better-supported one wins.
19
+
20
+ ### What is the operator's, and what is my operationalisation -- stated so it can be challenged
21
+
22
+ **Theirs:** the two layers above. **Mine, and it is in the source:**
23
+
24
+ * **The entry syntax.** There is no way to read "is this a repair of the default flow" out of prose; any attempt to
25
+ guess would overwrite correct versions with wrong ones. So a keyed entry declares it:
26
+ `- 〔id:<stable-id> fix evidence=<n> verified=<n>〕<the fact>`.
27
+ Entries without an `id` are prose: they are unioned and never adjudicated, because without an id there is no way to
28
+ tell "same fact, two versions" from "two different facts".
29
+ * **`completeness = evidence + 2 × verified`. The weight 2 is a judgement, not the operator's words.** One reproduced
30
+ fact outranks three unreproduced ones. It only applies when **both** sides declare `fix`.
31
+
32
+ **Every decision is recorded** as a comment in the merged text (`<!-- <id> -> local (repair): ... -->`), because a
33
+ merge that cannot explain its choices produces a document nobody can audit -- and the first sign of a wrong rule
34
+ would be a fact nobody can find.
35
+
36
+ ### What it refuses
37
+
38
+ | Case | Behaviour |
39
+ |---|---|
40
+ | the same id twice **inside one document** | **refused**, not resolved: a broken document is not a conflict |
41
+ | a blob that fails its own hash | `W2M_BAD_BLOB`, and **nothing is merged** -- comparing unverified content would launder it |
42
+ | a pointer naming an evicted blob | reported as that, with the remedy (publish again) |
43
+ | no fleet document at all | reported as a state: publishing comes first |
44
+
45
+ ### And it still writes nothing
46
+
47
+ The merged text comes back in the result; **the local file is not modified.** Half of a merge's input arrived over the
48
+ network, and letting one round trip rewrite the only local copy would hand a remote party a write into local memory.
49
+
50
+ ### Verified
51
+
52
+ **13 tests in `test/fleet-merge.test.mjs`** exercise the rule as a rule, with no profile and no relay: the default
53
+ layer picks the newer publication; **a declared repair beats a NEWER non-repair** (the operator's override -- if this
54
+ test flips, the rule has silently become "newest wins"); two repairs are separated by completeness, then by
55
+ verification count, then by recency; a genuine tie resolves deterministically **and is recorded as a tie**; a
56
+ duplicated id is refused; prose unions; inputs are not mutated; an identical id produces no decision.
57
+
58
+ Full suite: see the pre-flight line. Pre-flight **GREEN, 15 of 15 checks**.
59
+
60
+ ### NOT verified
61
+
62
+ The `merge` action **end to end** -- the same gap as `publish`/`pull`/`latest`/`diff`: the rule is proven, the
63
+ plumbing around it is not, because that needs a profile with a `fleet-memory.md` and a live relay.
64
+
65
+ ## [1.0.10] — 2026-10-17
66
+
67
+ **`w2m_memory {"action":"diff"}`: compare this machine's collective memory against the fleet's current blob,
68
+ and write nothing.**
69
+
70
+ ### Merge is still not implemented, deliberately
71
+
72
+ The question merge depends on has not been answered:
73
+
74
+ > **When two machines in one fleet assert different things about the same fact, who wins?**
75
+
76
+ That is a product decision about what the fleet's memory *is*, not an implementation detail, and shipping a merge
77
+ before its rule is how a fleet silently loses one machine's memory. So this release ships **the half that needs no
78
+ decision** -- and names the other half as open rather than leaving it implied.
79
+
80
+ ### What `diff` answers
81
+
82
+ * **Is my document the newest one?** `same: true/false`, decided by **whole-document hash equality**.
83
+ * **What would I be overwriting?** `preview.local_only` and `preview.fleet_only`.
84
+ * **Is the fleet's document even there?** Three distinct absence states, each named: the fleet never published; the
85
+ pointer names a blob that eviction removed; the blob exists and verified.
86
+
87
+ ### Four decisions worth recording
88
+
89
+ **It writes nothing.** No `.orig`, no `.rej`, no silent merge. Comparing is not a licence to edit a document this
90
+ machine owns.
91
+
92
+ **`same` is hash equality, not "no line differences".** Two documents with identical line sets in a different order
93
+ are **not** the same, and callling them the same would hide an order change -- which in a markdown document is a
94
+ structural change.
95
+
96
+ **A set comparison, not a diff.** A line-oriented diff would report "moved" versus "changed" and then want a merge
97
+ policy. **A set comparison cannot resolve anything, which is exactly the property this step requires.**
98
+
99
+ **The fleet copy is re-hashed before it is compared.** Comparing unverified content would launder it: the report
100
+ would give a hash-verified-looking answer about bytes nobody checked. A blob that fails its own name raises
101
+ `W2M_BAD_BLOB` and is never compared.
102
+
103
+ Trailing whitespace is normalised (it is noise); leading whitespace is preserved (it is markdown structure).
104
+
105
+ ### Verified
106
+
107
+ The comparison rule is pinned directly: `local_only` / `fleet_only` separate correctly, trailing whitespace creates
108
+ no difference, and leading whitespace does. Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**; pre-flight
109
+ **GREEN, 15 of 15 checks**.
110
+
111
+ ### NOT verified
112
+
113
+ The `diff` action **end to end** -- the same gap as `publish`/`pull`/`latest`: type-correct and lint-clean, but
114
+ no test executes it, because that needs a profile with a `fleet-memory.md` and a live relay.
115
+
116
+ ### Open: #2b
117
+
118
+ **Append-merge with conflict markers, and the marker's existence is the refusal** -- a document containing markers is
119
+ one a human must not trust until resolved. Last-writer-wins is rejected: it loses data silently, and the project's
120
+ own eviction rule already refuses "delete the only copy of a fact" in spirit. **That recommendation is in
121
+ `FLEET-SYNC-2-DESIGN.md` and is not implemented, because it encodes a rule that is the operator's to choose.**
122
+
5
123
  ## [1.0.9] — 2026-10-17
6
124
 
7
125
  **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.11",
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",
@@ -117,13 +117,13 @@
117
117
  }
118
118
  },
119
119
  "scripts": {
120
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
120
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
121
121
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
122
122
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
123
123
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
124
124
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
125
125
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
126
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
126
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
127
127
  "pack": "node scripts/pack.mjs",
128
128
  "verify:join": "node scripts/verify-join.mjs",
129
129
  "lint": "eslint .",
@@ -137,6 +137,7 @@
137
137
  "test:blocks": "node --test --test-force-exit test/blocks.test.mjs",
138
138
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
139
139
  "test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
140
+ "test:fleet-merge": "node --test --test-force-exit test/fleet-merge.test.mjs",
140
141
  "test:health": "node --test --test-force-exit test/health.test.mjs",
141
142
  "test:storage": "node --test --test-force-exit test/storage.test.mjs",
142
143
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
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.11",
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": [
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Fleet memory merge — the deterministic core of #2b.
3
+ *
4
+ * ## The rule, which is the operator's and not mine
5
+ *
6
+ * > **默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。**
7
+ * > (Default: the newest wins. But if it is a repair of the default flow, the most complete and most thoroughly
8
+ * > verified one has final edit authority.)
9
+ *
10
+ * ## What is the operator's decision, and what is my operationalisation
11
+ *
12
+ * **The operator's:** the two layers. Newest wins by default; a declared repair of the default flow outranks
13
+ * recency. A repaired fact must not be overwritten by a later-written older understanding.
14
+ *
15
+ * **Mine, and it must be reviewable:**
16
+ *
17
+ * * the entry syntax (§3 of FLEET-SYNC-2B-MERGE-SPEC.md) — there is no way to read "is this a repair" out of
18
+ * prose, and guessing would overwrite correct versions with wrong ones;
19
+ * * `completeness = evidence + 2 * verified`. Verified evidence is a stronger signal than more unverified evidence
20
+ * — one reproduced fact beats three unreproduced ones. **The weight 2 is a judgement, not the operator's words**,
21
+ * and it only applies when both sides declare `fix`.
22
+ *
23
+ * ## Why nothing here is silent
24
+ *
25
+ * Every adjudicated id produces a decision record, and the decision records are appended to the output as comments.
26
+ * **A merge that cannot explain its own choices produces a document nobody can audit**, and the first sign of a
27
+ * wrong rule would be a fact nobody can find.
28
+ *
29
+ * @module fleet-merge
30
+ */
31
+
32
+ /** The bracket pair that marks an entry: U+3014 / U+3015. Chosen because they never occur in the prose. */
33
+ const OPEN = '〔';
34
+ const CLOSE = '〕';
35
+ /** `- 〔...〕content` -- built from the constants so a change to the pair cannot desynchronise the parser. */
36
+ const ENTRY_RE = new RegExp('^-\\s+' + OPEN + '([^' + CLOSE + ']*)' + CLOSE + '(.*)$');
37
+
38
+ /**
39
+ * Parse one markdown line as a keyed entry.
40
+ *
41
+ * Only `- ` list items that begin with a `〔...〕` metadata bracket are entries. **Everything else is unkeyed prose**,
42
+ * and unkeyed lines never participate in adjudication: without an id there is no way to tell "these two lines are
43
+ * about the same thing" from "these two lines are different facts", and guessing produces conflicts that are not
44
+ * conflicts.
45
+ *
46
+ * @param {string} line
47
+ * @returns {{id:string, fix:boolean, evidence:number, verified:number, content:string, raw:string}|null}
48
+ */
49
+ export function parseEntry(line) {
50
+ if (typeof line !== 'string') return null;
51
+ const match = ENTRY_RE.exec(line);
52
+ if (match === null) return null;
53
+ const meta = match[1];
54
+ const content = match[2];
55
+
56
+ let id = null;
57
+ let fix = false;
58
+ let evidence = 0;
59
+ let verified = 0;
60
+ for (const token of meta.trim().split(/\s+/)) {
61
+ if (token.startsWith('id:')) {
62
+ id = token.slice(3).trim();
63
+ continue;
64
+ }
65
+ if (token === 'fix') { fix = true; continue; }
66
+ const eq = token.indexOf('=');
67
+ if (eq > 0) {
68
+ const key = token.slice(0, eq);
69
+ const value = Number(token.slice(eq + 1));
70
+ if (!Number.isInteger(value) || value < 0) continue;
71
+ if (key === 'evidence') evidence = value;
72
+ else if (key === 'verified') verified = value;
73
+ }
74
+ }
75
+ if (id === null || id === '') return null;
76
+ return { id, fix, evidence, verified, content: content.trim(), raw: line };
77
+ }
78
+
79
+ /**
80
+ * How much a declared repair has to show, for the "most complete and most thoroughly verified" half of the rule.
81
+ *
82
+ * @param {{evidence:number, verified:number}} entry
83
+ */
84
+ export function completeness(entry) {
85
+ return entry.evidence + 2 * entry.verified;
86
+ }
87
+
88
+ /**
89
+ * Split a document into unkeyed prose and keyed entries.
90
+ *
91
+ * A duplicate id **within one document** is recorded rather than merged, because two entries claiming the same id in
92
+ * one document is a broken document, not a conflict to resolve.
93
+ *
94
+ * @param {string} text
95
+ * @returns {{prose:string[], entries:Map<string, object>, duplicates:string[]}}
96
+ */
97
+ export function parseDocument(text) {
98
+ const prose = [];
99
+ const entries = new Map();
100
+ const duplicates = [];
101
+ for (const line of String(text ?? '').split('\n')) {
102
+ const entry = parseEntry(line);
103
+ if (entry === null) {
104
+ if (line.trim() !== '') prose.push(line);
105
+ continue;
106
+ }
107
+ if (entries.has(entry.id)) {
108
+ if (!duplicates.includes(entry.id)) duplicates.push(entry.id);
109
+ continue;
110
+ }
111
+ entries.set(entry.id, entry);
112
+ }
113
+ return { prose, entries, duplicates };
114
+ }
115
+
116
+ /**
117
+ * Decide one id, given the same entry from two documents.
118
+ *
119
+ * The order is fixed so the result never depends on iteration order:
120
+ *
121
+ * 1. only one side declares `fix` -> that side
122
+ * 2. both declare `fix` -> higher `completeness`
123
+ * 3. equal `completeness` -> higher `verified`
124
+ * 4. still equal -> newer (higher `addedAtMs`)
125
+ * 5. still equal -> lower hash, for determinism only, and the tie is recorded
126
+ *
127
+ * @returns {{winner:'local'|'fleet', layer:'repair'|'default'|'tie', reason:string}}
128
+ */
129
+ export function decide(local, fleet, context) {
130
+ const { localAddedAtMs = 0, fleetAddedAtMs = 0, localHash = '', fleetHash = '' } = context;
131
+
132
+ if (local.fix !== fleet.fix) {
133
+ const winner = local.fix ? 'local' : 'fleet';
134
+ return {
135
+ winner,
136
+ layer: 'repair',
137
+ reason: `${winner} declares "fix" of the default flow, which outranks recency (the other side does not)`,
138
+ };
139
+ }
140
+
141
+ if (local.fix && fleet.fix) {
142
+ const localScore = completeness(local);
143
+ const fleetScore = completeness(fleet);
144
+ if (localScore !== fleetScore) {
145
+ return {
146
+ winner: localScore > fleetScore ? 'local' : 'fleet',
147
+ layer: 'repair',
148
+ reason: `both declare "fix"; completeness ${localScore} vs ${fleetScore} (evidence + 2 x verified)`,
149
+ };
150
+ }
151
+ if (local.verified !== fleet.verified) {
152
+ return {
153
+ winner: local.verified > fleet.verified ? 'local' : 'fleet',
154
+ layer: 'repair',
155
+ reason: `both declare "fix" with equal completeness; verified ${local.verified} vs ${fleet.verified}`,
156
+ };
157
+ }
158
+ // fall through to recency
159
+ }
160
+
161
+ if (localAddedAtMs !== fleetAddedAtMs) {
162
+ return {
163
+ winner: localAddedAtMs > fleetAddedAtMs ? 'local' : 'fleet',
164
+ layer: 'default',
165
+ reason: `default layer: the newer publication wins (${Math.max(localAddedAtMs, fleetAddedAtMs)} > ${Math.min(localAddedAtMs, fleetAddedAtMs)})`,
166
+ };
167
+ }
168
+
169
+ const winner = localHash < fleetHash ? 'local' : 'fleet';
170
+ return {
171
+ winner,
172
+ layer: 'tie',
173
+ reason: 'identical timestamps and scores; resolved by hash order for determinism only -- this is a genuine tie',
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Merge two documents under the rule above.
179
+ *
180
+ * **Never throws for a resolvable merge, never resolves something it cannot justify, and never invents content.**
181
+ *
182
+ * @param {{localText:string, localAddedAtMs?:number, localHash?:string,
183
+ * fleetText:string, fleetAddedAtMs?:number, fleetHash?:string}} input
184
+ * @returns {{text:string, decisions:{id:string,winner:string,layer:string,reason:string}[],
185
+ * unresolved:{id:string,reason:string}[], refused:{id:string,reason:string}[],
186
+ * added:string[], removed:string[], unchanged:number, wroteAnything:false}}
187
+ */
188
+ export function mergeMemory(input) {
189
+ const {
190
+ localText, localAddedAtMs = 0, localHash = '',
191
+ fleetText, fleetAddedAtMs = 0, fleetHash = '',
192
+ } = input;
193
+
194
+ const local = parseDocument(localText);
195
+ const fleet = parseDocument(fleetText);
196
+ const context = { localAddedAtMs, fleetAddedAtMs, localHash, fleetHash };
197
+
198
+ const decisions = [];
199
+ const unresolved = [];
200
+ const refused = [];
201
+
202
+ /** ids that are broken (duplicated) in either document -- refused, never merged. */
203
+ const broken = new Set([...local.duplicates, ...fleet.duplicates]);
204
+ for (const id of broken) {
205
+ refused.push({ id, reason: 'this id appears more than once in one document; a broken document is not a conflict' });
206
+ }
207
+
208
+ const allIds = [...new Set([...local.entries.keys(), ...fleet.entries.keys()])];
209
+ const chosen = new Map();
210
+ let unchanged = 0;
211
+
212
+ for (const id of allIds) {
213
+ if (broken.has(id)) continue;
214
+ const l = local.entries.get(id);
215
+ const f = fleet.entries.get(id);
216
+
217
+ if (l !== undefined && f === undefined) { chosen.set(id, { entry: l, side: 'local' }); continue; }
218
+ if (l === undefined && f !== undefined) { chosen.set(id, { entry: f, side: 'fleet' }); continue; }
219
+
220
+ // Same id on both sides. Identical text is not a conflict.
221
+ if (l.raw === f.raw || l.content === f.content) { chosen.set(id, { entry: l, side: 'both' }); unchanged += 1; continue; }
222
+
223
+ const verdict = decide(l, f, context);
224
+ chosen.set(id, { entry: verdict.winner === 'local' ? l : f, side: verdict.winner });
225
+ decisions.push({ id, winner: verdict.winner, layer: verdict.layer, reason: verdict.reason });
226
+ }
227
+
228
+ // Prose is a union; it cannot conflict because it has no identity to conflict on.
229
+ const localProse = new Set(local.prose);
230
+ const proseOut = [...local.prose];
231
+ for (const line of fleet.prose) if (!localProse.has(line)) proseOut.push(line);
232
+
233
+ // Entries: local document order first (so the machine's own structure survives), then fleet-only ids in fleet order.
234
+ const entryLines = [];
235
+ for (const line of String(localText ?? '').split('\n')) {
236
+ const parsed = parseEntry(line);
237
+ if (parsed === null || !chosen.has(parsed.id)) continue;
238
+ const pick = chosen.get(parsed.id);
239
+ if (!entryLines.some((e) => e.id === parsed.id)) entryLines.push({ id: parsed.id, raw: pick.entry.raw });
240
+ }
241
+ for (const line of String(fleetText ?? '').split('\n')) {
242
+ const parsed = parseEntry(line);
243
+ if (parsed === null || !chosen.has(parsed.id)) continue;
244
+ if (!entryLines.some((e) => e.id === parsed.id)) entryLines.push({ id: parsed.id, raw: chosen.get(parsed.id).entry.raw });
245
+ }
246
+
247
+ const audit = [
248
+ '<!-- fleet-merge: decisions are recorded so a wrong rule is visible after the fact -->',
249
+ ...decisions.map((d) => `<!-- ${d.id} -> ${d.winner} (${d.layer}): ${d.reason} -->`),
250
+ ...unresolved.map((u) => `<!-- ${u.id} UNRESOLVED: ${u.reason} -->`),
251
+ ...refused.map((r) => `<!-- ${r.id} REFUSED: ${r.reason} -->`),
252
+ ];
253
+
254
+ const text = [
255
+ ...proseOut,
256
+ ...entryLines.map((e) => e.raw),
257
+ ...audit,
258
+ ].join('\n');
259
+
260
+ return {
261
+ text,
262
+ decisions,
263
+ unresolved,
264
+ refused,
265
+ added: [...fleet.entries.keys()].filter((id) => !local.entries.has(id) && !broken.has(id)),
266
+ removed: [...local.entries.keys()].filter((id) => !fleet.entries.has(id) && !broken.has(id)),
267
+ unchanged,
268
+ wroteAnything: false,
269
+ };
270
+ }
@@ -104,6 +104,9 @@ import { appendMemory, readMemorySync } from '../agent/memory.mjs';
104
104
  // plugin asserting a claim on its own behalf, which is the sender's job.
105
105
  import { acceptBlock, verifyBlock } from '../agent/blocks.mjs';
106
106
  import { acceptWithAuthority } from '../agent/field-authority.mjs';
107
+ // v1.0.11: the merge rule (#2b) lives in its own module so the operator's two layers can be tested as a rule,
108
+ // exhaustively, without a profile or a relay.
109
+ import { mergeMemory } from './fleet-merge.mjs';
107
110
 
108
111
  // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
109
112
  // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
@@ -208,7 +211,7 @@ const MAX_STAGES = 16;
208
211
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
212
  * overrides it at runtime for tests and packagers.
210
213
  */
211
- const PLUGIN_VERSION = '1.0.9';
214
+ const PLUGIN_VERSION = '1.0.11';
212
215
 
213
216
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
217
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -5141,13 +5144,15 @@ export async function apply(ctx, config = {}) {
5141
5144
  */
5142
5145
  action: {
5143
5146
  type: 'string',
5144
- enum: ['read', 'publish', 'pull', 'latest'],
5147
+ enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge'],
5145
5148
  description:
5146
5149
  'read (default) = answer from memory. publish = upload the curated collective memory '
5147
5150
  + '(`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.',
5151
+ + 'relay which blob this fleet published last. pull = fetch one blob by SHA-256 -- or by "latest" -- and '
5152
+ + 'return its text, re-hashing it. diff = compare the local document against the fleet current blob and '
5153
+ + 'report what differs. merge = apply the declared conflict rule (newest wins by default; a declared '
5154
+ + 'repair of the default flow outranks recency) and return the merged text with every decision recorded. '
5155
+ + '**No action modifies the local file.**',
5151
5156
  default: 'read',
5152
5157
  },
5153
5158
  hash: {
@@ -5464,6 +5469,260 @@ export async function apply(ctx, config = {}) {
5464
5469
  });
5465
5470
  }
5466
5471
 
5472
+ /**
5473
+ * v1.0.10 (#2a): **compare** this machine's collective memory against the fleet's current blob. Writes nothing.
5474
+ *
5475
+ * ## Why this exists before merge does
5476
+ *
5477
+ * "Merge" needs a conflict rule -- *when two machines assert different facts, who wins?* -- and that is a
5478
+ * product decision, not an implementation detail. Shipping a merge before its rule is how a fleet silently
5479
+ * loses one machine's memory.
5480
+ *
5481
+ * What needs no decision is the half that **prevents harm**: telling a caller whether their local document is
5482
+ * the newest one, and what would be lost if they published over the fleet's copy. That is this action.
5483
+ *
5484
+ * ## Why a set comparison instead of a diff
5485
+ *
5486
+ * `same` is decided by **whole-document hash equality**, not by the absence of line differences: two documents
5487
+ * with identical line sets in a different order are not the same, and reporting them as the same would hide an
5488
+ * order change. The line sets are then reported as `local_only` / `fleet_only` -- deliberately *not* a diff,
5489
+ * because a diff would report "moved" versus "changed" and then want a merge policy. **A set comparison cannot
5490
+ * resolve anything, which is the property this step needs.**
5491
+ *
5492
+ * ## Why it re-hashes the fleet copy
5493
+ *
5494
+ * Comparing unverified content would launder it: the report would give a hash-verified-looking answer about
5495
+ * bytes nobody checked. A blob that fails its own name is reported as unverified, never compared.
5496
+ */
5497
+ if (typeof args?.action === 'string' && args.action === 'diff') {
5498
+ const file = collectivePath();
5499
+ if (file === null) {
5500
+ throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to compare collective memory from', {
5501
+ hint: 'the collective layer lives beside the profile that mounted this plugin',
5502
+ });
5503
+ }
5504
+ let localBytes;
5505
+ try {
5506
+ localBytes = await fs.readFile(file);
5507
+ } catch {
5508
+ throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to compare at ${file}`, {
5509
+ hint: 'write measured facts to it first; this action compares, it does not create',
5510
+ });
5511
+ }
5512
+ const localHash = createHash('sha256').update(localBytes).digest('hex');
5513
+ const base = requireRabbit(cfg);
5514
+ const token = await resolveToken(cfg);
5515
+
5516
+ const pointer = await request({
5517
+ rabbitUrl: base,
5518
+ token,
5519
+ pathname: '/v1/fleet/blobs/latest',
5520
+ signal: exec?.signal,
5521
+ });
5522
+ if (pointer.status === 404) {
5523
+ // No fleet document yet: nothing to differ from. Reported as a state, not as an error.
5524
+ return JSON.stringify({
5525
+ ok: true,
5526
+ same: false,
5527
+ local: { hash: localHash, bytes: localBytes.length, file },
5528
+ fleet: { present: false, reason: 'this fleet has published no blob yet' },
5529
+ merged: false,
5530
+ wrote_anything: false,
5531
+ });
5532
+ }
5533
+ if (pointer.status !== 200) {
5534
+ throw new W2MError(
5535
+ 'W2M_RABBIT_REFUSED',
5536
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
5537
+ { http_status: pointer.status, detail: pointer.error ?? null },
5538
+ );
5539
+ }
5540
+ const fleetHash = String(pointer.json?.hash ?? '');
5541
+ const fleetRes = await request({
5542
+ rabbitUrl: base,
5543
+ token,
5544
+ pathname: `/v1/fleet/blobs/${fleetHash}`,
5545
+ accept: 'application/octet-stream',
5546
+ signal: exec?.signal,
5547
+ });
5548
+ if (fleetRes.status === 404) {
5549
+ // The pointer is a hint (see #3): eviction can take the document it names.
5550
+ return JSON.stringify({
5551
+ ok: true,
5552
+ same: false,
5553
+ local: { hash: localHash, bytes: localBytes.length, file },
5554
+ fleet: {
5555
+ present: false,
5556
+ pointed_at: fleetHash,
5557
+ reason: 'the pointer names a blob that no longer exists (oldest-first eviction)',
5558
+ },
5559
+ merged: false,
5560
+ wrote_anything: false,
5561
+ });
5562
+ }
5563
+ if (fleetRes.status !== 200) {
5564
+ throw new W2MError(
5565
+ 'W2M_RABBIT_REFUSED',
5566
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/${fleetHash} answered HTTP ${fleetRes.status}`,
5567
+ { http_status: fleetRes.status, detail: fleetRes.error ?? null },
5568
+ );
5569
+ }
5570
+ const fleetText = fleetRes.text ?? '';
5571
+ const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
5572
+ if (fleetActual !== fleetHash) {
5573
+ throw new W2MError(
5574
+ 'W2M_BAD_BLOB',
5575
+ `W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`,
5576
+ {
5577
+ hint: 'refusing to compare unverified content -- a comparison over bytes nobody checked would '
5578
+ + 'launder them',
5579
+ expected: fleetHash,
5580
+ actual: fleetActual,
5581
+ },
5582
+ );
5583
+ }
5584
+
5585
+ const localText = localBytes.toString('utf8');
5586
+ // Trailing whitespace only: leading whitespace is markdown structure, not noise.
5587
+ const norm = (text) => new Set(text.split('\n').map((line) => line.replace(/\s+$/, '')));
5588
+ const localLines = norm(localText);
5589
+ const fleetLines = norm(fleetText);
5590
+ const localOnly = [...localLines].filter((line) => !fleetLines.has(line));
5591
+ const fleetOnly = [...fleetLines].filter((line) => !localLines.has(line));
5592
+ return JSON.stringify({
5593
+ ok: true,
5594
+ same: localHash === fleetHash,
5595
+ local: { hash: localHash, bytes: localBytes.length, lines: localLines.size, file },
5596
+ fleet: {
5597
+ present: true,
5598
+ hash: fleetHash,
5599
+ bytes: Buffer.byteLength(fleetText, 'utf8'),
5600
+ lines: fleetLines.size,
5601
+ published_at: pointer.json?.published_at ?? null,
5602
+ published_by: pointer.json?.published_by ?? null,
5603
+ },
5604
+ differing_lines: localOnly.length + fleetOnly.length,
5605
+ preview: {
5606
+ local_only: localOnly.slice(0, 20),
5607
+ fleet_only: fleetOnly.slice(0, 20),
5608
+ truncated: localOnly.length > 20 || fleetOnly.length > 20,
5609
+ },
5610
+ merged: false,
5611
+ wrote_anything: false,
5612
+ note: 'a comparison only. Merge has no conflict rule yet, so nothing is resolved and nothing is written.',
5613
+ });
5614
+ }
5615
+
5616
+ /**
5617
+ * v1.0.11 (#2b): merge this machine's collective memory with the fleet's current blob, **under the operator's
5618
+ * rule**, and still write nothing.
5619
+ *
5620
+ * ## The rule (the operator's, quoted)
5621
+ *
5622
+ * > 默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。
5623
+ *
5624
+ * The two layers are implemented in `src/plugin/fleet-merge.mjs`: newest publication wins by default, and a
5625
+ * declared repair of the default flow outranks recency. **The operationalisation is that module's; this branch
5626
+ * only supplies the inputs.**
5627
+ *
5628
+ * ## Why the result is returned rather than written
5629
+ *
5630
+ * The local file is this machine's only copy, and half of a merge's input arrived over the network. **Letting
5631
+ * one round trip rewrite it would hand a remote party a write into local memory.** So the merged text comes
5632
+ * back in the result, decisions and all, and the caller decides.
5633
+ *
5634
+ * ## Recency inputs, named
5635
+ *
5636
+ * `localAddedAtMs` is the file's mtime; `fleetAddedAtMs` is the relay's `published_at` for the pointer's blob.
5637
+ * Both are compared as milliseconds. **Neither is a claim about content** -- which is exactly why they are only
5638
+ * the tie-break of last resort under the default layer.
5639
+ */
5640
+ if (typeof args?.action === 'string' && args.action === 'merge') {
5641
+ const file = collectivePath();
5642
+ if (file === null) {
5643
+ throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to merge collective memory from', {
5644
+ hint: 'the collective layer lives beside the profile that mounted this plugin',
5645
+ });
5646
+ }
5647
+ let localBytes;
5648
+ let localStat;
5649
+ try {
5650
+ localBytes = await fs.readFile(file);
5651
+ localStat = await fs.stat(file);
5652
+ } catch {
5653
+ throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to merge at ${file}`, {
5654
+ hint: 'merge needs a local document; it compares against the fleet copy, it does not create one',
5655
+ });
5656
+ }
5657
+ const localHash = createHash('sha256').update(localBytes).digest('hex');
5658
+ const base = requireRabbit(cfg);
5659
+ const token = await resolveToken(cfg);
5660
+
5661
+ const pointer = await request({ rabbitUrl: base, token, pathname: '/v1/fleet/blobs/latest', signal: exec?.signal });
5662
+ if (pointer.status === 404) {
5663
+ return JSON.stringify({
5664
+ ok: true, merged: false, wrote_anything: false,
5665
+ local: { hash: localHash, bytes: localBytes.length, file },
5666
+ reason: 'this fleet has published no blob yet, so there is nothing to merge with. Publishing comes first.',
5667
+ });
5668
+ }
5669
+ if (pointer.status !== 200) {
5670
+ throw new W2MError('W2M_RABBIT_REFUSED', `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`, {
5671
+ http_status: pointer.status, detail: pointer.error ?? null,
5672
+ });
5673
+ }
5674
+ const fleetHash = String(pointer.json?.hash ?? '');
5675
+ const fleetRes = await request({
5676
+ rabbitUrl: base, token, pathname: `/v1/fleet/blobs/${fleetHash}`,
5677
+ accept: 'application/octet-stream', signal: exec?.signal,
5678
+ });
5679
+ if (fleetRes.status !== 200) {
5680
+ throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: the fleet pointer names ${fleetHash}, which answered HTTP ${fleetRes.status}`, {
5681
+ hint: 'the pointer is a hint (see #3); oldest-first eviction can remove the blob it names',
5682
+ prescribed: 'publish from a machine in this fleet to establish a current document again',
5683
+ });
5684
+ }
5685
+ const fleetText = fleetRes.text ?? '';
5686
+ const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
5687
+ if (fleetActual !== fleetHash) {
5688
+ throw new W2MError('W2M_BAD_BLOB', `W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`, {
5689
+ hint: 'refusing to merge unverified content', expected: fleetHash, actual: fleetActual,
5690
+ });
5691
+ }
5692
+
5693
+ const fleetPublishedAt = Date.parse(String(pointer.json?.published_at ?? ''));
5694
+ const outcome = mergeMemory({
5695
+ localText: localBytes.toString('utf8'),
5696
+ localAddedAtMs: Number.isFinite(localStat.mtimeMs) ? localStat.mtimeMs : 0,
5697
+ localHash,
5698
+ fleetText,
5699
+ fleetAddedAtMs: Number.isFinite(fleetPublishedAt) ? fleetPublishedAt : 0,
5700
+ fleetHash,
5701
+ });
5702
+
5703
+ return JSON.stringify({
5704
+ ok: true,
5705
+ merged: true,
5706
+ wrote_anything: false,
5707
+ rule: 'default: newest publication wins; a declared repair of the default flow outranks recency, and between '
5708
+ + 'two repairs the one with the higher (evidence + 2 x verified) wins',
5709
+ local: { hash: localHash, bytes: localBytes.length, file, added_at_ms: Math.round(localStat.mtimeMs) },
5710
+ fleet: {
5711
+ hash: fleetHash, bytes: Buffer.byteLength(fleetText, 'utf8'),
5712
+ published_at: pointer.json?.published_at ?? null, published_by: pointer.json?.published_by ?? null,
5713
+ },
5714
+ decisions: outcome.decisions,
5715
+ unresolved: outcome.unresolved,
5716
+ refused: outcome.refused,
5717
+ unchanged: outcome.unchanged,
5718
+ added: outcome.added,
5719
+ removed: outcome.removed,
5720
+ merged_text: outcome.text,
5721
+ next: 'this result is text only. Writing it back is the caller\'s decision -- a merge does not edit the '
5722
+ + 'document it read.',
5723
+ });
5724
+ }
5725
+
5467
5726
  const limitRaw = Number(args?.limit);
5468
5727
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
5469
5728