@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.
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * scripts/collect-cold-install-evidence.cjs -- Phase 341 Plan 06 (D-13 step 2).
6
+ *
7
+ * WHAT: emits one JSON envelope proving a cold install of the npm-source
8
+ * plugin artifact landed on this machine and that both bundled MCP servers
9
+ * (mindrian-os, mindrian-brain) load. One command, run identically on
10
+ * Windows, macOS and Linux, so a human operator on a machine this repo
11
+ * cannot reach can paste the result back into a tracked proof document.
12
+ *
13
+ * WHY: there is no Windows box and no macOS box in this dev environment.
14
+ * 341-RESEARCH names that as the phase's one hard external dependency with
15
+ * no fallback (see 341-RESEARCH.md "Loader Ground Truth" F-2/F-3 and
16
+ * assumption A1). The proof this collector produces is evidence, not a
17
+ * claim: the fields below are what the D-13 gate actually reads.
18
+ *
19
+ * Canon Part 8 (Graph Boundary): local filesystem reads plus local spawns of
20
+ * this repo's own scripts (node scripts/doctor.cjs). The ONE network touch
21
+ * in the whole envelope is inherited from `doctor --acceptance`'s full-tier
22
+ * points (the live `npm view` version-of-record check and the brain-smoke
23
+ * wire probe) and from `doctor --eureka-smoke`'s optional model-cache read;
24
+ * this is stated here, not hidden. Nothing in this file opens a socket
25
+ * itself.
26
+ *
27
+ * MCP-server probe note (measured on this Linux dev box, both files read
28
+ * before writing this probe per the plan's own instruction): neither
29
+ * bin/mindrian-mcp-server.cjs nor bin/mindrian-brain-mcp-client.cjs exposes
30
+ * a --version flag or any other CLI probe flag -- both call an unconditional
31
+ * top-level main() that connects a StdioServerTransport. Measured behavior
32
+ * differs from the plan's literal "require() + treat a clean exit as
33
+ * loads:true" fallback: mindrian-brain-mcp-client.cjs happens to exit 0 on
34
+ * stdin EOF, but mindrian-mcp-server.cjs does NOT -- it stays connected to
35
+ * stdio by design (a healthy MCP stdio server never exits on its own) and
36
+ * only stops when killed. A literal "clean exit only" probe would therefore
37
+ * mis-report a HEALTHY mindrian-os server as not-loading. The narrowest
38
+ * non-destructive probe each file actually supports is its own startup log
39
+ * line to stderr (`... MCP server v<version> started ...`), written at the
40
+ * point server.connect() succeeds. This collector spawns the real entry
41
+ * file (not require(), since require() previously start it in-process too),
42
+ * feeds it a closed stdin (input:''), captures stderr up to 2000 characters
43
+ * bounded by an explicit timeout, and treats either a clean exit(0) OR the
44
+ * presence of the entry's own "started" marker in stderr as loads:true. A
45
+ * timeout-kill after a confirmed "started" marker is expected lifecycle for
46
+ * a server that intentionally never exits on inspection, not a failure --
47
+ * documented as a note either way for transparency.
48
+ *
49
+ * Version note: plugin_version is read via lib/core/repo-version.cjs (the
50
+ * ONE sanctioned way to read this repo's own version, never a tree search --
51
+ * see that module's header) when CLAUDE_PLUGIN_ROOT is unset (a dev
52
+ * checkout), and directly from <CLAUDE_PLUGIN_ROOT>/package.json when it is
53
+ * set (an installed copy), per the plan's own instruction.
54
+ *
55
+ * Every probe below is wrapped so a failure produces a recorded `null` (or
56
+ * `false` where the field is boolean-shaped) plus a `notes` entry, never a
57
+ * throw -- an evidence collector that crashes on an unhealthy install is
58
+ * useless precisely when it is needed most.
59
+ *
60
+ * House rule: hyphens only, no em-dashes, no emoji. CJS, process.argv
61
+ * routing.
62
+ */
63
+
64
+ const fs = require('node:fs');
65
+ const path = require('node:path');
66
+ const os = require('node:os');
67
+ const { spawnSync } = require('node:child_process');
68
+
69
+ const SCHEMA = 'mos-cold-install-evidence/1';
70
+
71
+ function usage() {
72
+ return 'usage: node scripts/collect-cold-install-evidence.cjs [--json]';
73
+ }
74
+
75
+ // -- small helpers -----------------------------------------------------
76
+
77
+ function safe(notes, label, fn, fallback) {
78
+ try {
79
+ return fn();
80
+ } catch (e) {
81
+ notes.push(label + ': ' + (e && e.message ? e.message : String(e)));
82
+ return fallback;
83
+ }
84
+ }
85
+
86
+ function existsSafe(p) {
87
+ try {
88
+ return !!p && fs.existsSync(p);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function readJsonSafe(p) {
95
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
96
+ }
97
+
98
+ // countInstalledPackages(nodeModulesDir): counts installed packages two
99
+ // levels deep for a scoped entry (@scope/name is ONE package, not two), one
100
+ // level deep otherwise. Skips dotfiles (.bin, .package-lock.json).
101
+ function countInstalledPackages(nodeModulesDir) {
102
+ let count = 0;
103
+ const top = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
104
+ for (const entry of top) {
105
+ if (!entry.isDirectory()) continue;
106
+ if (entry.name.startsWith('.')) continue;
107
+ if (entry.name.startsWith('@')) {
108
+ const scopeDir = path.join(nodeModulesDir, entry.name);
109
+ let scoped;
110
+ try {
111
+ scoped = fs.readdirSync(scopeDir, { withFileTypes: true });
112
+ } catch {
113
+ continue;
114
+ }
115
+ for (const s of scoped) {
116
+ if (s.isDirectory()) count += 1;
117
+ }
118
+ } else {
119
+ count += 1;
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+
125
+ // longestRelativePath(rootDir): walks rootDir recursively (bounded, symlinks
126
+ // NOT followed to avoid a cycle) and returns { length, path } of the longest
127
+ // path relative to rootDir. Returns null on an unreadable root.
128
+ function longestRelativePath(rootDir) {
129
+ let best = { length: 0, path: '' };
130
+ const stack = [rootDir];
131
+ while (stack.length) {
132
+ const dir = stack.pop();
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(dir, { withFileTypes: true });
136
+ } catch {
137
+ continue;
138
+ }
139
+ for (const entry of entries) {
140
+ const abs = path.join(dir, entry.name);
141
+ const rel = path.relative(rootDir, abs);
142
+ if (rel.length > best.length) best = { length: rel.length, path: rel };
143
+ if (entry.isSymbolicLink()) continue;
144
+ if (entry.isDirectory()) stack.push(abs);
145
+ }
146
+ }
147
+ return best;
148
+ }
149
+
150
+ // probeMcpEntry(entryPath, notes, id): spawns the entry file directly with a
151
+ // closed stdin, capped stderr and a bounded timeout. loads:true when either
152
+ // the process exits cleanly OR its own startup marker appears in stderr
153
+ // before the timeout kills it (see the header comment for why).
154
+ function probeMcpEntry(entryPath, id, notes) {
155
+ if (!existsSafe(entryPath)) {
156
+ return { loads: false };
157
+ }
158
+ const scratchCwd = safe(notes, 'mcp-probe-' + id + '-scratch-dir', function () {
159
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'mos-mcp-probe-'));
160
+ }, os.tmpdir());
161
+ const result = spawnSync(process.execPath, [entryPath], {
162
+ cwd: scratchCwd,
163
+ encoding: 'utf8',
164
+ timeout: 5000,
165
+ input: '',
166
+ });
167
+ const stderrTail = (result.stderr || '').slice(0, 2000);
168
+ const startedMarker = /started/i.test(stderrTail);
169
+ const cleanExit = result.status === 0 && !result.signal;
170
+ if (result.signal) {
171
+ notes.push('mcp-probe-' + id + ': killed by signal ' + result.signal + ' after timeout' +
172
+ (startedMarker ? ' (startup marker seen first -- expected lifecycle for a server that never exits on inspection)' : ' (no startup marker seen -- probe inconclusive)'));
173
+ }
174
+ if (result.error) {
175
+ notes.push('mcp-probe-' + id + ': spawn error: ' + result.error.message);
176
+ }
177
+ return { loads: !!(cleanExit || startedMarker) };
178
+ }
179
+
180
+ // spawnDoctorJson(pluginRoot, args, notes, label): spawns
181
+ // `node <pluginRoot>/scripts/doctor.cjs <args> --json`, and extracts the
182
+ // trailing JSON block from stdout (doctor.cjs's --acceptance path prints
183
+ // human-readable lines before the final JSON.stringify block; --eureka-smoke
184
+ // prints JSON only). Returns the parsed object, or null with a notes entry.
185
+ function spawnDoctorJson(pluginRoot, args, notes, label, timeoutMs) {
186
+ const doctorPath = path.join(pluginRoot, 'scripts', 'doctor.cjs');
187
+ if (!existsSafe(doctorPath)) {
188
+ notes.push(label + ': doctor.cjs not found at ' + doctorPath);
189
+ return null;
190
+ }
191
+ const result = spawnSync(process.execPath, [doctorPath].concat(args).concat(['--json']), {
192
+ cwd: pluginRoot,
193
+ encoding: 'utf8',
194
+ timeout: timeoutMs,
195
+ input: '',
196
+ maxBuffer: 10 * 1024 * 1024,
197
+ });
198
+ if (result.error) {
199
+ notes.push(label + ': spawn error: ' + result.error.message);
200
+ return null;
201
+ }
202
+ if (result.signal) {
203
+ notes.push(label + ': killed by signal ' + result.signal + ' (timeout ' + timeoutMs + 'ms exceeded)');
204
+ return null;
205
+ }
206
+ const stdout = result.stdout || '';
207
+ const lines = stdout.split('\n');
208
+ const startIdx = lines.findIndex(function (l) { return l.trim() === '{'; });
209
+ if (startIdx === -1) {
210
+ notes.push(label + ': no JSON block located in stdout (exit code ' + result.status + ')');
211
+ return null;
212
+ }
213
+ const jsonText = lines.slice(startIdx).join('\n');
214
+ try {
215
+ return JSON.parse(jsonText);
216
+ } catch (e) {
217
+ notes.push(label + ': JSON.parse failed: ' + e.message);
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // -- the collector -------------------------------------------------------
223
+
224
+ function collectEvidence(opts) {
225
+ opts = opts || {};
226
+ const notes = [];
227
+ const home = os.homedir();
228
+
229
+ const pluginRootEnv = process.env.CLAUDE_PLUGIN_ROOT || null;
230
+ // baseRoot: the root this collector treats as "the plugin" for every
231
+ // repo-relative read below (doctor.cjs, .mcp.json). Falls back to this
232
+ // script's own repo when CLAUDE_PLUGIN_ROOT is unset (a dev checkout).
233
+ const repoRoot = path.resolve(__dirname, '..');
234
+ const baseRoot = pluginRootEnv && existsSafe(pluginRootEnv) ? pluginRootEnv : repoRoot;
235
+ if (pluginRootEnv && !existsSafe(pluginRootEnv)) {
236
+ notes.push('CLAUDE_PLUGIN_ROOT is set to a non-existent path (' + pluginRootEnv + '); falling back to this script\'s own repo root for local reads');
237
+ }
238
+
239
+ // -- claude_code_version --
240
+ const claudeCodeVersion = safe(notes, 'claude_code_version', function () {
241
+ const r = spawnSync('claude', ['--version'], { encoding: 'utf8', timeout: 5000, input: '' });
242
+ if (r.error) throw r.error;
243
+ if (r.status !== 0) throw new Error('exit ' + r.status);
244
+ const m = /(\d+\.\d+\.\d+)/.exec((r.stdout || '') + (r.stderr || ''));
245
+ if (!m) throw new Error('could not parse a version from claude --version output');
246
+ return m[1];
247
+ }, null);
248
+
249
+ // -- plugin_version (per the plan: repo-version.cjs from a checkout, the
250
+ // installed package.json from an install; never a tree search) --
251
+ let pluginVersion = null;
252
+ if (pluginRootEnv) {
253
+ pluginVersion = safe(notes, 'plugin_version', function () {
254
+ const pkg = readJsonSafe(path.join(pluginRootEnv, 'package.json'));
255
+ if (!pkg.version) throw new Error('package.json at CLAUDE_PLUGIN_ROOT has no version field');
256
+ return pkg.version;
257
+ }, null);
258
+ } else {
259
+ pluginVersion = safe(notes, 'plugin_version', function () {
260
+ const { readRepoVersion } = require(path.join(repoRoot, 'lib', 'core', 'repo-version.cjs'));
261
+ return readRepoVersion().version;
262
+ }, null);
263
+ }
264
+
265
+ // -- marketplace_source: the plugins[].source object for "mos" from the
266
+ // cached marketplace catalog Claude Code itself reads from. --
267
+ const marketplaceCatalogPath = path.join(home, '.claude', 'plugins', 'marketplaces', 'mindrian-marketplace', '.claude-plugin', 'marketplace.json');
268
+ const marketplaceSource = safe(notes, 'marketplace_source', function () {
269
+ const catalog = readJsonSafe(marketplaceCatalogPath);
270
+ const entry = Array.isArray(catalog.plugins) ? catalog.plugins.find(function (p) { return p.name === 'mos'; }) : null;
271
+ if (!entry) throw new Error('no "mos" entry in ' + marketplaceCatalogPath);
272
+ if (!entry.source) throw new Error('"mos" entry has no source field');
273
+ return entry.source;
274
+ }, null);
275
+
276
+ // -- npm_cache_present --
277
+ const npmCachePath = path.join(home, '.claude', 'plugins', 'npm-cache');
278
+ const npmCachePresent = existsSafe(npmCachePath);
279
+
280
+ // -- cache_dir / node_modules_present / completion_record_present /
281
+ // installed_package_count / longest_relative_path --
282
+ let cacheDir = null;
283
+ let nodeModulesPresent = false;
284
+ let completionRecordPresent = false;
285
+ let installedPackageCount = 0;
286
+ let longestPath = { length: 0, path: '' };
287
+ if (pluginVersion) {
288
+ cacheDir = path.join(home, '.claude', 'plugins', 'cache', 'mindrian-marketplace', 'mos', pluginVersion);
289
+ if (existsSafe(cacheDir)) {
290
+ const nodeModulesDir = path.join(cacheDir, 'node_modules');
291
+ nodeModulesPresent = existsSafe(nodeModulesDir);
292
+ completionRecordPresent = existsSafe(path.join(nodeModulesDir, '.package-lock.json'));
293
+ if (nodeModulesPresent) {
294
+ installedPackageCount = safe(notes, 'installed_package_count', function () {
295
+ return countInstalledPackages(nodeModulesDir);
296
+ }, 0);
297
+ }
298
+ longestPath = safe(notes, 'longest_relative_path', function () {
299
+ return longestRelativePath(cacheDir);
300
+ }, { length: 0, path: '' });
301
+ } else {
302
+ notes.push('cache_dir does not exist yet: ' + cacheDir);
303
+ }
304
+ } else {
305
+ notes.push('cache_dir could not be computed: plugin_version is unknown');
306
+ }
307
+
308
+ // -- mcp_servers: probe both .mcp.json entries --
309
+ const mcpJsonPath = path.join(baseRoot, '.mcp.json');
310
+ let mcpServers = [];
311
+ const mcpConfig = safe(notes, 'mcp_servers', function () { return readJsonSafe(mcpJsonPath); }, null);
312
+ if (mcpConfig && mcpConfig.mcpServers) {
313
+ mcpServers = Object.keys(mcpConfig.mcpServers).map(function (id) {
314
+ const def = mcpConfig.mcpServers[id];
315
+ const rawArg = Array.isArray(def.args) && def.args.length ? def.args[0] : '';
316
+ const entryPath = rawArg.replace('${CLAUDE_PLUGIN_ROOT}', baseRoot);
317
+ const entryExists = existsSafe(entryPath);
318
+ const probe = probeMcpEntry(entryPath, id, notes);
319
+ return { id: id, entry_path: entryPath, entry_exists: entryExists, loads: entryExists && probe.loads };
320
+ });
321
+ } else {
322
+ notes.push('mcp_servers: could not read ' + mcpJsonPath + ' (or it has no mcpServers block)');
323
+ }
324
+
325
+ // -- doctor_acceptance (full tier: the one network touch this envelope
326
+ // documents, per the header comment) --
327
+ const doctorAcceptanceRaw = spawnDoctorJson(baseRoot, ['--acceptance'], notes, 'doctor_acceptance', 180000);
328
+ const doctorAcceptance = doctorAcceptanceRaw
329
+ ? { ok: Array.isArray(doctorAcceptanceRaw.failed_points) && doctorAcceptanceRaw.failed_points.length === 0, failed_point_ids: doctorAcceptanceRaw.failed_points || [] }
330
+ : { ok: false, failed_point_ids: [] };
331
+
332
+ // -- eureka_smoke --
333
+ const eurekaSmokeRaw = spawnDoctorJson(baseRoot, ['--eureka-smoke'], notes, 'eureka_smoke', 60000);
334
+ const eurekaSmoke = eurekaSmokeRaw
335
+ ? {
336
+ ok: !!eurekaSmokeRaw.ok,
337
+ layers: Array.isArray(eurekaSmokeRaw.layers)
338
+ ? eurekaSmokeRaw.layers.map(function (l) { return { id: l.id, ok: !!l.ok, advisory: !!l.advisory }; })
339
+ : [],
340
+ }
341
+ : { ok: false, layers: [] };
342
+
343
+ const envelope = {
344
+ schema: SCHEMA,
345
+ collected_at: new Date().toISOString(),
346
+ platform: process.platform,
347
+ arch: process.arch,
348
+ node: process.version,
349
+ os_release: os.release(),
350
+ claude_code_version: claudeCodeVersion,
351
+ plugin_root: pluginRootEnv,
352
+ plugin_version: pluginVersion,
353
+ marketplace_source: marketplaceSource,
354
+ npm_cache_present: npmCachePresent,
355
+ cache_dir: cacheDir,
356
+ node_modules_present: nodeModulesPresent,
357
+ completion_record_present: completionRecordPresent,
358
+ installed_package_count: installedPackageCount,
359
+ mcp_servers: mcpServers,
360
+ doctor_acceptance: doctorAcceptance,
361
+ eureka_smoke: eurekaSmoke,
362
+ longest_relative_path: longestPath,
363
+ first_install_seconds: opts.firstInstallSeconds != null ? opts.firstInstallSeconds : null,
364
+ notes: opts.firstInstallSeconds == null
365
+ ? notes.concat(['first_install_seconds is null by default -- the human operator records the wall-clock seconds the "claude plugin update mos@mindrian-marketplace" command took, per 341-RESEARCH Open Question 1'])
366
+ : notes,
367
+ };
368
+
369
+ return envelope;
370
+ }
371
+
372
+ // -- CLI -------------------------------------------------------------
373
+
374
+ function main() {
375
+ const args = process.argv.slice(2);
376
+ let json = false;
377
+ for (const arg of args) {
378
+ if (arg === '--json') { json = true; continue; }
379
+ process.stderr.write(usage() + '\n');
380
+ process.exit(2);
381
+ return;
382
+ }
383
+
384
+ const envelope = collectEvidence({});
385
+
386
+ if (json) {
387
+ process.stdout.write(JSON.stringify(envelope) + '\n');
388
+ process.exit(0);
389
+ return;
390
+ }
391
+
392
+ console.log('Cold-install evidence (' + envelope.schema + ')');
393
+ console.log(' platform: ' + envelope.platform + ' / ' + envelope.arch + ' / node ' + envelope.node);
394
+ console.log(' plugin_version: ' + envelope.plugin_version);
395
+ console.log(' marketplace_source: ' + JSON.stringify(envelope.marketplace_source));
396
+ console.log(' node_modules_present: ' + envelope.node_modules_present + ' completion_record_present: ' + envelope.completion_record_present);
397
+ console.log(' installed_package_count: ' + envelope.installed_package_count);
398
+ console.log(' mcp_servers: ' + envelope.mcp_servers.map(function (s) { return s.id + '=' + (s.loads ? 'loads' : 'no-load'); }).join(', '));
399
+ console.log(' doctor_acceptance.ok: ' + envelope.doctor_acceptance.ok);
400
+ console.log(' eureka_smoke.ok: ' + envelope.eureka_smoke.ok);
401
+ console.log(' longest_relative_path: ' + envelope.longest_relative_path.length + ' chars');
402
+ console.log('');
403
+ console.log(JSON.stringify(envelope, null, 2));
404
+ process.exit(0);
405
+ }
406
+
407
+ module.exports = { collectEvidence };
408
+
409
+ if (require.main === module) {
410
+ main();
411
+ }
@@ -332,9 +332,9 @@ print(json.dumps(reg['rooms'][name], indent=2))
332
332
  *) REAL_ROOMDIR="${ROOMS_HOME}/${RPATH}" ;;
333
333
  esac
334
334
  _write_current_room "${REAL_ROOMDIR}" "${NAME}"
335
- # Fire-and-forget graph sync (D-16: never blocks)
335
+ # Fire-and-forget local SQLite graph sync (D-16: never blocks). The Brain half
336
+ # was removed 2026-09-10 as a Canon Part 8 breach (quick task 260910-h32).
336
337
  node "${SCRIPT_DIR}/sync-rooms-graph" "$ROOMS_HOME" >/dev/null 2>&1 &
337
- node "${SCRIPT_DIR}/sync-rooms-brain" "$ROOMS_HOME" >/dev/null 2>&1 &
338
338
  ;;
339
339
 
340
340
  read)
@@ -597,9 +597,9 @@ with open(tmp, 'w') as f:
597
597
  os.replace(tmp, reg_file)
598
598
  print('archived')
599
599
  " "$REGISTRY_FILE" "$NAME"
600
- # Fire-and-forget graph sync (D-16: never blocks)
600
+ # Fire-and-forget local SQLite graph sync (D-16: never blocks). The Brain half
601
+ # was removed 2026-09-10 as a Canon Part 8 breach (quick task 260910-h32).
601
602
  node "${SCRIPT_DIR}/sync-rooms-graph" "$ROOMS_HOME" >/dev/null 2>&1 &
602
- node "${SCRIPT_DIR}/sync-rooms-brain" "$ROOMS_HOME" >/dev/null 2>&1 &
603
603
  ;;
604
604
 
605
605
  get-active)
@@ -369,10 +369,11 @@ Per Canon Part 10 sub-claim 1, conversation IS the surface; commands are interna
369
369
  context=""
370
370
 
371
371
  if [ -d "$ROOM_DIR" ]; then
372
- # Fire-and-forget room hierarchy graph sync (Phase 59.2, D-16: never blocks)
372
+ # Fire-and-forget local SQLite room hierarchy graph sync (Phase 59.2, D-16: never
373
+ # blocks). The Brain half was removed 2026-09-10 as a Canon Part 8 breach (quick
374
+ # task 260910-h32).
373
375
  ROOMS_HOME="${MINDRIAN_ROOMS_HOME:-$HOME/MindrianRooms}"
374
376
  node "${SCRIPT_DIR}/sync-rooms-graph" "$ROOMS_HOME" >/dev/null 2>&1 &
375
- node "${SCRIPT_DIR}/sync-rooms-brain" "$ROOMS_HOME" >/dev/null 2>&1 &
376
377
 
377
378
  # =============================================================================
378
379
  # CONTEXT INTELLIGENCE: Archetype detection + tiered loading (CTX-01, CTX-02, CTX-05)
@@ -37,7 +37,8 @@ const path = require('path');
37
37
  const crypto = require('crypto');
38
38
 
39
39
  // ---------------------------------------------------------------------------
40
- // Brain client (lazy-loaded, fire-and-forget pattern from sync-rooms-brain)
40
+ // Brain client (lazy-loaded, fire-and-forget pattern, same style as the
41
+ // retired room-hierarchy sync writer removed 2026-09-10, quick task 260910-h32)
41
42
  // ---------------------------------------------------------------------------
42
43
 
43
44
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
@@ -395,9 +395,10 @@ into Larry's voice for the thin-coverage case. Decision #8 already settled the d
395
395
  subsection is the missing wiring, not a new rule.
396
396
 
397
397
  The trigger is a SIGNAL, never a fixed roster of command names. Any brain_* result carrying one
398
- of these shapes fires the clause: an empty `signals` set in the DirectiveEnvelope `brain_ask`
399
- returns (the tool's own description says the envelope "degrades harmlessly to an empty signals
400
- set when the upstream response carries none"); a `normalize_framework_name` result with zero
398
+ of these shapes fires the clause: the `brain_ask` tool's own description now says its
399
+ DirectiveEnvelope's `directive` and `next_gate` are composed from the graph-grounded rows behind
400
+ the answer, and that an empty `grounding.rows` means thin footing, not a clean answer -- an empty
401
+ `grounding.rows` is exactly that signal; a `normalize_framework_name` result with zero
401
402
  canonical matches or with multiple ambiguous ones (the healthy floor is exactly one match); a low
402
403
  `orchestration_readiness` `readiness_score` (the floor is 3). Any low-confidence signal counts,
403
404
  including ones from tools not named here -- this is signal-driven, not a hardcoded list.