@mindrian_os/cli 2.0.0-beta.31 → 2.0.0-beta.33

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,7 +1,7 @@
1
1
  {
2
2
  "name": "mos",
3
3
  "description": "MindrianOS -- Your AI innovation co-founder. Larry thinks with you through PWS methodology, builds your Data Room as you explore, and chains frameworks intelligently. Install and go.",
4
- "version": "2.0.0-beta.31",
4
+ "version": "2.0.0-beta.33",
5
5
  "author": {
6
6
  "name": "Jonathan Sagir",
7
7
  "url": "https://mindrian-os.com"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## [2.0.0-beta.33] - 2026-09-10
2
+
3
+ ### Fixed - Theo answers Larry end to end (quick task 260910-hni)
4
+
5
+ - `brain_ask` through the plugin no longer returns an empty DirectiveEnvelope on every Theo answer.
6
+ `lib/core/brain-client.cjs` `ask()` now composes `directive.guided.framework`, `next_gate.options`
7
+ (ranked framework chain with the `/mos:` commands Theo links to each framework) and a new additive
8
+ `grounding` field (Theo's ranked book rows: chapterId, section, score, snippet) from Theo's
9
+ `structured_rows` + `recommend_chain`. The question's own words are never echoed back (Canon Part 8).
10
+ Incumbent-shaped responses pass through byte-unchanged. Live e2e through the MCP shim: framework
11
+ Design Thinking, 4 chain options, 8 grounding rows.
12
+ - `askOp()` accepts Theo's op-mode shape (`rows` + `coverage`); it had been degrading every curated op to
13
+ zero rows because Theo never sends a `count` key.
14
+ - `lib/mcp/brain-router.cjs` routes `/mos:act` chains from the commands Theo attaches to each framework,
15
+ so Tier 3 is graph-grounded again instead of silently falling to the local heuristic.
16
+ - `lib/core/part8-egress-guard.cjs` recognizes the `recommend_chain` payload shape (rung enum +
17
+ step budget) as a known safe shape. `tests/run-all-339.sh` now counts a `.cjs` leg that prints SKIP
18
+ as skipped, not passed.
19
+ - Larry's thin-grounding clause (`skills/larry-personality/SKILL.md`) keys on an empty `grounding.rows`
20
+ instead of the retired "empty signals set" wording.
21
+
22
+ ### Removed - Canon Part 8 (quick task 260910-h32)
23
+
24
+ - `scripts/sync-rooms-brain` and its three detached spawns (`scripts/session-start`, twice in
25
+ `scripts/room-registry`). It string-interpolated every room's name, venture, stage, status and path
26
+ into `brain_write` Cypher and sent it to the Brain origin on every SessionStart (observed: 1,498
27
+ POSTs, 55 rooms, Authorization header on each). Room state already lives in local SQLite via
28
+ `sync-rooms-graph`. A default-deny census test (`tests/test-quick-260910-h32-no-brain-write-from-scripts.cjs`,
29
+ registered in `run-all-257.sh`) keeps it from coming back.
30
+
1
31
  ## [2.0.0-beta.31] - 2026-09-10
2
32
 
3
33
  ### Changed - install and update overhaul, Phase 341 waves 1-5 (SEED teardown 2026-09-09)
@@ -163,7 +163,7 @@ const server = new McpServer({ name: 'mindrian-brain', version: version });
163
163
  server.registerTool(
164
164
  'brain_ask',
165
165
  {
166
- description: 'Ask the remote teaching graph a natural-language methodology question. Routing happens server-side. Returns a DirectiveEnvelope carrying the directive content; the envelope degrades harmlessly to an empty signals set when the upstream response carries none. Reach for this first for an open methodology question; use brain_search when you already know the topic and want matching nodes directly.',
166
+ description: 'Ask the remote teaching graph a natural-language methodology question. Routing happens server-side. Returns a DirectiveEnvelope whose directive and next_gate are composed from the graph-grounded rows behind the answer plus a recommended framework chain; grounding.rows names the rows the answer stands on, and an empty grounding.rows means thin footing, not a clean answer. Reach for this first for an open methodology question; use brain_search when you already know the topic and want matching nodes directly.',
167
167
  inputSchema: z.strictObject({ question: z.string().describe('A methodology question (generic framework handles only -- never user artifacts or personal data per Canon Part 8).') }),
168
168
  },
169
169
  async ({ question }) => {
@@ -1110,6 +1110,200 @@ async function schema() {
1110
1110
  return result;
1111
1111
  }
1112
1112
 
1113
+ /**
1114
+ * _inferRungFromQuestion(question) -> one of the four Theo rung ids.
1115
+ *
1116
+ * Theo's `classify_problem_type` tool takes NO parameters (the caller
1117
+ * classifies), and there is no free-text rung classifier anywhere in this
1118
+ * repo (`brain-derivation.cjs::classifyProblemType` scores MINTO triples,
1119
+ * not text). This helper fills that local gap. It is PURE and LOCAL: it
1120
+ * never sends the question anywhere, which is the whole reason it exists
1121
+ * (Canon Part 8 -- the question is user data and stays local).
1122
+ *
1123
+ * Precedence is FIXED and load-bearing (do not reorder): Wicked is the
1124
+ * orthogonal stakeholder-conflict axis and wins outright; UnDefined's
1125
+ * markers are the least ambiguous of the remaining three; WellDefined's
1126
+ * markers are next; IllDefined's markers are the broadest and are also the
1127
+ * default, so they run last and cost nothing when they lose.
1128
+ *
1129
+ * Multi-word markers match as a substring; single-word markers match on a
1130
+ * word boundary so `which` does not fire inside `whichever`. Non-string or
1131
+ * empty input returns `'IllDefined'` and never throws.
1132
+ *
1133
+ * @param {*} question
1134
+ * @returns {'Wicked'|'UnDefined'|'WellDefined'|'IllDefined'}
1135
+ */
1136
+ const _RUNG_MARKERS = [
1137
+ { rung: 'Wicked', markers: ['stakeholder', 'disagree', 'values', 'political'] },
1138
+ { rung: 'UnDefined', markers: ['future of', 'no boundary', 'unbounded'] },
1139
+ { rung: 'WellDefined', markers: ['measure', 'spec', 'test', 'kpi', 'how do we'] },
1140
+ { rung: 'IllDefined', markers: ['next big thing', 'which', 'should we', 'opportunity'] },
1141
+ ];
1142
+
1143
+ function _matchesRungMarker(text, marker) {
1144
+ if (marker.indexOf(' ') !== -1) return text.indexOf(marker) !== -1;
1145
+ return new RegExp('\\b' + marker + '\\b').test(text);
1146
+ }
1147
+
1148
+ function _inferRungFromQuestion(question) {
1149
+ if (typeof question !== 'string' || !question.trim()) return 'IllDefined';
1150
+ const lc = question.trim().toLowerCase();
1151
+ for (let i = 0; i < _RUNG_MARKERS.length; i++) {
1152
+ const entry = _RUNG_MARKERS[i];
1153
+ for (let j = 0; j < entry.markers.length; j++) {
1154
+ if (_matchesRungMarker(lc, entry.markers[j])) return entry.rung;
1155
+ }
1156
+ }
1157
+ return 'IllDefined';
1158
+ }
1159
+
1160
+ /**
1161
+ * _composeTheoAsk(payload, question, deps) -> composed envelope payload.
1162
+ *
1163
+ * Composes `directive`, `next_gate` and `grounding` onto a Theo
1164
+ * `answer_mode: 'structured_rows'` payload so downstream consumers
1165
+ * (`wrapDirective`, `brainRoute`, `rs-chain-feeder.cjs`) get a real
1166
+ * framework and chain instead of an empty scaffold. `deps` is the
1167
+ * injectable wire seam (`{ recommendChain, query }`) the offline test suite
1168
+ * drives; it defaults to this module's own wrappers so production behavior
1169
+ * needs no injection.
1170
+ *
1171
+ * Runs entirely inside one outer try/catch and NEVER throws: on any failure
1172
+ * it still returns the same object shape with `options: []` and
1173
+ * `chain_status: 'unreachable'`, so a rows response with a failed chain
1174
+ * still returns `grounding`.
1175
+ *
1176
+ * Canon Part 8: the only two extra wire calls this makes carry a closed
1177
+ * rung enum (`recommendChain`) and framework names Theo itself just
1178
+ * returned (`query`, label-anchored on `(f:Framework)`, never
1179
+ * relationship-first -- Theo's read allow-list refuses
1180
+ * `DirectedRelationshipTypeScan`). `query_terms` (the question's own words)
1181
+ * is the ONLY key removed from the payload, because it is the question
1182
+ * echoed back; the row projection is by the four NAMED keys
1183
+ * (chapterId/section/score/snippet), never a blind row copy, for the same
1184
+ * reason plus shape stability.
1185
+ *
1186
+ * @param {object} payload - the raw Theo structured_rows payload
1187
+ * @param {string} question - the original question (local only, never sent)
1188
+ * @param {{recommendChain?: Function, query?: Function}} [deps]
1189
+ * @returns {Promise<object>}
1190
+ */
1191
+ function _projectGroundingRows(rows) {
1192
+ return Array.isArray(rows)
1193
+ ? rows
1194
+ .filter((r) => r && typeof r === 'object')
1195
+ .map((r) => ({ chapterId: r.chapterId, section: r.section, score: r.score, snippet: r.snippet }))
1196
+ : [];
1197
+ }
1198
+
1199
+ async function _composeTheoAsk(payload, question, deps) {
1200
+ deps = deps || {};
1201
+ const recommend = deps.recommendChain || recommendChain;
1202
+ const runQuery = deps.query || query;
1203
+
1204
+ try {
1205
+ const rung = _inferRungFromQuestion(question);
1206
+
1207
+ let chainRes = null;
1208
+ let chainStatus;
1209
+ try {
1210
+ chainRes = await recommend(rung, 4);
1211
+ if (chainRes == null || chainRes.error || !Array.isArray(chainRes.chain)) {
1212
+ chainStatus = 'unreachable';
1213
+ } else if (chainRes.chain.length === 0) {
1214
+ chainStatus = 'empty';
1215
+ } else {
1216
+ chainStatus = 'ok';
1217
+ }
1218
+ } catch (_e) {
1219
+ chainStatus = 'unreachable';
1220
+ }
1221
+ const steps = chainStatus === 'ok' ? chainRes.chain : [];
1222
+
1223
+ // COMMANDS: exactly one runQuery call, only when there are steps, in its
1224
+ // own try/catch (on any failure every option gets commands: []).
1225
+ // Label-anchored and never relationship-first: Theo's read allow-list
1226
+ // refuses a template that starts from [:USES_FRAMEWORK].
1227
+ const commandsByFramework = new Map();
1228
+ if (steps.length > 0) {
1229
+ try {
1230
+ const names = steps
1231
+ .map((s) => s && s.framework)
1232
+ .filter((n) => typeof n === 'string');
1233
+ const result = await runQuery(
1234
+ 'MATCH (f:Framework) WHERE f.name IN $names OPTIONAL MATCH (c:MindrianCommand)-[:USES_FRAMEWORK]->(f) RETURN f.name AS framework, collect(DISTINCT c.name) AS commands',
1235
+ { names: names }
1236
+ );
1237
+ const records = (result && Array.isArray(result.records)) ? result.records : [];
1238
+ for (const rec of records) {
1239
+ if (!rec || typeof rec.framework !== 'string') continue;
1240
+ const slugs = Array.isArray(rec.commands)
1241
+ ? rec.commands
1242
+ .filter((c) => typeof c === 'string' && c.length > 0)
1243
+ .map((c) => c.replace(/^\/mos:/, ''))
1244
+ : [];
1245
+ commandsByFramework.set(rec.framework, slugs);
1246
+ }
1247
+ } catch (_e) {
1248
+ // any failure here -> every option gets commands: [] below (Map stays empty)
1249
+ }
1250
+ }
1251
+
1252
+ // CONFIDENCE: top step reads exactly 0.9; a missing/non-finite degree -> 0.5.
1253
+ const top = steps.reduce((max, s) => {
1254
+ const d = s && Number.isFinite(s.degree) ? s.degree : 0;
1255
+ return d > max ? d : max;
1256
+ }, 0);
1257
+ const options = steps.map((s) => {
1258
+ const degree = s && s.degree;
1259
+ let confidence = 0.5;
1260
+ if (top > 0 && Number.isFinite(degree)) {
1261
+ confidence = Math.max(0.5, Math.min(0.9, Math.round((0.5 + 0.4 * (degree / top)) * 100) / 100));
1262
+ }
1263
+ return {
1264
+ framework: s && s.framework,
1265
+ confidence: confidence,
1266
+ commands: commandsByFramework.get(s && s.framework) || [],
1267
+ };
1268
+ });
1269
+
1270
+ const out = Object.assign({}, payload);
1271
+ delete out.query_terms;
1272
+ out.directive = {
1273
+ guided: { questions: [], framework: (steps[0] && steps[0].framework) || null, stage: rung },
1274
+ };
1275
+ out.next_gate = { sub_shape: 'F.1', options: options };
1276
+ out.grounding = {
1277
+ source: 'theo',
1278
+ answer_mode: payload.answer_mode,
1279
+ rows: _projectGroundingRows(payload.rows),
1280
+ problem_type: rung,
1281
+ problem_type_source: 'heuristic',
1282
+ chain_coverage: (chainRes && chainRes.coverage && typeof chainRes.coverage === 'object') ? chainRes.coverage : null,
1283
+ chain_status: chainStatus,
1284
+ confidence_source: 'theo_degree_normalized',
1285
+ };
1286
+ return out;
1287
+ } catch (_e) {
1288
+ const rung = _inferRungFromQuestion(question);
1289
+ const out = Object.assign({}, payload);
1290
+ delete out.query_terms;
1291
+ out.directive = { guided: { questions: [], framework: null, stage: rung } };
1292
+ out.next_gate = { sub_shape: 'F.1', options: [] };
1293
+ out.grounding = {
1294
+ source: 'theo',
1295
+ answer_mode: payload.answer_mode,
1296
+ rows: _projectGroundingRows(payload.rows),
1297
+ problem_type: rung,
1298
+ problem_type_source: 'heuristic',
1299
+ chain_coverage: null,
1300
+ chain_status: 'unreachable',
1301
+ confidence_source: 'theo_degree_normalized',
1302
+ };
1303
+ return out;
1304
+ }
1305
+ }
1306
+
1113
1307
  /**
1114
1308
  * Natural-language methodology question against the Brain (wraps brain_ask).
1115
1309
  *
@@ -1119,9 +1313,14 @@ async function schema() {
1119
1313
  * question. Canon Part 8: the question string carries only generic methodology
1120
1314
  * language -- never user artifacts, meeting text, or personal identifiers.
1121
1315
  *
1122
- * Returns the parsed brain_ask payload ({ question, keyword, source, count,
1123
- * results: [...] }) on success; a { text: 'Error: ...' } / { error: ... }
1124
- * passthrough on a server-side error; null when the Brain is unreachable or no
1316
+ * Two response shapes on success:
1317
+ * - INCUMBENT: carries `directive` already -- returned BYTE-UNCHANGED, the
1318
+ * same object reference, no copy.
1319
+ * - THEO structured_rows: `{ answer_mode: 'structured_rows', rows: [...] }`
1320
+ * with no `directive` -- composed through `_composeTheoAsk` into a full
1321
+ * envelope payload (`directive`, `next_gate`, `grounding` added).
1322
+ * A `{ text: 'Error: ...' } / { error: ... }` sentinel passes through
1323
+ * UNCHANGED on a server-side error; null when the Brain is unreachable or no
1125
1324
  * API key is configured (graceful degradation -- mirrors query()).
1126
1325
  *
1127
1326
  * @param {string} question
@@ -1129,7 +1328,15 @@ async function schema() {
1129
1328
  */
1130
1329
  async function ask(question) {
1131
1330
  if (typeof question !== 'string' || !question.trim()) return null;
1132
- return callTool('brain_ask', { question: question });
1331
+ const raw = await callTool('brain_ask', { question: question });
1332
+ if (raw == null) return raw; // transport-null contract, byte-locked
1333
+ if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
1334
+ if (raw.error) return raw; // egress_blocked / tier_denied / rate_limited / invalid_key
1335
+ if (raw.directive) return raw; // incumbent proof -- same object reference
1336
+ if (raw.answer_mode === 'structured_rows' && Array.isArray(raw.rows)) {
1337
+ return _composeTheoAsk(raw, question);
1338
+ }
1339
+ return raw;
1133
1340
  }
1134
1341
 
1135
1342
  /**
@@ -1162,24 +1369,64 @@ async function ask(question) {
1162
1369
  * @param {object} [params] - generic-handles-only params object
1163
1370
  * @returns {Promise<{op: string, source?: string, count: number, rows: Array, degraded?: boolean}>}
1164
1371
  */
1372
+ /**
1373
+ * _normalizeAskOpResult(result, operation) -- the askOp() shape recognizer,
1374
+ * extracted so tests can drive it directly with zero network. Copies the
1375
+ * dual-shape recognizer STRUCTURE from
1376
+ * lib/core/enrichment-queue.cjs::captureReadinessMiss: one entry point, arms
1377
+ * keyed on payload SHAPE never on key presence alone.
1378
+ *
1379
+ * Theo op answers carry `coverage`, never `count`, so a `count`-keyed
1380
+ * recognizer alone reads a live three-row answer as a degraded zero. Arms,
1381
+ * in order:
1382
+ * 1. INCUMBENT: `count` is a number and `rows` is an array -- the exact
1383
+ * object askOp built before this task. MUST run first so an incumbent
1384
+ * payload can never take arm 2.
1385
+ * 2. THEO: `rows` is an array (no numeric `count` required) -- reports
1386
+ * `coverage.matched` as `count` when it is a finite number, else
1387
+ * `rows.length`.
1388
+ * 3. Everything else (null, an `{error:...}` sentinel, a `{text:...}`
1389
+ * passthrough, a non-array `rows`) -- the existing degraded sentinel.
1390
+ * Degrade ONLY when `rows` is not an array.
1391
+ *
1392
+ * @param {*} result
1393
+ * @param {string} operation
1394
+ * @returns {{op: string, source?: string, count: number, rows: Array, coverage?: object, degraded?: boolean}}
1395
+ */
1396
+ function _normalizeAskOpResult(result, operation) {
1397
+ if (result && typeof result === 'object'
1398
+ && typeof result.count === 'number' && Array.isArray(result.rows)) {
1399
+ return {
1400
+ op: result.op || operation,
1401
+ source: result.source,
1402
+ count: result.count,
1403
+ rows: result.rows,
1404
+ ...(result.degraded ? { degraded: true } : {}),
1405
+ };
1406
+ }
1407
+ if (result && typeof result === 'object' && Array.isArray(result.rows)) {
1408
+ const coverage = result.coverage;
1409
+ const matched = (coverage && typeof coverage === 'object' && Number.isFinite(coverage.matched))
1410
+ ? coverage.matched
1411
+ : result.rows.length;
1412
+ const out = {
1413
+ op: result.op || operation,
1414
+ source: 'theo',
1415
+ count: matched,
1416
+ rows: result.rows,
1417
+ };
1418
+ if (coverage && typeof coverage === 'object') out.coverage = coverage;
1419
+ return out;
1420
+ }
1421
+ return { op: operation, count: 0, rows: [], degraded: true };
1422
+ }
1423
+
1165
1424
  async function askOp(operation, params = {}) {
1166
1425
  try {
1167
- const result = await callTool('brain_ask', { op: operation, params: params || {} });
1168
- // callTool already parses the JSON text payload of the MCP content item,
1169
- // so a well-formed curated-op response arrives as the payload object.
1170
- if (result && typeof result === 'object'
1171
- && typeof result.count === 'number' && Array.isArray(result.rows)) {
1172
- return {
1173
- op: result.op || operation,
1174
- source: result.source,
1175
- count: result.count,
1176
- rows: result.rows,
1177
- ...(result.degraded ? { degraded: true } : {}),
1178
- };
1179
- }
1180
- // Unreachable Brain (null), an error/text passthrough, or any unexpected
1181
- // shape -> graceful degraded sentinel.
1182
- return { op: operation, count: 0, rows: [], degraded: true };
1426
+ return _normalizeAskOpResult(
1427
+ await callTool('brain_ask', { op: operation, params: params || {} }),
1428
+ operation
1429
+ );
1183
1430
  } catch (_err) {
1184
1431
  return { op: operation, count: 0, rows: [], degraded: true };
1185
1432
  }
@@ -2300,6 +2547,10 @@ module.exports = {
2300
2547
  smartSearch,
2301
2548
  ask,
2302
2549
  askOp,
2550
+ // Quick 260910-hni: the pure local rung heuristic ask()/_composeTheoAsk
2551
+ // uses to classify a question before the one recommend_chain wire call.
2552
+ // A NAMED export per the locked design, not a _test-only member.
2553
+ _inferRungFromQuestion,
2303
2554
  schema,
2304
2555
  stats,
2305
2556
  getBrainUrl,
@@ -2361,5 +2612,10 @@ module.exports = {
2361
2612
  // so the exact D-01/D-02 schedule is unit-tested with zero sleeps.
2362
2613
  _parseRetryAfterMs,
2363
2614
  _rateLimitWaitMs,
2615
+ // Quick 260910-hni test surface: the composer's injectable deps seam
2616
+ // and the widened askOp recognizer, both offline-drivable with zero
2617
+ // network (see tests/test-339-theo-ask-compose.cjs).
2618
+ _composeTheoAsk,
2619
+ _normalizeAskOpResult,
2364
2620
  },
2365
2621
  };
@@ -159,6 +159,15 @@ function _copyIfPlainObject(value) {
159
159
  * `lib/mcp/no-instructions.test.cjs` exists to prevent). When neither field
160
160
  * is present, the returned object keeps the exact same seven keys in the
161
161
  * exact same insertion order it had before this change.
162
+ *
163
+ * Quick 260910-hni adds `grounding` as a THIRD named additive field, same
164
+ * shape guard and same reason: an untyped top-level copy would let
165
+ * arbitrary Brain-returned keys reach the model, exactly the leakage class
166
+ * `lib/mcp/no-instructions.test.cjs` exists to prevent. `_copyIfPlainObject`
167
+ * is a SHALLOW copy, so `grounding.rows` stays the same array reference,
168
+ * exactly as `refusal.next_moves` already behaves. Absence of `grounding`
169
+ * leaves the seven keys and their insertion order untouched
170
+ * (`tests/test-257-envelope-passthrough.cjs` Arm 3 pins this).
162
171
  * @param {object|null} brainResponse
163
172
  * @param {object} signals
164
173
  * @returns {object} DirectiveEnvelope per CAPABILITY-MAP spec.
@@ -206,6 +215,10 @@ function wrapDirective(brainResponse, signals) {
206
215
  if (refusal !== null) {
207
216
  envelope.refusal = refusal;
208
217
  }
218
+ const grounding = _copyIfPlainObject(brainResponse.grounding);
219
+ if (grounding !== null) {
220
+ envelope.grounding = grounding;
221
+ }
209
222
 
210
223
  return envelope;
211
224
  }
@@ -105,20 +105,35 @@ function isApproved(decision) {
105
105
  // -> wireAccept, run per dimension. Lazy-required so the module loads without
106
106
  // the heavy lens engine; a leg that is unavailable degrades to a disclosed
107
107
  // low-quality pass (SEED-059) rather than throwing. Exercised live at phase
108
- // verification (223-VALIDATION), never in the hermetic fixture.
108
+ // verification (223-VALIDATION), never in the hermetic fixture. SAME research
109
+ // pipe contract as lib/mcp/tool-router.cjs (extractContext takes one object;
110
+ // the driver reads camelCase lensSet); the two callers must change together
111
+ // until a shared adapter exists (Canon Part 7).
109
112
  async function defaultResearchFn(dimension, ctx) {
110
113
  const context = isPlainObject(ctx) ? ctx : {};
111
114
  try {
112
115
  // eslint-disable-next-line global-require
113
- const extractor = require('./research-context-extractor.cjs');
116
+ const extractor = isPlainObject(context._extractor) ? context._extractor : require('./research-context-extractor.cjs');
114
117
  // eslint-disable-next-line global-require
115
- const lensDriver = require('../lens-engine/source-lens-driver.cjs');
118
+ const lensDriver = isPlainObject(context._lensDriver) ? context._lensDriver : require('../lens-engine/source-lens-driver.cjs');
116
119
  const wirer = require('./findings-wirer.cjs');
120
+ // The topic is the roster cell handle when present, else the dimension
121
+ // label -- both are GENERIC handles (Part 8 rule at lines 217-219 above),
122
+ // never room content, so nothing user-specific becomes a corpus query.
123
+ const topic = typeof context.handle === 'string' && context.handle.length > 0 ? context.handle : String(dimension);
117
124
  const extracted = typeof extractor.extractContext === 'function'
118
- ? await extractor.extractContext(context.roomDir, { dimension: dimension })
125
+ ? await extractor.extractContext({ roomDir: context.roomDir, topic: topic, db: context.db })
119
126
  : null;
120
127
  const lensed = typeof lensDriver.runSourceLens === 'function'
121
- ? await lensDriver.runSourceLens(Object.assign({ dimension: dimension }, extracted || {}))
128
+ ? await lensDriver.runSourceLens({
129
+ roomDir: context.roomDir,
130
+ topic: topic,
131
+ lensSet: extracted ? extracted.lens_set : undefined,
132
+ preflight: extracted ? extracted.preflight : undefined,
133
+ stage: 'explore',
134
+ db: context.db,
135
+ dimension: dimension,
136
+ })
122
137
  : null;
123
138
  const findings = lensed && Array.isArray(lensed.findings) ? lensed.findings : [];
124
139
  const quality = lensed && typeof lensed.quality === 'string' ? lensed.quality : (findings.length > 0 ? 'ok' : 'low');
@@ -276,7 +291,8 @@ function rosterToCells(roster, axes) {
276
291
  * gateFn(ctx)->approve, onHalt(info),
277
292
  * jtbdFns?{getCurrent, ...write} (the JTBD read/write seam pair),
278
293
  * planFn?, researchFn?, computeFn?,
279
- * writeFn?, classifyFn?, bankRollupFn?, genericDims?, run_id?, dateStr?
294
+ * writeFn?, classifyFn?, bankRollupFn?, genericDims?, run_id?, dateStr?,
295
+ * _extractor?, _lensDriver? (test seams for the shipped defaultResearchFn)
280
296
  * }
281
297
  *
282
298
  * result: { ok, dry_run?, halted?, halt_stage?, stages:[{stage,status,disclosure?}],
@@ -428,6 +444,10 @@ async function runIntelPipeline(opts) {
428
444
  let pass;
429
445
  try {
430
446
  const researchCtx = { roomDir: roomDir, db: db, dimension: dim };
447
+ // Test seams for the shipped research pipe (quick-260910-dk1): threaded
448
+ // through only when the caller supplies a plain object, never faked up.
449
+ if (isPlainObject(o._extractor)) researchCtx._extractor = o._extractor;
450
+ if (isPlainObject(o._lensDriver)) researchCtx._lensDriver = o._lensDriver;
431
451
  if (cell) {
432
452
  researchCtx.entity = cell.entity;
433
453
  researchCtx.axis = cell.axis;
@@ -542,4 +562,5 @@ async function runIntelPipeline(opts) {
542
562
  return { ok: written ? written.ok !== false : false, halted: false, stages: stages, plan: plan, written: written };
543
563
  }
544
564
 
545
- module.exports = { runIntelPipeline, PIPELINE_STAGES, rosterToCells };
565
+ // _internal.defaultResearchFn exposed for tests (private; do NOT consume in production)
566
+ module.exports = { runIntelPipeline, PIPELINE_STAGES, rosterToCells, _internal: { defaultResearchFn } };
@@ -328,6 +328,16 @@ function _isFreeFormTool(toolName) {
328
328
  const TAXONOMY_RUNGS = Object.freeze(new Set(['undefined', 'ill-defined', 'well-defined', 'wicked']));
329
329
  const KNOWN_LABEL_MAX = 120;
330
330
 
331
+ // Quick 260910-hni: recommend_chain's problem_type enum, the UNION of the
332
+ // incumbent's canonical rung strings and Theo's own ids (both vocabularies
333
+ // are live during the cutover soak; recommendChain() in brain-client.cjs
334
+ // already selects the right table by origin, this recognizer just has to
335
+ // accept whichever one the wrapper actually sent).
336
+ const RECOMMEND_CHAIN_PROBLEM_TYPES = Object.freeze(new Set([
337
+ 'Undefined Problem', 'Ill-Defined Problem', 'Well-Defined Problem',
338
+ 'UnDefined', 'IllDefined', 'WellDefined', 'Wicked',
339
+ ]));
340
+
331
341
  // _isSafeShortLabel(v): a string, 1-120 chars, no CR/LF, and clears _safeAudit.
332
342
  // The length-and-single-line bound is label hygiene ("this is a node label,
333
343
  // not prose"), NOT the content defense -- step 1's default-deny scan is. It is
@@ -436,6 +446,23 @@ function _proveKnownToolShape(payload, toolName) {
436
446
  return { class: 'known_tool_shape', reason: 'taxonomy_ladder rung enum' };
437
447
  }
438
448
 
449
+ if (toolName.indexOf('recommend_chain') !== -1) {
450
+ // BOTH required, ZERO optional: recommendChain() (lib/core/brain-
451
+ // client.cjs) always sends both problem_type and max_steps, and an empty
452
+ // optional list is what makes this fail-closed on any extra key.
453
+ if (!_hasExactKeys(payload, ['problem_type', 'max_steps'], [])) return null;
454
+ if (typeof payload.problem_type !== 'string'
455
+ || !RECOMMEND_CHAIN_PROBLEM_TYPES.has(payload.problem_type)) return null;
456
+ if (!Number.isInteger(payload.max_steps)
457
+ || payload.max_steps < 1 || payload.max_steps > 6) return null;
458
+ // _normalizeBrainProblemType passes any well-shaped UNMAPPED token
459
+ // through unchanged, so an off-enum value (say 'Trinity') falls out of
460
+ // this arm to the terminal catch-all as ambiguous, which PROCEEDS with a
461
+ // disclosure rather than blocking: this change strictly NARROWS what is
462
+ // ambiguous and never widens what may carry content.
463
+ return { class: 'known_tool_shape', reason: 'recommend_chain problem_type enum' };
464
+ }
465
+
439
466
  return null;
440
467
  }
441
468
 
@@ -349,10 +349,31 @@ async function brainRoute(roomDir, stateContent, intent) {
349
349
  ? (brainResult.directive.guided.framework || null)
350
350
  : null;
351
351
 
352
- // Build the chain: anchor first (if present + not already in options), then options[].framework.
352
+ // Build the chain: anchor first (if present + not already in options), then
353
+ // options[].commands (Quick 260910-hni), then options[].framework.
354
+ //
355
+ // Quick 260910-hni: KNOWN_METHODOLOGIES holds BARE command slugs (e.g.
356
+ // `find-bottlenecks`), so a Theo framework name like "Design Thinking"
357
+ // normalizes to `designthinking` and matches nothing below, while a slug
358
+ // like `find-bottlenecks` matches itself exactly -- pushing the command
359
+ // slugs ahead of the framework name lets this chain route even when the
360
+ // framework name alone would not.
361
+ //
362
+ // The Phase 339 D-03b BRAIN_ROUTE_NOTE_NO_NEXT_GATE disclosure (:344-346)
363
+ // now stops firing on the Theo path because the composed envelope carries
364
+ // `next_gate` again (Task 1, quick/260910-hni): that is the INTENDED
365
+ // outcome of this quick task, not a regression. The disclosure stays in
366
+ // place for any origin that still returns no `next_gate`.
353
367
  const rawChain = [];
354
368
  if (anchorFramework && typeof anchorFramework === 'string') rawChain.push(anchorFramework);
355
369
  for (const opt of options) {
370
+ if (opt && Array.isArray(opt.commands)) {
371
+ for (const slug of opt.commands) {
372
+ if (typeof slug === 'string' && slug.length > 0 && !rawChain.includes(slug)) {
373
+ rawChain.push(slug);
374
+ }
375
+ }
376
+ }
356
377
  if (opt && typeof opt.framework === 'string' && opt.framework.length > 0) {
357
378
  if (!rawChain.includes(opt.framework)) rawChain.push(opt.framework);
358
379
  }
@@ -541,6 +541,8 @@ async function runResearchPipeline(roomDir, topic, opts) {
541
541
  const options = (opts && typeof opts === 'object') ? opts : {};
542
542
 
543
543
  try {
544
+ // Same research pipe contract as lib/core/intel-pipeline.cjs defaultResearchFn
545
+ // (the second caller of extractContext + runSourceLens); change together.
544
546
  const extracted = extractContext({ roomDir, topic, db });
545
547
  let driverResult;
546
548
  try {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mindrian_os/cli",
3
- "version": "2.0.0-beta.31",
3
+ "version": "2.0.0-beta.33",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@mindrian_os/cli",
9
- "version": "2.0.0-beta.31",
9
+ "version": "2.0.0-beta.33",
10
10
  "license": "BSL-1.1",
11
11
  "dependencies": {
12
12
  "@ig3/markdown-it-wikilinks": "^1.0.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindrian_os/cli",
3
- "version": "2.0.0-beta.31",
3
+ "version": "2.0.0-beta.33",
4
4
  "description": "Install MindrianOS into Claude Code with one command -- `npx @mindrian_os/cli`. Ships the MindrianOS plugin (Larry + PWS methodology + Data Room) plus a setup/diagnostics CLI (install/doctor/update).",
5
5
  "scripts": {
6
6
  "mcp": "node bin/mindrian-mcp-server.cjs",
@@ -3,6 +3,12 @@
3
3
  Schema for Room/RoomGroup hierarchy in both KuzuDB (local) and Neo4j Brain (remote).
4
4
  Added in Phase 59.2. Additive-only -- filesystem + registry.json remain operational truth.
5
5
 
6
+ > **RETIRED (2026-09-10, quick task 260910-h32):** the Neo4j/Brain half of this
7
+ > schema is retired. `scripts/sync-rooms-brain` was deleted because it egressed
8
+ > room names, venture names, stages, statuses and paths to the Brain, a Canon
9
+ > Part 8 breach. The KuzuDB/local half remains live via `scripts/sync-rooms-graph`.
10
+ > This document is kept as the record of what the Brain-side schema WAS.
11
+
6
12
  ## Node Types
7
13
 
8
14
  ### Room
@@ -76,7 +82,7 @@ Connects Room to Framework nodes based on methodology commands run in that room.
76
82
  |------|----|------------|-------|
77
83
  | Room | Framework | first_used (datetime), usage_count (int) | Created from room/.analytics.json |
78
84
 
79
- Source: `track-analytics` records command usage per room. `sync-rooms-brain` maps commands to Framework node names.
85
+ Source: `track-analytics` records command usage per room. `sync-rooms-brain` maps commands to Framework node names. (RETIRED 2026-09-10 with the script; kept as history.)
80
86
 
81
87
  ### SHARES_THEME (Brain only)
82
88
  Cross-room content similarity detected from problem-definition keywords.
@@ -201,12 +207,12 @@ RETURN r.name, s.name AS stage,
201
207
  | Script | Target | Trigger | Frequency |
202
208
  |--------|--------|---------|-----------|
203
209
  | `scripts/sync-rooms-graph` | KuzuDB local | session-start, room-registry create/archive | Every session + on room changes |
204
- | `scripts/sync-rooms-brain` | Neo4j Brain | session-start (when Brain available) | Best-effort, per session |
210
+ | `scripts/sync-rooms-brain` | Neo4j Brain (RETIRED 2026-09-10) | none -- script deleted, Canon Part 8 | never |
205
211
 
206
- Both scripts are idempotent and fire-and-forget. Failure degrades gracefully:
207
- - Brain unavailable -> KuzuDB only
212
+ The one surviving script is idempotent and fire-and-forget. Failure degrades
213
+ gracefully across the two remaining tiers:
208
214
  - KuzuDB unavailable -> filesystem only (Tier 0)
209
- - Both unavailable -> everything still works from registry.json
215
+ - Everything still works from registry.json even if KuzuDB never runs
210
216
 
211
217
  ## Additive-Only Rule (D-15 through D-18)
212
218