@twinsearth/w2m-dsh-plugin 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,143 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.54.0] — 2026-10-15
8
+
9
+ **The fleet is published as an MCP server — read-only, and every declared route is checked against the relay's
10
+ own router.**
11
+
12
+ ### Five tools, and nothing that can change anything
13
+
14
+ ```
15
+ w2m_devices -> /v1/devices
16
+ w2m_stats -> /healthz
17
+ w2m_history -> /v1/tasks
18
+ w2m_report -> /v1/tasks/{id}/report
19
+ w2m_status -> /v1/agents/{id}/status
20
+ ```
21
+
22
+ **`w2m_run` is deliberately absent.** A server that hands fleet execution to any process able to start it gives
23
+ away the ability to run commands on every machine in the fleet — and this project has already learned that a
24
+ guard which is merely *named* well is not a guard: the `--dry-run` flag in `scripts/ilink-fleet-bridge.mjs`
25
+ **dispatched for real while its name promised otherwise**. So the first version exposes five reads, and a test
26
+ asserts by name that no mutating tool appears — **a later edit adding `w2m_run` has to delete an assertion rather
27
+ than quietly widen what the fleet exposes.**
28
+
29
+ ### I invented two routes, and the check that catches that now exists
30
+
31
+ My first draft declared `/v1/stats` and `/v1/status`. **Neither exists.** The relay's complete GET surface is
32
+ `/healthz`, `/metrics`, `/v1/stream`, `/v1/devices`, `/v1/tasks`, `/v1/tasks/{id}`, `/v1/tasks/{id}/report` and
33
+ `/v1/agents/{id}/status`.
34
+
35
+ **A fabricated route does not fail loudly.** The relay answers 404, the MCP client reports a broken server, and
36
+ the bug is in this package — a failure that surfaces in somebody else's tool, at a time nobody connects to this
37
+ commit. So `scripts/mcp-check.mjs` **reads the routes back out of `src/relay/server.mjs` and compares them**, and
38
+ the pre-flight runs it as a fifteenth check. Verified by injection: adding `/v1/invented` turns it red and names
39
+ the route.
40
+
41
+ **Two routes needed canonicalising to compare at all** — the router carries `/v1/tasks/([^/]+)/report` where the
42
+ tool declares `/v1/tasks/{id}/report`. The first version of the check failed on two routes that *are* routed,
43
+ which is its own lesson: **a check that fails on correct input gets disabled.**
44
+
45
+ ### Both protocol eras, because the deployed clients are on the older one
46
+
47
+ The current revision (`2026-07-28`) is **stateless** — version, identity and capabilities travel as per-request
48
+ `_meta`, and there is **no handshake**. Every earlier revision opens with `initialize`.
49
+
50
+ **A server implementing only the modern contract is unreachable from most deployed clients**, which is exactly
51
+ the population this exists to serve. So `initialize` is answered for legacy clients, `_meta` is honoured for
52
+ modern ones, and an unsupported version gets the `-32022` error **with the `supported` list**, as the
53
+ specification requires. `server/discover`, which the current revision demands of every server, is implemented.
54
+
55
+ **A notification is never answered** — JSON-RPC forbids it, and `notifications/initialized` is sent by every
56
+ legacy client, so getting that wrong breaks every connection.
57
+
58
+ ### stdout carries protocol messages and nothing else
59
+
60
+ Over stdio a stray `console.log` corrupts the stream, and the symptom is a **hung client**, not a chatty server.
61
+ Every diagnostic in this file goes to stderr through one `log()`, and the test drives the real `serve()` loop
62
+ over in-memory streams rather than spawning a process — **because a test that needs a subprocess is skipped
63
+ wherever piped stdio is refused, which is exactly where a protocol regression must still be caught.**
64
+
65
+ ### Four mistakes of mine, three of them the same mistake
66
+
67
+ * **`/v1/stats` and `/v1/status` were fabricated.** Read the router; do not write the route you expect.
68
+ * **`mcp-check.mjs` ran its whole check and called `process.exit` on import**, so the consuming suite reported
69
+ **1 test instead of 25** with no failure named. **This is the third file in this project to need the same
70
+ guarded-`main()` fix** — `skill-check.mjs` and `plugin-manifest-check.mjs` were the first two — and each one
71
+ now carries the guard with a comment saying why.
72
+ * **I deleted a doc comment's closing `*/`**, which commented out `export const TOOLS` and produced
73
+ `TOOLS is not defined`. **That is the fourth time in this project that an edit matched anchor text without
74
+ reading the block containing it.**
75
+ * **One assertion failed three times for three different incidental reasons** — a sample value substituted
76
+ where the builder applied a default, a `{id}` that `canonicalRoute` refuses to collapse, and an optional
77
+ property left unset. **The fix was to narrow what it tests**: the property is *"this tool can reach the route
78
+ it declares"*, and the query string is not part of that.
79
+
80
+ **25 new tests, 0 failures.** Full suite 2302 in 85 files. **Pre-flight GREEN with FIFTEEN checks.**
81
+
82
+ ## [0.53.0] — 2026-10-15
83
+
84
+ **The shared-memory layer gets a suite, and it corrected three things this session had already "verified".**
85
+
86
+ ### The layer was measured by hand; now it is measured by the suite
87
+
88
+ Field-level omission at level 0, the refusal of an unproducible ancestor, the level 0/70 difference — **all of
89
+ that was real evidence, produced through actual dispatches to an actual machine.** It was also **not
90
+ repeatable**: it lived in a session, not in the suite. **A measurement that cannot be re-run is a memory of a
91
+ measurement.**
92
+
93
+ `test/memory-layer.test.mjs` adds **22 assertions**, and the suite grew to 84 files.
94
+
95
+ ### Three corrections, each of which the hand-run had got wrong
96
+
97
+ **1. `evidence` costs 50, not 70.**
98
+
99
+ ```
100
+ FIELD_LEVELS = { subject: 0, body: 0, evidence: 50 }
101
+ ```
102
+
103
+ A level-70 dispatch succeeded, and **70 was written down as the threshold.** It is not: 49 is refused and 50 is
104
+ allowed, and **50 is not arbitrary — it is the bottom of the `captain` band**, which is why the number is what
105
+ it is.
106
+
107
+ **2. `confidence` is an invariant, not a high-cost field.**
108
+
109
+ `NEVER_ACCEPTED.confidence` = *"a receiver does not adopt the sender's certainty (**invariant, not a
110
+ default**)"*. Level 90 is refused exactly as level 0 is, and there is no level that satisfies it. **The reason
111
+ string says "invariant" on purpose: a reader who thinks it is a threshold will try to raise it.**
112
+
113
+ **3. `FIELD_UNDECLARED` does not mean "forbidden", and the two APIs answer different questions.**
114
+
115
+ | call | question | `origin` / `observed_at` / `lineage` |
116
+ |---|---|---|
117
+ | `acceptField` | *may a receiver choose this?* | `FIELD_UNDECLARED` — no rule lets a receiver pick it |
118
+ | `acceptWithAuthority` | *what does this receiver keep?* | **present in `accepted`** — they are inherited |
119
+
120
+ **So `UNDECLARED` means "not a receiver's to choose", not "refused".** Reading it as a refusal would have the
121
+ receiver drop the ancestry that makes the block meaningful — and `INHERITED_FIELDS` plus
122
+ `RECEIVER_CHOOSABLE_FIELDS` partition the seven exactly, so neither list can change without the other.
123
+
124
+ ### Four assertions of mine that were wrong, and the last one is the interesting one
125
+
126
+ * **`ancestry` takes a `Map`, not an array**, and returns `{chain, missing, cycle, depth}` — **there is no `ok`
127
+ and no `code`.**
128
+ * **`MISSING_PARENT` is not an `ancestry` outcome.** `ancestry` **collects** gaps in `missing` — *"knowing how
129
+ much of a history is absent is more useful than knowing that some of it is"* — while `acceptBlock` is what
130
+ **refuses**, returning `accepted: null`.
131
+ * **`isDescendantOf` returns `{descendant, chain, missing, checked}`, not a boolean.** It returns more than a
132
+ boolean because that is its point: *"a claim that descends from something this machine already thought is an
133
+ **echo**, not corroboration, and **a fleet that cannot tell those apart will confidently agree with
134
+ itself**."* A parent that is not held gives `descendant: false` — and my assertion had demanded `true`.
135
+ * **I could not construct a cycle, and that turned out to be the finding.** `blockHash` covers `lineage`, so
136
+ rebuilding a block changes its hash and the edge no longer closes — **a real cycle would need a hash
137
+ preimage.** The walk produces a *diamond* instead, and `CYCLE` is reachable **only** from a `have` map whose
138
+ keys disagree with the blocks under them: a corrupted or hostile store, where **terminating is the point,
139
+ because an unbounded walk on hostile input is a denial of service.**
140
+
141
+ **Three attempts at that test asserted a cycle the code was right not to report.** The fourth printed the
142
+ traversal instead of predicting it, which is the only one of the four that produced a fact.
143
+
7
144
  ## [0.52.0] — 2026-10-15
8
145
 
9
146
  **Agent Plugins 1.0.0 manifests, validated against the published schemas — and the honest note about who
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Publish this fleet as an MCP server, read-only (v0.54.0).
4
+ *
5
+ * ## Why read-only, and why that is the whole design
6
+ *
7
+ * The point of exposing the fleet to Claude Code, Cursor and anything else that speaks MCP is that a *different*
8
+ * agent can ask what the fleet is. **`w2m_run` is deliberately absent.** A server that hands `w2m_run` to any
9
+ * process able to start it gives away fleet execution rights, and this project has already learned that a guard
10
+ * which is merely *named* well is not a guard -- the `--dry-run` flag in `scripts/ilink-fleet-bridge.mjs`
11
+ * dispatched for real while its name promised otherwise.
12
+ *
13
+ * **So the first version exposes five tools that cannot change anything**, and each one is a thin wrapper over
14
+ * an endpoint the plugin already calls. **Nothing new is invented here**; if a tool is not already a read the
15
+ * plugin performs, it is not in this file.
16
+ *
17
+ * ## Two protocol eras, because the clients in the wild are on the older one
18
+ *
19
+ * The current revision (`2026-07-28`) is **modern**: version, identity and capabilities travel as per-request
20
+ * `_meta`, and there is **no handshake**. Every earlier revision (`2025-11-25` and back) is **legacy**: an
21
+ * `initialize` handshake, then `notifications/initialized`, then requests.
22
+ *
23
+ * **A server that implements only the modern contract is unreachable from most deployed clients**, which is
24
+ * exactly the population this exists to serve. So this is **dual-era**: `initialize` is answered for legacy
25
+ * clients, a request carrying `_meta` is answered for modern ones, and an unknown version gets the
26
+ * `-32022 UnsupportedProtocolVersionError` the specification requires, listing what is supported.
27
+ *
28
+ * ## stdout carries protocol messages and nothing else
29
+ *
30
+ * Over stdio, **one stray `console.log` corrupts the stream** and the failure looks like a hung client rather
31
+ * than a chatty server. So every diagnostic in this file goes to **stderr**, and `log()` is the only way to
32
+ * write one. There is no `console.log` here by construction, and the test asserts that.
33
+ */
34
+
35
+ import { readFileSync, existsSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { homedir } from 'node:os';
38
+ import { pathToFileURL } from 'node:url';
39
+
40
+ /** Versions this server can speak, newest first. */
41
+ export const SUPPORTED_VERSIONS = Object.freeze(['2026-07-28', '2025-11-25']);
42
+ /** The version assumed when a legacy client sends no `_meta` at all. */
43
+ export const LEGACY_VERSION = '2025-11-25';
44
+ export const SERVER_NAME = 'w2m-dsh-fleet';
45
+ export const SERVER_VERSION = '0.54.0';
46
+
47
+ /** JSON-RPC and MCP error codes this server can return. */
48
+ export const ERROR = Object.freeze({
49
+ PARSE: -32700,
50
+ INVALID_REQUEST: -32600,
51
+ METHOD_NOT_FOUND: -32601,
52
+ INVALID_PARAMS: -32602,
53
+ INTERNAL: -32603,
54
+ UNSUPPORTED_PROTOCOL_VERSION: -32022,
55
+ });
56
+
57
+ /** Diagnostics, on stderr only. */
58
+ const log = (message) => process.stderr.write(`[w2m-mcp] ${message}\n`);
59
+
60
+ /**
61
+ * Where the relay and this machine's identity come from.
62
+ *
63
+ * Read from the same sources the plugin uses, so an MCP client sees the fleet the rest of the tooling sees
64
+ * rather than a second, separately configured one.
65
+ */
66
+ export function readConfig(env = process.env) {
67
+ const home = env.DSH_HOME ?? join(homedir(), '.dsh');
68
+ const deviceFile = env.W2M_DEVICE_FILE ?? join(home, 'xclient', 'device.json');
69
+ let device = null;
70
+ if (existsSync(deviceFile)) {
71
+ try { device = JSON.parse(readFileSync(deviceFile, 'utf8')); } catch (error) { log(`device.json unreadable: ${error.message}`); }
72
+ }
73
+ return {
74
+ relayUrl: env.W2M_RABBIT_URL ?? 'http://202.182.123.154:8787',
75
+ operatorToken: env.W2M_OPERATOR_TOKEN ?? '',
76
+ device,
77
+ deviceFile,
78
+ };
79
+ }
80
+
81
+ /** Headers for a relay call: the device token identifies this machine, the operator token authorises reads. */
82
+ const headers = (config) => {
83
+ const h = { 'content-type': 'application/json' };
84
+ if (config.device?.device_token) h['x-w2m-device-token'] = config.device.device_token;
85
+ if (config.operatorToken !== '') h.authorization = `Bearer ${config.operatorToken}`;
86
+ return h;
87
+ };
88
+
89
+ /**
90
+ * The five read-only tools.
91
+ *
92
+ * ## Every route here was read out of `src/relay/server.mjs`, not guessed
93
+ *
94
+ * The relay's complete GET surface is `/healthz`, `/metrics`, `/v1/stream`, `/v1/devices`, `/v1/tasks`,
95
+ * `/v1/tasks/{id}`, `/v1/tasks/{id}/report` and `/v1/agents/{id}/status`. **My first draft of this file
96
+ * invented `/v1/stats` and `/v1/status`, which do not exist** -- and a tool whose route is fabricated returns
97
+ * a 404 that reads as a broken relay rather than as a bug in this file.
98
+ *
99
+ * ## These are relay reads, not the DSH tools
100
+ *
101
+ * `w2m_stats` and `w2m_status` in the plugin both begin from `/healthz`, and each adds context the relay
102
+ * cannot know: which profile directory is in play, where `rabbitUrl` came from, whether a bundled runtime
103
+ * resolved. **That context lives in the plugin's process, so an MCP client calling this server gets the relay's
104
+ * half of the answer.** The descriptions say so rather than implying parity.
105
+ *
106
+ * `routes` names the paths a tool may call, and `scripts/mcp-check.mjs` asserts each one appears in the
107
+ * relay's router -- **so a fabricated route fails the pre-flight instead of failing in someone's client.**
108
+ */
109
+ export const TOOLS = Object.freeze([
110
+ {
111
+ name: 'w2m_devices',
112
+ description: 'List the machines the relay holds a lease for: id, name, platform, online state and last-seen. Read-only. Answers "who is in the fleet" without running anything.',
113
+ routes: ['/v1/devices'],
114
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
115
+ requests: () => ['/v1/devices'],
116
+ },
117
+ {
118
+ name: 'w2m_stats',
119
+ description: "The relay's own snapshot of the fleet: relay identity, uptime, device and task counts, aggregate round-trip times, and whether the relay restarted. Read-only. This is the relay's half of the answer -- the parts of the plugin's w2m_stats that come from the local profile are not reachable over MCP.",
120
+ routes: ['/healthz'],
121
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
122
+ requests: () => ['/healthz'],
123
+ },
124
+ {
125
+ name: 'w2m_history',
126
+ description: 'List recent tasks newest first: id, mode, shard count, which machines hold leases, and whether each was cancelled. Read-only, and deliberately narrower than w2m_status: it names what ran without fetching any result.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many tasks to return, newest first. Defaults to 20.' },
131
+ },
132
+ additionalProperties: false,
133
+ },
134
+ routes: ['/v1/tasks'],
135
+ requests: (args) => {
136
+ const raw = args.limit;
137
+ if (raw === undefined) return ['/v1/tasks'];
138
+ if (!Number.isInteger(raw) || raw < 1) throw new Error(`limit must be a positive integer, got ${JSON.stringify(raw)}`);
139
+ return [`/v1/tasks?limit=${raw}`];
140
+ },
141
+ },
142
+ {
143
+ name: 'w2m_report',
144
+ description: 'Fetch the rendered report for one finished task as markdown or json. Markdown is the human summary; json is the same aggregate shape w2m_status returns. Read-only, and does not start, resume or cancel anything.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ task_id: { type: 'string', minLength: 1, description: 'Task id returned by a dispatch.' },
149
+ format: { type: 'string', enum: ['md', 'json'], description: 'md for the rendered report, json for the aggregate. Defaults to md.' },
150
+ },
151
+ required: ['task_id'],
152
+ additionalProperties: false,
153
+ },
154
+ routes: ['/v1/tasks/{id}/report'],
155
+ requests: (args) => {
156
+ // A task id is opaque, but it lands in a URL path -- so anything that could change which endpoint is
157
+ // addressed is refused rather than escaped into place.
158
+ const id = String(args.task_id ?? '');
159
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`task_id ${JSON.stringify(id)} is not a plausible task id`);
160
+ return [`/v1/tasks/${id}/report?format=${args.format === 'json' ? 'json' : 'md'}`];
161
+ },
162
+ },
163
+ {
164
+ name: 'w2m_status',
165
+ description: "This plugin's own view of itself: the machine identity on disk, which relay it is talking to and where that address came from, whether the relay answers, and this machine's round-trip time. Read-only, and usable while the configuration is still broken.",
166
+ routes: ['/v1/agents/{id}/status'],
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ machine_id: { type: 'string', minLength: 1, description: 'Machine id, as listed by w2m_devices.' },
171
+ },
172
+ required: ['machine_id'],
173
+ additionalProperties: false,
174
+ },
175
+ requests: (args) => {
176
+ const id = String(args.machine_id ?? '');
177
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`machine_id ${JSON.stringify(id)} is not a plausible machine id`);
178
+ return [`/v1/agents/${id}/status`];
179
+ },
180
+ },
181
+ ]);
182
+
183
+ /** Tool names, for the tests and for a caller that wants the list without the schemas. */
184
+ export const TOOL_NAMES = Object.freeze(TOOLS.map((t) => t.name));
185
+
186
+ /**
187
+ * Perform one tool call.
188
+ *
189
+ * Returns the MCP `tools/call` result shape. **A failure is reported as `isError: true` with the reason in the
190
+ * text rather than as a JSON-RPC error**, because the model can act on the former and a transport-level error
191
+ * reads as a broken server.
192
+ *
193
+ * @param {string} name
194
+ * @param {object} args
195
+ * @param {{config: object, fetchImpl: Function}} deps
196
+ */
197
+ export async function callTool(name, args, deps) {
198
+ const tool = TOOLS.find((t) => t.name === name);
199
+ if (tool === undefined) {
200
+ return { content: [{ type: 'text', text: `no such tool: ${name}. This server exposes ${TOOL_NAMES.join(', ')}.` }], isError: true };
201
+ }
202
+ let paths;
203
+ try {
204
+ paths = tool.requests(args ?? {});
205
+ } catch (error) {
206
+ return { content: [{ type: 'text', text: `bad arguments: ${error.message}` }], isError: true };
207
+ }
208
+ const base = deps.config.relayUrl.replace(/\/+$/, '');
209
+ try {
210
+ // Sequential rather than concurrent: a tool that reads two endpoints should report the first failure rather
211
+ // than racing two errors against each other.
212
+ const parts = [];
213
+ for (const path of paths) {
214
+ const response = await deps.fetchImpl(`${base}${path}`, { headers: headers(deps.config) });
215
+ const body = await response.text();
216
+ if (!response.ok) {
217
+ return { content: [{ type: 'text', text: `the relay answered ${response.status} for ${name} ${path}: ${body.slice(0, 400)}` }], isError: true };
218
+ }
219
+ parts.push(paths.length === 1 ? body : `${path}\n${body}`);
220
+ }
221
+ return { content: [{ type: 'text', text: parts.join('\n\n') }], isError: false };
222
+ } catch (error) {
223
+ return { content: [{ type: 'text', text: `could not reach the relay at ${deps.config.relayUrl}: ${error.message}` }], isError: true };
224
+ }
225
+ }
226
+
227
+ /** Build a JSON-RPC success or error envelope. */
228
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
229
+ const fail = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
230
+
231
+ /** The protocol version a request declares, from `_meta`, or null for a legacy client. */
232
+ const declaredVersion = (request) => {
233
+ const meta = request?.params?._meta;
234
+ const v = meta?.['io.modelcontextprotocol/protocolVersion'];
235
+ return typeof v === 'string' ? v : null;
236
+ };
237
+
238
+ /**
239
+ * Answer one JSON-RPC message, or return null when nothing should be written.
240
+ *
241
+ * A notification has no `id` and **MUST NOT** be answered -- writing a reply to one is a protocol violation
242
+ * that some clients treat as a desynchronised stream.
243
+ *
244
+ * @param {object} request
245
+ * @param {{config: object, fetchImpl: Function}} deps
246
+ * @returns {Promise<object|null>}
247
+ */
248
+ export async function handleMessage(request, deps) {
249
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) {
250
+ return fail(null, ERROR.INVALID_REQUEST, 'not a JSON-RPC request object');
251
+ }
252
+ const { id, method, params } = request;
253
+ const isNotification = id === undefined || id === null;
254
+
255
+ const version = declaredVersion(request);
256
+ if (version !== null && !SUPPORTED_VERSIONS.includes(version)) {
257
+ // The specification requires the supported list, so a client can retry rather than give up.
258
+ return isNotification ? null : fail(id, ERROR.UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', { supported: [...SUPPORTED_VERSIONS], requested: version });
259
+ }
260
+
261
+ switch (method) {
262
+ case 'initialize': {
263
+ // Legacy handshake. `protocolVersion` echoes the client's choice when it is one this server speaks.
264
+ const asked = params?.protocolVersion;
265
+ const chosen = SUPPORTED_VERSIONS.includes(asked) ? asked : LEGACY_VERSION;
266
+ return ok(id, {
267
+ protocolVersion: chosen,
268
+ capabilities: { tools: { listChanged: false } },
269
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
270
+ instructions: 'Read-only view of a W2M fleet. These tools report; none of them can dispatch a command or change any machine.',
271
+ });
272
+ }
273
+
274
+ case 'notifications/initialized':
275
+ // A notification: acknowledged by doing nothing, never answered.
276
+ return null;
277
+
278
+ case 'server/discover':
279
+ // Required of every server by the current revision: name the versions supported.
280
+ return ok(id, { protocolVersion: SUPPORTED_VERSIONS[0], supportedVersions: [...SUPPORTED_VERSIONS], serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } });
281
+
282
+ case 'tools/list':
283
+ return ok(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
284
+
285
+ case 'tools/call': {
286
+ const name = params?.name;
287
+ if (typeof name !== 'string' || name === '') return fail(id, ERROR.INVALID_PARAMS, 'tools/call requires a tool name');
288
+ return ok(id, await callTool(name, params?.arguments ?? {}, deps));
289
+ }
290
+
291
+ case 'ping':
292
+ return ok(id, {});
293
+
294
+ default:
295
+ if (isNotification) return null;
296
+ return fail(id, ERROR.METHOD_NOT_FOUND, `method not found: ${String(method)}`);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Read newline-delimited JSON from `input` and answer each message on `output`.
302
+ *
303
+ * Exported so the test drives the same loop the process runs, without spawning anything -- a suite that needs a
304
+ * subprocess is skipped wherever piped stdio is refused, which is exactly where this needs to be testable.
305
+ */
306
+ export function serve({ input, output, deps }) {
307
+ let buffer = '';
308
+ let pending = Promise.resolve();
309
+ input.setEncoding('utf8');
310
+ input.on('data', (chunk) => {
311
+ buffer += chunk;
312
+ let newline = buffer.indexOf('\n');
313
+ while (newline >= 0) {
314
+ const line = buffer.slice(0, newline).trim();
315
+ buffer = buffer.slice(newline + 1);
316
+ if (line !== '') {
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(line);
320
+ } catch (error) {
321
+ output.write(`${JSON.stringify(fail(null, ERROR.PARSE, `invalid JSON: ${error.message}`))}\n`);
322
+ newline = buffer.indexOf('\n');
323
+ continue;
324
+ }
325
+ // Serialised so two calls cannot interleave their frames on stdout.
326
+ pending = pending.then(async () => {
327
+ const reply = await handleMessage(parsed, deps);
328
+ if (reply !== null) output.write(`${JSON.stringify(reply)}\n`);
329
+ }).catch((error) => {
330
+ log(`handler threw: ${error?.stack ?? error}`);
331
+ output.write(`${JSON.stringify(fail(parsed?.id ?? null, ERROR.INTERNAL, String(error?.message ?? error)))}\n`);
332
+ });
333
+ }
334
+ newline = buffer.indexOf('\n');
335
+ }
336
+ });
337
+ return pending;
338
+ }
339
+
340
+ function main() {
341
+ const config = readConfig();
342
+ log(`starting; relay ${config.relayUrl}; device ${config.device?.machine_name ?? '(none)'}`);
343
+ log(`read-only tools: ${TOOL_NAMES.join(', ')}`);
344
+ serve({ input: process.stdin, output: process.stdout, deps: { config, fetchImpl: fetch } });
345
+ }
346
+
347
+ // Only when run as a program, so importing this module in a test starts no server.
348
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) main();
package/mcp.json CHANGED
@@ -1,4 +1,11 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
- "mcpServers": {}
3
+ "mcpServers": {
4
+ "w2m-fleet": {
5
+ "type": "stdio",
6
+ "command": "node",
7
+ "args": ["./bin/w2m-mcp.mjs"],
8
+ "cwd": "${PLUGIN_ROOT}"
9
+ }
10
+ }
4
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -116,13 +116,13 @@
116
116
  }
117
117
  },
118
118
  "scripts": {
119
- "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/chatops-fleet-join.test.mjs",
119
+ "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/chatops-fleet-join.test.mjs",
120
120
  "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",
121
121
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
122
122
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
123
123
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
124
124
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
125
- "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/chatops-fleet-join.test.mjs",
125
+ "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/chatops-fleet-join.test.mjs",
126
126
  "pack": "node scripts/pack.mjs",
127
127
  "verify:join": "node scripts/verify-join.mjs",
128
128
  "lint": "eslint .",
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": "0.52.0",
4
+ "version": "0.54.0",
5
5
  "description": "Cross-platform fleet execution between Windows and macOS: one DeepSeek Harness account, a Rabbit relay, and signed device identity. Ships four Agent Skills describing fleet dispatch, field-level memory acceptance, session references, and resource scheduling.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: memory-sync
3
- description: Use when writing or reading a block through w2m_memory, when a field was omitted rather than nulled, when a block names an ancestor the caller cannot produce, or when deciding whether a fact belongs in durable memory at all. Covers field-level acceptance, the per-actor cost of each field, and the refusal codes that mean "kept, but not for you".
3
+ description: Use when writing or reading a block through w2m_memory, when a field was omitted rather than nulled, when a block names an ancestor the caller cannot produce, or when deciding whether a fact belongs in durable memory at all. Covers the seven CAT7 fields by their real names, the per-field cost a receiver pays, the one field no authority can accept, and why a refused ancestor is never re-rooted.
4
4
  ---
5
5
 
6
6
  # Memory sync
@@ -8,54 +8,115 @@ description: Use when writing or reading a block through w2m_memory, when a fiel
8
8
  `w2m_memory` stores blocks that carry **provenance**, and it decides field by field what each reader may see.
9
9
  The tool is small; the rules are the part that matters.
10
10
 
11
+ ## The seven fields, by their real names
12
+
13
+ ```
14
+ CAT7_FIELDS = [origin, subject, body, evidence, confidence, observed_at, lineage]
15
+ ```
16
+
17
+ **The order is part of the hash**, so a block is not a bag of fields — it is a canonical serialization.
18
+ `blockHash` covers all seven, which is why tampering with any one of them fails `verifyBlock` with
19
+ `BLOCK_HASH_MISMATCH`.
20
+
21
+ They split into exactly two groups, and the split is the reason a receiver cannot rewrite provenance:
22
+
23
+ | Group | Fields | Why |
24
+ |---|---|---|
25
+ | **inherited** | `origin`, `observed_at`, `lineage` | where it came from and when it was seen — **never offered to a receiver's chooser** |
26
+ | **receiver-choosable** | `subject`, `body`, `evidence`, `confidence` | what the receiver decides to keep |
27
+
28
+ **The two lists partition the seven**, so neither can grow without the other shrinking.
29
+
30
+ ### Names that are NOT part of this implementation
31
+
32
+ A description circulating elsewhere names the seven as *focus, issue, intent, motivation, commitment,
33
+ perspective, mood*, and refers to **`SVAF`** and **`remix`**. **None of those names exist here.** Measured:
34
+ `SVAF` and `remix` occur **zero** times in the source. The mechanisms have different names:
35
+
36
+ | Described elsewhere | What this implementation actually has |
37
+ |---|---|
38
+ | the seven-field cognitive model | `CAT7_FIELDS`, as listed above |
39
+ | `SVAF`, per-receiver role anchoring | `RECEIVER_CHOOSABLE_FIELDS` + `acceptField(field, actor)` |
40
+ | lineage by content-hash parent | `lineage` + `blockHash` + `ancestry` + `isDescendantOf` |
41
+ | `remix` storing only the receiver's own evaluation | `INHERITED_FIELDS` and `defaultChoose` |
42
+
43
+ **Writing the other names produces `BLOCK_UNKNOWN_FIELD` or `FIELD_NOT_A_FIELD`**, so a block built from that
44
+ description is refused at the wire rather than silently misread.
45
+
46
+ ## Cost is per field, and one field is not a threshold
47
+
48
+ ```js
49
+ FIELD_LEVELS = { subject: 0, body: 0, evidence: 50 }
50
+ NEVER_ACCEPTED = { confidence: "a receiver does not adopt the sender's certainty (invariant, not a default)" }
51
+ ```
52
+
53
+ **`evidence` costs 50** — the bottom of the `captain` band, which is why the number is what it is. Level 49 is
54
+ refused with `FIELD_BELOW_AUTHORITY` and `required: 50`; level 50 is allowed.
55
+
56
+ **`confidence` is refused at every level, including 90 and above**, with `FIELD_ALWAYS_REFUSED` and
57
+ `required: null`. **The reason string says "invariant" on purpose: a reader who thinks it is a threshold will
58
+ try to raise it.** There is no level that satisfies it.
59
+
60
+ `origin`, `observed_at` and `lineage` answer `FIELD_UNDECLARED` to `acceptField` — **which does not mean
61
+ "forbidden".** They have no rule because **a receiver does not choose them**, and `acceptWithAuthority` still
62
+ returns them in `accepted`. **Reading `UNDECLARED` as a refusal would drop the ancestry that makes the block
63
+ meaningful.**
64
+
11
65
  ## Acceptance is per field, and omission is not null
12
66
 
13
- A block is a set of named fields. When an actor's authority cannot cover a field, **the field is absent from
14
- what they receive — it is not present-with-`null`, and it is not an error.**
67
+ When an actor's authority cannot cover a field, **the field is absent from what they receive — it is not
68
+ present-with-`null`, and it is not an error.**
15
69
 
16
70
  ```
17
- accepted = [subject, body] refused = [evidence]
18
- hasOwnProperty('evidence') === false
71
+ level 0 accepted = [origin, observed_at, lineage, subject, body] refused = [evidence, confidence]
72
+ level 50 accepted = [origin, observed_at, lineage, subject, body, evidence] refused = [confidence]
19
73
  ```
20
74
 
21
75
  **Test for the key, not for a falsy value.** `if (!block.evidence)` cannot tell "omitted" from "empty", and
22
76
  those mean different things: one is a permission decision, the other is content.
23
77
 
78
+ `acceptBlock` returns a **partition**, not a filtered copy — *"the refused half is evidence: 'this machine
79
+ declined the confidence and kept the body' is a decision worth being able to inspect, and a filtered object
80
+ silently loses it."*
81
+
24
82
  **Never reconstruct a refused field.** If you need it, the answer is a higher-authority actor or a different
25
83
  block, not a guess.
26
84
 
27
85
  ## A block cannot name an ancestor its caller cannot produce
28
86
 
29
- Providing a block whose `parent` is not in the caller's own reachable set is refused as `BLOCK_MISSING_PARENT`,
30
- and **the block is rejected rather than re-rooted.**
87
+ `acceptBlock(block, { lineage })` refuses a block whose `lineage` names an ancestor outside the set the
88
+ receiver can produce:
31
89
 
32
- **This is deliberate.** Silently re-rooting would make the ancestry claim unfalsifiable — every block would
33
- "fit" somewhere, and the chain would stop carrying information. When you hit this refusal, supply the missing
34
- ancestor or drop the claim; do not look for a flag that relaxes it.
90
+ ```
91
+ { accepted: null, refused: [{ field: 'lineage', why: 'BLOCK_MISSING_PARENT: cannot produce ancestor(s) …;
92
+ refusing rather than re-rooting' }], accepted_fields: [], verified: true }
93
+ ```
35
94
 
36
- ## Cost is a property of the field, not of the request
95
+ **`verified: true` — the block itself is well formed; the ancestry is what is absent.** And it is refused
96
+ rather than re-rooted, for the reason the source states:
37
97
 
38
- Authority bands, and what they reach:
98
+ > Re-rooting — keeping the block and emptying its lineage — turns a claim with an unverifiable history into one
99
+ > with **no history**, which reads as a fresh, independent assertion. **That is precisely how a fabricated
100
+ > claim acquires respectability.**
39
101
 
40
- | Band | Range | Reaches |
41
- |---|---|---|
42
- | `ordinary` | 0–49 | the low-cost fields |
43
- | `captain` | 50–69 | more, including some evidence |
44
- | `org_admin` | 70–89 | `evidence` and above |
45
- | `system_admin` | 90+ | everything |
102
+ **`ancestry` answers a different question and does not fail.** It takes a `Map<hash, block>` and returns
103
+ `{chain, missing, cycle, depth}` — **there is no `ok` and no `code`.** Gaps are **collected** in `missing`
104
+ because *"knowing how much of a history is absent is more useful than knowing that some of it is."*
46
105
 
47
- **A field's cost is declared where the field is declared**, and the acceptance decision is made against the
48
- actor's level at write and at read. So the same block read by two actors is two different objects, and that is
49
- the feature rather than a leak.
106
+ **`isDescendantOf` returns `{descendant, chain, missing, checked}` — not a boolean.** It returns more than a
107
+ boolean on purpose: *"a claim that descends from something this machine already thought is an **echo**, not
108
+ corroboration, and a fleet that cannot tell those apart will confidently agree with itself."*
50
109
 
51
- Do not design a workflow that depends on a high-authority actor being present to make a low-authority read
52
- work. **Model the read you actually need.**
110
+ **You cannot construct a cycle.** `blockHash` covers `lineage`, so rebuilding a block changes its hash and the
111
+ edge no longer closes — a real cycle would need a hash preimage. `BLOCK_CYCLE` guards a `have` map whose keys
112
+ disagree with the blocks under them: **a corrupted or hostile store, where terminating is the point.**
53
113
 
54
114
  ## Writing: say what you can prove
55
115
 
56
116
  - **Name the ancestor only if you hold it.** A parent you cannot produce fails the whole block.
57
- - **Put the load-bearing fact in the cheapest field that can carry it.** A fact in `evidence` is invisible to
58
- everyone below `org_admin`, so a block whose meaning lives in `evidence` means nothing to most readers.
117
+ - **Put the load-bearing fact in the cheapest field that can carry it.** A fact in `evidence` is invisible
118
+ below level 50, so a block whose meaning lives in `evidence` means nothing to most readers.
119
+ - **Never write `confidence` expecting it to travel.** It is refused by every receiver by construction.
59
120
  - **One block, one claim.** Ancestry is how blocks relate; packing two claims into one block makes each
60
121
  unreadable to whoever may see only one of them.
61
122
  - **Expect omission to be silent.** Nothing in the result says "a field was hidden from you" beyond the field
@@ -65,5 +126,5 @@ work. **Model the read you actually need.**
65
126
 
66
127
  1. What is the **cheapest field** that can carry this fact?
67
128
  2. Do you **hold the parent** you are about to name?
68
- 3. Who needs to read this, and **what band do they hold**? If the answer is "everyone", the fact does not
69
- belong in a costly field.
129
+ 3. Who needs to read this, and **what band do they hold**? `ordinary` is 0–49, `captain` 50–69, `org_admin`
130
+ 70–89, `system_admin` 90+. If the answer is "everyone", the fact does not belong in `evidence`.
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.52.0';
211
+ const PLUGIN_VERSION = '0.54.0';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;