@mindrian_os/install 1.13.0-beta.14 → 1.13.0-beta.17

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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/commands/file-meeting.md +2 -0
  4. package/commands/grade.md +2 -0
  5. package/commands/mva-brief.md +56 -0
  6. package/commands/mva-option.md +89 -0
  7. package/commands/new-project.md +2 -0
  8. package/commands/onboard.md +2 -0
  9. package/hooks/hooks.json +9 -0
  10. package/lib/agents/mva/brain-classic-traps.cjs +77 -0
  11. package/lib/agents/mva/brain-cross-domain.cjs +79 -0
  12. package/lib/agents/mva/brain-similar-ventures.cjs +93 -0
  13. package/lib/agents/mva/dashboard-graph-neighborhood.cjs +72 -0
  14. package/lib/agents/mva/index.cjs +42 -0
  15. package/lib/agents/mva/six-hats-red-black.cjs +137 -0
  16. package/lib/agents/mva/tavily-funding-scan.cjs +147 -0
  17. package/lib/agents/mva/test-all-six-agents.cjs +467 -0
  18. package/lib/conversation/operator.cjs +64 -0
  19. package/lib/conversation/operator.test.cjs +160 -0
  20. package/lib/core/cache-prune.cjs +114 -8
  21. package/lib/core/install-state.cjs +242 -0
  22. package/lib/core/mva-agent-contract.cjs +170 -0
  23. package/lib/core/mva-agent-contract.test.cjs +169 -0
  24. package/lib/core/mva-budget.cjs +75 -0
  25. package/lib/core/mva-budget.test.cjs +68 -0
  26. package/lib/core/mva-classifier.cjs +370 -0
  27. package/lib/core/mva-classifier.test.cjs +248 -0
  28. package/lib/core/mva-deck-builder.cjs +452 -0
  29. package/lib/core/mva-deck-builder.test.cjs +287 -0
  30. package/lib/core/mva-detect.smoke.test.cjs +197 -0
  31. package/lib/core/mva-dispatcher.cjs +110 -0
  32. package/lib/core/mva-dispatcher.test.cjs +216 -0
  33. package/lib/core/mva-option-router.cjs +292 -0
  34. package/lib/core/mva-option-router.test.cjs +483 -0
  35. package/lib/core/mva-orchestrator.cjs +324 -0
  36. package/lib/core/mva-orchestrator.test.cjs +908 -0
  37. package/lib/core/mva-progressive-renderer.cjs +194 -0
  38. package/lib/core/mva-progressive-renderer.test.cjs +157 -0
  39. package/lib/core/mva-rule-linter.cjs +213 -0
  40. package/lib/core/mva-rule-linter.test.cjs +336 -0
  41. package/lib/core/mva-state.cjs +159 -0
  42. package/lib/core/mva-telemetry.cjs +170 -0
  43. package/lib/core/mva-telemetry.test.cjs +196 -0
  44. package/lib/core/mva-vercel-deploy.cjs +168 -0
  45. package/lib/core/mva-vercel-deploy.test.cjs +239 -0
  46. package/lib/core/navigation/dashboard-helpers.cjs +145 -0
  47. package/lib/core/navigation.cjs +11 -0
  48. package/lib/core/resolve-vercel-key.cjs +107 -0
  49. package/lib/core/resolve-vercel-key.test.cjs +137 -0
  50. package/lib/memory/run-feynman-tests.cjs +27 -0
  51. package/package.json +1 -1
  52. package/skills/mva-pipeline/SKILL.md +129 -0
@@ -0,0 +1,467 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 118 Plan 02 -- aggregate test runner for the 6 MVA agents.
4
+ *
5
+ * Tests 1-6: Task 1 (the 3 Brain agents: brain-similar-ventures,
6
+ * brain-cross-domain, brain-classic-traps)
7
+ * Tests 7-12: Task 2 (tavily-funding-scan + six-hats-red-black)
8
+ * Tests 13-17: Task 3 (dashboard-graph-neighborhood + index.cjs aggregator
9
+ * + end-to-end mock dispatch)
10
+ *
11
+ * Plus static grep tests for Canon Part 8 + Canon Part 9 invariants.
12
+ *
13
+ * All agents conform to the Agent contract from Plan 118-01:
14
+ * async function agent(context, signal) -> { status:'ok'|'empty', payload } | null
15
+ *
16
+ * Mocks are installed via require-cache monkey-patch (precedent:
17
+ * lib/memory/run-feynman-tests.cjs and lib/memory/brain-derive-command.test.cjs).
18
+ *
19
+ * Canon Part 8 invariant: this test file asserts that no agent reads
20
+ * MVA_SENTENCE or passes sentence_sha256 as a query body to Brain / Tavily.
21
+ */
22
+ const test = require('node:test');
23
+ const assert = require('node:assert');
24
+ const path = require('node:path');
25
+ const fs = require('node:fs');
26
+
27
+ const AGENT_DIR = __dirname;
28
+
29
+ // ---------- helpers ----------
30
+
31
+ function makeSignal(aborted) {
32
+ const ctl = new AbortController();
33
+ if (aborted) ctl.abort();
34
+ return ctl.signal;
35
+ }
36
+
37
+ function freshRequire(modPath) {
38
+ delete require.cache[require.resolve(modPath)];
39
+ return require(modPath);
40
+ }
41
+
42
+ /**
43
+ * Install a mock for the brain-client module before the agent under test
44
+ * require()s it. We resolve the brain-client.cjs path the agent will resolve,
45
+ * delete it from cache, install the mock, then freshRequire the agent.
46
+ *
47
+ * Returns the mock object so the test can inspect callTool/query/search args.
48
+ */
49
+ function installBrainMock(behavior) {
50
+ const brainPath = require.resolve(path.join(AGENT_DIR, '..', '..', 'core', 'brain-client.cjs'));
51
+ const mock = {
52
+ _calls: [],
53
+ isAvailable: () => behavior.available !== false,
54
+ query: async (cypher, params) => {
55
+ mock._calls.push({ tool: 'query', cypher, params });
56
+ if (behavior.queryResult !== undefined) return behavior.queryResult;
57
+ return { records: [] };
58
+ },
59
+ search: async (queryText, opts) => {
60
+ mock._calls.push({ tool: 'search', queryText, opts });
61
+ if (behavior.searchResult !== undefined) return behavior.searchResult;
62
+ return { results: [] };
63
+ },
64
+ callTool: async (toolName, args) => {
65
+ mock._calls.push({ tool: 'callTool', toolName, args });
66
+ if (behavior.callToolResult !== undefined) return behavior.callToolResult;
67
+ return null;
68
+ },
69
+ };
70
+ require.cache[brainPath] = {
71
+ id: brainPath,
72
+ filename: brainPath,
73
+ loaded: true,
74
+ exports: mock,
75
+ };
76
+ return mock;
77
+ }
78
+
79
+ function uninstallBrainMock() {
80
+ const brainPath = require.resolve(path.join(AGENT_DIR, '..', '..', 'core', 'brain-client.cjs'));
81
+ delete require.cache[brainPath];
82
+ }
83
+
84
+ // =========================================================================
85
+ // Task 1: The 3 Brain agents
86
+ // =========================================================================
87
+
88
+ test('Test 1: brain-similar-ventures returns 3 ventures with summary line', async () => {
89
+ installBrainMock({
90
+ available: true,
91
+ searchResult: {
92
+ results: [
93
+ { name: 'Alpha Co', status: 'active' },
94
+ { name: 'Beta Co', status: 'pivoted' },
95
+ { name: 'Gamma Co', status: 'active' },
96
+ ],
97
+ },
98
+ });
99
+ const agent = freshRequire(path.join(AGENT_DIR, 'brain-similar-ventures.cjs'));
100
+ const res = await agent.run(
101
+ { sentence_sha256: 'abc123', remaining_budget_ms: 30000 },
102
+ makeSignal(false)
103
+ );
104
+ uninstallBrainMock();
105
+ assert.equal(res.status, 'ok');
106
+ assert.match(res.payload.summary_line, /Found 3 ventures/);
107
+ assert.equal(res.payload.deck_data.ventures.length, 3);
108
+ });
109
+
110
+ test('Test 2: brain-cross-domain returns 1 analogy with Cross-domain summary', async () => {
111
+ installBrainMock({
112
+ available: true,
113
+ searchResult: {
114
+ results: [
115
+ { name: 'Bridge Pattern', signature: 'separate interface from implementation' },
116
+ ],
117
+ },
118
+ });
119
+ const agent = freshRequire(path.join(AGENT_DIR, 'brain-cross-domain.cjs'));
120
+ const res = await agent.run(
121
+ { sentence_sha256: 'abc123', remaining_budget_ms: 30000 },
122
+ makeSignal(false)
123
+ );
124
+ uninstallBrainMock();
125
+ assert.equal(res.status, 'ok');
126
+ assert.match(res.payload.summary_line, /^Cross-domain analogy:/);
127
+ assert.equal(res.payload.deck_data.analogy.name, 'Bridge Pattern');
128
+ });
129
+
130
+ test('Test 3: brain-classic-traps returns 1 trap with Classic trap summary', async () => {
131
+ installBrainMock({
132
+ available: true,
133
+ queryResult: {
134
+ records: [
135
+ { name: 'Premature Optimization', signature: 'building features before market validation' },
136
+ ],
137
+ },
138
+ });
139
+ const agent = freshRequire(path.join(AGENT_DIR, 'brain-classic-traps.cjs'));
140
+ const res = await agent.run(
141
+ { sentence_sha256: 'abc123', remaining_budget_ms: 30000 },
142
+ makeSignal(false)
143
+ );
144
+ uninstallBrainMock();
145
+ assert.equal(res.status, 'ok');
146
+ assert.match(res.payload.summary_line, /^Classic trap:/);
147
+ assert.equal(res.payload.deck_data.trap.name, 'Premature Optimization');
148
+ });
149
+
150
+ test('Test 4: all 3 Brain agents return status=empty in <100ms when isAvailable() is false', async () => {
151
+ const agents = ['brain-similar-ventures.cjs', 'brain-cross-domain.cjs', 'brain-classic-traps.cjs'];
152
+ for (const file of agents) {
153
+ installBrainMock({ available: false });
154
+ const agent = freshRequire(path.join(AGENT_DIR, file));
155
+ const t0 = Date.now();
156
+ const res = await agent.run(
157
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
158
+ makeSignal(false)
159
+ );
160
+ const dt = Date.now() - t0;
161
+ uninstallBrainMock();
162
+ assert.equal(res.status, 'empty', file + ' should be empty');
163
+ assert.equal(res.payload.reason, 'brain_unavailable', file + ' reason should be brain_unavailable');
164
+ assert.ok(dt < 100, file + ' took ' + dt + 'ms (must be <100ms)');
165
+ }
166
+ });
167
+
168
+ test('Test 5: all 3 Brain agents observe AbortSignal mid-flight', async () => {
169
+ const agents = ['brain-similar-ventures.cjs', 'brain-cross-domain.cjs', 'brain-classic-traps.cjs'];
170
+ for (const file of agents) {
171
+ installBrainMock({ available: true, searchResult: { results: [{ name: 'X' }] }, queryResult: { records: [{ name: 'X' }] } });
172
+ const agent = freshRequire(path.join(AGENT_DIR, file));
173
+ const res = await agent.run(
174
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
175
+ makeSignal(true) // signal pre-aborted
176
+ );
177
+ uninstallBrainMock();
178
+ // Agent must return null OR an empty/ok shape; what it MUST NOT do is throw.
179
+ assert.ok(res === null || (res && typeof res === 'object'), file + ' must not throw when signal aborted');
180
+ }
181
+ });
182
+
183
+ test('Test 6 [Canon Part 8 grep regression]: no raw sentence or sha256 in Brain query body', () => {
184
+ const sources = ['brain-similar-ventures.cjs', 'brain-cross-domain.cjs', 'brain-classic-traps.cjs'];
185
+ for (const src of sources) {
186
+ const code = fs.readFileSync(path.join(AGENT_DIR, src), 'utf8');
187
+ assert.equal(code.match(/MVA_SENTENCE/), null, src + ' must not read MVA_SENTENCE');
188
+ assert.equal(code.match(/process\.env\.CLAUDE_USER_PROMPT/), null, src + ' must not read CLAUDE_USER_PROMPT');
189
+ // Forbid passing sentence_sha256 as a query body or cypher param value
190
+ assert.equal(code.match(/search\([^)]*sentence_sha256/), null, src + ' must not pass sentence_sha256 to search()');
191
+ assert.equal(code.match(/query\([^)]*sentence_sha256/), null, src + ' must not pass sentence_sha256 to query()');
192
+ // Forbid writing to stdout (telemetry side-channel rule)
193
+ assert.equal(code.match(/process\.stdout|console\.log/), null, src + ' must not write to stdout');
194
+ }
195
+ });
196
+
197
+ // =========================================================================
198
+ // Task 2: Tavily funding-scan + Six-hats red/black
199
+ // =========================================================================
200
+
201
+ test('Test 7: tavily-funding-scan returns status=empty in <50ms when key absent', async () => {
202
+ // Ensure no key present in env nor file (test runs hermetic via env override)
203
+ const prevKey = process.env.TAVILY_API_KEY;
204
+ const prevHome = process.env.HOME;
205
+ delete process.env.TAVILY_API_KEY;
206
+ // Point HOME to a scratch dir that has no .mindrian.env so the file path lookup fails
207
+ const tmpHome = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'mva-tav-'));
208
+ process.env.HOME = tmpHome;
209
+ try {
210
+ const agent = freshRequire(path.join(AGENT_DIR, 'tavily-funding-scan.cjs'));
211
+ const t0 = Date.now();
212
+ const res = await agent.run(
213
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
214
+ makeSignal(false)
215
+ );
216
+ const dt = Date.now() - t0;
217
+ assert.equal(res.status, 'empty');
218
+ assert.equal(res.payload.reason, 'tavily_unavailable');
219
+ assert.ok(dt < 50, 'tavily empty took ' + dt + 'ms (must be <50ms)');
220
+ } finally {
221
+ if (prevKey !== undefined) process.env.TAVILY_API_KEY = prevKey;
222
+ if (prevHome !== undefined) process.env.HOME = prevHome;
223
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch (_e) {}
224
+ }
225
+ });
226
+
227
+ test('Test 8: tavily-funding-scan returns ok when key set and Tavily returns 1 result', async () => {
228
+ const prevKey = process.env.TAVILY_API_KEY;
229
+ const prevFetch = global.fetch;
230
+ process.env.TAVILY_API_KEY = 'tvly-test-key-12345';
231
+ global.fetch = async (url, opts) => {
232
+ assert.ok(String(url).startsWith('https://api.tavily.com'), 'must call Tavily API');
233
+ return {
234
+ ok: true,
235
+ json: async () => ({
236
+ results: [
237
+ {
238
+ title: 'NSF Innovation Corps Funding Track 2026',
239
+ url: 'https://example.com/nsf-icorps',
240
+ snippet: 'NSF I-Corps grants up to 50000 USD for pre-seed venture validation; deadline Q3 2026.',
241
+ },
242
+ ],
243
+ }),
244
+ };
245
+ };
246
+ try {
247
+ const agent = freshRequire(path.join(AGENT_DIR, 'tavily-funding-scan.cjs'));
248
+ const res = await agent.run(
249
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
250
+ makeSignal(false)
251
+ );
252
+ assert.equal(res.status, 'ok');
253
+ assert.match(res.payload.summary_line, /^Live funding match:/);
254
+ assert.ok(Array.isArray(res.payload.deck_data.funding));
255
+ assert.ok(res.payload.deck_data.funding.length >= 1);
256
+ } finally {
257
+ if (prevKey === undefined) delete process.env.TAVILY_API_KEY;
258
+ else process.env.TAVILY_API_KEY = prevKey;
259
+ global.fetch = prevFetch;
260
+ }
261
+ });
262
+
263
+ test('Test 9: tavily-funding-scan returns null when signal pre-aborted', async () => {
264
+ const prevKey = process.env.TAVILY_API_KEY;
265
+ process.env.TAVILY_API_KEY = 'tvly-test-key-12345';
266
+ const prevFetch = global.fetch;
267
+ global.fetch = async () => { throw new Error('should not be called'); };
268
+ try {
269
+ const agent = freshRequire(path.join(AGENT_DIR, 'tavily-funding-scan.cjs'));
270
+ const res = await agent.run(
271
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
272
+ makeSignal(true)
273
+ );
274
+ assert.ok(res === null || (res && res.status === 'empty'), 'must short-circuit on aborted signal');
275
+ } finally {
276
+ if (prevKey === undefined) delete process.env.TAVILY_API_KEY;
277
+ else process.env.TAVILY_API_KEY = prevKey;
278
+ global.fetch = prevFetch;
279
+ }
280
+ });
281
+
282
+ test('Test 10 [Canon Part 8]: tavily-funding-scan query is hardcoded generic string', () => {
283
+ const code = fs.readFileSync(path.join(AGENT_DIR, 'tavily-funding-scan.cjs'), 'utf8');
284
+ assert.equal(code.match(/MVA_SENTENCE/), null, 'must not read MVA_SENTENCE');
285
+ assert.equal(code.match(/process\.env\.CLAUDE_USER_PROMPT/), null, 'must not read CLAUDE_USER_PROMPT');
286
+ // The fetch body must not interpolate sentence_sha256
287
+ assert.equal(code.match(/JSON\.stringify\([^)]*sentence_sha256/), null, 'must not put sha256 in fetch body');
288
+ // The Tavily query must be a hardcoded string literal (NOT derived from context)
289
+ assert.match(code, /const query = "/, 'tavily query must be hardcoded string literal');
290
+ // No stdout writes
291
+ assert.equal(code.match(/process\.stdout|console\.log/), null, 'no stdout writes');
292
+ });
293
+
294
+ test('Test 11: six-hats-red-black is deterministic per sentence_sha256', async () => {
295
+ const agent = freshRequire(path.join(AGENT_DIR, 'six-hats-red-black.cjs'));
296
+ const r1 = await agent.run(
297
+ { sentence_sha256: 'aaaa1111bbbb2222cccc3333dddd4444', remaining_budget_ms: 30000 },
298
+ makeSignal(false)
299
+ );
300
+ const r2 = await agent.run(
301
+ { sentence_sha256: 'aaaa1111bbbb2222cccc3333dddd4444', remaining_budget_ms: 30000 },
302
+ makeSignal(false)
303
+ );
304
+ const r3 = await agent.run(
305
+ { sentence_sha256: 'ffffeeeeddddccccbbbb1111000099998', remaining_budget_ms: 30000 },
306
+ makeSignal(false)
307
+ );
308
+ assert.equal(r1.status, 'ok');
309
+ assert.match(r1.payload.summary_line, /^One question you haven't asked yourself:/);
310
+ // Same hash -> same output
311
+ assert.deepEqual(r1.payload, r2.payload, 'same hash must produce identical payload');
312
+ // Different hash -> different output (registry has 12 entries so collisions exist for short tests; assert at least one of red_flag/black_flag/summary differs)
313
+ const differs =
314
+ r1.payload.summary_line !== r3.payload.summary_line ||
315
+ r1.payload.deck_data.red_flag !== r3.payload.deck_data.red_flag ||
316
+ r1.payload.deck_data.black_flag !== r3.payload.deck_data.black_flag;
317
+ assert.ok(differs, 'different sha256 should yield different question pair');
318
+ });
319
+
320
+ test('Test 12: six-hats-red-black never makes a network call', async () => {
321
+ let fetchCalled = false;
322
+ const prevFetch = global.fetch;
323
+ global.fetch = async () => { fetchCalled = true; throw new Error('forbidden'); };
324
+ try {
325
+ const agent = freshRequire(path.join(AGENT_DIR, 'six-hats-red-black.cjs'));
326
+ await agent.run({ sentence_sha256: 'cafebabe', remaining_budget_ms: 30000 }, makeSignal(false));
327
+ } finally {
328
+ global.fetch = prevFetch;
329
+ }
330
+ assert.equal(fetchCalled, false, 'six-hats must not call fetch');
331
+ });
332
+
333
+ // =========================================================================
334
+ // Task 3: Dashboard graph-neighborhood + index aggregator + end-to-end
335
+ // =========================================================================
336
+
337
+ test('Test 13: dashboard-graph-neighborhood returns ok with 5-node neighborhood (mocked navigation)', async () => {
338
+ const navPath = require.resolve(path.join(AGENT_DIR, '..', '..', 'core', 'navigation.cjs'));
339
+ const realNav = require(navPath);
340
+ // Monkey-patch the cached module exports
341
+ const mockNav = {
342
+ detectActiveRoom: () => ({ roomDir: '/tmp/fake-room', hasRoomDb: true }),
343
+ getRecentDecisionNeighborhood: () => ({
344
+ nodes: [
345
+ { id: 'n1', type: 'decision' },
346
+ { id: 'n2', type: 'decision' },
347
+ { id: 'n3', type: 'decision' },
348
+ { id: 'n4', type: 'decision' },
349
+ { id: 'n5', type: 'decision' },
350
+ ],
351
+ edges: [],
352
+ }),
353
+ };
354
+ require.cache[navPath] = {
355
+ id: navPath,
356
+ filename: navPath,
357
+ loaded: true,
358
+ exports: mockNav,
359
+ };
360
+ try {
361
+ const agent = freshRequire(path.join(AGENT_DIR, 'dashboard-graph-neighborhood.cjs'));
362
+ const res = await agent.run(
363
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
364
+ makeSignal(false)
365
+ );
366
+ assert.equal(res.status, 'ok');
367
+ assert.match(res.payload.summary_line, /5 related decision nodes/);
368
+ assert.equal(res.payload.deck_data.nodes.length, 5);
369
+ } finally {
370
+ require.cache[navPath] = { id: navPath, filename: navPath, loaded: true, exports: realNav };
371
+ }
372
+ });
373
+
374
+ test('Test 14: dashboard-graph-neighborhood returns empty/no_active_room when no active room', async () => {
375
+ const navPath = require.resolve(path.join(AGENT_DIR, '..', '..', 'core', 'navigation.cjs'));
376
+ const realNav = require(navPath);
377
+ const mockNav = {
378
+ detectActiveRoom: () => null,
379
+ };
380
+ require.cache[navPath] = { id: navPath, filename: navPath, loaded: true, exports: mockNav };
381
+ try {
382
+ const agent = freshRequire(path.join(AGENT_DIR, 'dashboard-graph-neighborhood.cjs'));
383
+ const res = await agent.run(
384
+ { sentence_sha256: 'abc', remaining_budget_ms: 30000 },
385
+ makeSignal(false)
386
+ );
387
+ assert.equal(res.status, 'empty');
388
+ assert.equal(res.payload.reason, 'no_active_room');
389
+ } finally {
390
+ require.cache[navPath] = { id: navPath, filename: navPath, loaded: true, exports: realNav };
391
+ }
392
+ });
393
+
394
+ test('Test 15 [Canon Part 9 grep regression]: dashboard agent uses navigation.cjs exclusively', () => {
395
+ const code = fs.readFileSync(path.join(AGENT_DIR, 'dashboard-graph-neighborhood.cjs'), 'utf8');
396
+ assert.equal(code.match(/require\([^)]*room-db/), null, 'must not require room-db directly');
397
+ assert.equal(code.match(/require\(['"]better-sqlite3['"]\)/), null, 'must not require sqlite3 directly');
398
+ assert.equal(code.match(/require\(['"]node:sqlite['"]\)/), null, 'must not require node:sqlite directly');
399
+ assert.match(code, /require\([^)]*navigation\.cjs/, 'must require navigation.cjs');
400
+ assert.equal(code.match(/MVA_SENTENCE/), null, 'must not read MVA_SENTENCE');
401
+ assert.equal(code.match(/process\.stdout|console\.log/), null, 'no stdout writes');
402
+ });
403
+
404
+ test('Test 16: lib/agents/mva/index.cjs exports ALL_AGENTS array with 6 entries matching B1 ids', () => {
405
+ const idx = freshRequire(path.join(AGENT_DIR, 'index.cjs'));
406
+ assert.ok(Array.isArray(idx.ALL_AGENTS), 'ALL_AGENTS must be an array');
407
+ assert.equal(idx.ALL_AGENTS.length, 6, 'exactly 6 agents');
408
+ const ids = idx.ALL_AGENTS.map((a) => a.id);
409
+ assert.deepEqual(ids, [
410
+ 'brain_similar',
411
+ 'brain_cross_domain',
412
+ 'brain_classic_traps',
413
+ 'tavily_funding',
414
+ 'six_hats_red_black',
415
+ 'dashboard_graph',
416
+ ]);
417
+ for (const a of idx.ALL_AGENTS) {
418
+ assert.equal(typeof a.fn, 'function', a.id + '.fn must be a function');
419
+ }
420
+ });
421
+
422
+ test('Test 17 [end-to-end mock dispatch]: 6 agents wire to mva-dispatcher and return 6 results <1500ms', async () => {
423
+ // Install brain mock that returns null isAvailable -> all 3 brain agents return empty fast
424
+ installBrainMock({ available: false });
425
+ // Hide Tavily key -> tavily returns empty fast
426
+ const prevKey = process.env.TAVILY_API_KEY;
427
+ delete process.env.TAVILY_API_KEY;
428
+ // Mock navigation.cjs to return no_active_room
429
+ const navPath = require.resolve(path.join(AGENT_DIR, '..', '..', 'core', 'navigation.cjs'));
430
+ const realNav = require(navPath);
431
+ require.cache[navPath] = {
432
+ id: navPath,
433
+ filename: navPath,
434
+ loaded: true,
435
+ exports: { detectActiveRoom: () => null },
436
+ };
437
+ // Hermetic HOME so tavily file-fallback also finds nothing
438
+ const prevHome = process.env.HOME;
439
+ const tmpHome = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'mva-e2e-'));
440
+ process.env.HOME = tmpHome;
441
+ try {
442
+ const { ALL_AGENTS } = freshRequire(path.join(AGENT_DIR, 'index.cjs'));
443
+ const { dispatchToArray } = require(path.join(AGENT_DIR, '..', '..', 'core', 'mva-dispatcher.cjs'));
444
+ const t0 = Date.now();
445
+ const results = await dispatchToArray(ALL_AGENTS, 'aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888', {
446
+ globalBudgetMs: 1500,
447
+ perAgentCapMs: 1000,
448
+ });
449
+ const dt = Date.now() - t0;
450
+ assert.equal(results.length, 6, 'must get 6 results');
451
+ for (const r of results) {
452
+ assert.ok(['ok', 'empty', 'error', 'timeout'].includes(r.status), 'status ' + r.status);
453
+ assert.equal(typeof r.agent_id, 'string');
454
+ assert.ok(r.duration_ms >= 0);
455
+ }
456
+ assert.ok(dt < 1500, 'mock e2e took ' + dt + 'ms (must be <1500ms)');
457
+ // At least six-hats should be ok (deterministic, no deps)
458
+ const sixHats = results.find((r) => r.agent_id === 'six_hats_red_black');
459
+ assert.equal(sixHats.status, 'ok', 'six-hats should always be ok');
460
+ } finally {
461
+ uninstallBrainMock();
462
+ if (prevKey !== undefined) process.env.TAVILY_API_KEY = prevKey;
463
+ require.cache[navPath] = { id: navPath, filename: navPath, loaded: true, exports: realNav };
464
+ if (prevHome !== undefined) process.env.HOME = prevHome;
465
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch (_e) {}
466
+ }
467
+ });
@@ -269,6 +269,68 @@ function transition(roomDir, to, trigger, contextDelta = {}) {
269
269
  return { success: true, current: resolvedTo, previous: from, entered_at: now };
270
270
  }
271
271
 
272
+ // ---------- Phase 118-05 Plan 05 -- MVA option router helper ----------
273
+ //
274
+ // transitionViaMVAOption(roomDir, optionId)
275
+ // -----------------------------------------
276
+ // Thin additive wrapper used by lib/core/mva-option-router.cjs when the user
277
+ // selects 1, 2, or 3 from the 30-second MVA brief footer.
278
+ //
279
+ // Option 1 -> transition to JUST_TALK via the ANY -> JUST_TALK rule with
280
+ // trigger 'manual_reset'. The brief stays in scrollback; Larry
281
+ // keeps the conversation open for follow-ups.
282
+ // Option 2 -> NO-OP. Per binding decision B6 OPTION A, option 2 is a stub
283
+ // for v1.13.0 (Phase 119 will wire the BUILD_ROOM path). The
284
+ // operator state is preserved; the router surfaces STUB_MESSAGE_119.
285
+ // Option 3 -> transition to METHODOLOGY via the ANY -> METHODOLOGY rule with
286
+ // trigger 'mos_command'. The router then invokes
287
+ // /mos:challenge-assumptions --from-brief <sha8>.
288
+ //
289
+ // The 9 existing transition rules are UNTOUCHED. The OPERATORS array is
290
+ // UNTOUCHED. This helper is purely a routing convenience that wraps the
291
+ // existing transition() API.
292
+ //
293
+ // Returns { ok: true, new_state, from } on success, with optional
294
+ // no_transition:true + reason:'option_2_stub' on the option-2 path. Returns
295
+ // { ok: false, error: 'invalid_option', valid_options: [1,2,3] } when optionId
296
+ // is anything other than the strict integers 1, 2, or 3.
297
+ function transitionViaMVAOption(roomDir, optionId) {
298
+ // Strict integer check -- reject strings, null, undefined, floats.
299
+ if (!Number.isInteger(optionId) || ![1, 2, 3].includes(optionId)) {
300
+ return { ok: false, error: 'invalid_option', valid_options: [1, 2, 3] };
301
+ }
302
+ const before = getCurrent(roomDir);
303
+ const fromState = before.current;
304
+
305
+ if (optionId === 1) {
306
+ const result = transition(roomDir, 'JUST_TALK', 'manual_reset');
307
+ if (!result.success) {
308
+ // Already JUST_TALK (no-op): validate() rejects same-from-same-to.
309
+ // Treat as already-in-target-state: ok with new_state=JUST_TALK.
310
+ if (fromState === 'JUST_TALK') {
311
+ return { ok: true, new_state: 'JUST_TALK', from: fromState };
312
+ }
313
+ return { ok: false, error: 'transition_failed', new_state: fromState, from: fromState };
314
+ }
315
+ return { ok: true, new_state: result.current, from: fromState };
316
+ }
317
+
318
+ if (optionId === 2) {
319
+ // Stub: no transition. Phase 119 will wire BUILD_ROOM here.
320
+ return { ok: true, new_state: fromState, no_transition: true, reason: 'option_2_stub' };
321
+ }
322
+
323
+ // optionId === 3
324
+ const result = transition(roomDir, 'METHODOLOGY', 'mos_command');
325
+ if (!result.success) {
326
+ if (fromState === 'METHODOLOGY') {
327
+ return { ok: true, new_state: 'METHODOLOGY', from: fromState };
328
+ }
329
+ return { ok: false, error: 'transition_failed', new_state: fromState, from: fromState };
330
+ }
331
+ return { ok: true, new_state: result.current, from: fromState };
332
+ }
333
+
272
334
  // ---------- Exports ----------
273
335
 
274
336
  module.exports = {
@@ -276,6 +338,8 @@ module.exports = {
276
338
  getCurrent,
277
339
  transition,
278
340
  validate,
341
+ // Phase 118-05 additive surface
342
+ transitionViaMVAOption,
279
343
  // Constants (consumed by 99-02 classifier, 99-04 hooks, 99-05 command)
280
344
  OPERATORS,
281
345
  TRIGGERS,