@twinsearth/w2m-dsh-plugin 0.9.8 → 0.10.2

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,209 @@ 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.10.2] — 2026-10-15
8
+
9
+ **Tiered storage — temporary, compressed, permanent, and the two kinds of knowledge that persist forever.**
10
+
11
+ ### Why tiers rather than one directory
12
+
13
+ The Linux host reached 100% filesystem on 2026-10-14 from a single uncapped writer at 14.7 GB/day, and the
14
+ recovery cost real time. The lesson was not "delete more": it was that **data with different lifetimes was
15
+ stored in one place under one policy**, so the only available policy was "never delete" until it became
16
+ "delete under pressure" — which is the worst possible moment to be deciding what matters.
17
+
18
+ So a file's tier is written down when it is stored, and the tier alone decides what may happen to it:
19
+
20
+ | Tier | Lifetime | May it be deleted? |
21
+ |---|---|---|
22
+ | `temp` | a stated TTL | yes, automatically, once expired |
23
+ | `compressed` | until the archive is unwanted | only whole, never partially |
24
+ | `permanent` | forever | **no — refused, not warned about** |
25
+ | `skill` | forever | **no** |
26
+ | `law` | forever | **no — amended instead** |
27
+
28
+ ### Why `permanent` refuses rather than warns
29
+
30
+ A warning is a thing a caller ignores, and the caller under pressure is exactly who this exists for.
31
+ `memory.jsonl` is the **only** durable copy of what a command printed — the relay keeps `stdout_sha256` and
32
+ never the text — so deleting it deletes the fleet's ability to answer what it already tried, and nothing
33
+ else in the system notices. `device.key` is the identity peers know a machine by; losing it makes every
34
+ peer holding the old one refuse that machine with no visible cause. A forced removal remains possible,
35
+ because a rule with no escape hatch gets bypassed rather than followed.
36
+
37
+ `law` entries carry an **amendment chain** naming the hash each amendment supersedes, because "what does
38
+ the rule say now" and "what did it used to say" are both questions a reader has, and only the second
39
+ explains why something was done the old way.
40
+
41
+ ### A test that asserted nothing, found while writing it
42
+
43
+ The compressed-removal case ended with `existsSync(join(..., 'nope'))` — a path that never exists, so the
44
+ assertion could never be false. It now checks the real property: removal leaves no orphan on disk, because
45
+ an index that forgets a file still on disk is how a store grows without anything being able to name what
46
+ is in it.
47
+
48
+ **24 tests, 0 failures.**
49
+
50
+ ## [0.10.1] — 2026-10-15
51
+
52
+ **A service-health agent: measure, classify, recommend — never act.**
53
+
54
+ ### Why it only advises
55
+
56
+ A component that both decides a server is unhealthy *and* changes that server is one bug away from
57
+ deleting the thing it was protecting. The 2026-10-14 disk incident is the concrete example: the recovery
58
+ was `truncate`, **not** `rm`, because rsyslog still held the file descriptor open, so removing the file
59
+ would have freed nothing while appearing to succeed. A cleaner that had decided on its own to delete "the
60
+ big log file" would have looked successful and changed nothing.
61
+
62
+ ### Where the thresholds come from — measurement, not taste
63
+
64
+ - **Disk** — critical at 90% used *or* under 1 GB free; warn at 80%. The host reached 100% with 268 MB
65
+ free, which is the state this is for. The absolute floor matters independently: a huge filesystem can be
66
+ "only 85% used" and still have less headroom than a task needs.
67
+ - **Log growth** — 14.7 GB/day is the measured rate that caused the incident. Any single file over 1 GB is
68
+ reported **by name**, because "the disk is filling" is not actionable and "this file is filling it" is.
69
+ - **Load** — compared against CPU count, never an absolute number, because the consequence worth naming is
70
+ that a loaded machine starts expiring tasks at the ~50 s lease ceiling.
71
+ - **Memory** — set where a host starts swapping, not where it looks comfortable.
72
+
73
+ ### Two defects the suite found in its own work
74
+
75
+ - The critical load multiple was `loadPerCpuWarn * 2`, which **reads as a rule and is a coincidence** —
76
+ equal to 3.0 only while the warning threshold is 1.5, so changing one would silently move the other. Its
77
+ own test asserted the documented 1.5 / 3.0 pair and failed. It is now a named constant.
78
+ - A test asserted `critical` for 4.0 load over 2 CPUs. 4 / 2 is 2.0 per CPU, between warn and critical.
79
+ **The implementation was right and the assertion was not.**
80
+
81
+ ### Permanent storage is a classification, not a copy
82
+
83
+ `DATA_CLASSES` marks `memory.jsonl`, `device.json`, `device.key` and `fleet-memory.md` as
84
+ `IRREPLACEABLE`. It copies nothing: deciding where data may live is a policy this module has no standing
85
+ to make, and the honest half is saying which data cannot be regenerated.
86
+
87
+ ### A limitation measured rather than assumed
88
+
89
+ `df` exists on Linux and macOS and not on Windows, so a Windows sample carries `disks: []`. The important
90
+ part is that it is **empty** rather than filled with a healthy-looking default — an unmeasured disk must
91
+ never read as free space. A test asserts the empty case on whichever host runs the suite.
92
+
93
+ **20 tests, 0 failures.**
94
+
95
+ ## [0.10.0] — 2026-10-15
96
+
97
+ **The cross-session approval index, and a syncer that refuses to wedge a repository.**
98
+
99
+ ### The approval index (roadmap v4.9.0)
100
+
101
+ `approval-broker.mjs` answers approvals **per session**: a request belongs to one IM window and the
102
+ operator answers it there. That is right for the person in that conversation and wrong for a supervisor,
103
+ who by definition is not in it — the design's own words are that a supervisor reviews all agents' pending
104
+ approvals *from one queue*. Without this, a request raised in a session nobody is watching sits until its
105
+ deadline delegates it.
106
+
107
+ The index presents pending work across sessions, filtered by what the viewer may act on, and verifies
108
+ authority at the moment of decision. It decides nothing itself: `decide()` returns a verdict and the caller
109
+ settles the broker, because the broker already owns settlement and a second settler would be a second owner
110
+ of the same promise.
111
+
112
+ Two choices worth stating. The subject's authority level is recorded **at raise time**, because it is a
113
+ fact about who asked and must not be re-derived from a caller who might be the approver — an index that let
114
+ the approver supply it would let the approver lower it. And `listFor` returns work the viewer **cannot**
115
+ answer, with the reason, rather than filtering it out: a filtered list makes the fleet look idle at the
116
+ moment it is most blocked.
117
+
118
+ ### `scripts/fleet-sync.mjs` — broadcast, sync, merge, with a decision
119
+
120
+ Three defects were found by running it, two of them dangerous in production:
121
+
122
+ - **A failed rebase wedged the repository.** No committer identity meant `git rebase` stopped part-way,
123
+ leaving a detached HEAD and `.git/rebase-merge` for a person to resolve. An automatic tool that can
124
+ wedge a repository is worse than one that does nothing. The identity is now checked before starting, and
125
+ a failure **aborts** the rebase to restore the exact previous state.
126
+ - **A failed `rev-parse` was read as a ref name.** `git rev-parse <unknown>` **echoes the argument** to
127
+ stdout and then exits non-zero, so a failed lookup produced `{ok:false, out:'origin/main'}` — a plausible
128
+ ref name attached to a status saying it does not exist. `inspect` read `out` alone and reported a
129
+ repository with **no remote at all** as `ahead` with `ahead/behind: NaN/0`. Every ref lookup now uses
130
+ `--verify`, and an unparseable `rev-list` is `unreadable`/stop rather than falling through to a default.
131
+ - **The published/unpublished safety test was dead code.** `published = local_only < ahead` can never be
132
+ true: `ahead` counts the commits absent from the remote-tracking branch and `--not --remotes` selects
133
+ exactly those commits. The real question is narrower — would this rebase rewrite a commit some remote ref
134
+ already reaches — and it is now answered by testing each local commit for reachability from the remotes.
135
+
136
+ A finding that changed a test rather than the code: **on a single upstream, a "both sides published"
137
+ divergence cannot exist.** If a local commit is reachable from `origin/main` then HEAD is its ancestor, so
138
+ `behind` is 0 and there is no divergence. Three fixtures failed before that became clear. The reachable
139
+ version of the hazard is a local commit some *other* remote ref already has — a branch pushed as a feature
140
+ and then rewritten — and that is what the test now builds.
141
+
142
+ **30 tests across the two, 0 failures**, against real repositories and a real bare remote: a mocked git
143
+ would have been a test of the mock.
144
+
145
+ ## [0.9.9] — 2026-10-15
146
+
147
+ **Cognitive memory blocks (roadmap v4.8.0): field-level acceptance and lineage — and the first release whose suite was run on the fleet machine rather than on the development host.**
148
+
149
+ ### `src/agent/blocks.mjs`
150
+
151
+ The memory layer shipped in 0.9.6/0.9.7 accepts a peer's claim **whole or not at all**, which is a blunt
152
+ instrument with two costs the roadmap records as P1 and P2. A block is now seven fixed fields so the
153
+ decision can be made per field:
154
+
155
+ | Field | May a receiver choose it? |
156
+ |---|---|
157
+ | `subject`, `body`, `evidence` | yes |
158
+ | `confidence` | **no — refused whatever the chooser says** |
159
+ | `origin`, `observed_at`, `lineage` | **no — inherited, and setting them would be forging provenance** |
160
+
161
+ Lineage is by **content hash**, not by id, because an id is assigned by whoever creates the block and can
162
+ be copied. A block whose parent cannot be produced is **refused rather than re-rooted**: re-rooting turns
163
+ an unverifiable history into no history, which reads as a fresh, independent assertion, and that is how a
164
+ fabricated claim acquires a respectable past.
165
+
166
+ ### Three defects the suite found in its own module
167
+
168
+ - **`confidence` was only a default, not an invariant.** Supplying a custom `choose` *replaced* the
169
+ default rather than refining it, so the file's central conservative property — a receiver does not adopt
170
+ the sender's certainty about the sender's own assertion — was silently optional. "Silently optional" is
171
+ how a safety default stops being one, so the refusal is now a hard invariant that no chooser can open.
172
+ - **A cycle fixture that asserted nothing.** The first cycle test mutated `lineage` after hashing and keyed
173
+ the map by the recomputed digest, leaving the references pointing at hashes the map did not hold — the
174
+ walker reported `missing` and never met the cycle it was supposed to test. `linkFixtures` now makes the
175
+ reference and the key the same string by construction.
176
+ - **A test that measured the clock.** `makeBlock` stamps the current time when no `observed_at` is given
177
+ (correctly — that is what the field means), so two blocks built a millisecond apart differ. The failure
178
+ looked like a hashing defect and was a 1 ms difference in a field the test never mentioned.
179
+
180
+ ### Verified on MacMini, not only here
181
+
182
+ This is the first release whose suite was executed on the fleet machine:
183
+
184
+ ```
185
+ MacMini (MacMinideMac-mini.local)
186
+ HEAD : 642242446d3eb0ed1928f128d088b727284a6d43
187
+ expected : 642242446d3eb0ed1928f128d088b727284a6d43 MATCH
188
+ suiteOk : true
189
+ ```
190
+
191
+ `test/blocks.test.mjs` — **28 tests, 0 failures**, adversarial by design: most cases assert a refusal
192
+ rather than a happy path, because what makes a fleet memory worse than no memory is losing the ability to
193
+ tell a fact from an echo. `eslint` 0 errors.
194
+
195
+ ### Notes on how this got here, because the tooling cost more than the module
196
+
197
+ `run-relay.mjs` targets `root@<relay>` and runs **bash on the relay host**; `relay-run.mjs` runs a
198
+ **JavaScript file on a fleet machine**. Both are called "relay" and only one of them is the fleet — a
199
+ clone believed to be on MacMini was on the relay for a round. Also: `Set-Content -Encoding utf8` writes a
200
+ **BOM**, and a shebang behind a BOM is not a shebang. And a 43 KB script cannot travel as an argv element
201
+ under Windows' 32 KB command-line limit, which is why this work reaches MacMini as a **branch** it fetches
202
+ rather than as an inline command.
203
+
204
+ ### Environment, measured
205
+
206
+ MacMini has `git` 2.54.0, `curl`, `python3`, 70 GiB free, and **working** HTTPS to npm, the GitHub API,
207
+ release assets and codeload — including `git ls-remote`, which an earlier session recorded as failing.
208
+ It has no `npm`, `pnpm` or `brew`, so `node --test` directly is the suite runner.
209
+
7
210
  ## [0.9.8] — 2026-10-14
8
211
 
9
212
  **The numeric authority model — the layer four of the five roadmap layers depend on — plus a roadmap and a memory write that reports its own outcome.**
package/ROADMAP.md CHANGED
@@ -46,7 +46,7 @@ it into P1. So this is not a foundation poured in advance of a use — the use i
46
46
  Each row states its **deliverable** and its **falsifying test** — the observation that would show it is
47
47
  not working. A row without the second is a feature list, not a plan.
48
48
 
49
- ### v4.7.0 — authority model ← **next**
49
+ ### v4.7.0 — authority model **shipped in 0.9.8**
50
50
 
51
51
  **Deliverable.** `src/agent/authority.mjs`: an integer `authority_level` on every actor, the four bands
52
52
  (0–49 ordinary, 50–69 captain, 70–89 org admin, 90+ system admin), a rule evaluator carrying
@@ -60,7 +60,7 @@ that produced it.
60
60
  the document does not grant it and inventing it would be the worst possible default;
61
61
  - a rule with no `matching_exempt_level` exempts nobody, rather than exempting everybody.
62
62
 
63
- ### v4.8.0 — memory: field-level acceptance (MMP P1/P2)
63
+ ### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9, suite verified on MacMini**
64
64
 
65
65
  **Deliverable.** Extend the shipped memory layer: a CAT7 seven-field cognitive block, per-field
66
66
  acceptance driven by §2's authority model, and lineage — each accepted claim records the content hash of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.9.8",
3
+ "version": "0.10.2",
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",
@@ -112,13 +112,13 @@
112
112
  }
113
113
  },
114
114
  "scripts": {
115
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.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",
115
+ "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/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
116
  "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",
117
117
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
118
118
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
119
119
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
120
120
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
121
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.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",
121
+ "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/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
122
  "pack": "node scripts/pack.mjs",
123
123
  "verify:join": "node scripts/verify-join.mjs",
124
124
  "lint": "eslint .",
@@ -128,7 +128,12 @@
128
128
  "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
129
129
  "test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs",
130
130
  "test:memory": "node --test --test-force-exit test/memory.test.mjs",
131
- "test:authority": "node --test --test-force-exit test/authority.test.mjs"
131
+ "test:authority": "node --test --test-force-exit test/authority.test.mjs",
132
+ "test:blocks": "node --test --test-force-exit test/blocks.test.mjs",
133
+ "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
134
+ "test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
135
+ "test:health": "node --test --test-force-exit test/health.test.mjs",
136
+ "test:storage": "node --test --test-force-exit test/storage.test.mjs"
132
137
  },
133
138
  "keywords": [
134
139
  "dsh-bundle",
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The cross-session pending-approval index (roadmap v4.9.0).
3
+ *
4
+ * ## The problem
5
+ *
6
+ * `approval-broker.mjs` answers approvals **per session**: a request belongs to one IM window, and the
7
+ * operator answers it there. That is right for the person who is *in* that conversation and wrong for a
8
+ * supervisor, who by definition is not — the design's own words are that a supervisor reviews all agents'
9
+ * pending approvals *from one queue*. Without this file, a request raised in a session nobody is watching
10
+ * sits until its deadline delegates it, and the fact that it was waiting is recorded only inside the
11
+ * session that could not answer it.
12
+ *
13
+ * ## What the index does and, more importantly, does not do
14
+ *
15
+ * It **presents** pending work across sessions, filtered by what the viewer has the authority to act on,
16
+ * and it **verifies** authority at the moment of decision. It does not decide anything itself: `decide()`
17
+ * returns a verdict and the caller settles the broker. That split exists because the broker already owns
18
+ * settlement — the timer, the abort listener and the delegate all settle one record — and a second settler
19
+ * would be a second owner of the same promise.
20
+ *
21
+ * ## Why the filter is authority-based rather than a flag
22
+ *
23
+ * Every actor above the ordinary band can in principle approve *something*; what differs is what. So
24
+ * `listFor` answers "what may this actor act on" rather than "is this actor a supervisor", and the answer
25
+ * is per request. An index that showed everything to everyone above a threshold would make the threshold
26
+ * the whole policy, and the authority model would be decoration.
27
+ *
28
+ * ## The three refusals, each for a different reason
29
+ *
30
+ * * **Self-approval** is refused: an actor that can approve its own request has no supervisor.
31
+ * * **Peer approval** is refused: two captains approving each other is a ring, not a chain, and the
32
+ * chain's whole purpose is that a decision can be escalated past the level that wanted it.
33
+ * * **A vanished request** is refused rather than ignored, because "already answered" and "never
34
+ * existed" look identical from a stale queue row and only one means someone should fix their view.
35
+ */
36
+
37
+ import { AUTHORITY_VERDICT, isAuthorityLevel, mayApprove, requiredApproverLevel } from './authority.mjs';
38
+
39
+ /** Why the index refused to let an actor act. Codes, so a caller can branch without matching prose. */
40
+ export const INDEX_REFUSAL = Object.freeze({
41
+ UNKNOWN_REQUEST: 'APPROVAL_UNKNOWN_REQUEST',
42
+ ALREADY_SETTLED: 'APPROVAL_ALREADY_SETTLED',
43
+ SELF_APPROVAL: 'APPROVAL_SELF_APPROVAL',
44
+ INSUFFICIENT_AUTHORITY: 'APPROVAL_INSUFFICIENT_AUTHORITY',
45
+ NO_SUBJECT_LEVEL: 'APPROVAL_NO_SUBJECT_LEVEL',
46
+ BAD_ACTOR: 'APPROVAL_BAD_ACTOR',
47
+ });
48
+
49
+ /**
50
+ * How long a settled request stays visible to a supervisor.
51
+ *
52
+ * A queue that empties the instant a decision is made cannot answer "did I already deal with that", which
53
+ * is the question a supervisor returning to a busy queue actually has. Kept short deliberately: this is a
54
+ * receipt, not a history, and the audit trail is the place for history.
55
+ */
56
+ export const SETTLED_RETENTION_MS = 120_000;
57
+
58
+ /**
59
+ * Create an index over one or more sessions' pending approvals.
60
+ *
61
+ * @param {object} [options]
62
+ * @param {() => number} [options.now] Injected clock, so a test can advance time without waiting.
63
+ * @param {(event: string, detail?: object) => void} [options.audit] Called on every state change.
64
+ * @param {number} [options.settledRetentionMs]
65
+ */
66
+ export function createApprovalIndex(options = {}) {
67
+ const now = options.now ?? (() => Date.now());
68
+ const audit = options.audit ?? (() => {});
69
+ const settledRetentionMs = options.settledRetentionMs ?? SETTLED_RETENTION_MS;
70
+
71
+ /** approvalId -> entry. The entry is the index's own record; the broker owns settlement. */
72
+ const entries = new Map();
73
+
74
+ /**
75
+ * Register a pending approval.
76
+ *
77
+ * Called by whatever raised it — the bridge, in practice — so that the request becomes visible outside
78
+ * the session that owns it. The subject's authority level is recorded **here**, at raise time, because
79
+ * it is a fact about who asked and must not be re-derived later from a caller who might be the approver.
80
+ * An index that let the approver supply the subject's level would let the approver lower it.
81
+ *
82
+ * @param {{approvalId: string, sessionKey?: string|null, subject: {id?: string|null, level: number},
83
+ * action: string, toolName?: string|null, reason?: string|null, openedAt?: number,
84
+ * deadlineAt?: number|null}} entry
85
+ * @returns {{ok: boolean, reason_code?: string, detail?: string}}
86
+ */
87
+ function register(entry) {
88
+ const approvalId = entry?.approvalId;
89
+ if (typeof approvalId !== 'string' || approvalId === '') {
90
+ return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs an approvalId' };
91
+ }
92
+ if (!isAuthorityLevel(entry?.subject?.level)) {
93
+ // Refused rather than defaulted. A request whose subject has no level cannot be placed relative to
94
+ // any approver, and guessing a level here would be inventing the one fact the decision rests on.
95
+ return { ok: false, reason_code: INDEX_REFUSAL.NO_SUBJECT_LEVEL, detail: `subject level must be an integer 0..100, got ${JSON.stringify(entry?.subject?.level ?? null)}` };
96
+ }
97
+ if (typeof entry.action !== 'string' || entry.action.trim() === '') {
98
+ return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs a non-empty action' };
99
+ }
100
+
101
+ const at = Number.isFinite(entry.openedAt) ? entry.openedAt : now();
102
+ const record = {
103
+ approvalId,
104
+ sessionKey: entry.sessionKey ?? null,
105
+ subjectId: entry.subject.id ?? null,
106
+ subjectLevel: entry.subject.level,
107
+ action: entry.action,
108
+ toolName: entry.toolName ?? null,
109
+ reason: entry.reason ?? null,
110
+ openedAt: at,
111
+ deadlineAt: Number.isFinite(entry.deadlineAt) ? entry.deadlineAt : null,
112
+ settled: null,
113
+ settledAt: null,
114
+ };
115
+ entries.set(approvalId, record);
116
+ audit('approval-index/registered', { approval_id: approvalId, session: record.sessionKey, subject_level: record.subjectLevel, action: record.action });
117
+ return { ok: true };
118
+ }
119
+
120
+ /** Retention is applied on read, so no timer is needed and a paused process cannot accumulate work. */
121
+ function reap() {
122
+ const cutoff = now() - settledRetentionMs;
123
+ for (const [id, record] of entries) {
124
+ if (record.settled !== null && record.settledAt !== null && record.settledAt < cutoff) entries.delete(id);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Can this actor act on this request? A pure question: it settles nothing.
130
+ *
131
+ * Split from `decide` so a caller can render a queue with reasons and only then act, without every
132
+ * render having a side effect.
133
+ *
134
+ * @param {object} record
135
+ * @param {{id?: string|null, level: number}} actor
136
+ * @returns {{allowed: boolean, reason_code: string|null, detail: string|null, required: number|null}}
137
+ */
138
+ function mayAct(record, actor) {
139
+ if (!isAuthorityLevel(actor?.level)) {
140
+ return { allowed: false, reason_code: INDEX_REFUSAL.BAD_ACTOR, detail: `actor level must be an integer 0..100, got ${JSON.stringify(actor?.level ?? null)}`, required: null };
141
+ }
142
+ if (record.settled !== null) {
143
+ return { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"`, required: null };
144
+ }
145
+ const peer = mayApprove(
146
+ { id: actor.id ?? null, level: actor.level },
147
+ { id: record.subjectId, level: record.subjectLevel },
148
+ );
149
+ if (peer.ok) return { allowed: true, reason_code: null, detail: null, required: peer.required };
150
+ // `mayApprove` already distinguishes self-approval from an insufficient level, and the distinction
151
+ // matters to a reader: one is outranking, the other is identity. Mapped rather than re-derived, so
152
+ // the two modules cannot drift into disagreeing about which happened.
153
+ const selfRefused = typeof peer.reason === 'string' && peer.reason.includes('may not approve its own');
154
+ return {
155
+ allowed: false,
156
+ reason_code: selfRefused ? INDEX_REFUSAL.SELF_APPROVAL : INDEX_REFUSAL.INSUFFICIENT_AUTHORITY,
157
+ detail: peer.reason,
158
+ required: peer.required,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Record that a request was settled, by whoever settled it.
164
+ *
165
+ * Separate from `decide` because not every settlement is a decision: the broker's timer delegates, the
166
+ * host cancels on abort, and a disposal settles everything. A queue that only learned about operator
167
+ * decisions would keep showing requests that are long gone.
168
+ */
169
+ function settle(approvalId, outcome) {
170
+ const record = entries.get(approvalId);
171
+ if (record === undefined) return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST };
172
+ if (record.settled !== null) return { ok: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"` };
173
+ record.settled = String(outcome);
174
+ record.settledAt = now();
175
+ audit('approval-index/settled', { approval_id: approvalId, outcome: record.settled });
176
+ return { ok: true };
177
+ }
178
+
179
+ /**
180
+ * Decide a request as this actor, if the actor may.
181
+ *
182
+ * Returns the verdict and marks the entry settled so the queue stops showing it. The caller is
183
+ * responsible for actually settling the broker's promise — this function deliberately cannot, because
184
+ * the broker owns the resolver and a second settler would race the timer.
185
+ *
186
+ * @param {string} approvalId
187
+ * @param {{id?: string|null, level: number}} actor
188
+ * @param {string} outcome
189
+ * @returns {{ok: boolean, reason_code: string|null, detail: string|null, approvalId: string}}
190
+ */
191
+ function decide(approvalId, actor, outcome) {
192
+ const record = entries.get(approvalId);
193
+ if (record === undefined) {
194
+ return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, approvalId };
195
+ }
196
+ const check = mayAct(record, actor);
197
+ if (!check.allowed) return { ok: false, reason_code: check.reason_code, detail: check.detail, approvalId };
198
+
199
+ record.settled = String(outcome);
200
+ record.settledAt = now();
201
+ record.decidedBy = actor.id ?? null;
202
+ audit('approval-index/decided', { approval_id: approvalId, by: actor.id ?? null, level: actor.level, outcome: record.settled });
203
+ return { ok: true, reason_code: null, detail: null, approvalId };
204
+ }
205
+
206
+ /**
207
+ * What this actor may act on, oldest first.
208
+ *
209
+ * Oldest first because a queue's job is to surface what has been waiting longest — the request closest to
210
+ * its deadline is the one whose loss costs most, and a newest-first view would hide exactly that.
211
+ *
212
+ * Each row carries its own verdict rather than being filtered out, so a supervisor can see that work
213
+ * exists above their authority and is not theirs to answer. A filtered list would make the fleet look
214
+ * idle at the moment it is most blocked.
215
+ */
216
+ function listFor(actor, { includeSettled = false, nowMs = null } = {}) {
217
+ reap();
218
+ const at = Number.isFinite(nowMs) ? nowMs : now();
219
+ const rows = [];
220
+ for (const record of entries.values()) {
221
+ if (!includeSettled && record.settled !== null) continue;
222
+ const check = record.settled !== null
223
+ ? { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `settled as "${record.settled}"`, required: null }
224
+ : mayAct(record, actor);
225
+ rows.push({
226
+ approval_id: record.approvalId,
227
+ session: record.sessionKey,
228
+ action: record.action,
229
+ tool: record.toolName,
230
+ reason: record.reason,
231
+ subject: { id: record.subjectId, level: record.subjectLevel },
232
+ opened_at: new Date(record.openedAt).toISOString(),
233
+ waiting_ms: Math.max(0, at - record.openedAt),
234
+ deadline_at: record.deadlineAt === null ? null : new Date(record.deadlineAt).toISOString(),
235
+ overdue: record.deadlineAt !== null && record.deadlineAt <= at && record.settled === null,
236
+ settled: record.settled,
237
+ may_act: check.allowed,
238
+ required_level: check.required,
239
+ why_not: check.allowed ? null : { reason_code: check.reason_code, detail: check.detail },
240
+ });
241
+ }
242
+ rows.sort((a, b) => Date.parse(a.opened_at) - Date.parse(b.opened_at));
243
+ return rows;
244
+ }
245
+
246
+ /**
247
+ * The shape of the queue, without the rows.
248
+ *
249
+ * The design asks for one queue across sessions; a supervisor's first question is usually "how much is
250
+ * waiting and where", and a count grouped by session answers it without reading every row.
251
+ */
252
+ function summary(actor) {
253
+ const rows = listFor(actor);
254
+ const bySession = new Map();
255
+ let actionable = 0;
256
+ let overdue = 0;
257
+ for (const row of rows) {
258
+ const key = row.session ?? '(no session)';
259
+ bySession.set(key, (bySession.get(key) ?? 0) + 1);
260
+ if (row.may_act) actionable += 1;
261
+ if (row.overdue) overdue += 1;
262
+ }
263
+ return {
264
+ pending: rows.length,
265
+ actionable,
266
+ overdue,
267
+ by_session: Object.fromEntries([...bySession.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))),
268
+ viewer: { id: actor?.id ?? null, level: actor?.level ?? null },
269
+ };
270
+ }
271
+
272
+ /** Every pending approval, unfiltered. For diagnostics and for a caller that has already authorised. */
273
+ function all({ includeSettled = false } = {}) {
274
+ reap();
275
+ return [...entries.values()].filter((r) => includeSettled || r.settled === null);
276
+ }
277
+
278
+ return {
279
+ register,
280
+ settle,
281
+ decide,
282
+ mayAct: (approvalId, actor) => {
283
+ const record = entries.get(approvalId);
284
+ if (record === undefined) return { allowed: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, required: null };
285
+ return mayAct(record, actor);
286
+ },
287
+ listFor,
288
+ summary,
289
+ all,
290
+ get size() {
291
+ reap();
292
+ return [...entries.values()].filter((r) => r.settled === null).length;
293
+ },
294
+ /** Exposed for diagnostics only. */
295
+ _internals: { entries },
296
+ };
297
+ }
298
+
299
+ /** Re-exported so a caller of this module does not have to reach into `authority.mjs` for one name. */
300
+ export { AUTHORITY_VERDICT, requiredApproverLevel };