@twinsearth/w2m-dsh-plugin 0.36.0 → 0.38.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,96 @@ 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.38.0] — 2026-10-15
8
+
9
+ **A seven-phase council run on MacMini — real-machine evidence for the tool that had never touched one.**
10
+
11
+ 0.37.0's `w2m_council` was the only substantial addition in this span that had **never run anywhere but this
12
+ laptop.** It now has a measurement, and the exit code came from the machine:
13
+
14
+ ```
15
+ OK phases=7 outcome=ship dissent=1 reproduced=true exit 0
16
+ ```
17
+
18
+ Driven through `node -e` on MacMini against its own checkout at 0.37.0: the council was convened, roles
19
+ assigned, two arguments recorded, two weighted votes cast, the outcome fixed, the decision sealed, and the
20
+ sealed decision **read back after the council stopped being advanceable** — with its dissent, its reason, and a
21
+ `recount` that reproduced the outcome from the stored ballots. **Every one of those was asserted before the
22
+ process was allowed to exit `0`**, so this is not a "did it load" smoke test.
23
+
24
+ ### The same run answered a question that had only been a hypothesis for four rounds
25
+
26
+ **v4.10.0's council works on MacMini because it reaches for nothing that machine lacks.** `council.mjs` imports
27
+ `node:crypto` and `./authority.mjs`, and neither needs `node_modules`. The eight suites that failed there in
28
+ 0.32.0 therefore failed on **what their fixtures reach for**, not on the modules under test — **a measurement
29
+ now, where four earlier releases recorded a guess as a guess.**
30
+
31
+ It also sharpens the shape of that failure: a suite whose *subject* has no dependencies still fails if the
32
+ *suite* imports one, and **from an exit code alone the two are indistinguishable.**
33
+
34
+ ### The sixth time I exceeded the ceiling
35
+
36
+ The update dispatch — download a release tarball and extract it — **expired**. Measured again, and the number
37
+ has not moved: the agent's lease survives roughly **50 seconds** (`heartbeatIntervalMs 10s × missedHeartbeats 2
38
+ + leaseGraceMs 30s`), and an expired lease carries **no exit code at all**, so the work was lost rather than
39
+ reported as failed.
40
+
41
+ **What matters is what happened next:** rather than retrying the download, a **three-line probe** established
42
+ that the extraction had in fact completed — **the partial run had succeeded and only the report was lost.** A
43
+ retry would have spent another dispatch redoing finished work, and could have raced the `tar` it had left
44
+ half-written.
45
+
46
+ **No new tests.** What changed is a measurement, and a hypothesis that stopped being one.
47
+
48
+ ## [0.37.0] — 2026-10-15
49
+
50
+ **`council.mjs` is wired. v4.10.0's committee governance is reachable, and its dissent arrives with its decision.**
51
+
52
+ 0.36.0's reachability classification found `src/agent/council.mjs` as a **closed loop** — 275 lines with three
53
+ falsifying tests asserted by name, imported by **nothing but its own suite.** This is the eleventh tool and the
54
+ fix for the third module of that shape.
55
+
56
+ ### Why the state lives outside the call
57
+
58
+ A council walks **seven phases across many calls**: `convene → frame → deliberate → vote → decide → record →
59
+ adjourn`. One that lived inside a single invocation **could never finish**, and the failure would look like
60
+ "the phases are wrong" rather than "the state was thrown away". `createCouncilSessions` holds it — the same
61
+ lesson the approval queue and the P2P runtime taught, applied a third time.
62
+
63
+ Two kinds of state, deliberately capped differently:
64
+
65
+ * a **live** council is scratch space. Losing one costs the work of returning to that phase, so the cap is
66
+ generous (64) and eviction is **oldest-first**, so a reader can say which survives without reading code.
67
+ * a **decided** council is the artifact. `ROADMAP.md`'s third falsifying test is that *"a decision with
68
+ recorded dissent is retrievable with the dissent"* — and **retrievable means after the council that produced
69
+ it is gone.** So the decided cap is larger (256) and decisions outlive live eviction. Tested.
70
+
71
+ ### One tool with an action, not seven tools
72
+
73
+ The seven phases are **a state machine over one object** and the machine already refuses an operation in the
74
+ wrong phase by name. Seven tools would let a model call the wrong one and learn the order from errors; one
75
+ tool reports `phase` and the whole `phases` list on **every** call, so the legal next move is visible rather
76
+ than inferred. A `CouncilRefusal` comes back as a **value** — `ok: false` with the phase and the list — not as
77
+ a throw, for the same reason.
78
+
79
+ **A tie is refused, not broken**, and the tool does not paper over it: a split council is the one case where
80
+ the council exists to escalate to a person.
81
+
82
+ Every read of a decision returns the **dissent with its reasons** and a **`recount`**, so the seal never has
83
+ to be taken on trust.
84
+
85
+ ### My own error, and the fix is a pattern rather than a correction
86
+
87
+ My test helper reached the `deliberate` phase by **counting advances**, got the count wrong by one, and failed
88
+ with a `WRONG_PHASE` refusal that **read like a defect in the module**. Measured order:
89
+ `convene → frame → deliberate → …`, so `argue` needs **two** advances and not one. The helper now **names its
90
+ destination** — `advanceTo(council, 'deliberate')` — because **one that counts steps drifts silently when the
91
+ phase list grows, and one that names a destination cannot.**
92
+
93
+ 28 tool-count assertions updated across nine files, the whole set that has caught this every time.
94
+
95
+ **13 new tests, 0 failures.**
96
+
7
97
  ## [0.36.0] — 2026-10-15
8
98
 
9
99
  **The classification found a third closed loop — and it is `council.mjs`, which `ROADMAP.md` marks shipped.**
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:**
@@ -68,16 +68,24 @@ declared its own `export const AUTHORITY_MAX = 100` instead of importing the bou
68
68
  **Two copies of a limit that nothing can keep in step, failing silently** — raise the real maximum and this
69
69
  module keeps refusing `101` while naming the wrong number as authoritative. It now imports it.
70
70
 
71
- ### `v4.10.0`'s council is built and not routed — found here, not claimed before
71
+ ### `v4.10.0`'s council was BUILT AND NOT ROUTED — **fixed in 0.37.0**
72
72
 
73
73
  `reachability-classify` reported `src/agent/council.mjs` as a closed loop, and reading it to check that
74
74
  confirmed the finding: **275 lines implementing committee governance, imported by nothing but its own suite.**
75
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
76
  asserted by name** — which they are. **What was never checked is whether anything routes a question to it.**
77
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.
78
+ **This was the same defect as `field-authority` and `approval-gate`, in a third module** — the one the
79
+ reachability work was built to find.
80
+
81
+ **`w2m_council` now routes it.** The state lives in `createCouncilSessions` because a council walks **seven
82
+ phases across many calls** and one held inside a single invocation **could never finish**. Every read of a
83
+ decision returns the **dissent with its reasons** and a **`recount`**, so v4.10.0's third falsifying test —
84
+ *"a decision with recorded dissent is retrievable with the dissent"* — holds **after the council that produced
85
+ it is gone**, which is a property of the session rather than of the decision. Both are tested.
86
+
87
+ **A tie is still refused, not broken**, and the tool does not paper over it: a split council is the one case
88
+ where the council exists to escalate to a person.
81
89
 
82
90
  ---
83
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.36.0",
3
+ "version": "0.38.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/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",
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/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",
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 .",
@@ -152,7 +152,8 @@
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
154
  "reachability": "node scripts/reachability-check.mjs",
155
- "reachability:classify": "node scripts/reachability-classify.mjs"
155
+ "reachability:classify": "node scripts/reachability-classify.mjs",
156
+ "test:council-session": "node --test --test-force-exit test/council-session.test.mjs"
156
157
  },
157
158
  "keywords": [
158
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
+ }
@@ -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.36.0';
205
+ const PLUGIN_VERSION = '0.38.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
  /**