@twinsearth/w2m-dsh-plugin 0.53.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 +75 -0
- package/bin/w2m-mcp.mjs +348 -0
- package/mcp.json +8 -1
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/skills/memory-sync/SKILL.md +88 -27
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,81 @@ 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
|
+
|
|
7
82
|
## [0.53.0] — 2026-10-15
|
|
8
83
|
|
|
9
84
|
**The shared-memory layer gets a suite, and it corrected three things this session had already "verified".**
|
package/bin/w2m-mcp.mjs
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "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/memory-layer.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/memory-layer.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.
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
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]
|
|
18
|
-
|
|
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
|
-
|
|
30
|
-
|
|
87
|
+
`acceptBlock(block, { lineage })` refuses a block whose `lineage` names an ancestor outside the set the
|
|
88
|
+
receiver can produce:
|
|
31
89
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
58
|
-
|
|
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**?
|
|
69
|
-
belong in
|
|
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`.
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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;
|