@mindrian_os/install 1.13.0-beta.12 → 1.13.0-beta.14
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 +57 -10
- package/README.md +74 -572
- package/commands/act.md +1 -0
- package/commands/admin.md +1 -0
- package/commands/analyze-needs.md +1 -0
- package/commands/analyze-systems.md +1 -0
- package/commands/analyze-timing.md +1 -0
- package/commands/auto-explore.md +2 -0
- package/commands/beautiful-question.md +1 -0
- package/commands/brain-derive.md +1 -0
- package/commands/build-knowledge.md +1 -0
- package/commands/build-thesis.md +1 -0
- package/commands/causal.md +1 -0
- package/commands/challenge-assumptions.md +1 -0
- package/commands/compare-ventures.md +1 -0
- package/commands/dashboard.md +1 -0
- package/commands/deep-grade.md +1 -0
- package/commands/diagnose.md +1 -0
- package/commands/diagnostics.md +1 -0
- package/commands/doctor.md +2 -1
- package/commands/dominant-designs.md +1 -0
- package/commands/explain-decision.md +1 -0
- package/commands/explore-domains.md +1 -0
- package/commands/explore-futures.md +1 -0
- package/commands/explore-trends.md +1 -0
- package/commands/export.md +1 -0
- package/commands/feynman-timeline-refresh.md +78 -0
- package/commands/file-meeting.md +1 -0
- package/commands/find-analogies.md +1 -0
- package/commands/find-bottlenecks.md +1 -0
- package/commands/find-connections.md +1 -0
- package/commands/funding.md +1 -0
- package/commands/grade.md +1 -0
- package/commands/graph.md +1 -0
- package/commands/hat-briefing.md +1 -0
- package/commands/heal.md +1 -0
- package/commands/help.md +1 -0
- package/commands/hmi-status.md +1 -0
- package/commands/jtbd.md +1 -0
- package/commands/leadership.md +1 -0
- package/commands/lean-canvas.md +1 -0
- package/commands/macro-trends.md +1 -0
- package/commands/map-unknowns.md +1 -0
- package/commands/memory.md +1 -0
- package/commands/models.md +1 -0
- package/commands/mos-reason.md +1 -0
- package/commands/mullins.md +1 -0
- package/commands/new-project.md +1 -0
- package/commands/onboard.md +1 -0
- package/commands/operator.md +2 -1
- package/commands/opportunities.md +1 -0
- package/commands/organize.md +1 -0
- package/commands/persona.md +1 -0
- package/commands/pipeline.md +1 -0
- package/commands/present.md +1 -0
- package/commands/publish.md +1 -0
- package/commands/query.md +1 -0
- package/commands/radar.md +1 -0
- package/commands/reanalyze.md +1 -0
- package/commands/research.md +1 -0
- package/commands/room.md +1 -0
- package/commands/rooms.md +1 -0
- package/commands/root-cause.md +1 -0
- package/commands/rs-experts.md +1 -0
- package/commands/rs-explain.md +1 -0
- package/commands/rs-fetch.md +1 -0
- package/commands/rs-thesis.md +1 -0
- package/commands/scenario-plan.md +1 -0
- package/commands/scheduled-tasks.md +1 -0
- package/commands/score-innovation.md +1 -0
- package/commands/scout.md +1 -0
- package/commands/setup.md +8 -3
- package/commands/snapshot.md +1 -0
- package/commands/speakers.md +1 -0
- package/commands/splash.md +1 -0
- package/commands/status.md +1 -0
- package/commands/structure-argument.md +1 -0
- package/commands/suggest-next.md +1 -0
- package/commands/systems-thinking.md +1 -0
- package/commands/think-hats.md +1 -0
- package/commands/update.md +1 -0
- package/commands/user-needs.md +1 -0
- package/commands/validate.md +1 -0
- package/commands/value-proposition.md +1 -0
- package/commands/vault.md +1 -0
- package/commands/visualize.md +1 -0
- package/commands/whitespace.md +1 -0
- package/commands/wiki.md +1 -0
- package/lib/brain/framework-chain-slice.cjs +193 -0
- package/lib/core/active-plugin-root.cjs +71 -6
- package/lib/core/brain-client.cjs +451 -36
- package/lib/core/cache-prune.cjs +208 -0
- package/lib/core/feynman/ROOM.md +25 -0
- package/lib/core/feynman/timeline-renderer.cjs +197 -0
- package/lib/core/feynman/timeline-runner.cjs +281 -0
- package/lib/core/navigation/edges.cjs +86 -0
- package/lib/core/navigation/insights.cjs +37 -0
- package/lib/core/navigation/memory-events.cjs +56 -1
- package/lib/core/navigation/neighborhood.cjs +5 -4
- package/lib/core/navigation/packet.cjs +176 -10
- package/lib/core/navigation/projections.cjs +201 -0
- package/lib/core/navigation.cjs +31 -0
- package/lib/core/resolve-brain-key.cjs +201 -0
- package/lib/mcp/larry-server-instructions.md +1 -1
- package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
- package/lib/memory/f-selector-ranker.test.cjs +593 -0
- package/lib/memory/navigation-projections.test.cjs +241 -0
- package/lib/memory/navigation-write-edge.test.cjs +206 -0
- package/lib/memory/packet-chain-hint.test.cjs +407 -0
- package/lib/memory/packet-schema-validation.test.cjs +317 -0
- package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
- package/lib/memory/per-command-teaching.test.cjs +110 -0
- package/lib/memory/run-feynman-tests.cjs +121 -0
- package/lib/memory/security-trifecta.test.cjs +23 -6
- package/lib/memory/selector-decisions.test.cjs +417 -0
- package/lib/memory/selector-miss.test.cjs +290 -0
- package/lib/workflow/f-selector-ranker.cjs +420 -0
- package/lib/workflow/selector-decisions.cjs +368 -0
- package/package.json +4 -1
- package/references/design/email-template-standard.md +1 -1
- package/references/user-research/2026-04-05-leah-lawrence-session.md +3 -3
- package/skills/brain-connector/SKILL.md +9 -3
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* lib/core/resolve-brain-key.cjs -- the ONE resolver for "where is the Brain
|
|
4
|
+
* API key on this machine?" (Phase 123 Plan-07).
|
|
5
|
+
*
|
|
6
|
+
* Background: before this module, three different places guessed independently
|
|
7
|
+
* how to discover the Brain key (brain-client.cjs::getApiKey, session-start's
|
|
8
|
+
* shell test of MINDRIAN_BRAIN_KEY, brain-connector's skill detection). Three
|
|
9
|
+
* guessers, three failure modes -- a standard install with the key in
|
|
10
|
+
* ~/.mindrian.env was visible to brain-client but invisible to session-start,
|
|
11
|
+
* so a working HTTP-path Brain still printed a Tier-0 MCP warning. This
|
|
12
|
+
* collapses them into one source of truth. Mirrors active-plugin-root.cjs
|
|
13
|
+
* (the resolver this one is patterned on).
|
|
14
|
+
*
|
|
15
|
+
* Precedence (first hit wins) per D-31:
|
|
16
|
+
* 1. MINDRIAN_BRAIN_KEY env var -- explicit operator intent, highest.
|
|
17
|
+
* 2. <home>/.mindrian.env -- global backup, persists across CWDs.
|
|
18
|
+
* 3. <cwd>/.env -- project-local override.
|
|
19
|
+
* 4. not-found.
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT (FLAG-3 fix). The default for `home` is
|
|
22
|
+
* process.env.HOME || process.env.USERPROFILE || os.homedir()
|
|
23
|
+
* -- NOT bare os.homedir(). On Linux/POSIX, os.homedir() reads /etc/passwd and
|
|
24
|
+
* IGNORES process.env.HOME, which breaks hermetic tests that override HOME to
|
|
25
|
+
* a scratch directory. The env-aware default matches the precedent in
|
|
26
|
+
* scripts/doctor.cjs, scripts/session-start, and lib/core/active-plugin-root.cjs.
|
|
27
|
+
*
|
|
28
|
+
* Returns { key, source, available, reason }:
|
|
29
|
+
* - available=true: key is the trimmed value, reason is null.
|
|
30
|
+
* - available=false: key is null, reason is the explicit cause string
|
|
31
|
+
* (never a silent null -- SEC-02 rejections route here too).
|
|
32
|
+
*
|
|
33
|
+
* SEC-02 permission check (POSIX-only, no-op on Windows). When the resolved
|
|
34
|
+
* source is a key file (~/.mindrian.env or CWD .env), stat the mode. If any
|
|
35
|
+
* group or world bit is set (mode & 0o077), return available:false with the
|
|
36
|
+
* explicit reason "permissions too open: <path> is mode 0NNN, must be 0600".
|
|
37
|
+
*
|
|
38
|
+
* Use as a module:
|
|
39
|
+
* const { resolveBrainKey } = require('<...>/lib/core/resolve-brain-key.cjs');
|
|
40
|
+
* const r = resolveBrainKey(); // r.key, r.source, r.available, r.reason
|
|
41
|
+
*
|
|
42
|
+
* Use as a CLI (so scripts/session-start can shell out without a JSON parser
|
|
43
|
+
* in bash):
|
|
44
|
+
* node lib/core/resolve-brain-key.cjs -> prints JSON, exits 0
|
|
45
|
+
*
|
|
46
|
+
* Canon Part 8: this reads LOCAL files and env only. Zero network surface.
|
|
47
|
+
* The plan's verification grep against this file's source returns nothing
|
|
48
|
+
* -- no network markers in this resolver, ever. The actual Brain call is
|
|
49
|
+
* brain-client.cjs's job; this module only DISCOVERS whether a key exists.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
const os = require('node:os');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SEC-02 permission gate. POSIX-only -- on Windows POSIX mode bits do not
|
|
58
|
+
* translate to NTFS ACLs, so we return ok unconditionally there (a single
|
|
59
|
+
* stderr note would couple this resolver to a side-effect; the calling layer
|
|
60
|
+
* is the right place to surface that one-time note).
|
|
61
|
+
*
|
|
62
|
+
* mode & 0o077 !== 0 => any group or world bit set => reject.
|
|
63
|
+
* 0o600 / 0o400 pass; 0o644 / 0o664 / 0o666 fail.
|
|
64
|
+
*
|
|
65
|
+
* On stat failure (file vanished between exists() and stat() -- rare but
|
|
66
|
+
* possible) we treat it as not-ok with an explanatory reason rather than
|
|
67
|
+
* pretending the file is fine.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} p the file path
|
|
70
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
71
|
+
*/
|
|
72
|
+
function checkFilePermissions(p) {
|
|
73
|
+
if (process.platform === 'win32') {
|
|
74
|
+
return { ok: true, reason: null };
|
|
75
|
+
}
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = fs.statSync(p);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, reason: 'unable to stat ' + p + ': ' + (e && e.code || String(e)) };
|
|
81
|
+
}
|
|
82
|
+
const mode = stat.mode & 0o777;
|
|
83
|
+
if ((mode & 0o077) !== 0) {
|
|
84
|
+
const modeStr = mode.toString(8).padStart(3, '0');
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: 'permissions too open: ' + p + ' is mode 0' + modeStr + ', must be 0600',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, reason: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a single MINDRIAN_BRAIN_KEY=... line out of a (possibly multi-line)
|
|
95
|
+
* env-file body. Returns the trimmed value, or null if the line is absent or
|
|
96
|
+
* the value is empty. We deliberately do NOT use a full dotenv parser -- one
|
|
97
|
+
* variable, one regex, no transitive dependency.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} body
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
function _parseKey(body) {
|
|
103
|
+
const m = body.match(/^MINDRIAN_BRAIN_KEY\s*=\s*(.+?)\s*$/m);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const v = m[1].trim();
|
|
106
|
+
return v.length > 0 ? v : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the Brain API key from the standard precedence chain.
|
|
111
|
+
*
|
|
112
|
+
* The `home` and `cwd` parameters exist as test seams. Their defaults match
|
|
113
|
+
* the precedent across the rest of the plugin (scripts/doctor.cjs, scripts/
|
|
114
|
+
* session-start, lib/core/active-plugin-root.cjs): env-aware home resolution
|
|
115
|
+
* so hermetic tests overriding process.env.HOME actually work on Linux/POSIX,
|
|
116
|
+
* where os.homedir() reads /etc/passwd and ignores the env override.
|
|
117
|
+
*
|
|
118
|
+
* @param {{home?: string, cwd?: string}} [opts]
|
|
119
|
+
* @returns {{key: string|null, source: 'env'|'mindrian-env-file'|'cwd-env-file'|'not-found', available: boolean, reason: string|null}}
|
|
120
|
+
*/
|
|
121
|
+
function resolveBrainKey(opts) {
|
|
122
|
+
const o = opts || {};
|
|
123
|
+
const home = o.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
124
|
+
const cwd = o.cwd || process.cwd();
|
|
125
|
+
|
|
126
|
+
// (1) Env var wins.
|
|
127
|
+
if (process.env.MINDRIAN_BRAIN_KEY) {
|
|
128
|
+
const v = String(process.env.MINDRIAN_BRAIN_KEY).trim();
|
|
129
|
+
if (v.length > 0) {
|
|
130
|
+
return { key: v, source: 'env', available: true, reason: null };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// (2) <home>/.mindrian.env
|
|
135
|
+
const mindrianEnvPath = path.join(home, '.mindrian.env');
|
|
136
|
+
if (fs.existsSync(mindrianEnvPath)) {
|
|
137
|
+
const perms = checkFilePermissions(mindrianEnvPath);
|
|
138
|
+
if (!perms.ok) {
|
|
139
|
+
return { key: null, source: 'mindrian-env-file', available: false, reason: perms.reason };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const body = fs.readFileSync(mindrianEnvPath, 'utf8');
|
|
143
|
+
const v = _parseKey(body);
|
|
144
|
+
if (v) {
|
|
145
|
+
return { key: v, source: 'mindrian-env-file', available: true, reason: null };
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return {
|
|
149
|
+
key: null,
|
|
150
|
+
source: 'mindrian-env-file',
|
|
151
|
+
available: false,
|
|
152
|
+
reason: 'unable to read ' + mindrianEnvPath + ': ' + (e && e.code || String(e)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// (3) <cwd>/.env
|
|
158
|
+
const cwdEnvPath = path.join(cwd, '.env');
|
|
159
|
+
if (fs.existsSync(cwdEnvPath)) {
|
|
160
|
+
const perms = checkFilePermissions(cwdEnvPath);
|
|
161
|
+
if (!perms.ok) {
|
|
162
|
+
return { key: null, source: 'cwd-env-file', available: false, reason: perms.reason };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const body = fs.readFileSync(cwdEnvPath, 'utf8');
|
|
166
|
+
const v = _parseKey(body);
|
|
167
|
+
if (v) {
|
|
168
|
+
return { key: v, source: 'cwd-env-file', available: true, reason: null };
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return {
|
|
172
|
+
key: null,
|
|
173
|
+
source: 'cwd-env-file',
|
|
174
|
+
available: false,
|
|
175
|
+
reason: 'unable to read ' + cwdEnvPath + ': ' + (e && e.code || String(e)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// (4) not-found.
|
|
181
|
+
return {
|
|
182
|
+
key: null,
|
|
183
|
+
source: 'not-found',
|
|
184
|
+
available: false,
|
|
185
|
+
reason: 'MINDRIAN_BRAIN_KEY not set (env), and neither ' + mindrianEnvPath
|
|
186
|
+
+ ' nor ' + cwdEnvPath + ' contains a MINDRIAN_BRAIN_KEY line',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { resolveBrainKey, checkFilePermissions };
|
|
191
|
+
|
|
192
|
+
// CLI entry point. session-start shells out via:
|
|
193
|
+
// node lib/core/resolve-brain-key.cjs
|
|
194
|
+
// and parses the JSON. Exit 0 always (the consumer reads the JSON to learn
|
|
195
|
+
// available/reason); a non-zero exit here would surface as "resolver failed"
|
|
196
|
+
// rather than the clearer not-found / permissions-too-open branches.
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
const r = resolveBrainKey();
|
|
199
|
+
process.stdout.write(JSON.stringify(r) + '\n');
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Larry -- MCP Server Instructions
|
|
2
2
|
|
|
3
|
-
You are Larry, a thinking partner modeled on Prof. Lawrence Aronhime (30+ years teaching innovation
|
|
3
|
+
You are Larry, a thinking partner modeled on Prof. Lawrence Aronhime (30+ years teaching innovation). NOT a textbook, NOT a framework dispenser. If your response looks like a PDF, start over.
|
|
4
4
|
|
|
5
5
|
## Voice
|
|
6
6
|
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phase 125-02 -- Brain Cypher chain slice (framework_chain_hint) tests.
|
|
6
|
+
*
|
|
7
|
+
* Run: node --test lib/memory/brain-cypher-chain-slice.test.cjs
|
|
8
|
+
* Exit 0 on pass. Uses node:test + node:assert/strict.
|
|
9
|
+
*
|
|
10
|
+
* Coverage maps 1:1 to 125-02-PLAN.md Task 1 <behavior> Tests 1-10:
|
|
11
|
+
* 1. Happy-path: 3 rows -> 3 edges, slice_scope=2, snapshot_id non-null.
|
|
12
|
+
* 2. LIMIT 50 verbatim in the Cypher template constant.
|
|
13
|
+
* 3. Sanitization called on each framework name; bound value is sanitized.
|
|
14
|
+
* 4. max_hops bound to 1..3 enum (0 and 4 degrade; 1, 2, 3 valid).
|
|
15
|
+
* 5. Empty active_frameworks short-circuits (no Brain call).
|
|
16
|
+
* 6. Brain unreachable (isAvailable=false) degrades.
|
|
17
|
+
* 7. Brain throws degrades (slice_rationale carries brain_query_failed).
|
|
18
|
+
* 8. Result mapping: 5 fields propagate verbatim.
|
|
19
|
+
* 9. Null tolerance (G-05): null confidence + null transform_description preserved.
|
|
20
|
+
* 10. brain_snapshot_id is sha256 hash of the JSON-stringified raw rows.
|
|
21
|
+
*
|
|
22
|
+
* Plus Canon Part 8 grep audit: the Cypher template contains zero user-content
|
|
23
|
+
* tokens (no roomDir, no artifact, no body, no transcript token).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { test } = require('node:test');
|
|
27
|
+
const assert = require('node:assert/strict');
|
|
28
|
+
const crypto = require('node:crypto');
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
fetchFrameworkChainSlice,
|
|
33
|
+
FRAMEWORK_CHAIN_SLICE_CYPHER,
|
|
34
|
+
_test,
|
|
35
|
+
} = require('/home/jsagi/MindrianOS-Plugin/lib/brain/framework-chain-slice.cjs');
|
|
36
|
+
|
|
37
|
+
// Build a mock brainClient with stubbed isAvailable + query + _test seam.
|
|
38
|
+
// sanitizationCalls records every arg passed to sanitizeCypherInput so the
|
|
39
|
+
// test can assert call counts + bound-value equivalence.
|
|
40
|
+
function makeMockClient(opts) {
|
|
41
|
+
const o = opts || {};
|
|
42
|
+
const sanitizationCalls = [];
|
|
43
|
+
const queryInvocations = [];
|
|
44
|
+
const sanitizer = function (v) {
|
|
45
|
+
sanitizationCalls.push(v);
|
|
46
|
+
// Match the shipped whitelist exactly: [a-zA-Z0-9 ._-]
|
|
47
|
+
return String(v == null ? '' : v).replace(/[^a-zA-Z0-9 ._-]/g, '');
|
|
48
|
+
};
|
|
49
|
+
const isAvailable = (typeof o.available === 'boolean') ? o.available : true;
|
|
50
|
+
const queryFn = (typeof o.queryFn === 'function')
|
|
51
|
+
? o.queryFn
|
|
52
|
+
: (async function () { return []; });
|
|
53
|
+
return {
|
|
54
|
+
isAvailable: function () { return isAvailable; },
|
|
55
|
+
query: async function (cypher, params) {
|
|
56
|
+
queryInvocations.push({ cypher: cypher, params: params });
|
|
57
|
+
return queryFn(cypher, params);
|
|
58
|
+
},
|
|
59
|
+
_test: {
|
|
60
|
+
sanitizeCypherInput: sanitizer,
|
|
61
|
+
},
|
|
62
|
+
// Exposed on the mock for the test to inspect (not on real brain-client).
|
|
63
|
+
_sanitizationCalls: sanitizationCalls,
|
|
64
|
+
_queryInvocations: queryInvocations,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- Test 2 (FIRST,
|
|
69
|
+
// pure-constant check; runs without any async).
|
|
70
|
+
|
|
71
|
+
test('Test 2: Cypher template constant contains LIMIT 50 verbatim', function () {
|
|
72
|
+
assert.ok(
|
|
73
|
+
typeof FRAMEWORK_CHAIN_SLICE_CYPHER === 'string'
|
|
74
|
+
&& FRAMEWORK_CHAIN_SLICE_CYPHER.length > 0,
|
|
75
|
+
'FRAMEWORK_CHAIN_SLICE_CYPHER must be a non-empty string'
|
|
76
|
+
);
|
|
77
|
+
assert.ok(
|
|
78
|
+
FRAMEWORK_CHAIN_SLICE_CYPHER.includes('LIMIT 50'),
|
|
79
|
+
'FRAMEWORK_CHAIN_SLICE_CYPHER must contain literal "LIMIT 50"'
|
|
80
|
+
);
|
|
81
|
+
// Bonus: the parameterized hop range marker.
|
|
82
|
+
assert.ok(
|
|
83
|
+
FRAMEWORK_CHAIN_SLICE_CYPHER.includes('FEEDS_INTO*1..$max_hops'),
|
|
84
|
+
'Cypher must include parameterized hop range "FEEDS_INTO*1..$max_hops"'
|
|
85
|
+
);
|
|
86
|
+
// Canon Part 8 grep: no user-content tokens in the template.
|
|
87
|
+
const forbidden = ['roomDir', 'artifact', 'body', 'transcript', 'roomSlug'];
|
|
88
|
+
for (const tok of forbidden) {
|
|
89
|
+
assert.ok(
|
|
90
|
+
!FRAMEWORK_CHAIN_SLICE_CYPHER.includes(tok),
|
|
91
|
+
'Cypher must not contain user-content token "' + tok + '"'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// Read-only: no write keywords in the Cypher template.
|
|
95
|
+
const writeKeywords = ['MERGE', 'CREATE', 'DELETE', 'SET ', 'REMOVE'];
|
|
96
|
+
for (const kw of writeKeywords) {
|
|
97
|
+
assert.ok(
|
|
98
|
+
!FRAMEWORK_CHAIN_SLICE_CYPHER.includes(kw),
|
|
99
|
+
'Cypher must be read-only (no "' + kw + '")'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------- Test 1
|
|
105
|
+
|
|
106
|
+
test('Test 1: happy-path returns 3 mapped edges + slice_scope=2 + non-null snapshot_id', async function () {
|
|
107
|
+
const rows = [
|
|
108
|
+
{ from: 'A', to: 'B', confidence: 0.9, transform_description: 'a->b', hop_distance: 1 },
|
|
109
|
+
{ from: 'A', to: 'C', confidence: 0.7, transform_description: 'a->c', hop_distance: 2 },
|
|
110
|
+
{ from: 'B', to: 'D', confidence: 0.8, transform_description: 'b->d', hop_distance: 2 },
|
|
111
|
+
];
|
|
112
|
+
const mock = makeMockClient({ queryFn: async function () { return rows; } });
|
|
113
|
+
const result = await fetchFrameworkChainSlice({
|
|
114
|
+
active_frameworks: ['Beautiful Question Framework'],
|
|
115
|
+
max_hops: 2,
|
|
116
|
+
brainClient: mock,
|
|
117
|
+
});
|
|
118
|
+
assert.equal(Array.isArray(result.edges), true);
|
|
119
|
+
assert.equal(result.edges.length, 3);
|
|
120
|
+
assert.equal(result.slice_scope, 2);
|
|
121
|
+
assert.equal(typeof result.brain_snapshot_id, 'string');
|
|
122
|
+
assert.equal(result.brain_snapshot_id.length, 64); // sha256 hex
|
|
123
|
+
assert.ok(result.slice_rationale.includes('brain_reachable'));
|
|
124
|
+
assert.equal(typeof result.fetched_at, 'string');
|
|
125
|
+
// ISO 8601 sanity check.
|
|
126
|
+
assert.ok(/^\d{4}-\d{2}-\d{2}T/.test(result.fetched_at));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------- Test 3
|
|
130
|
+
|
|
131
|
+
test('Test 3: sanitization called for every framework name; bound value is sanitized', async function () {
|
|
132
|
+
const mock = makeMockClient({ queryFn: async function () { return []; } });
|
|
133
|
+
const dangerous = "SWOT; DROP TABLE x";
|
|
134
|
+
await fetchFrameworkChainSlice({
|
|
135
|
+
active_frameworks: [dangerous, 'Beautiful Question Framework'],
|
|
136
|
+
max_hops: 1,
|
|
137
|
+
brainClient: mock,
|
|
138
|
+
});
|
|
139
|
+
// Sanitizer was called on each name.
|
|
140
|
+
assert.equal(mock._sanitizationCalls.length, 2);
|
|
141
|
+
assert.equal(mock._sanitizationCalls[0], dangerous);
|
|
142
|
+
// The bound active_frameworks param contains the SANITIZED string (no `;`).
|
|
143
|
+
assert.equal(mock._queryInvocations.length, 1);
|
|
144
|
+
const bound = mock._queryInvocations[0].params.active_frameworks;
|
|
145
|
+
assert.ok(Array.isArray(bound));
|
|
146
|
+
assert.ok(!bound[0].includes(';'), 'sanitized value must not contain ";"');
|
|
147
|
+
// The whitelist is [a-zA-Z0-9 ._-] -- letters survive, only the metacharacter
|
|
148
|
+
// `;` is stripped. The injection vector dies because the bound value is the
|
|
149
|
+
// sanitizer output (a plain identifier string), not raw user input.
|
|
150
|
+
assert.equal(bound[0], 'SWOT DROP TABLE x'); // ';' stripped, kept word chars
|
|
151
|
+
// The second framework name (clean) passes through unchanged.
|
|
152
|
+
assert.equal(bound[1], 'Beautiful Question Framework');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------- Test 4
|
|
156
|
+
|
|
157
|
+
test('Test 4: max_hops bound to 1..3 enum; 0 and 4 degrade; 1, 2, 3 valid', async function () {
|
|
158
|
+
const mock = makeMockClient({ queryFn: async function () { return []; } });
|
|
159
|
+
// max_hops = 0 -> degraded, no Brain call.
|
|
160
|
+
let r = await fetchFrameworkChainSlice({
|
|
161
|
+
active_frameworks: ['X'], max_hops: 0, brainClient: mock,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(r.edges.length, 0);
|
|
164
|
+
assert.ok(r.slice_rationale.includes('invalid_max_hops'));
|
|
165
|
+
// max_hops = 4 -> degraded.
|
|
166
|
+
r = await fetchFrameworkChainSlice({
|
|
167
|
+
active_frameworks: ['X'], max_hops: 4, brainClient: mock,
|
|
168
|
+
});
|
|
169
|
+
assert.ok(r.slice_rationale.includes('invalid_max_hops'));
|
|
170
|
+
// No Brain call should have been made for the two invalid hop counts.
|
|
171
|
+
assert.equal(mock._queryInvocations.length, 0);
|
|
172
|
+
// max_hops = 1, 2, 3 -> valid (Brain query issued).
|
|
173
|
+
for (const hops of [1, 2, 3]) {
|
|
174
|
+
const m2 = makeMockClient({ queryFn: async function () { return []; } });
|
|
175
|
+
const ok = await fetchFrameworkChainSlice({
|
|
176
|
+
active_frameworks: ['X'], max_hops: hops, brainClient: m2,
|
|
177
|
+
});
|
|
178
|
+
assert.equal(ok.slice_scope, hops);
|
|
179
|
+
assert.equal(m2._queryInvocations.length, 1);
|
|
180
|
+
assert.equal(m2._queryInvocations[0].params.max_hops, hops);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------- Test 5
|
|
185
|
+
|
|
186
|
+
test('Test 5: empty active_frameworks short-circuits without Brain call', async function () {
|
|
187
|
+
const mock = makeMockClient({ queryFn: async function () { return []; } });
|
|
188
|
+
const r = await fetchFrameworkChainSlice({
|
|
189
|
+
active_frameworks: [], max_hops: 2, brainClient: mock,
|
|
190
|
+
});
|
|
191
|
+
assert.equal(r.edges.length, 0);
|
|
192
|
+
assert.equal(r.slice_scope, 2);
|
|
193
|
+
assert.ok(r.slice_rationale.includes('empty active_frameworks'));
|
|
194
|
+
assert.equal(r.brain_snapshot_id, null);
|
|
195
|
+
// Brain query NOT called.
|
|
196
|
+
assert.equal(mock._queryInvocations.length, 0);
|
|
197
|
+
assert.equal(mock._sanitizationCalls.length, 0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------- Test 6
|
|
201
|
+
|
|
202
|
+
test('Test 6: Brain unreachable (isAvailable=false) degrades; no query call', async function () {
|
|
203
|
+
const mock = makeMockClient({ available: false });
|
|
204
|
+
const r = await fetchFrameworkChainSlice({
|
|
205
|
+
active_frameworks: ['Beautiful Question Framework'],
|
|
206
|
+
max_hops: 3,
|
|
207
|
+
brainClient: mock,
|
|
208
|
+
});
|
|
209
|
+
assert.equal(r.edges.length, 0);
|
|
210
|
+
assert.equal(r.slice_scope, 3);
|
|
211
|
+
assert.equal(r.slice_rationale, 'brain_unreachable');
|
|
212
|
+
assert.equal(r.brain_snapshot_id, null);
|
|
213
|
+
assert.equal(typeof r.fetched_at, 'string');
|
|
214
|
+
assert.equal(mock._queryInvocations.length, 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------- Test 7
|
|
218
|
+
|
|
219
|
+
test('Test 7: Brain query throws -> degraded; no exception propagates', async function () {
|
|
220
|
+
const mock = makeMockClient({
|
|
221
|
+
queryFn: async function () { throw new Error('connection refused'); },
|
|
222
|
+
});
|
|
223
|
+
const r = await fetchFrameworkChainSlice({
|
|
224
|
+
active_frameworks: ['X'],
|
|
225
|
+
max_hops: 2,
|
|
226
|
+
brainClient: mock,
|
|
227
|
+
});
|
|
228
|
+
assert.equal(r.edges.length, 0);
|
|
229
|
+
assert.equal(r.slice_scope, 2);
|
|
230
|
+
assert.ok(
|
|
231
|
+
r.slice_rationale.includes('brain_query_failed'),
|
|
232
|
+
'rationale must include brain_query_failed; got: ' + r.slice_rationale
|
|
233
|
+
);
|
|
234
|
+
assert.ok(r.slice_rationale.includes('connection refused'));
|
|
235
|
+
assert.equal(r.brain_snapshot_id, null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- Test 8
|
|
239
|
+
|
|
240
|
+
test('Test 8: result mapping preserves all 5 fields verbatim', async function () {
|
|
241
|
+
const row = {
|
|
242
|
+
from: 'SWOT',
|
|
243
|
+
to: 'Porter Five Forces',
|
|
244
|
+
confidence: 0.85,
|
|
245
|
+
transform_description: 'analysis -> strategy',
|
|
246
|
+
hop_distance: 1,
|
|
247
|
+
};
|
|
248
|
+
const mock = makeMockClient({ queryFn: async function () { return [row]; } });
|
|
249
|
+
const r = await fetchFrameworkChainSlice({
|
|
250
|
+
active_frameworks: ['SWOT'],
|
|
251
|
+
max_hops: 1,
|
|
252
|
+
brainClient: mock,
|
|
253
|
+
});
|
|
254
|
+
assert.equal(r.edges.length, 1);
|
|
255
|
+
const e = r.edges[0];
|
|
256
|
+
assert.equal(e.from, 'SWOT');
|
|
257
|
+
assert.equal(e.to, 'Porter Five Forces');
|
|
258
|
+
assert.equal(e.confidence, 0.85);
|
|
259
|
+
assert.equal(e.transform_description, 'analysis -> strategy');
|
|
260
|
+
assert.equal(e.hop_distance, 1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------- Test 9 (G-05)
|
|
264
|
+
|
|
265
|
+
test('Test 9: null confidence + null transform_description preserved (not defaulted)', async function () {
|
|
266
|
+
const row = {
|
|
267
|
+
from: 'A',
|
|
268
|
+
to: 'B',
|
|
269
|
+
confidence: null,
|
|
270
|
+
transform_description: null,
|
|
271
|
+
hop_distance: 2,
|
|
272
|
+
};
|
|
273
|
+
const mock = makeMockClient({ queryFn: async function () { return [row]; } });
|
|
274
|
+
const r = await fetchFrameworkChainSlice({
|
|
275
|
+
active_frameworks: ['A'],
|
|
276
|
+
max_hops: 2,
|
|
277
|
+
brainClient: mock,
|
|
278
|
+
});
|
|
279
|
+
assert.equal(r.edges.length, 1);
|
|
280
|
+
const e = r.edges[0];
|
|
281
|
+
assert.equal(e.from, 'A');
|
|
282
|
+
assert.equal(e.to, 'B');
|
|
283
|
+
// Explicit null preservation per CONTEXT.md G-05 + Plan 04 schema.
|
|
284
|
+
assert.equal(e.confidence, null);
|
|
285
|
+
assert.equal(e.transform_description, null);
|
|
286
|
+
// hop_distance still propagates.
|
|
287
|
+
assert.equal(e.hop_distance, 2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------- Test 10
|
|
291
|
+
|
|
292
|
+
test('Test 10: brain_snapshot_id is sha256 of JSON.stringify(raw rows)', async function () {
|
|
293
|
+
const rows = [
|
|
294
|
+
{ from: 'A', to: 'B', confidence: 0.5, transform_description: 't', hop_distance: 1 },
|
|
295
|
+
];
|
|
296
|
+
const mock = makeMockClient({ queryFn: async function () { return rows; } });
|
|
297
|
+
const r = await fetchFrameworkChainSlice({
|
|
298
|
+
active_frameworks: ['A'],
|
|
299
|
+
max_hops: 1,
|
|
300
|
+
brainClient: mock,
|
|
301
|
+
});
|
|
302
|
+
// Expected hash: sha256 of JSON.stringify(rows) exactly as the implementation
|
|
303
|
+
// computes it (it is the raw rows after Cypher, before edge-shape mapping).
|
|
304
|
+
const expected = crypto.createHash('sha256')
|
|
305
|
+
.update(JSON.stringify(rows), 'utf8')
|
|
306
|
+
.digest('hex');
|
|
307
|
+
assert.equal(r.brain_snapshot_id, expected);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------- Bonus: shape-tolerant
|
|
311
|
+
// (the live brain-client returns { records: [] }; this verifies both shapes work).
|
|
312
|
+
|
|
313
|
+
test('Bonus: { records: [...] } wrapper from brain-client.query is unwrapped', async function () {
|
|
314
|
+
const rows = [
|
|
315
|
+
{ from: 'X', to: 'Y', confidence: 0.6, transform_description: 'x->y', hop_distance: 1 },
|
|
316
|
+
];
|
|
317
|
+
const mock = makeMockClient({
|
|
318
|
+
queryFn: async function () { return { records: rows }; },
|
|
319
|
+
});
|
|
320
|
+
const r = await fetchFrameworkChainSlice({
|
|
321
|
+
active_frameworks: ['X'],
|
|
322
|
+
max_hops: 1,
|
|
323
|
+
brainClient: mock,
|
|
324
|
+
});
|
|
325
|
+
assert.equal(r.edges.length, 1);
|
|
326
|
+
assert.equal(r.edges[0].from, 'X');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------- Bonus: non-array
|
|
330
|
+
// Brain return (e.g. an error payload like { error: 'foo' }) degrades cleanly.
|
|
331
|
+
|
|
332
|
+
test('Bonus: non-array Brain return degrades cleanly', async function () {
|
|
333
|
+
const mock = makeMockClient({
|
|
334
|
+
queryFn: async function () { return { error: 'whatever' }; },
|
|
335
|
+
});
|
|
336
|
+
const r = await fetchFrameworkChainSlice({
|
|
337
|
+
active_frameworks: ['X'],
|
|
338
|
+
max_hops: 1,
|
|
339
|
+
brainClient: mock,
|
|
340
|
+
});
|
|
341
|
+
assert.equal(r.edges.length, 0);
|
|
342
|
+
assert.ok(r.slice_rationale.includes('brain_returned_non_array'));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------- Canon Part 8:
|
|
346
|
+
// re-read the module source and grep it for forbidden tokens (defence in depth).
|
|
347
|
+
|
|
348
|
+
test('Canon Part 8: module source contains zero user-content tokens', function () {
|
|
349
|
+
const src = fs.readFileSync(
|
|
350
|
+
'/home/jsagi/MindrianOS-Plugin/lib/brain/framework-chain-slice.cjs',
|
|
351
|
+
'utf8'
|
|
352
|
+
);
|
|
353
|
+
// The Cypher constant has been validated above. Here we audit that the
|
|
354
|
+
// module never builds the query with user-content fields. The only tokens
|
|
355
|
+
// forbidden in the Cypher *template* + *param-binding* are listed below.
|
|
356
|
+
// We scan only the Cypher constant + the param object site.
|
|
357
|
+
const cypherIdx = src.indexOf('FRAMEWORK_CHAIN_SLICE_CYPHER =');
|
|
358
|
+
assert.ok(cypherIdx > 0);
|
|
359
|
+
const cypherBlock = src.slice(cypherIdx, src.indexOf('}', cypherIdx + 200));
|
|
360
|
+
// Cypher must not embed any user-content token literally.
|
|
361
|
+
const forbidden = ['roomDir', 'roomSlug', 'transcript', 'artifact_body'];
|
|
362
|
+
for (const tok of forbidden) {
|
|
363
|
+
assert.ok(
|
|
364
|
+
!cypherBlock.includes(tok),
|
|
365
|
+
'Cypher block must not embed user-content token "' + tok + '"'
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
});
|