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

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.
@@ -1110,6 +1110,244 @@ 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
+ // theo_rank and the command-bearing-first sort below never touch this
1254
+ // computation -- the sort reorders `options`, it never recomputes a
1255
+ // confidence value (Quick 260911-ddd, DDD-01).
1256
+ const top = steps.reduce((max, s) => {
1257
+ const d = s && Number.isFinite(s.degree) ? s.degree : 0;
1258
+ return d > max ? d : max;
1259
+ }, 0);
1260
+ const options = steps.map((s, idx) => {
1261
+ const degree = s && s.degree;
1262
+ let confidence = 0.5;
1263
+ if (top > 0 && Number.isFinite(degree)) {
1264
+ confidence = Math.max(0.5, Math.min(0.9, Math.round((0.5 + 0.4 * (degree / top)) * 100) / 100));
1265
+ }
1266
+ // Quick 260911-ddd (DDD-01): theo_rank carries Theo's own rank so it
1267
+ // is never lost by the reorder below. Theo's `step` field when it is
1268
+ // a finite number, else the 1-based index of this step WITHIN Theo's
1269
+ // own chain array, computed here before any sort so a later reorder
1270
+ // can never leak into this number.
1271
+ const theoRank = (s && Number.isFinite(s.step)) ? s.step : idx + 1;
1272
+ return {
1273
+ framework: s && s.framework,
1274
+ confidence: confidence,
1275
+ commands: commandsByFramework.get(s && s.framework) || [],
1276
+ theo_rank: theoRank,
1277
+ };
1278
+ });
1279
+
1280
+ // Quick 260911-ddd (DDD-01): stable partition, command-bearing options
1281
+ // first, Theo's relative order preserved inside each group. Theo's
1282
+ // ranking is NEVER altered at the source -- this reorders the plugin's
1283
+ // OWN options array only. A single forward pass into two queues, then
1284
+ // concatenated, is stable BY CONSTRUCTION; it does not rest on
1285
+ // Array.prototype.sort's engine-stability semantics. When every option
1286
+ // or no option carries a command, one queue is empty and the
1287
+ // concatenation is the identity, so Theo's order survives unchanged
1288
+ // for free.
1289
+ //
1290
+ // DOWNSTREAM CONSEQUENCE, stated here so it is not mistaken for a
1291
+ // regression later: lib/mcp/brain-router.cjs:398 derives topConf from
1292
+ // options[0].confidence. On a chain whose top-ranked framework has no
1293
+ // command, the routed confidence now reads the first command-bearing
1294
+ // option's confidence (0.83 on the live IllDefined shape, previously
1295
+ // 0.9) -- that is the intended meaning of the change (confidence
1296
+ // describes the option actually surfaced to the user), not a bug.
1297
+ // brain-router.cjs is deliberately NOT touched to compensate for this.
1298
+ const commandBearing = [];
1299
+ const commandLess = [];
1300
+ for (const opt of options) {
1301
+ if (Array.isArray(opt.commands) && opt.commands.length > 0) {
1302
+ commandBearing.push(opt);
1303
+ } else {
1304
+ commandLess.push(opt);
1305
+ }
1306
+ }
1307
+ const sortedOptions = commandBearing.concat(commandLess);
1308
+
1309
+ const out = Object.assign({}, payload);
1310
+ delete out.query_terms;
1311
+ out.directive = {
1312
+ guided: { questions: [], framework: (sortedOptions[0] && sortedOptions[0].framework) || null, stage: rung },
1313
+ };
1314
+ out.next_gate = { sub_shape: 'F.1', options: sortedOptions };
1315
+ out.grounding = {
1316
+ source: 'theo',
1317
+ answer_mode: payload.answer_mode,
1318
+ rows: _projectGroundingRows(payload.rows),
1319
+ problem_type: rung,
1320
+ problem_type_source: 'heuristic',
1321
+ chain_coverage: (chainRes && chainRes.coverage && typeof chainRes.coverage === 'object') ? chainRes.coverage : null,
1322
+ chain_status: chainStatus,
1323
+ confidence_source: 'theo_degree_normalized',
1324
+ // Quick 260911-ddd (DDD-01): names the applied ordering so a
1325
+ // consumer never has to branch on the key's presence -- present here
1326
+ // AND on the trailing catch path below.
1327
+ option_order: 'command_bearing_first',
1328
+ };
1329
+ return out;
1330
+ } catch (_e) {
1331
+ const rung = _inferRungFromQuestion(question);
1332
+ const out = Object.assign({}, payload);
1333
+ delete out.query_terms;
1334
+ out.directive = { guided: { questions: [], framework: null, stage: rung } };
1335
+ out.next_gate = { sub_shape: 'F.1', options: [] };
1336
+ out.grounding = {
1337
+ source: 'theo',
1338
+ answer_mode: payload.answer_mode,
1339
+ rows: _projectGroundingRows(payload.rows),
1340
+ problem_type: rung,
1341
+ problem_type_source: 'heuristic',
1342
+ chain_coverage: null,
1343
+ chain_status: 'unreachable',
1344
+ confidence_source: 'theo_degree_normalized',
1345
+ option_order: 'command_bearing_first',
1346
+ };
1347
+ return out;
1348
+ }
1349
+ }
1350
+
1113
1351
  /**
1114
1352
  * Natural-language methodology question against the Brain (wraps brain_ask).
1115
1353
  *
@@ -1119,9 +1357,14 @@ async function schema() {
1119
1357
  * question. Canon Part 8: the question string carries only generic methodology
1120
1358
  * language -- never user artifacts, meeting text, or personal identifiers.
1121
1359
  *
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
1360
+ * Two response shapes on success:
1361
+ * - INCUMBENT: carries `directive` already -- returned BYTE-UNCHANGED, the
1362
+ * same object reference, no copy.
1363
+ * - THEO structured_rows: `{ answer_mode: 'structured_rows', rows: [...] }`
1364
+ * with no `directive` -- composed through `_composeTheoAsk` into a full
1365
+ * envelope payload (`directive`, `next_gate`, `grounding` added).
1366
+ * A `{ text: 'Error: ...' } / { error: ... }` sentinel passes through
1367
+ * UNCHANGED on a server-side error; null when the Brain is unreachable or no
1125
1368
  * API key is configured (graceful degradation -- mirrors query()).
1126
1369
  *
1127
1370
  * @param {string} question
@@ -1129,7 +1372,15 @@ async function schema() {
1129
1372
  */
1130
1373
  async function ask(question) {
1131
1374
  if (typeof question !== 'string' || !question.trim()) return null;
1132
- return callTool('brain_ask', { question: question });
1375
+ const raw = await callTool('brain_ask', { question: question });
1376
+ if (raw == null) return raw; // transport-null contract, byte-locked
1377
+ if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
1378
+ if (raw.error) return raw; // egress_blocked / tier_denied / rate_limited / invalid_key
1379
+ if (raw.directive) return raw; // incumbent proof -- same object reference
1380
+ if (raw.answer_mode === 'structured_rows' && Array.isArray(raw.rows)) {
1381
+ return _composeTheoAsk(raw, question);
1382
+ }
1383
+ return raw;
1133
1384
  }
1134
1385
 
1135
1386
  /**
@@ -1162,24 +1413,64 @@ async function ask(question) {
1162
1413
  * @param {object} [params] - generic-handles-only params object
1163
1414
  * @returns {Promise<{op: string, source?: string, count: number, rows: Array, degraded?: boolean}>}
1164
1415
  */
1416
+ /**
1417
+ * _normalizeAskOpResult(result, operation) -- the askOp() shape recognizer,
1418
+ * extracted so tests can drive it directly with zero network. Copies the
1419
+ * dual-shape recognizer STRUCTURE from
1420
+ * lib/core/enrichment-queue.cjs::captureReadinessMiss: one entry point, arms
1421
+ * keyed on payload SHAPE never on key presence alone.
1422
+ *
1423
+ * Theo op answers carry `coverage`, never `count`, so a `count`-keyed
1424
+ * recognizer alone reads a live three-row answer as a degraded zero. Arms,
1425
+ * in order:
1426
+ * 1. INCUMBENT: `count` is a number and `rows` is an array -- the exact
1427
+ * object askOp built before this task. MUST run first so an incumbent
1428
+ * payload can never take arm 2.
1429
+ * 2. THEO: `rows` is an array (no numeric `count` required) -- reports
1430
+ * `coverage.matched` as `count` when it is a finite number, else
1431
+ * `rows.length`.
1432
+ * 3. Everything else (null, an `{error:...}` sentinel, a `{text:...}`
1433
+ * passthrough, a non-array `rows`) -- the existing degraded sentinel.
1434
+ * Degrade ONLY when `rows` is not an array.
1435
+ *
1436
+ * @param {*} result
1437
+ * @param {string} operation
1438
+ * @returns {{op: string, source?: string, count: number, rows: Array, coverage?: object, degraded?: boolean}}
1439
+ */
1440
+ function _normalizeAskOpResult(result, operation) {
1441
+ if (result && typeof result === 'object'
1442
+ && typeof result.count === 'number' && Array.isArray(result.rows)) {
1443
+ return {
1444
+ op: result.op || operation,
1445
+ source: result.source,
1446
+ count: result.count,
1447
+ rows: result.rows,
1448
+ ...(result.degraded ? { degraded: true } : {}),
1449
+ };
1450
+ }
1451
+ if (result && typeof result === 'object' && Array.isArray(result.rows)) {
1452
+ const coverage = result.coverage;
1453
+ const matched = (coverage && typeof coverage === 'object' && Number.isFinite(coverage.matched))
1454
+ ? coverage.matched
1455
+ : result.rows.length;
1456
+ const out = {
1457
+ op: result.op || operation,
1458
+ source: 'theo',
1459
+ count: matched,
1460
+ rows: result.rows,
1461
+ };
1462
+ if (coverage && typeof coverage === 'object') out.coverage = coverage;
1463
+ return out;
1464
+ }
1465
+ return { op: operation, count: 0, rows: [], degraded: true };
1466
+ }
1467
+
1165
1468
  async function askOp(operation, params = {}) {
1166
1469
  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 };
1470
+ return _normalizeAskOpResult(
1471
+ await callTool('brain_ask', { op: operation, params: params || {} }),
1472
+ operation
1473
+ );
1183
1474
  } catch (_err) {
1184
1475
  return { op: operation, count: 0, rows: [], degraded: true };
1185
1476
  }
@@ -2300,6 +2591,10 @@ module.exports = {
2300
2591
  smartSearch,
2301
2592
  ask,
2302
2593
  askOp,
2594
+ // Quick 260910-hni: the pure local rung heuristic ask()/_composeTheoAsk
2595
+ // uses to classify a question before the one recommend_chain wire call.
2596
+ // A NAMED export per the locked design, not a _test-only member.
2597
+ _inferRungFromQuestion,
2303
2598
  schema,
2304
2599
  stats,
2305
2600
  getBrainUrl,
@@ -2361,5 +2656,10 @@ module.exports = {
2361
2656
  // so the exact D-01/D-02 schedule is unit-tested with zero sleeps.
2362
2657
  _parseRetryAfterMs,
2363
2658
  _rateLimitWaitMs,
2659
+ // Quick 260910-hni test surface: the composer's injectable deps seam
2660
+ // and the widened askOp recognizer, both offline-drivable with zero
2661
+ // network (see tests/test-339-theo-ask-compose.cjs).
2662
+ _composeTheoAsk,
2663
+ _normalizeAskOpResult,
2364
2664
  },
2365
2665
  };
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
6
+ *
7
+ * Quick 260911-ddd (DDD-02) -- a content-free Brain pre-warm fired at MCP
8
+ * shim startup so the Brain is already warm by the time the first question
9
+ * arrives, instead of leaving every cold Render wake to silently degrade
10
+ * /mos:act's Tier 3 race to the local heuristic (see
11
+ * lib/mcp/brain-route-bound.cjs for the measured cold-wake numbers this
12
+ * exists to cover).
13
+ *
14
+ * WHY THE TIMEOUT DOES NOT BOUND THE WAKE ITSELF: the request reaching
15
+ * Render is what wakes the instance; aborting our own wait does not cancel
16
+ * that in-flight wake on Render's side. The timeout here only bounds how
17
+ * long THIS PROCESS holds the marker write open waiting for a verdict --
18
+ * which is why 15000 ms is generous without costing anyone anything: even
19
+ * if we give up waiting, the wake we triggered keeps running on Render and
20
+ * the NEXT real call benefits from it.
21
+ *
22
+ * CANON PART 8 (D-02): the probe sends theo_health with NO ARGUMENTS
23
+ * (`callTool('theo_health', {})`, the exact shape class-m-brain-smoke.cjs:225
24
+ * already ships) and the marker persists ONLY {at, ok, origin_host} --
25
+ * never a response body, never a header value, never a question. Every fs
26
+ * and network failure is swallowed; this function NEVER throws and NEVER
27
+ * rejects.
28
+ *
29
+ * MCP STDIO SAFETY: this file writes NOTHING to stdout, ever, because it
30
+ * can run inside an MCP stdio process where a stray stdout byte corrupts
31
+ * the JSON-RPC transport. A single stderr line only when MINDRIAN_DEBUG is
32
+ * set.
33
+ *
34
+ * The deps seam ({ probe, originHost, now, homeDir, timeoutMs }, all
35
+ * optional) mirrors the same injectable-probe discipline
36
+ * lib/core/doctor/class-m-brain-smoke.cjs already uses for its own
37
+ * theoHealthFn seam, so a test drives every arm through deps and never
38
+ * touches a real Brain.
39
+ *
40
+ * No em-dashes. CJS only.
41
+ */
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+ const os = require('os');
46
+
47
+ const DEFAULT_PREWARM_TIMEOUT_MS = 15000;
48
+
49
+ const debugLog = (msg) => {
50
+ if (!process.env.MINDRIAN_DEBUG) return;
51
+ try {
52
+ process.stderr.write('[brain-prewarm] ' + msg + '\n');
53
+ } catch (_e) {
54
+ // swallow -- this function must never throw
55
+ }
56
+ };
57
+
58
+ /**
59
+ * Derive the timeout (ms) for the probe race. Reads
60
+ * MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS when it parses to a finite positive
61
+ * integer; otherwise DEFAULT_PREWARM_TIMEOUT_MS.
62
+ * @returns {number}
63
+ */
64
+ function _resolveTimeoutMs() {
65
+ const raw = process.env.MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS;
66
+ if (typeof raw !== 'string' || raw.trim().length === 0) return DEFAULT_PREWARM_TIMEOUT_MS;
67
+ const parsed = Number(raw);
68
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return DEFAULT_PREWARM_TIMEOUT_MS;
69
+ return parsed;
70
+ }
71
+
72
+ /**
73
+ * Reduce a Brain origin URL to its host only (never the full URL, never a
74
+ * path, never a query string -- the marker holds origin_host, not the URL).
75
+ * @param {string} url
76
+ * @returns {string|null}
77
+ */
78
+ function _hostOnly(url) {
79
+ if (typeof url !== 'string' || url.length === 0) return null;
80
+ try {
81
+ return new URL(url).host || null;
82
+ } catch (_e) {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * markerPath(homeDir) -- the on-disk location of the pre-warm marker.
89
+ * @param {string} [homeDir] defaults to MINDRIAN_HOME or ~/.mindrian
90
+ * @returns {string}
91
+ */
92
+ function markerPath(homeDir) {
93
+ const home = homeDir || process.env.MINDRIAN_HOME || path.join(os.homedir(), '.mindrian');
94
+ return path.join(home, 'brain-prewarm.json');
95
+ }
96
+
97
+ /**
98
+ * prewarm(deps) -- fires one content-free theo_health probe and persists a
99
+ * minimal marker. NEVER throws, NEVER rejects, NEVER writes to stdout.
100
+ *
101
+ * @param {object} [deps]
102
+ * @param {() => Promise<any>} [deps.probe] defaults to
103
+ * `() => require('./brain-client.cjs').callTool('theo_health', {})`,
104
+ * required LAZILY so importing this module costs nothing.
105
+ * @param {string} [deps.originHost] defaults to the host of
106
+ * `require('./brain-client.cjs').getBrainUrl()`.
107
+ * @param {() => Date} [deps.now] defaults to `() => new Date()`.
108
+ * @param {string} [deps.homeDir] defaults to MINDRIAN_HOME or ~/.mindrian.
109
+ * @param {number} [deps.timeoutMs] defaults to
110
+ * MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS or 15000.
111
+ * @returns {Promise<{ at: string, ok: boolean, origin_host: string|null }>}
112
+ */
113
+ async function prewarm(deps) {
114
+ deps = deps || {};
115
+ const probe = deps.probe || (() => require('./brain-client.cjs').callTool('theo_health', {}));
116
+ const originHost = (typeof deps.originHost === 'string')
117
+ ? deps.originHost
118
+ : _hostOnly((() => {
119
+ try {
120
+ return require('./brain-client.cjs').getBrainUrl();
121
+ } catch (_e) {
122
+ return null;
123
+ }
124
+ })());
125
+ const now = deps.now || (() => new Date());
126
+ const homeDir = deps.homeDir;
127
+ const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0 ? deps.timeoutMs : _resolveTimeoutMs();
128
+
129
+ let ok = false;
130
+ try {
131
+ const TIMEOUT_SENTINEL = Symbol('brain-prewarm-timeout');
132
+ const result = await Promise.race([
133
+ Promise.resolve()
134
+ .then(() => probe())
135
+ .catch(() => null),
136
+ new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs)),
137
+ ]);
138
+ ok = !!(result && result !== TIMEOUT_SENTINEL && typeof result === 'object');
139
+ } catch (_e) {
140
+ ok = false;
141
+ }
142
+
143
+ // Canon Part 8 (D-02): EXACTLY three keys, nothing else. Never any part
144
+ // of the probe response body, never a key, never a question.
145
+ const marker = { at: now().toISOString(), ok: ok, origin_host: originHost || null };
146
+
147
+ try {
148
+ const filePath = markerPath(homeDir);
149
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
150
+ fs.writeFileSync(filePath, JSON.stringify(marker), 'utf8');
151
+ debugLog('marker written: ' + JSON.stringify(marker));
152
+ } catch (e) {
153
+ debugLog('marker write failed: ' + (e && e.message ? e.message : String(e)));
154
+ }
155
+
156
+ return marker;
157
+ }
158
+
159
+ // Directly spawnable: `node lib/core/brain-prewarm.cjs`.
160
+ if (require.main === module) {
161
+ prewarm().catch(() => {});
162
+ }
163
+
164
+ module.exports = { prewarm, markerPath };
@@ -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
  }