@twinsearth/w2m-dsh-plugin 0.49.0 → 0.52.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,176 @@ 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.52.0] — 2026-10-15
8
+
9
+ **Agent Plugins 1.0.0 manifests, validated against the published schemas — and the honest note about who
10
+ reads them.**
11
+
12
+ ### What shipped
13
+
14
+ ```
15
+ plugin.json root manifest, Agent Plugins 1.0.0, $schema pinned to the canonical identifier
16
+ mcp.json schema-valid with an EMPTY server map -- see below
17
+ skills/ unchanged; the four SKILL.md files were already the portable form
18
+ ```
19
+
20
+ `scripts/plugin-manifest-check.mjs` validates both, `pre-flight` gained a fourteenth check, and
21
+ `test/agent-plugins.test.mjs` adds fourteen assertions across both manifests and the skills.
22
+
23
+ ### The schemas were read, and the constraints are written out rather than fetched
24
+
25
+ `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json` and `.../mcp.schema.json` are both live, and the
26
+ rules come from them — including the `name` pattern `^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`,
27
+ **which refuses `--` and `..` and which I would not have guessed.** They are **not fetched at check time**:
28
+ a validator that needs the network cannot run offline, and one that skips itself when the fetch fails reports
29
+ success for the case it could not measure.
30
+
31
+ **What this cannot check, stated rather than implied:** the schema's own description says *"the Agent Plugins
32
+ specification defines additional semantic and operational requirements."* Path containment, symlink
33
+ resolution, MCP transport support and the `PLUGIN_ROOT`/`PLUGIN_DATA` expansion rules are **not** expressible
34
+ here and **are not claimed**.
35
+
36
+ ### `mcp.json` ships with zero servers, and that is the honest version
37
+
38
+ The request specified MCP as the unified calling protocol. **DSH has no MCP client.** Measured across all
39
+ 8543 small code and config files in the application bundle: `plugin.json` 0, `mcp.json` 0, `agent-plugins` 0,
40
+ **and `mcpServers` 0 occurrences in host-owned code** — the 202 hits are third-party SDKs inside
41
+ `node_modules`, which support MCP without this host reading a config for it.
42
+
43
+ **And there is no MCP server in this package.** So the file is schema-valid with an empty map: a portable
44
+ client reads it and starts nothing, which is true. **The alternative — declaring a server that does not
45
+ exist — is a manifest that launches a failure**, and shipping that to satisfy a specification would be the
46
+ same mistake as advertising a capability the code lacks.
47
+
48
+ ### The one rule that is not from a schema caught me immediately
49
+
50
+ `plugin.json`'s `version` must equal `package.json`'s. **On the validator's first run it failed: the manifest
51
+ said 0.52.0 and `package.json` still said 0.51.0.** That is the whole justification for the rule — DSH loads
52
+ plugins through `package.json#dsh` and a Cordis bundle patch, **so nothing in this host ever reads
53
+ `plugin.json`.** A manifest with no consumer has no way to notice it is stale.
54
+
55
+ **A file nobody reads is a file that cannot be wrong.** The schema check and the drift check are the only
56
+ things standing between this manifest and quiet decay.
57
+
58
+ ### Four assertions that were wrong, mine
59
+
60
+ * **`frontmatter('---\nname: a\n')` returns `null`, not `{ data: undefined }`** — an unclosed block is not
61
+ frontmatter, and my assertion demanded the shape the code does not return. **The code was right.**
62
+ * **I typed `disableModelInvoction`** in the rejected-spelling set. **A misspelled name in a rejection list
63
+ is a name that is not rejected** — the exact failure the list exists to prevent.
64
+ * **I exported a contract object above the constants it read**, so importing the module threw
65
+ `Cannot access 'PLUGIN_FIELDS' before initialization`.
66
+ * **I wrote the `implementations` Map entry as `['name']: () => …`** inside an array of pairs, matching an
67
+ anchor line without reading the structure around it. **This is the third time in this project that an
68
+ edit matched an anchor string instead of the block containing it.**
69
+
70
+ Both validators now export their rules and keep the CLI in `main()`, so **the suite asserts them without
71
+ spawning a child** — a test that needs a subprocess is skipped wherever piped stdio is refused, which is
72
+ exactly where a check must still run.
73
+
74
+ ## [0.51.0] — 2026-10-15
75
+
76
+ **Four Agent Skills, written against the loader's real contract — and a gate that makes the contract matter.**
77
+
78
+ ### The four skills
79
+
80
+ ```
81
+ skills/
82
+ ├── fleet-control/SKILL.md the four modes, the nine verdicts, every refusal code
83
+ ├── memory-sync/SKILL.md field-level acceptance, omission versus null, ancestry
84
+ ├── session-ref/SKILL.md the dsh-session: codec, its three checks, its silent trap
85
+ └── resource-schedule/SKILL.md lease states, the ~50s ceiling, what "expired" means
86
+ ```
87
+
88
+ **Every rule in them was measured before it was written.** The refusal codes are the ones this session
89
+ actually produced, the ~50-second ceiling is one that was hit more than eight times, and the `dsh-session:`
90
+ section describes a codec whose three checks were read out of `@deepseek-ai/dsh-session-reference` — **including
91
+ the one that surprises people, where a payload that decodes correctly is still refused for not being
92
+ *canonical*.**
93
+
94
+ ### The contract came from the loader, not from a specification
95
+
96
+ `parseSkillFile` reads **`name`** and **`description`**, tests the name against
97
+ `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, and **rejects three renamed spellings outright** —
98
+ `disableModelInvocation`, `modelInvocable`, `userInvocable` — in favour of the hyphenated forms. On any
99
+ failure it logs a warning and **returns**.
100
+
101
+ **That is the whole reason this ships with a gate.** A skill that violates the contract is not an error: it
102
+ simply never appears, the directory still looks shipped, and nothing is red. **It is a file whose only symptom
103
+ of being wrong is not being there.**
104
+
105
+ ### New check: `agent skills`, and the twelfth became the thirteenth
106
+
107
+ `scripts/skill-check.mjs` mirrors the loader's rules, and `pre-flight` now runs it.
108
+
109
+ ```
110
+ PASS agent skills (frontmatter satisfies the loader, so no skill is silently dropped): 4 skill(s) satisfy the loader contract
111
+ pre-flight: 13 pass, 0 fail, 1 unverifiable (13/13 registered check(s) reported) -- GREEN
112
+ ```
113
+
114
+ It is bound in **three** places — `DECLARED_CHECKS`, `GATE_INVENTORY` (five artifact rows: the validator and
115
+ the four `SKILL.md` files), and the implementation map — because this repository's own rule is that a
116
+ declared check with no artifact, or an artifact with no check, is red in both directions.
117
+
118
+ ### Two mistakes of mine, both worth keeping visible
119
+
120
+ **The check reported `exit null` and nothing else.** The cause was `result.error` — an `EPERM` from
121
+ `spawnSync` under a `workspace-write` sandbox that had replaced the `danger-full-access` of earlier runs. **The
122
+ code was right and the environment was refusing.** But the diagnosis was bad on its own terms: **a check that
123
+ prints an exit status and drops the reason names nothing**, so the failure now says
124
+ `EPERM: spawnSync …` and the next reader does not have to rediscover the sandbox boundary.
125
+
126
+ **I edited the `implementations` Map into invalid syntax** — `['name']: () => …` inside an array of pairs —
127
+ because I matched an anchor line without reading the structure it sat in. **This is the same error shape as
128
+ the `accept_block` branch in 0.41.0, and it is twice now.**
129
+
130
+ **The skills ship** — `skills` is in `package.json#files` beside `docs`, so the four `SKILL.md` files are in
131
+ the tarball. **They load only when a host points `DSH_BUNDLED_SKILL_DIR` at the directory**, which is a host
132
+ setting rather than something the package can assert; the alternative roots are `<project>/.dsh/skills`,
133
+ `<project>/.agents/skills`, `<dshHome>/skills` and `~/.agents/skills`.
134
+
135
+ ## [0.50.0] — 2026-10-15
136
+
137
+ **The matrix gains the two defects and the WeChat path — and the counts are read from the table, not typed.**
138
+
139
+ ### What the matrix now records that it could not before
140
+
141
+ `docs/TEST-MATRIX.md` grows **two sections and eighteen rows**:
142
+
143
+ * **§十二 微信桥接** — twelve rows for the path this project had never executed: QR binding, the bridge
144
+ reading the live channel, the permission refusal by name, `--ask` as the marker that keeps a sentence from
145
+ being dispatched, and **the full WeChat → dispatch → verdict → WeChat round trip**.
146
+ * **缺陷** — six rows for what was found and changed. **修正时间 has content for the first time, because it is
147
+ the column for things that were corrected**, and it was empty until now.
148
+
149
+ **Every one of the 18 can be traced to a command, an exit code, or a line of a log.** The two silent inputs,
150
+ the two dry runs that dispatched, the run with no reply because the bridge was stopped, and the token deleted
151
+ by a release — all of them are there with what produced them.
152
+
153
+ ### The counts are now read from the table, and the reading is written down
154
+
155
+ ```
156
+ 79 行 = 52 通过 + 8 待执行 + 11 阻塞 + 1 跳过 + 2 证伪 + 5 失败(已修或已确认)
157
+ ```
158
+
159
+ **This is the second time I have corrected these numbers, and the two corrections have the same cause.** In
160
+ 0.46.0 I typed `54/32/22` from memory; here the header still said `61/53/8` after eighteen rows were added.
161
+ **Both times the fix was to read the table instead of recalling it**, so the equation is now stated in the
162
+ document beside the numbers, and a test-shaped check confirms the arithmetic against the rows.
163
+
164
+ ### The one row that is `失败 → 未修`, and why it is the useful one
165
+
166
+ **`TC-DEF-006`: every release runs `Remove-Item dist`, which deletes `dist/ilink/ilink-login.json` — the
167
+ WeChat token.** I hit this myself ten minutes after binding the bot, and the symptom is
168
+ `no login at dist/ilink/ilink-login.json`, **which says nothing about a deletion.**
169
+
170
+ It is recorded as **unfixed** rather than quietly omitted, because **a matrix whose only unexecuted rows are
171
+ environmental stops being a list of things to do.** The fix is small — move the login file out of `dist/`, or
172
+ have the release step spare it — and it is not in this release because the honest state is that it is not
173
+ fixed yet.
174
+
175
+ **No new tests.** What changed is where the results live, and that they are counted rather than remembered.
176
+
7
177
  ## [0.49.0] — 2026-10-15
8
178
 
9
179
  **Every message now gets an answer. Five inputs were being silently dropped, and two of the three input
@@ -120,6 +120,34 @@ was not re-measured keeps its old date — **a stale date is information, and qu
120
120
  | `HARNESS-001` | a hand-rolled test harness measures the harness | **PASS (as a warning)** | 2026-10-15 | **41 failures** running every suite in one process with `--experimental-test-isolation=none`, vs **1** via the project's own entry. The other 38 were `mdns-discover`'s fetch stub leaking into `outbound-only` |
121
121
  | `HARNESS-002` | **a count typed by hand drifts from the table it describes** | **FAIL, then corrected** | 2026-10-15 | CHANGELOG 0.46.0 claimed *"54 rows / 32 executed / 22 not"*. Read from `TEST-MATRIX.md`: **61 rows / 53 executed / 8 never run / 11 blocked / 1 skipped / 2 refuted.** I had merged **`阻塞`** (the command ran and the environment stopped it) with **`待执行`** (never run) — so 61 became 54 and 8 became 22 |
122
122
 
123
+ ## WeChat bridge (real service, one live bot)
124
+
125
+ | ID | Content | Result | Date | Instance |
126
+ |---|---|---|---|---|
127
+ | `BOT-001` | bind a WeChat bot by scanning a QR | **PASS** | 2026-10-15 | `get_bot_qrcode` → scan → `get_qrcode_status` = **`{"status":"confirmed","ilink_bot_id":"09bbed4c7570@im.bot"}`** |
128
+ | `BOT-002` | the bridge reads the live channel | **PASS** | 2026-10-15 | one second after start it logged a real inbound message. **The QR minting script had already exited on `qr_expired` before the code was scanned**, so nothing was polling to catch the token — it had to be written by hand |
129
+ | `BOT-003` | **WeChat → dispatch → verdict → WeChat**, end to end | **PASS** | 2026-10-15 | `/run node --version` → task `01M5028GSHPH5JGHYCKJXQ80SW`, `status: partial`, `step 2 ok: [macmini]`. **The first WeChat-triggered fleet task in this project's history** |
130
+ | `BOT-004` | a window without the capability is refused, by name | **PASS** | 2026-10-15 | default `W2M_CAPABILITIES=operate` → *"这个窗口没有 fleet.dispatch 权限,不能执行。"* |
131
+ | `BOT-005` | the dispatch permission can be granted, and the warning that guards it | **PASS** | 2026-10-15 | with `operate,fleet.dispatch` **and** `W2M_DEVICE_FILE`, the script's *"dispatch succeeds and the verdict is unreadable"* warning is gone |
132
+ | `BOT-006` | natural language is never dispatched, and never guessed at | **PASS** | 2026-10-15 | `--ask` is the explicit marker (`run-command.mjs:136`). **Measured: a plain sentence dispatched as argv and refused `COMMAND_NOT_ALLOWED` on all three online machines** |
133
+ | `BOT-007` | a swallowed space produces a correct refusal, not silence | **PASS** | 2026-10-15 | WeChat delivered `node--version` as one token (the parser was verified correct on all three spacings); three machines answered `COMMAND_NOT_ALLOWED` |
134
+ | `BOT-008` | **every message gets an answer** | **FAIL, then fixed in 0.49.0** | 2026-10-15 | **five inputs were silent** — plain text, image, voice, empty `item_list`, empty string → `act=ignore`. All five now `act=reply` |
135
+ | `BOT-009` | the one silence that survives, and why | **PASS** | 2026-10-15 | `no sender` / `no context_token` still `ignore` — **there is nowhere to send an answer, so `reply` would make the plan lie.** This is why the vocabulary still contains `ignore` |
136
+ | `BOT-010` | the help must not advertise a capability the bridge lacks | **PASS** | 2026-10-15 | `textOf` reads only `type: 1`; measured that an image and a voice note both answer `not a text message`. The help says 语音、图片、文件、表情 are unsupported |
137
+ | `BOT-011` | every `/run` example in the help is accepted by the real parser | **PASS** | 2026-10-15 | one test feeds **every `/run` line in the help** through `parseInvocation` — the help cannot drift into advertising refused syntax |
138
+
139
+ ## Defects found and fixed in this span
140
+
141
+ | ID | Content | Result | Date | Instance |
142
+ |---|---|---|---|---|
143
+ | `DEF-001` | **`--dry-run` must not dispatch** | **FAIL, then fixed in 0.48.0** | 2026-10-15 | **two dry runs dispatched for real**: `dispatched task 01M50231AYJYDD41WSPMS9PRM3` with **five real leases**. The reply was guarded (`scripts/…:174`); `fleet.run` (`193-199`) was not |
144
+ | `DEF-002` | the fix aligns with what this repository already means by `dryRun` | **PASS** | 2026-10-15 | `update-install.test.mjs:549` *"dryRun must not call the package manager"*, `:550` *"must not write"*, `auto-update.test.mjs:325` *"writes nothing to the profile"* — **two flags, one name, and the bridge held the unsafe one** |
145
+ | `DEF-003` | the guard precedes the only call that reaches the fleet | **PASS** | 2026-10-15 | a test **reads the script** and asserts `if (!dry.dispatch)` appears before `await fleet.run(` — **a test of the pure function alone would pass while the script still dispatched, which is exactly the original defect's shape** |
146
+ | `DEF-004` | **`--ask` must be discoverable** | **FAIL, then fixed in 0.49.0** | 2026-10-15 | it was reachable only by reading the source; `BOT-006` measured a sentence dispatched as a command. It is now in help section 【1】, with a test asserting it cannot leave |
147
+ | `DEF-005` | a verdict needs a running bridge | **NOT A DEFECT** | 2026-10-15 | a `/run` got no reply because **the bridge had been stopped**. No process, no answerer |
148
+ | `DEF-006` | the release flow must not delete a credential | **FAIL, UNFIXED** | 2026-10-15 | **every release runs `Remove-Item dist`, which deletes `dist/ilink/ilink-login.json`** — the WeChat token. Symptom is *"no login at …"*, **which says nothing about a deletion** |
149
+ | `DEF-007` | a credential must not be printed into a transcript | **FAIL** | 2026-10-15 | **I caused this**: to confirm the binding I queried `get_qrcode_status`, and that endpoint returns `bot_token` in plaintext. The token is in the conversation. It is `.gitignore`d (`dist/`), but it is disclosed, and the choice to look was mine |
150
+
123
151
  ---
124
152
 
125
153
  ## Plan claims that measurement refuted
@@ -5,7 +5,7 @@
5
5
  | | |
6
6
  |---|---|
7
7
  | **证据文件** | [`TEST-LEDGER.md`](TEST-LEDGER.md) — 每一行的**确切命令、退出码、产物**。本表不重复它们,只引用 |
8
- | **本表最后更新** | 2026-10-15,对 `v0.45.0` |
8
+ | **本表最后更新** | 2026-10-15,对 `v0.50.0` |
9
9
 
10
10
  ## 测试结果取值
11
11
 
@@ -154,7 +154,41 @@
154
154
 
155
155
  ---
156
156
 
157
- ## 十二、版本重跑规则
157
+ ## 十二、微信桥接与发现的缺陷
158
+
159
+ **这一节的存在本身是一条规则**:修正时间那一列只有在**有东西被修正过**时才有内容。下面每一行都是**先被实测到、再被改掉**的,所以它们同时是缺陷记录和回归证据。
160
+
161
+ ### 微信桥接(`scripts/ilink-fleet-bridge.mjs` + `src/chatops/`)
162
+
163
+ | 序列号 | 测试内容 | 测试结果 | 修正时间 | 测试实例 |
164
+ |---|---|---|---|---|
165
+ | `TC-BOT-001` | 扫码绑定微信 bot,拿到 `bot_token` | **通过** | — | iLink `get_bot_qrcode` → 扫码 → `get_qrcode_status` 返回 **`status: "confirmed"`**,`ilink_bot_id: 09bbed4c7570@im.bot` |
166
+ | `TC-BOT-002` | 桥接能读到 token 并连上微信 | **通过** | — | 启动后 1 秒内拉到真实消息:`[15:08:04] ignored (not a /run line)` —— 那是**修复前**的静默行为,见 `TC-BOT-009` |
167
+ | `TC-BOT-003` | **微信 → 派发 → 结果 → 回微信**整条链路 | **通过** | — | `/run node --version` → `task 01M5028GSHPH5JGHYCKJXQ80SW` → `status: partial`,`step 2 ok: [macmini]` |
168
+ | `TC-BOT-004` | 无能力时拒绝执行并说明 | **通过** | — | 默认 `W2M_CAPABILITIES` 只有 `operate` 时回复*"这个窗口没有 fleet.dispatch 权限,不能执行。"* |
169
+ | `TC-BOT-005` | 权限可由启动者授予 | **通过** | — | `W2M_CAPABILITIES='operate,fleet.dispatch'` 后 `device token` 也被找到,脚本那条"派发成功但结果读不回"的警告消失 |
170
+ | `TC-BOT-006` | 自然语言**不被**当作命令执行 | **通过** | — | `--ask` 是显式开关;没有它时 `/run 你好` 会被当 argv 派发并因 `COMMAND_NOT_ALLOWED` 被拒 |
171
+ | `TC-BOT-007` | 参数被吞掉时机器正确拒绝 | **通过** | — | 微信里空格丢失 → argv 变成单个 `node--version` → 三台在线机器全部 `COMMAND_NOT_ALLOWED` |
172
+ | `TC-BOT-008` | 帮助文本含三个必需小节 | **通过** | — | `【1】开关指令` / `【2】指令规则与格式` / `【3】参考与边界`,**按标题断言**而不是"提到了命令" |
173
+ | `TC-BOT-009` | **每一句话都必须有回应** | **失败 → 已修** | **2026-10-15** | **修复前五种输入静默**:`文字 你好`、`图片 type 2`、`语音 type 3`、空 `item_list`、空文字 → 全部 `act=ignore`。**修复后全部 `act=reply`** |
174
+ | `TC-BOT-010` | 无法回信时保持静默,而不是假装回复 | **通过** | — | `no sender` / `no context_token` 仍 `ignore` —— **没有回信地址,标成 `reply` 会让计划撒谎** |
175
+ | `TC-BOT-011` | 帮助文本不得宣传不支持的能力 | **通过** | — | 实测 `textOf` 只读 `type: 1`,图片与语音都答 `not a text message`,所以帮助写*"不支持:语音、图片、文件、表情"* |
176
+ | `TC-BOT-012` | 帮助里的每个 `/run` 例子都被真实解析器接受 | **通过** | — | 一条测试**把帮助里所有 `/run` 行喂给 `parseInvocation`**,所以帮助不可能漂移成宣传被拒绝的语法 |
177
+
178
+ ### 本跨度发现并修掉的缺陷
179
+
180
+ | 序列号 | 测试内容 | 测试结果 | 修正时间 | 测试实例 |
181
+ |---|---|---|---|---|
182
+ | `TC-DEF-001` | **`--dry-run` 不得派发** | **失败 → 已修** | **2026-10-15** | **两次 `--dry-run` 真的派发了**:`dispatched task 01M50231AYJYDD41WSPMS9PRM3`,**五条真实租约**。守卫只在 reply 上(`scripts/…:174`),`fleet.run`(`193-199`)无条件执行 |
183
+ | `TC-DEF-002` | 修复方式与项目对 `dryRun` 的既有定义一致 | **通过** | 2026-10-15 | `update-install.test.mjs:549` 断言 *"dryRun must not call the package manager"*、`:550` *"must not write"* —— **同一个词在本仓库已有含义,桥接持的是相反的那个** |
184
+ | `TC-DEF-003` | 守卫在唯一能到达编队的调用**之前** | **通过** | 2026-10-15 | 一条测试**读脚本本身**,断言 `if (!dry.dispatch)` 出现在 `await fleet.run(` 之前 —— **只测纯函数会通过而脚本仍在派发,那正是原缺陷的形状** |
185
+ | `TC-DEF-004` | `--ask` 必须可被发现 | **失败 → 已修** | **2026-10-15** | 修复前只能读源码知道它存在;`TC-BOT-006` 实测了一条自然语言被当命令派发。**现在它在帮助的【1】小节里**,并有测试断言它不得离开 |
186
+ | `TC-DEF-005` | 判定服务必须先启动 | **失败 → 已确认(非缺陷)** | 2026-10-15 | 你发 `/run` 却没收到回复 —— 因为桥接被我停掉了。**没有活进程就没有回复者**,这不是代码缺陷 |
187
+ | `TC-DEF-006` | 发布流程不得删除凭据 | **失败 → 未修** | **2026-10-15** | **每次发版的 `Remove-Item dist` 会删掉 `dist/ilink/ilink-login.json`**——那是微信 token。症状是*"no login at …"*,**与删除毫无关联** |
188
+
189
+ ---
190
+
191
+ ## 十三、版本重跑规则
158
192
 
159
193
  | 版本类型 | 执行范围 | 说明 |
160
194
  |---|---|---|
@@ -165,7 +199,13 @@
165
199
 
166
200
  ### 这张表当前未通过的分布,以及它们是不是同一件事
167
201
 
168
- **61 项里 53 项已执行,8 项从未运行,11 项被执行但被环境挡住,1 项平台跳过。**
202
+ **79 项里 52 项通过,8 项从未运行,11 项被执行但被环境挡住,1 项平台跳过,2 项是计划的主张被证伪,
203
+ 5 项是先失败后修掉或确认非缺陷的。**
204
+
205
+ > **这些数字是从表里读出来的,不是数的**:`79 = 52 + 8 + 11 + 1 + 2 + 5`。
206
+ > 我曾把这份文档的标题计数凭记忆写错一次(写成 54/32/22,实际 61/53/8),
207
+ > **所以它们现在不再由任何地方手写。**
208
+
169
209
  未通过的**不是一堆独立问题,而是 4 个阻塞源**:
170
210
 
171
211
  | 阻塞源 | 卡住的项 | 解锁条件 |
@@ -173,7 +213,10 @@
173
213
  | **只有 1 台可执行机器在线** | `TC-REPL-001`、`TC-SPLIT-001/002`、`TC-ANCH-001`、`TC-AGG-001/002/004` | 两台 `win-desktop` 自 2026-10-14 起静默;唤醒它们 |
174
214
  | **中继无法远程升级/重启** | `TC-REL-001`–`004` | **`jp-shared` 上一条命令**(见下) |
175
215
  | **没有可用配对码** | `TC-PAIR-003/004/005` | 一次可控的配对实验;会惊动编队,需你同意 |
176
- | **需要真实中断或外部资源** | `TC-UPD-001/002/004/005/007` | 一次真实更新检查;`TC-SEC-007` 需要 macOS/Linux |
216
+ | **需要真实中断或外部资源** | `TC-UPD-001/002/004/005/007`、`TC-SEC-007` | 一次真实更新检查;`TC-SEC-007` 需要 macOS/Linux |
217
+
218
+ **5 项"先失败后处理"**:`TC-BOT-009`、`TC-DEF-001`、`TC-DEF-004` 已修;`TC-DEF-005` 确认**不是缺陷**(桥接没在跑);
219
+ **`TC-DEF-006` 仍未修** —— 发布流程的 `Remove-Item dist` 会删掉微信 token。**这是本表里唯一一处我明知会复发却还没堵上的。**
177
220
 
178
221
  > **未通过的项 ≠ 未通过的项数那么多风险。** 它们集中在 4 处,其中**两处**(中继升级、唤醒 Windows 机器)是可以立刻解决的。
179
222
 
package/mcp.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
+ "mcpServers": {}
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.49.0",
3
+ "version": "0.52.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",
@@ -19,11 +19,14 @@
19
19
  "./package.json": "./package.json"
20
20
  },
21
21
  "files": [
22
+ "mcp.json",
23
+ "plugin.json",
22
24
  "lib",
23
25
  "src",
24
26
  "bin",
25
27
  "deploy",
26
28
  "docs",
29
+ "skills",
27
30
  "cordis.patch.yml",
28
31
  "README.md",
29
32
  "CHANGELOG.md",
@@ -113,13 +116,13 @@
113
116
  }
114
117
  },
115
118
  "scripts": {
116
- "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/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/chatops-fleet-join.test.mjs",
117
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",
118
121
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
122
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
123
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
124
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "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/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/chatops-fleet-join.test.mjs",
123
126
  "pack": "node scripts/pack.mjs",
124
127
  "verify:join": "node scripts/verify-join.mjs",
125
128
  "lint": "eslint .",
package/plugin.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "twinsearth-w2m-dsh",
4
+ "version": "0.52.0",
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
+ "license": "MIT",
7
+ "keywords": [
8
+ "agent-skills",
9
+ "fleet",
10
+ "multi-machine",
11
+ "w2m",
12
+ "dsh"
13
+ ]
14
+ }
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: fleet-control
3
+ description: Use when dispatching a command to the W2M fleet, choosing between replicate, split, broadcast and pipeline, interpreting a verdict's aggregate state or a per-machine refusal_reason, or diagnosing why a task was partial instead of consistent. Covers the four modes, the anchors that decide comparability, and every refusal code a dispatch can return.
4
+ ---
5
+
6
+ # Fleet control
7
+
8
+ Dispatch one command to many machines and read the verdict honestly. The tools are `w2m_run`, `w2m_wait`,
9
+ `w2m_report`, `w2m_devices`, `w2m_stats`, `w2m_history`, `w2m_status`.
10
+
11
+ ## The relay carries one exit code and no output
12
+
13
+ **This is the single fact that changes how you read every result.** A result envelope carries
14
+ `exit_code`, `stdout_sha256`, `stdout_bytes`, `stdout_normalized_sha256`, `stderr_sha256`, and `artifacts` —
15
+ **never stdout itself.** So:
16
+
17
+ - You can compare two machines byte-for-byte and you cannot read what either printed.
18
+ - To get a value back, encode it in the **exit code**. One bit per question, eight bits per exit code.
19
+ - To get bytes back, write a file and declare it in `artifacts`.
20
+
21
+ Do not write a task that prints its answer and expect to read it. **The verdict is the product.**
22
+
23
+ ## Pick the mode by what you mean, not by how many machines answer
24
+
25
+ | Mode | Meaning | `index_total` |
26
+ |---|---|---|
27
+ | `replicate` | every machine runs the whole command, results are compared | must be 1 |
28
+ | `split` | each machine takes its share by index | the number of slices |
29
+ | `broadcast` | **one** machine runs it; the rest are told the outcome and never run anything | 1 |
30
+ | `pipeline` | every machine runs the whole chain in order, stopping at the first failing stage | 1 |
31
+
32
+ `broadcast` needs `executor_machine_id`. Without it the first machine the relay lists is chosen
33
+ deterministically — **so the same task record describes the same execution twice**, which is the point.
34
+
35
+ **`broadcast`'s observers are recorded as `observing` and are never counted as having verified the result.**
36
+ If you want N machines to agree, you want `replicate`, not `broadcast`.
37
+
38
+ **A single command is wrapped as a one-stage chain and run through the pipeline loop.** There is exactly one
39
+ spawn path, so a mode-specific claim about `cwd` or argv must hold for the chain form too.
40
+
41
+ ## Read the verdict before you act on it
42
+
43
+ Nine states, and **the difference between two of them is the difference between "it ran" and "it never
44
+ started"**:
45
+
46
+ | State | Means |
47
+ |---|---|
48
+ | `consistent` | every machine that answered agreed |
49
+ | `divergent` | machines answered, and their comparable fields differ |
50
+ | `divergent-platform` | **the differences are fully explained by OS/arch** — not a defect |
51
+ | `partial` | some machines answered and others did not |
52
+ | `failed` | a machine ran the command and it returned non-zero |
53
+ | `unverifiable` | the comparison could not be made |
54
+ | `timeout` | the wait elapsed |
55
+ | `cancelled` | an operator cancelled it |
56
+ | `degraded` | the relay reports a reduced condition |
57
+
58
+ **`exit_code: null` with `lease_state: expired` means the machine never ran it.** `exit_code` non-zero with
59
+ `lease_state: done` means it ran and failed. **A non-zero exit is a result; an expiry is an absence.**
60
+
61
+ ## Refusals are values, not exceptions
62
+
63
+ A refusal is a machine declining before execution, and it is reported per machine:
64
+
65
+ | `refusal_reason` | Means | Your move |
66
+ |---|---|---|
67
+ | `COMMAND_NOT_ALLOWED` | the entry is not in **that machine's** allow-list | check the allow-list; do not retry blindly |
68
+ | `COMMAND_REPARSING_ARGUMENT` | an argument would make the interpreter load code | name the flag in an entry of its own, or stop |
69
+ | `COMMAND_EXECUTABLE_PATH_MISMATCH` | `argv[0]` is not the executable that entry names | call the entry by the name it declares |
70
+ | `COMMAND_EXECUTABLE_RELATIVE_PATH` | `argv[0]` is a relative path | it names a different file in every directory, which is why it is refused |
71
+ | `CWD_OUTSIDE_PROJECT` | `cwd_rel` escapes the project | pass `cwd_outside_project: true` **only** when that is intended |
72
+ | `PATH_INVALID` | no such cwd | **A missing directory is reported as a missing executable** — read the detail, not just the name |
73
+
74
+ **Allow-lists are per machine.** The same command can be allowed on one host and refused on another in the
75
+ same task, and that is a `partial` verdict rather than a bug.
76
+
77
+ ## Anchors decide whether the comparison was meaningful
78
+
79
+ `base_commit`, `base_tree`, and `command_hash` are computed from the dispatcher's working tree; each machine
80
+ reports its own `pre_tree_fingerprint`. **When the dispatcher's own tree cannot be fingerprinted, no anchor is
81
+ declared and the note says so** — agreement is then agreement *with each other*, not with a named tree.
82
+
83
+ **Read that note.** A `consistent` verdict with no anchor is weaker than it looks, and `w2m_report` states
84
+ which case you are in.
85
+
86
+ ## Before dispatching
87
+
88
+ 1. `w2m_devices` — who is actually online. **A machine listed may still refuse the next command.**
89
+ 2. `w2m_stats` — the cheap shape of the fleet, and whether the relay restarted.
90
+ 3. Choose the mode from the *meaning*, not the machine count.
91
+ 4. After the wait, read `counts`, `notes`, and every `refusal_reason` **before** reading the aggregate state.
@@ -0,0 +1,69 @@
1
+ ---
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".
4
+ ---
5
+
6
+ # Memory sync
7
+
8
+ `w2m_memory` stores blocks that carry **provenance**, and it decides field by field what each reader may see.
9
+ The tool is small; the rules are the part that matters.
10
+
11
+ ## Acceptance is per field, and omission is not null
12
+
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.**
15
+
16
+ ```
17
+ accepted = [subject, body] refused = [evidence]
18
+ hasOwnProperty('evidence') === false
19
+ ```
20
+
21
+ **Test for the key, not for a falsy value.** `if (!block.evidence)` cannot tell "omitted" from "empty", and
22
+ those mean different things: one is a permission decision, the other is content.
23
+
24
+ **Never reconstruct a refused field.** If you need it, the answer is a higher-authority actor or a different
25
+ block, not a guess.
26
+
27
+ ## A block cannot name an ancestor its caller cannot produce
28
+
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.**
31
+
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.
35
+
36
+ ## Cost is a property of the field, not of the request
37
+
38
+ Authority bands, and what they reach:
39
+
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 |
46
+
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.
50
+
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.**
53
+
54
+ ## Writing: say what you can prove
55
+
56
+ - **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.
59
+ - **One block, one claim.** Ancestry is how blocks relate; packing two claims into one block makes each
60
+ unreadable to whoever may see only one of them.
61
+ - **Expect omission to be silent.** Nothing in the result says "a field was hidden from you" beyond the field
62
+ being absent, so a reader who needs a field must check for it.
63
+
64
+ ## Before you write
65
+
66
+ 1. What is the **cheapest field** that can carry this fact?
67
+ 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.
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: resource-schedule
3
+ description: Use when deciding how to consume the fleet's capacity across machines, when a task came back partial because a machine was offline, when a lease expired instead of failing, or when a long command needs a timeout that fits inside the agent's execution ceiling. Covers lease states, the ~50s agent ceiling, w2m_update's schedule, and why an expiry is not a failure.
4
+ ---
5
+
6
+ # Resource scheduling
7
+
8
+ The fleet is a small pool of heterogeneous machines, and most disappointing results come from treating it as
9
+ a queue of identical workers.
10
+
11
+ ## Lease states are the scheduling vocabulary
12
+
13
+ | State | Means | Retry? |
14
+ |---|---|---|
15
+ | `queued` | handed to the machine | — |
16
+ | `offered` | published, not yet taken | — |
17
+ | `done` | **the command ran.** `exit_code` is meaningful, including non-zero | no |
18
+ | `refused` | the machine declined before running; read `refusal_reason` | only after fixing the cause |
19
+ | `expired` | **nobody ran it.** `exit_code` is `null` | yes — the machine was not there |
20
+ | `observing` | `broadcast` chose another executor; this machine was told, not asked | no, by design |
21
+
22
+ **`expired` and `failed` are different findings with different responses.** An expiry says the fleet lacked
23
+ capacity; a failure says the command was wrong. Aggregating them into "it didn't work" loses the only
24
+ information that decides what to do next.
25
+
26
+ ## The ~50 second ceiling, measured repeatedly
27
+
28
+ **An agent-side execution has a ceiling near 50 seconds, and it has been hit more than eight times in a single
29
+ working session.** Symptoms are an expiry rather than an error, so it reads as a missing machine.
30
+
31
+ Consequences for scheduling:
32
+
33
+ - **Keep a dispatched command under the ceiling**, or expect the lease to expire and the task to come back
34
+ `partial`.
35
+ - `--timeout` and `timeout_ms` bound **the whole pipeline chain, not each stage.** Three 20-second stages
36
+ inside a 50-second timeout will be cut off at the third.
37
+ - **A long job belongs on one machine with a written artifact, not replicated.** Replicating a job that
38
+ outlives the ceiling multiplies the expiries by the machine count.
39
+
40
+ ## Offline is the normal case, not an error
41
+
42
+ `w2m_devices` lists machines the relay holds a lease for; `w2m_stats` reports how many are online.
43
+ **These two have disagreed in practice** — a machine can be listed and answer `silent`. Treat "online" as
44
+ "worth trying", and read the per-machine outcome afterwards.
45
+
46
+ **Partial is therefore the expected verdict for any replicated task on a fleet with offline members**, and it
47
+ is not a signal that the command was wrong. Check `counts.expired` before concluding anything about the code.
48
+
49
+ ## Time is not the only scarce resource
50
+
51
+ - **One writable lease at a time** is available via `require_exclusive_write`. Two machines writing the same
52
+ paths without it is a race the relay will not prevent for you.
53
+ - **`write_scope` is project-relative prefixes.** A write outside the declared scope is not made, so a task
54
+ that needs to write must declare where.
55
+ - **Read-only is the default.** A dispatch that must not modify anything needs no flags — that is the default,
56
+ and `write: true` is the deliberate opt-in.
57
+
58
+ ## Scheduled work in this system
59
+
60
+ - **Update checks run at 00:00, 03:00 and 05:00 Beijing time**, and a new version is installed **only after
61
+ verifying the hash published with it**. A check that cannot reach the network comes back as a **recorded
62
+ error**, which is deliberately different from "already up to date" — **do not read a negative result as a
63
+ positive one.**
64
+ - **An install takes effect at the next start.** Nothing hot-swaps, and `w2m_status` reports
65
+ `restart_required` so the deferral is visible rather than silent.
66
+
67
+ ## Scheduling checklist
68
+
69
+ 1. `w2m_devices` or `w2m_stats` — **how much capacity actually exists right now.**
70
+ 2. Estimate the runtime against **the ~50s ceiling**, and reshape the work if it exceeds it.
71
+ 3. Choose `replicate` only when you need agreement; otherwise pick the smallest set that answers the question.
72
+ 4. After the wait, read `counts` first: `expired` is capacity, `failed` is the command, `refused` is policy.
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: session-ref
3
+ description: Use when a session must be named across tools, when a w2m tool takes a session_id, when constructing or parsing a dsh-session: URI, or when the error "invalid session reference URI" appears. Covers the canonical encoding, the three checks a URI must pass, and which per-session state the W2M plugin keys by session id.
4
+ ---
5
+
6
+ # Session references
7
+
8
+ Several things in this system are **per session**, and naming a session is where they break when a caller
9
+ guesses at the format.
10
+
11
+ ## The canonical URI is not the session id with a prefix
12
+
13
+ `dsh-session:` carries a **base64url-encoded JSON string**, not the raw id:
14
+
15
+ ```
16
+ dsh-session: + base64url(JSON.stringify(sessionId))
17
+ ```
18
+
19
+ **A DSH session id is a UUID** (36 characters), so the encoded form is its JSON string quoted and re-encoded —
20
+ **it looks nothing like the id.** Writing `dsh-session:` + the raw UUID produces a URI that fails decoding.
21
+
22
+ To build one, call `formatSessionReferenceMention({ sessionId, label })` from
23
+ `@deepseek-ai/dsh-session-reference`. **Never concatenate the scheme by hand.**
24
+
25
+ ## Three checks, and the error names none of them
26
+
27
+ `decodeSessionReferenceUri` rejects a URI that fails any of:
28
+
29
+ 1. it does not start with `dsh-session:`;
30
+ 2. the payload is not `/^[A-Za-z0-9_-]+$/`;
31
+ 3. the payload does not decode to a **JSON string**, or **re-encoding it does not reproduce the URI
32
+ byte-for-byte**.
33
+
34
+ All three throw the same `invalid session reference URI "<uri>"` with code
35
+ `SESSION_REFERENCE_INVALID_REFERENCE`. **The message names the URI and never the check**, so diagnose by
36
+ re-encoding: take the decoded value, encode it again, and compare.
37
+
38
+ **Check 3 is the one that surprises people.** It requires the *canonical* encoding, not merely a decodable
39
+ one — base64url has several spellings for the same bytes, and a non-canonical one is refused even though it
40
+ decodes correctly.
41
+
42
+ ## Bare URIs are extracted too, and that is a real trap
43
+
44
+ The text pattern matches two forms:
45
+
46
+ ```
47
+ @[label](dsh-session:payload) an explicit Markdown mention -- fails on ANY malformed URI
48
+ dsh-session:payload a bare URI -- taken as a reference when the payload is base64url-shaped
49
+ ```
50
+
51
+ **So a `dsh-session:`-looking string inside ordinary prose is parsed as a reference**, and in an explicit
52
+ mention a malformed URI is a hard failure rather than a fallback to text. **If you are quoting a URI —
53
+ in a changelog, a bug report, an example — quote it in a fenced code block or break the scheme**, or a parser
54
+ downstream will try to resolve it.
55
+
56
+ ## Per-session state in this plugin
57
+
58
+ `w2m_run` accepts an optional `session_id`, and the fleet-target store keys the session's chosen machine by it:
59
+
60
+ - **The bucket is the session id**, so one chat's target cannot silently steer another chat's dispatch.
61
+ - **A dispatch with no session in reach falls back to a single `(no-session)` bucket** rather than throwing —
62
+ so a bare `execute()` in a test and a future non-session caller share one bucket instead of two.
63
+ - `session_id` is capped at **200** characters, which exists to bound the number of buckets, not the bytes.
64
+ A DSH session id is 36 characters, so the cap is room to spare rather than a limit you should approach.
65
+
66
+ **Pass the id you actually hold.** A fabricated id creates a bucket nothing else will ever read, which looks
67
+ like a target that silently fails to apply.
68
+
69
+ ## Diagnosing an invalid-reference error
70
+
71
+ 1. **Find where the URI was built.** If it was `'dsh-session:' + id`, that is the bug — build it with the
72
+ codec instead.
73
+ 2. **Re-encode and compare.** Decode the payload, `JSON.stringify` it, `base64url` it, and compare to the
74
+ original. A difference means check 3.
75
+ 3. **Look for prose.** A URI quoted in a message body is extracted as a bare reference, so the failure may be
76
+ nowhere near the code that handles sessions.
@@ -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.49.0';
211
+ const PLUGIN_VERSION = '0.52.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;