@polderlabs/bizar 10.5.0 → 10.7.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 (120) hide show
  1. package/bizar-dash/src/server/agent-bus.mjs +485 -0
  2. package/bizar-dash/src/server/api.mjs +6 -0
  3. package/bizar-dash/src/server/loop-runtime.mjs +429 -0
  4. package/bizar-dash/src/server/routes/agent-bus.mjs +185 -0
  5. package/bizar-dash/src/server/routes/loops.mjs +138 -0
  6. package/bizar-dash/src/server/task-splitter.mjs +241 -0
  7. package/bizar-dash/tests/agent-bus.test.mjs +289 -0
  8. package/bizar-dash/tests/loop-runtime.test.mjs +312 -0
  9. package/bizar-dash/tests/loops-agent-bus-routes.test.mjs +293 -0
  10. package/bizar-dash/tests/memory-protocol-drift.test.mjs +18 -11
  11. package/bizar-dash/tests/task-splitter.test.mjs +268 -0
  12. package/cli/__tests__/feature-list-bridge.test.mjs +42 -42
  13. package/cli/agent-browser-up.sh +35 -35
  14. package/cli/agent-browser-update.mjs +42 -42
  15. package/cli/agent-browser-update.test.mjs +7 -7
  16. package/cli/audit.mjs +3 -3
  17. package/cli/bin.mjs +10 -10
  18. package/cli/bootstrap.mjs +1 -1
  19. package/cli/cli-commands-validation.test.mjs +2 -2
  20. package/cli/commands/claim.mjs +3 -3
  21. package/cli/commands/claude-cmd.mjs +3 -3
  22. package/cli/commands/eval.mjs +6 -6
  23. package/cli/commands/util.mjs +18 -18
  24. package/cli/commands/validate.mjs +10 -10
  25. package/cli/init.mjs +1 -1
  26. package/cli/install/postinstall.mjs +4 -4
  27. package/cli/plow-through.test.mjs +7 -6
  28. package/cli/prompts.mjs +11 -11
  29. package/cli/provision.mjs +4 -16
  30. package/config/skills/bizar/SKILL.md +1 -1
  31. package/config/skills/skillopt/SKILL.md +129 -0
  32. package/config/skills/thinking-archetypes/SKILL.md +90 -0
  33. package/config/skills/thinking-bayesian/SKILL.md +267 -0
  34. package/config/skills/thinking-bounded-rationality/SKILL.md +406 -0
  35. package/config/skills/thinking-circle-of-competence/SKILL.md +216 -0
  36. package/config/skills/thinking-cynefin/SKILL.md +70 -0
  37. package/config/skills/thinking-debiasing/SKILL.md +192 -0
  38. package/config/skills/thinking-dual-process/SKILL.md +282 -0
  39. package/config/skills/thinking-effectuation/SKILL.md +366 -0
  40. package/config/skills/thinking-feedback-loops/SKILL.md +464 -0
  41. package/config/skills/thinking-fermi-estimation/SKILL.md +263 -0
  42. package/config/skills/thinking-first-principles/SKILL.md +167 -0
  43. package/config/skills/thinking-five-whys-plus/SKILL.md +139 -0
  44. package/config/skills/thinking-inversion/SKILL.md +195 -0
  45. package/config/skills/thinking-jobs-to-be-done/SKILL.md +363 -0
  46. package/config/skills/thinking-kepner-tregoe/SKILL.md +154 -0
  47. package/config/skills/thinking-leverage-points/SKILL.md +390 -0
  48. package/config/skills/thinking-lindy-effect/SKILL.md +331 -0
  49. package/config/skills/thinking-map-territory/SKILL.md +111 -0
  50. package/config/skills/thinking-margin-of-safety/SKILL.md +330 -0
  51. package/config/skills/thinking-model-combination/SKILL.md +406 -0
  52. package/config/skills/thinking-model-router/SKILL.md +360 -0
  53. package/config/skills/thinking-model-selection/SKILL.md +341 -0
  54. package/config/skills/thinking-occams-razor/SKILL.md +129 -0
  55. package/config/skills/thinking-ooda/SKILL.md +127 -0
  56. package/config/skills/thinking-opportunity-cost/SKILL.md +360 -0
  57. package/config/skills/thinking-pre-mortem/SKILL.md +170 -0
  58. package/config/skills/thinking-probabilistic/SKILL.md +324 -0
  59. package/config/skills/thinking-red-team/SKILL.md +142 -0
  60. package/config/skills/thinking-regret-minimization/SKILL.md +335 -0
  61. package/config/skills/thinking-reversibility/SKILL.md +326 -0
  62. package/config/skills/thinking-scientific-method/SKILL.md +162 -0
  63. package/config/skills/thinking-second-order/SKILL.md +184 -0
  64. package/config/skills/thinking-socratic/SKILL.md +198 -0
  65. package/config/skills/thinking-steel-manning/SKILL.md +332 -0
  66. package/config/skills/thinking-systems/SKILL.md +238 -0
  67. package/config/skills/thinking-theory-of-constraints/SKILL.md +338 -0
  68. package/config/skills/thinking-thought-experiment/SKILL.md +354 -0
  69. package/config/skills/thinking-triz/SKILL.md +171 -0
  70. package/config/skills/thinking-via-negativa/SKILL.md +358 -0
  71. package/package.json +2 -3
  72. package/packages/sdk/dist/consensus/index.js +4 -4
  73. package/packages/sdk/dist/router/agent-model-registry.js +2 -2
  74. package/packages/sdk/dist/router/index.d.ts +3 -3
  75. package/packages/sdk/dist/router/index.js +4 -4
  76. package/packages/sdk/dist/router/q-learning-router.d.ts +2 -2
  77. package/packages/sdk/dist/router/q-learning-router.js +18 -18
  78. package/packages/sdk/package.json +1 -1
  79. package/scripts/bh-full-e2e.mjs +10 -8
  80. package/scripts/check-agents.mjs +27 -24
  81. package/scripts/mirror-claude-md.sh +0 -0
  82. package/scripts/sync-skills-mirror.mjs +113 -0
  83. package/scripts/verify-thinking-skills.mjs +135 -0
  84. package/config/agents/_shared/AGENT_BASELINE.md +0 -155
  85. package/config/agents/_shared/CLINE_TOOLS.md +0 -398
  86. package/config/agents/_shared/SKILLS.md +0 -109
  87. package/config/agents/agent-browser.md +0 -90
  88. package/config/agents/baldr.md +0 -60
  89. package/config/agents/forseti.md +0 -60
  90. package/config/agents/frigg.md +0 -52
  91. package/config/agents/heimdall.md +0 -44
  92. package/config/agents/hermod.md +0 -64
  93. package/config/agents/mimir.md +0 -60
  94. package/config/agents/odin.md +0 -310
  95. package/config/agents/quick.md +0 -47
  96. package/config/agents/semble-search.md +0 -59
  97. package/config/agents/thor.md +0 -64
  98. package/config/agents/tyr.md +0 -67
  99. package/config/agents/vidarr.md +0 -65
  100. package/config/agents/vor.md +0 -62
  101. package/config/commands/audit.md +0 -5
  102. package/config/commands/bizar.md +0 -22
  103. package/config/commands/explain.md +0 -5
  104. package/config/commands/init.md +0 -26
  105. package/config/commands/learn.md +0 -5
  106. package/config/commands/plan.md +0 -30
  107. package/config/commands/plow-through.md +0 -47
  108. package/config/commands/pr-review.md +0 -5
  109. package/config/commands/setup-provider.md +0 -95
  110. package/config/commands/tailscale-serve.md +0 -97
  111. package/config/commands/team.md +0 -124
  112. package/config/commands/test.md +0 -60
  113. package/config/commands/validate.md +0 -66
  114. package/config/commands/visual-plan.md +0 -19
  115. package/config/hooks/PostToolUse +0 -66
  116. package/config/hooks/PreToolUse +0 -82
  117. package/config/hooks/README.md +0 -68
  118. package/config/hooks/TaskResume +0 -48
  119. package/config/hooks/TaskStart +0 -47
  120. package/config/hooks/UserPromptSubmit +0 -55
@@ -0,0 +1,138 @@
1
+ /**
2
+ * src/server/routes/loops.mjs
3
+ *
4
+ * G-autoloop Phase 5 — HTTP surface for the loop runtime.
5
+ *
6
+ * Endpoints (mounted under `/api/loops`):
7
+ * GET /api/loops — snapshot of all known loops
8
+ * GET /api/loops/:id — single loop state
9
+ * POST /api/loops — create a new pending loop
10
+ * PATCH /api/loops/:id — patch fields (intervalMs, maxIterations, etc.)
11
+ * POST /api/loops/:id/start — flip status to 'running' (idempotent)
12
+ * POST /api/loops/:id/stop — flip status to 'stopped' (idempotent)
13
+ * DELETE /api/loops/:id — delete the state file
14
+ *
15
+ * The actual scheduling (in-process setInterval) happens in
16
+ * `loop-runtime.mjs`. This router owns only the HTTP wiring — keeps
17
+ * the runtime pure + testable.
18
+ *
19
+ * Phase 5 deliberately stops short of dispatching real tasks here.
20
+ * That's wired in a follow-up so we can ship the loop infrastructure
21
+ * (CRUD + scheduler) and let a dedicated route pair it with the real
22
+ * `taskDelegator` + project-store integration.
23
+ */
24
+
25
+ import { Router } from 'express';
26
+ import {
27
+ createLoop,
28
+ readLoop,
29
+ patchLoop,
30
+ deleteLoop,
31
+ listLoops,
32
+ snapshotLoops,
33
+ startLoop,
34
+ stopLoop,
35
+ } from '../loop-runtime.mjs';
36
+ import { wrap } from './_shared.mjs';
37
+
38
+ const VALID_PATCH_FIELDS = new Set([
39
+ 'name',
40
+ 'intervalMs',
41
+ 'maxIterations',
42
+ 'projectId',
43
+ ]);
44
+
45
+ /**
46
+ * @param {Object} deps
47
+ * @param {Function} [deps.broadcast]
48
+ */
49
+ export function createLoopsRouter({ broadcast = () => {} } = {}) {
50
+ const router = Router();
51
+
52
+ router.get('/loops', wrap(async (req, res) => {
53
+ const snap = snapshotLoops();
54
+ res.json({ loops: Object.values(snap), count: Object.keys(snap).length });
55
+ }));
56
+
57
+ router.get('/loops/:id', wrap(async (req, res) => {
58
+ const s = readLoop(req.params.id);
59
+ if (!s) {
60
+ res.status(404).json({ error: 'not_found' });
61
+ return;
62
+ }
63
+ res.json(s);
64
+ }));
65
+
66
+ router.post('/loops', wrap(async (req, res) => {
67
+ const body = req.body || {};
68
+ if (!body.projectId) {
69
+ res.status(400).json({ error: 'bad_request', message: 'projectId required' });
70
+ return;
71
+ }
72
+ const s = createLoop({
73
+ projectId: body.projectId,
74
+ name: body.name,
75
+ intervalMs: body.intervalMs,
76
+ maxIterations: body.maxIterations,
77
+ });
78
+ broadcast({ type: 'loops:change', loop: s });
79
+ res.status(201).json(s);
80
+ }));
81
+
82
+ router.patch('/loops/:id', wrap(async (req, res) => {
83
+ const patch = {};
84
+ for (const [k, v] of Object.entries(req.body || {})) {
85
+ if (VALID_PATCH_FIELDS.has(k)) patch[k] = v;
86
+ }
87
+ const next = patchLoop(req.params.id, patch);
88
+ if (!next) {
89
+ res.status(404).json({ error: 'not_found' });
90
+ return;
91
+ }
92
+ broadcast({ type: 'loops:change', loop: next });
93
+ res.json(next);
94
+ }));
95
+
96
+ // Start a loop. The runtime owns the timer; this just flips state.
97
+ // We re-use `startLoop` from the runtime so the in-process scheduler
98
+ // is the single source of truth. If the caller passed a `dry` flag,
99
+ // we only flip the state without engaging the timer — useful for
100
+ // the dashboard's "arm only" toggle.
101
+ router.post('/loops/:id/start', wrap(async (req, res) => {
102
+ const dry = req.body && req.body.dry === true;
103
+ let next;
104
+ try {
105
+ next = dry ? patchLoop(req.params.id, { status: 'running' }) : startLoop(req.params.id, {});
106
+ } catch (err) {
107
+ res.status(404).json({ error: 'not_found', message: err.message });
108
+ return;
109
+ }
110
+ if (!next) {
111
+ res.status(404).json({ error: 'not_found' });
112
+ return;
113
+ }
114
+ broadcast({ type: 'loops:change', loop: next });
115
+ res.json(next);
116
+ }));
117
+
118
+ router.post('/loops/:id/stop', wrap(async (req, res) => {
119
+ const next = stopLoop(req.params.id);
120
+ if (!next) {
121
+ res.status(404).json({ error: 'not_found' });
122
+ return;
123
+ }
124
+ broadcast({ type: 'loops:change', loop: next });
125
+ res.json(next);
126
+ }));
127
+
128
+ router.delete('/loops/:id', wrap(async (req, res) => {
129
+ // Stop the in-process timer first so it doesn't keep firing on a
130
+ // deleted state.json.
131
+ stopLoop(req.params.id);
132
+ deleteLoop(req.params.id);
133
+ broadcast({ type: 'loops:deleted', loopId: req.params.id });
134
+ res.status(204).end();
135
+ }));
136
+
137
+ return router;
138
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * src/server/task-splitter.mjs
3
+ *
4
+ * v10.6.0 — G-autoloop Phase 3. Task ordering + auto-splitting.
5
+ *
6
+ * The "best order" problem the user cares about has three pieces:
7
+ *
8
+ * 1. Order. Which ready task should the loop pick next? Already
9
+ * solved in `loop-runtime.mjs:pickReadyTasks` (priority
10
+ * then age, dependencies gated on `done` status).
11
+ *
12
+ * 2. Size. How big is each task? Operators want to know
13
+ * "`tsk_abc` is L-sized" before they let the loop loose
14
+ * on it. Size is a heuristic over title/description
15
+ * length, presence of linked goal/KR, and historical
16
+ * completion times.
17
+ *
18
+ * 3. Split. Tasks tagged `size ∈ { L, XL }` get routed to the
19
+ * `task-decomposer` agent, which returns 2-N smaller
20
+ * tasks. The new tasks link back via `parentTaskId`
21
+ * so progress aggregates up. The parent stays open
22
+ * until all children are `done`.
23
+ *
24
+ * This module implements pure functions for (2) and (3). Splitting
25
+ * itself delegates to a caller-supplied `decompose` function so we
26
+ * don't bake an LLM call into the harness — that's the agent's job
27
+ * (per our architecture boundary: skills/MCP define agent behaviour,
28
+ * not the dash server).
29
+ */
30
+
31
+ const SIZE_ORDER = ['S', 'M', 'L', 'XL'];
32
+
33
+ /**
34
+ * Estimate a task's size bucket from its title, description, linked
35
+ * goals, and history. Heuristic, no LLM. Buckets: S, M, L, XL.
36
+ *
37
+ * Signals:
38
+ * - title word count : +0.2 per word over 6
39
+ * - description char count : +0.001 per char over 200
40
+ * - linked goal/KR : +1 size point each
41
+ * - avg historical duration : +1 if > 2h, +2 if > 6h
42
+ *
43
+ * @param {Object} task
44
+ * @param {string} task.title
45
+ * @param {string} [task.description]
46
+ * @param {Object} [task.metadata] may carry goalId/krId
47
+ * @param {Array} [history] prior tasks from this assignee;
48
+ * we use title-length buckets and
49
+ * avg `timeSpent` minutes as a
50
+ * proxy for difficulty.
51
+ * @returns {'S'|'M'|'L'|'XL'}
52
+ */
53
+ export function estimateSize(task, history = []) {
54
+ if (!task || typeof task !== 'object') return 'M';
55
+ const title = String(task.title || '');
56
+ const desc = String(task.description || '');
57
+ const meta = task.metadata || {};
58
+
59
+ let score = 0;
60
+ // Title length signal — every word over 6 adds 0.2 to the score.
61
+ const words = title.trim().split(/\s+/).filter(Boolean);
62
+ if (words.length > 6) score += (words.length - 6) * 0.2;
63
+
64
+ // Description length signal — chars over 200 add 0.001 each.
65
+ if (desc.length > 200) score += (desc.length - 200) * 0.001;
66
+
67
+ // Linked goal / KR — both count.
68
+ if (meta.goalId) score += 1;
69
+ if (meta.krId) score += 1;
70
+
71
+ // Historical signal — if prior similar-sized tasks took > 2h, push up.
72
+ const historicalMinutes = avgHistoricalMinutes(task, history);
73
+ if (historicalMinutes > 360) score += 2; // > 6h
74
+ else if (historicalMinutes > 120) score += 1; // > 2h
75
+
76
+ return scoreToBucket(score);
77
+ }
78
+
79
+ function scoreToBucket(score) {
80
+ if (score < 0.5) return 'S';
81
+ if (score < 1.5) return 'M';
82
+ if (score < 3.0) return 'L';
83
+ return 'XL';
84
+ }
85
+
86
+ /**
87
+ * Average minutes spent on historical tasks that share at least 2
88
+ * title-words with the candidate. We treat very short histories
89
+ * (< 2 items) as unreliable and return 0.
90
+ *
91
+ * @param {Object} task
92
+ * @param {Array} history
93
+ * @returns {number}
94
+ */
95
+ export function avgHistoricalMinutes(task, history) {
96
+ if (!task || !Array.isArray(history) || history.length < 2) return 0;
97
+ const titleWords = new Set(
98
+ String(task.title || '').toLowerCase().split(/\s+/).filter((w) => w.length > 3),
99
+ );
100
+ if (titleWords.size === 0) return 0;
101
+ const similar = history.filter((h) => {
102
+ if (!h || !h.title) return false;
103
+ const hw = new Set(String(h.title).toLowerCase().split(/\s+/).filter((w) => w.length > 3));
104
+ let overlap = 0;
105
+ for (const w of titleWords) if (hw.has(w)) overlap++;
106
+ return overlap >= 2;
107
+ });
108
+ if (similar.length === 0) return 0;
109
+ let total = 0;
110
+ let count = 0;
111
+ for (const h of similar) {
112
+ const mins = Number(h.timeSpent) || 0;
113
+ if (mins > 0) {
114
+ total += mins;
115
+ count++;
116
+ }
117
+ }
118
+ return count > 0 ? total / count : 0;
119
+ }
120
+
121
+ /**
122
+ * Decide whether a task should be split. Threshold: `size ∈ { L, XL }`
123
+ * OR explicit `metadata.autoSplit === true`.
124
+ *
125
+ * @param {Object} task
126
+ * @param {Array} [history]
127
+ * @returns {boolean}
128
+ */
129
+ export function shouldSplit(task, history = []) {
130
+ if (!task) return false;
131
+ if (task.metadata && task.metadata.autoSplit === true) return true;
132
+ if (task.metadata && task.metadata.autoSplit === false) return false;
133
+ const size = estimateSize(task, history);
134
+ return size === 'L' || size === 'XL';
135
+ }
136
+
137
+ /**
138
+ * Plan a split. Returns the **call shape** the caller dispatches to a
139
+ * `task-decomposer` agent (LLM-step), plus the metadata that should
140
+ * be patched back onto the parent. The agent must return:
141
+ *
142
+ * [{ title, description?, size?, priority? }, ...] // 2..N tasks
143
+ *
144
+ * @param {Object} parent
145
+ * @returns {Object} plan
146
+ * - kind: 'task-decompose'
147
+ * - parentTaskId
148
+ * - prompt: natural-language instruction for the agent
149
+ * - constraints: { min: 2, max: 5 } — the agent may not exceed this
150
+ */
151
+ export function planSplit(parent) {
152
+ if (!parent || !parent.id) throw new TypeError('planSplit: task id required');
153
+ const p = parent;
154
+ return {
155
+ kind: 'task-decompose',
156
+ parentTaskId: p.id,
157
+ prompt: [
158
+ `Break the following task into 2-5 smaller, independently-completable subtasks.`,
159
+ `Each subtask should be completable in under 30 minutes by a single subagent.`,
160
+ ``,
161
+ `Title: ${p.title || '(no title)'}`,
162
+ `Description: ${p.description || '(no description)'}`,
163
+ p.assignee ? `Original assignee: ${p.assignee}` : null,
164
+ p.metadata && p.metadata.goalId ? `Linked goal: ${p.metadata.goalId}` : null,
165
+ ].filter(Boolean).join('\n'),
166
+ constraints: { min: 2, max: 5 },
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Validate a `task-decompose` response. Returns an array of task
172
+ * seeds (without ids — those are minted by the task store on create).
173
+ *
174
+ * @param {Array} candidates
175
+ * @param {Object} [opts]
176
+ * @param {number} [opts.min=2]
177
+ * @param {number} [opts.max=5]
178
+ * @returns {Array} cleaned seeds, ready to be passed to `tasksStore.create`
179
+ */
180
+ export function acceptSplit(candidates, { min = 2, max = 5 } = {}) {
181
+ if (!Array.isArray(candidates)) return [];
182
+ const out = [];
183
+ for (const c of candidates) {
184
+ if (!c || typeof c !== 'object') continue;
185
+ if (!c.title || typeof c.title !== 'string') continue;
186
+ out.push({
187
+ title: String(c.title).slice(0, 200),
188
+ description: c.description ? String(c.description).slice(0, 4000) : '',
189
+ priority: Number.isFinite(c.priority) ? c.priority : 5,
190
+ tags: Array.isArray(c.tags) ? c.tags.filter((t) => typeof t === 'string') : ['autosplit'],
191
+ });
192
+ if (out.length >= max) break;
193
+ }
194
+ if (out.length < min) return [];
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Roll up child task progress into the parent's `metadata.progress`
200
+ * (0..1) and auto-complete the parent when all children are `done`.
201
+ *
202
+ * Pure function — the caller is responsible for writing back to the
203
+ * task store. Returns `{ ratio, allDone }`. If `parent` has no
204
+ * children array, returns `{ ratio: 0, allDone: false }`.
205
+ *
206
+ * @param {Object} parent
207
+ * @returns {Object}
208
+ */
209
+ export function rollupProgress(parent) {
210
+ if (!parent) return { ratio: 0, allDone: false };
211
+ const children = Array.isArray(parent.subtasks) ? parent.subtasks : [];
212
+ if (children.length === 0) return { ratio: 0, allDone: false };
213
+ let done = 0;
214
+ for (const c of children) {
215
+ if (c && c.status === 'done') done++;
216
+ }
217
+ const ratio = done / children.length;
218
+ return { ratio, allDone: done === children.length };
219
+ }
220
+
221
+ /**
222
+ * Suggest a goal-aware priority bump. If a task links to a goal whose
223
+ * status is `at-risk` or `blocked`, bump its priority by 1; if `done`,
224
+ * drop the task to backlog. Pure helper; caller decides what to do
225
+ * with the result.
226
+ *
227
+ * @param {Object} task
228
+ * @param {Object} [goal] parsed goal object (from progress-parser)
229
+ * @returns {{ priority: number, action: 'bump'|'drop'|'none' }}
230
+ */
231
+ export function goalAwarePriority(task, goal) {
232
+ if (!task || !goal) return { priority: Number.isFinite(task && task.priority) ? task.priority : 5, action: 'none' };
233
+ const base = Number.isFinite(task.priority) ? task.priority : 5;
234
+ if (goal.status === 'at-risk') return { priority: Math.max(1, base - 1), action: 'bump' };
235
+ if (goal.status === 'blocked') return { priority: base, action: 'none' };
236
+ if (goal.status === 'done') return { priority: 9, action: 'drop' };
237
+ return { priority: base, action: 'none' };
238
+ }
239
+
240
+ /** Exported for tests + UI. */
241
+ export const __sizeBuckets = SIZE_ORDER;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * tests/agent-bus.test.mjs
3
+ *
4
+ * Self-check for agent-bus.mjs (G-autoloop Phase 4).
5
+ * Uses node:test + node:assert/strict, mirroring the convention in
6
+ * tests/background-steer.test.mjs. Isolates state by pointing the
7
+ * module at a fresh temp directory and resetting presence on exit.
8
+ */
9
+
10
+ import { test, before, after, beforeEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, appendFileSync, writeFileSync, renameSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const TMP = mkdtempSync(join(tmpdir(), 'bizar-agent-bus-'));
17
+
18
+ // Point the module at the temp dir BEFORE importing it.
19
+ process.env.BIZAR_AGENT_BUS_ROOT = TMP;
20
+
21
+ const {
22
+ AGENT_BUS_LOG_DIR,
23
+ AGENT_BUS_PRESENCE_FILE,
24
+ DEFAULT_CHANNEL,
25
+ isValidAddress,
26
+ resolveAddress,
27
+ publish,
28
+ steer,
29
+ correct,
30
+ handoff,
31
+ request,
32
+ respond,
33
+ subscribe,
34
+ subscribeChannel,
35
+ readChannel,
36
+ announce,
37
+ leave,
38
+ heartbeat,
39
+ getPresence,
40
+ _resetForTests,
41
+ } = await import('../src/server/agent-bus.mjs');
42
+
43
+ beforeEach(() => _resetForTests());
44
+
45
+ after(() => {
46
+ try { rmSync(TMP, { recursive: true, force: true }); } catch { /* */ }
47
+ });
48
+
49
+ test('isValidAddress: accepts loose and targeted and broadcast', () => {
50
+ assert.equal(isValidAddress('agent://odin'), true);
51
+ assert.equal(isValidAddress('agent://odin/session-abc123'), true);
52
+ assert.equal(isValidAddress('agent://*'), true);
53
+ assert.equal(isValidAddress('odin'), false);
54
+ assert.equal(isValidAddress('agent:/odin'), false);
55
+ assert.equal(isValidAddress(''), false);
56
+ assert.equal(isValidAddress(null), false);
57
+ assert.equal(isValidAddress(undefined), false);
58
+ });
59
+
60
+ test('publish: rejects bad input with TypeError', () => {
61
+ assert.throws(() => publish(null), TypeError);
62
+ assert.throws(() => publish({}), TypeError);
63
+ assert.throws(() => publish({ from: 'odin', to: 'agent://thor', kind: 'request' }), TypeError);
64
+ assert.throws(() => publish({ from: 'agent://odin', to: 'agent://thor', kind: 'bogus' }), TypeError);
65
+ });
66
+
67
+ test('publish: appends to JSONL log and emits live', () => {
68
+ const seen = [];
69
+ const unsub = subscribe((m) => seen.push(m));
70
+ const id = publish({ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: { q: 'hello' } });
71
+ unsub();
72
+ assert.ok(id.length > 0);
73
+ assert.equal(seen.length, 1);
74
+ assert.equal(seen[0].kind, 'request');
75
+ assert.equal(seen[0].channel, DEFAULT_CHANNEL);
76
+ const logFile = join(AGENT_BUS_LOG_DIR, `${DEFAULT_CHANNEL}.jsonl`);
77
+ assert.ok(existsSync(logFile));
78
+ const text = readFileSync(logFile, 'utf8');
79
+ assert.equal(text.trim().split('\n').length, 1);
80
+ });
81
+
82
+ test('publish: per-task channels are auto-created on first publish', () => {
83
+ const id = publish({
84
+ from: 'agent://odin',
85
+ to: 'agent://thor',
86
+ channel: 'task-tsk_abc',
87
+ taskId: 'tsk_abc',
88
+ kind: 'request',
89
+ payload: { x: 1 },
90
+ });
91
+ assert.ok(id);
92
+ assert.ok(existsSync(join(AGENT_BUS_LOG_DIR, 'task-tsk_abc.jsonl')));
93
+ });
94
+
95
+ test('steer + correct + handoff: convenience wrappers set kind and payload', () => {
96
+ const seen = [];
97
+ const unsub = subscribe((m) => seen.push(m));
98
+ steer('agent://odin', 'agent://thor', 'focus on tests', { channel: 'task-tsk_x', taskId: 'tsk_x' });
99
+ correct('agent://odin', 'agent://thor', 'use new endpoint', { before: 'a()', after: 'b()', reason: 'simpler' }, { taskId: 'tsk_x' });
100
+ handoff('agent://odin', 'agent://thor', 'tsk_x', 'better fit', { channel: 'task-tsk_x' });
101
+ unsub();
102
+ assert.equal(seen.length, 3);
103
+ assert.equal(seen[0].kind, 'steer');
104
+ assert.equal(seen[0].payload.note, 'focus on tests');
105
+ assert.equal(seen[1].kind, 'correct');
106
+ assert.equal(seen[1].payload.before, 'a()');
107
+ assert.equal(seen[1].payload.after, 'b()');
108
+ assert.equal(seen[2].kind, 'handoff');
109
+ assert.equal(seen[2].payload.taskId, 'tsk_x');
110
+ });
111
+
112
+ test('request/respond: correlation roundtrip resolves the Promise', async () => {
113
+ const unsub = subscribe((msg) => {
114
+ if (msg.kind === 'request') {
115
+ respond(msg, 'agent://thor', { ok: true, value: 42 });
116
+ }
117
+ });
118
+ const reply = await request('agent://odin', 'agent://thor', { ask: 'meaning' }, { timeoutMs: 2000 });
119
+ unsub();
120
+ assert.equal(reply.kind, 'response');
121
+ assert.equal(reply.from, 'agent://thor');
122
+ assert.equal(reply.payload.value, 42);
123
+ assert.equal(reply.correlationId.length > 0, true);
124
+ });
125
+
126
+ test('request: timeout rejects with TIMEOUT code', async () => {
127
+ await assert.rejects(
128
+ request('agent://odin', 'agent://nobody', { ask: 'nothing' }, { timeoutMs: 50 }),
129
+ (err) => err.code === 'TIMEOUT',
130
+ );
131
+ });
132
+
133
+ test('subscribeChannel: only fires for the named channel', () => {
134
+ const target = [];
135
+ const other = [];
136
+ const u1 = subscribeChannel('task-tsk_y', (m) => target.push(m));
137
+ const u2 = subscribeChannel('task-tsk_z', (m) => other.push(m));
138
+ publish({ from: 'agent://odin', to: 'agent://thor', channel: 'task-tsk_y', kind: 'request', payload: {} });
139
+ publish({ from: 'agent://odin', to: 'agent://thor', channel: 'task-tsk_z', kind: 'request', payload: {} });
140
+ u1(); u2();
141
+ assert.equal(target.length, 1);
142
+ assert.equal(other.length, 1);
143
+ });
144
+
145
+ test('readChannel: skips corrupt JSONL lines and respects limit', () => {
146
+ const logFile = join(AGENT_BUS_LOG_DIR, 'corrupt.jsonl');
147
+ mkdirSync(AGENT_BUS_LOG_DIR, { recursive: true });
148
+ const good = { id: 'a', from: 'agent://odin', to: 'agent://thor', channel: 'corrupt', kind: 'request', payload: {}, ts: new Date().toISOString() };
149
+ appendFileSync(logFile, JSON.stringify(good) + '\n');
150
+ appendFileSync(logFile, 'this is not json\n');
151
+ appendFileSync(logFile, JSON.stringify({ ...good, id: 'b' }) + '\n');
152
+ const out = readChannel('corrupt');
153
+ assert.equal(out.length, 2);
154
+ assert.equal(out[0].id, 'a');
155
+ assert.equal(out[1].id, 'b');
156
+ });
157
+
158
+ test('readChannel: limit truncates to the last N', () => {
159
+ for (let i = 0; i < 5; i++) {
160
+ publish({ from: 'agent://odin', to: 'agent://thor', channel: 'limit', kind: 'request', payload: { i } });
161
+ }
162
+ const out = readChannel('limit', { limit: 2 });
163
+ assert.equal(out.length, 2);
164
+ assert.equal(out[0].payload.i, 3);
165
+ assert.equal(out[1].payload.i, 4);
166
+ });
167
+
168
+ test('readChannel: since filters out messages at or before the cutoff', async () => {
169
+ publish({ from: 'agent://odin', to: 'agent://thor', channel: 'since', kind: 'request', payload: { i: 1 } });
170
+ // Guarantee a strictly-monotonic clock between msg 1 and the cutoff.
171
+ await new Promise((r) => setTimeout(r, 5));
172
+ const cutoff = new Date().toISOString();
173
+ await new Promise((r) => setTimeout(r, 5));
174
+ publish({ from: 'agent://odin', to: 'agent://thor', channel: 'since', kind: 'request', payload: { i: 2 } });
175
+ const out = readChannel('since', { since: cutoff });
176
+ assert.equal(out.length, 1);
177
+ assert.equal(out[0].payload.i, 2);
178
+ });
179
+
180
+ test('presence: announce + heartbeat + getPresence roundtrip', () => {
181
+ announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan'], model: 'opus' });
182
+ announce({ name: 'thor', sessionId: 'sess_2', currentTask: 'tsk_1' });
183
+ const p = getPresence();
184
+ assert.ok(p.odin);
185
+ assert.equal(p.odin.sessionId, 'sess_1');
186
+ assert.deepEqual(p.odin.capabilities, ['plan']);
187
+ assert.ok(p.thor);
188
+ heartbeat('odin', 'sess_1');
189
+ heartbeat('thor', 'sess_1'); // wrong sessionId — no-op
190
+ assert.equal(getPresence().odin.sessionId, 'sess_1');
191
+ });
192
+
193
+ test('presence: leave removes and persists', () => {
194
+ announce({ name: 'odin', sessionId: 'sess_1' });
195
+ assert.ok(getPresence().odin);
196
+ leave('odin');
197
+ assert.equal(getPresence().odin, undefined);
198
+ // Persistence: presence.json on disk reflects the leave.
199
+ const persisted = JSON.parse(readFileSync(AGENT_BUS_PRESENCE_FILE, 'utf8'));
200
+ assert.equal(persisted.odin, undefined);
201
+ });
202
+
203
+ test('presence: announce with same name+sessionId updates lastSeenAt in-place', () => {
204
+ announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan'] });
205
+ const first = getPresence().odin.lastSeenAt;
206
+ // Wait 5ms so the timestamp moves.
207
+ const wait = Date.now() + 5;
208
+ while (Date.now() < wait) { /* spin */ }
209
+ announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan', 'impl'] });
210
+ const second = getPresence().odin;
211
+ assert.equal(second.sessionId, 'sess_1');
212
+ assert.notEqual(second.lastSeenAt, first);
213
+ });
214
+
215
+ test('presence: getPresence filters stale entries older than 5 minutes', () => {
216
+ announce({ name: 'fresh', sessionId: 's' });
217
+ announce({ name: 'stale', sessionId: 's' });
218
+ // Backdate stale manually via the in-memory map.
219
+ // We don't have a handle to it, so we use leave+reannounce with a
220
+ // forged timestamp persisted through read-modify-write.
221
+ const persisted = JSON.parse(readFileSync(AGENT_BUS_PRESENCE_FILE, 'utf8'));
222
+ persisted.stale.lastSeenAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
223
+ // _resetForTests wipes the in-memory map; we leave the file as-is
224
+ // and call announce again so in-memory state matches.
225
+ writeFileSync(AGENT_BUS_PRESENCE_FILE + '.tmp', JSON.stringify(persisted));
226
+ renameSync(AGENT_BUS_PRESENCE_FILE + '.tmp', AGENT_BUS_PRESENCE_FILE);
227
+ _resetForTests();
228
+ announce({ name: 'fresh', sessionId: 's' });
229
+ // `stale` is no longer in-memory, so getPresence won't see it —
230
+ // this is correct. The persistence-level staleness check would
231
+ // only matter on cold start. We assert the in-memory filter:
232
+ const p = getPresence();
233
+ assert.ok(p.fresh);
234
+ assert.equal(p.stale, undefined);
235
+ });
236
+
237
+ test('resolveAddress: loose address returns live sessions; targeted requires matching sid', () => {
238
+ announce({ name: 'odin', sessionId: 'sess_a' });
239
+ announce({ name: 'thor', sessionId: 'sess_b' });
240
+ assert.equal(resolveAddress('agent://odin').length, 1);
241
+ assert.equal(resolveAddress('agent://odin')[0].sessionId, 'sess_a');
242
+ assert.equal(resolveAddress('agent://odin/sess_a').length, 1);
243
+ assert.equal(resolveAddress('agent://odin/wrong-sid').length, 0);
244
+ assert.equal(resolveAddress('agent://nobody').length, 0);
245
+ assert.equal(resolveAddress('not-an-address').length, 0);
246
+ });
247
+
248
+ test('publish: empty payload becomes {}', () => {
249
+ const id = publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
250
+ const log = readChannel(DEFAULT_CHANNEL);
251
+ const found = log.find((m) => m.id === id);
252
+ assert.ok(found);
253
+ assert.deepEqual(found.payload, {});
254
+ });
255
+
256
+ test('publish: correlationId and taskId survive the roundtrip', () => {
257
+ publish({
258
+ from: 'agent://odin',
259
+ to: 'agent://thor',
260
+ channel: 'task-tsk_q',
261
+ taskId: 'tsk_q',
262
+ kind: 'request',
263
+ payload: {},
264
+ });
265
+ const log = readChannel('task-tsk_q');
266
+ assert.equal(log[0].taskId, 'tsk_q');
267
+ assert.equal(log[0].correlationId, null);
268
+ });
269
+
270
+ test('subscribe: returns an unsubscribe function that detaches the listener', () => {
271
+ let count = 0;
272
+ const unsub = subscribe(() => count++);
273
+ publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
274
+ publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
275
+ assert.equal(count, 2);
276
+ unsub();
277
+ publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
278
+ assert.equal(count, 2);
279
+ });
280
+
281
+ test('subscribe: rejects non-function listener', () => {
282
+ assert.throws(() => subscribe('not a fn'), TypeError);
283
+ assert.throws(() => subscribe(null), TypeError);
284
+ });
285
+
286
+ test('announce: rejects missing name or sessionId', () => {
287
+ assert.throws(() => announce({ sessionId: 's' }), TypeError);
288
+ assert.throws(() => announce({ name: 'x' }), TypeError);
289
+ });