@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 +91 -0
- package/README.md +6 -6
- package/docs/1.0.0-ACCEPTANCE.md +31 -1
- package/package.json +6 -4
- package/src/agent/council-session.mjs +139 -0
- package/src/agent/council.mjs +19 -2
- package/src/plugin/tools.mjs +162 -1
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 (
|
|
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,
|
|
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
|
|
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 |
|
|
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
|
|
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:**
|
package/docs/1.0.0-ACCEPTANCE.md
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/agent/council.mjs
CHANGED
|
@@ -75,8 +75,25 @@ export class CouncilRefusal extends Error {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
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.
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
/**
|