@twinsearth/w2m-dsh-plugin 0.35.0 → 0.37.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,97 @@ 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.37.0] — 2026-10-15
8
+
9
+ **`council.mjs` is wired. v4.10.0's committee governance is reachable, and its dissent arrives with its decision.**
10
+
11
+ 0.36.0's reachability classification found `src/agent/council.mjs` as a **closed loop** — 275 lines with three
12
+ falsifying tests asserted by name, imported by **nothing but its own suite.** This is the eleventh tool and the
13
+ fix for the third module of that shape.
14
+
15
+ ### Why the state lives outside the call
16
+
17
+ A council walks **seven phases across many calls**: `convene → frame → deliberate → vote → decide → record →
18
+ adjourn`. One that lived inside a single invocation **could never finish**, and the failure would look like
19
+ "the phases are wrong" rather than "the state was thrown away". `createCouncilSessions` holds it — the same
20
+ lesson the approval queue and the P2P runtime taught, applied a third time.
21
+
22
+ Two kinds of state, deliberately capped differently:
23
+
24
+ * a **live** council is scratch space. Losing one costs the work of returning to that phase, so the cap is
25
+ generous (64) and eviction is **oldest-first**, so a reader can say which survives without reading code.
26
+ * a **decided** council is the artifact. `ROADMAP.md`'s third falsifying test is that *"a decision with
27
+ recorded dissent is retrievable with the dissent"* — and **retrievable means after the council that produced
28
+ it is gone.** So the decided cap is larger (256) and decisions outlive live eviction. Tested.
29
+
30
+ ### One tool with an action, not seven tools
31
+
32
+ The seven phases are **a state machine over one object** and the machine already refuses an operation in the
33
+ wrong phase by name. Seven tools would let a model call the wrong one and learn the order from errors; one
34
+ tool reports `phase` and the whole `phases` list on **every** call, so the legal next move is visible rather
35
+ than inferred. A `CouncilRefusal` comes back as a **value** — `ok: false` with the phase and the list — not as
36
+ a throw, for the same reason.
37
+
38
+ **A tie is refused, not broken**, and the tool does not paper over it: a split council is the one case where
39
+ the council exists to escalate to a person.
40
+
41
+ Every read of a decision returns the **dissent with its reasons** and a **`recount`**, so the seal never has
42
+ to be taken on trust.
43
+
44
+ ### My own error, and the fix is a pattern rather than a correction
45
+
46
+ My test helper reached the `deliberate` phase by **counting advances**, got the count wrong by one, and failed
47
+ with a `WRONG_PHASE` refusal that **read like a defect in the module**. Measured order:
48
+ `convene → frame → deliberate → …`, so `argue` needs **two** advances and not one. The helper now **names its
49
+ destination** — `advanceTo(council, 'deliberate')` — because **one that counts steps drifts silently when the
50
+ phase list grows, and one that names a destination cannot.**
51
+
52
+ 28 tool-count assertions updated across nine files, the whole set that has caught this every time.
53
+
54
+ **13 new tests, 0 failures.**
55
+
56
+ ## [0.36.0] — 2026-10-15
57
+
58
+ **The classification found a third closed loop — and it is `council.mjs`, which `ROADMAP.md` marks shipped.**
59
+
60
+ ### What the classifier does that a regex cannot
61
+
62
+ 468 findings against this repository, and the one real defect in them was invisible. Refining the pattern
63
+ cannot fix that: **the information is not in the name, it is in the shape of the import graph.**
64
+
65
+ The shape that identified the two known defects was — **one export, one importer, and that importer is the
66
+ test named after the module.** That is not a heuristic about naming; it is a statement about the graph: **a
67
+ module whose only importer is its own suite exists in a closed loop, and nothing in the program reaches it.**
68
+ The name is used only to decide whether the loop is *closed*.
69
+
70
+ Against this repository: **316 `sibling-imported`, 91 `closed-loop`, 61 `same-module`.**
71
+
72
+ **91 is not a defect count and the classifier says so**, in three tests that pin its own limits rather than
73
+ commenting on them: it cannot see a **dynamic import** (`await import('./x.mjs')` names no binding), and it
74
+ cannot see a module reached only because a **CLI entry point spawns it** — which is every file in `scripts/`,
75
+ and is why that number is large.
76
+
77
+ ### `v4.10.0`'s council is BUILT, NOT ROUTED
78
+
79
+ `src/agent/council.mjs` is a closed loop: **275 lines implementing committee governance, imported by nothing
80
+ but its own suite.** `ROADMAP.md` marks v4.10.0 *shipped in 0.11.0*, and 0.30.0 verified its three falsifying
81
+ tests are asserted by name — **which they are.** What was never checked is whether anything routes a question
82
+ to it. **Same defect as `field-authority` and `approval-gate`, in a third module**, and the one the
83
+ reachability work existed to find. Recorded as **built, not routed**, and routing it needs a tool surface and
84
+ a session rather than a line of code.
85
+
86
+ ### A duplicated limit, found by reading the file to check the first finding
87
+
88
+ `council.mjs` declared its own `export const AUTHORITY_MAX = 100` instead of importing the bound from
89
+ `authority.mjs`. **Two copies of a limit that nothing can keep in step, and the failure is silent**: raise the
90
+ real maximum and this module keeps refusing `101` while naming the wrong number as authoritative. It now
91
+ imports it, and `AUTHORITY_MAX` has exactly one literal definition in `src/`.
92
+
93
+ **Reading the file to check the wiring finding is how the constant surfaced** — the argument for investigating
94
+ a report rather than counting it.
95
+
96
+ **10 new tests, 0 failures.**
97
+
7
98
  ## [0.35.0] — 2026-10-15
8
99
 
9
100
  **§1.5 measured at last: the reachability report, and the honest reason it is not a gate.**
package/README.md CHANGED
@@ -39,7 +39,7 @@ So the cross-machine capability has to be built. This is that build.
39
39
  ▼
40
40
  ┌──────────────────────────────────┐ ┌──────────────────────────┐
41
41
  │ Windows · DSH session │ │ macOS · DSH session │
42
- │ w2m plugin (10 tools) │ │ w2m plugin (10 tools) │
42
+ │ w2m plugin (11 tools) │ │ w2m plugin (11 tools) │
43
43
  │ │ │ │ │ │
44
44
  │ Localside agent │ │ Localside agent │
45
45
  │ · three anchors │ │ · three anchors │
@@ -293,16 +293,16 @@ Then give it the relay URL in that profile's `cordis.patch.yml` — or leave
293
293
  > got "@twinsearth/w2m-dsh-plugin/tools"), skipping
294
294
  > ```
295
295
  >
296
- > That is a silent failure by construction — the install looks right, ten tools appear, and
296
+ > That is a silent failure by construction — the install looks right, eleven tools appear, and
297
297
  > `w2m_status` reports `paired: false` and a null `rabbitUrl` because none of your values were ever
298
298
  > applied. It is not hypothetical: this row's expected name moved from the `/tools` subpath to the
299
299
  > package root in v0.5.4 (see `cordis.patch.yml`'s header for why), and every override that still
300
300
  > asserted the old subpath stopped being applied on upgrade. `id` already identifies the row, so the
301
301
  > safest `name:` is no `name:` at all.
302
302
 
303
- Restart DSH. You should see ten tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
303
+ Restart DSH. You should see eleven tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
304
304
  `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`,
305
- `w2m_approvals`.
305
+ `w2m_approvals`, `w2m_council`.
306
306
 
307
307
  > `dsh` may not be on your `PATH`. On a packaged install it lives under
308
308
  > `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
@@ -585,7 +585,7 @@ a product.
585
585
  |---|---|---|
586
586
  | relay | 142 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all **nine** aggregation states plus `pending`/`refused`, report generation, operator token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
587
587
  | agent | 129 | allow-list allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts, and the bundled-runtime resolver |
588
- | tools | 89 | ten tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
588
+ | tools | 89 | eleven tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
589
589
  | broadcast | 19 | exactly one executor, the rest `observing`; observers excluded from every aggregation step so a machine that did not run is never counted as agreeing |
590
590
  | claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
591
591
  | fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
@@ -713,7 +713,7 @@ the plugin:
713
713
 
714
714
  ```bash
715
715
  node scripts/check-entrypoints.mjs # imports lib/tools.js, the exact specifier cordis.patch.yml mounts
716
- node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 10 tools
716
+ node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 11 tools
717
717
  ```
718
718
 
719
719
  **Not verified:**
@@ -18,7 +18,7 @@ but has no caller, that is stated in its own column rather than hidden behind a
18
18
  | 1.2 | Every dependency edge is declared, and every declared edge is used | `scripts/layer-check.mjs` | **yes** |
19
19
  | 1.3 | Every suite on disk is named by a script | pre-flight `test wiring`, `claims` | **yes** — reported, not silently ignored |
20
20
  | 1.4 | Every document's claim about the artifact matches the artifact | `test/claims.test.mjs` | **yes** — 48 claims, and it has caught real drift |
21
- | 1.5 | No module is unreachable — every declared thing has a caller | `scripts/reachability-check.mjs` | **MEASURED, not enforced — see below** |
21
+ | 1.5 | No module is unreachable — every declared thing has a caller | `scripts/reachability-check.mjs`, `reachability-classify.mjs` | **MEASURED, not enforced — see below** |
22
22
 
23
23
  `1.5` is the row that mattered most and the one that went unsatisfied longest. This span shipped three
24
24
  mechanisms that were, for a round each, fully tested and completely uncalled: `field-authority.mjs`,
@@ -49,6 +49,36 @@ reliable for — including the false positive that broke the first version, `dee
49
49
  **So `1.5` is measured, not closed.** A defect of this class is now *visible*; it is not *prevented*, and
50
50
  nothing in the pre-flight fails when one appears.
51
51
 
52
+ ### What the classification found, and what it reported about itself
53
+
54
+ `reachability-classify.mjs` sorts the findings by **the shape of the import graph** rather than by name. The
55
+ shape that identified the two known defects was: **one export, one importer, and that importer is the test
56
+ named after the module** — a closed loop, where nothing in the program reaches the code.
57
+
58
+ Against this repository: **468 findings, 316 `sibling-imported`, 91 `closed-loop`, 61 `same-module`.**
59
+
60
+ **The 91 is not a defect count, and the classifier says so in three pinned tests.** It cannot see a
61
+ **dynamic import** (`await import('./x.mjs')` names no binding) and it cannot see a module reached only because
62
+ a **CLI entry point spawns it** — which is every file in `scripts/`, and is why that number is large. Both
63
+ limits are asserted rather than commented, because a classifier whose limits are undocumented gets trusted
64
+ past them.
65
+
66
+ **Investigating one finding by hand found a real defect that the count had buried**: `src/agent/council.mjs`
67
+ declared its own `export const AUTHORITY_MAX = 100` instead of importing the bound from `authority.mjs`.
68
+ **Two copies of a limit that nothing can keep in step, failing silently** — raise the real maximum and this
69
+ module keeps refusing `101` while naming the wrong number as authoritative. It now imports it.
70
+
71
+ ### `v4.10.0`'s council is built and not routed — found here, not claimed before
72
+
73
+ `reachability-classify` reported `src/agent/council.mjs` as a closed loop, and reading it to check that
74
+ confirmed the finding: **275 lines implementing committee governance, imported by nothing but its own suite.**
75
+ `v4.10.0` is marked *shipped in 0.11.0* in `ROADMAP.md`, and **0.30.0 verified its three falsifying tests are
76
+ asserted by name** — which they are. **What was never checked is whether anything routes a question to it.**
77
+
78
+ **This is the same defect as `field-authority` and `approval-gate`, in a third module**, and it is the one the
79
+ reachability work was built to find. It is recorded here as **BUILT, NOT ROUTED** rather than described as
80
+ delivered, and routing it needs a tool surface and a session — not a line of code.
81
+
52
82
  ---
53
83
 
54
84
  ## 2. 分层级 — layers, with a direction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.35.0",
3
+ "version": "0.37.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",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/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/layer-model.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",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/layer-model.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",
117
117
  "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
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "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/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/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/layer-model.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",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/layer-model.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",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -151,7 +151,9 @@
151
151
  "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
152
152
  "test:authority-choice": "node --test --test-force-exit test/authority-choice.test.mjs",
153
153
  "test:reachability": "node --test --test-force-exit test/reachability-check.test.mjs",
154
- "reachability": "node scripts/reachability-check.mjs"
154
+ "reachability": "node scripts/reachability-check.mjs",
155
+ "reachability:classify": "node scripts/reachability-classify.mjs",
156
+ "test:council-session": "node --test --test-force-exit test/council-session.test.mjs"
155
157
  },
156
158
  "keywords": [
157
159
  "dsh-bundle",
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Live councils, and the decisions they finish — the part that must outlive a single tool call.
3
+ *
4
+ * ## Why this exists as a separate module
5
+ *
6
+ * A council runs through **seven phases across many calls**: someone convenes it, people assign roles, argue,
7
+ * vote, and only then is an outcome fixed and sealed. A `convene()` that lived and died inside one tool
8
+ * invocation would be a state machine nobody could ever finish, and the failure would look like "the phases
9
+ * are wrong" rather than "the state was thrown away".
10
+ *
11
+ * This is the same lesson `tools.mjs` learned about the approval queue and the P2P runtime: **something that
12
+ * must outlive a single call belongs to something other than the call.**
13
+ *
14
+ * ## Why decided councils are kept when live ones are dropped
15
+ *
16
+ * The two are different kinds of thing:
17
+ *
18
+ * * a **live** council is scratch space. Losing one costs the work of getting back to that phase, and that
19
+ * is a real cost, so the cap is generous;
20
+ * * a **decided** council is the artifact. `ROADMAP.md`'s third falsifying test for v4.10.0 is that *"a
21
+ * decision with recorded dissent is retrievable with the dissent"* — and **retrievable means after the
22
+ * council that produced it is gone.** So a sealed decision is what gets evicted last.
23
+ *
24
+ * ## What the caps are for
25
+ *
26
+ * Neither map is bounded by anything but a number here, and a long-running process that convenes councils
27
+ * fills memory. The caps are **stated and tested** rather than discovered, and eviction is **oldest-first** so
28
+ * the behaviour is predictable: a reader can say which council survives without reading the implementation.
29
+ */
30
+ import { PHASES, ROLES, convene, recount } from './council.mjs';
31
+
32
+ /** How many unfinished councils are held. Generous, because losing one costs redoing the phases. */
33
+ export const MAX_LIVE_COUNCILS = 64;
34
+
35
+ /** How many decided councils are held. Larger, because these are the artifact rather than the workspace. */
36
+ export const MAX_DECIDED_COUNCILS = 256;
37
+
38
+ /** Why a session operation was refused. */
39
+ export const SESSION_REFUSAL = Object.freeze({
40
+ NO_ID: 'COUNCIL_SESSION_NO_ID',
41
+ NOT_FOUND: 'COUNCIL_SESSION_NOT_FOUND',
42
+ NOT_DECIDED: 'COUNCIL_SESSION_NOT_DECIDED',
43
+ ALREADY_EXISTS: 'COUNCIL_SESSION_ALREADY_EXISTS',
44
+ });
45
+
46
+ /**
47
+ * @param {{maxLive?: number, maxDecided?: number}} [options]
48
+ */
49
+ export function createCouncilSessions(options = {}) {
50
+ const maxLive = Number.isInteger(options.maxLive) ? options.maxLive : MAX_LIVE_COUNCILS;
51
+ const maxDecided = Number.isInteger(options.maxDecided) ? options.maxDecided : MAX_DECIDED_COUNCILS;
52
+
53
+ /** Insertion-ordered, so "oldest" is the first key — eviction needs no timestamps to be predictable. */
54
+ const live = new Map();
55
+ const decided = new Map();
56
+
57
+ const requireId = (id) => typeof id === 'string' && id.trim() !== '';
58
+
59
+ /**
60
+ * Open a council under an id.
61
+ *
62
+ * The id is **required from the caller** rather than generated here, for the reason `raiseApproval` gives:
63
+ * an id invented at this layer could not be correlated with whoever is waiting to use it.
64
+ */
65
+ function open(id, { question, participants = [], now } = {}) {
66
+ if (!requireId(id)) {
67
+ return { ok: false, reason_code: SESSION_REFUSAL.NO_ID, detail: 'opening a council needs an id the caller chose, so it can be addressed later' };
68
+ }
69
+ if (live.has(id) || decided.has(id)) {
70
+ // Refused rather than replaced: silently discarding a council mid-phase would look like the phases had
71
+ // reset, which is the most misleading possible symptom.
72
+ return { ok: false, reason_code: SESSION_REFUSAL.ALREADY_EXISTS, detail: `a council is already open under ${JSON.stringify(id)}; choose another id or adjourn it first` };
73
+ }
74
+ const council = convene({ question, participants, ...(typeof now === 'function' ? { now } : {}) });
75
+ live.set(id, council);
76
+ while (live.size > maxLive) live.delete(live.keys().next().value);
77
+ return { ok: true, id, phase: council.phase, phases: council.phases, roles: [...ROLES], detail: `opened at "${council.phase}"` };
78
+ }
79
+
80
+ /** The live council under an id, or a refusal naming what is missing. */
81
+ function get(id) {
82
+ if (!requireId(id)) return { ok: false, reason_code: SESSION_REFUSAL.NO_ID, detail: 'a council id is required' };
83
+ const council = live.get(id) ?? null;
84
+ if (council === null) {
85
+ const wasDecided = decided.has(id);
86
+ return {
87
+ ok: false,
88
+ reason_code: wasDecided ? SESSION_REFUSAL.NOT_DECIDED : SESSION_REFUSAL.NOT_FOUND,
89
+ detail: wasDecided
90
+ ? `${JSON.stringify(id)} has already been decided and sealed, so it cannot be advanced; read it with action="decision"`
91
+ : `no live council under ${JSON.stringify(id)}`,
92
+ };
93
+ }
94
+ return { ok: true, id, council };
95
+ }
96
+
97
+ /**
98
+ * Seal a council and move it from live to decided.
99
+ *
100
+ * Called after `record()`. The council object stays the same; only which map holds it changes, and that is
101
+ * the whole point — **a decided council must remain readable after it stops being advanceable.**
102
+ */
103
+ function seal(id) {
104
+ const found = get(id);
105
+ if (!found.ok) return found;
106
+ const decision = found.council.decision;
107
+ if (decision === null || decision === undefined) {
108
+ return { ok: false, reason_code: SESSION_REFUSAL.NOT_DECIDED, detail: 'the council has no decision to seal; decide() must run first' };
109
+ }
110
+ live.delete(id);
111
+ decided.set(id, { decision, sealed_at: new Date().toISOString() });
112
+ while (decided.size > maxDecided) decided.delete(decided.keys().next().value);
113
+ return { ok: true, id, decision };
114
+ }
115
+
116
+ /** A decided council, with its dissent, and whether its outcome reproduces from its own stored ballots. */
117
+ function decision(id) {
118
+ if (!requireId(id)) return { ok: false, reason_code: SESSION_REFUSAL.NO_ID, detail: 'a council id is required' };
119
+ const held = decided.get(id);
120
+ if (held === undefined) {
121
+ return {
122
+ ok: false,
123
+ reason_code: live.has(id) ? SESSION_REFUSAL.NOT_DECIDED : SESSION_REFUSAL.NOT_FOUND,
124
+ detail: live.has(id) ? `${JSON.stringify(id)} is still open, so nothing is decided yet` : `no decided council under ${JSON.stringify(id)}`,
125
+ };
126
+ }
127
+ // `recount` is what makes "weights are reproducible" checkable rather than asserted: it recomputes from
128
+ // the stored ballots and reports agreement. Returning it WITH the decision means a reader never has to
129
+ // take the seal on trust.
130
+ return { ok: true, id, ...held, recount: recount(held.decision) };
131
+ }
132
+
133
+ /** Ids held, oldest first, for a caller that wants to know what is here without listing contents. */
134
+ function ids() {
135
+ return { live: [...live.keys()], decided: [...decided.keys()] };
136
+ }
137
+
138
+ return { open, get, seal, decision, ids, maxLive, maxDecided, phaseNames: [...PHASES] };
139
+ }
@@ -75,8 +75,25 @@ export class CouncilRefusal extends Error {
75
75
  }
76
76
  }
77
77
 
78
- /** Authority bands, taken from v4.7.0: 0-49 ordinary, 50-69 captain, 70-89 org admin, 90+ system admin. */
79
- export const AUTHORITY_MAX = 100;
78
+ /**
79
+ * Authority bands, taken from v4.7.0: 0-49 ordinary, 50-69 captain, 70-89 org admin, 90+ system admin.
80
+ *
81
+ * ## This was a SECOND copy of a number that already existed, and the reachability check is what found it
82
+ *
83
+ * It read `export const AUTHORITY_MAX = 100;` — a literal, redeclared here. `src/agent/authority.mjs` owns
84
+ * that bound and the bands it belongs to, and `AUTHORITY_BAND_FLOORS` is derived from them precisely so a new
85
+ * band cannot leave a stale copy behind. A second literal cannot be kept in step by anything, and **the
86
+ * failure it produces is silent**: raise the real maximum to 120 and this module keeps refusing 101, with a
87
+ * message that names the wrong number as authoritative.
88
+ *
89
+ * Found by `scripts/reachability-classify.mjs`, which reported this file as a **closed loop** — a module whose
90
+ * only importer is the test named after it. That finding was about wiring, and the duplicated constant was
91
+ * sitting in the same file. **Reading the file to check the first finding is how the second one surfaced**,
92
+ * which is the argument for investigating a report rather than counting it.
93
+ */
94
+ import { AUTHORITY_MAX } from './authority.mjs';
95
+
96
+ export { AUTHORITY_MAX };
80
97
 
81
98
  /**
82
99
  * The weight of one participant, as a pure function of the two numbers on its ballot.
@@ -131,6 +131,10 @@ import { gateDispatch, approvalRequestFor, raiseApproval } from '../agent/permis
131
131
  import { createApprovalIndex } from '../agent/approval-index.mjs';
132
132
  // v0.31.0: the approval queue's front door. Written in 0.30.0 and given a caller here.
133
133
  import { createApprovalGate } from '../agent/approval-gate.mjs';
134
+ // v0.37.0: v4.10.0's council, reached at last. `reachability-classify.mjs` found it imported by nothing but
135
+ // its own suite, and that is the defect this import fixes.
136
+ import { PHASES, ROLES } from '../agent/council.mjs';
137
+ import { createCouncilSessions } from '../agent/council-session.mjs';
134
138
  import { AUTHORITY_MAX, isAuthorityLevel } from '../agent/authority.mjs';
135
139
  // v0.33.0: the level is chosen by NAME rather than typed. `resolveConfiguredLevel` also accepts a plain
136
140
  // integer, because a profile patch written by an earlier version must keep loading.
@@ -198,7 +202,7 @@ const MAX_STAGES = 16;
198
202
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
199
203
  * overrides it at runtime for tests and packagers.
200
204
  */
201
- const PLUGIN_VERSION = '0.35.0';
205
+ const PLUGIN_VERSION = '0.37.0';
202
206
 
203
207
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
204
208
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3331,6 +3335,19 @@ export async function apply(ctx, config = {}) {
3331
3335
  Number.isInteger(config.approvalRaiseLimitPerMinute) ? { limit: config.approvalRaiseLimitPerMinute } : {},
3332
3336
  );
3333
3337
 
3338
+ /**
3339
+ * v0.37.0: the council, reached at last.
3340
+ *
3341
+ * `src/agent/council.mjs` shipped in 0.11.0 as v4.10.0's committee governance — 275 lines with three
3342
+ * falsifying tests asserted by name — and **nothing imported it.** `scripts/reachability-classify.mjs`
3343
+ * reported it as a closed loop in 0.36.0, and that is what this fixes.
3344
+ *
3345
+ * `createCouncilSessions` holds the state, because a council runs through **seven phases across many
3346
+ * calls**: one that lived inside a single invocation could never finish. That is the same lesson the
3347
+ * approval queue and the P2P runtime taught, applied a third time.
3348
+ */
3349
+ const councilSessions = createCouncilSessions();
3350
+
3334
3351
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3335
3352
  // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
3336
3353
  // composition and does not need a warning on every headless run.
@@ -5389,6 +5406,150 @@ export async function apply(ctx, config = {}) {
5389
5406
  });
5390
5407
  },
5391
5408
  }));
5409
+
5410
+ /**
5411
+ * v0.37.0: the eleventh tool -- a question put to a council, and the outcome read back with its dissent.
5412
+ *
5413
+ * ## Why one tool with an action rather than seven
5414
+ *
5415
+ * The seven phases are a **state machine over one object**, and the machine already refuses an operation in
5416
+ * the wrong phase with a named refusal. Seven tools would let a model call the wrong one and have to learn
5417
+ * the order from errors; one tool with an action can report `phase` and `phases` on every call, so the
5418
+ * legal next move is visible rather than inferred.
5419
+ *
5420
+ * ## What it will not do
5421
+ *
5422
+ * It does not break a tie. `decide()` refuses one by name, because a split council is exactly the case a
5423
+ * person must see -- and a tool that returned *some* outcome for a tie would hide the only situation where
5424
+ * the council exists to escalate.
5425
+ */
5426
+ ctx.tools.register(defineTool({
5427
+ name: 'w2m_council',
5428
+ description:
5429
+ 'Put a question to a council of agents and read the outcome back WITH its dissent. A council walks seven ' +
5430
+ 'phases (' + PHASES.join(' -> ') + ') and every action reports the phase it is in and the whole phase list, so the ' +
5431
+ 'legal next move is visible rather than guessed. Roles are assigned during "convene", arguments are ' +
5432
+ 'recorded during "deliberate", votes during "vote", the outcome is fixed during "decide", and "record" ' +
5433
+ 'seals it. A TIE IS REFUSED, not broken: a split council is the case a person must see. Votes carry a ' +
5434
+ 'weight computed from the voter\'s authority and reputation, and that weight is reproducible from the ' +
5435
+ 'stored ballots, which every read reports.',
5436
+ parameters: {
5437
+ action: {
5438
+ type: 'string',
5439
+ enum: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record', 'state', 'decision', 'list'],
5440
+ description: 'What to do. "open" starts a council, "state" reports one mid-flight, "decision" reads a sealed one back with its dissent.',
5441
+ default: 'state',
5442
+ },
5443
+ council_id: {
5444
+ type: 'string',
5445
+ description: 'Which council. Required for everything except "list"; chosen by the caller so it can be addressed later.',
5446
+ },
5447
+ question: { type: 'string', description: 'What the council is deciding. Used by "open" only.' },
5448
+ participant: { type: 'string', description: 'Who is acting. Used by assign, argue and vote.' },
5449
+ role: { type: 'string', enum: [...ROLES], description: 'The hat being worn. Used by "assign" only.' },
5450
+ stance: { type: 'string', enum: ['for', 'against'], description: 'Which side an argument takes. Used by "argue" only.' },
5451
+ text: { type: 'string', description: 'The argument itself. Used by "argue" only.' },
5452
+ authority: { type: 'number', description: 'The voter authority level, 0..100. Used by "vote"; the weight comes from this and reputation.' },
5453
+ reputation: { type: 'number', description: 'The voter reputation. Used by "vote"; it can never overturn authority.' },
5454
+ choice: { type: 'string', description: 'What the voter votes for. Used by "vote" only.' },
5455
+ reason: { type: 'string', description: 'Why the voter chose it. Carried with the ballot and read back with the dissent.' },
5456
+ },
5457
+ output: jsonOutput,
5458
+ presentCall: (args) => ({
5459
+ card: 'generic',
5460
+ title: `w2m council ${typeof args?.action === 'string' ? args.action : 'state'}`,
5461
+ // Only actions that change the council are writes; reading one is not.
5462
+ kind: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record'].includes(args?.action) ? 'write' : 'read',
5463
+ rawInput: {},
5464
+ }),
5465
+ async execute(args, exec) {
5466
+ throwIfAborted(exec?.signal);
5467
+ const action = typeof args?.action === 'string' ? args.action : 'state';
5468
+ const id = typeof args?.council_id === 'string' ? args.council_id : null;
5469
+
5470
+ if (action === 'list') {
5471
+ return JSON.stringify({ ok: true, action, ...councilSessions.ids(), phases: PHASES, roles: [...ROLES] });
5472
+ }
5473
+ if (action === 'open') {
5474
+ const opened = councilSessions.open(id, { question: typeof args?.question === 'string' ? args.question : '' });
5475
+ return JSON.stringify({ ok: opened.ok, action, council_id: id, phase: opened.phase ?? null, phases: PHASES, roles: [...ROLES], reason_code: opened.reason_code ?? null, detail: opened.detail });
5476
+ }
5477
+ if (action === 'decision') {
5478
+ const read = councilSessions.decision(id);
5479
+ return JSON.stringify({
5480
+ ok: read.ok,
5481
+ action,
5482
+ council_id: id,
5483
+ reason_code: read.reason_code ?? null,
5484
+ detail: read.detail ?? null,
5485
+ decision: read.decision ?? null,
5486
+ // A dissent without its reason is a number rather than a position, and a decision whose outcome does
5487
+ // not reproduce from its own ballots is one nobody should act on. Both travel with every read.
5488
+ dissent: read.decision?.dissent ?? null,
5489
+ unanimous: read.decision?.unanimous ?? null,
5490
+ recount: read.recount ?? null,
5491
+ });
5492
+ }
5493
+
5494
+ const found = councilSessions.get(id);
5495
+ if (!found.ok) {
5496
+ return JSON.stringify({ ok: false, action, council_id: id, reason_code: found.reason_code, detail: found.detail });
5497
+ }
5498
+ const council = found.council;
5499
+
5500
+ // Every mutation goes through the council's own refusals rather than a check here, so the phase rules
5501
+ // live in exactly one place and a tool cannot disagree with the module about them.
5502
+ try {
5503
+ if (action === 'advance') {
5504
+ const moved = council.advance();
5505
+ return JSON.stringify({ ok: true, action, council_id: id, phase: moved.phase, phases: PHASES, moved: moved.moved, detail: moved.detail ?? null });
5506
+ }
5507
+ if (action === 'assign') {
5508
+ const assigned = council.assign(args?.participant, args?.role);
5509
+ return JSON.stringify({ ok: true, action, council_id: id, phase: council.phase, assigned });
5510
+ }
5511
+ if (action === 'argue') {
5512
+ const recorded = council.argue({ participant: args?.participant, stance: args?.stance, text: args?.text });
5513
+ return JSON.stringify({ ok: true, action, council_id: id, phase: council.phase, argument: recorded });
5514
+ }
5515
+ if (action === 'vote') {
5516
+ const ballot = council.vote({ participant: args?.participant, authority: args?.authority, reputation: args?.reputation, choice: args?.choice, reason: args?.reason ?? null });
5517
+ // The weight is returned because it is the thing a reader would otherwise have to recompute to
5518
+ // believe the tally.
5519
+ return JSON.stringify({ ok: true, action, council_id: id, phase: council.phase, ballot });
5520
+ }
5521
+ if (action === 'decide') {
5522
+ const decision = council.decide();
5523
+ return JSON.stringify({ ok: true, action, council_id: id, phase: council.phase, decision, dissent: decision.dissent, unanimous: decision.unanimous });
5524
+ }
5525
+ if (action === 'record') {
5526
+ const sealed = council.record();
5527
+ const moved = councilSessions.seal(id);
5528
+ return JSON.stringify({ ok: moved.ok, action, council_id: id, phase: council.phase, seal: sealed, reason_code: moved.reason_code ?? null, detail: moved.detail ?? null });
5529
+ }
5530
+ } catch (error) {
5531
+ // A phase refusal is a value, not a crash: the caller is told which phase it is in and what the phase
5532
+ // list is, so it can correct itself without a second call to find out.
5533
+ if (error?.name === 'CouncilRefusal') {
5534
+ return JSON.stringify({ ok: false, action, council_id: id, phase: council.phase, phases: PHASES, reason_code: error.code, detail: error.message, extra: error.extra ?? null });
5535
+ }
5536
+ throw error;
5537
+ }
5538
+
5539
+ // "state" and anything unrecognised: report rather than mutate.
5540
+ return JSON.stringify({
5541
+ ok: true,
5542
+ action: 'state',
5543
+ council_id: id,
5544
+ phase: council.phase,
5545
+ phases: PHASES,
5546
+ members: council.members,
5547
+ votes: council.votes,
5548
+ arguments: council.arguments,
5549
+ decision: council.decision,
5550
+ });
5551
+ },
5552
+ }));
5392
5553
  }
5393
5554
 
5394
5555
  /**