@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 +118 -0
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/plugin/fleet-merge.mjs +270 -0
- package/src/plugin/tools.mjs +264 -5
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.
|
|
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.
|
|
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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
5149
|
-
+ '
|
|
5150
|
-
+ '
|
|
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
|
|