@mindrian_os/install 1.13.0-beta.24 → 1.13.0-beta.28

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' },
@@ -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,