@mindrian_os/install 1.13.0-beta.13 → 1.13.0-beta.16

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 (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +21 -11
  3. package/README.md +74 -572
  4. package/commands/act.md +1 -0
  5. package/commands/admin.md +1 -0
  6. package/commands/analyze-needs.md +1 -0
  7. package/commands/analyze-systems.md +1 -0
  8. package/commands/analyze-timing.md +1 -0
  9. package/commands/auto-explore.md +1 -0
  10. package/commands/beautiful-question.md +1 -0
  11. package/commands/brain-derive.md +1 -0
  12. package/commands/build-knowledge.md +1 -0
  13. package/commands/build-thesis.md +1 -0
  14. package/commands/causal.md +1 -0
  15. package/commands/challenge-assumptions.md +1 -0
  16. package/commands/compare-ventures.md +1 -0
  17. package/commands/dashboard.md +1 -0
  18. package/commands/deep-grade.md +1 -0
  19. package/commands/diagnose.md +1 -0
  20. package/commands/diagnostics.md +1 -0
  21. package/commands/doctor.md +1 -0
  22. package/commands/dominant-designs.md +1 -0
  23. package/commands/explain-decision.md +1 -0
  24. package/commands/explore-domains.md +1 -0
  25. package/commands/explore-futures.md +1 -0
  26. package/commands/explore-trends.md +1 -0
  27. package/commands/export.md +1 -0
  28. package/commands/feynman-timeline-refresh.md +78 -0
  29. package/commands/file-meeting.md +1 -0
  30. package/commands/find-analogies.md +1 -0
  31. package/commands/find-bottlenecks.md +1 -0
  32. package/commands/find-connections.md +1 -0
  33. package/commands/funding.md +1 -0
  34. package/commands/grade.md +1 -0
  35. package/commands/graph.md +1 -0
  36. package/commands/hat-briefing.md +1 -0
  37. package/commands/heal.md +1 -0
  38. package/commands/help.md +1 -0
  39. package/commands/hmi-status.md +1 -0
  40. package/commands/jtbd.md +1 -0
  41. package/commands/leadership.md +1 -0
  42. package/commands/lean-canvas.md +1 -0
  43. package/commands/macro-trends.md +1 -0
  44. package/commands/map-unknowns.md +1 -0
  45. package/commands/memory.md +1 -0
  46. package/commands/models.md +1 -0
  47. package/commands/mos-reason.md +1 -0
  48. package/commands/mullins.md +1 -0
  49. package/commands/new-project.md +1 -0
  50. package/commands/onboard.md +1 -0
  51. package/commands/operator.md +1 -0
  52. package/commands/opportunities.md +1 -0
  53. package/commands/organize.md +1 -0
  54. package/commands/persona.md +1 -0
  55. package/commands/pipeline.md +1 -0
  56. package/commands/present.md +1 -0
  57. package/commands/publish.md +1 -0
  58. package/commands/query.md +1 -0
  59. package/commands/radar.md +1 -0
  60. package/commands/reanalyze.md +1 -0
  61. package/commands/research.md +1 -0
  62. package/commands/room.md +1 -0
  63. package/commands/rooms.md +1 -0
  64. package/commands/root-cause.md +1 -0
  65. package/commands/rs-experts.md +1 -0
  66. package/commands/rs-explain.md +1 -0
  67. package/commands/rs-fetch.md +1 -0
  68. package/commands/rs-thesis.md +1 -0
  69. package/commands/scenario-plan.md +1 -0
  70. package/commands/scheduled-tasks.md +1 -0
  71. package/commands/score-innovation.md +1 -0
  72. package/commands/scout.md +1 -0
  73. package/commands/setup.md +1 -0
  74. package/commands/snapshot.md +1 -0
  75. package/commands/speakers.md +1 -0
  76. package/commands/splash.md +1 -0
  77. package/commands/status.md +1 -0
  78. package/commands/structure-argument.md +1 -0
  79. package/commands/suggest-next.md +1 -0
  80. package/commands/systems-thinking.md +1 -0
  81. package/commands/think-hats.md +1 -0
  82. package/commands/update.md +1 -0
  83. package/commands/user-needs.md +1 -0
  84. package/commands/validate.md +1 -0
  85. package/commands/value-proposition.md +1 -0
  86. package/commands/vault.md +1 -0
  87. package/commands/visualize.md +1 -0
  88. package/commands/whitespace.md +1 -0
  89. package/commands/wiki.md +1 -0
  90. package/lib/brain/framework-chain-slice.cjs +193 -0
  91. package/lib/core/cache-prune.cjs +114 -8
  92. package/lib/core/feynman/ROOM.md +25 -0
  93. package/lib/core/feynman/timeline-renderer.cjs +197 -0
  94. package/lib/core/feynman/timeline-runner.cjs +281 -0
  95. package/lib/core/install-state.cjs +242 -0
  96. package/lib/core/navigation/edges.cjs +86 -0
  97. package/lib/core/navigation/insights.cjs +37 -0
  98. package/lib/core/navigation/memory-events.cjs +39 -0
  99. package/lib/core/navigation/packet.cjs +89 -9
  100. package/lib/core/navigation/projections.cjs +201 -0
  101. package/lib/core/navigation.cjs +25 -0
  102. package/lib/mcp/larry-server-instructions.md +1 -1
  103. package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
  104. package/lib/memory/f-selector-ranker.test.cjs +593 -0
  105. package/lib/memory/navigation-projections.test.cjs +241 -0
  106. package/lib/memory/navigation-write-edge.test.cjs +206 -0
  107. package/lib/memory/packet-chain-hint.test.cjs +407 -0
  108. package/lib/memory/packet-schema-validation.test.cjs +317 -0
  109. package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
  110. package/lib/memory/per-command-teaching.test.cjs +110 -0
  111. package/lib/memory/run-feynman-tests.cjs +36 -0
  112. package/lib/memory/selector-decisions.test.cjs +417 -0
  113. package/lib/memory/selector-miss.test.cjs +290 -0
  114. package/lib/workflow/f-selector-ranker.cjs +420 -0
  115. package/lib/workflow/selector-decisions.cjs +368 -0
  116. package/package.json +1 -1
  117. package/references/design/email-template-standard.md +1 -1
  118. package/references/user-research/2026-04-05-leah-lawrence-session.md +3 -3
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+ // Phase 125-01 -- pure projection helpers for f-selector-ranker (Plan 05 consumer).
3
+ // All three functions are pure + synchronous; they project roomState into the
4
+ // three signals D4's scoring formula consumes. No I/O. No Brain calls. No db
5
+ // writes. Database READS via existing navigation primitives only -- no direct
6
+ // room-db access.
7
+ //
8
+ // Canon Part 9: SQL remembers and navigates; these helpers are the "navigate"
9
+ // projections over the local mind that the ranker reads BEFORE making its
10
+ // recommendation. Canon Part 7: extension of navigation/, not modification of
11
+ // navigation.cjs's closed surface. Plan 05 may require these directly from
12
+ // projections.cjs OR via navigation.cjs re-export -- Plan 01 ships the
13
+ // projections; navigation.cjs re-export is OPTIONAL (deferred to Plan 05 if
14
+ // preferred to avoid the closed-surface widening discussion).
15
+ //
16
+ // Function signatures (LOCKED in 125-CONTEXT.md "Function signatures (LOCKED)"):
17
+ // resolveActiveFrameworks(roomState) -> Array<{name, weight, source}>
18
+ // resolveHopDepth(roomState) -> {depth: 1|2|3, rationale: string}
19
+ // computeInvestmentLevel(roomState) -> {level: number, label: string}
20
+ //
21
+ // roomState shape (extends the chain-recommender.cjs shape per RESEARCH G-03 + G-04):
22
+ // {
23
+ // problemType?: 'UDP'|'IDP'|'WDP'|string,
24
+ // governing_thought?: string,
25
+ // activeJtbd?: string,
26
+ // brainAnchors?: string[],
27
+ // framework_invocations?: number, // from COUNT(memory_event WHERE event_type='framework_invoked')
28
+ // feedsIntoEdges?: Edge[],
29
+ // brainSection?: object,
30
+ // brainSections?: object,
31
+ // recentFrameworks?: string[], // from findRecentChanges memory_event 'framework_invoked'
32
+ // }
33
+
34
+ // Common frameworks in the Brain teaching graph -- used as substring hints
35
+ // when extracting from governing_thought free-text. NOT exhaustive (the Brain
36
+ // has 100+ frameworks); just the high-traffic ones for early-room
37
+ // governing_thought parsing. Plan 05 may extend this list or replace with a
38
+ // registry-derived superset; the projection helper is just a substring scan.
39
+ const KNOWN_FRAMEWORK_HINTS = Object.freeze([
40
+ 'Beautiful Question Framework', 'Mullins 7 Domains', 'SWOT', 'Porter Five Forces',
41
+ 'JTBD', 'Jobs to be Done', 'Lean Canvas', 'Business Model Canvas',
42
+ 'Six Thinking Hats', 'Cynefin', 'Root Cause Analysis', 'First Principles',
43
+ 'Wardley Map', 'OKR', 'Scenario Planning', 'Design Thinking',
44
+ ]);
45
+
46
+ function _extractFrameworkFromThought(text) {
47
+ if (typeof text !== 'string' || text.length === 0) return null;
48
+ for (const fw of KNOWN_FRAMEWORK_HINTS) {
49
+ if (text.indexOf(fw) !== -1) return fw;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // Resolve a JTBD id to a framework name via:
55
+ // jtbd-taxonomy.json (entry.methodology_hooks[0]) -> command slug
56
+ // command-registry.json (commands[].command) -> first frameworks[]
57
+ // Synchronous, fs-only read. Returns null on any failure -- never throws.
58
+ // Mirrors lib/brain/chain-recommender.cjs::_jtbdToFramework (same fs pattern,
59
+ // same fail-soft contract). Plan 05 may share the registry via injection if
60
+ // it wants to centralize the cache; Plan 01 just reads.
61
+ function _jtbdToFramework(activeJtbd) {
62
+ if (typeof activeJtbd !== 'string' || activeJtbd.length === 0) return null;
63
+ const path = require('node:path');
64
+ const fs = require('node:fs');
65
+ const TAXONOMY_PATH = path.join(__dirname, '..', '..', 'hmi', 'jtbd-taxonomy.json');
66
+ const REGISTRY_PATH = path.join(__dirname, '..', '..', '..', 'data', 'command-registry.json');
67
+ let tax;
68
+ let reg;
69
+ try { tax = JSON.parse(fs.readFileSync(TAXONOMY_PATH, 'utf8')); } catch (_e) { return null; }
70
+ try { reg = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')); } catch (_e) { return null; }
71
+ const entry = (tax.entries || []).find((e) => e && e.id === activeJtbd);
72
+ if (!entry || !Array.isArray(entry.methodology_hooks) || entry.methodology_hooks.length === 0) return null;
73
+ const firstHook = entry.methodology_hooks[0]; // e.g. '/mos:beautiful-question' or 'mos:beautiful-question'
74
+ if (typeof firstHook !== 'string') return null;
75
+ const parts = firstHook.split(':');
76
+ if (parts.length < 2) return null;
77
+ const slug = parts.slice(1).join(':');
78
+ const cmd = (reg.commands || []).find((c) => c && typeof c.command === 'string' && c.command.split(':').slice(1).join(':') === slug);
79
+ return (cmd && Array.isArray(cmd.frameworks) && cmd.frameworks.length > 0) ? cmd.frameworks[0] : null;
80
+ }
81
+
82
+ // D1 -- Active-framework multi-signal projection.
83
+ // Precedence (highest -> lowest weight):
84
+ // 1.00 governing_thought (CONTEXT signal, Phase 90)
85
+ // 0.75 activeJtbd (INTENT signal, /mos:jtbd via taxonomy)
86
+ // 0.50 brainAnchors (MEMORY signal, BRAIN.md anchors per Phase 90)
87
+ // 0.25 recentFrameworks (TEMPORAL signal, memory_event 'framework_invoked' recency)
88
+ // First-write-wins per framework name (a framework already claimed by a higher
89
+ // signal does NOT also appear at a lower one). Empty array on empty roomState.
90
+ function resolveActiveFrameworks(roomState) {
91
+ if (!roomState || typeof roomState !== 'object') return [];
92
+ const out = new Map(); // name -> {name, weight, source}
93
+
94
+ // 1. governing_thought (CONTEXT signal, weight 1.0)
95
+ if (typeof roomState.governing_thought === 'string' && roomState.governing_thought.length > 0) {
96
+ const fw = _extractFrameworkFromThought(roomState.governing_thought);
97
+ if (fw) out.set(fw, { name: fw, weight: 1.0, source: 'governing_thought' });
98
+ }
99
+
100
+ // 2. activeJtbd (INTENT signal, weight 0.75)
101
+ if (typeof roomState.activeJtbd === 'string' && roomState.activeJtbd.length > 0) {
102
+ const fw = _jtbdToFramework(roomState.activeJtbd);
103
+ if (fw && !out.has(fw)) {
104
+ out.set(fw, { name: fw, weight: 0.75, source: 'activeJtbd' });
105
+ }
106
+ }
107
+
108
+ // 3. BRAIN.md anchors (MEMORY signal, weight 0.5)
109
+ if (Array.isArray(roomState.brainAnchors)) {
110
+ for (const a of roomState.brainAnchors) {
111
+ if (typeof a === 'string' && a.length > 0 && !out.has(a)) {
112
+ out.set(a, { name: a, weight: 0.5, source: 'brain_md' });
113
+ }
114
+ }
115
+ }
116
+
117
+ // 4. memory_event recency (TEMPORAL signal, weight 0.25)
118
+ if (Array.isArray(roomState.recentFrameworks)) {
119
+ for (const a of roomState.recentFrameworks) {
120
+ if (typeof a === 'string' && a.length > 0 && !out.has(a)) {
121
+ out.set(a, { name: a, weight: 0.25, source: 'memory_event' });
122
+ }
123
+ }
124
+ }
125
+
126
+ return Array.from(out.values()).sort((a, b) => b.weight - a.weight);
127
+ }
128
+
129
+ // D2 -- Variable hop depth, context-driven.
130
+ // Defaults to depth 3 (WIDE) on ambiguity per CONTEXT.md.
131
+ // depth 1: WDP + strong governing_thought (execution mode)
132
+ // depth 2: IDP (exploratory mode); or WDP without governing_thought
133
+ // depth 3: wicked / undefined / no anchor (default WIDE)
134
+ function resolveHopDepth(roomState) {
135
+ if (!roomState || typeof roomState !== 'object') {
136
+ return { depth: 3, rationale: 'no roomState provided; defaulting WIDE on ambiguity' };
137
+ }
138
+ const problemType = roomState.problemType;
139
+ const hasGoverningThought =
140
+ typeof roomState.governing_thought === 'string' && roomState.governing_thought.length > 0;
141
+ if (problemType === 'WDP' && hasGoverningThought) {
142
+ return {
143
+ depth: 1,
144
+ rationale: 'well-defined state with strong governing_thought; execution mode',
145
+ };
146
+ }
147
+ if (problemType === 'IDP') {
148
+ return {
149
+ depth: 2,
150
+ rationale: 'ill-defined state; evolving governing_thought; exploratory mode',
151
+ };
152
+ }
153
+ if (problemType === 'WDP') {
154
+ return {
155
+ depth: 2,
156
+ rationale: 'well-defined problemType but no governing_thought; moderate slice',
157
+ };
158
+ }
159
+ return {
160
+ depth: 3,
161
+ rationale: 'wicked or undefined state; no anchor; defaulting WIDE on ambiguity',
162
+ };
163
+ }
164
+
165
+ // D3 -- Continuous investment gradient.
166
+ // level = min(1.0, max(0.0, framework_invocations / 10))
167
+ // level === 0 -> "fresh room, ranking with Brain priors"
168
+ // 0 < level < 0.5 -> "warming up: Brain + early local signal"
169
+ // 0.5 <= level < 1 -> "balanced: Brain + memory equal weight"
170
+ // level === 1.0 -> "full local scoring with Brain confidence"
171
+ // Caps at 1.0 for >= 10 invocations; floors at 0.0 for missing or negative
172
+ // counter. Drives D4's scoring formula:
173
+ // score = brain_confidence*0.40 + (1-recency_decay)*0.30*investment_level
174
+ // + problem_type_bind*0.30*investment_level
175
+ // (normalized to 0..1)
176
+ function computeInvestmentLevel(roomState) {
177
+ const raw =
178
+ roomState && typeof roomState === 'object' && typeof roomState.framework_invocations === 'number'
179
+ ? roomState.framework_invocations
180
+ : 0;
181
+ const level = Math.min(1.0, Math.max(0.0, raw / 10));
182
+ let label;
183
+ if (level === 0) {
184
+ label = 'fresh room, ranking with Brain priors';
185
+ } else if (level < 0.5) {
186
+ label = 'warming up: Brain + early local signal';
187
+ } else if (level < 1.0) {
188
+ label = 'balanced: Brain + memory equal weight';
189
+ } else {
190
+ label = 'full local scoring with Brain confidence';
191
+ }
192
+ return { level, label };
193
+ }
194
+
195
+ module.exports = {
196
+ resolveActiveFrameworks,
197
+ resolveHopDepth,
198
+ computeInvestmentLevel,
199
+ // Test seam (private; used by lib/memory/navigation-projections.test.cjs only).
200
+ _test: { _extractFrameworkFromThought, _jtbdToFramework, KNOWN_FRAMEWORK_HINTS },
201
+ };
@@ -10,6 +10,16 @@
10
10
  // re-exports those plus internal helpers as needed) requires canon amendment.
11
11
  // Canon Part 9: navigation IS the local mind; this is the only module callers should
12
12
  // require for graph reads, ranking, packet building, and truth-state promotion.
13
+ //
14
+ // Phase 125-00 amendment (Pass 3 GAP-2 resolution): writeEdge added as a thin
15
+ // re-export on the navigation surface. Additive extension per the Phase 110-03
16
+ // logMemoryEvent precedent (and the Phase 124-01 firstCapturedLastTouchedBySection
17
+ // precedent shipped between 110-03 and here). The closed DOCUMENTED 13-function
18
+ // API is unchanged in spirit -- additive re-exports of internal helpers are
19
+ // needed for the Plan 06 selector-decisions surface to write typed cascade edges
20
+ // (DEFERRED / REJECTED per CONTEXT.md D7) without bypassing the chokepoint.
21
+ // Canon Part 4 binding: every choice is graph data; writeEdge is the primitive
22
+ // that lets the F-selector ranker emit those choices as typed edges.
13
23
 
14
24
  const focus = require('./navigation/focus.cjs');
15
25
  const neighborhoodMod = require('./navigation/neighborhood.cjs');
@@ -19,6 +29,7 @@ const insights = require('./navigation/insights.cjs');
19
29
  const packet = require('./navigation/packet.cjs');
20
30
  const ingestion = require('./navigation/ingestion.cjs');
21
31
  const roomHome = require('./navigation/room-home.cjs');
32
+ const edges = require('./navigation/edges.cjs');
22
33
 
23
34
  function notImplementedYet(name, plan) {
24
35
  return function () {
@@ -63,4 +74,18 @@ module.exports = {
63
74
  // reaching into the internal navigation/memory-events.cjs. The closed navigation surface is
64
75
  // the DOCUMENTED 13-function API; the implementation re-exports internal helpers as needed.)
65
76
  logMemoryEvent: memoryEvents.logEvent,
77
+
78
+ // First-captured / last-touched scalars by section (Phase 124-01 -- a thin re-export so
79
+ // lib/core/feynman/timeline-renderer.cjs can compose its D-05 summary line over the
80
+ // memory_event log without reaching into the internal navigation/insights.cjs. The closed
81
+ // navigation surface is the DOCUMENTED 13-function API; the implementation re-exports
82
+ // internal helpers as needed -- same pattern as the Phase 110-03 logMemoryEvent re-export.)
83
+ firstCapturedLastTouchedBySection: insights.firstCapturedLastTouchedBySection,
84
+
85
+ // Edge-write primitive (Phase 125-00 -- Pass 3 GAP-2 resolution; D7 typed cascade edge
86
+ // surface for F.1 defer / F.2 reject. Allowlist gated on ALLOWED_EDGE_TYPES Set in
87
+ // navigation/edges.cjs. Plan 06 selector-decisions.cjs is the first consumer; Phase
88
+ // 116/117/118 will extend the allowlist additively for tension / auto-explore /
89
+ // MVA edges. Same additive-re-export pattern as logMemoryEvent + firstCapturedLastTouchedBySection.)
90
+ writeEdge: edges.writeEdge,
66
91
  };
@@ -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 at Johns Hopkins). NOT a textbook, NOT a framework dispenser. If your response looks like a PDF, start over.
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
+ });