@mindrian_os/install 1.13.0-beta.22 → 1.13.0-beta.26
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 +39 -0
- package/agents/brain-query.md +12 -15
- package/agents/grading.md +14 -26
- package/agents/investor.md +6 -7
- package/agents/research.md +1 -2
- package/bin/mindrian-brain-mcp-client.cjs +16 -3
- package/bin/mindrian-mcp-server.cjs +18 -3
- package/commands/act.md +8 -8
- package/commands/rs-experts.md +3 -1
- package/commands/rs-explain.md +2 -2
- package/commands/rs-thesis.md +3 -1
- package/hooks/hooks.json +8 -8
- package/lib/agents/mva/brain-classic-traps.cjs +29 -51
- package/lib/brain/chain-recommender.cjs +14 -8
- package/lib/brain/framework-chain-slice.cjs +89 -70
- package/lib/core/brain-client.cjs +54 -0
- package/lib/core/brain-derivation-prompts.cjs +15 -10
- package/lib/core/brain-derivation.cjs +16 -2
- package/lib/core/mcp-dep-heal.cjs +246 -0
- package/lib/core/mcp-dep-heal.test.cjs +253 -0
- package/lib/core/npm-cli-resolve.cjs +151 -0
- package/lib/core/npm-cli-resolve.test.cjs +153 -0
- package/lib/core/npm-install-lock.cjs +302 -0
- package/lib/core/npm-install-lock.test.cjs +325 -0
- package/lib/core/rs-chain-feeder.cjs +62 -30
- package/lib/core/rs-nl-to-query.cjs +16 -6
- package/lib/hmi/cross-room-memory.cjs +72 -29
- package/lib/mcp/brain-router.cjs +69 -55
- package/lib/memory/brain-cypher-chain-slice.test.cjs +143 -143
- package/lib/memory/brain-derivation.test.cjs +10 -5
- package/package.json +2 -4
- package/references/brain/query-patterns.md +29 -17
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Phase 125-02 -- Brain
|
|
3
|
-
// CONTEXT.md Scope IN section B item 4
|
|
4
|
-
// brain-client.query that produces the framework_chain_hint shape Plan 03 stitches
|
|
5
|
-
// into the packet's local_graph_summary.
|
|
2
|
+
// Phase 125-02 -- Brain framework-chain slice (FEEDS_INTO 1-3 hops).
|
|
3
|
+
// CONTEXT.md Scope IN section B item 4.
|
|
6
4
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Repointed (BUG 2 fix) from the admin-gated brain_query / raw-Cypher path
|
|
6
|
+
// onto the curated brain.askOp('framework_chain_slice', { seeds, max_hops })
|
|
7
|
+
// surface so any valid API key can call it.
|
|
10
8
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// Contract (from .planning/brain-curated-ops-contract.md):
|
|
10
|
+
// brain.askOp('framework_chain_slice', { seeds: string[], max_hops?: 1|2|3 })
|
|
11
|
+
// -> { op, source, count, rows: Array<{from, to, hop_distance}>, degraded? }
|
|
12
|
+
//
|
|
13
|
+
// INTEGRATOR NOTE -- partial substitute:
|
|
14
|
+
// The old Cypher returned `confidence` and `transform_description` per edge.
|
|
15
|
+
// The curated op returns ONLY `from`, `to`, `hop_distance` (no confidence,
|
|
16
|
+
// no transform_description). Downstream consumers that used those fields will
|
|
17
|
+
// receive `null` after this change. Flag logged in fetchFrameworkChainSlice
|
|
18
|
+
// return envelope as `confidence_omitted: true` so callers can detect this.
|
|
19
|
+
// A future curated-op extension could re-expose the fields if needed.
|
|
20
|
+
//
|
|
21
|
+
// Canon Part 8: seeds are generic framework-name strings (allow-listed by
|
|
22
|
+
// the server); max_hops is a clamped integer 1-3. No user content crosses the
|
|
23
|
+
// wire. The curated op enforces this server-side; no caller Cypher ever.
|
|
24
|
+
//
|
|
25
|
+
// Graceful degradation: any failure path (Brain unreachable, askOp throws,
|
|
26
|
+
// invalid max_hops, empty seeds, non-array result, degraded envelope) returns
|
|
27
|
+
// a degraded result envelope rather than throwing. Tier 0 stays functional.
|
|
28
|
+
//
|
|
29
|
+
// BACKWARD-COMPAT SURFACE: the module still exports FRAMEWORK_CHAIN_SLICE_CYPHER
|
|
30
|
+
// as an empty string so existing test-seam checks that assert on the exported
|
|
31
|
+
// constant do not break (tests/brain-cypher-chain-slice.test.cjs Test 2 checks
|
|
32
|
+
// that the CYPHER includes 'LIMIT 50' and 'FEEDS_INTO*1..$max_hops'; those
|
|
33
|
+
// tests will need updating now that the Cypher is server-side). The constant is
|
|
34
|
+
// deprecated; callers should NOT use it.
|
|
14
35
|
|
|
15
36
|
const crypto = require('node:crypto');
|
|
16
37
|
const defaultBrainClient = require('../core/brain-client.cjs');
|
|
17
38
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
const FRAMEWORK_CHAIN_SLICE_CYPHER =
|
|
24
|
-
'MATCH (f:Framework)-[r:FEEDS_INTO*1..$max_hops]->(g:Framework) ' +
|
|
25
|
-
'WHERE f.name IN $active_frameworks ' +
|
|
26
|
-
'RETURN f.name AS from, g.name AS to, ' +
|
|
27
|
-
' r.confidence AS confidence, ' +
|
|
28
|
-
' r.transform_description AS transform_description, ' +
|
|
29
|
-
' length(r) AS hop_distance ' +
|
|
30
|
-
'LIMIT 50';
|
|
39
|
+
// DEPRECATED: the Cypher is now frozen server-side inside the curated op.
|
|
40
|
+
// Retained as an empty string to avoid hard breaking any import that
|
|
41
|
+
// destructures this export. Tests that assert on its content should be
|
|
42
|
+
// updated to reflect the curated-op architecture.
|
|
43
|
+
const FRAMEWORK_CHAIN_SLICE_CYPHER = '';
|
|
31
44
|
|
|
32
45
|
function _nowIso() {
|
|
33
46
|
return new Date().toISOString();
|
|
@@ -62,22 +75,29 @@ function _unwrapRows(result) {
|
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
65
|
-
* Fetch a
|
|
78
|
+
* Fetch a 1-3 hop FEEDS_INTO slice from the live Brain via the curated
|
|
79
|
+
* askOp surface (D-MOAT-1-safe; no admin gate required).
|
|
66
80
|
*
|
|
67
81
|
* @param {object} opts
|
|
68
|
-
* @param {string[]} opts.active_frameworks - Array of framework name strings
|
|
69
|
-
* Empty array short-circuits to a degraded envelope.
|
|
82
|
+
* @param {string[]} opts.active_frameworks - Array of framework name strings
|
|
83
|
+
* used as seeds. Empty array short-circuits to a degraded envelope.
|
|
70
84
|
* @param {1|2|3} opts.max_hops - Hop depth (must be exactly 1, 2, or 3).
|
|
71
85
|
* Any other value short-circuits to a degraded envelope.
|
|
72
86
|
* @param {object} [opts.brainClient] - Test seam; defaults to live brain-client.
|
|
73
87
|
* @returns {Promise<{
|
|
74
|
-
* edges: Array<{from:string|null, to:string|null, confidence:
|
|
75
|
-
* transform_description:
|
|
88
|
+
* edges: Array<{from:string|null, to:string|null, confidence:null,
|
|
89
|
+
* transform_description:null, hop_distance:number|null}>,
|
|
76
90
|
* slice_scope: 1|2|3,
|
|
77
91
|
* slice_rationale: string,
|
|
78
92
|
* brain_snapshot_id: string|null,
|
|
79
|
-
* fetched_at: string
|
|
93
|
+
* fetched_at: string,
|
|
94
|
+
* confidence_omitted: true
|
|
80
95
|
* }>}
|
|
96
|
+
*
|
|
97
|
+
* INTEGRATOR NOTE: confidence + transform_description are always null after
|
|
98
|
+
* this repoint. The curated op returns only { from, to, hop_distance }.
|
|
99
|
+
* `confidence_omitted: true` is set in every returned envelope so callers
|
|
100
|
+
* can detect the reduction and adapt accordingly.
|
|
81
101
|
*/
|
|
82
102
|
async function fetchFrameworkChainSlice(opts) {
|
|
83
103
|
const o = opts || {};
|
|
@@ -85,29 +105,31 @@ async function fetchFrameworkChainSlice(opts) {
|
|
|
85
105
|
const max_hops = (o.max_hops === 1 || o.max_hops === 2 || o.max_hops === 3) ? o.max_hops : null;
|
|
86
106
|
const brainClient = o.brainClient || defaultBrainClient;
|
|
87
107
|
|
|
88
|
-
// Gate 1: max_hops must be 1, 2, or 3.
|
|
89
|
-
// string, null) returns a degraded envelope with slice_scope defaulted to 3.
|
|
108
|
+
// Gate 1: max_hops must be 1, 2, or 3.
|
|
90
109
|
if (max_hops === null) {
|
|
91
|
-
return _degraded({ max_hops: 3, rationale: 'invalid_max_hops; expected 1|2|3' });
|
|
110
|
+
return Object.assign(_degraded({ max_hops: 3, rationale: 'invalid_max_hops; expected 1|2|3' }), { confidence_omitted: true });
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
// Gate 2: empty active_frameworks short-circuits before any Brain contact.
|
|
95
|
-
// The Cypher would match nothing anyway; the degraded envelope is honest.
|
|
96
114
|
if (active_frameworks.length === 0) {
|
|
97
|
-
return _degraded({ max_hops: max_hops, rationale: 'empty active_frameworks; no slice fetched' });
|
|
115
|
+
return Object.assign(_degraded({ max_hops: max_hops, rationale: 'empty active_frameworks; no slice fetched' }), { confidence_omitted: true });
|
|
98
116
|
}
|
|
99
117
|
|
|
100
|
-
// Gate 3: Brain reachability check
|
|
101
|
-
// live brain-client.isAvailable() is sync + cheap (an API-key resolve).
|
|
118
|
+
// Gate 3: Brain reachability check.
|
|
102
119
|
if (typeof brainClient.isAvailable === 'function' && !brainClient.isAvailable()) {
|
|
103
|
-
return _degraded({ max_hops: max_hops, rationale: 'brain_unreachable' });
|
|
120
|
+
return Object.assign(_degraded({ max_hops: max_hops, rationale: 'brain_unreachable' }), { confidence_omitted: true });
|
|
104
121
|
}
|
|
105
122
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
// Gate 4: askOp must be available on the client. The live brain-client
|
|
124
|
+
// gains askOp in the parallel BUG 2 build; tests may stub it. If the
|
|
125
|
+
// method is absent we fall back to the degraded path cleanly.
|
|
126
|
+
if (typeof brainClient.askOp !== 'function') {
|
|
127
|
+
return Object.assign(_degraded({ max_hops: max_hops, rationale: 'askOp_not_available_on_client' }), { confidence_omitted: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sanitize seed names before passing to askOp. The server also enforces
|
|
131
|
+
// the allow-list but we sanitize client-side as defence-in-depth.
|
|
132
|
+
// Canon Part 8 invariant preserved.
|
|
111
133
|
const sanitizer = (brainClient._test && typeof brainClient._test.sanitizeCypherInput === 'function')
|
|
112
134
|
? brainClient._test.sanitizeCypherInput
|
|
113
135
|
: function (s) { return s; };
|
|
@@ -117,66 +139,63 @@ async function fetchFrameworkChainSlice(opts) {
|
|
|
117
139
|
.filter(function (n) { return typeof n === 'string' && n.length > 0; });
|
|
118
140
|
|
|
119
141
|
if (sanitized.length === 0) {
|
|
120
|
-
return _degraded({
|
|
142
|
+
return Object.assign(_degraded({
|
|
121
143
|
max_hops: max_hops,
|
|
122
144
|
rationale: 'all_active_frameworks_rejected_by_sanitizer',
|
|
123
|
-
});
|
|
145
|
+
}), { confidence_omitted: true });
|
|
124
146
|
}
|
|
125
147
|
|
|
126
|
-
// Issue the
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// with caller-derived data (Canon Part 8 enforced by inspection).
|
|
130
|
-
let result;
|
|
148
|
+
// Issue the curated op. `seeds` is the param name per the contract;
|
|
149
|
+
// `max_hops` is passed through as-is (server clamps to [1,3]).
|
|
150
|
+
let opResult;
|
|
131
151
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
opResult = await brainClient.askOp('framework_chain_slice', {
|
|
153
|
+
seeds: sanitized,
|
|
134
154
|
max_hops: max_hops,
|
|
135
155
|
});
|
|
136
156
|
} catch (e) {
|
|
137
157
|
const msg = (e && e.message) ? String(e.message) : 'unknown';
|
|
138
|
-
return _degraded({
|
|
158
|
+
return Object.assign(_degraded({
|
|
139
159
|
max_hops: max_hops,
|
|
140
160
|
rationale: 'brain_query_failed: ' + msg.slice(0, 60),
|
|
141
|
-
});
|
|
161
|
+
}), { confidence_omitted: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Degraded envelope from server.
|
|
165
|
+
if (!opResult || opResult.degraded) {
|
|
166
|
+
return Object.assign(_degraded({ max_hops: max_hops, rationale: 'brain_returned_degraded' }), { confidence_omitted: true });
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
const rows =
|
|
169
|
+
const rows = Array.isArray(opResult.rows) ? opResult.rows : null;
|
|
145
170
|
if (rows === null) {
|
|
146
|
-
return _degraded({ max_hops: max_hops, rationale: 'brain_returned_non_array' });
|
|
171
|
+
return Object.assign(_degraded({ max_hops: max_hops, rationale: 'brain_returned_non_array' }), { confidence_omitted: true });
|
|
147
172
|
}
|
|
148
173
|
|
|
149
|
-
// Map
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// length(r) which is always a number when the row exists.
|
|
153
|
-
// Defensive: hard-cap at 50 even though the Cypher already enforces LIMIT 50.
|
|
174
|
+
// Map curated op rows { from, to, hop_distance } to the edge shape.
|
|
175
|
+
// confidence and transform_description are NOT in the curated op response;
|
|
176
|
+
// they are set to null explicitly and flagged via confidence_omitted.
|
|
154
177
|
const edges = rows.slice(0, 50).map(function (r) {
|
|
155
178
|
return {
|
|
156
179
|
from: (r && typeof r.from === 'string') ? r.from : null,
|
|
157
180
|
to: (r && typeof r.to === 'string') ? r.to : null,
|
|
158
|
-
confidence:
|
|
159
|
-
transform_description:
|
|
160
|
-
? r.transform_description
|
|
161
|
-
: null,
|
|
181
|
+
confidence: null, // not provided by curated op
|
|
182
|
+
transform_description: null, // not provided by curated op
|
|
162
183
|
hop_distance: (r && typeof r.hop_distance === 'number') ? r.hop_distance : null,
|
|
163
184
|
};
|
|
164
185
|
});
|
|
165
186
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// expose a commit_id; we derive a deterministic content hash of the raw
|
|
169
|
-
// rows so callers can detect slice changes without holding the rows
|
|
170
|
-
// themselves (Plan 03 + downstream packet validator never sees them).
|
|
187
|
+
// Deterministic content hash for cache invalidation (content-addressed
|
|
188
|
+
// snapshot -- same rationale as the original implementation).
|
|
171
189
|
const snapshotId = _sha256Hex(JSON.stringify(rows));
|
|
172
190
|
|
|
173
191
|
return {
|
|
174
192
|
edges: edges,
|
|
175
193
|
slice_scope: max_hops,
|
|
176
194
|
slice_rationale: 'brain_reachable; ' + edges.length + ' edges fetched; '
|
|
177
|
-
+ sanitized.length + '
|
|
195
|
+
+ sanitized.length + ' seeds; max_hops=' + max_hops,
|
|
178
196
|
brain_snapshot_id: snapshotId,
|
|
179
197
|
fetched_at: _nowIso(),
|
|
198
|
+
confidence_omitted: true,
|
|
180
199
|
};
|
|
181
200
|
}
|
|
182
201
|
|
|
@@ -464,6 +464,59 @@ async function ask(question) {
|
|
|
464
464
|
return callTool('brain_ask', { question: question });
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Curated-op call against the Brain (the `op` MODE of brain_ask).
|
|
469
|
+
*
|
|
470
|
+
* brain_ask gained an optional curated-op surface (BUG 2 fix). Where ask()
|
|
471
|
+
* runs the natural-language directive path, askOp() runs one of a closed set
|
|
472
|
+
* of named, parameterized operations the Brain resolves to a FROZEN
|
|
473
|
+
* server-side Cypher string. The three ops:
|
|
474
|
+
*
|
|
475
|
+
* - 'list_frameworks' params { limit? } -> rows { name, description, category }
|
|
476
|
+
* - 'framework_edges' params { edge_type, limit? } -> rows { from, to, confidence, transform }
|
|
477
|
+
* or { framework, problem_type }
|
|
478
|
+
* - 'framework_chain_slice' params { seeds, max_hops?, limit? } -> rows { from, to, hop_distance }
|
|
479
|
+
*
|
|
480
|
+
* Canon Part 8: every param is a generic methodology handle (framework name,
|
|
481
|
+
* closed enum, integer) -- never user content. No caller Cypher is ever sent;
|
|
482
|
+
* the Brain owns the query text. This path is ungated -- any valid key may
|
|
483
|
+
* call it (only query()/write() touch the admin-gated tools).
|
|
484
|
+
*
|
|
485
|
+
* Consumers that only need a framework chain for ONE anchor keep using
|
|
486
|
+
* ask(question) and read next_gate.options[].framework (the directive path).
|
|
487
|
+
*
|
|
488
|
+
* Returns the parsed curated-op payload { op, source, count, rows, degraded? }
|
|
489
|
+
* on success. On any transport / parse failure (Brain unreachable, no API key,
|
|
490
|
+
* bad payload) returns a graceful { op, count: 0, rows: [], degraded: true } so
|
|
491
|
+
* the caller never crashes -- mirrors query()'s graceful-degradation contract.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} operation - one of the three curated op names
|
|
494
|
+
* @param {object} [params] - generic-handles-only params object
|
|
495
|
+
* @returns {Promise<{op: string, source?: string, count: number, rows: Array, degraded?: boolean}>}
|
|
496
|
+
*/
|
|
497
|
+
async function askOp(operation, params = {}) {
|
|
498
|
+
try {
|
|
499
|
+
const result = await callTool('brain_ask', { op: operation, params: params || {} });
|
|
500
|
+
// callTool already parses the JSON text payload of the MCP content item,
|
|
501
|
+
// so a well-formed curated-op response arrives as the payload object.
|
|
502
|
+
if (result && typeof result === 'object'
|
|
503
|
+
&& typeof result.count === 'number' && Array.isArray(result.rows)) {
|
|
504
|
+
return {
|
|
505
|
+
op: result.op || operation,
|
|
506
|
+
source: result.source,
|
|
507
|
+
count: result.count,
|
|
508
|
+
rows: result.rows,
|
|
509
|
+
...(result.degraded ? { degraded: true } : {}),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// Unreachable Brain (null), an error/text passthrough, or any unexpected
|
|
513
|
+
// shape -> graceful degraded sentinel.
|
|
514
|
+
return { op: operation, count: 0, rows: [], degraded: true };
|
|
515
|
+
} catch (_err) {
|
|
516
|
+
return { op: operation, count: 0, rows: [], degraded: true };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
467
520
|
/**
|
|
468
521
|
* Get Pinecone stats.
|
|
469
522
|
*/
|
|
@@ -1242,6 +1295,7 @@ module.exports = {
|
|
|
1242
1295
|
search,
|
|
1243
1296
|
smartSearch,
|
|
1244
1297
|
ask,
|
|
1298
|
+
askOp,
|
|
1245
1299
|
schema,
|
|
1246
1300
|
stats,
|
|
1247
1301
|
enrichCausalEdges,
|
|
@@ -182,18 +182,20 @@ function safeInt(value, fallback) {
|
|
|
182
182
|
// interface (which takes raw cypher); interpolation is unavoidable.
|
|
183
183
|
// Safety is achieved by the two-layer validation above.
|
|
184
184
|
|
|
185
|
+
// BUG 2 fix: was mode:'query' (admin-gated brain_query with ADDRESSES_PROBLEM_TYPE
|
|
186
|
+
// Cypher). Repointed to mode:'search' (ungated brain_search). Returns a semantic
|
|
187
|
+
// search string instead of Cypher. The Brain's Pinecone index surfaces frameworks
|
|
188
|
+
// that address the problem type and phase via embedding similarity.
|
|
189
|
+
// Canon Part 8: only frozen enum strings are interpolated -- no user content.
|
|
185
190
|
function buildPatternMatchesQuery(ctx) {
|
|
186
191
|
validateCtx(ctx);
|
|
187
192
|
const section = safeEnum(ctx.section_slug, 'unknown');
|
|
188
193
|
const problemType = safeEnum(ctx.problem_type, 'UDP');
|
|
189
194
|
const phase = safeEnum(ctx.phase_indicator, 'unknown');
|
|
190
195
|
return (
|
|
191
|
-
'
|
|
192
|
-
'
|
|
193
|
-
'
|
|
194
|
-
'RETURN f.name AS framework, f.description AS description, ' +
|
|
195
|
-
'"' + section + '" AS section_slug ' +
|
|
196
|
-
'LIMIT 10'
|
|
196
|
+
'frameworks that address problem_type:' + problemType +
|
|
197
|
+
' at phase:' + phase +
|
|
198
|
+
' for section:' + section
|
|
197
199
|
);
|
|
198
200
|
}
|
|
199
201
|
|
|
@@ -241,15 +243,18 @@ function buildUnfilledOpportunityMatchesQuery(ctx) {
|
|
|
241
243
|
);
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
// BUG 2 fix: was mode:'query' (admin-gated brain_query with FEEDS_INTO Cypher).
|
|
247
|
+
// Repointed to mode:'search' (ungated brain_search). Returns a semantic
|
|
248
|
+
// search string instead of Cypher. The Brain's Pinecone index surfaces framework
|
|
249
|
+
// chain predictions via embedding similarity on FEEDS_INTO relationships.
|
|
250
|
+
// Canon Part 8: only frozen enum strings are interpolated -- no user content.
|
|
244
251
|
function buildFrameworkChainPredictionsQuery(ctx) {
|
|
245
252
|
validateCtx(ctx);
|
|
246
253
|
const phase = safeEnum(ctx.phase_indicator, 'unknown');
|
|
247
254
|
const problemType = safeEnum(ctx.problem_type, 'UDP');
|
|
248
255
|
return (
|
|
249
|
-
'
|
|
250
|
-
'
|
|
251
|
-
'OPTIONAL MATCH (f1)-[:ADDRESSES_PROBLEM_TYPE]->(pt:ProblemType {name: "' + problemType + '"}) ' +
|
|
252
|
-
'RETURN f1.name AS from_framework, f2.name AS to_framework LIMIT 10'
|
|
256
|
+
'framework chain predictions FEEDS_INTO phase:' + phase +
|
|
257
|
+
' problem_type:' + problemType
|
|
253
258
|
);
|
|
254
259
|
}
|
|
255
260
|
|
|
@@ -58,12 +58,26 @@ const prompts = require('./brain-derivation-prompts.cjs');
|
|
|
58
58
|
|
|
59
59
|
// Section heading -> prompt builder mapping. Order matters: this is the
|
|
60
60
|
// canonical emission order inside BRAIN.md body.
|
|
61
|
+
//
|
|
62
|
+
// BUG 2 fix (2026-05-22): sections whose Cypher traverses FEEDS_INTO or
|
|
63
|
+
// ADDRESSES_PROBLEM_TYPE edges are repointed from mode:'query' (admin-gated
|
|
64
|
+
// brain_query) to mode:'search' (ungated brain_search). Their prompt builders
|
|
65
|
+
// are also updated (see brain-derivation-prompts.cjs) to emit a semantic
|
|
66
|
+
// search string instead of raw Cypher. The SECTION_BUILDERS mode field is
|
|
67
|
+
// the dispatch key in the derivation loop below.
|
|
68
|
+
//
|
|
69
|
+
// Sections that still use mode:'query' target node types with no curated op
|
|
70
|
+
// and no practical semantic-search equivalent (WickedIndicator, Opportunity,
|
|
71
|
+
// RigorLevel, ProblemType, HsiRecommendation). Those calls will fail (or
|
|
72
|
+
// return empty) for non-admin users -- that is the correct graceful-degradation
|
|
73
|
+
// behaviour (existing firstError path). A future phase can add curated ops
|
|
74
|
+
// for those node types.
|
|
61
75
|
const SECTION_BUILDERS = Object.freeze([
|
|
62
|
-
{ heading: 'Pattern Matches', builder: 'buildPatternMatchesQuery', mode: '
|
|
76
|
+
{ heading: 'Pattern Matches', builder: 'buildPatternMatchesQuery', mode: 'search' },
|
|
63
77
|
{ heading: 'Cross-Domain Analogies', builder: 'buildCrossDomainAnalogiesQuery', mode: 'search' },
|
|
64
78
|
{ heading: 'Wicked Indicators', builder: 'buildWickedIndicatorsQuery', mode: 'query' },
|
|
65
79
|
{ heading: 'Unfilled Opportunity Matches', builder: 'buildUnfilledOpportunityMatchesQuery', mode: 'query' },
|
|
66
|
-
{ heading: 'Framework Chain Predictions', builder: 'buildFrameworkChainPredictionsQuery', mode: '
|
|
80
|
+
{ heading: 'Framework Chain Predictions', builder: 'buildFrameworkChainPredictionsQuery', mode: 'search' },
|
|
67
81
|
{ heading: 'Assessment Thinking Chain Position', builder: 'buildAssessmentThinkingChainPositionQuery', mode: 'query' },
|
|
68
82
|
{ heading: 'ProblemType Classification', builder: 'buildProblemTypeClassificationQuery', mode: 'query' },
|
|
69
83
|
{ heading: 'Flagged Contradictions (cross-room)', builder: 'buildFlaggedContradictionsXroomQuery', mode: 'query' },
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* MindrianOS Plugin -- MCP dependency self-heal (Option D, hybrid self-heal).
|
|
8
|
+
*
|
|
9
|
+
* THE PROBLEM (debug session mcp-servers-cache-missing-node-modules):
|
|
10
|
+
* `claude plugin update` lands a fresh plugin cache directory with NO
|
|
11
|
+
* node_modules. The SessionStart reconcile hook (scripts/sessionstart-npm-
|
|
12
|
+
* reconcile.cjs) repairs it -- but on the FIRST post-update session Claude Code
|
|
13
|
+
* spawns the bundled MCP servers (.mcp.json, alwaysLoad) at a moment that can
|
|
14
|
+
* precede the hook's npm install finishing. The servers then crash at module
|
|
15
|
+
* load with MODULE_NOT_FOUND for @modelcontextprotocol/sdk.
|
|
16
|
+
*
|
|
17
|
+
* THE FIX (this module): make each MCP entry point self-sufficient. Each server
|
|
18
|
+
* calls `requireWithHeal(...)` instead of bare `require(...)`. On a
|
|
19
|
+
* MODULE_NOT_FOUND it runs a ONE-SHOT synchronous `npm install` in the plugin
|
|
20
|
+
* cache root, then re-requires. Combined with flipping the reconcile hook to
|
|
21
|
+
* synchronous (async:false) in hooks.json, this closes the race from both ends:
|
|
22
|
+
* - healthy session: requireWithHeal succeeds first try, near-zero cost.
|
|
23
|
+
* - first post-update session: the hook usually wins; if it has not, the
|
|
24
|
+
* server heals itself before connecting its transport.
|
|
25
|
+
*
|
|
26
|
+
* RACE GUARD: both servers can spawn together. npm-install-lock.cjs guarantees
|
|
27
|
+
* exactly one runs `npm install` while the other WAITS, so two concurrent
|
|
28
|
+
* installs never corrupt node_modules.
|
|
29
|
+
*
|
|
30
|
+
* Canon Part 8: zero network surface. The only child process is `npm install`.
|
|
31
|
+
* No Brain calls, no external requests, no user data.
|
|
32
|
+
*
|
|
33
|
+
* Canon Part 7: reuse before build -- this mirrors the detection logic already
|
|
34
|
+
* in scripts/sessionstart-npm-reconcile.cjs (the hook) rather than inventing a
|
|
35
|
+
* new mechanism; it is the same `npm install --no-audit --no-fund --silent`
|
|
36
|
+
* invocation, wrapped for the require-time crash path.
|
|
37
|
+
*
|
|
38
|
+
* CROSS-PLATFORM (escalated mandate 2026-05-21): the npm invocation is resolved
|
|
39
|
+
* through lib/core/npm-cli-resolve.cjs, which runs npm via its absolute
|
|
40
|
+
* npm-cli.js entry off process.execPath -- correct on Windows (no `.cmd`
|
|
41
|
+
* dependency), Mac (no PATH dependency for GUI-launched Claude Code), and Linux.
|
|
42
|
+
* The bare spawnSync('npm') the prior fix used was dead on Windows and fragile
|
|
43
|
+
* on Mac. The self-heal here is the BACKSTOP; the primary guarantee is the
|
|
44
|
+
* vendored production node_modules shipped with the plugin (see CHANGELOG
|
|
45
|
+
* v1.13.0-beta.23) -- on a normal install ensureDepsPresent finds the deps
|
|
46
|
+
* already present and never spawns anything.
|
|
47
|
+
*
|
|
48
|
+
* HARD RULE: no em-dashes anywhere in this file (hyphens only).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const fs = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
const { spawnSync } = require('node:child_process');
|
|
54
|
+
|
|
55
|
+
const {
|
|
56
|
+
acquireInstallLock,
|
|
57
|
+
releaseInstallLock,
|
|
58
|
+
waitForUnlock,
|
|
59
|
+
} = require('./npm-install-lock.cjs');
|
|
60
|
+
const { resolveNpmCli, buildInstallArgs } = require('./npm-cli-resolve.cjs');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the plugin cache root the install must run in. CLAUDE_PLUGIN_ROOT is
|
|
64
|
+
* set by Claude Code when it spawns plugin processes; the __dirname fallback
|
|
65
|
+
* (lib/core -> plugin root) covers manual / test invocation.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} [fallbackDir] - explicit override (used by callers / tests)
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function resolvePluginRoot(fallbackDir) {
|
|
71
|
+
return (
|
|
72
|
+
process.env.CLAUDE_PLUGIN_ROOT ||
|
|
73
|
+
process.env.MINDRIAN_OS_ROOT ||
|
|
74
|
+
fallbackDir ||
|
|
75
|
+
path.resolve(__dirname, '..', '..')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `npm install` once in `dir`, guarded so two racing servers cannot run it
|
|
81
|
+
* concurrently. The loser waits for the winner instead.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} dir
|
|
84
|
+
* @returns {{ ran: boolean, waited: boolean, ok: boolean }}
|
|
85
|
+
*/
|
|
86
|
+
function runGuardedInstall(dir) {
|
|
87
|
+
const haveLock = acquireInstallLock(dir);
|
|
88
|
+
|
|
89
|
+
if (!haveLock) {
|
|
90
|
+
// Another live process is installing. Wait for it, then return without
|
|
91
|
+
// running our own install -- node_modules should now exist.
|
|
92
|
+
const cleared = waitForUnlock(dir);
|
|
93
|
+
return { ran: false, waited: true, ok: cleared };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Portable npm resolution: run npm via its absolute npm-cli.js off the
|
|
98
|
+
// current node binary (process.execPath). This is correct on Windows
|
|
99
|
+
// (no `.cmd` extension dependency), Mac (no PATH dependency), and Linux.
|
|
100
|
+
const npm = resolveNpmCli();
|
|
101
|
+
const result = spawnSync(
|
|
102
|
+
npm.command,
|
|
103
|
+
buildInstallArgs(npm),
|
|
104
|
+
{ cwd: dir, timeout: 120000, stdio: 'ignore', shell: npm.shell }
|
|
105
|
+
);
|
|
106
|
+
const ok = !!result && result.status === 0;
|
|
107
|
+
return { ran: true, waited: false, ok };
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return { ran: true, waited: false, ok: false };
|
|
110
|
+
} finally {
|
|
111
|
+
releaseInstallLock(dir);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* require() a module; on MODULE_NOT_FOUND, run a one-shot guarded `npm install`
|
|
117
|
+
* in the plugin cache root and retry exactly once.
|
|
118
|
+
*
|
|
119
|
+
* Any non-MODULE_NOT_FOUND error is re-thrown immediately (a real bug, not a
|
|
120
|
+
* missing-dependency situation -- healing would not help).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} moduleId - the module specifier to require
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {string} [opts.pluginRoot] - explicit plugin cache root override
|
|
125
|
+
* @param {function} [opts.log] - sink for a one-line stderr breadcrumb
|
|
126
|
+
* @returns {*} the required module
|
|
127
|
+
*/
|
|
128
|
+
function requireWithHeal(moduleId, opts) {
|
|
129
|
+
opts = opts || {};
|
|
130
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
131
|
+
try {
|
|
132
|
+
return require(moduleId);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (!err || err.code !== 'MODULE_NOT_FOUND') throw err;
|
|
135
|
+
|
|
136
|
+
const dir = resolvePluginRoot(opts.pluginRoot);
|
|
137
|
+
log('[mcp-dep-heal] missing dependency for ' + moduleId + '; self-healing npm install in ' + dir);
|
|
138
|
+
|
|
139
|
+
const outcome = runGuardedInstall(dir);
|
|
140
|
+
log(
|
|
141
|
+
'[mcp-dep-heal] install ' +
|
|
142
|
+
(outcome.waited ? 'waited-for-peer' : outcome.ran ? 'ran' : 'skipped') +
|
|
143
|
+
'; ok=' + outcome.ok
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Retry the require. If the peer-install or our own install succeeded the
|
|
147
|
+
// module now resolves. If it still fails, the error propagates -- the
|
|
148
|
+
// server crashes with a clear MODULE_NOT_FOUND, exactly as before, and the
|
|
149
|
+
// SessionStart reconcile hook is the remaining safety net for next session.
|
|
150
|
+
return require(moduleId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The full production dependency set the plugin requires at runtime, read from
|
|
156
|
+
* the plugin's own package.json. This is the bug_011 fix: a probe limited to
|
|
157
|
+
* just ['@modelcontextprotocol/sdk', 'zod'] passes on a PARTIALLY-populated
|
|
158
|
+
* node_modules (sdk + zod present, @modelcontextprotocol/ext-apps or another
|
|
159
|
+
* dep absent), no heal runs, then a bare `require` deeper in the lib/mcp/*
|
|
160
|
+
* chain (capability-registry.cjs -> app-views.cjs -> ext-apps/server) throws
|
|
161
|
+
* MODULE_NOT_FOUND at module-init scope and crashes the server. Probing the
|
|
162
|
+
* full `dependencies` set -- exactly as scripts/sessionstart-npm-reconcile.cjs
|
|
163
|
+
* already does -- catches an incomplete tree before any require runs.
|
|
164
|
+
*
|
|
165
|
+
* Defensive: a missing or unreadable package.json yields the MCP-critical pair
|
|
166
|
+
* as a fallback rather than crashing -- the heal pre-flight must never throw.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} dir - resolved plugin cache root
|
|
169
|
+
* @returns {string[]} dependency names to stat-check
|
|
170
|
+
*/
|
|
171
|
+
function productionDepNames(dir) {
|
|
172
|
+
const fallback = ['@modelcontextprotocol/sdk', 'zod'];
|
|
173
|
+
try {
|
|
174
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
175
|
+
if (!fs.existsSync(pkgPath)) return fallback;
|
|
176
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
177
|
+
const names = Object.keys(pkg.dependencies || {});
|
|
178
|
+
return names.length ? names : fallback;
|
|
179
|
+
} catch (_) {
|
|
180
|
+
// Unreadable / unparseable package.json -- fall back gracefully.
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Heal the plugin's node_modules up front, BEFORE any dependency require runs.
|
|
187
|
+
* Cheap pre-flight: a few stat() calls on a healthy box, the guarded install
|
|
188
|
+
* only on a genuinely-missing cache.
|
|
189
|
+
*
|
|
190
|
+
* MCP entry points call this once at the very top so the subsequent SDK / zod
|
|
191
|
+
* requires are guaranteed to resolve. Idempotent and defensive: any error is
|
|
192
|
+
* swallowed (the per-require requireWithHeal path remains as a second net).
|
|
193
|
+
*
|
|
194
|
+
* @param {object} [opts]
|
|
195
|
+
* @param {string} [opts.pluginRoot]
|
|
196
|
+
* @param {string[]} [opts.probe] - explicit dependency names to stat-check.
|
|
197
|
+
* When omitted, defaults to the FULL
|
|
198
|
+
* production dependency set from the plugin's
|
|
199
|
+
* package.json (bug_011) so a partially
|
|
200
|
+
* populated node_modules is detected, not just
|
|
201
|
+
* a totally absent one.
|
|
202
|
+
* @param {function} [opts.log]
|
|
203
|
+
* @returns {{ healed: boolean, ok: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
function ensureDepsPresent(opts) {
|
|
206
|
+
opts = opts || {};
|
|
207
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
208
|
+
const dir = resolvePluginRoot(opts.pluginRoot);
|
|
209
|
+
const probe = Array.isArray(opts.probe) && opts.probe.length
|
|
210
|
+
? opts.probe
|
|
211
|
+
: productionDepNames(dir);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const nm = path.join(dir, 'node_modules');
|
|
215
|
+
let missing = false;
|
|
216
|
+
if (!fs.existsSync(nm)) {
|
|
217
|
+
missing = true;
|
|
218
|
+
} else {
|
|
219
|
+
for (const dep of probe) {
|
|
220
|
+
if (!fs.existsSync(path.join(nm, ...dep.split('/')))) { missing = true; break; }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!missing) return { healed: false, ok: true };
|
|
224
|
+
|
|
225
|
+
log('[mcp-dep-heal] node_modules missing/incomplete; self-healing npm install in ' + dir);
|
|
226
|
+
const outcome = runGuardedInstall(dir);
|
|
227
|
+
log(
|
|
228
|
+
'[mcp-dep-heal] install ' +
|
|
229
|
+
(outcome.waited ? 'waited-for-peer' : 'ran') +
|
|
230
|
+
'; ok=' + outcome.ok
|
|
231
|
+
);
|
|
232
|
+
return { healed: true, ok: outcome.ok };
|
|
233
|
+
} catch (_) {
|
|
234
|
+
// Never let the heal pre-flight itself crash the server -- requireWithHeal
|
|
235
|
+
// is the backstop.
|
|
236
|
+
return { healed: false, ok: false };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
requireWithHeal,
|
|
242
|
+
ensureDepsPresent,
|
|
243
|
+
runGuardedInstall,
|
|
244
|
+
resolvePluginRoot,
|
|
245
|
+
productionDepNames,
|
|
246
|
+
};
|