@mindrian_os/install 1.13.0-beta.11 → 1.13.0-beta.13

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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +68 -3
  3. package/bin/cli.js +114 -57
  4. package/commands/act.md +16 -2
  5. package/commands/auto-explore.md +1 -0
  6. package/commands/doctor.md +1 -1
  7. package/commands/operator.md +1 -1
  8. package/commands/pipeline.md +16 -1
  9. package/commands/setup.md +7 -3
  10. package/commands/suggest-next.md +17 -3
  11. package/lib/core/active-plugin-root.cjs +207 -0
  12. package/lib/core/brain-client.cjs +451 -36
  13. package/lib/core/cache-prune.cjs +208 -0
  14. package/lib/core/framework-chain-composer.cjs +156 -43
  15. package/lib/core/migrations/phase-109-nodes-provenance.cjs +47 -0
  16. package/lib/core/navigation/memory-events.cjs +17 -1
  17. package/lib/core/navigation/neighborhood.cjs +5 -4
  18. package/lib/core/navigation/packet.cjs +87 -1
  19. package/lib/core/navigation.cjs +6 -0
  20. package/lib/core/resolve-brain-key.cjs +201 -0
  21. package/lib/hmi/jtbd-taxonomy.json +2 -1
  22. package/lib/memory/framework-chain-composer.test.cjs +54 -20
  23. package/lib/memory/navigation-hook-resolver.test.cjs +177 -0
  24. package/lib/memory/run-feynman-tests.cjs +102 -0
  25. package/lib/memory/security-trifecta.test.cjs +23 -6
  26. package/lib/memory/suggest-next-workflow.test.cjs +176 -0
  27. package/lib/memory/workflow-layer-e2e.test.cjs +262 -0
  28. package/lib/workflow/ROOM.md +1 -1
  29. package/package.json +4 -1
  30. package/references/brain/command-triggers-schema.md +10 -221
  31. package/references/methodology/index.md +11 -74
  32. package/skills/brain-connector/SKILL.md +12 -8
  33. package/skills/pws-methodology/SKILL.md +7 -5
@@ -9,12 +9,73 @@
9
9
  // Canon Part 9: builder produces the JS object; Phase 110 wraps with validateAndSendBrainPacket;
10
10
  // Phase 109 honors the privacy: 'no_raw_artifact_text' field by default in constraints.
11
11
 
12
+ const fs = require('node:fs');
12
13
  const path = require('node:path');
13
14
  const crypto = require('node:crypto');
14
15
  const { getNeighborhood } = require('./neighborhood.cjs');
15
16
  const { findContradictions, findUnsupportedClaims, findRelevantOpportunities } = require('./insights.cjs');
16
17
  const { findRecentChanges } = require('./memory-events.cjs');
17
18
 
19
+ // Phase 110-02 (D-03 + D-09): privacy-mode opt-up. local_summary_only is the default and
20
+ // the only mode any of the 12 shipped Brain jobs ever requests (every $def.in.properties.privacy_mode
21
+ // in data/brain-packet-schema.json is { const: 'local_summary_only' }). allow_filenames opts
22
+ // up via .config.json preferences.brain_privacy_mode (project-level) or opts.privacyMode
23
+ // (per-call); allow_excerpts ADDITIONALLY requires a Part-3 Decision Gate APPROVE-with-reason
24
+ // row on the room graph tagged brain_excerpts -- there is NO shipped consumer of allow_excerpts
25
+ // as of v1.13.0-beta.3, so it is a defined-but-unconsumed escape hatch. Config can only CAP
26
+ // the mode, never RAISE what a job sends -- the schema's per-job const enforces this for free
27
+ // in Phase 110-03 sendPacket.
28
+ const PRIVACY_MODES = ['local_summary_only', 'allow_filenames', 'allow_excerpts'];
29
+
30
+ function readRoomConfigPrivacyMode(roomDir) {
31
+ if (!roomDir) return null;
32
+ try {
33
+ const p = path.join(roomDir, '.config.json');
34
+ if (!fs.existsSync(p)) return null;
35
+ const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
36
+ const v = cfg && cfg.preferences && cfg.preferences.brain_privacy_mode;
37
+ return PRIVACY_MODES.includes(v) ? v : null;
38
+ } catch (_) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // The Part-3 Decision Gate APPROVE row (Canon Part 3 + Part 4). The F.0 selector at
44
+ // lib/hmi/shape-f0-renderer.cjs is the canonical render path that, when wired in a future
45
+ // plan, writes the brain_excerpts-tagged APPROVE decision row that this helper looks for.
46
+ // As of v1.13.0-beta.3 there is NO shipped consumer of allow_excerpts -- so this returns
47
+ // false until a Part-3 gate is wired and the user approves. The query is a single guarded
48
+ // SELECT against the local room graph (the same db handle buildBrainPacket already takes);
49
+ // any error or absent row returns false so the resolver caps down safely.
50
+ function roomHasExcerptApproval(db, roomDir) {
51
+ try {
52
+ if (!db) return false;
53
+ const row = db.prepare(
54
+ "SELECT 1 FROM nodes WHERE type IN ('decision','memory_event') " +
55
+ "AND review_status = 'confirmed' AND created_by = 'user' " +
56
+ "AND instr(properties, 'brain_excerpts') > 0 LIMIT 1"
57
+ ).get();
58
+ return !!row;
59
+ } catch (_) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function resolvePrivacyMode(db, roomDir, opts) {
65
+ const options = opts || {};
66
+ const perCall = options.privacyMode;
67
+ const fromConfig = readRoomConfigPrivacyMode(roomDir);
68
+ const requested = (typeof perCall === 'string' && PRIVACY_MODES.includes(perCall))
69
+ ? perCall
70
+ : (fromConfig || 'local_summary_only');
71
+ if (requested === 'allow_excerpts' && !roomHasExcerptApproval(db, roomDir)) {
72
+ // Cap down per "config caps, never raises". If config said allow_filenames, keep it;
73
+ // otherwise drop to local_summary_only.
74
+ return fromConfig === 'allow_filenames' ? 'allow_filenames' : 'local_summary_only';
75
+ }
76
+ return requested;
77
+ }
78
+
18
79
  function loadJtbd(roomDir, mocks) {
19
80
  if (mocks && mocks.jtbd) return mocks.jtbd;
20
81
  try { return require(path.resolve(__dirname, '..', '..', 'hmi', 'jtbd-state.cjs')); } catch (_) { return null; }
@@ -121,6 +182,11 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
121
182
  const options = opts || {};
122
183
  const mocks = options._mocks;
123
184
  const roomId = options.roomId || null;
185
+ const roomDir = options.roomDir || null;
186
+
187
+ // Phase 110-02: resolve the privacy mode BEFORE building the packet body so the resolved
188
+ // value is available on the top-level return object alongside packet_version and origin.
189
+ const privacyMode = resolvePrivacyMode(db, roomDir, options);
124
190
 
125
191
  // Active context. Mocks override the require() calls for hermetic tests.
126
192
  const jtbdMod = loadJtbd(null, mocks);
@@ -155,6 +221,16 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
155
221
  packet_version: '1.0',
156
222
  job,
157
223
  room_stage: getRoomStage(db),
224
+ // Phase 110-02 D-08 layer 1: stamp the navigation-API provenance origin. The schema's
225
+ // $defs.Origin enum constrains this to the closed set; brain-client.sendPacket (Phase
226
+ // 110-03) refuses any other value at wire time. defense-in-depth -- three layers (schema
227
+ // enum + pre-commit hook + sendPacket allowlist), no in-process nonce.
228
+ origin: 'navigation_api',
229
+ // Phase 110-02 D-09: top-level privacy_mode (one of D-03's 3 enum values; default
230
+ // local_summary_only). Separate from constraints.privacy (human-readable note) -- the
231
+ // schema's $defs.PrivacyMode enum + each job's $def.in.properties.privacy_mode const
232
+ // enforces "config caps, never raises" at the wire level in Phase 110-03 sendPacket.
233
+ privacy_mode: privacyMode,
158
234
  active_context: {
159
235
  jtbd: jtbdId,
160
236
  operator,
@@ -179,4 +255,14 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
179
255
  };
180
256
  }
181
257
 
182
- module.exports = { buildBrainPacket, surface_banked_opportunities, shortText };
258
+ module.exports = {
259
+ buildBrainPacket,
260
+ surface_banked_opportunities,
261
+ shortText,
262
+ // Phase 110-02 (D-09): exported so Phase 110-05 round-trip tests, future callers, and
263
+ // brain-client.sendPacket (110-03) can introspect / reuse the resolution order.
264
+ resolvePrivacyMode,
265
+ readRoomConfigPrivacyMode,
266
+ roomHasExcerptApproval,
267
+ PRIVACY_MODES,
268
+ };
@@ -57,4 +57,10 @@ module.exports = {
57
57
 
58
58
  // Truth-state chokepoint (Plan 109-04 LIVE).
59
59
  promoteNodeStatus: transitions.promoteNodeStatus,
60
+
61
+ // Memory-event logging (Phase 110-03 -- a thin re-export so brain-client.cjs can log the
62
+ // brain_packet_rejected / brain_response_rejected / brain_legacy_path_used events without
63
+ // reaching into the internal navigation/memory-events.cjs. The closed navigation surface is
64
+ // the DOCUMENTED 13-function API; the implementation re-exports internal helpers as needed.)
65
+ logMemoryEvent: memoryEvents.logEvent,
60
66
  };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+ /*
3
+ * lib/core/resolve-brain-key.cjs -- the ONE resolver for "where is the Brain
4
+ * API key on this machine?" (Phase 123 Plan-07).
5
+ *
6
+ * Background: before this module, three different places guessed independently
7
+ * how to discover the Brain key (brain-client.cjs::getApiKey, session-start's
8
+ * shell test of MINDRIAN_BRAIN_KEY, brain-connector's skill detection). Three
9
+ * guessers, three failure modes -- a standard install with the key in
10
+ * ~/.mindrian.env was visible to brain-client but invisible to session-start,
11
+ * so a working HTTP-path Brain still printed a Tier-0 MCP warning. This
12
+ * collapses them into one source of truth. Mirrors active-plugin-root.cjs
13
+ * (the resolver this one is patterned on).
14
+ *
15
+ * Precedence (first hit wins) per D-31:
16
+ * 1. MINDRIAN_BRAIN_KEY env var -- explicit operator intent, highest.
17
+ * 2. <home>/.mindrian.env -- global backup, persists across CWDs.
18
+ * 3. <cwd>/.env -- project-local override.
19
+ * 4. not-found.
20
+ *
21
+ * IMPORTANT (FLAG-3 fix). The default for `home` is
22
+ * process.env.HOME || process.env.USERPROFILE || os.homedir()
23
+ * -- NOT bare os.homedir(). On Linux/POSIX, os.homedir() reads /etc/passwd and
24
+ * IGNORES process.env.HOME, which breaks hermetic tests that override HOME to
25
+ * a scratch directory. The env-aware default matches the precedent in
26
+ * scripts/doctor.cjs, scripts/session-start, and lib/core/active-plugin-root.cjs.
27
+ *
28
+ * Returns { key, source, available, reason }:
29
+ * - available=true: key is the trimmed value, reason is null.
30
+ * - available=false: key is null, reason is the explicit cause string
31
+ * (never a silent null -- SEC-02 rejections route here too).
32
+ *
33
+ * SEC-02 permission check (POSIX-only, no-op on Windows). When the resolved
34
+ * source is a key file (~/.mindrian.env or CWD .env), stat the mode. If any
35
+ * group or world bit is set (mode & 0o077), return available:false with the
36
+ * explicit reason "permissions too open: <path> is mode 0NNN, must be 0600".
37
+ *
38
+ * Use as a module:
39
+ * const { resolveBrainKey } = require('<...>/lib/core/resolve-brain-key.cjs');
40
+ * const r = resolveBrainKey(); // r.key, r.source, r.available, r.reason
41
+ *
42
+ * Use as a CLI (so scripts/session-start can shell out without a JSON parser
43
+ * in bash):
44
+ * node lib/core/resolve-brain-key.cjs -> prints JSON, exits 0
45
+ *
46
+ * Canon Part 8: this reads LOCAL files and env only. Zero network surface.
47
+ * The plan's verification grep against this file's source returns nothing
48
+ * -- no network markers in this resolver, ever. The actual Brain call is
49
+ * brain-client.cjs's job; this module only DISCOVERS whether a key exists.
50
+ */
51
+
52
+ const fs = require('node:fs');
53
+ const path = require('node:path');
54
+ const os = require('node:os');
55
+
56
+ /**
57
+ * SEC-02 permission gate. POSIX-only -- on Windows POSIX mode bits do not
58
+ * translate to NTFS ACLs, so we return ok unconditionally there (a single
59
+ * stderr note would couple this resolver to a side-effect; the calling layer
60
+ * is the right place to surface that one-time note).
61
+ *
62
+ * mode & 0o077 !== 0 => any group or world bit set => reject.
63
+ * 0o600 / 0o400 pass; 0o644 / 0o664 / 0o666 fail.
64
+ *
65
+ * On stat failure (file vanished between exists() and stat() -- rare but
66
+ * possible) we treat it as not-ok with an explanatory reason rather than
67
+ * pretending the file is fine.
68
+ *
69
+ * @param {string} p the file path
70
+ * @returns {{ok: boolean, reason: string|null}}
71
+ */
72
+ function checkFilePermissions(p) {
73
+ if (process.platform === 'win32') {
74
+ return { ok: true, reason: null };
75
+ }
76
+ let stat;
77
+ try {
78
+ stat = fs.statSync(p);
79
+ } catch (e) {
80
+ return { ok: false, reason: 'unable to stat ' + p + ': ' + (e && e.code || String(e)) };
81
+ }
82
+ const mode = stat.mode & 0o777;
83
+ if ((mode & 0o077) !== 0) {
84
+ const modeStr = mode.toString(8).padStart(3, '0');
85
+ return {
86
+ ok: false,
87
+ reason: 'permissions too open: ' + p + ' is mode 0' + modeStr + ', must be 0600',
88
+ };
89
+ }
90
+ return { ok: true, reason: null };
91
+ }
92
+
93
+ /**
94
+ * Parse a single MINDRIAN_BRAIN_KEY=... line out of a (possibly multi-line)
95
+ * env-file body. Returns the trimmed value, or null if the line is absent or
96
+ * the value is empty. We deliberately do NOT use a full dotenv parser -- one
97
+ * variable, one regex, no transitive dependency.
98
+ *
99
+ * @param {string} body
100
+ * @returns {string|null}
101
+ */
102
+ function _parseKey(body) {
103
+ const m = body.match(/^MINDRIAN_BRAIN_KEY\s*=\s*(.+?)\s*$/m);
104
+ if (!m) return null;
105
+ const v = m[1].trim();
106
+ return v.length > 0 ? v : null;
107
+ }
108
+
109
+ /**
110
+ * Resolve the Brain API key from the standard precedence chain.
111
+ *
112
+ * The `home` and `cwd` parameters exist as test seams. Their defaults match
113
+ * the precedent across the rest of the plugin (scripts/doctor.cjs, scripts/
114
+ * session-start, lib/core/active-plugin-root.cjs): env-aware home resolution
115
+ * so hermetic tests overriding process.env.HOME actually work on Linux/POSIX,
116
+ * where os.homedir() reads /etc/passwd and ignores the env override.
117
+ *
118
+ * @param {{home?: string, cwd?: string}} [opts]
119
+ * @returns {{key: string|null, source: 'env'|'mindrian-env-file'|'cwd-env-file'|'not-found', available: boolean, reason: string|null}}
120
+ */
121
+ function resolveBrainKey(opts) {
122
+ const o = opts || {};
123
+ const home = o.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
124
+ const cwd = o.cwd || process.cwd();
125
+
126
+ // (1) Env var wins.
127
+ if (process.env.MINDRIAN_BRAIN_KEY) {
128
+ const v = String(process.env.MINDRIAN_BRAIN_KEY).trim();
129
+ if (v.length > 0) {
130
+ return { key: v, source: 'env', available: true, reason: null };
131
+ }
132
+ }
133
+
134
+ // (2) <home>/.mindrian.env
135
+ const mindrianEnvPath = path.join(home, '.mindrian.env');
136
+ if (fs.existsSync(mindrianEnvPath)) {
137
+ const perms = checkFilePermissions(mindrianEnvPath);
138
+ if (!perms.ok) {
139
+ return { key: null, source: 'mindrian-env-file', available: false, reason: perms.reason };
140
+ }
141
+ try {
142
+ const body = fs.readFileSync(mindrianEnvPath, 'utf8');
143
+ const v = _parseKey(body);
144
+ if (v) {
145
+ return { key: v, source: 'mindrian-env-file', available: true, reason: null };
146
+ }
147
+ } catch (e) {
148
+ return {
149
+ key: null,
150
+ source: 'mindrian-env-file',
151
+ available: false,
152
+ reason: 'unable to read ' + mindrianEnvPath + ': ' + (e && e.code || String(e)),
153
+ };
154
+ }
155
+ }
156
+
157
+ // (3) <cwd>/.env
158
+ const cwdEnvPath = path.join(cwd, '.env');
159
+ if (fs.existsSync(cwdEnvPath)) {
160
+ const perms = checkFilePermissions(cwdEnvPath);
161
+ if (!perms.ok) {
162
+ return { key: null, source: 'cwd-env-file', available: false, reason: perms.reason };
163
+ }
164
+ try {
165
+ const body = fs.readFileSync(cwdEnvPath, 'utf8');
166
+ const v = _parseKey(body);
167
+ if (v) {
168
+ return { key: v, source: 'cwd-env-file', available: true, reason: null };
169
+ }
170
+ } catch (e) {
171
+ return {
172
+ key: null,
173
+ source: 'cwd-env-file',
174
+ available: false,
175
+ reason: 'unable to read ' + cwdEnvPath + ': ' + (e && e.code || String(e)),
176
+ };
177
+ }
178
+ }
179
+
180
+ // (4) not-found.
181
+ return {
182
+ key: null,
183
+ source: 'not-found',
184
+ available: false,
185
+ reason: 'MINDRIAN_BRAIN_KEY not set (env), and neither ' + mindrianEnvPath
186
+ + ' nor ' + cwdEnvPath + ' contains a MINDRIAN_BRAIN_KEY line',
187
+ };
188
+ }
189
+
190
+ module.exports = { resolveBrainKey, checkFilePermissions };
191
+
192
+ // CLI entry point. session-start shells out via:
193
+ // node lib/core/resolve-brain-key.cjs
194
+ // and parses the JSON. Exit 0 always (the consumer reads the JSON to learn
195
+ // available/reason); a non-zero exit here would surface as "resolver failed"
196
+ // rather than the clearer not-found / permissions-too-open branches.
197
+ if (require.main === module) {
198
+ const r = resolveBrainKey();
199
+ process.stdout.write(JSON.stringify(r) + '\n');
200
+ process.exit(0);
201
+ }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "canon_parts": [2, "2a", 3, 7],
4
+ "methodology_hooks_note": "informational only; lib/workflow/command-resolver.cjs (reading the generated data/command-registry.json, built from each command's frontmatter -- see docs/COMMAND-FRONTMATTER.md and docs/WORKFLOWS.md) is authoritative for framework-to-command. These hooks are convenience pointers for JTBD-driven routing; they are NOT a source of truth. Larry never names a /mos: from memory.",
4
5
  "entries": [
5
6
  {
6
7
  "id": "decide-pursue",
@@ -16,7 +17,7 @@
16
17
  "methodology_hooks": [
17
18
  "/mos:diagnose",
18
19
  "/mos:build-thesis",
19
- "/mos:value-proposition",
20
+ "/mos:validate-proposition",
20
21
  "/mos:lean-canvas"
21
22
  ],
22
23
  "next_move_verbs": [
@@ -104,6 +104,17 @@ const SAMPLE_4_LINES = [
104
104
  '- Jobs-to-be-Done FEEDS_INTO Value Proposition Canvas (confidence: 0.90, phase: pre-opportunity)',
105
105
  ].join('\n');
106
106
 
107
+ // Phase 122-04: a chain whose `next` framework IS registered in
108
+ // data/command-registry.json, so proposeNextFramework returns a non-null
109
+ // /mos: command (Tests 16 + 18 need the engine's offer_next_step.command
110
+ // to be a real command string -- only the resolver-registered frameworks
111
+ // yield one; unregistered ones degrade to null per reliability rule 5).
112
+ // 'Business Model Canvas' is in KNOWN_FRAMEWORKS (detectable from a
113
+ // governing thought); 'Lean Canvas' resolves to /mos:lean-canvas.
114
+ const SAMPLE_REGISTERED_CHAIN = [
115
+ '- Business Model Canvas FEEDS_INTO Lean Canvas (confidence: 0.85, phase: thesis-build)',
116
+ ].join('\n');
117
+
107
118
  // =========================================================
108
119
  // Task 1 -- parser + completion detection + proposal (Tests 1..15)
109
120
  // =========================================================
@@ -297,25 +308,43 @@ run('Test 11: proposeNextFramework tie-breaking by confidence desc', () => {
297
308
  assert.equal(Math.abs(out.confidence - 0.85) < 1e-9, true);
298
309
  });
299
310
 
300
- run('Test 12: proposeNextFramework /mos: command mapping', () => {
311
+ run('Test 12: proposeNextFramework command resolution via the resolver (Phase 122-04: degrade to null, not fabricate)', () => {
301
312
  const { proposeNextFramework } = requireComposer();
302
- // 'Lean Canvas' has an existing /mos:lean-canvas command.
313
+ const resolver = require('../workflow/command-resolver.cjs');
314
+ // 'Lean Canvas' has an existing /mos:lean-canvas command in the registry.
303
315
  const knownEdges = [
304
316
  { from: 'Business Model Canvas', to: 'Lean Canvas', confidence: 0.82, phase_indicator: 'thesis' },
305
317
  ];
306
318
  const known = proposeNextFramework('Business Model Canvas', knownEdges);
307
319
  assert.equal(known !== null, true);
308
- assert.equal(typeof known.command, 'string');
309
- assert.equal(known.command.indexOf('/mos:') === 0, true,
310
- 'command must start with /mos:; got: ' + known.command);
311
- // Unknown framework name falls back to /mos:beautiful-question or
312
- // a slugified default. Either way the command MUST start with /mos:.
320
+ // Phase 122-04: command comes from the resolver (commandsForFramework[0]).
321
+ const expected = resolver.commandsForFramework('Lean Canvas');
322
+ assert.equal(known.command, expected.length > 0 ? expected[0] : null,
323
+ 'command must equal commandsForFramework(next)[0] (or null); got: ' + known.command);
324
+ assert.equal(typeof known.command === 'string' && known.command.indexOf('/mos:') === 0, true,
325
+ 'Lean Canvas is registered, so command must be a /mos: string; got: ' + known.command);
326
+ // A workflow array (the resolver's composeWorkflow for the chain) is on
327
+ // the proposal as data -- a list of { step, framework, command|null, optional }.
328
+ assert.equal(Array.isArray(known.workflow), true, 'workflow must be a composeWorkflow array');
329
+ assert.equal(known.workflow.length >= 2, true, 'workflow includes [completed, next, ...]');
330
+ assert.equal(known.workflow[0].step, 1);
331
+ for (let i = 0; i < known.workflow.length; i += 1) {
332
+ const s = known.workflow[i];
333
+ assert.equal(s.step, i + 1);
334
+ assert.equal('command' in s && 'optional' in s && 'framework' in s, true);
335
+ assert.equal(s.command === null || (typeof s.command === 'string' && s.command.indexOf('/mos:') === 0), true);
336
+ }
337
+ // A framework name the registry does not know yet -> command is null
338
+ // (degrade, do not fabricate -- WORKFLOW-LAYER-SPEC reliability rule 5).
313
339
  const unknownEdges = [
314
340
  { from: 'X', to: 'A Wholly Imaginary Framework', confidence: 0.8, phase_indicator: null },
315
341
  ];
316
342
  const unknown = proposeNextFramework('X', unknownEdges);
317
343
  assert.equal(unknown !== null, true);
318
- assert.equal(unknown.command.indexOf('/mos:') === 0, true);
344
+ assert.equal(unknown.command, null, 'command must be null for an unregistered framework; got: ' + unknown.command);
345
+ assert.equal(Array.isArray(unknown.workflow), true);
346
+ assert.equal(unknown.workflow.every(function (s) { return s.command === null; }), true,
347
+ 'an unregistered chain composes to all-null commands (run manually)');
319
348
  });
320
349
 
321
350
  run('Test 13: proposeNextFramework grounding rule (FEEDS_INTO + Brain + confidence in reason)', () => {
@@ -408,9 +437,13 @@ function makeQuadrupleWithChain(chainBody, governingThought) {
408
437
 
409
438
  run('Test 16: decide() with chain + governing_thought -> offer_next_step with FEEDS_INTO reason', () => {
410
439
  const { decide } = requireEngine();
440
+ // Phase 122-04: use a chain whose `next` framework is registered, so the
441
+ // resolver yields a real /mos: command (an unregistered `next` would
442
+ // degrade to command:null -- a true statement, but the presenter then
443
+ // treats it as not-an-offer; this test wants the command-carrying path).
411
444
  const quadruple = makeQuadrupleWithChain(
412
- SAMPLE_4_LINES,
413
- 'After our SWOT Analysis we found two key threats and one opportunity.'
445
+ SAMPLE_REGISTERED_CHAIN,
446
+ 'After our Business Model Canvas work we mapped the value flows.'
414
447
  );
415
448
  const out = decide(
416
449
  { sectionPath: '/tmp/fixture', sessionId: 's1' },
@@ -421,11 +454,11 @@ run('Test 16: decide() with chain + governing_thought -> offer_next_step with FE
421
454
  intentSignal: null,
422
455
  }
423
456
  );
424
- // Engine should compose a chain offer: SWOT -> Porter (the highest-conf
425
- // outgoing edge from SWOT in SAMPLE_4_LINES).
457
+ // Engine should compose a chain offer: Business Model Canvas -> Lean Canvas.
426
458
  assert.equal(out.offer_next_step !== null, true,
427
459
  'expected non-null offer_next_step; trace: ' + (out.decision_trace.chosen_rationale || ''));
428
- assert.equal(typeof out.offer_next_step.command, 'string');
460
+ assert.equal(typeof out.offer_next_step.command, 'string',
461
+ 'offer command must be a string (Lean Canvas is registered); got: ' + out.offer_next_step.command);
429
462
  assert.equal(out.offer_next_step.command.indexOf('/mos:') === 0, true);
430
463
  assert.equal(/FEEDS_INTO/.test(out.offer_next_step.reason), true,
431
464
  'offer reason must reference FEEDS_INTO; got: ' + out.offer_next_step.reason);
@@ -460,13 +493,12 @@ run('Test 17: decide() Mode A confidence 0.85 chain -> recommended_eligible flag
460
493
 
461
494
  run('Test 18: decide() user override path: turn 1 offer; turn 2 different command -> REJECTED chain trace', () => {
462
495
  const { decide } = requireEngine();
463
- // Turn 1: chain proposes /mos:porter-five-forces (or similar slug from
464
- // Porter Five Forces). Engine writes trace.brain_patterns including
465
- // category 'framework_chain_proposal' OR sets offer_next_step with
466
- // chain context.
496
+ // Turn 1: chain proposes /mos:lean-canvas (Business Model Canvas FEEDS_INTO
497
+ // Lean Canvas; Lean Canvas is resolver-registered so the command is real).
498
+ // Engine sets offer_next_step with the chain command + reason.
467
499
  const turn1Quad = makeQuadrupleWithChain(
468
- SAMPLE_4_LINES,
469
- 'After our SWOT Analysis we identified threats.'
500
+ SAMPLE_REGISTERED_CHAIN,
501
+ 'After our Business Model Canvas work we mapped the value flows.'
470
502
  );
471
503
  const turn1 = decide(
472
504
  { sectionPath: '/tmp/fixture', sessionId: 's1', userText: '' },
@@ -480,6 +512,8 @@ run('Test 18: decide() user override path: turn 1 offer; turn 2 different comman
480
512
  assert.equal(turn1.offer_next_step !== null, true,
481
513
  'turn 1 must produce a chain offer; trace: ' + turn1.decision_trace.chosen_rationale);
482
514
  const turn1Command = turn1.offer_next_step.command;
515
+ assert.equal(typeof turn1Command === 'string' && turn1Command.length > 0, true,
516
+ 'turn 1 chain command must be a real /mos: string; got: ' + turn1Command);
483
517
 
484
518
  // Turn 2: user invokes a DIFFERENT /mos: command. Engine should record
485
519
  // this as REJECTED chain suggestion in decision_trace.
@@ -487,7 +521,7 @@ run('Test 18: decide() user override path: turn 1 offer; turn 2 different comman
487
521
  {
488
522
  sectionPath: '/tmp/fixture',
489
523
  sessionId: 's1',
490
- userText: '/mos:lean-canvas help me with my lean canvas instead',
524
+ userText: '/mos:mullins run the 7-domains screen instead',
491
525
  },
492
526
  {
493
527
  quadruple: turn1Quad,