@polderlabs/bizar 6.3.0 → 7.0.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.
Files changed (245) hide show
  1. package/.claude/hooks/sessionend-recall.mjs +3 -3
  2. package/.claude/hooks/worker-suggest.mjs +110 -0
  3. package/.claude/settings.json +9 -0
  4. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-CW6fWKds.js} +3 -3
  5. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js.map → EnvVarsSection-CW6fWKds.js.map} +1 -1
  6. package/bizar-dash/dist/assets/MobileChat-D7zRhU9K.js +1 -0
  7. package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-D7zRhU9K.js.map} +1 -1
  8. package/bizar-dash/dist/assets/MobileSettings-K-sNsn_r.js +1 -0
  9. package/bizar-dash/dist/assets/{MobileSettings-CEQNJNLJ.js.map → MobileSettings-K-sNsn_r.js.map} +1 -1
  10. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
  11. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
  12. package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
  13. package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
  14. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
  15. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
  16. package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
  17. package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
  18. package/bizar-dash/dist/assets/main-C-Md3DaY.css +1 -0
  19. package/bizar-dash/dist/assets/main-CO8fIxQK.js +18 -0
  20. package/bizar-dash/dist/assets/main-CO8fIxQK.js.map +1 -0
  21. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
  22. package/bizar-dash/dist/assets/{markdown-tOLaD6nm.js.map → markdown-w2bDDkAH.js.map} +1 -1
  23. package/bizar-dash/dist/assets/mobile-DXYCA0hC.js +1 -0
  24. package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DXYCA0hC.js.map} +1 -1
  25. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js +2 -0
  26. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js.map +1 -0
  27. package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
  28. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
  29. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
  30. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js +2 -0
  31. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js.map +1 -0
  32. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
  33. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
  34. package/bizar-dash/dist/index.html +12 -10
  35. package/bizar-dash/dist/mobile.html +7 -4
  36. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  37. package/bizar-dash/src/server/api.mjs +11 -0
  38. package/bizar-dash/src/server/memory-consolidator.mjs +218 -0
  39. package/bizar-dash/src/server/memory-distillation.mjs +296 -0
  40. package/bizar-dash/src/server/routes/distill.mjs +129 -0
  41. package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
  42. package/bizar-dash/src/web/App.tsx +4 -0
  43. package/bizar-dash/src/web/components/Tag.tsx +18 -5
  44. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  45. package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
  46. package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
  47. package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
  48. package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
  49. package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
  50. package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
  51. package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
  52. package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
  53. package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
  54. package/bizar-dash/src/web/lib/types.ts +25 -0
  55. package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
  56. package/bizar-dash/src/web/styles/main.css +251 -0
  57. package/bizar-dash/src/web/styles/tasks-redesign.css +231 -0
  58. package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
  59. package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
  60. package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
  61. package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
  62. package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
  63. package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
  64. package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
  65. package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
  66. package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
  67. package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
  68. package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
  69. package/bizar-dash/src/web/ui/controls/controls.css +454 -0
  70. package/bizar-dash/src/web/ui/controls/index.ts +33 -0
  71. package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
  72. package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
  73. package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
  74. package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
  75. package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
  76. package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
  77. package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
  78. package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
  79. package/bizar-dash/src/web/ui/data/data.css +535 -0
  80. package/bizar-dash/src/web/ui/data/index.ts +31 -0
  81. package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
  82. package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
  83. package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
  84. package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
  85. package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
  86. package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
  87. package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
  88. package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
  89. package/bizar-dash/src/web/ui/index.ts +56 -0
  90. package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
  91. package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
  92. package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
  93. package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
  94. package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
  95. package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
  96. package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
  97. package/bizar-dash/src/web/ui/layout/index.ts +33 -0
  98. package/bizar-dash/src/web/ui/layout/layout.css +413 -0
  99. package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
  100. package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
  101. package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
  102. package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
  103. package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
  104. package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
  105. package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
  106. package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
  107. package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
  108. package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
  109. package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
  110. package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
  111. package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
  112. package/bizar-dash/src/web/ui/styles/globals.css +86 -0
  113. package/bizar-dash/src/web/ui/styles/reset.css +125 -0
  114. package/bizar-dash/src/web/ui/styles/tokens.css +195 -0
  115. package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
  116. package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
  117. package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
  118. package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
  119. package/bizar-dash/src/web/views/Agents.tsx +506 -266
  120. package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
  121. package/bizar-dash/src/web/views/Overview.tsx +996 -448
  122. package/bizar-dash/src/web/views/Tasks.tsx +277 -273
  123. package/bizar-dash/tests/communication-log.test.tsx +67 -0
  124. package/bizar-dash/tests/dep-graph.test.tsx +40 -0
  125. package/bizar-dash/tests/event-log.test.tsx +61 -0
  126. package/bizar-dash/tests/goal-input.test.tsx +72 -0
  127. package/bizar-dash/tests/goap-planner.test.ts +68 -0
  128. package/bizar-dash/tests/quality-gates.test.tsx +59 -0
  129. package/bizar-dash/tests/setup.ts +31 -1
  130. package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
  131. package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
  132. package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
  133. package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
  134. package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
  135. package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
  136. package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
  137. package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
  138. package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
  139. package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
  140. package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
  141. package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
  142. package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
  143. package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
  144. package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
  145. package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
  146. package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
  147. package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
  148. package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
  149. package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
  150. package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
  151. package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
  152. package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
  153. package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
  154. package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
  155. package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
  156. package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
  157. package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
  158. package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
  159. package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
  160. package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
  161. package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
  162. package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
  163. package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
  164. package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
  165. package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
  166. package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
  167. package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
  168. package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
  169. package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
  170. package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
  171. package/bizar-dash/tests/views/Agents.test.tsx +359 -0
  172. package/bizar-dash/tests/views/Overview.test.tsx +413 -0
  173. package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
  174. package/bizar-dash/vitest.config.ts +3 -1
  175. package/cli/__tests__/cost-gate.test.mjs +301 -0
  176. package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
  177. package/cli/bin.mjs +38 -0
  178. package/cli/commands/claim.mjs +282 -0
  179. package/cli/commands/cost.mjs +279 -0
  180. package/cli/cost-gate.mjs +501 -0
  181. package/cli/feature-list-bridge.mjs +400 -0
  182. package/cli/memory-constants.mjs +17 -0
  183. package/cli/worker-dispatcher.mjs +259 -0
  184. package/cli/worker-dispatcher.test.mjs +204 -0
  185. package/config/trigger-patterns.json +186 -0
  186. package/package.json +2 -2
  187. package/packages/sdk/package.json +1 -1
  188. package/packages/sdk/src/agent-registry.ts +304 -0
  189. package/packages/sdk/src/consensus/byzantine.ts +587 -0
  190. package/packages/sdk/src/consensus/index.ts +147 -0
  191. package/packages/sdk/src/consensus/queen.ts +173 -0
  192. package/packages/sdk/src/consensus/types.ts +199 -0
  193. package/packages/sdk/src/federation/audit.ts +156 -0
  194. package/packages/sdk/src/federation/budget.ts +269 -0
  195. package/packages/sdk/src/federation/envelope.ts +144 -0
  196. package/packages/sdk/src/federation/hmac.ts +160 -0
  197. package/packages/sdk/src/federation/index.ts +443 -0
  198. package/packages/sdk/src/federation/pii.ts +241 -0
  199. package/packages/sdk/src/federation/policy.ts +112 -0
  200. package/packages/sdk/src/federation/trust.ts +146 -0
  201. package/packages/sdk/src/index.ts +67 -0
  202. package/packages/sdk/src/mcp/server.ts +351 -1
  203. package/packages/sdk/src/router/codemod-intent.ts +125 -0
  204. package/packages/sdk/src/router/index.ts +247 -0
  205. package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
  206. package/packages/sdk/src/router/memory-distillation.ts +249 -0
  207. package/packages/sdk/src/router/model-router.ts +235 -0
  208. package/packages/sdk/src/router/q-learning-router.ts +304 -0
  209. package/packages/sdk/src/swarm-topology.ts +279 -0
  210. package/packages/sdk/tests/agent-registry.test.ts +285 -0
  211. package/packages/sdk/tests/consensus.test.ts +376 -0
  212. package/packages/sdk/tests/federation/audit.test.ts +164 -0
  213. package/packages/sdk/tests/federation/budget.test.ts +189 -0
  214. package/packages/sdk/tests/federation/envelope.test.ts +120 -0
  215. package/packages/sdk/tests/federation/hmac.test.ts +194 -0
  216. package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
  217. package/packages/sdk/tests/federation/pii.test.ts +106 -0
  218. package/packages/sdk/tests/federation/policy.test.ts +140 -0
  219. package/packages/sdk/tests/federation/trust.test.ts +124 -0
  220. package/packages/sdk/tests/mcp-tools.test.ts +204 -0
  221. package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
  222. package/packages/sdk/tests/model-router.test.mjs +108 -0
  223. package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
  224. package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
  225. package/packages/sdk/tests/router.test.mjs +91 -0
  226. package/packages/sdk/tests/sdk.test.mjs +26 -2
  227. package/packages/sdk/tests/swarm-topology.test.ts +258 -0
  228. package/scripts/bh-full-e2e.mjs +11 -15
  229. package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
  230. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
  231. package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
  232. package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
  233. package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
  234. package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
  235. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
  236. package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
  237. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
  238. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
  239. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
  240. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
  241. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
  242. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
  243. package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
  244. package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
  245. package/packages/sdk/package-lock.json +0 -32
@@ -0,0 +1,301 @@
1
+ /**
2
+ * cli/__tests__/cost-gate.test.mjs
3
+ *
4
+ * F-035 — tests for cli/cost-gate.mjs (atomic SQLite-backed room budget tracker).
5
+ *
6
+ * Uses Node's built-in `node:test`. No external framework.
7
+ *
8
+ * Coverage:
9
+ * 1. registerRoom — idempotent, accepts valid cap, rejects invalid.
10
+ * 2. status — reflects spentUsd / remaining correctly.
11
+ * 3. listRooms — returns every room with up-to-date totals.
12
+ * 4. reserve → commit: happy path, final remaining matches.
13
+ * 5. reserve → release: spentUsd stays 0, remaining recovers.
14
+ * 6. BUDGET_EXCEEDED: refusal when projected > cap.
15
+ * 7. Reservation expiry: state flips to 'expired' on sweepExpired.
16
+ * 8. Late commit: COMMIT_AFTER_EXPIRY warning + 'committed_post_expiry' state.
17
+ * 9. Concurrent reserves: one succeeds, the other BUDGET_EXCEEDEDs (BEGIN
18
+ * IMMEDIATE atomicity).
19
+ * 10. ALREADY_FINALIZED on double commit / double release.
20
+ * 11. ROOM_NOT_FOUND path.
21
+ * 12. Expiry clamping [5s, 300s] per ADR-164.1 §3.2.
22
+ */
23
+
24
+ import { test, describe, before, afterEach, beforeEach } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+
27
+ import { mkdtempSync, rmSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+
31
+ const {
32
+ CostGate,
33
+ RESERVATION_EXPIRY_FLOOR_MS,
34
+ RESERVATION_EXPIRY_CEILING_MS,
35
+ clampReservationExpiry,
36
+ } = await import('../cost-gate.mjs');
37
+
38
+ let tmpdir_created = false;
39
+ let tmp;
40
+
41
+ before(() => {
42
+ if (!tmpdir_created) {
43
+ tmp = mkdtempSync(join(tmpdir(), 'bizar-cost-gate-'));
44
+ tmpdir_created = true;
45
+ }
46
+ });
47
+
48
+ afterEach(() => {
49
+ // each test creates its own gate; nothing to clean up
50
+ });
51
+
52
+ function freshGate(clock) {
53
+ const dbPath = join(tmp, `gate-${Math.random().toString(36).slice(2, 8)}-${Date.now()}.db`);
54
+ return new CostGate({
55
+ dbPath,
56
+ clock: clock || (() => Date.now()),
57
+ });
58
+ }
59
+
60
+ describe('registerRoom()', () => {
61
+ let gate;
62
+ beforeEach(() => { gate = freshGate(); });
63
+ afterEach(() => gate.close());
64
+
65
+ test('inserts a new room', () => {
66
+ const r = gate.registerRoom('titan-cluster', 50);
67
+ assert.equal(r.ok, true);
68
+ assert.equal(r.roomId, 'titan-cluster');
69
+ assert.equal(r.capUsd, 50);
70
+ });
71
+
72
+ test('upsert on conflict — idempotent', () => {
73
+ gate.registerRoom('titan-cluster', 50);
74
+ gate.registerRoom('titan-cluster', 75); // raises cap
75
+ const s = gate.status('titan-cluster');
76
+ assert.equal(s.capUsd, 75);
77
+ });
78
+
79
+ test('rejects invalid roomId', () => {
80
+ assert.throws(() => gate.registerRoom('', 10), /roomId/);
81
+ assert.throws(() => gate.registerRoom(null, 10), /roomId/);
82
+ });
83
+
84
+ test('rejects negative cap', () => {
85
+ assert.throws(() => gate.registerRoom('r', -1), /capUsd/);
86
+ assert.throws(() => gate.registerRoom('r', NaN), /capUsd/);
87
+ });
88
+ });
89
+
90
+ describe('status() and listRooms()', () => {
91
+ let gate;
92
+ beforeEach(() => { gate = freshGate(); });
93
+ afterEach(() => gate.close());
94
+
95
+ test('status returns null for unknown room', () => {
96
+ assert.equal(gate.status('nope'), null);
97
+ });
98
+
99
+ test('status reflects spentUsd and remaining correctly', () => {
100
+ gate.registerRoom('alpha', 100);
101
+ gate.reserve('alpha', 'tester', 30);
102
+ // Capture txId deterministically for the commit
103
+ const rsv = gate.reserve('alpha', 'tester', 10);
104
+ gate.commit(rsv.txId, 10);
105
+ const s = gate.status('alpha');
106
+ assert.equal(s.spentUsd, 10, 'only the committed 10 is spent');
107
+ assert.equal(s.reservedUsd, 30, 'the 30 reservation is still live');
108
+ assert.equal(s.remainingUsd, 100 - 10 - 30);
109
+ assert.ok(s.txCount >= 2);
110
+ });
111
+
112
+ test('listRooms returns every registered room', () => {
113
+ gate.registerRoom('a', 1);
114
+ gate.registerRoom('b', 2);
115
+ gate.registerRoom('c', 3);
116
+ const all = gate.listRooms();
117
+ const ids = all.map((r) => r.roomId).sort();
118
+ assert.deepEqual(ids, ['a', 'b', 'c']);
119
+ });
120
+ });
121
+
122
+ describe('reserve → commit happy path', () => {
123
+ let gate;
124
+ beforeEach(() => { gate = freshGate(); });
125
+ afterEach(() => gate.close());
126
+
127
+ test('reserve then commit with smaller amount', () => {
128
+ gate.registerRoom('r', 100);
129
+ const rsv = gate.reserve('r', 'agent1', 40);
130
+ assert.equal(rsv.ok, true);
131
+ assert.ok(rsv.txId.startsWith('tx-'));
132
+ const cm = gate.commit(rsv.txId, 25);
133
+ assert.equal(cm.ok, true);
134
+ assert.equal(cm.committed, true);
135
+ const s = gate.status('r');
136
+ assert.equal(s.spentUsd, 25);
137
+ assert.equal(s.remainingUsd, 75);
138
+ });
139
+
140
+ test('reserve then commit with same amount', () => {
141
+ gate.registerRoom('r', 100);
142
+ const rsv = gate.reserve('r', 'agent1', 40);
143
+ const cm = gate.commit(rsv.txId, 40);
144
+ assert.equal(cm.committed, true);
145
+ assert.equal(gate.status('r').spentUsd, 40);
146
+ });
147
+ });
148
+
149
+ describe('reserve → release', () => {
150
+ let gate;
151
+ beforeEach(() => { gate = freshGate(); });
152
+ afterEach(() => gate.close());
153
+
154
+ test('release frees the budget without charging it', () => {
155
+ gate.registerRoom('r', 100);
156
+ const rsv = gate.reserve('r', 'a', 30);
157
+ const rel = gate.release(rsv.txId);
158
+ assert.equal(rel.ok, true);
159
+ assert.equal(rel.released, true);
160
+ const s = gate.status('r');
161
+ assert.equal(s.spentUsd, 0);
162
+ assert.equal(s.reservedUsd, 0);
163
+ assert.equal(s.remainingUsd, 100);
164
+ });
165
+
166
+ test('release on already-released tx returns ALREADY_FINALIZED', () => {
167
+ gate.registerRoom('r', 100);
168
+ const rsv = gate.reserve('r', 'a', 10);
169
+ gate.release(rsv.txId);
170
+ const rel = gate.release(rsv.txId);
171
+ assert.equal(rel.ok, false);
172
+ assert.equal(rel.error, 'ALREADY_FINALIZED');
173
+ });
174
+ });
175
+
176
+ describe('BUDGET_EXCEEDED refusal', () => {
177
+ let gate;
178
+ beforeEach(() => { gate = freshGate(); });
179
+ afterEach(() => gate.close());
180
+
181
+ test('refuses reserve that would exceed cap', () => {
182
+ gate.registerRoom('r', 100);
183
+ const r1 = gate.reserve('r', 'a', 80);
184
+ assert.equal(r1.ok, true);
185
+ const r2 = gate.reserve('r', 'a', 30);
186
+ assert.equal(r2.ok, false);
187
+ assert.equal(r2.error, 'BUDGET_EXCEEDED');
188
+ assert.equal(r2.capUsd, 100);
189
+ assert.equal(r2.committed, 0);
190
+ assert.equal(r2.reserved, 80);
191
+ });
192
+
193
+ test('committing an exact-fit reservation is allowed (with epsilon)', () => {
194
+ gate.registerRoom('r', 100);
195
+ const rsv = gate.reserve('r', 'a', 100);
196
+ assert.equal(rsv.ok, true);
197
+ assert.equal(rsv.remainingAfterReserve, 0);
198
+ });
199
+ });
200
+
201
+ describe('expiry + late commit', () => {
202
+ let gate;
203
+ let now;
204
+ beforeEach(() => {
205
+ now = 1_700_000_000_000;
206
+ gate = freshGate(() => now);
207
+ });
208
+ afterEach(() => gate.close());
209
+
210
+ test('sweepExpired flips reserved rows to expired', () => {
211
+ gate.registerRoom('r', 100);
212
+ const rsv = gate.reserve('r', 'a', 10, { expiryMs: 5_000 });
213
+ assert.equal(rsv.ok, true);
214
+ now += 6_000; // past expiry
215
+ const swept = gate.sweepExpired();
216
+ assert.equal(swept, 1);
217
+ const tx = gate.listTransactions('r').find((t) => t.tx_id === rsv.txId);
218
+ assert.equal(tx.kind, 'expired');
219
+ });
220
+
221
+ test('commit after expiry returns COMMIT_AFTER_EXPIRY warning + spends the actual amount', () => {
222
+ gate.registerRoom('r', 100);
223
+ const rsv = gate.reserve('r', 'a', 10, { expiryMs: 5_000 });
224
+ now += 6_000; // past expiry
225
+ const cm = gate.commit(rsv.txId, 7.5);
226
+ assert.equal(cm.ok, true);
227
+ assert.equal(cm.warned, 'COMMIT_AFTER_EXPIRY');
228
+ const tx = gate.listTransactions('r').find((t) => t.tx_id === rsv.txId);
229
+ assert.equal(tx.kind, 'committed_post_expiry');
230
+ const s = gate.status('r');
231
+ assert.equal(s.spentUsd, 7.5);
232
+ assert.equal(s.remainingUsd, 92.5);
233
+ });
234
+
235
+ test('commit twice returns ALREADY_FINALIZED', () => {
236
+ gate.registerRoom('r', 100);
237
+ const rsv = gate.reserve('r', 'a', 10);
238
+ gate.commit(rsv.txId, 8);
239
+ const cm2 = gate.commit(rsv.txId, 8);
240
+ assert.equal(cm2.ok, false);
241
+ assert.equal(cm2.error, 'ALREADY_FINALIZED');
242
+ });
243
+ });
244
+
245
+ describe('CONCURRENT reserve race (BEGIN IMMEDIATE atomicity)', () => {
246
+ test('only one of N concurrent reserves can exceed the cap when strictly under', () => {
247
+ const gate = freshGate();
248
+ try {
249
+ gate.registerRoom('cr', 10);
250
+ // Fire 10 reserves of $1.50 against a $10 cap. The first 6
251
+ // succeed ($9 projected); the 7th hits BUDGET_EXCEEDED. We use
252
+ // a tight loop so they pile up against the immediate write lock.
253
+ const results = [];
254
+ for (let i = 0; i < 20; i++) {
255
+ const r = gate.reserve('cr', `caller-${i}`, 1.5);
256
+ results.push(r);
257
+ }
258
+ const ok = results.filter((r) => r.ok).length;
259
+ const denied = results.filter((r) => !r.ok).length;
260
+ assert.equal(ok + denied, 20);
261
+ assert.equal(ok, 6, `expected 6 reserves to fit (6 × $1.50 = $9 ≤ $10), got ${ok}`);
262
+ assert.equal(denied, 14);
263
+ assert.ok(
264
+ results.some((r) => !r.ok && r.error === 'BUDGET_EXCEEDED'),
265
+ 'at least one must hit BUDGET_EXCEEDED',
266
+ );
267
+ const s = gate.status('cr');
268
+ assert.equal(s.reservedUsd, 9);
269
+ assert.equal(s.spentUsd, 0);
270
+ } finally {
271
+ gate.close();
272
+ }
273
+ });
274
+ });
275
+
276
+ describe('ROOM_NOT_FOUND', () => {
277
+ let gate;
278
+ beforeEach(() => { gate = freshGate(); });
279
+ afterEach(() => gate.close());
280
+
281
+ test('reserve returns ROOM_NOT_FOUND', () => {
282
+ const r = gate.reserve('nope', 'a', 10);
283
+ assert.equal(r.ok, false);
284
+ assert.equal(r.error, 'ROOM_NOT_FOUND');
285
+ });
286
+ });
287
+
288
+ describe('clampReservationExpiry — ADR-164.1 §3.2', () => {
289
+ test('floors at 5_000 ms', () => {
290
+ assert.equal(clampReservationExpiry(100), RESERVATION_EXPIRY_FLOOR_MS);
291
+ });
292
+ test('caps at 300_000 ms', () => {
293
+ assert.equal(clampReservationExpiry(60 * 60 * 1000), RESERVATION_EXPIRY_CEILING_MS);
294
+ });
295
+ test('passes through valid values', () => {
296
+ assert.equal(clampReservationExpiry(30_000), 30_000);
297
+ });
298
+ test('uses 60_000 ms default for undefined', () => {
299
+ assert.equal(clampReservationExpiry(undefined), 60_000);
300
+ });
301
+ });
@@ -0,0 +1,371 @@
1
+ /**
2
+ * cli/__tests__/feature-list-bridge.test.mjs
3
+ *
4
+ * F-035 — tests for cli/feature-list-bridge.mjs (claim protocol over
5
+ * feature_list.json).
6
+ *
7
+ * Uses Node's built-in `node:test`. No external framework.
8
+ *
9
+ * Coverage:
10
+ * 1. claim — first claim succeeds; second claim rejected ALREADY_CLAIMED.
11
+ * 2. release — reverts to unclaimed; subsequent release errors.
12
+ * 3. handoff — moves claimant to recipient atomically.
13
+ * 4. steal — moves claimant with a stealReason recorded in history.
14
+ * 5. transition — claimStatus moves through allowed states.
15
+ * 6. list — filters by status, claimantId, type.
16
+ * 7. Bad transition — non-allowed status change errors.
17
+ * 8. unknown feature — NOT_FOUND.
18
+ * 9. Persistence: round-trips through file storage.
19
+ */
20
+
21
+ import { test, describe, before, after, beforeEach, afterEach } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+
24
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ const {
29
+ FeatureListBridge,
30
+ CLAIM_STATUSES,
31
+ STEAL_REASONS,
32
+ ClaimError,
33
+ } = await import('../feature-list-bridge.mjs');
34
+
35
+ // ── Fixture loader ────────────────────────────────────────────────────
36
+
37
+ let tmp;
38
+ let bridgeFactory;
39
+
40
+ function writeFixture(dir, name, features) {
41
+ const file = join(dir, name);
42
+ const payload = { $schema: 'https://bizar.dev/schemas/feature_list.v1.json', features };
43
+ writeFileSync(file, JSON.stringify(payload, null, 2));
44
+ return file;
45
+ }
46
+
47
+ const minimalFeature = (id, extra = {}) => ({
48
+ id,
49
+ behavior: `Behavior for ${id}`,
50
+ verification: 'make check',
51
+ state: 'not_started',
52
+ layer: 'L1',
53
+ category: 'core',
54
+ created: '2026-07-11',
55
+ ...extra,
56
+ });
57
+
58
+ before(() => {
59
+ tmp = mkdtempSync(join(tmpdir(), 'bizar-bridge-'));
60
+ bridgeFactory = (filePath) => new FeatureListBridge({
61
+ filePath,
62
+ backup: false, // speed up tests
63
+ });
64
+ });
65
+
66
+ after(() => {
67
+ if (tmp && existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
68
+ });
69
+
70
+ // ── claim ─────────────────────────────────────────────────────────────
71
+
72
+ describe('claim()', () => {
73
+ let file;
74
+ let br;
75
+ beforeEach(() => {
76
+ file = writeFixture(tmp, `claim-${Math.random().toString(36).slice(2, 8)}.json`, [
77
+ minimalFeature('F-A'),
78
+ minimalFeature('F-B', { state: 'passing' }),
79
+ ]);
80
+ br = bridgeFactory(file);
81
+ });
82
+ afterEach(() => { /* tmp cleaned in after() */ });
83
+
84
+ test('first claim succeeds', () => {
85
+ const result = br.claim('F-A', { type: 'human', id: 'odin', name: 'Odin' }, 'first-run');
86
+ assert.equal(result.ok, true);
87
+ const f = result.feature;
88
+ assert.equal(f.claimant.id, 'odin');
89
+ assert.equal(f.claimStatus, 'claimed');
90
+ assert.ok(f.claimedAt);
91
+ assert.equal(f.claimReason, 'first-run');
92
+ assert.equal(f.claimHistory.length, 1);
93
+ assert.equal(f.claimHistory[0].event, 'claim');
94
+ });
95
+
96
+ test('second claim rejected with ALREADY_CLAIMED', () => {
97
+ br.claim('F-A', { type: 'human', id: 'odin', name: 'Odin' }, 'first-run');
98
+ assert.throws(
99
+ () => br.claim('F-A', { type: 'agent', id: 'tyr' }, 'second-run'),
100
+ (err) => err instanceof ClaimError && err.code === 'ALREADY_CLAIMED',
101
+ );
102
+ });
103
+
104
+ test('claim against unknown feature rejected with NOT_FOUND', () => {
105
+ assert.throws(
106
+ () => br.claim('F-NONE', { type: 'agent', id: 'x' }, ''),
107
+ (err) => err instanceof ClaimError && err.code === 'NOT_FOUND',
108
+ );
109
+ });
110
+
111
+ test('claim rejects malformed claimant', () => {
112
+ assert.throws(
113
+ () => br.claim('F-A', { type: 'robot', id: 'x' }, ''),
114
+ (err) => err instanceof ClaimError && err.code === 'BAD_CLAIMANT',
115
+ );
116
+ });
117
+
118
+ test('can re-claim a completed feature', () => {
119
+ br.claim('F-A', { type: 'human', id: 'odin' }, '');
120
+ br.transition('F-A', 'completed', { id: 'odin' }, '');
121
+ // released -> re-claimable
122
+ const r2 = br.claim('F-A', { type: 'agent', id: 'tyr' }, 'second life');
123
+ assert.equal(r2.ok, true);
124
+ });
125
+ });
126
+
127
+ // ── release ───────────────────────────────────────────────────────────
128
+
129
+ describe('release()', () => {
130
+ let file;
131
+ let br;
132
+ beforeEach(() => {
133
+ file = writeFixture(tmp, `rel-${Math.random().toString(36).slice(2, 8)}.json`, [minimalFeature('F-X')]);
134
+ br = bridgeFactory(file);
135
+ });
136
+
137
+ test('release after claim sets status to unclaimed', () => {
138
+ br.claim('F-X', { type: 'human', id: 'odin' }, '');
139
+ const result = br.release('F-X', { id: 'odin' }, 'done');
140
+ assert.equal(result.ok, true);
141
+ assert.equal(result.feature.claimStatus, 'unclaimed');
142
+ assert.equal(result.feature.claimant, null);
143
+ assert.equal(result.feature.claimHistory.length, 2);
144
+ });
145
+
146
+ test('release without claim errors with NOT_CLAIMED', () => {
147
+ assert.throws(
148
+ () => br.release('F-X', { id: 'odin' }, ''),
149
+ (err) => err instanceof ClaimError && err.code === 'NOT_CLAIMED',
150
+ );
151
+ });
152
+
153
+ test('release with wrong claimant errors with WRONG_CLAIMANT', () => {
154
+ br.claim('F-X', { type: 'human', id: 'odin' }, '');
155
+ assert.throws(
156
+ () => br.release('F-X', { id: 'tyr' }, ''),
157
+ (err) => err instanceof ClaimError && err.code === 'WRONG_CLAIMANT',
158
+ );
159
+ });
160
+ });
161
+
162
+ // ── handoff ───────────────────────────────────────────────────────────
163
+
164
+ describe('handoff()', () => {
165
+ let file;
166
+ let br;
167
+ beforeEach(() => {
168
+ file = writeFixture(tmp, `h-${Math.random().toString(36).slice(2, 8)}.json`, [minimalFeature('F-H')]);
169
+ br = bridgeFactory(file);
170
+ });
171
+
172
+ test('handoff moves claimant', () => {
173
+ br.claim('F-H', { type: 'human', id: 'odin' }, '');
174
+ const result = br.handoff(
175
+ 'F-H',
176
+ { type: 'human', id: 'odin' },
177
+ { type: 'agent', id: 'tyr', name: 'Tyr' },
178
+ 'context finished',
179
+ );
180
+ assert.equal(result.ok, true);
181
+ assert.equal(result.feature.claimant.id, 'tyr');
182
+ assert.equal(result.feature.claimStatus, 'claimed');
183
+ // history: claim + handoff-initiated + handoff-accepted
184
+ const events = result.feature.claimHistory.map((e) => e.event);
185
+ assert.deepEqual(events, ['claim', 'handoff-initiated', 'handoff-accepted']);
186
+ });
187
+
188
+ test('handoff with no current claim errors', () => {
189
+ assert.throws(
190
+ () => br.handoff(
191
+ 'F-H',
192
+ { type: 'human', id: 'odin' },
193
+ { type: 'agent', id: 'tyr' },
194
+ '',
195
+ ),
196
+ (err) => err instanceof ClaimError && err.code === 'NOT_CLAIMED',
197
+ );
198
+ });
199
+ });
200
+
201
+ // ── steal ─────────────────────────────────────────────────────────────
202
+
203
+ describe('steal()', () => {
204
+ let file;
205
+ let br;
206
+ beforeEach(() => {
207
+ file = writeFixture(tmp, `s-${Math.random().toString(36).slice(2, 8)}.json`, [minimalFeature('F-S')]);
208
+ br = bridgeFactory(file);
209
+ });
210
+
211
+ test('steal moves the claim', () => {
212
+ br.claim('F-S', { type: 'human', id: 'odin' }, '');
213
+ for (const reason of STEAL_REASONS) {
214
+ // Steal a fresh feature for each reason to avoid ALREADY_CLAIMED.
215
+ const f = minimalFeature(`F-S-${reason}`);
216
+ const fFile = writeFixture(tmp, `s-${reason}.json`, [f]);
217
+ const subBridge = bridgeFactory(fFile);
218
+ subBridge.claim(`F-S-${reason}`, { type: 'agent', id: 'previous' }, '');
219
+ const result = subBridge.steal(`F-S-${reason}`, { type: 'agent', id: 'tyr' }, reason);
220
+ assert.equal(result.ok, true);
221
+ assert.equal(result.previousClaimant.id, 'previous');
222
+ assert.equal(result.feature.claimant.id, 'tyr');
223
+ assert.equal(result.stealReason, reason);
224
+ assert.ok(
225
+ result.feature.claimHistory.some((e) => e.event === 'steal' && e.reason === reason),
226
+ );
227
+ }
228
+ });
229
+
230
+ test('steal rejects unknown reason', () => {
231
+ br.claim('F-S', { type: 'human', id: 'odin' }, '');
232
+ assert.throws(
233
+ () => br.steal('F-S', { type: 'agent', id: 'tyr' }, 'bogus-reason'),
234
+ (err) => err instanceof ClaimError && err.code === 'UNKNOWN_STEAL_REASON',
235
+ );
236
+ });
237
+ });
238
+
239
+ // ── transition ────────────────────────────────────────────────────────
240
+
241
+ describe('transition()', () => {
242
+ let file;
243
+ let br;
244
+ beforeEach(() => {
245
+ file = writeFixture(tmp, `t-${Math.random().toString(36).slice(2, 8)}.json`, [minimalFeature('F-T')]);
246
+ br = bridgeFactory(file);
247
+ });
248
+
249
+ test('claim → active → paused → active → completed', () => {
250
+ br.claim('F-T', { type: 'human', id: 'odin' }, '');
251
+ br.transition('F-T', 'active', { id: 'odin' }, '');
252
+ assert.equal(br.get('F-T').claimStatus, 'active');
253
+ br.transition('F-T', 'paused', { id: 'odin' }, 'lunch');
254
+ assert.equal(br.get('F-T').claimStatus, 'paused');
255
+ br.transition('F-T', 'active', { id: 'odin' }, '');
256
+ assert.equal(br.get('F-T').claimStatus, 'active');
257
+ br.transition('F-T', 'completed', { id: 'odin' }, '');
258
+ assert.equal(br.get('F-T').claimStatus, 'completed');
259
+ });
260
+
261
+ test('illegal transition rejected', () => {
262
+ br.claim('F-T', { type: 'human', id: 'odin' }, '');
263
+ // claimed → blocked is NOT in ALLOWED_TRANSITIONS['claimed']
264
+ assert.throws(
265
+ () => br.transition('F-T', 'blocked', { id: 'odin' }, ''),
266
+ (err) => err instanceof ClaimError && err.code === 'BAD_TRANSITION',
267
+ );
268
+ });
269
+
270
+ test('unknown status rejected', () => {
271
+ br.claim('F-T', { type: 'human', id: 'odin' }, '');
272
+ assert.throws(
273
+ () => br.transition('F-T', 'bogus', { id: 'odin' }, ''),
274
+ (err) => err instanceof ClaimError && err.code === 'UNKNOWN_STATUS',
275
+ );
276
+ });
277
+ });
278
+
279
+ // ── list ──────────────────────────────────────────────────────────────
280
+
281
+ describe('list()', () => {
282
+ let file;
283
+ let br;
284
+ beforeEach(() => {
285
+ file = writeFixture(tmp, `l-${Math.random().toString(36).slice(2, 8)}.json`, [
286
+ minimalFeature('F-1'),
287
+ minimalFeature('F-2'),
288
+ minimalFeature('F-3'),
289
+ minimalFeature('F-4'),
290
+ ]);
291
+ br = bridgeFactory(file);
292
+ });
293
+
294
+ test('claims 2, handoffs 1, steals 1 — list filters', () => {
295
+ br.claim('F-1', { type: 'human', id: 'odin' }, '');
296
+ br.claim('F-2', { type: 'agent', id: 'tyr' }, '');
297
+ br.claim('F-3', { type: 'human', id: 'odin' }, '');
298
+ br.handoff('F-3', { type: 'human', id: 'odin' }, { type: 'agent', id: 'tyr' }, '');
299
+ br.claim('F-4', { type: 'human', id: 'mimir' }, '');
300
+ br.steal('F-4', { type: 'agent', id: 'tyr' }, 'voluntary');
301
+
302
+ const all = br.list();
303
+ assert.equal(all.length, 4);
304
+ const claimed = br.list({ status: 'claimed' });
305
+ assert.equal(claimed.length, 4, 'all four are claimed at this point');
306
+ const humanClaims = br.list({ claimedByHuman: true });
307
+ // All four claimants are human-type except F-2 (initial agent) and F-3 (after handoff).
308
+ // Once F-3 is handed off it has an agent claimant. F-4 was stolen by an agent.
309
+ const humanIds = humanClaims.map((f) => f.id);
310
+ assert.ok(humanIds.includes('F-1'), 'F-1 stayed human');
311
+ assert.ok(!humanIds.includes('F-4'), 'F-4 is now an agent claim');
312
+
313
+ const byOdin = br.list({ claimantId: 'odin' });
314
+ assert.equal(byOdin.length, 1, 'only F-1 still belongs to odin');
315
+ assert.equal(byOdin[0].id, 'F-1');
316
+ });
317
+
318
+ test('list with no filters returns every feature', () => {
319
+ const all = br.list();
320
+ assert.equal(all.length, 4);
321
+ });
322
+ });
323
+
324
+ // ── Persistence ───────────────────────────────────────────────────────
325
+
326
+ describe('persistence', () => {
327
+ test('bridges are independent — each gets its own file', () => {
328
+ const file = writeFixture(tmp, `p-${Math.random().toString(36).slice(2, 6)}.json`, [minimalFeature('F-P')]);
329
+ const a = bridgeFactory(file);
330
+ a.claim('F-P', { type: 'human', id: 'odin' }, '');
331
+ const b = bridgeFactory(file);
332
+ assert.equal(b.get('F-P').claimant.id, 'odin');
333
+ assert.equal(b.get('F-P').claimStatus, 'claimed');
334
+ });
335
+
336
+ test('atomic write produces a parseable file', () => {
337
+ const file = writeFixture(tmp, `pw-${Math.random().toString(36).slice(2, 6)}.json`, [minimalFeature('F-PW')]);
338
+ const a = bridgeFactory(file);
339
+ a.claim('F-PW', { type: 'agent', id: 'first' }, '');
340
+ a.release('F-PW', { id: 'first' }, 'round-trip test');
341
+ a.claim('F-PW', { type: 'agent', id: 'second' }, 're-claim after release');
342
+ // verify file is valid JSON
343
+ const txt = readFileSync(file, 'utf8');
344
+ const data = JSON.parse(txt);
345
+ assert.ok(Array.isArray(data.features));
346
+ assert.equal(data.features.length, 1);
347
+ const f = data.features[0];
348
+ assert.equal(f.claimant.id, 'second');
349
+ assert.equal(f.claimStatus, 'claimed');
350
+ assert.ok(Array.isArray(f.claimHistory));
351
+ assert.equal(f.claimHistory.length, 3);
352
+ });
353
+ });
354
+
355
+ // ── CLAIM_STATUSES + STEAL_REASONS shape ──────────────────────────────
356
+
357
+ describe('exported constants', () => {
358
+ test('CLAIM_STATUSES matches the spec', () => {
359
+ assert.deepEqual(
360
+ [...CLAIM_STATUSES].sort(),
361
+ ['active', 'blocked', 'claimed', 'completed', 'handoff-pending', 'paused', 'unclaimed'].sort(),
362
+ );
363
+ });
364
+
365
+ test('STEAL_REASONS matches the spec', () => {
366
+ assert.deepEqual(
367
+ [...STEAL_REASONS].sort(),
368
+ ['blocked-timeout', 'overloaded', 'stale', 'voluntary'].sort(),
369
+ );
370
+ });
371
+ });