@mindrian_os/install 1.13.0-beta.16 → 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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +10 -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/mva-agent-contract.cjs +170 -0
  21. package/lib/core/mva-agent-contract.test.cjs +169 -0
  22. package/lib/core/mva-budget.cjs +75 -0
  23. package/lib/core/mva-budget.test.cjs +68 -0
  24. package/lib/core/mva-classifier.cjs +370 -0
  25. package/lib/core/mva-classifier.test.cjs +248 -0
  26. package/lib/core/mva-deck-builder.cjs +452 -0
  27. package/lib/core/mva-deck-builder.test.cjs +287 -0
  28. package/lib/core/mva-detect.smoke.test.cjs +197 -0
  29. package/lib/core/mva-dispatcher.cjs +110 -0
  30. package/lib/core/mva-dispatcher.test.cjs +216 -0
  31. package/lib/core/mva-option-router.cjs +292 -0
  32. package/lib/core/mva-option-router.test.cjs +483 -0
  33. package/lib/core/mva-orchestrator.cjs +324 -0
  34. package/lib/core/mva-orchestrator.test.cjs +908 -0
  35. package/lib/core/mva-progressive-renderer.cjs +194 -0
  36. package/lib/core/mva-progressive-renderer.test.cjs +157 -0
  37. package/lib/core/mva-rule-linter.cjs +213 -0
  38. package/lib/core/mva-rule-linter.test.cjs +336 -0
  39. package/lib/core/mva-state.cjs +159 -0
  40. package/lib/core/mva-telemetry.cjs +170 -0
  41. package/lib/core/mva-telemetry.test.cjs +196 -0
  42. package/lib/core/mva-vercel-deploy.cjs +168 -0
  43. package/lib/core/mva-vercel-deploy.test.cjs +239 -0
  44. package/lib/core/navigation/dashboard-helpers.cjs +145 -0
  45. package/lib/core/navigation.cjs +11 -0
  46. package/lib/core/resolve-vercel-key.cjs +107 -0
  47. package/lib/core/resolve-vercel-key.test.cjs +137 -0
  48. package/lib/memory/run-feynman-tests.cjs +27 -0
  49. package/package.json +1 -1
  50. package/skills/mva-pipeline/SKILL.md +129 -0
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 118-05 Plan 05 -- operator helper tests for transitionViaMVAOption.
5
+ *
6
+ * This is the FIRST test file for lib/conversation/operator.cjs. Plan 118-05
7
+ * Task 1 adds the transitionViaMVAOption(roomDir, optionId) helper -- a thin
8
+ * additive wrapper around the existing transition() function. The helper is
9
+ * called by lib/core/mva-option-router.cjs when the user picks 1, 2, or 3
10
+ * from the 30-second MVA brief footer.
11
+ *
12
+ * The 9 existing transition rules are NOT modified. The OPERATORS array is
13
+ * NOT modified. This is a small additive surface.
14
+ *
15
+ * Option 1 -> JUST_TALK (via 'manual_reset' trigger; existing ANY rule)
16
+ * Option 2 -> NO transition (stub per binding decision B6 OPTION A; Phase 119
17
+ * will wire the BUILD_ROOM path)
18
+ * Option 3 -> METHODOLOGY (via 'mos_command' trigger; existing ANY rule)
19
+ *
20
+ * Pure CJS, node built-ins only.
21
+ */
22
+ 'use strict';
23
+
24
+ const test = require('node:test');
25
+ const assert = require('node:assert/strict');
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const os = require('node:os');
29
+
30
+ const operator = require('./operator.cjs');
31
+
32
+ // Hermetic temp roomDir per test (so we never pollute real rooms).
33
+ function makeTempRoom() {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'op-mva-option-'));
35
+ return dir;
36
+ }
37
+
38
+ function cleanupTempRoom(dir) {
39
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {}
40
+ }
41
+
42
+ // ---------- Task 1: transitionViaMVAOption helper ----------
43
+
44
+ test('Test 1: transitionViaMVAOption(roomDir, 1) -> JUST_TALK', () => {
45
+ const roomDir = makeTempRoom();
46
+ try {
47
+ // Move to a non-JUST_TALK state first so the transition is meaningful.
48
+ // From the default JUST_TALK, step to EXPLORE_CAPTURE.
49
+ const pre = operator.transition(roomDir, 'EXPLORE_CAPTURE', 'user_message');
50
+ assert.equal(pre.success, true);
51
+ assert.equal(operator.getCurrent(roomDir).current, 'EXPLORE_CAPTURE');
52
+
53
+ const result = operator.transitionViaMVAOption(roomDir, 1);
54
+ assert.equal(result.ok, true, 'option 1 should succeed');
55
+ assert.equal(result.new_state, 'JUST_TALK', 'option 1 must end in JUST_TALK');
56
+ assert.equal(result.from, 'EXPLORE_CAPTURE', 'from must be the previous state');
57
+ assert.equal(operator.getCurrent(roomDir).current, 'JUST_TALK');
58
+ } finally {
59
+ cleanupTempRoom(roomDir);
60
+ }
61
+ });
62
+
63
+ test('Test 2: transitionViaMVAOption(roomDir, 2) -> no transition (stub)', () => {
64
+ const roomDir = makeTempRoom();
65
+ try {
66
+ // Move to METHODOLOGY first so we can verify "no transition" preserves it.
67
+ const pre = operator.transition(roomDir, 'METHODOLOGY', 'mos_command');
68
+ assert.equal(pre.success, true);
69
+ assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
70
+
71
+ const result = operator.transitionViaMVAOption(roomDir, 2);
72
+ assert.equal(result.ok, true, 'option 2 returns ok:true (stub but valid)');
73
+ assert.equal(result.no_transition, true, 'option 2 must flag no_transition');
74
+ assert.equal(result.reason, 'option_2_stub', 'reason must be option_2_stub');
75
+ assert.equal(result.new_state, 'METHODOLOGY', 'state unchanged after stub option');
76
+ assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
77
+ } finally {
78
+ cleanupTempRoom(roomDir);
79
+ }
80
+ });
81
+
82
+ test('Test 3: transitionViaMVAOption(roomDir, 3) -> METHODOLOGY', () => {
83
+ const roomDir = makeTempRoom();
84
+ try {
85
+ // From JUST_TALK default, option 3 should jump straight to METHODOLOGY
86
+ // via the ANY -> METHODOLOGY 'mos_command' rule.
87
+ assert.equal(operator.getCurrent(roomDir).current, 'JUST_TALK');
88
+
89
+ const result = operator.transitionViaMVAOption(roomDir, 3);
90
+ assert.equal(result.ok, true, 'option 3 should succeed');
91
+ assert.equal(result.new_state, 'METHODOLOGY', 'option 3 must end in METHODOLOGY');
92
+ assert.equal(result.from, 'JUST_TALK', 'from must be the previous state');
93
+ assert.equal(operator.getCurrent(roomDir).current, 'METHODOLOGY');
94
+ } finally {
95
+ cleanupTempRoom(roomDir);
96
+ }
97
+ });
98
+
99
+ test('Test 4: transitionViaMVAOption(roomDir, 99) -> invalid_option error', () => {
100
+ const roomDir = makeTempRoom();
101
+ try {
102
+ const before = operator.getCurrent(roomDir).current;
103
+ const result = operator.transitionViaMVAOption(roomDir, 99);
104
+ assert.equal(result.ok, false);
105
+ assert.equal(result.error, 'invalid_option');
106
+ assert.deepEqual(result.valid_options, [1, 2, 3]);
107
+ // State unchanged
108
+ assert.equal(operator.getCurrent(roomDir).current, before);
109
+ } finally {
110
+ cleanupTempRoom(roomDir);
111
+ }
112
+ });
113
+
114
+ test('Test 4b: transitionViaMVAOption rejects non-integer optionId', () => {
115
+ const roomDir = makeTempRoom();
116
+ try {
117
+ const r1 = operator.transitionViaMVAOption(roomDir, '1');
118
+ assert.equal(r1.ok, false, 'string "1" is rejected (strict int check)');
119
+ assert.equal(r1.error, 'invalid_option');
120
+
121
+ const r2 = operator.transitionViaMVAOption(roomDir, 0);
122
+ assert.equal(r2.ok, false, '0 is rejected');
123
+
124
+ const r3 = operator.transitionViaMVAOption(roomDir, null);
125
+ assert.equal(r3.ok, false, 'null is rejected');
126
+ } finally {
127
+ cleanupTempRoom(roomDir);
128
+ }
129
+ });
130
+
131
+ // ---------- Task 1 Test 5: no regression on OPERATORS or rules ----------
132
+
133
+ test('Test 5a: OPERATORS array is unchanged (5 entries; canonical order)', () => {
134
+ assert.deepEqual(operator.OPERATORS, [
135
+ 'JUST_TALK',
136
+ 'EXPLORE_CAPTURE',
137
+ 'BUILD_ROOM',
138
+ 'METHODOLOGY',
139
+ 'DECISION_GATE',
140
+ ]);
141
+ });
142
+
143
+ test('Test 5b: TRANSITION_RULES has 9 entries (Phase 99 D-08 invariant preserved)', () => {
144
+ assert.equal(operator.TRANSITION_RULES.length, 9);
145
+ });
146
+
147
+ test('Test 5c: existing transition rules still work (JUST_TALK -> EXPLORE_CAPTURE on user_message)', () => {
148
+ const roomDir = makeTempRoom();
149
+ try {
150
+ const r = operator.transition(roomDir, 'EXPLORE_CAPTURE', 'user_message');
151
+ assert.equal(r.success, true);
152
+ assert.equal(r.current, 'EXPLORE_CAPTURE');
153
+ } finally {
154
+ cleanupTempRoom(roomDir);
155
+ }
156
+ });
157
+
158
+ test('Test 5d: transitionViaMVAOption exported as top-level function', () => {
159
+ assert.equal(typeof operator.transitionViaMVAOption, 'function');
160
+ });
@@ -0,0 +1,170 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 118-01 Plan 01 Task 1 -- mva-agent-contract.
5
+ *
6
+ * The AgentResult shape + the runAgent wrapper used by mva-dispatcher.cjs
7
+ * (Task 3 of this plan). Plan 118-02 implements 6 specific agents against
8
+ * the Agent contract documented below.
9
+ *
10
+ * AgentContext interface
11
+ * ----------------------
12
+ * {
13
+ * sentence_sha256: string, // dispatcher-provided -- ONLY identifier passed to agents
14
+ * remaining_budget_ms: number, // dispatcher-provided
15
+ * }
16
+ *
17
+ * raw_sentence is NEVER exposed to agents (Canon Part 8 hard invariant).
18
+ * Agents that need linguistic features must derive them from neighborhood-graph
19
+ * queries against room.db (sentence-sha8 only), NOT from the raw sentence string.
20
+ *
21
+ * process.env.MVA_SENTENCE is NEVER set. There is no escape hatch.
22
+ *
23
+ * Agent function signature
24
+ * ------------------------
25
+ * async function agent(context, signal) -> { status: 'ok'|'empty', payload } | null
26
+ *
27
+ * If the agent throws, runAgent wraps with status='error'.
28
+ * If the AbortSignal fires before resolve, runAgent wraps with status='timeout'.
29
+ * If the agent returns null, runAgent wraps with status='empty'.
30
+ *
31
+ * Pure CJS, node built-ins only, zero new runtime dependencies.
32
+ * Canon Part 8 invariants:
33
+ * - NEVER serialize stack traces (stack traces can contain user content).
34
+ * - Error messages capped at 200 chars (sliced to prevent user-content blow-through).
35
+ * - The AgentContext object passed to the agent contains ONLY documented keys.
36
+ */
37
+ 'use strict';
38
+
39
+ const ALLOWED_STATUSES = new Set(['ok', 'empty', 'error', 'timeout']);
40
+
41
+ /**
42
+ * AGENT_RESULT_SHAPE -- a frozen object documenting the AgentResult shape.
43
+ *
44
+ * AgentResult = {
45
+ * agent_id: string, // 'brain_similar' | ... | 'dashboard_graph'
46
+ * status: 'ok' | 'empty' | 'error' | 'timeout',
47
+ * duration_ms: number, // wall-clock from runAgent invocation
48
+ * payload?: any, // agent-specific; opaque to dispatcher
49
+ * error?: string, // sanitized message (<= 200 chars)
50
+ * };
51
+ */
52
+ const AGENT_RESULT_SHAPE = Object.freeze({
53
+ agent_id: 'string',
54
+ status: "'ok' | 'empty' | 'error' | 'timeout'",
55
+ duration_ms: 'number >= 0',
56
+ payload: 'optional any',
57
+ error: 'optional string'
58
+ });
59
+
60
+ /**
61
+ * validateAgentResult -- returns true if the result conforms to AgentResult shape.
62
+ *
63
+ * @param {unknown} result
64
+ * @returns {boolean}
65
+ */
66
+ function validateAgentResult(result) {
67
+ if (!result || typeof result !== 'object') return false;
68
+ if (typeof result.agent_id !== 'string' || result.agent_id.length === 0) return false;
69
+ if (typeof result.status !== 'string' || !ALLOWED_STATUSES.has(result.status)) return false;
70
+ if (typeof result.duration_ms !== 'number' || result.duration_ms < 0) return false;
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * Sanitize an error into a short message. Drops stack traces, drops object
76
+ * properties, slices to 200 chars to prevent any user-content blow-through.
77
+ *
78
+ * @param {unknown} err
79
+ * @returns {string}
80
+ */
81
+ function sanitizeError(err) {
82
+ let msg;
83
+ if (err && typeof err === 'object' && typeof err.message === 'string') {
84
+ msg = err.message;
85
+ } else {
86
+ msg = String(err);
87
+ }
88
+ return msg.slice(0, 200);
89
+ }
90
+
91
+ /**
92
+ * runAgent -- invoke an agent function under an AbortController timeout. Catches
93
+ * any throw and converts to a structured AgentResult.
94
+ *
95
+ * @param {{ id: string, fn: Function }} agentDef
96
+ * @param {{ sentence_sha256: string }} context
97
+ * @param {{ timeoutMs?: number }} opts
98
+ * @returns {Promise<{ agent_id: string, status: string, duration_ms: number, payload?: any, error?: string }>}
99
+ */
100
+ async function runAgent(agentDef, context, opts) {
101
+ const timeoutMs = (opts && typeof opts.timeoutMs === 'number') ? opts.timeoutMs : 35000;
102
+ const t0 = Date.now();
103
+ const controller = new AbortController();
104
+
105
+ // Build an AgentContext containing ONLY documented keys. We do not spread
106
+ // arbitrary caller props (defense-in-depth against accidental raw_sentence
107
+ // leakage). Canon Part 8 invariant.
108
+ const agentContext = {
109
+ sentence_sha256: context && context.sentence_sha256,
110
+ remaining_budget_ms: timeoutMs
111
+ };
112
+
113
+ let timer;
114
+ const timeoutPromise = new Promise((_resolve, reject) => {
115
+ timer = setTimeout(() => {
116
+ controller.abort();
117
+ reject(new Error('__mva_dispatcher_timeout__'));
118
+ }, timeoutMs);
119
+ });
120
+
121
+ try {
122
+ const result = await Promise.race([
123
+ Promise.resolve().then(() => agentDef.fn(agentContext, controller.signal)),
124
+ timeoutPromise
125
+ ]);
126
+
127
+ if (timer) clearTimeout(timer);
128
+
129
+ const duration_ms = Date.now() - t0;
130
+
131
+ if (result === null || result === undefined) {
132
+ return { agent_id: agentDef.id, status: 'empty', duration_ms };
133
+ }
134
+
135
+ // Result is { status: 'ok' | 'empty', payload }.
136
+ if (result && typeof result === 'object' && typeof result.status === 'string') {
137
+ if (result.status === 'ok' || result.status === 'empty') {
138
+ return {
139
+ agent_id: agentDef.id,
140
+ status: result.status,
141
+ duration_ms,
142
+ payload: result.payload
143
+ };
144
+ }
145
+ }
146
+
147
+ // Unknown shape -- treat as empty (defense-in-depth; agent contract violation).
148
+ return { agent_id: agentDef.id, status: 'empty', duration_ms };
149
+ } catch (err) {
150
+ if (timer) clearTimeout(timer);
151
+ const duration_ms = Date.now() - t0;
152
+
153
+ if (controller.signal.aborted) {
154
+ return { agent_id: agentDef.id, status: 'timeout', duration_ms };
155
+ }
156
+
157
+ return {
158
+ agent_id: agentDef.id,
159
+ status: 'error',
160
+ duration_ms,
161
+ error: sanitizeError(err)
162
+ };
163
+ }
164
+ }
165
+
166
+ module.exports = {
167
+ runAgent,
168
+ validateAgentResult,
169
+ AGENT_RESULT_SHAPE
170
+ };
@@ -0,0 +1,169 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 118-01 Plan 01 Task 1 -- mva-agent-contract tests.
5
+ *
6
+ * Tests the AgentResult shape, validateAgentResult, and runAgent wrapper.
7
+ * Per Canon Part 8 (Graph Boundary): AgentContext NEVER carries raw sentence;
8
+ * the only sentence-derived field is sentence_sha256. There is no escape hatch
9
+ * via process.env.MVA_SENTENCE.
10
+ *
11
+ * Pure CJS, node built-ins only. Run via `node --test`.
12
+ */
13
+ 'use strict';
14
+
15
+ const test = require('node:test');
16
+ const assert = require('node:assert');
17
+
18
+ const {
19
+ runAgent,
20
+ validateAgentResult,
21
+ AGENT_RESULT_SHAPE
22
+ } = require('./mva-agent-contract.cjs');
23
+
24
+ test('mva-agent-contract: Test 1 -- agent returns ok wraps result', async () => {
25
+ const agentDef = {
26
+ id: 'test',
27
+ fn: async (_ctx, _signal) => ({ status: 'ok', payload: { x: 1 } })
28
+ };
29
+ const result = await runAgent(
30
+ agentDef,
31
+ { sentence_sha256: 'abc123' },
32
+ { timeoutMs: 1000 }
33
+ );
34
+ assert.strictEqual(result.agent_id, 'test');
35
+ assert.strictEqual(result.status, 'ok');
36
+ assert.deepStrictEqual(result.payload, { x: 1 });
37
+ assert.strictEqual(typeof result.duration_ms, 'number');
38
+ assert.ok(result.duration_ms >= 0);
39
+ });
40
+
41
+ test('mva-agent-contract: Test 2 -- agent throws becomes error result', async () => {
42
+ const agentDef = {
43
+ id: 'test',
44
+ fn: async (_ctx, _signal) => { throw new Error('boom'); }
45
+ };
46
+ const result = await runAgent(
47
+ agentDef,
48
+ { sentence_sha256: 'abc123' },
49
+ { timeoutMs: 1000 }
50
+ );
51
+ assert.strictEqual(result.agent_id, 'test');
52
+ assert.strictEqual(result.status, 'error');
53
+ assert.strictEqual(result.error, 'boom');
54
+ assert.ok(!('stack' in result), 'result must never carry stack');
55
+ assert.strictEqual(typeof result.duration_ms, 'number');
56
+ });
57
+
58
+ test('mva-agent-contract: Test 3 -- agent returns null becomes empty', async () => {
59
+ const agentDef = {
60
+ id: 'test',
61
+ fn: async (_ctx, _signal) => null
62
+ };
63
+ const result = await runAgent(
64
+ agentDef,
65
+ { sentence_sha256: 'abc123' },
66
+ { timeoutMs: 1000 }
67
+ );
68
+ assert.strictEqual(result.agent_id, 'test');
69
+ assert.strictEqual(result.status, 'empty');
70
+ assert.strictEqual(typeof result.duration_ms, 'number');
71
+ });
72
+
73
+ test('mva-agent-contract: Test 4 -- agent exceeds timeout returns timeout', async () => {
74
+ const agentDef = {
75
+ id: 'test',
76
+ fn: async (_ctx, signal) => {
77
+ await new Promise((resolve, reject) => {
78
+ const t = setTimeout(resolve, 500);
79
+ signal.addEventListener('abort', () => {
80
+ clearTimeout(t);
81
+ reject(new Error('aborted'));
82
+ });
83
+ });
84
+ return { status: 'ok', payload: {} };
85
+ }
86
+ };
87
+ const t0 = Date.now();
88
+ const result = await runAgent(
89
+ agentDef,
90
+ { sentence_sha256: 'abc123' },
91
+ { timeoutMs: 100 }
92
+ );
93
+ const elapsed = Date.now() - t0;
94
+ assert.strictEqual(result.agent_id, 'test');
95
+ assert.strictEqual(result.status, 'timeout');
96
+ assert.ok(elapsed < 300, `elapsed ${elapsed} must be under 300ms`);
97
+ assert.ok(result.duration_ms >= 90, `duration_ms ${result.duration_ms} should be ~100`);
98
+ });
99
+
100
+ test('mva-agent-contract: Test 5 -- agent receives both args, signal observed', async () => {
101
+ let observedSignalAbortedAtStart = null;
102
+ let observedSignalAbortedAtEnd = null;
103
+ let observedSha = null;
104
+ let observedRemaining = null;
105
+ const agentDef = {
106
+ id: 'test',
107
+ fn: async (ctx, signal) => {
108
+ observedSignalAbortedAtStart = signal.aborted;
109
+ observedSha = ctx.sentence_sha256;
110
+ observedRemaining = ctx.remaining_budget_ms;
111
+ // Wait long enough to be cancelled
112
+ await new Promise((resolve, reject) => {
113
+ const t = setTimeout(resolve, 500);
114
+ signal.addEventListener('abort', () => {
115
+ clearTimeout(t);
116
+ observedSignalAbortedAtEnd = signal.aborted;
117
+ reject(new Error('aborted'));
118
+ });
119
+ });
120
+ return { status: 'ok', payload: {} };
121
+ }
122
+ };
123
+ const result = await runAgent(
124
+ agentDef,
125
+ { sentence_sha256: 'def456' },
126
+ { timeoutMs: 100 }
127
+ );
128
+ assert.strictEqual(observedSignalAbortedAtStart, false, 'signal must start non-aborted');
129
+ assert.strictEqual(observedSignalAbortedAtEnd, true, 'signal must be aborted on timeout');
130
+ assert.strictEqual(observedSha, 'def456');
131
+ assert.strictEqual(observedRemaining, 100);
132
+ assert.strictEqual(result.status, 'timeout');
133
+ });
134
+
135
+ test('mva-agent-contract: Test 6 -- validateAgentResult', () => {
136
+ assert.strictEqual(
137
+ validateAgentResult({ agent_id: 'x', status: 'ok', duration_ms: 10 }),
138
+ true
139
+ );
140
+ assert.strictEqual(
141
+ validateAgentResult({ agent_id: 'x', status: 'empty', duration_ms: 10 }),
142
+ true
143
+ );
144
+ assert.strictEqual(
145
+ validateAgentResult({ agent_id: 'x', status: 'error', duration_ms: 10, error: 'e' }),
146
+ true
147
+ );
148
+ assert.strictEqual(
149
+ validateAgentResult({ agent_id: 'x', status: 'timeout', duration_ms: 10 }),
150
+ true
151
+ );
152
+ assert.strictEqual(validateAgentResult(null), false);
153
+ assert.strictEqual(validateAgentResult({}), false);
154
+ assert.strictEqual(validateAgentResult({ status: 'ok', duration_ms: 10 }), false);
155
+ assert.strictEqual(validateAgentResult({ agent_id: 'x', duration_ms: 10 }), false);
156
+ assert.strictEqual(
157
+ validateAgentResult({ agent_id: 'x', status: 'invalid', duration_ms: 10 }),
158
+ false
159
+ );
160
+ assert.strictEqual(
161
+ validateAgentResult({ agent_id: 'x', status: 'ok', duration_ms: -1 }),
162
+ false
163
+ );
164
+ assert.strictEqual(
165
+ validateAgentResult({ agent_id: 'x', status: 'ok' }),
166
+ false
167
+ );
168
+ assert.ok(AGENT_RESULT_SHAPE, 'shape doc must be exported');
169
+ });
@@ -0,0 +1,75 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 118-01 Plan 01 Task 2 -- mva-budget.
5
+ *
6
+ * Global wall-clock budget tracker used by mva-dispatcher.cjs. Per binding
7
+ * decision B2 (HARD):
8
+ * GLOBAL_BUDGET_MS = 45000 (the hard 30-Second-MVA cap; named for the
9
+ * sharp-question fallback window per source spec
10
+ * line 113 -- "Hard budget: 45 seconds maximum")
11
+ * PER_AGENT_CAP_MS = 35000 (each agent gets at most 35s OR remaining
12
+ * global, whichever is less -- source spec
13
+ * line 123 -- "Agents return structured JSON
14
+ * within 35s budget each")
15
+ *
16
+ * The dispatcher creates ONE budget per dispatch call. Each agent's per-agent
17
+ * abort signal feeds from this budget via perAgentMs(): min(35000, remainingMs()).
18
+ *
19
+ * Pure CJS, leaf module -- no dependencies on any other lib/core file.
20
+ * Canon Part 8: this module touches NO user content; it is wall-clock math only.
21
+ */
22
+ 'use strict';
23
+
24
+ const GLOBAL_BUDGET_MS = 45000;
25
+ const PER_AGENT_CAP_MS = 35000;
26
+
27
+ /**
28
+ * Create a wall-clock budget tracker.
29
+ *
30
+ * @param {number} [globalBudgetMs=45000]
31
+ * @returns {{
32
+ * startedAt: number,
33
+ * globalBudgetMs: number,
34
+ * remainingMs: () => number,
35
+ * perAgentMs: (cap?: number) => number,
36
+ * isExpired: () => boolean,
37
+ * elapsedMs: () => number,
38
+ * }}
39
+ */
40
+ function createBudget(globalBudgetMs) {
41
+ const cap = (typeof globalBudgetMs === 'number') ? globalBudgetMs : GLOBAL_BUDGET_MS;
42
+ const startedAt = Date.now();
43
+
44
+ function remainingMs() {
45
+ return Math.max(0, cap - (Date.now() - startedAt));
46
+ }
47
+
48
+ function perAgentMs(perAgentCapMs) {
49
+ const requested = (typeof perAgentCapMs === 'number') ? perAgentCapMs : PER_AGENT_CAP_MS;
50
+ return Math.max(0, Math.min(requested, remainingMs()));
51
+ }
52
+
53
+ function isExpired() {
54
+ return remainingMs() === 0;
55
+ }
56
+
57
+ function elapsedMs() {
58
+ return Date.now() - startedAt;
59
+ }
60
+
61
+ return {
62
+ startedAt,
63
+ globalBudgetMs: cap,
64
+ remainingMs,
65
+ perAgentMs,
66
+ isExpired,
67
+ elapsedMs
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ createBudget,
73
+ GLOBAL_BUDGET_MS,
74
+ PER_AGENT_CAP_MS
75
+ };
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 118-01 Plan 01 Task 2 -- mva-budget tests.
5
+ *
6
+ * Per binding decision B2 (HARD):
7
+ * 45-second global wall-clock budget + 35-second per-agent budget.
8
+ * Per-agent abort signal feeds from the global deadline: each agent gets
9
+ * min(35s, remaining_global_budget).
10
+ *
11
+ * Pure CJS, node built-ins only. Run via `node --test`.
12
+ */
13
+ 'use strict';
14
+
15
+ const test = require('node:test');
16
+ const assert = require('node:assert');
17
+
18
+ const {
19
+ createBudget,
20
+ GLOBAL_BUDGET_MS,
21
+ PER_AGENT_CAP_MS
22
+ } = require('./mva-budget.cjs');
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+
26
+ test('mva-budget: Test 1 -- remaining decreases over time', async () => {
27
+ const budget = createBudget(45000);
28
+ const r0 = budget.remainingMs();
29
+ assert.ok(r0 >= 44990 && r0 <= 45000, `expected ~45000, got ${r0}`);
30
+ await sleep(100);
31
+ const r1 = budget.remainingMs();
32
+ // Allow generous slop for slow CI / GC pauses.
33
+ assert.ok(r1 >= 44800 && r1 <= 44910, `expected ~44900, got ${r1}`);
34
+ });
35
+
36
+ test('mva-budget: Test 2 -- default globalBudgetMs is 45000', () => {
37
+ const budget = createBudget();
38
+ const r = budget.remainingMs();
39
+ assert.ok(r >= 44990 && r <= 45000, `expected ~45000, got ${r}`);
40
+ assert.strictEqual(budget.globalBudgetMs, 45000);
41
+ });
42
+
43
+ test('mva-budget: Test 3 -- perAgentMs returns 35000 when remaining is 45000', () => {
44
+ const budget = createBudget(45000);
45
+ assert.strictEqual(budget.perAgentMs(35000), 35000);
46
+ });
47
+
48
+ test('mva-budget: Test 4 -- perAgentMs caps at remaining', async () => {
49
+ const budget = createBudget(200);
50
+ await sleep(150);
51
+ const cap = budget.perAgentMs(180);
52
+ // remaining is ~50; perAgentCap 180 -> min(180, 50) = ~50
53
+ assert.ok(cap >= 0 && cap <= 80, `expected ~50, got ${cap}`);
54
+ assert.ok(cap < 180, 'must NOT return 180 when remaining is lower');
55
+ });
56
+
57
+ test('mva-budget: Test 5 -- isExpired flips true after globalBudgetMs', async () => {
58
+ const budget = createBudget(80);
59
+ assert.strictEqual(budget.isExpired(), false);
60
+ await sleep(120);
61
+ assert.strictEqual(budget.isExpired(), true);
62
+ assert.strictEqual(budget.remainingMs(), 0);
63
+ });
64
+
65
+ test('mva-budget: Test 6 -- exported constants', () => {
66
+ assert.strictEqual(GLOBAL_BUDGET_MS, 45000);
67
+ assert.strictEqual(PER_AGENT_CAP_MS, 35000);
68
+ });