@mindrian_os/cli 2.0.0-beta.33 → 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.
@@ -1,19 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Integration Registry detects available integrations from env, MCP config, filesystem.
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 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
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
- try {
70
- const raw = fs.readFileSync(mcpConfigPath, 'utf-8');
71
- const parsed = JSON.parse(raw);
72
- const servers = parsed.mcpServers || parsed.servers || {};
73
- return Object.keys(servers).map(k => k.toLowerCase());
74
- } catch (e) {
75
- return [];
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 integration key from INTEGRATION_CATALOG
102
- * @param {object} opts { workDir, mcpKeys }
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 defaults to process.cwd()
151
- * @param {string} options.mcpConfig path to .mcp.json (defaults to workDir/.mcp.json)
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 integration key
172
- * @param {object} options same as detectIntegrations
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 the user's current message
188
- * @param {object} roomState current room state (activeMethodology truthy = suppress)
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 only: it requires nothing, executes
81
- * nothing, opens no wire. The scanner that reconciles it against source
82
- * lives in the test, not here -- that split is what lets this file hold
83
- * plain quoted path strings as DATA without ever matching its own
84
- * require-expression scan pattern.
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
- bound_ms: 2000,
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 };
@@ -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 (2s hard timeout)
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: 2000 (the Promise.race wrap in recommend()
291
- // below). D-01 (254-CONTEXT.md) ratified this as SHIPPED, released
292
- // behaviour, not a new decision. A new composed Brain call anywhere under
293
- // lib/mcp/ requires an entry there or the build fails
294
- // (tests/test-254-composition-census.cjs).
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 (2s timeout).
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 (2s timeout, non-blocking)
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')), 2000))
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
- module.exports = { recommend, validateChain };
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,12 +1,12 @@
1
1
  {
2
2
  "name": "@mindrian_os/cli",
3
- "version": "2.0.0-beta.33",
3
+ "version": "2.0.0-beta.35",
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.33",
9
+ "version": "2.0.0-beta.35",
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.33",
3
+ "version": "2.0.0-beta.35",
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",