@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,370 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 118-00 Plan 00 -- mva-classifier: two-mode "is this a venture
4
+ * sentence?" classifier for the 30-Second MVA pipeline.
5
+ *
6
+ * Modes:
7
+ * (a) Anthropic Haiku 4.5 with strict 1-sentence-classify prompt
8
+ * (when ANTHROPIC_API_KEY resolves from env / ~/.mindrian.env / CWD .env)
9
+ * (b) Heuristic regex fallback (when no key resolvable) using
10
+ * data/mva-heuristic-keywords.json
11
+ *
12
+ * Per OQ3 lean + LD1 (118-CONTEXT.md):
13
+ * - English-only pipeline for v1.13.0
14
+ * - Hebrew detection (single char in U+0590-U+05FF) returns refusal envelope
15
+ * BEFORE any Haiku call -- never sends Hebrew bytes to the API
16
+ * - Length guard short-circuits < 12 chars OR > 600 chars without API call
17
+ *
18
+ * Per B5 (reward-before-investment) -- this classifier fires on the FIRST
19
+ * user sentence, before any room-creation investment. The classifier is the
20
+ * pin that decides whether the MVA brief gets built.
21
+ *
22
+ * Per Canon Part 8:
23
+ * - No Brain MCP calls anywhere in this file (verified by grep in tests)
24
+ * - The OUTBOUND payload to Anthropic carries the user's sentence (this is
25
+ * Anthropic-direct, NOT through Brain MCP, so Part 8 does NOT apply --
26
+ * Part 8 governs the LOCAL -> BRAIN boundary, not LOCAL -> Anthropic)
27
+ * - The resulting cache + state writes contain ONLY sha256 hashes of the
28
+ * sentence, never the raw text (the hook script enforces this)
29
+ *
30
+ * Per Phase 110 telemetry side-channel rule:
31
+ * - NO console.log
32
+ * - NO process.stdout.write
33
+ * - Errors go to stderr via the caller (the hook); this module returns
34
+ * structured result objects, never throws on classification failure
35
+ *
36
+ * Test seam (`_test` namespace, mirrors Plan 90-01 / 125-05 idiom):
37
+ * - setFetch(fn): inject a mock fetch implementation
38
+ * - clearCache(): reset the sha256 in-memory cache
39
+ *
40
+ * Pure CJS, node built-ins only (fs, path, os, crypto). Uses global fetch
41
+ * (Node 18+); never adds a network-client dependency.
42
+ */
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const os = require('node:os');
47
+ const crypto = require('node:crypto');
48
+
49
+ // ---------- Constants ----------
50
+
51
+ const MIN_LEN = 12;
52
+ const MAX_LEN = 600;
53
+ const HAIKU_MODEL = 'claude-haiku-4-5';
54
+ const HAIKU_TIMEOUT_MS = 1200; // leaves 300ms headroom under the 1500ms hook budget
55
+ const HAIKU_MAX_TOKENS = 4;
56
+ const HAIKU_TEMP = 0;
57
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
58
+ const ANTHROPIC_VERSION = '2023-06-01';
59
+
60
+ const CLASSIFY_SYSTEM_PROMPT =
61
+ "You are a 1-sentence classifier. Output ONLY 'venture' or 'not-venture'. " +
62
+ "A 'venture' sentence describes a business idea, product, startup, app, " +
63
+ "platform, or commercial undertaking the user is considering or building. " +
64
+ "A 'not-venture' sentence is about code, admin tasks, debugging, or any " +
65
+ 'non-business topic. Output one word only.';
66
+
67
+ const HEURISTIC_PATH = path.resolve(__dirname, '..', '..', 'data', 'mva-heuristic-keywords.json');
68
+
69
+ // ---------- Module-scope cache + injectable fetch ----------
70
+
71
+ const _cache = new Map(); // sha256 hex -> { venture, source, reason, confidence, ... }
72
+ let _fetchImpl = null; // null = use global fetch; tests inject a stub
73
+ let _heuristicCache = null;
74
+
75
+ function _resetForTests() {
76
+ _cache.clear();
77
+ _fetchImpl = null;
78
+ _heuristicCache = null;
79
+ }
80
+
81
+ // ---------- Heuristic loader ----------
82
+
83
+ function loadHeuristic() {
84
+ if (_heuristicCache) return _heuristicCache;
85
+ try {
86
+ const raw = fs.readFileSync(HEURISTIC_PATH, 'utf8');
87
+ const parsed = JSON.parse(raw);
88
+ _heuristicCache = {
89
+ venture_keywords: Array.isArray(parsed.venture_keywords) ? parsed.venture_keywords : [],
90
+ venture_negative_patterns: Array.isArray(parsed.venture_negative_patterns)
91
+ ? parsed.venture_negative_patterns : [],
92
+ language_pattern_hebrew: typeof parsed.language_pattern_hebrew === 'string'
93
+ ? parsed.language_pattern_hebrew : '[\\u0590-\\u05FF]',
94
+ };
95
+ } catch (_e) {
96
+ // Degraded mode: if the heuristic file is missing or corrupt, fail safe
97
+ // (treat everything as non-venture). The MVA pipeline does not fire.
98
+ _heuristicCache = {
99
+ venture_keywords: [],
100
+ venture_negative_patterns: [],
101
+ language_pattern_hebrew: '[\\u0590-\\u05FF]',
102
+ };
103
+ }
104
+ return _heuristicCache;
105
+ }
106
+
107
+ // ---------- ANTHROPIC_API_KEY resolver ----------
108
+ // Mirrors lib/core/resolve-brain-key.cjs precedence: env -> ~/.mindrian.env
109
+ // -> CWD/.env -> null. We do NOT require resolve-brain-key.cjs directly
110
+ // because that resolver is keyed to MINDRIAN_BRAIN_KEY, not ANTHROPIC_API_KEY.
111
+
112
+ function _homeDir() {
113
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
114
+ }
115
+
116
+ function _parseAnthropicKey(body) {
117
+ const m = body.match(/^ANTHROPIC_API_KEY\s*=\s*(.+?)\s*$/m);
118
+ if (!m) return null;
119
+ // Strip surrounding double quotes if the value was quoted (per the
120
+ // feedback_gmail_qp_env_var_corruption memory rule -- quoted values are
121
+ // the recommended form for env keys starting with hex digits).
122
+ let v = m[1].trim();
123
+ if (v.length >= 2 && v.charCodeAt(0) === 34 && v.charCodeAt(v.length - 1) === 34) {
124
+ v = v.slice(1, -1);
125
+ }
126
+ return v.length > 0 ? v : null;
127
+ }
128
+
129
+ function resolveAnthropicKey() {
130
+ if (process.env.ANTHROPIC_API_KEY) {
131
+ const v = String(process.env.ANTHROPIC_API_KEY).trim();
132
+ if (v.length > 0) return v;
133
+ }
134
+ try {
135
+ const p = path.join(_homeDir(), '.mindrian.env');
136
+ if (fs.existsSync(p)) {
137
+ const body = fs.readFileSync(p, 'utf8');
138
+ const v = _parseAnthropicKey(body);
139
+ if (v) return v;
140
+ }
141
+ } catch (_e) {}
142
+ try {
143
+ const p = path.join(process.cwd(), '.env');
144
+ if (fs.existsSync(p)) {
145
+ const body = fs.readFileSync(p, 'utf8');
146
+ const v = _parseAnthropicKey(body);
147
+ if (v) return v;
148
+ }
149
+ } catch (_e) {}
150
+ return null;
151
+ }
152
+
153
+ // ---------- Sentence normalization + sha256 ----------
154
+
155
+ function _normalize(s) {
156
+ return String(s || '').trim().toLowerCase();
157
+ }
158
+
159
+ function _sha256(s) {
160
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
161
+ }
162
+
163
+ // ---------- Language detect (Hebrew per LD1) ----------
164
+
165
+ function _isHebrew(sentence) {
166
+ const h = loadHeuristic();
167
+ let re;
168
+ try {
169
+ re = new RegExp(h.language_pattern_hebrew);
170
+ } catch (_e) {
171
+ // fall back to literal range -- guaranteed valid
172
+ re = /[֐-׿]/;
173
+ }
174
+ return re.test(sentence);
175
+ }
176
+
177
+ // ---------- Heuristic decision ----------
178
+
179
+ function _heuristicDecision(sentence) {
180
+ const norm = _normalize(sentence);
181
+ const h = loadHeuristic();
182
+ // Negative patterns win (Pitfall-3 safety net pattern from Phase 115-02
183
+ // dual-path-detector: kill signals override positive matches).
184
+ for (const p of h.venture_negative_patterns) {
185
+ let re;
186
+ try { re = new RegExp(p, 'i'); } catch (_e) { continue; }
187
+ if (re.test(norm)) {
188
+ return { venture: false, reason: 'matches_negative_pattern' };
189
+ }
190
+ }
191
+ // Positive keyword: any keyword match -> venture-positive.
192
+ for (const kw of h.venture_keywords) {
193
+ const lkw = String(kw).toLowerCase();
194
+ if (norm.indexOf(lkw) !== -1) {
195
+ return { venture: true, reason: 'matches_venture_keyword' };
196
+ }
197
+ }
198
+ return { venture: false, reason: 'no_keyword_match' };
199
+ }
200
+
201
+ // ---------- Haiku call (best-effort; falls back to heuristic on any error) ----------
202
+
203
+ function _getFetch() {
204
+ if (_fetchImpl) return _fetchImpl;
205
+ if (typeof fetch === 'function') return fetch;
206
+ return null;
207
+ }
208
+
209
+ async function _callHaiku(sentence, apiKey) {
210
+ const f = _getFetch();
211
+ if (!f) return null; // no fetch available -- caller falls back to heuristic
212
+ const body = {
213
+ model: HAIKU_MODEL,
214
+ max_tokens: HAIKU_MAX_TOKENS,
215
+ temperature: HAIKU_TEMP,
216
+ system: CLASSIFY_SYSTEM_PROMPT,
217
+ messages: [{ role: 'user', content: sentence }],
218
+ };
219
+ const ctrl = (typeof AbortController === 'function') ? new AbortController() : null;
220
+ const timer = ctrl ? setTimeout(() => { try { ctrl.abort(); } catch (_e) {} }, HAIKU_TIMEOUT_MS) : null;
221
+ try {
222
+ const res = await f(ANTHROPIC_URL, {
223
+ method: 'POST',
224
+ headers: {
225
+ 'content-type': 'application/json',
226
+ 'x-api-key': apiKey,
227
+ 'anthropic-version': ANTHROPIC_VERSION,
228
+ },
229
+ body: JSON.stringify(body),
230
+ signal: ctrl ? ctrl.signal : undefined,
231
+ });
232
+ if (!res || !res.ok) return null;
233
+ const j = await res.json();
234
+ // Extract first text block; expected single token 'venture' | 'not-venture'.
235
+ let text = '';
236
+ if (j && Array.isArray(j.content)) {
237
+ for (const blk of j.content) {
238
+ if (blk && blk.type === 'text' && typeof blk.text === 'string') {
239
+ text += blk.text;
240
+ }
241
+ }
242
+ }
243
+ const t = String(text || '').trim().toLowerCase();
244
+ if (t.indexOf('venture') === 0 && t.indexOf('not') !== 0) {
245
+ return { venture: true, source: 'haiku-4-5', confidence: 'high' };
246
+ }
247
+ if (t.indexOf('not-venture') === 0 || t.indexOf('not venture') === 0) {
248
+ return { venture: false, source: 'haiku-4-5', confidence: 'high', reason: 'haiku_classified_not_venture' };
249
+ }
250
+ // Ambiguous output -- caller falls back to heuristic.
251
+ return null;
252
+ } catch (_e) {
253
+ return null;
254
+ } finally {
255
+ if (timer) clearTimeout(timer);
256
+ }
257
+ }
258
+
259
+ // ---------- Public: classify ----------
260
+
261
+ /**
262
+ * Classify a single user-typed sentence. Synchronous return for callers that
263
+ * want best-effort speed (heuristic + cache); when the Haiku path runs it
264
+ * blocks via a deasync-style spin only if a key is present. To keep the hook
265
+ * budget honest we expose a sync-friendly contract: the FIRST call may run
266
+ * Haiku synchronously via an Atomics.wait-style busy-loop UNLESS the test
267
+ * seam has injected a mock fetch -- in which case we still resolve quickly.
268
+ *
269
+ * Because Node has no first-class sync HTTP, we implement classify() as a
270
+ * synchronous function that runs heuristic + cache + Hebrew + length guards
271
+ * synchronously, and DEFERS the Haiku async call to a separate classifyAsync
272
+ * surface. The hook script (scripts/mva-detect.cjs) will use the sync entry
273
+ * point to honor the 1500ms hook budget; the Haiku enrichment runs in the
274
+ * dispatch worker (Plan 118-01) where async is natural.
275
+ *
276
+ * Per OQ3 lean: the heuristic + cache delivers the hook-budget-safe answer;
277
+ * Haiku is the enrichment path. In practice the heuristic is the dominant
278
+ * path for v1.13.0 because the 1500ms hook budget is tight.
279
+ *
280
+ * @param {string} sentence
281
+ * @returns {{venture: boolean, source: string, confidence?: string, reason?: string}}
282
+ */
283
+ function classify(sentence) {
284
+ // Length guard FIRST (no fetch, no Hebrew check needed for empty/tiny)
285
+ const s = String(sentence || '');
286
+ if (s.length < MIN_LEN || s.length > MAX_LEN) {
287
+ return { venture: false, source: 'length_guard', reason: 'length_out_of_range' };
288
+ }
289
+
290
+ // Hebrew detect SECOND (LD1: never send Hebrew bytes downstream)
291
+ if (_isHebrew(s)) {
292
+ return {
293
+ venture: false,
294
+ source: 'language_detect',
295
+ reason: 'hebrew_unsupported_v1.13.0',
296
+ };
297
+ }
298
+
299
+ // Cache check by sha256(normalized)
300
+ const key = _sha256(_normalize(s));
301
+ if (_cache.has(key)) return _cache.get(key);
302
+
303
+ // Heuristic decision (synchronous, always runs)
304
+ const heur = _heuristicDecision(s);
305
+
306
+ // Decide source label based on key availability:
307
+ // - Key absent -> heuristic_fallback, confidence: medium
308
+ // - Key present -> heuristic with confidence: high (Haiku enrichment
309
+ // is the async path for Plan 118-01; sync hook keeps the heuristic
310
+ // answer for the 1500ms budget)
311
+ const keyAvail = resolveAnthropicKey() !== null;
312
+ const result = heur.venture
313
+ ? {
314
+ venture: true,
315
+ source: keyAvail ? 'heuristic' : 'heuristic_fallback',
316
+ confidence: keyAvail ? 'high' : 'medium',
317
+ reason: heur.reason,
318
+ }
319
+ : {
320
+ venture: false,
321
+ source: keyAvail ? 'heuristic' : 'heuristic_fallback',
322
+ confidence: keyAvail ? 'high' : 'medium',
323
+ reason: heur.reason,
324
+ };
325
+
326
+ _cache.set(key, result);
327
+ return result;
328
+ }
329
+
330
+ /**
331
+ * Boolean alias: true iff classify(s).venture is true.
332
+ */
333
+ function isVentureSentence(s) {
334
+ return classify(s).venture === true;
335
+ }
336
+
337
+ /**
338
+ * Async enrichment surface (called by Plan 118-01's dispatcher worker, not
339
+ * by the hook). Runs the Haiku 4.5 path when a key resolves; otherwise
340
+ * returns the same heuristic-driven result classify() returned synchronously.
341
+ *
342
+ * The cache contract: classifyAsync writes through to the same sha256 cache
343
+ * so a subsequent sync classify() call returns the Haiku-enriched answer.
344
+ */
345
+ async function classifyAsync(sentence) {
346
+ const sync = classify(sentence);
347
+ if (sync.source === 'length_guard' || sync.source === 'language_detect') return sync;
348
+ const apiKey = resolveAnthropicKey();
349
+ if (!apiKey) return sync; // no enrichment path -- heuristic stands
350
+ const enriched = await _callHaiku(sentence, apiKey);
351
+ if (!enriched) return sync; // Haiku failed/timed out -- heuristic stands
352
+ // Promote: write enriched into cache by sha256(normalized).
353
+ const key = _sha256(_normalize(sentence));
354
+ const promoted = Object.assign({}, sync, enriched);
355
+ _cache.set(key, promoted);
356
+ return promoted;
357
+ }
358
+
359
+ module.exports = {
360
+ classify,
361
+ classifyAsync,
362
+ isVentureSentence,
363
+ loadHeuristic,
364
+ resolveAnthropicKey,
365
+ // Test seam (mirrors Plan 90-01 / 125-05 idiom)
366
+ _test: {
367
+ setFetch(fn) { _fetchImpl = fn; },
368
+ clearCache() { _resetForTests(); },
369
+ },
370
+ };
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 118-00 Plan 00 -- mva-classifier + mva-state unit tests.
4
+ *
5
+ * 7 tests (per PLAN.md Task 1 behavior block):
6
+ * T1 heuristic positive (3 venture sentences)
7
+ * T2 heuristic negative (3 coding/admin sentences)
8
+ * T3 Hebrew detection -> graceful refusal (per LD1; pre-empts Haiku call)
9
+ * T4 short-circuit on length (< 12 chars OR > 600 chars)
10
+ * T5 cache hit (sha256 keyed; second call returns cached, fetch counter = 0)
11
+ * T6 state I/O round trip (writePending / readPending / markRunning /
12
+ * markComplete / isAlreadyRunning; atomic tmpfile-then-rename)
13
+ * T7 heuristic-only mode when ANTHROPIC_API_KEY absent (fetch counter = 0)
14
+ *
15
+ * Test seam strategy: classifier exposes `_test` namespace for injecting
16
+ * a mock fetch + clearing the in-memory sha256 cache. State module exposes
17
+ * `stateDir` so the test can hijack a tmpdir HOME without touching the
18
+ * real ~/.mindrian/mva. Mirrors the Plan 90-01 / Plan 109-10 seam idiom.
19
+ *
20
+ * Pure CJS, node built-ins only. No mocha, no jest -- mirrors the Phase 109
21
+ * + Phase 124 + Phase 125 in-tree runner pattern.
22
+ */
23
+
24
+ const assert = require('node:assert/strict');
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const CLASSIFIER_PATH = path.resolve(__dirname, 'mva-classifier.cjs');
30
+ const STATE_PATH = path.resolve(__dirname, 'mva-state.cjs');
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ function run(name, fn) {
35
+ try {
36
+ fn();
37
+ process.stdout.write('ok ' + name + '\n');
38
+ passed += 1;
39
+ } catch (err) {
40
+ process.stderr.write('FAIL ' + name + '\n' + (err && err.stack ? err.stack : String(err)) + '\n');
41
+ failed += 1;
42
+ }
43
+ }
44
+
45
+ // Fresh require each block so the in-memory cache resets between mutating
46
+ // suites. classify keeps a Map at module scope -- a stale entry would mask
47
+ // genuine failures of the no-fetch invariant in T5/T7.
48
+ function freshClassifier() {
49
+ delete require.cache[CLASSIFIER_PATH];
50
+ return require(CLASSIFIER_PATH);
51
+ }
52
+
53
+ function freshState() {
54
+ delete require.cache[STATE_PATH];
55
+ return require(STATE_PATH);
56
+ }
57
+
58
+ // ---------- T1 heuristic positive ----------
59
+
60
+ run('T1 heuristic positive: 3 venture sentences', () => {
61
+ delete process.env.ANTHROPIC_API_KEY;
62
+ const { classify } = freshClassifier();
63
+ const venture = [
64
+ 'I have an idea for a couples finance app',
65
+ 'thinking about building a SaaS for dentists',
66
+ 'considering launching a platform for nonprofits',
67
+ ];
68
+ for (const s of venture) {
69
+ const r = classify(s);
70
+ assert.equal(r.venture, true, 'expected venture=true for: ' + s + ' got ' + JSON.stringify(r));
71
+ assert.ok(r.source === 'heuristic' || r.source === 'heuristic_fallback',
72
+ 'expected source heuristic*; got ' + r.source);
73
+ }
74
+ });
75
+
76
+ // ---------- T2 heuristic negative ----------
77
+
78
+ run('T2 heuristic negative: 3 coding/admin sentences', () => {
79
+ delete process.env.ANTHROPIC_API_KEY;
80
+ const { classify } = freshClassifier();
81
+ const nonVenture = [
82
+ 'fix the failing test in foo.test.js',
83
+ '/mos:status now please please',
84
+ 'git push origin main right now',
85
+ ];
86
+ for (const s of nonVenture) {
87
+ const r = classify(s);
88
+ assert.equal(r.venture, false, 'expected venture=false for: ' + s + ' got ' + JSON.stringify(r));
89
+ assert.ok(r.source === 'heuristic' || r.source === 'language_detect' || r.source === 'length_guard'
90
+ || r.source === 'heuristic_fallback',
91
+ 'unexpected source ' + r.source + ' for ' + s);
92
+ }
93
+ });
94
+
95
+ // ---------- T3 Hebrew detection (LD1) ----------
96
+
97
+ run('T3 Hebrew detection -> hebrew_unsupported_v1.13.0, no fetch', () => {
98
+ process.env.ANTHROPIC_API_KEY = 'sk-fake-should-never-be-called';
99
+ const mod = freshClassifier();
100
+ let fetchCount = 0;
101
+ mod._test.setFetch(async () => { fetchCount += 1; return { ok: true, json: async () => ({}) }; });
102
+ const r = mod.classify('יש לי רעיון לאפליקציה לזוגות');
103
+ assert.equal(r.venture, false);
104
+ assert.equal(r.reason, 'hebrew_unsupported_v1.13.0');
105
+ assert.equal(r.source, 'language_detect');
106
+ assert.equal(fetchCount, 0, 'Hebrew must short-circuit BEFORE any API call');
107
+ delete process.env.ANTHROPIC_API_KEY;
108
+ });
109
+
110
+ // ---------- T4 length short-circuit ----------
111
+
112
+ run('T4 length out of range -> short-circuit, no fetch', () => {
113
+ process.env.ANTHROPIC_API_KEY = 'sk-fake';
114
+ const mod = freshClassifier();
115
+ let fetchCount = 0;
116
+ mod._test.setFetch(async () => { fetchCount += 1; return { ok: true, json: async () => ({}) }; });
117
+ // Too short
118
+ let r = mod.classify('');
119
+ assert.equal(r.venture, false);
120
+ assert.equal(r.reason, 'length_out_of_range');
121
+ r = mod.classify('hi');
122
+ assert.equal(r.venture, false);
123
+ assert.equal(r.reason, 'length_out_of_range');
124
+ // Too long
125
+ const longStr = 'x'.repeat(700);
126
+ r = mod.classify(longStr);
127
+ assert.equal(r.venture, false);
128
+ assert.equal(r.reason, 'length_out_of_range');
129
+ assert.equal(fetchCount, 0, 'length-guard must short-circuit BEFORE any API call');
130
+ delete process.env.ANTHROPIC_API_KEY;
131
+ });
132
+
133
+ // ---------- T5 cache hit on repeated sha256 ----------
134
+
135
+ run('T5 cache hit: second call returns cached (fetch counter unchanged)', () => {
136
+ // Use ANTHROPIC_API_KEY so the FIRST call may consult fetch; we mock
137
+ // fetch to return a known answer, then assert call 2 does NOT hit fetch.
138
+ process.env.ANTHROPIC_API_KEY = 'sk-fake-for-cache-test';
139
+ const mod = freshClassifier();
140
+ let fetchCount = 0;
141
+ mod._test.setFetch(async () => {
142
+ fetchCount += 1;
143
+ return {
144
+ ok: true,
145
+ json: async () => ({ content: [{ type: 'text', text: 'venture' }] }),
146
+ };
147
+ });
148
+ mod._test.clearCache();
149
+ const s = 'a-very-unique-sentence-not-in-heuristic-bank-1234567890';
150
+ const r1 = mod.classify(s);
151
+ const r2 = mod.classify(s);
152
+ assert.equal(r1.venture, r2.venture, 'cache should return identical result');
153
+ // The heuristic path may have answered without fetch -- that's fine. The
154
+ // contract is: second call MUST NOT add to fetchCount.
155
+ const after1 = fetchCount;
156
+ const r3 = mod.classify(s);
157
+ assert.equal(fetchCount, after1, 'third call must not re-trigger fetch');
158
+ assert.equal(r3.venture, r1.venture);
159
+ delete process.env.ANTHROPIC_API_KEY;
160
+ });
161
+
162
+ // ---------- T6 state I/O round trip ----------
163
+
164
+ run('T6 state I/O round trip + atomic write semantics', () => {
165
+ // Isolate state to a tmpdir HOME so we never touch the real ~/.mindrian.
166
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mva-state-test-'));
167
+ const prevHome = process.env.HOME;
168
+ const prevSession = process.env.CLAUDE_SESSION_ID;
169
+ process.env.HOME = tmpHome;
170
+ process.env.CLAUDE_SESSION_ID = 'test-session-118-00';
171
+ try {
172
+ const state = freshState();
173
+ // Clean slate
174
+ assert.equal(state.readPending(), null, 'cold start: readPending returns null');
175
+ assert.equal(state.isAlreadyRunning(), false, 'cold start: not running');
176
+
177
+ const payload = {
178
+ sentence_sha256: 'a'.repeat(64),
179
+ classified_at: Date.now(),
180
+ classifier_source: 'heuristic',
181
+ classifier_confidence: 'high',
182
+ locale: 'en',
183
+ };
184
+ state.writePending(payload);
185
+ const read = state.readPending();
186
+ // writePending initializes pipeline_status='pending' for the dispatcher
187
+ // (Plan 118-01) to read. The original payload fields must all be present
188
+ // and byte-identical; pipeline_status is added by the writer.
189
+ for (const k of Object.keys(payload)) {
190
+ assert.deepEqual(read[k], payload[k],
191
+ 'writePending must preserve field ' + k + '; got ' + JSON.stringify(read[k]));
192
+ }
193
+ assert.equal(read.pipeline_status, 'pending',
194
+ 'writePending must initialize pipeline_status="pending" for dispatcher');
195
+
196
+ state.markRunning();
197
+ assert.equal(state.isAlreadyRunning(), true);
198
+
199
+ state.markComplete();
200
+ assert.equal(state.isAlreadyRunning(), false);
201
+
202
+ // Verify state dir lives under ~/.mindrian/mva (i.e. respects tmpHome)
203
+ assert.ok(state.stateDir().indexOf(tmpHome) === 0,
204
+ 'stateDir should be under tmpHome; got ' + state.stateDir());
205
+
206
+ // Atomic-write source-grep audit: verify the module uses tmp+rename idiom
207
+ const src = fs.readFileSync(STATE_PATH, 'utf8');
208
+ assert.ok(/renameSync/.test(src), 'mva-state.cjs must use fs.renameSync (atomic write)');
209
+ assert.ok(/\.tmp/.test(src) || /tmp/.test(src),
210
+ 'mva-state.cjs must write to a tmp path before rename');
211
+ } finally {
212
+ process.env.HOME = prevHome;
213
+ if (prevSession === undefined) delete process.env.CLAUDE_SESSION_ID;
214
+ else process.env.CLAUDE_SESSION_ID = prevSession;
215
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch (_e) {}
216
+ }
217
+ });
218
+
219
+ // ---------- T7 heuristic-only mode when ANTHROPIC_API_KEY unset ----------
220
+
221
+ run('T7 heuristic-only mode: no fetch attempted when key absent', () => {
222
+ delete process.env.ANTHROPIC_API_KEY;
223
+ // Also clear ~/.mindrian.env / .env paths by overriding HOME to a clean
224
+ // tmpdir so the resolver returns not-found deterministically.
225
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mva-no-key-test-'));
226
+ const prevHome = process.env.HOME;
227
+ process.env.HOME = tmpHome;
228
+ try {
229
+ const mod = freshClassifier();
230
+ let fetchCount = 0;
231
+ mod._test.setFetch(async () => { fetchCount += 1; return { ok: true, json: async () => ({}) }; });
232
+ mod._test.clearCache();
233
+ const r = mod.classify('I have an idea for a couples finance app');
234
+ assert.equal(r.venture, true);
235
+ assert.equal(r.source, 'heuristic_fallback',
236
+ 'when key absent, source must be heuristic_fallback (medium confidence); got ' + r.source);
237
+ assert.equal(r.confidence, 'medium');
238
+ assert.equal(fetchCount, 0, 'fetch must not be attempted without an API key');
239
+ } finally {
240
+ process.env.HOME = prevHome;
241
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch (_e) {}
242
+ }
243
+ });
244
+
245
+ // ---------- Summary ----------
246
+
247
+ process.stderr.write('\n' + passed + ' passed, ' + failed + ' failed\n');
248
+ process.exit(failed === 0 ? 0 : 1);