@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.
@@ -128,35 +128,32 @@ const SKILL_SPAWN_RULES = Object.freeze([
128
128
 
129
129
  // ---------- Internal helpers ----------
130
130
 
131
- // Sanitize a string for embedding in a Cypher query. Mirrors the byte-
132
- // for-byte whitelist from brain-client.cjs::sanitizeCypherInput
133
- // ([a-zA-Z0-9 ._-]). We do NOT call brainClient._test.sanitizeCypherInput
134
- // directly so this module remains testable with a stub brainClient.
135
- function _sanitizeCypher(value) {
131
+ // Sanitize a string to a safe generic handle suitable for embedding in a
132
+ // NL question sent to brain_ask. Mirrors the whitelist from brain-client.cjs
133
+ // ([a-zA-Z0-9 ._-]) so only generic methodology handles pass through.
134
+ // Canon Part 8: no user content ever flows into the NL question.
135
+ function _sanitizeHandle(value) {
136
136
  if (value === null || value === undefined) return '';
137
137
  const s = typeof value === 'string' ? value : String(value);
138
138
  return s.replace(/[^a-zA-Z0-9 ._-]/g, '');
139
139
  }
140
140
 
141
- // Build the Brain Cypher query asking for upstream FEEDS_INTO frameworks
142
- // for the reverse-salient engine, parameterized (defensively sanitized)
143
- // by problem_type + stage for forward-compat (89.5 may bind these via
144
- // Brain's parameterized query path).
145
- function _buildUpstreamCypher(problem_type, stage) {
146
- const safeProblem = _sanitizeCypher(problem_type);
147
- const safeStage = _sanitizeCypher(stage);
148
- // Note: safeProblem / safeStage are intentionally embedded in a
149
- // comment line so they participate in the query text without
150
- // affecting the MATCH semantics. The authoritative Brain edge query
151
- // is for upstream frameworks pointing at the reverse-salient node.
152
- return (
153
- '// chain-feeder upstream lookup (problem_type=' + safeProblem +
154
- ' stage=' + safeStage + ')\n' +
155
- 'MATCH (rs:Framework {id: "reverse-salient-framework"}) ' +
156
- '<-[:FEEDS_INTO]-(upstream:Framework) ' +
157
- 'WHERE rs.id IS NOT NULL ' +
158
- 'RETURN upstream.name AS name LIMIT 10'
159
- );
141
+ // Build the NL question for brain_ask asking for upstream FEEDS_INTO
142
+ // frameworks for the reverse-salient engine.
143
+ // BUG 2 fix: replaced the former admin-gated raw-Cypher brain_query path
144
+ // with a brain_ask question (ungated -- valid for all API keys).
145
+ // Canon Part 8: the question carries only sanitized generic enum handles,
146
+ // never user content, never artifact text.
147
+ function _buildUpstreamQuestion(problem_type, stage) {
148
+ const safeProblem = _sanitizeHandle(problem_type);
149
+ const safeStage = _sanitizeHandle(stage);
150
+ // Generic NL question: asks for frameworks that feed into the
151
+ // reverse-salient framework for the given problem type and stage.
152
+ let q = 'what frameworks feed into the reverse salient framework';
153
+ if (safeProblem) q += ' for a ' + safeProblem + ' problem';
154
+ if (safeStage) q += ' at the ' + safeStage + ' stage';
155
+ q += '?';
156
+ return q;
160
157
  }
161
158
 
162
159
  // Defensively pull the framework name from a Brain record. Brain
@@ -187,18 +184,53 @@ async function lookupUpstream(problem_type, stage, opts) {
187
184
  return { state: 'ready' };
188
185
  }
189
186
 
190
- let result;
187
+ // BUG 2 fix: use brain_ask (ungated) instead of brain_query (admin-gated).
188
+ // brain.ask() returns a DirectiveEnvelope; we read next_gate.options[].framework
189
+ // for the upstream framework names, mirroring the old record.name extraction.
190
+ if (typeof brainClient.ask !== 'function') {
191
+ process.stderr.write('chain-feeder: brainClient.ask not available; assuming upstream ready\n');
192
+ return { state: 'ready' };
193
+ }
194
+
195
+ let askResult;
191
196
  try {
192
- const cypher = _buildUpstreamCypher(problem_type, stage);
193
- result = await brainClient.query(cypher);
197
+ const nlQuestion = _buildUpstreamQuestion(problem_type, stage);
198
+ askResult = await brainClient.ask(nlQuestion);
194
199
  } catch (_err) {
195
- process.stderr.write('chain-feeder: Brain query threw; assuming upstream ready\n');
200
+ process.stderr.write('chain-feeder: Brain ask threw; assuming upstream ready\n');
201
+ return { state: 'ready' };
202
+ }
203
+
204
+ if (!askResult) {
205
+ process.stderr.write(
206
+ 'chain-feeder: Brain ask returned null; assuming upstream ready\n'
207
+ );
196
208
  return { state: 'ready' };
197
209
  }
198
210
 
199
- if (!result || !Array.isArray(result.records)) {
211
+ // Translate the brain_ask response into the framework-name list shape
212
+ // the upstream-freshness check expects. next_gate.options[].framework
213
+ // carries the framework names; fall back to directive.guided.framework.
214
+ // Build a synthetic result.records array for the existing freshness loop.
215
+ const frameworkNames = [];
216
+ if (askResult.next_gate && Array.isArray(askResult.next_gate.options)) {
217
+ for (const opt of askResult.next_gate.options) {
218
+ if (opt && typeof opt.framework === 'string' && opt.framework.length > 0) {
219
+ frameworkNames.push(opt.framework);
220
+ }
221
+ }
222
+ }
223
+ if (askResult.directive && askResult.directive.guided && askResult.directive.guided.framework) {
224
+ const anchor = askResult.directive.guided.framework;
225
+ if (!frameworkNames.includes(anchor)) frameworkNames.unshift(anchor);
226
+ }
227
+
228
+ // Wrap in the shape the downstream freshness loop already handles.
229
+ const result = { records: frameworkNames.map(function (n) { return { name: n }; }) };
230
+
231
+ if (!Array.isArray(result.records)) {
200
232
  process.stderr.write(
201
- 'chain-feeder: Brain query returned unexpected shape; assuming upstream ready\n'
233
+ 'chain-feeder: Brain ask returned unexpected shape; assuming upstream ready\n'
202
234
  );
203
235
  return { state: 'ready' };
204
236
  }
@@ -182,9 +182,14 @@ const ALLOW_LIST_INTENTS = Object.freeze([
182
182
  /downstream\s+of/i,
183
183
  /what\s+chains?\s+into/i,
184
184
  ],
185
- brain_template:
186
- 'MATCH (m:Methodology {id: $target_framework})-[:FEEDS_INTO*0..1]-(t:Methodology) '
187
- + 'RETURN t.name AS name, t.id AS id LIMIT 10',
185
+ // BUG 2 fix: brain_template is now null so buildBrainQueryFromNL returns
186
+ // null (the safe default) and does NOT attempt raw admin-gated Cypher.
187
+ // The brain_ask NL question is synthesized in executeBundle (rs-explain-
188
+ // command.cjs) from intent_id + params.target_framework (enum handle only;
189
+ // Canon Part 8 preserved). brain_ask is ungated; brain_query was admin-gated.
190
+ brain_template: null,
191
+ // brain_ask_template documents the NL question shape used at runtime.
192
+ brain_ask_template: 'what frameworks chain from {target_framework} via FEEDS_INTO?',
188
193
  sql_template: null,
189
194
  cypher_template: null,
190
195
  params_extractor: extractFrameworkParam,
@@ -221,9 +226,14 @@ const ALLOW_LIST_INTENTS = Object.freeze([
221
226
  /what\s+comes\s+after/i,
222
227
  /chains?\s+from\s+(?:jtbd|persona|swot|porter)/i,
223
228
  ],
224
- brain_template:
225
- 'MATCH (m:Methodology {id: $source_id})-[:FEEDS_INTO*1..3]->(t:Methodology) '
226
- + 'RETURN t.name AS name, t.id AS id LIMIT 10',
229
+ // BUG 2 fix: brain_template is now null so buildBrainQueryFromNL returns
230
+ // null (the safe default) and does NOT attempt raw admin-gated Cypher.
231
+ // The brain_ask NL question is synthesized in executeBundle (rs-explain-
232
+ // command.cjs) from intent_id + params.source_id (enum handle only;
233
+ // Canon Part 8 preserved). brain_ask is ungated; brain_query was admin-gated.
234
+ brain_template: null,
235
+ // brain_ask_template documents the NL question shape used at runtime.
236
+ brain_ask_template: 'what methodology chain follows {source_id}?',
227
237
  sql_template: null,
228
238
  cypher_template: null,
229
239
  params_extractor: extractMethodologyParam,
@@ -261,18 +261,35 @@ function buildBrainQueryContext(localContext) {
261
261
  }
262
262
 
263
263
  // ============================================================
264
- // Brain query -- SINGLE chokepoint (Tripwire #2).
264
+ // Brain search -- SINGLE chokepoint (Tripwire #2).
265
265
  // ============================================================
266
266
  //
267
267
  // The ONE AND ONLY function in this module that reaches Brain. Every
268
268
  // other code path that wants Brain enrichment routes through here.
269
+ //
270
+ // BUG FIX 2026-05-22: the former implementation called client.query(payload)
271
+ // where payload was a plain OBJECT (assembled by tryBrainHints). brain_query
272
+ // requires a Cypher STRING; passing an object causes MCP input validation to
273
+ // fail silently, making Mode A cross-room enrichment dead for all users.
274
+ //
275
+ // Fix: route the JTBD co-occurrence hint lookup through the UNGATED
276
+ // brain_search (semantic) instead. We build a generic methodology query
277
+ // string from the allow-listed context keys and call client.search().
278
+ // brain_search accepts a free-text query string and returns semantic
279
+ // matches from the Pinecone index -- no admin gate, no Cypher string
280
+ // required, no user content (the query string is derived ONLY from
281
+ // allow-listed enums and slugs per Canon Part 8).
282
+ //
283
+ // Result shape from brain_search: { matches: Array<{title,score,...}> }
284
+ // or { records: Array<...> } depending on the server version. We adapt
285
+ // both shapes in tryBrainHints below.
269
286
 
270
- async function tryBrainQuery(payload) {
287
+ async function tryBrainSearch(queryText) {
271
288
  const client = getBrainClient();
272
- if (!client || typeof client.query !== 'function') return null;
289
+ if (!client || typeof client.search !== 'function') return null;
273
290
  // The single Brain call site. (Tripwire #2: exactly one match for
274
- // /(?:brainClient|brain|client)\.query\(/ in the entire module.)
275
- return await client.query(payload);
291
+ // /(?:tryBrainSearch|client\.search)\(/ in the entire module.)
292
+ return await client.search(queryText, { topK: 3 });
276
293
  }
277
294
 
278
295
  // ============================================================
@@ -411,32 +428,58 @@ async function tryBrainHints(byJtbd) {
411
428
  const jtbds = Object.keys(byJtbd);
412
429
  if (jtbds.length === 0) return null;
413
430
 
414
- const hints = {};
415
- for (const jtbd of jtbds) {
416
- const ctx = buildBrainQueryContext({
417
- jtbd_id: jtbd,
418
- jtbd_state_enum: 'in_flight',
419
- });
420
- const payload = Object.assign({ query_type: 'jtbd_cooccurrence' }, ctx);
431
+ // Build a SINGLE generic methodology query string from the JTBD slugs.
432
+ // The query string is derived from allow-listed slug tokens only (no user
433
+ // prose, no artifact bodies, no room names). Canon Part 8 safe.
434
+ //
435
+ // We send ONE search call for all JTBDs together (rather than one per JTBD)
436
+ // to minimize Brain round-trips; the semantic results are generic methodology
437
+ // patterns applicable to any venture doing these jobs.
438
+ const safeJtbdList = jtbds
439
+ .filter(function (j) { return typeof j === 'string' && /^[a-z0-9-]{1,64}$/.test(j); })
440
+ .slice(0, 8);
441
+
442
+ if (safeJtbdList.length === 0) return null;
443
+
444
+ // Query string: fully generic methodology language (no user content).
445
+ // e.g. "methodology patterns for jtbd: prepare-pitch find-bottleneck"
446
+ const queryText = 'methodology patterns for jtbd: ' + safeJtbdList.join(' ');
447
+
448
+ // Tripwire #4: scan the outgoing query string before send.
449
+ const queryPayload = { q: queryText };
450
+ if (!payloadIsClean(queryPayload)) {
451
+ // Query audit failed; abort Mode A.
452
+ return null;
453
+ }
421
454
 
422
- // Tripwire #4: scan before send
423
- if (!payloadIsClean(payload)) {
424
- // payload audit failed; abort Mode A (pretend Brain unreachable)
425
- return null;
426
- }
455
+ let r;
456
+ try {
457
+ r = await tryBrainSearch(queryText);
458
+ } catch (err) {
459
+ // Re-throw so outer aggregateAcrossRooms can record the warning
460
+ // row. This is the "read-only Brain failure mid-render" case --
461
+ // we degrade to Mode B but stamp a single warning row.
462
+ throw err;
463
+ }
427
464
 
428
- let r;
429
- try {
430
- r = await tryBrainQuery(payload);
431
- } catch (err) {
432
- // Re-throw so outer aggregateAcrossRooms can record the warning
433
- // row. This is the "read-only Brain failure mid-render" case --
434
- // we degrade to Mode B but stamp a single warning row.
435
- throw err;
436
- }
437
- if (r && r.patterns) {
438
- hints[jtbd] = r.patterns;
439
- }
465
+ // brain_search returns { matches: [...] } or { records: [...] }.
466
+ // Extract the pattern list and assign uniformly across all JTBDs.
467
+ // (The response is generic -- there are no per-JTBD buckets from semantic
468
+ // search; we attribute the same hint set to every JTBD in the batch.)
469
+ const matchItems = (r && Array.isArray(r.matches)) ? r.matches
470
+ : (r && Array.isArray(r.records)) ? r.records
471
+ : [];
472
+
473
+ if (matchItems.length === 0) return null;
474
+
475
+ // Map semantic matches to the patterns shape the caller expects.
476
+ const patterns = matchItems.slice(0, 5).map(function (m) {
477
+ return { title: m.title || m.name || 'methodology pattern', score: m.score || 0 };
478
+ });
479
+
480
+ const hints = {};
481
+ for (const jtbd of safeJtbdList) {
482
+ hints[jtbd] = patterns;
440
483
  }
441
484
 
442
485
  return Object.keys(hints).length > 0 ? hints : null;
@@ -254,7 +254,13 @@ function localRoute(roomDir, stateContent, intent) {
254
254
  // ---------------------------------------------------------------------------
255
255
 
256
256
  /**
257
- * Call Brain API for framework recommendation.
257
+ * Call Brain API for framework recommendation via brain_ask (ungated).
258
+ * Replaced the former raw-Cypher brain_query path (admin-gated, BUG 2)
259
+ * with brain.ask(question) -- valid for all API keys.
260
+ * Reads next_gate.options[].framework for the ranked chain.
261
+ * Canon Part 8: the NL question carries only the generic problem-type enum,
262
+ * never user content or artifact text.
263
+ *
258
264
  * @param {string} roomDir
259
265
  * @param {string} stateContent
260
266
  * @param {string} [intent]
@@ -270,65 +276,73 @@ async function brainRoute(roomDir, stateContent, intent) {
270
276
 
271
277
  if (!brainClient.isAvailable()) return null;
272
278
 
273
- // Query Brain for framework recommendations based on room state
279
+ // Build a generic NL question carrying only the problem-type enum.
280
+ // Canon Part 8: no user content, no artifact text, no proprietary numbers.
274
281
  const { definition, complexity } = extractProblemType(stateContent);
282
+ const safeDefinition = definition.replace(/[^a-zA-Z-]/g, '') || 'undefined';
283
+ const safeComplexity = complexity.replace(/[^a-zA-Z-]/g, '') || 'complex';
284
+ const question = 'recommend a framework for a ' + safeDefinition + ' definition '
285
+ + safeComplexity + ' problem';
286
+
287
+ let brainResult;
288
+ try {
289
+ if (typeof brainClient.ask !== 'function') return null;
290
+ brainResult = await brainClient.ask(question);
291
+ } catch (_e) {
292
+ return null;
293
+ }
294
+
295
+ if (!brainResult) return null;
296
+
297
+ // Read next_gate.options[] for the ranked framework chain.
298
+ // Gracefully handle both presence and absence of next_gate.
299
+ const options = (brainResult.next_gate && Array.isArray(brainResult.next_gate.options))
300
+ ? brainResult.next_gate.options
301
+ : [];
275
302
 
276
- // PIPE-03: Include both CO_OCCURS and FEEDS_INTO relationships
277
- // for chain ordering. FEEDS_INTO encodes directional sequences
278
- // (e.g., scenario-plan FEEDS_INTO root-cause), while CO_OCCURS
279
- // captures frameworks that commonly run together.
280
- // I-3 fix: sanitize complexity to prevent Cypher injection from malformed STATE.md
281
- const safeComplexity = complexity.replace(/[^a-zA-Z]/g, '');
282
- const cypher = `
283
- MATCH (pt:ProblemType)<-[:ADDRESSES_PROBLEM_TYPE]-(f:Framework)
284
- WHERE pt.name CONTAINS "${safeComplexity}"
285
- WITH f, pt
286
- OPTIONAL MATCH (f)-[:FEEDS_INTO]->(f_next:Framework)
287
- WITH f, pt, collect(DISTINCT f_next.name) AS feeds_chain
288
- OPTIONAL MATCH (f)-[:CO_OCCURS]->(f_co:Framework)
289
- WITH f, pt, feeds_chain, collect(DISTINCT f_co.name) AS co_chain
290
- RETURN f.name AS primary,
291
- CASE WHEN size(feeds_chain) > 0 THEN feeds_chain[..3]
292
- ELSE co_chain[..3] END AS chain,
293
- pt.name AS problem_type,
294
- CASE WHEN size(feeds_chain) > 0 THEN 'feeds_into'
295
- ELSE 'co_occurs' END AS chain_type
296
- LIMIT 3
297
- `;
298
-
299
- const result = await brainClient.query(cypher);
300
-
301
- if (result && result.records && result.records.length > 0) {
302
- const rec = result.records[0];
303
- const primary = rec.primary || rec[0];
304
- const chainNames = rec.chain || rec[1] || [];
305
- const fullChain = [primary, ...chainNames].filter(Boolean);
306
-
307
- // Map Brain framework names to CLI command names
308
- const mappedChain = fullChain
309
- .map(name => {
310
- const normalized = name.toLowerCase().replace(/[^a-z-]/g, '');
311
- return KNOWN_METHODOLOGIES.find(m => m === normalized || normalized.includes(m))
312
- || normalized;
313
- })
314
- .filter(Boolean)
315
- .slice(0, 4);
316
-
317
- if (mappedChain.length > 0) {
318
- const chainType = rec.chain_type || rec[3] || 'co_occurs';
319
- return {
320
- chain: mappedChain,
321
- confidence: 0.85,
322
- source: 'brain',
323
- chain_type: chainType,
324
- reasoning: `Brain recommends ${mappedChain.join(' -> ')} for ${definition} ${complexity} problem. ` +
325
- `Chain derived from ${chainType === 'feeds_into' ? 'FEEDS_INTO (sequential)' : 'CO_OCCURS (complementary)'} relationships.`,
326
- target_sections: []
327
- };
303
+ const anchorFramework = (brainResult.directive && brainResult.directive.guided)
304
+ ? (brainResult.directive.guided.framework || null)
305
+ : null;
306
+
307
+ // Build the chain: anchor first (if present + not already in options), then options[].framework.
308
+ const rawChain = [];
309
+ if (anchorFramework && typeof anchorFramework === 'string') rawChain.push(anchorFramework);
310
+ for (const opt of options) {
311
+ if (opt && typeof opt.framework === 'string' && opt.framework.length > 0) {
312
+ if (!rawChain.includes(opt.framework)) rawChain.push(opt.framework);
328
313
  }
329
314
  }
330
315
 
331
- return null;
316
+ if (rawChain.length === 0) return null;
317
+
318
+ // Map Brain framework names to CLI methodology names (slug normalization).
319
+ // This mirrors the previous mapping in the old Cypher path.
320
+ const mappedChain = rawChain
321
+ .map(function (name) {
322
+ const normalized = name.toLowerCase().replace(/[^a-z-]/g, '');
323
+ return KNOWN_METHODOLOGIES.find(function (m) { return m === normalized || normalized.includes(m); })
324
+ || normalized;
325
+ })
326
+ .filter(Boolean)
327
+ .slice(0, 4);
328
+
329
+ if (mappedChain.length === 0) return null;
330
+
331
+ // Derive top confidence from the options array (or default 0.8).
332
+ const topConf = (options.length > 0 && typeof options[0].confidence === 'number')
333
+ ? options[0].confidence
334
+ : 0.8;
335
+
336
+ return {
337
+ chain: mappedChain,
338
+ confidence: topConf,
339
+ source: 'brain',
340
+ chain_type: 'feeds_into',
341
+ reasoning: 'Brain recommends ' + mappedChain.join(' -> ') + ' for '
342
+ + safeDefinition + ' ' + safeComplexity + ' problem. '
343
+ + 'Chain derived from brain_ask FEEDS_INTO recommendations.',
344
+ target_sections: [],
345
+ };
332
346
  }
333
347
 
334
348
  // ---------------------------------------------------------------------------