@mindrian_os/cli 2.0.0-beta.33 → 2.0.0-beta.37
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +88 -0
- package/bin/mindrian-brain-mcp-client.cjs +14 -0
- package/commands/doctor.md +4 -3
- package/data/brain-census.generated.json +2827 -1523
- package/data/hooks-markers.json +25 -0
- package/hooks/hooks.json +0 -23
- package/lib/core/brain-client.cjs +83 -3
- package/lib/core/brain-prewarm.cjs +164 -0
- package/lib/core/doctor/class-m-brain-smoke.cjs +182 -26
- package/lib/core/doctor/class-m-brain-smoke.test.cjs +441 -82
- package/lib/core/install-id.cjs +232 -0
- package/lib/core/integration-registry.cjs +96 -20
- package/lib/mcp/brain-composition-census.cjs +22 -6
- package/lib/mcp/brain-route-bound.cjs +66 -0
- package/lib/mcp/brain-router.cjs +25 -10
- package/lib/mcp/hook-adapter-audit.cjs +13 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/build-brain-census.cjs +321 -4
- package/scripts/doctor.cjs +57 -7
- package/scripts/session-start +22 -0
- package/skills/doctor/SKILL.md +4 -3
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* Quick 260911-iko (D-01 through D-08) -- the opaque per-install id Theo
|
|
8
|
+
* uses to bucket "one install calling twice" apart from "two installs
|
|
9
|
+
* calling once", without ever learning who the install belongs to.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT IS FOR: Theo needs a bucket key. Today it has none, and every
|
|
12
|
+
* substitute it could reach for instead (a key, a hostname, an account id)
|
|
13
|
+
* would be user data crossing the Brain boundary, which Canon Part 8
|
|
14
|
+
* forbids. A 128-bit coin flip minted locally and sent as a header is the
|
|
15
|
+
* one answer that gives Theo the bucketing and gives the user nothing to
|
|
16
|
+
* leak.
|
|
17
|
+
*
|
|
18
|
+
* WHY A CSPRNG VALUE IS A GENERIC HANDLE, NOT USER DATA (D-07): the value
|
|
19
|
+
* is `crypto.randomBytes(16)`, 16 bytes straight out of the platform CSPRNG
|
|
20
|
+
* with NO INPUT at all. There is no function from the user, the machine,
|
|
21
|
+
* the account, the room, the path, the hostname, or the Brain key to this
|
|
22
|
+
* value, so there is nothing to invert. A hash of an identifier would still
|
|
23
|
+
* BE that identifier wearing a hat: the same user on two installs would
|
|
24
|
+
* hash to the same bucket, and anyone holding the identifier could confirm
|
|
25
|
+
* a match. A random 128-bit value cannot do either. It carries exactly one
|
|
26
|
+
* bit of meaning: "the caller that sent this header before is the caller
|
|
27
|
+
* sending it now."
|
|
28
|
+
*
|
|
29
|
+
* THE EXPLICIT FORBIDDEN LIST -- this module must NEVER derive the id from:
|
|
30
|
+
* - os.hostname()
|
|
31
|
+
* - os.userInfo() / os.userInfo().username
|
|
32
|
+
* - process.env.USER / process.env.USERNAME / process.env.LOGNAME
|
|
33
|
+
* - process.cwd()
|
|
34
|
+
* - a home directory path
|
|
35
|
+
* - a MAC address or machine id
|
|
36
|
+
* - an account id or room name
|
|
37
|
+
* - the Brain key (process.env.MINDRIAN_BRAIN_KEY, resolve-brain-key.cjs)
|
|
38
|
+
* - a hash of any of the above (crypto's createHash)
|
|
39
|
+
* tests/test-339-install-id-header.cjs arm 9 scans THIS FILE with comments
|
|
40
|
+
* stripped and fails the suite if any of those tokens appears outside this
|
|
41
|
+
* prose -- so this comment is safe and LOAD-BEARING, and must stay.
|
|
42
|
+
*
|
|
43
|
+
* NEVER LOGGED, NEVER PRINTED IN FULL (D-03): doctor reports presence only,
|
|
44
|
+
* and on rotation the word "rotated" -- never the value. The id itself is
|
|
45
|
+
* the user's own to read from their own file.
|
|
46
|
+
*
|
|
47
|
+
* Posture copied deliberately from lib/core/brain-prewarm.cjs: this module
|
|
48
|
+
* NEVER throws to its caller and NEVER writes to stdout, because it can run
|
|
49
|
+
* inside an MCP stdio process where a stray stdout byte corrupts the
|
|
50
|
+
* JSON-RPC transport. A single stderr line, guarded by MINDRIAN_DEBUG, is
|
|
51
|
+
* permitted, and it prints the FILE PATH or an error message only, never
|
|
52
|
+
* the id value.
|
|
53
|
+
*
|
|
54
|
+
* CJS only, no new dependencies: require only fs, path, os, crypto.
|
|
55
|
+
* No em-dashes (hyphens only).
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const path = require('path');
|
|
60
|
+
const os = require('os');
|
|
61
|
+
const crypto = require('crypto');
|
|
62
|
+
|
|
63
|
+
const INSTALL_ID_HEADER_NAME = 'x-theo-install-id';
|
|
64
|
+
const ID_SHAPE_RE = /^[a-f0-9]{32}$/;
|
|
65
|
+
const FILE_NAME = 'theo-install-id.json';
|
|
66
|
+
|
|
67
|
+
const debugLog = (msg) => {
|
|
68
|
+
if (!process.env.MINDRIAN_DEBUG) return;
|
|
69
|
+
try {
|
|
70
|
+
process.stderr.write('[install-id] ' + msg + '\n');
|
|
71
|
+
} catch (_e) {
|
|
72
|
+
// swallow -- this function must never throw
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* installIdPath(homeDir) -- the on-disk location of the id file.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately duplicates the SAME resolution expression as
|
|
80
|
+
* lib/core/brain-prewarm.cjs::markerPath rather than extracting a shared
|
|
81
|
+
* helper -- two call sites is below the threshold where coupling two
|
|
82
|
+
* never-throws modules beats a duplicated two-line expression. Safety net:
|
|
83
|
+
* tests/test-339-install-id-header.cjs arm 10 asserts both modules resolve
|
|
84
|
+
* to the same directory (the drift guard). Extraction trigger: a THIRD
|
|
85
|
+
* call site.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} [homeDir] defaults to MINDRIAN_HOME or ~/.mindrian
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function installIdPath(homeDir) {
|
|
91
|
+
const home = homeDir || process.env.MINDRIAN_HOME || path.join(os.homedir(), '.mindrian');
|
|
92
|
+
return path.join(home, FILE_NAME);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the id file and return the id string, or null on any failure, any
|
|
97
|
+
* shape mismatch, or a fresh homeDir with no file yet. Never mints, never
|
|
98
|
+
* writes, never throws.
|
|
99
|
+
* @param {string} filePath
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
function _readValidId(filePath) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
if (parsed && typeof parsed.id === 'string' && ID_SHAPE_RE.test(parsed.id)) {
|
|
107
|
+
return parsed.id;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
} catch (_e) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* peekInstallId({ homeDir }) -- read-only. Returns the id when the file
|
|
117
|
+
* holds a valid one, otherwise null. NEVER mints, NEVER writes, NEVER
|
|
118
|
+
* throws. This exists so the doctor can report presence without a
|
|
119
|
+
* diagnostic run silently creating the thing it is diagnosing.
|
|
120
|
+
* @param {{homeDir?: string}} [opts]
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
function peekInstallId(opts) {
|
|
124
|
+
const o = opts || {};
|
|
125
|
+
try {
|
|
126
|
+
return _readValidId(installIdPath(o.homeDir));
|
|
127
|
+
} catch (_e) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Atomically write { id, minted_at } to installIdPath(homeDir), mode 0600.
|
|
134
|
+
* Every fs operation is wrapped; any failure returns false and leaves no
|
|
135
|
+
* temp file behind.
|
|
136
|
+
* @param {string} homeDir
|
|
137
|
+
* @param {string} id
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function _atomicWrite(homeDir, id) {
|
|
141
|
+
const finalPath = installIdPath(homeDir);
|
|
142
|
+
const dir = path.dirname(finalPath);
|
|
143
|
+
const tmpPath = path.join(
|
|
144
|
+
dir,
|
|
145
|
+
FILE_NAME + '.tmp-' + process.pid + '-' + crypto.randomBytes(3).toString('hex')
|
|
146
|
+
);
|
|
147
|
+
const body = { id: id, minted_at: new Date().toISOString() };
|
|
148
|
+
const json = JSON.stringify(body);
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
151
|
+
fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
|
|
152
|
+
fs.renameSync(tmpPath, finalPath);
|
|
153
|
+
if (process.platform !== 'win32') {
|
|
154
|
+
try {
|
|
155
|
+
fs.chmodSync(finalPath, 0o600);
|
|
156
|
+
} catch (_e) {
|
|
157
|
+
// belt only -- the write-time mode already applied it.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
debugLog('id written: ' + finalPath);
|
|
161
|
+
return true;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
debugLog('id write failed: ' + (e && e.message ? e.message : String(e)));
|
|
164
|
+
try {
|
|
165
|
+
fs.unlinkSync(tmpPath);
|
|
166
|
+
} catch (_e) {
|
|
167
|
+
// no temp file to clean up, or already gone.
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* getInstallId({ homeDir }) -- mint-once. peekInstallId first; on a hit
|
|
175
|
+
* return it. On a miss, mint a fresh 32-hex id from crypto.randomBytes(16),
|
|
176
|
+
* write it atomically, then RE-CHECK for a concurrent winner: two processes
|
|
177
|
+
* making their first Brain call at the same instant must converge on ONE
|
|
178
|
+
* bucket, not split it. Every failure returns null rather than throwing.
|
|
179
|
+
*
|
|
180
|
+
* Returning null rather than an unpersisted in-memory id on a write
|
|
181
|
+
* failure is deliberate: a volatile id would send a different value on
|
|
182
|
+
* every process and quietly break the one-install-one-id property the
|
|
183
|
+
* whole header exists to provide.
|
|
184
|
+
*
|
|
185
|
+
* @param {{homeDir?: string}} [opts]
|
|
186
|
+
* @returns {string|null}
|
|
187
|
+
*/
|
|
188
|
+
function getInstallId(opts) {
|
|
189
|
+
const o = opts || {};
|
|
190
|
+
const existing = peekInstallId(o);
|
|
191
|
+
if (existing) return existing;
|
|
192
|
+
|
|
193
|
+
const minted = crypto.randomBytes(16).toString('hex');
|
|
194
|
+
const wrote = _atomicWrite(o.homeDir, minted);
|
|
195
|
+
if (!wrote) {
|
|
196
|
+
// Concurrent-winner re-check even on our own write failure: another
|
|
197
|
+
// process may have won the race while we were failing.
|
|
198
|
+
return peekInstallId(o);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Concurrent-winner re-check: if another process's mint landed between
|
|
202
|
+
// our write and this read, defer to it so both processes converge on one
|
|
203
|
+
// bucket rather than splitting into two.
|
|
204
|
+
const afterWrite = peekInstallId(o);
|
|
205
|
+
if (afterWrite && afterWrite !== minted) {
|
|
206
|
+
return afterWrite;
|
|
207
|
+
}
|
|
208
|
+
return afterWrite || minted;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* resetInstallId({ homeDir }) -- mints unconditionally and REPLACES,
|
|
213
|
+
* skipping the concurrent-winner re-check (rotation must win over an
|
|
214
|
+
* existing file by definition). Same atomic write, same mode, same
|
|
215
|
+
* never-throws contract.
|
|
216
|
+
* @param {{homeDir?: string}} [opts]
|
|
217
|
+
* @returns {string|null}
|
|
218
|
+
*/
|
|
219
|
+
function resetInstallId(opts) {
|
|
220
|
+
const o = opts || {};
|
|
221
|
+
const minted = crypto.randomBytes(16).toString('hex');
|
|
222
|
+
const wrote = _atomicWrite(o.homeDir, minted);
|
|
223
|
+
return wrote ? minted : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
installIdHeaderName: INSTALL_ID_HEADER_NAME,
|
|
228
|
+
installIdPath: installIdPath,
|
|
229
|
+
peekInstallId: peekInstallId,
|
|
230
|
+
getInstallId: getInstallId,
|
|
231
|
+
resetInstallId: resetInstallId,
|
|
232
|
+
};
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Integration Registry
|
|
4
|
+
* Integration Registry - detects available integrations from env, MCP config, filesystem.
|
|
5
5
|
*
|
|
6
6
|
* Zero npm dependencies. Pure Node.js built-ins only (Phase 10 pattern).
|
|
7
7
|
*
|
|
8
8
|
* Exports:
|
|
9
|
-
* INTEGRATION_CATALOG
|
|
10
|
-
* detectIntegrations(options)
|
|
11
|
-
* checkIntegration(name)
|
|
12
|
-
* getContextTriggers(userMessage, roomState)
|
|
9
|
+
* INTEGRATION_CATALOG - all known integrations with detection config
|
|
10
|
+
* detectIntegrations(options) - scan all integrations, return structured status
|
|
11
|
+
* checkIntegration(name) - single integration check
|
|
12
|
+
* getContextTriggers(userMessage, roomState) - suggest integrations based on context
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Catalog of all known integrations.
|
|
@@ -61,19 +62,92 @@ const INTEGRATION_CATALOG = {
|
|
|
61
62
|
},
|
|
62
63
|
};
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Read and JSON.parse a config file. Returns the parsed object, or null on
|
|
67
|
+
* any failure (missing file, unreadable, malformed JSON). Private: the one
|
|
68
|
+
* fs.readFileSync + JSON.parse site both parseMcpConfig() and
|
|
69
|
+
* readScopedMcpServers() delegate to, so there is exactly one place that
|
|
70
|
+
* touches disk for either reader (Canon Part 7, no third reader minted).
|
|
71
|
+
*/
|
|
72
|
+
function _readJsonConfig(configPath) {
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Parse .mcp.json to extract configured MCP server keys.
|
|
66
83
|
* Returns array of server key strings (lowercased).
|
|
84
|
+
* Byte-unchanged behavior from before the _readJsonConfig extraction: any
|
|
85
|
+
* read/parse failure still yields [].
|
|
67
86
|
*/
|
|
68
87
|
function parseMcpConfig(mcpConfigPath) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
const parsed = _readJsonConfig(mcpConfigPath);
|
|
89
|
+
if (!parsed) return [];
|
|
90
|
+
const servers = parsed.mcpServers || parsed.servers || {};
|
|
91
|
+
return Object.keys(servers).map(k => k.toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read Claude Code's own MCP server config (~/.claude.json by default) and
|
|
96
|
+
* project the mcpServers entries at BOTH scopes Claude Code actually has for
|
|
97
|
+
* this file: top-level mcpServers (scope "user") and
|
|
98
|
+
* projects[projectDir].mcpServers (scope "local"). This intentionally never
|
|
99
|
+
* reads a repo's own .mcp.json ("project" scope in Claude Code's own
|
|
100
|
+
* vocabulary) -- that is a different file entirely and is out of scope for
|
|
101
|
+
* this reader by construction, never by discipline.
|
|
102
|
+
*
|
|
103
|
+
* SECURITY (Canon Part 8, quick task 260911-axz, T-axz-01): the returned
|
|
104
|
+
* shape is a STRUCTURAL secret-leak guard, not a discretionary one. Each
|
|
105
|
+
* entry is projected down to exactly { name, scope, url_host, type }. This
|
|
106
|
+
* function NEVER copies `headers`, NEVER copies the full `url` (only the
|
|
107
|
+
* host, derived via `new URL(entry.url).host`), and NEVER copies `env`, so
|
|
108
|
+
* no caller of this function -- today or in a future edit -- can leak a
|
|
109
|
+
* Bearer token, an API key, or any other header value even by accident:
|
|
110
|
+
* the bytes are never read into the return value in the first place.
|
|
111
|
+
*
|
|
112
|
+
* @param {{configPath?: string, projectDir?: string}} [opts]
|
|
113
|
+
* @returns {Array<{name: string, scope: 'user'|'local', url_host: string|null, type: string|null}>}
|
|
114
|
+
*/
|
|
115
|
+
function readScopedMcpServers(opts) {
|
|
116
|
+
const o = opts || {};
|
|
117
|
+
const configPath = o.configPath || path.join(os.homedir(), '.claude.json');
|
|
118
|
+
const projectDir = o.projectDir || process.cwd();
|
|
119
|
+
|
|
120
|
+
const parsed = _readJsonConfig(configPath);
|
|
121
|
+
if (!parsed) return [];
|
|
122
|
+
|
|
123
|
+
const out = [];
|
|
124
|
+
const pushScope = (serversObj, scope) => {
|
|
125
|
+
if (!serversObj || typeof serversObj !== 'object') return;
|
|
126
|
+
for (const name of Object.keys(serversObj)) {
|
|
127
|
+
const entry = serversObj[name] || {};
|
|
128
|
+
let url_host = null;
|
|
129
|
+
if (typeof entry.url === 'string' && entry.url.length > 0) {
|
|
130
|
+
try {
|
|
131
|
+
url_host = new URL(entry.url).host;
|
|
132
|
+
} catch (_e) {
|
|
133
|
+
url_host = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
out.push({
|
|
137
|
+
name: name,
|
|
138
|
+
scope: scope,
|
|
139
|
+
url_host: url_host,
|
|
140
|
+
type: typeof entry.type === 'string' ? entry.type : null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
pushScope(parsed.mcpServers, 'user');
|
|
146
|
+
if (parsed.projects && typeof parsed.projects === 'object' && parsed.projects[projectDir]) {
|
|
147
|
+
pushScope(parsed.projects[projectDir].mcpServers, 'local');
|
|
76
148
|
}
|
|
149
|
+
|
|
150
|
+
return out;
|
|
77
151
|
}
|
|
78
152
|
|
|
79
153
|
/**
|
|
@@ -98,8 +172,8 @@ function detectObsidianVault(workDir) {
|
|
|
98
172
|
|
|
99
173
|
/**
|
|
100
174
|
* Detect status of a single integration.
|
|
101
|
-
* @param {string} name
|
|
102
|
-
* @param {object} opts
|
|
175
|
+
* @param {string} name - integration key from INTEGRATION_CATALOG
|
|
176
|
+
* @param {object} opts - { workDir, mcpKeys }
|
|
103
177
|
* @returns {object} status object
|
|
104
178
|
*/
|
|
105
179
|
function detectSingle(name, opts) {
|
|
@@ -147,8 +221,8 @@ function detectSingle(name, opts) {
|
|
|
147
221
|
/**
|
|
148
222
|
* Detect all integrations.
|
|
149
223
|
* @param {object} options
|
|
150
|
-
* @param {string} options.workDir
|
|
151
|
-
* @param {string} options.mcpConfig
|
|
224
|
+
* @param {string} options.workDir - defaults to process.cwd()
|
|
225
|
+
* @param {string} options.mcpConfig - path to .mcp.json (defaults to workDir/.mcp.json)
|
|
152
226
|
* @returns {object} keyed by integration name, each with status object
|
|
153
227
|
*/
|
|
154
228
|
function detectIntegrations(options = {}) {
|
|
@@ -168,8 +242,8 @@ function detectIntegrations(options = {}) {
|
|
|
168
242
|
|
|
169
243
|
/**
|
|
170
244
|
* Check a single integration by name.
|
|
171
|
-
* @param {string} name
|
|
172
|
-
* @param {object} options
|
|
245
|
+
* @param {string} name - integration key
|
|
246
|
+
* @param {object} options - same as detectIntegrations
|
|
173
247
|
* @returns {object} status object for that integration
|
|
174
248
|
*/
|
|
175
249
|
function checkIntegration(name, options = {}) {
|
|
@@ -184,8 +258,8 @@ function checkIntegration(name, options = {}) {
|
|
|
184
258
|
* Analyze user message + room state and return integration suggestions.
|
|
185
259
|
* Returns at most 1 suggestion. Suppressed during active methodology.
|
|
186
260
|
*
|
|
187
|
-
* @param {string} userMessage
|
|
188
|
-
* @param {object} roomState
|
|
261
|
+
* @param {string} userMessage - the user's current message
|
|
262
|
+
* @param {object} roomState - current room state (activeMethodology truthy = suppress)
|
|
189
263
|
* @returns {Array<{integration: string, reason: string, offer_text: string}>}
|
|
190
264
|
*/
|
|
191
265
|
function getContextTriggers(userMessage, roomState = {}) {
|
|
@@ -229,4 +303,6 @@ module.exports = {
|
|
|
229
303
|
detectIntegrations,
|
|
230
304
|
checkIntegration,
|
|
231
305
|
getContextTriggers,
|
|
306
|
+
parseMcpConfig,
|
|
307
|
+
readScopedMcpServers,
|
|
232
308
|
};
|
|
@@ -77,15 +77,28 @@
|
|
|
77
77
|
* Section 3.5's explicit APPROVE condition) or validateSites() throws
|
|
78
78
|
* at module load.
|
|
79
79
|
*
|
|
80
|
-
* This module is a DECLARATION
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
80
|
+
* This module is a DECLARATION that requires exactly one pure-data leaf
|
|
81
|
+
* (brain-route-bound.cjs) and nothing else; it still EXECUTES nothing and
|
|
82
|
+
* OPENS NO WIRE (Quick 260911-ddd, DDD-02: the leaf itself requires nothing
|
|
83
|
+
* and opens no wire either, so this property still holds transitively).
|
|
84
|
+
* The scanner that reconciles this declaration against source lives in the
|
|
85
|
+
* test, not here -- that split is what lets this file hold plain quoted
|
|
86
|
+
* path strings as DATA without ever matching its own require-expression
|
|
87
|
+
* scan pattern. The new require names `brain-route-bound.cjs`, which
|
|
88
|
+
* matches neither the census scan's REACH_RE (brain-client|chain-
|
|
89
|
+
* recommender) nor its wire-pattern scan, so this file's own scan-safety
|
|
90
|
+
* property is unaffected.
|
|
85
91
|
*
|
|
86
92
|
* No em-dashes. CJS only.
|
|
87
93
|
*/
|
|
88
94
|
|
|
95
|
+
// Quick 260911-ddd (DDD-02): the single source of the Tier 3 bound. Reading
|
|
96
|
+
// the leaf here (rather than reading brain-router.cjs's own export) keeps
|
|
97
|
+
// the DECLARATION direction correct: this census depends on the constant,
|
|
98
|
+
// never on the implementation, so the declaration cannot silently drift
|
|
99
|
+
// out of sync with a router edit.
|
|
100
|
+
const { BRAIN_ROUTE_TIMEOUT_MS } = require('./brain-route-bound.cjs');
|
|
101
|
+
|
|
89
102
|
const COMPOSITION_SITES = Object.freeze([
|
|
90
103
|
Object.freeze({
|
|
91
104
|
file: 'lib/mcp/brain-router.cjs',
|
|
@@ -94,7 +107,10 @@ const COMPOSITION_SITES = Object.freeze([
|
|
|
94
107
|
via: "brainClient.ask(question) -- a generic problem-type/complexity enum question, never room content",
|
|
95
108
|
reaches_brain: true,
|
|
96
109
|
belt: 'callTool',
|
|
97
|
-
|
|
110
|
+
// Quick 260911-ddd (DDD-02): bound_ms is the DEFAULT from the single
|
|
111
|
+
// source (lib/mcp/brain-route-bound.cjs); MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS
|
|
112
|
+
// can move the APPLIED bound at runtime without editing this file.
|
|
113
|
+
bound_ms: BRAIN_ROUTE_TIMEOUT_MS,
|
|
98
114
|
frequency: 'one call per act* invocation, Tier 3 only (Tier 1 cache and Tier 2 local heuristic run first and can short-circuit before this call is made)',
|
|
99
115
|
reason: "D-01 ratifies this as the orchestration tool's live Brain-grounded recommendation leg, shipped before this phase existed. Desktop and Cowork have no MCP hook surface, so this is their only Brain-grounded enrichment path for act*.",
|
|
100
116
|
ratified_by: 'D-01',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
5
|
+
*
|
|
6
|
+
* Quick 260911-ddd (DDD-02) -- the single source of truth for
|
|
7
|
+
* brain-router.cjs's Tier 3 race bound. A pure-data leaf: requires nothing,
|
|
8
|
+
* opens no wire. lib/mcp/brain-router.cjs reads BRAIN_ROUTE_TIMEOUT_MS /
|
|
9
|
+
* resolveBrainRouteTimeoutMs() to build its Promise.race; brain-composition-
|
|
10
|
+
* census.cjs's bound_ms entry reads BRAIN_ROUTE_TIMEOUT_MS so the census
|
|
11
|
+
* DECLARATION depends on a constant instead of on the router's own
|
|
12
|
+
* implementation -- that direction (census -> leaf, never census -> router)
|
|
13
|
+
* is what keeps the census honest: a router edit can never silently move the
|
|
14
|
+
* bound the census reports without also moving this leaf.
|
|
15
|
+
*
|
|
16
|
+
* WHY 6000, MEASURED GROUND (not intuition):
|
|
17
|
+
*
|
|
18
|
+
* The raced call is brainClient.ask(), which since quick 260910-hni makes
|
|
19
|
+
* THREE sequential Theo calls (brain_ask, then recommend_chain, then
|
|
20
|
+
* brain_query). A Render instance waking from spin-down was measured at
|
|
21
|
+
* 2.034 s on the FIRST call alone, and Theo redeploys on every push to its
|
|
22
|
+
* main, opening a fresh cold window each time. So the old 2000 ms bound
|
|
23
|
+
* could not cover even a WARM three-call composition on a slow link, let
|
|
24
|
+
* alone a cold one -- every cold window silently degraded /mos:act to the
|
|
25
|
+
* local heuristic with no disclosure of why.
|
|
26
|
+
*
|
|
27
|
+
* 6000 is the measured 2.034 s cold wake plus two warm follow-on calls
|
|
28
|
+
* (roughly 1 s total) with roughly 2x headroom on top of that sum.
|
|
29
|
+
*
|
|
30
|
+
* THE ACCEPTED COST: when Theo is genuinely down, the race now rejects at
|
|
31
|
+
* 6 s instead of 2 s. This is a real cost, not hand-waved away -- but
|
|
32
|
+
* localRec (lib/mcp/brain-router.cjs's Tier 2 local heuristic) is already
|
|
33
|
+
* computed BEFORE the race starts, so the Tier 2 answer is instant once the
|
|
34
|
+
* race loses and /mos:act still resolves either way. The trade is a rarer
|
|
35
|
+
* 6 s worst case against a silently degraded recommendation on every cold
|
|
36
|
+
* window -- accepted (see the threat register, T-ddd-04).
|
|
37
|
+
*
|
|
38
|
+
* No em-dashes. CJS only.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The default Tier 3 race bound in milliseconds. See the file header for
|
|
43
|
+
* the measured justification.
|
|
44
|
+
* @type {number}
|
|
45
|
+
*/
|
|
46
|
+
const BRAIN_ROUTE_TIMEOUT_MS = 6000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* resolveBrainRouteTimeoutMs(env) -- honors MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS
|
|
50
|
+
* only when it parses to a finite integer strictly greater than 0;
|
|
51
|
+
* otherwise returns BRAIN_ROUTE_TIMEOUT_MS unchanged. A malformed operator
|
|
52
|
+
* env can never zero out or invert the bound.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} [env] defaults to process.env
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
function resolveBrainRouteTimeoutMs(env) {
|
|
58
|
+
const source = env || process.env;
|
|
59
|
+
const raw = source && source.MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS;
|
|
60
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return BRAIN_ROUTE_TIMEOUT_MS;
|
|
61
|
+
const parsed = Number(raw);
|
|
62
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return BRAIN_ROUTE_TIMEOUT_MS;
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { BRAIN_ROUTE_TIMEOUT_MS, resolveBrainRouteTimeoutMs };
|
package/lib/mcp/brain-router.cjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 3-tier fallback for framework recommendations:
|
|
7
7
|
* Tier 1: In-memory cache (instant, 10-min TTL)
|
|
8
8
|
* Tier 2: Local heuristic from problem-types.md (~100ms)
|
|
9
|
-
* Tier 3: Brain API via brain-client.cjs (
|
|
9
|
+
* Tier 3: Brain API via brain-client.cjs (bound: MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS, default 6000ms -- lib/mcp/brain-route-bound.cjs)
|
|
10
10
|
*
|
|
11
11
|
* RECOMMENDS only -- never executes frameworks.
|
|
12
12
|
* Called by orchestration router for act*, suggest-next commands.
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { safeReadFile } = require('../core/index.cjs');
|
|
17
|
+
// Quick 260911-ddd (DDD-02): the single source of the Tier 3 race bound.
|
|
18
|
+
// See brain-route-bound.cjs's header for the measured 6000 ms justification
|
|
19
|
+
// and brain-composition-census.cjs, whose bound_ms entry reads this SAME
|
|
20
|
+
// leaf. brain-composition-census provenance: this file's Tier 3 race is
|
|
21
|
+
// enumerated in lib/mcp/brain-composition-census.cjs.
|
|
22
|
+
const { BRAIN_ROUTE_TIMEOUT_MS, resolveBrainRouteTimeoutMs } = require('./brain-route-bound.cjs');
|
|
17
23
|
|
|
18
24
|
// ---------------------------------------------------------------------------
|
|
19
25
|
// Cache (Tier 1)
|
|
@@ -287,11 +293,12 @@ function localRoute(roomDir, stateContent, intent) {
|
|
|
287
293
|
// Phase 254 (COMP-01): this call is enumerated in
|
|
288
294
|
// lib/mcp/brain-composition-census.cjs as the 'orchestration (act,
|
|
289
295
|
// act-chain, act-dry-run, act-swarm)' reaching site -- reaches_brain: true,
|
|
290
|
-
// belt: 'callTool', bound_ms:
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
296
|
+
// belt: 'callTool', bound_ms: BRAIN_ROUTE_TIMEOUT_MS (Quick 260911-ddd,
|
|
297
|
+
// DDD-02; the Promise.race wrap in recommend() below, default 6000ms,
|
|
298
|
+
// overridable via MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS). D-01 (254-CONTEXT.md)
|
|
299
|
+
// ratified this as SHIPPED, released behaviour, not a new decision. A new
|
|
300
|
+
// composed Brain call anywhere under lib/mcp/ requires an entry there or
|
|
301
|
+
// the build fails (tests/test-254-composition-census.cjs).
|
|
295
302
|
async function brainRoute(roomDir, stateContent, intent) {
|
|
296
303
|
// Phase 339 Plan 05 (D-03b), 2026-09-03: reset at entry so a value read
|
|
297
304
|
// by a caller can only ever reflect THIS invocation's own outcome.
|
|
@@ -422,7 +429,8 @@ async function brainRoute(roomDir, stateContent, intent) {
|
|
|
422
429
|
|
|
423
430
|
/**
|
|
424
431
|
* Get a framework recommendation for a room.
|
|
425
|
-
* 3-tier fallback: cache -> local heuristic -> Brain API (
|
|
432
|
+
* 3-tier fallback: cache -> local heuristic -> Brain API (bound:
|
|
433
|
+
* MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS, default 6000ms).
|
|
426
434
|
*
|
|
427
435
|
* @param {string} roomDir - Path to room directory
|
|
428
436
|
* @param {{ intent?: string, mode?: string }} [options]
|
|
@@ -457,11 +465,15 @@ async function recommend(roomDir, options = {}) {
|
|
|
457
465
|
// Tier 2: Local heuristic (always computed as fallback)
|
|
458
466
|
const localRec = localRoute(roomDir, stateContent, intent);
|
|
459
467
|
|
|
460
|
-
// Tier 3: Brain API (
|
|
468
|
+
// Tier 3: Brain API (bound: MINDRIAN_BRAIN_ROUTE_TIMEOUT_MS, default
|
|
469
|
+
// 6000ms; non-blocking -- localRec above is already computed, so the
|
|
470
|
+
// Tier 2 answer is instant once this race loses). Quick 260911-ddd
|
|
471
|
+
// (DDD-02): evaluated at CALL TIME so an env override is honored per
|
|
472
|
+
// call and is testable, rather than a frozen 2000 literal.
|
|
461
473
|
try {
|
|
462
474
|
const brainRec = await Promise.race([
|
|
463
475
|
brainRoute(roomDir, stateContent, intent),
|
|
464
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')),
|
|
476
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), resolveBrainRouteTimeoutMs()))
|
|
465
477
|
]);
|
|
466
478
|
if (brainRec) {
|
|
467
479
|
setCache(cacheKey, brainRec);
|
|
@@ -548,4 +560,7 @@ function validateChain(roomDir, chain) {
|
|
|
548
560
|
return { valid: true };
|
|
549
561
|
}
|
|
550
562
|
|
|
551
|
-
|
|
563
|
+
// Quick 260911-ddd (DDD-02): re-exported so the Tier 3 bound pin is
|
|
564
|
+
// readable from either side (this file and brain-composition-census.cjs
|
|
565
|
+
// both read the same lib/mcp/brain-route-bound.cjs leaf).
|
|
566
|
+
module.exports = { recommend, validateChain, BRAIN_ROUTE_TIMEOUT_MS, resolveBrainRouteTimeoutMs };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Phase 198-08 (SPEC-5, D-06) -- hook-adapter-audit: the measured
|
|
3
3
|
// "adapter-only" budget for MIGRATED hook scripts (enumerated from
|
|
4
|
-
//
|
|
4
|
+
// data/hooks-markers.json's _mcpFirst198Migrated marker, moved there by
|
|
5
|
+
// quick task 260911-juq because the Claude Code hook loader warns on
|
|
6
|
+
// unknown top-level keys in hooks/hooks.json). Two checks:
|
|
5
7
|
// 1. IMPORT AUDIT -- a migrated hook script's own require() calls (after
|
|
6
8
|
// comment-stripping, so a header/doc comment naming lib/core does not
|
|
7
9
|
// self-invalidate the gate) never reach into lib/core, lib/workflow, or
|
|
@@ -14,10 +16,10 @@
|
|
|
14
16
|
// cannot silently re-fatten a thin adapter back into a business-logic
|
|
15
17
|
// script without this test failing.
|
|
16
18
|
//
|
|
17
|
-
// Scope: ONLY the surfaces hooks.json's own migration marker
|
|
18
|
-
// statusline + SessionStart THIS plan; Stop-gate scripts are
|
|
19
|
-
// concern and are never enumerated here -- enumeration comes from
|
|
20
|
-
// marker, not a hand-maintained list, so Plan 09 adding its own marker
|
|
19
|
+
// Scope: ONLY the surfaces data/hooks-markers.json's own migration marker
|
|
20
|
+
// names (D-05: statusline + SessionStart THIS plan; Stop-gate scripts are
|
|
21
|
+
// Plan 09's concern and are never enumerated here -- enumeration comes from
|
|
22
|
+
// the marker, not a hand-maintained list, so Plan 09 adding its own marker
|
|
21
23
|
// entries later does not require touching this file).
|
|
22
24
|
//
|
|
23
25
|
// No em-dashes. CJS only.
|
|
@@ -26,7 +28,7 @@ const fs = require('node:fs');
|
|
|
26
28
|
const path = require('node:path');
|
|
27
29
|
|
|
28
30
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
29
|
-
const
|
|
31
|
+
const HOOKS_MARKERS_PATH = path.join(REPO_ROOT, 'data', 'hooks-markers.json');
|
|
30
32
|
|
|
31
33
|
// Measured baseline + a small margin (D-06: "set the budget from the thin
|
|
32
34
|
// post-migration line counts with a small margin; record the exact numbers
|
|
@@ -68,15 +70,17 @@ const LINE_BUDGETS = {
|
|
|
68
70
|
const FORBIDDEN_IMPORT_PATTERN = /require\(.*lib\/core|require\(.*lib\/workflow|require\(.*lib\/memory/;
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
|
-
* migratedSurfaces() -- read hooks.json's
|
|
72
|
-
* marker (Task 1
|
|
73
|
+
* migratedSurfaces() -- read data/hooks-markers.json's
|
|
74
|
+
* _mcpFirst198Migrated.surfaces marker (Task 1; moved out of hooks/hooks.json
|
|
75
|
+
* by quick task 260911-juq so the hook loader stops warning on unknown
|
|
76
|
+
* top-level keys) and return the list of migrated script paths (repo-root
|
|
73
77
|
* relative). Never throws; a missing/malformed marker returns [].
|
|
74
78
|
*
|
|
75
79
|
* @returns {string[]}
|
|
76
80
|
*/
|
|
77
81
|
function migratedSurfaces() {
|
|
78
82
|
try {
|
|
79
|
-
const raw = fs.readFileSync(
|
|
83
|
+
const raw = fs.readFileSync(HOOKS_MARKERS_PATH, 'utf8');
|
|
80
84
|
const parsed = JSON.parse(raw);
|
|
81
85
|
const marker = parsed && parsed._mcpFirst198Migrated;
|
|
82
86
|
const surfaces = marker && Array.isArray(marker.surfaces) ? marker.surfaces : [];
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindrian_os/cli",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.37",
|
|
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.
|
|
9
|
+
"version": "2.0.0-beta.37",
|
|
10
10
|
"license": "BSL-1.1",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@ig3/markdown-it-wikilinks": "^1.0.2",
|