@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.
@@ -1,33 +1,46 @@
1
1
  'use strict';
2
- // Phase 125-02 -- Brain Cypher slice query (parameterized 1-3 hop FEEDS_INTO; LIMIT 50).
3
- // CONTEXT.md Scope IN section B item 4 -- the LOCKED Cypher. Async wrapper over
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
- // Canon Part 8: only $active_frameworks (array of generic framework name strings,
8
- // each passed through sanitizeCypherInput) + $max_hops (1|2|3 int) cross the wire.
9
- // No user content. No artifact text. No room identifiers in the query.
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
- // Graceful degradation: any failure path (Brain unreachable, query throws, invalid
12
- // max_hops, empty active_frameworks, all-rejected-by-sanitizer, non-array result)
13
- // returns a degraded result envelope rather than throwing. Tier 0 stays functional.
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
- // LOCKED Cypher template per CONTEXT.md Scope IN section B item 4.
19
- // READ-ONLY (MATCH/RETURN/LIMIT only -- no MERGE/CREATE/DELETE). The two $-bound
20
- // params ($active_frameworks, $max_hops) are the ONLY values that cross the wire;
21
- // every framework name in $active_frameworks is sanitized via the brain-client
22
- // sanitizeCypherInput whitelist before binding (Canon Part 8 invariant).
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 parameterized 1-3 hop FEEDS_INTO slice from the live Brain.
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:number|null,
75
- * transform_description:string|null, hop_distance:number|null}>,
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. Anything else (0, 4, undefined,
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 before issuing the Cypher query. The
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
- // Canon Part 8 sanitization. brain-client._test.sanitizeCypherInput is the
107
- // shipped whitelist (Phase 110-05 D-04 invariant). Each framework name is
108
- // sanitized BEFORE binding; values that scrub down to empty are dropped.
109
- // Fallback no-op only when the seam is genuinely absent (the live
110
- // brain-client always exposes it; tests may stub a minimal client).
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 parameterized Cypher. The ONLY $-bound values are the sanitized
127
- // framework name array + the max_hops integer. The Cypher string itself is
128
- // a frozen constant -- no template interpolation, no string concatenation
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
- result = await brainClient.query(FRAMEWORK_CHAIN_SLICE_CYPHER, {
133
- active_frameworks: sanitized,
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 = _unwrapRows(result);
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 raw rows to the framework_chain_hint edge shape. Null-tolerant per
150
- // G-05: confidence and transform_description may be null on the live graph
151
- // and we preserve null explicitly (no defaults). hop_distance comes from
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: (r && typeof r.confidence === 'number') ? r.confidence : null,
159
- transform_description: (r && typeof r.transform_description === 'string')
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
- // brain_snapshot_id: per CONTEXT.md Open Question #4, "Brain commit_id if
167
- // exposed, else SHA of Cypher result." The shipped brain_query does NOT
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 + ' active_frameworks; max_hops=' + max_hops,
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
- 'MATCH (f:Framework)-[:ADDRESSES_PROBLEM_TYPE]->(pt:ProblemType) ' +
192
- 'WHERE pt.name CONTAINS "' + problemType + '" ' +
193
- 'OPTIONAL MATCH (f)-[:APPLIES_AT_PHASE]->(p:Phase {name: "' + phase + '"}) ' +
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
- 'MATCH (f1:Framework)-[:APPLIES_AT_PHASE]->(p:Phase {name: "' + phase + '"}) ' +
250
- 'MATCH (f1)-[:FEEDS_INTO]->(f2:Framework) ' +
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: 'query' },
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: 'query' },
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
+ };