@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/commands/file-meeting.md +2 -0
- package/commands/grade.md +2 -0
- package/commands/mva-brief.md +56 -0
- package/commands/mva-option.md +89 -0
- package/commands/new-project.md +2 -0
- package/commands/onboard.md +2 -0
- package/hooks/hooks.json +9 -0
- package/lib/agents/mva/brain-classic-traps.cjs +77 -0
- package/lib/agents/mva/brain-cross-domain.cjs +79 -0
- package/lib/agents/mva/brain-similar-ventures.cjs +93 -0
- package/lib/agents/mva/dashboard-graph-neighborhood.cjs +72 -0
- package/lib/agents/mva/index.cjs +42 -0
- package/lib/agents/mva/six-hats-red-black.cjs +137 -0
- package/lib/agents/mva/tavily-funding-scan.cjs +147 -0
- package/lib/agents/mva/test-all-six-agents.cjs +467 -0
- package/lib/conversation/operator.cjs +64 -0
- package/lib/conversation/operator.test.cjs +160 -0
- package/lib/core/mva-agent-contract.cjs +170 -0
- package/lib/core/mva-agent-contract.test.cjs +169 -0
- package/lib/core/mva-budget.cjs +75 -0
- package/lib/core/mva-budget.test.cjs +68 -0
- package/lib/core/mva-classifier.cjs +370 -0
- package/lib/core/mva-classifier.test.cjs +248 -0
- package/lib/core/mva-deck-builder.cjs +452 -0
- package/lib/core/mva-deck-builder.test.cjs +287 -0
- package/lib/core/mva-detect.smoke.test.cjs +197 -0
- package/lib/core/mva-dispatcher.cjs +110 -0
- package/lib/core/mva-dispatcher.test.cjs +216 -0
- package/lib/core/mva-option-router.cjs +292 -0
- package/lib/core/mva-option-router.test.cjs +483 -0
- package/lib/core/mva-orchestrator.cjs +324 -0
- package/lib/core/mva-orchestrator.test.cjs +908 -0
- package/lib/core/mva-progressive-renderer.cjs +194 -0
- package/lib/core/mva-progressive-renderer.test.cjs +157 -0
- package/lib/core/mva-rule-linter.cjs +213 -0
- package/lib/core/mva-rule-linter.test.cjs +336 -0
- package/lib/core/mva-state.cjs +159 -0
- package/lib/core/mva-telemetry.cjs +170 -0
- package/lib/core/mva-telemetry.test.cjs +196 -0
- package/lib/core/mva-vercel-deploy.cjs +168 -0
- package/lib/core/mva-vercel-deploy.test.cjs +239 -0
- package/lib/core/navigation/dashboard-helpers.cjs +145 -0
- package/lib/core/navigation.cjs +11 -0
- package/lib/core/resolve-vercel-key.cjs +107 -0
- package/lib/core/resolve-vercel-key.test.cjs +137 -0
- package/lib/memory/run-feynman-tests.cjs +27 -0
- package/package.json +1 -1
- package/skills/mva-pipeline/SKILL.md +129 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Phase 118-06 Plan 06 -- mva-rule-linter unit tests.
|
|
4
|
+
*
|
|
5
|
+
* Tests 1-7: linter library + CLI baseline (Task 1).
|
|
6
|
+
* Tests 8-11: pre-commit hook wire + WARN-5 rule-doc parity audit (Task 2).
|
|
7
|
+
*
|
|
8
|
+
* Test 3 reads LD1 from 118-CONTEXT.md (CRITICAL-1+5 -- not in this file;
|
|
9
|
+
* lives in tests/test-mva-dror-harness.cjs).
|
|
10
|
+
*
|
|
11
|
+
* Pure CJS, node built-ins only. Mirrors the Phase 118-00 in-tree runner
|
|
12
|
+
* pattern from lib/core/mva-classifier.test.cjs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { spawnSync } = require('node:child_process');
|
|
20
|
+
|
|
21
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
22
|
+
const LINTER_PATH = path.resolve(__dirname, 'mva-rule-linter.cjs');
|
|
23
|
+
const CLI_PATH = path.resolve(REPO_ROOT, 'scripts', 'check-reward-before-investment.cjs');
|
|
24
|
+
const HOOK_PATH = path.resolve(REPO_ROOT, 'scripts', 'hooks', 'pre-commit');
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
|
|
29
|
+
function run(name, fn) {
|
|
30
|
+
try {
|
|
31
|
+
fn();
|
|
32
|
+
process.stdout.write('ok ' + name + '\n');
|
|
33
|
+
passed += 1;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
process.stderr.write('FAIL ' + name + '\n' + (err && err.stack ? err.stack : String(err)) + '\n');
|
|
36
|
+
failed += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function freshLinter() {
|
|
41
|
+
delete require.cache[LINTER_PATH];
|
|
42
|
+
return require(LINTER_PATH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create a temp dir + write fixture files there. Cleanup is best-effort
|
|
46
|
+
// (left in place if a test throws so a human can inspect).
|
|
47
|
+
function mkTempCommandsDir() {
|
|
48
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mva-rule-linter-'));
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeFixture(dir, filename, body) {
|
|
53
|
+
fs.writeFileSync(path.join(dir, filename), body, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------- T1: compliant command passes ----------
|
|
57
|
+
|
|
58
|
+
run('T1 compliant command: interactive_first_reward present + valid -> compliant', () => {
|
|
59
|
+
const { scanCommands } = freshLinter();
|
|
60
|
+
const dir = mkTempCommandsDir();
|
|
61
|
+
writeFixture(dir, 'good.md', [
|
|
62
|
+
'---',
|
|
63
|
+
'name: good',
|
|
64
|
+
'description: A command that follows the rule',
|
|
65
|
+
'interactive_first_reward: reframe_question',
|
|
66
|
+
'---',
|
|
67
|
+
'',
|
|
68
|
+
'# good',
|
|
69
|
+
].join('\n'));
|
|
70
|
+
const result = scanCommands(dir);
|
|
71
|
+
assert.equal(result.compliant.length, 1, 'expected 1 compliant, got ' + JSON.stringify(result));
|
|
72
|
+
assert.equal(result.missing.length, 0);
|
|
73
|
+
assert.equal(result.invalid.length, 0);
|
|
74
|
+
assert.equal(result.ok, true);
|
|
75
|
+
assert.equal(result.compliant[0].value, 'reframe_question');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------- T2: missing field fails ----------
|
|
79
|
+
|
|
80
|
+
run('T2 missing field: frontmatter present but no interactive_first_reward -> missing', () => {
|
|
81
|
+
const { scanCommands } = freshLinter();
|
|
82
|
+
const dir = mkTempCommandsDir();
|
|
83
|
+
writeFixture(dir, 'forgot.md', [
|
|
84
|
+
'---',
|
|
85
|
+
'name: forgot',
|
|
86
|
+
'description: A command that forgot the field',
|
|
87
|
+
'---',
|
|
88
|
+
'',
|
|
89
|
+
'# forgot',
|
|
90
|
+
].join('\n'));
|
|
91
|
+
const result = scanCommands(dir);
|
|
92
|
+
assert.equal(result.compliant.length, 0);
|
|
93
|
+
assert.equal(result.missing.length, 1);
|
|
94
|
+
assert.equal(result.invalid.length, 0);
|
|
95
|
+
assert.equal(result.ok, false);
|
|
96
|
+
assert.equal(result.missing[0].reason, 'missing_field');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------- T3: invalid value fails ----------
|
|
100
|
+
|
|
101
|
+
run('T3 invalid value: typo / unknown reward type -> invalid', () => {
|
|
102
|
+
const { scanCommands } = freshLinter();
|
|
103
|
+
const dir = mkTempCommandsDir();
|
|
104
|
+
writeFixture(dir, 'typo.md', [
|
|
105
|
+
'---',
|
|
106
|
+
'name: typo',
|
|
107
|
+
'description: A command with a typo in the reward field',
|
|
108
|
+
'interactive_first_reward: gibberish',
|
|
109
|
+
'---',
|
|
110
|
+
].join('\n'));
|
|
111
|
+
const result = scanCommands(dir);
|
|
112
|
+
assert.equal(result.invalid.length, 1);
|
|
113
|
+
assert.equal(result.invalid[0].reason, 'invalid_value');
|
|
114
|
+
assert.equal(result.invalid[0].value, 'gibberish');
|
|
115
|
+
assert.equal(result.ok, false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------- T4: --none (scripting only) accepted verbatim ----------
|
|
119
|
+
|
|
120
|
+
run('T4 --none (scripting only) verbatim accepted per rule doc line 81', () => {
|
|
121
|
+
const { validateFrontmatter } = freshLinter();
|
|
122
|
+
const r = validateFrontmatter({
|
|
123
|
+
name: 'cron-job',
|
|
124
|
+
interactive_first_reward: '--none (scripting only)',
|
|
125
|
+
});
|
|
126
|
+
assert.equal(r.ok, true, 'expected --none (scripting only) to validate; got ' + JSON.stringify(r));
|
|
127
|
+
assert.equal(r.value, '--none (scripting only)');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------- T5: every REWARD_TYPES enum value accepted ----------
|
|
131
|
+
|
|
132
|
+
run('T5 every REWARD_TYPES enum value accepted', () => {
|
|
133
|
+
const { validateFrontmatter, REWARD_TYPES } = freshLinter();
|
|
134
|
+
// Convert frozen Set to array for iteration -- check every entry.
|
|
135
|
+
for (const v of REWARD_TYPES) {
|
|
136
|
+
const r = validateFrontmatter({ interactive_first_reward: v });
|
|
137
|
+
assert.equal(r.ok, true, 'expected ' + JSON.stringify(v) + ' to validate; got ' + JSON.stringify(r));
|
|
138
|
+
}
|
|
139
|
+
// And spot-check the 5 named ones (plus the scripting opt-out = 6 total).
|
|
140
|
+
const expected = [
|
|
141
|
+
'reframe_question',
|
|
142
|
+
'instant_brief',
|
|
143
|
+
'schema_preview',
|
|
144
|
+
'calibration_distribution_preview',
|
|
145
|
+
'paragraph_preview',
|
|
146
|
+
'--none (scripting only)',
|
|
147
|
+
];
|
|
148
|
+
for (const v of expected) {
|
|
149
|
+
assert.ok(REWARD_TYPES.has(v), 'REWARD_TYPES missing canonical entry: ' + v);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------- T6: no-frontmatter file degrades gracefully ----------
|
|
154
|
+
|
|
155
|
+
run('T6 file without ANY frontmatter: degrades to missing (does NOT crash)', () => {
|
|
156
|
+
const { scanCommands } = freshLinter();
|
|
157
|
+
const dir = mkTempCommandsDir();
|
|
158
|
+
writeFixture(dir, 'legacy.md', '# legacy command\n\nNo frontmatter at all.\n');
|
|
159
|
+
const result = scanCommands(dir);
|
|
160
|
+
assert.equal(result.missing.length, 1);
|
|
161
|
+
assert.equal(result.missing[0].reason, 'no_frontmatter');
|
|
162
|
+
assert.equal(result.invalid.length, 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------- T7: CLI spawn -- compliant -> exit 0; missing -> exit 1 ----------
|
|
166
|
+
|
|
167
|
+
run('T7 CLI spawn: compliant dir exits 0; missing-field dir exits 1 with offender on stderr', () => {
|
|
168
|
+
// Subtest A: all compliant -> exit 0.
|
|
169
|
+
const dirGood = mkTempCommandsDir();
|
|
170
|
+
writeFixture(dirGood, 'one.md', [
|
|
171
|
+
'---',
|
|
172
|
+
'name: one',
|
|
173
|
+
'interactive_first_reward: instant_brief',
|
|
174
|
+
'---',
|
|
175
|
+
].join('\n'));
|
|
176
|
+
const okResult = spawnSync(process.execPath, [CLI_PATH, dirGood], { encoding: 'utf8' });
|
|
177
|
+
assert.equal(okResult.status, 0, 'expected exit 0 on compliant; got ' + okResult.status + '\nstdout: ' + okResult.stdout + '\nstderr: ' + okResult.stderr);
|
|
178
|
+
assert.ok(/the rule holds/.test(okResult.stdout), 'expected Larry-voice success on stdout; got: ' + okResult.stdout);
|
|
179
|
+
|
|
180
|
+
// Subtest B: missing field -> exit 1, offender named on stderr.
|
|
181
|
+
const dirBad = mkTempCommandsDir();
|
|
182
|
+
writeFixture(dirBad, 'broken.md', [
|
|
183
|
+
'---',
|
|
184
|
+
'name: broken',
|
|
185
|
+
'description: A command that forgot the field',
|
|
186
|
+
'---',
|
|
187
|
+
].join('\n'));
|
|
188
|
+
const badResult = spawnSync(process.execPath, [CLI_PATH, dirBad], { encoding: 'utf8' });
|
|
189
|
+
assert.equal(badResult.status, 1, 'expected exit 1 on missing; got ' + badResult.status);
|
|
190
|
+
assert.ok(/broken\.md/.test(badResult.stderr), 'expected stderr to name broken.md; got: ' + badResult.stderr);
|
|
191
|
+
assert.ok(/missing_field/.test(badResult.stderr), 'expected stderr to mention missing_field reason; got: ' + badResult.stderr);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---------- T8: hook is wired (CRITICAL-4 invariant) ----------
|
|
195
|
+
|
|
196
|
+
run('T8 hook wired (CRITICAL-4): scripts/hooks/pre-commit references check-reward-before-investment.cjs', () => {
|
|
197
|
+
const hook = fs.readFileSync(HOOK_PATH, 'utf8');
|
|
198
|
+
assert.ok(
|
|
199
|
+
hook.includes('check-reward-before-investment.cjs'),
|
|
200
|
+
'hooks/pre-commit must reference check-reward-before-investment.cjs (CRITICAL-4); got hook of length ' + hook.length
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------- T9: scaffold E2E (CRITICAL-4) -- temp git repo, staged offender, hook blocks ----------
|
|
205
|
+
|
|
206
|
+
run('T9 scaffold E2E (CRITICAL-4): hook blocks a staged commands/foo.md that lacks the field', () => {
|
|
207
|
+
// Create an isolated temp git repo with a copy of our hook + scripts + lib.
|
|
208
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mva-precommit-e2e-'));
|
|
209
|
+
|
|
210
|
+
// Initialize bare git repo
|
|
211
|
+
const init = spawnSync('git', ['init', '-q'], { cwd: tmp, encoding: 'utf8' });
|
|
212
|
+
if (init.status !== 0) {
|
|
213
|
+
// Skip if git is unavailable in this environment (exit 77 = POSIX skip).
|
|
214
|
+
process.stderr.write('T9 skipped: git init failed; env=' + init.stderr + '\n');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Local identity so commit (if ever attempted) wouldn't fail on identity --
|
|
218
|
+
// we never actually commit; the hook is run directly via bash.
|
|
219
|
+
spawnSync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: tmp });
|
|
220
|
+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmp });
|
|
221
|
+
|
|
222
|
+
// Recreate the relevant directory structure inside the temp repo so the
|
|
223
|
+
// hook can resolve paths the same way it does in the real repo. We mirror:
|
|
224
|
+
// <tmp>/scripts/hooks/pre-commit
|
|
225
|
+
// <tmp>/scripts/check-reward-before-investment.cjs
|
|
226
|
+
// <tmp>/lib/core/mva-rule-linter.cjs
|
|
227
|
+
// <tmp>/commands/foo.md (the offender)
|
|
228
|
+
fs.mkdirSync(path.join(tmp, 'scripts', 'hooks'), { recursive: true });
|
|
229
|
+
fs.mkdirSync(path.join(tmp, 'lib', 'core'), { recursive: true });
|
|
230
|
+
fs.mkdirSync(path.join(tmp, 'commands'), { recursive: true });
|
|
231
|
+
|
|
232
|
+
fs.copyFileSync(HOOK_PATH, path.join(tmp, 'scripts', 'hooks', 'pre-commit'));
|
|
233
|
+
fs.copyFileSync(CLI_PATH, path.join(tmp, 'scripts', 'check-reward-before-investment.cjs'));
|
|
234
|
+
fs.copyFileSync(LINTER_PATH, path.join(tmp, 'lib', 'core', 'mva-rule-linter.cjs'));
|
|
235
|
+
|
|
236
|
+
// Install the hook into .git/hooks/
|
|
237
|
+
const installedHook = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
238
|
+
fs.copyFileSync(HOOK_PATH, installedHook);
|
|
239
|
+
fs.chmodSync(installedHook, 0o755);
|
|
240
|
+
|
|
241
|
+
// The offender: commands/foo.md with NO interactive_first_reward field.
|
|
242
|
+
const offenderPath = path.join(tmp, 'commands', 'foo.md');
|
|
243
|
+
fs.writeFileSync(offenderPath, [
|
|
244
|
+
'---',
|
|
245
|
+
'name: foo',
|
|
246
|
+
'description: test command without the rule field',
|
|
247
|
+
'---',
|
|
248
|
+
'',
|
|
249
|
+
'# foo',
|
|
250
|
+
].join('\n'), 'utf8');
|
|
251
|
+
|
|
252
|
+
// Stage the offender
|
|
253
|
+
spawnSync('git', ['add', 'commands/foo.md'], { cwd: tmp });
|
|
254
|
+
|
|
255
|
+
// Run the hook directly. The Phase 87-01a guard runs BEFORE the linter
|
|
256
|
+
// block; commands/ has no .room-root sentinel so the ROOM.md/MINTO.md
|
|
257
|
+
// invariant skips (per the find_room_root scoping). The Phase 122
|
|
258
|
+
// build-command-registry --check guard would run if scripts/build-command
|
|
259
|
+
// -registry.cjs exists in the temp repo -- we did NOT copy it, so that
|
|
260
|
+
// block is a no-op. That leaves OUR linter block as the gating check.
|
|
261
|
+
//
|
|
262
|
+
// The linter block must:
|
|
263
|
+
// 1. detect that commands/foo.md is staged
|
|
264
|
+
// 2. invoke node scripts/check-reward-before-investment.cjs
|
|
265
|
+
// 3. propagate the non-zero exit
|
|
266
|
+
//
|
|
267
|
+
// We invoke the hook with `bash` so the BASH_SOURCE machinery works.
|
|
268
|
+
const hookResult = spawnSync('bash', [installedHook], { cwd: tmp, encoding: 'utf8' });
|
|
269
|
+
|
|
270
|
+
// The hook should exit non-zero. The stderr should mention foo.md.
|
|
271
|
+
assert.notEqual(
|
|
272
|
+
hookResult.status, 0,
|
|
273
|
+
'expected non-zero exit from pre-commit hook with missing-field commands/foo.md staged; got ' + hookResult.status +
|
|
274
|
+
'\nstdout: ' + hookResult.stdout + '\nstderr: ' + hookResult.stderr
|
|
275
|
+
);
|
|
276
|
+
// The hook should mention foo.md somewhere in its diagnostic output (either
|
|
277
|
+
// stdout or stderr is fine -- the linter writes the offender to stderr;
|
|
278
|
+
// the hook's wrapper line is on stderr too).
|
|
279
|
+
const combined = (hookResult.stdout || '') + (hookResult.stderr || '');
|
|
280
|
+
assert.ok(
|
|
281
|
+
/foo\.md/.test(combined),
|
|
282
|
+
'expected hook diagnostic to name foo.md; combined output: ' + combined
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------- T10: WARN-5 -- new-project.md carries `instant_brief` (NOT reframe_question) ----------
|
|
287
|
+
|
|
288
|
+
run('T10 WARN-5: commands/new-project.md declares interactive_first_reward: instant_brief', () => {
|
|
289
|
+
const { parseFrontmatter } = freshLinter();
|
|
290
|
+
const npPath = path.resolve(REPO_ROOT, 'commands', 'new-project.md');
|
|
291
|
+
const fm = parseFrontmatter(fs.readFileSync(npPath, 'utf8'));
|
|
292
|
+
assert.ok(fm, 'commands/new-project.md must have parseable frontmatter');
|
|
293
|
+
assert.equal(
|
|
294
|
+
fm.interactive_first_reward,
|
|
295
|
+
'instant_brief',
|
|
296
|
+
'commands/new-project.md must carry interactive_first_reward: instant_brief (WARN-5; rule doc line 56-58 prescribes Instant Brief, NOT reframe_question); got: ' + JSON.stringify(fm.interactive_first_reward)
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ---------- T11: WARN-5 audit -- 4-command rule-doc parity ----------
|
|
301
|
+
|
|
302
|
+
run('T11 WARN-5 audit: 4 named commands match rule-doc remediation column', () => {
|
|
303
|
+
const { parseFrontmatter } = freshLinter();
|
|
304
|
+
// Per docs/reward-before-investment-rule.md (the canonical in-repo copy
|
|
305
|
+
// shipped by Plan 06 Task 2; the prescriptions live at lines 56-70 of the
|
|
306
|
+
// source ~/MindrianRooms/.../reward-before-investment-rule.md):
|
|
307
|
+
// new-project -> instant_brief (line 56-58)
|
|
308
|
+
// file-meeting -> paragraph_preview (line 60-62)
|
|
309
|
+
// grade -> calibration_distribution_preview (line 64-66)
|
|
310
|
+
// onboard -> reframe_question (line 68-70)
|
|
311
|
+
const prescribed = {
|
|
312
|
+
'new-project.md': 'instant_brief',
|
|
313
|
+
'file-meeting.md': 'paragraph_preview',
|
|
314
|
+
'grade.md': 'calibration_distribution_preview',
|
|
315
|
+
'onboard.md': 'reframe_question',
|
|
316
|
+
};
|
|
317
|
+
for (const [filename, expected] of Object.entries(prescribed)) {
|
|
318
|
+
const fpath = path.resolve(REPO_ROOT, 'commands', filename);
|
|
319
|
+
const fm = parseFrontmatter(fs.readFileSync(fpath, 'utf8'));
|
|
320
|
+
assert.ok(fm, 'commands/' + filename + ' must have parseable frontmatter');
|
|
321
|
+
assert.equal(
|
|
322
|
+
fm.interactive_first_reward,
|
|
323
|
+
expected,
|
|
324
|
+
'commands/' + filename + ' declares ' + JSON.stringify(fm.interactive_first_reward) +
|
|
325
|
+
' but rule doc prescribes ' + JSON.stringify(expected) + ' (WARN-5 4-command parity audit)'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---------- summary ----------
|
|
331
|
+
|
|
332
|
+
process.stdout.write('\nmva-rule-linter tests: ' + passed + '/' + (passed + failed) + ' passed\n');
|
|
333
|
+
if (failed > 0) {
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
process.exit(0);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Phase 118-00 Plan 00 -- mva-state: session-scoped state I/O for the
|
|
4
|
+
* 30-Second MVA pipeline. The wire between Plan 118-00 (this plan, writer)
|
|
5
|
+
* and Plans 118-01..05 (readers + dispatch).
|
|
6
|
+
*
|
|
7
|
+
* State file: ~/.mindrian/mva/<session-id>.json
|
|
8
|
+
* session-id = process.env.CLAUDE_SESSION_ID || 'default'
|
|
9
|
+
*
|
|
10
|
+
* Schema:
|
|
11
|
+
* {
|
|
12
|
+
* sentence_sha256: sha256 hex of the user's prompt sentence (64 chars)
|
|
13
|
+
* -- NEVER the raw sentence (Canon Part 8)
|
|
14
|
+
* classified_at: epoch ms
|
|
15
|
+
* classifier_source: 'heuristic' | 'heuristic_fallback' | 'language_detect' | 'haiku-4-5'
|
|
16
|
+
* classifier_confidence: 'high' | 'medium' | 'low'
|
|
17
|
+
* locale: 'en' | 'he'
|
|
18
|
+
* hebrew_refusal: true -- only present when LD1 short-circuit fired
|
|
19
|
+
* pipeline_status: 'pending' | 'running' | 'complete'
|
|
20
|
+
* started_at: epoch ms (set by markRunning)
|
|
21
|
+
* completed_at: epoch ms (set by markComplete)
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Atomicity: every write goes to <session-id>.json.tmp.<pid>.<rand> then
|
|
25
|
+
* fs.renameSync to <session-id>.json. Mirrors the lib/core/rs-egress-telemetry
|
|
26
|
+
* tmp+rename pattern (Phase 89.2 Plan 01). A crashed writer cannot leave a
|
|
27
|
+
* half-JSON readers would choke on.
|
|
28
|
+
*
|
|
29
|
+
* Canon Part 8: file is LOCAL only. Zero network surface. The sentence_sha256
|
|
30
|
+
* is a one-way hash; no path from this file back to the raw prompt.
|
|
31
|
+
*
|
|
32
|
+
* Pure CJS, node built-ins only (fs, path, os).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const os = require('node:os');
|
|
38
|
+
|
|
39
|
+
function homeDir() {
|
|
40
|
+
// Match the env-aware home resolution pattern from resolve-brain-key.cjs
|
|
41
|
+
// so hermetic tests overriding process.env.HOME on Linux/POSIX work
|
|
42
|
+
// (os.homedir reads /etc/passwd and ignores process.env.HOME).
|
|
43
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stateDir() {
|
|
47
|
+
return path.join(homeDir(), '.mindrian', 'mva');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sessionId() {
|
|
51
|
+
const s = process.env.CLAUDE_SESSION_ID;
|
|
52
|
+
return (typeof s === 'string' && s.length > 0) ? s : 'default';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stateFile() {
|
|
56
|
+
return path.join(stateDir(), sessionId() + '.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureDir() {
|
|
60
|
+
const dir = stateDir();
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
} catch (_e) {
|
|
64
|
+
// best-effort; the writeFileSync will surface a clearer error
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Atomic write: writeFileSync to a tmp path, then renameSync into place.
|
|
70
|
+
* Throws on fs error -- callers (hook scripts) MUST wrap in try/catch and
|
|
71
|
+
* exit 0 to honor the Phase 117 hook-best-effort contract.
|
|
72
|
+
*/
|
|
73
|
+
function _atomicWrite(target, body) {
|
|
74
|
+
ensureDir();
|
|
75
|
+
const tmp = target + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
76
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
77
|
+
fs.renameSync(tmp, target);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the current state file. Returns null on absent / parse-error /
|
|
82
|
+
* read-error (graceful degradation; readers treat null as "no pending").
|
|
83
|
+
*/
|
|
84
|
+
function readPending() {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = fs.readFileSync(stateFile(), 'utf8');
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return null; // ENOENT or permission -- treat as no state
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch (_e) {
|
|
94
|
+
return null; // corrupt file -- treat as no state
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write the venture-classified or hebrew-refusal payload. Initializes
|
|
100
|
+
* pipeline_status='pending' so the dispatcher (Plan 118-01) knows to fire.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} payload Must include sentence_sha256, classified_at,
|
|
103
|
+
* classifier_source, classifier_confidence, locale.
|
|
104
|
+
* May include hebrew_refusal:true (per LD1).
|
|
105
|
+
*/
|
|
106
|
+
function writePending(payload) {
|
|
107
|
+
const body = Object.assign({}, payload, { pipeline_status: 'pending' });
|
|
108
|
+
_atomicWrite(stateFile(), JSON.stringify(body));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Transition pipeline_status -> 'running'. The dispatcher calls this when
|
|
113
|
+
* it begins the 6-agent fan-out so a re-entrant UserPromptSubmit does not
|
|
114
|
+
* re-fire the pipeline for an in-flight session.
|
|
115
|
+
*/
|
|
116
|
+
function markRunning() {
|
|
117
|
+
const cur = readPending();
|
|
118
|
+
if (!cur) return; // nothing to mark; silent
|
|
119
|
+
const body = Object.assign({}, cur, {
|
|
120
|
+
pipeline_status: 'running',
|
|
121
|
+
started_at: Date.now(),
|
|
122
|
+
});
|
|
123
|
+
_atomicWrite(stateFile(), JSON.stringify(body));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Transition pipeline_status -> 'complete'. The orchestrator calls this
|
|
128
|
+
* after the deck deploys (Plan 118-04) or the budget exhausts gracefully.
|
|
129
|
+
*/
|
|
130
|
+
function markComplete() {
|
|
131
|
+
const cur = readPending();
|
|
132
|
+
if (!cur) return;
|
|
133
|
+
const body = Object.assign({}, cur, {
|
|
134
|
+
pipeline_status: 'complete',
|
|
135
|
+
completed_at: Date.now(),
|
|
136
|
+
});
|
|
137
|
+
_atomicWrite(stateFile(), JSON.stringify(body));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @returns {boolean} true if a pipeline is currently running for this session
|
|
142
|
+
* (the hook uses this to gate re-fire on a second UserPromptSubmit).
|
|
143
|
+
*/
|
|
144
|
+
function isAlreadyRunning() {
|
|
145
|
+
const cur = readPending();
|
|
146
|
+
return !!(cur && cur.pipeline_status === 'running');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
writePending,
|
|
151
|
+
readPending,
|
|
152
|
+
markRunning,
|
|
153
|
+
markComplete,
|
|
154
|
+
isAlreadyRunning,
|
|
155
|
+
stateDir,
|
|
156
|
+
// exposed for tests + downstream plans that need the canonical path
|
|
157
|
+
stateFile,
|
|
158
|
+
sessionId,
|
|
159
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 118-03 Plan 03 -- mva-telemetry.
|
|
5
|
+
*
|
|
6
|
+
* JSONL writer for the 6 Phase 121 MVA event types. Atomic append to
|
|
7
|
+
* ~/.mindrian/telemetry/v1.13/mva.jsonl. Schema-enforced (rejects events
|
|
8
|
+
* with raw user content; per-event ALLOWED_FIELDS is the source of truth
|
|
9
|
+
* for Plan 118-06's Dror harness grep test).
|
|
10
|
+
*
|
|
11
|
+
* Per Plan 118-03 OQ8 (resolved): the 6 event types are
|
|
12
|
+
* mva_pipeline_started, mva_agent_returned, mva_brief_rendered,
|
|
13
|
+
* mva_option_selected, mva_brief_deployed, mva_pipeline_failed.
|
|
14
|
+
*
|
|
15
|
+
* CRITICAL invariant (WARN-2 from iteration 2): the mva_brief_rendered
|
|
16
|
+
* event uses `total_duration_ms` (NOT `duration_ms`). Plan 118-06's
|
|
17
|
+
* Dror harness greps ALLOWED_FIELDS.mva_brief_rendered to assert this.
|
|
18
|
+
*
|
|
19
|
+
* Canon Part 8 (LOCAL telemetry):
|
|
20
|
+
* - Sentence-related identifier is sentence_sha256 ONLY (one-way hash).
|
|
21
|
+
* - Every string field is capped (sentence_sha256=64 exact, error_short<=60,
|
|
22
|
+
* other strings<=64) to prevent raw user content from sneaking through.
|
|
23
|
+
* - Fields not in ALLOWED_FIELDS for the event type are rejected.
|
|
24
|
+
*
|
|
25
|
+
* Atomic append: fs.appendFileSync writes a single short line. POSIX
|
|
26
|
+
* append semantics guarantee atomicity for writes within PIPE_BUF (4096
|
|
27
|
+
* bytes on Linux). Our lines are < 512 bytes, well below the limit.
|
|
28
|
+
*
|
|
29
|
+
* Pure CJS, node built-ins only.
|
|
30
|
+
*/
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
|
|
37
|
+
// ---------- Frozen invariants ----------
|
|
38
|
+
|
|
39
|
+
const EVENT_TYPES = Object.freeze([
|
|
40
|
+
'mva_pipeline_started',
|
|
41
|
+
'mva_agent_returned',
|
|
42
|
+
'mva_brief_rendered',
|
|
43
|
+
'mva_option_selected',
|
|
44
|
+
'mva_brief_deployed',
|
|
45
|
+
'mva_pipeline_failed'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Per-event scalar field schema. Source-of-truth for Plan 118-06 Dror harness.
|
|
49
|
+
// CRITICAL: mva_brief_rendered uses 'total_duration_ms' (NOT 'duration_ms').
|
|
50
|
+
const ALLOWED_FIELDS = Object.freeze({
|
|
51
|
+
mva_pipeline_started: Object.freeze(['sentence_sha256']),
|
|
52
|
+
mva_agent_returned: Object.freeze(['sentence_sha256', 'agent_id', 'duration_ms', 'status', 'error_short']),
|
|
53
|
+
mva_brief_rendered: Object.freeze(['sentence_sha256', 'total_duration_ms', 'agent_count_ok', 'agent_count_failed']),
|
|
54
|
+
mva_option_selected: Object.freeze(['sentence_sha256', 'option_id', 'time_to_click_ms']),
|
|
55
|
+
mva_brief_deployed: Object.freeze(['sentence_sha256', 'vercel_subdomain_hash', 'deploy_duration_ms', 'status', 'error_short']),
|
|
56
|
+
mva_pipeline_failed: Object.freeze(['sentence_sha256', 'total_duration_ms', 'error_short'])
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// String-length caps. error_short is special-cased to <= 60.
|
|
60
|
+
// sentence_sha256 must be exactly 64 hex chars.
|
|
61
|
+
const MAX_STRING_LEN = 64;
|
|
62
|
+
const MAX_ERROR_SHORT_LEN = 60;
|
|
63
|
+
const SHA256_LEN = 64;
|
|
64
|
+
|
|
65
|
+
// ---------- Path resolvers (env-aware for hermetic testing) ----------
|
|
66
|
+
|
|
67
|
+
function homeDir() {
|
|
68
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function telemetryDir() {
|
|
72
|
+
return path.join(homeDir(), '.mindrian', 'telemetry', 'v1.13');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function telemetryFile() {
|
|
76
|
+
return path.join(telemetryDir(), 'mva.jsonl');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- Validation ----------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* validateEventPayload(event, payload) -> { ok: boolean, error?: string }
|
|
83
|
+
*
|
|
84
|
+
* Returns ok:false if:
|
|
85
|
+
* - event is not in EVENT_TYPES
|
|
86
|
+
* - any key in payload is not in ALLOWED_FIELDS[event]
|
|
87
|
+
* - any string value violates the per-field length cap
|
|
88
|
+
*
|
|
89
|
+
* Numeric fields are not length-checked. The session_id field is added
|
|
90
|
+
* by emit() (not the caller), so it is not validated against ALLOWED_FIELDS.
|
|
91
|
+
*/
|
|
92
|
+
function validateEventPayload(event, payload) {
|
|
93
|
+
if (!EVENT_TYPES.includes(event)) {
|
|
94
|
+
return { ok: false, error: 'unknown_event' };
|
|
95
|
+
}
|
|
96
|
+
if (!payload || typeof payload !== 'object') {
|
|
97
|
+
return { ok: false, error: 'payload_not_object' };
|
|
98
|
+
}
|
|
99
|
+
const allowed = ALLOWED_FIELDS[event];
|
|
100
|
+
for (const key of Object.keys(payload)) {
|
|
101
|
+
if (!allowed.includes(key)) {
|
|
102
|
+
return { ok: false, error: 'unknown_field:' + key };
|
|
103
|
+
}
|
|
104
|
+
const v = payload[key];
|
|
105
|
+
if (typeof v === 'string') {
|
|
106
|
+
if (key === 'sentence_sha256') {
|
|
107
|
+
if (v.length !== SHA256_LEN) {
|
|
108
|
+
return { ok: false, error: 'sha256_length_invalid' };
|
|
109
|
+
}
|
|
110
|
+
} else if (key === 'error_short') {
|
|
111
|
+
if (v.length > MAX_ERROR_SHORT_LEN) {
|
|
112
|
+
return { ok: false, error: 'error_short_too_long' };
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
if (v.length > MAX_STRING_LEN) {
|
|
116
|
+
return { ok: false, error: 'string_too_long:' + key };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- Public emit() ----------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* emit(event, payload) -> appends one JSONL line to ~/.mindrian/telemetry/v1.13/mva.jsonl
|
|
128
|
+
*
|
|
129
|
+
* Validates first. On invalid payload throws a ValidationError (so callers
|
|
130
|
+
* cannot silently leak). On disk error returns silently (best-effort: the
|
|
131
|
+
* pipeline must not crash because telemetry is unavailable).
|
|
132
|
+
*/
|
|
133
|
+
function emit(event, payload) {
|
|
134
|
+
const v = validateEventPayload(event, payload);
|
|
135
|
+
if (!v.ok) {
|
|
136
|
+
const e = new Error('telemetry validation failed: ' + v.error);
|
|
137
|
+
e.code = 'TELEMETRY_VALIDATION';
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const record = Object.assign(
|
|
142
|
+
{
|
|
143
|
+
event: event,
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
session_id: (typeof process.env.CLAUDE_SESSION_ID === 'string' && process.env.CLAUDE_SESSION_ID.length > 0)
|
|
146
|
+
? process.env.CLAUDE_SESSION_ID.slice(0, MAX_STRING_LEN)
|
|
147
|
+
: 'default'
|
|
148
|
+
},
|
|
149
|
+
payload
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
fs.mkdirSync(telemetryDir(), { recursive: true });
|
|
154
|
+
fs.appendFileSync(telemetryFile(), JSON.stringify(record) + '\n', 'utf8');
|
|
155
|
+
} catch (_e) {
|
|
156
|
+
// Best-effort. The MVA pipeline must not fail because telemetry disk
|
|
157
|
+
// is unavailable. Plan 118-06's Dror harness checks for the file's
|
|
158
|
+
// existence post-run as the success signal.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
emit,
|
|
164
|
+
validateEventPayload,
|
|
165
|
+
EVENT_TYPES,
|
|
166
|
+
ALLOWED_FIELDS,
|
|
167
|
+
// Exposed for tests + downstream plans that need the canonical path
|
|
168
|
+
telemetryDir,
|
|
169
|
+
telemetryFile,
|
|
170
|
+
};
|