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

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.
@@ -12,6 +12,31 @@
12
12
  * an engine.offer_next_step candidate consumed by the navigation engine
13
13
  * (Plan 91-00) and rendered through the offer presenter (Plan 91-04).
14
14
  *
15
+ * Phase 122-04 -- routed through the resolver (the only door)
16
+ * ==========================================================
17
+ * proposeNextFramework() now resolves the next framework's /mos: command
18
+ * via lib/workflow/command-resolver.cjs commandsForFramework() -- the SOLE
19
+ * deterministic framework -> command path, reading only the generated
20
+ * data/command-registry.json. When the registry has no command for that
21
+ * framework, command degrades to null ("no /mos: for [framework] yet" --
22
+ * degrade, not fabricate per WORKFLOW-LAYER-SPEC reliability rule 5); the
23
+ * offer presenter already treats a null/empty command as not-an-offer.
24
+ * proposeNextFramework also returns a workflow field -- the resolver's
25
+ * composeWorkflow([completed, next, ...successors]) array -- so a multi-hop
26
+ * FEEDS_INTO chain is available as data on the proposal (a future plan can
27
+ * carry it into offer_next_step / shape-f1-renderer; this plan only puts
28
+ * the data there). mapFrameworkToCommandSlug() relies solely on the
29
+ * resolver (then FALLBACK_COMMAND_SLUG) so any remaining caller also gets
30
+ * the resolver answer.
31
+ *
32
+ * Phase 122-05 -- the residual map pruned
33
+ * =======================================
34
+ * FRAMEWORK_TO_COMMAND_SLUG is now Object.freeze({}) -- the resolver is the
35
+ * ONLY framework-to-command door (data/command-registry.json, generated from
36
+ * frontmatter; WORKFLOW-LAYER-SPEC reliability rule 1). The empty table is
37
+ * kept only as a back-compat export. KNOWN_FRAMEWORKS stays exported as a
38
+ * name-recognition bootstrap (it is NOT the framework-to-command source).
39
+ *
15
40
  * Canon Part 2 Engine 1 + Appendix E:
16
41
  * Framework chains power Act 1 -> BONO Orchestration handoffs.
17
42
  * FEEDS_INTO is the Brain-flagged graph infrastructure (~40 edges in
@@ -49,6 +74,11 @@
49
74
  const fs = require('node:fs');
50
75
  const path = require('node:path');
51
76
 
77
+ // The resolver (the only door). Required lazily inside the functions that
78
+ // need it so a missing module never crashes a caller that does not use the
79
+ // resolver path -- but it is an in-repo sibling so this never fails in
80
+ // practice. Reads only data/command-registry.json; never touches the Brain.
81
+
52
82
  // ---------- Frozen constants ----------
53
83
 
54
84
  // Confidence gates per locked decision in PLAN frontmatter:
@@ -63,12 +93,14 @@ const RECOMMENDED_FLOOR = 0.7;
63
93
  // plans may layer richer signal on top).
64
94
  const RECENT_WRITE_WINDOW_MS = 5 * 60 * 1000;
65
95
 
66
- // Bootstrap KNOWN_FRAMEWORKS list. Order is preservation-friendly with
67
- // existing /mos:* commands. The list is conservative; Brain-derived
68
- // FEEDS_INTO edges may reference frameworks outside this set (in which
69
- // case completion detection falls through to the mtime slug fallback,
70
- // and command mapping falls through to /mos:beautiful-question). The
71
- // list is extensible; future plans may pull from the Brain frameworks
96
+ // Bootstrap KNOWN_FRAMEWORKS list. NAME-RECOGNITION BOOTSTRAP ONLY -- this is
97
+ // NOT the framework-to-command source (that is OWNED by lib/workflow/
98
+ // command-resolver.cjs, reading the generated data/command-registry.json).
99
+ // detectCompletedFramework() uses this list to recognize a framework name in a
100
+ // governing thought / a filed-artifact slug; the list is conservative; a
101
+ // Brain-derived FEEDS_INTO edge may reference a framework outside this set, in
102
+ // which case completion detection falls through to the mtime slug fallback.
103
+ // The list is extensible; future plans may pull from the Brain frameworks
72
104
  // catalog directly.
73
105
  const KNOWN_FRAMEWORKS = Object.freeze([
74
106
  'SWOT Analysis',
@@ -91,31 +123,14 @@ const KNOWN_FRAMEWORKS = Object.freeze([
91
123
  'Rich Pictures',
92
124
  ]);
93
125
 
94
- // Map known framework names to canonical /mos: command slugs. This is a
95
- // best-effort table; unknown frameworks fall back to the
96
- // /mos:beautiful-question guide command per plan locked decisions.
97
- // The map is intentionally case-insensitive at lookup time so a Brain
98
- // edge "Lean Canvas" or "lean canvas" resolves identically.
99
- const FRAMEWORK_TO_COMMAND_SLUG = Object.freeze({
100
- 'swot analysis': 'beautiful-question', // No dedicated /mos:swot today; bypass to BQ guide.
101
- 'porter five forces': 'beautiful-question',
102
- 'value chain analysis': 'beautiful-question',
103
- 'business model canvas': 'beautiful-question',
104
- 'lean canvas': 'lean-canvas',
105
- 'jobs-to-be-done': 'beautiful-question',
106
- 'value proposition canvas': 'beautiful-question',
107
- '5 whys': 'beautiful-question',
108
- 'first principles': 'beautiful-question',
109
- 'design thinking': 'beautiful-question',
110
- 'blue ocean strategy': 'beautiful-question',
111
- "innovator's dilemma": 'beautiful-question',
112
- '7 s framework': 'beautiful-question',
113
- 'balanced scorecard': 'beautiful-question',
114
- 'mullins': 'mullins',
115
- 'beautiful question': 'beautiful-question',
116
- 'soft systems': 'beautiful-question',
117
- 'rich pictures': 'beautiful-question',
118
- });
126
+ // Framework-to-command mapping is OWNED by lib/workflow/command-resolver.cjs
127
+ // (the generated data/command-registry.json, built from each command's
128
+ // frontmatter -- WORKFLOW-LAYER-SPEC reliability rule 1: a single source of
129
+ // truth, nothing else asserts the mapping). Phase 122-05 pruned this table to
130
+ // an empty Object.freeze({}); it is kept ONLY as an empty back-compat export
131
+ // so any caller that still imports FRAMEWORK_TO_COMMAND_SLUG does not crash.
132
+ // Do NOT add entries here -- declare `frameworks:` in the command frontmatter.
133
+ const FRAMEWORK_TO_COMMAND_SLUG = Object.freeze({});
119
134
 
120
135
  // Default fallback command when the next framework has no known mapping.
121
136
  const FALLBACK_COMMAND_SLUG = 'beautiful-question';
@@ -304,7 +319,12 @@ function detectCompletedFramework(roomDir, sectionPath, reasoning) {
304
319
  * confidence: number,
305
320
  * source: 'FEEDS_INTO',
306
321
  * phase_indicator: string|null,
307
- * command: string, // '/mos:<slug>'
322
+ * command: string|null, // '/mos:<slug>' from the resolver, or null
323
+ * // when the registry has no command for
324
+ * // `next` yet (degrade, do not fabricate)
325
+ * workflow: Array|null, // resolver.composeWorkflow([completed, next, ...])
326
+ * // -- the multi-hop chain as data; the engine
327
+ * // does not propagate it yet (future plan)
308
328
  * reason: string, // grounding text (FEEDS_INTO + Brain + confidence)
309
329
  * recommended_eligible: boolean, // true when confidence >= 0.7
310
330
  * }
@@ -353,12 +373,44 @@ function proposeNextFramework(completedFramework, edges) {
353
373
  // the noise gate (we cannot certify a confidenceless edge).
354
374
  if (conf === null || conf < NOISE_FLOOR) return null;
355
375
 
356
- // Map next to /mos: command.
357
- const slug = mapFrameworkToCommandSlug(top.to);
358
- const command = '/mos:' + slug;
376
+ // Resolve next -> /mos: command via the resolver (the only door). The
377
+ // resolver reads only data/command-registry.json; it never touches the
378
+ // Brain. When the registry has no command for `next`, command degrades
379
+ // to null -- "no /mos: for [framework] yet" -- never a fabricated one
380
+ // (WORKFLOW-LAYER-SPEC reliability rule 5). The offer presenter already
381
+ // treats a null/empty command as not-an-offer.
382
+ let resolver = null;
383
+ try {
384
+ resolver = require('../workflow/command-resolver.cjs');
385
+ } catch (_e) {
386
+ resolver = null;
387
+ }
388
+ let command = null;
389
+ let workflow = null;
390
+ if (resolver) {
391
+ try {
392
+ const cmds = resolver.commandsForFramework(top.to);
393
+ command = (Array.isArray(cmds) && cmds.length > 0) ? cmds[0] : null;
394
+ } catch (_e) {
395
+ command = null;
396
+ }
397
+ // Multi-step path: build the resolver's composeWorkflow for the chain
398
+ // [completedFramework, next, ...further FEEDS_INTO successors up to ~3].
399
+ // This puts the multi-hop chain on the proposal as data; the navigation
400
+ // engine does not propagate `workflow` into offer_next_step in this plan
401
+ // (the presenter / shape-f1-renderer wiring is a future plan's job).
402
+ try {
403
+ const chain = collectForwardChain(completedFramework, edges, 3);
404
+ workflow = resolver.composeWorkflow(chain);
405
+ } catch (_e) {
406
+ workflow = null;
407
+ }
408
+ }
359
409
 
360
410
  // Grounding-rule reason: must contain FEEDS_INTO + Brain + the
361
- // confidence number per Plan 91-04 presenter contract + Test 13.
411
+ // confidence number per Plan 91-04 presenter contract + Test 13. When
412
+ // there is no command for `next`, the reason still names the framework
413
+ // (the consumer prints "run [framework] manually" rather than a command).
362
414
  const confStr = conf.toFixed(2);
363
415
  const reason =
364
416
  completedFramework + ' FEEDS_INTO ' + top.to +
@@ -370,26 +422,81 @@ function proposeNextFramework(completedFramework, edges) {
370
422
  source: 'FEEDS_INTO',
371
423
  phase_indicator: (typeof top.phase_indicator === 'string') ? top.phase_indicator : null,
372
424
  command: command,
425
+ workflow: workflow,
373
426
  reason: reason,
374
427
  recommended_eligible: conf >= RECOMMENDED_FLOOR,
375
428
  };
376
429
  }
377
430
 
431
+ /**
432
+ * collectForwardChain(start, edges, maxHops) -> [start, next, ...]
433
+ *
434
+ * Walks the highest-confidence FEEDS_INTO edge from `start`, then from that
435
+ * successor, etc., up to `maxHops` hops. Cycle-safe (a framework already in
436
+ * the chain stops the walk). Only follows edges that pass the noise floor.
437
+ * Returns at least [start]. Pure: no I/O.
438
+ *
439
+ * @param {string} start
440
+ * @param {Array} edges
441
+ * @param {number} maxHops
442
+ * @returns {string[]}
443
+ */
444
+ function collectForwardChain(start, edges, maxHops) {
445
+ const chain = [start];
446
+ if (!Array.isArray(edges) || edges.length === 0) return chain;
447
+ const hops = (typeof maxHops === 'number' && maxHops > 0) ? maxHops : 3;
448
+ const seen = new Set([String(start).toLowerCase()]);
449
+ let current = start;
450
+ for (let i = 0; i < hops; i += 1) {
451
+ const cur = String(current).toLowerCase();
452
+ let best = null;
453
+ for (const e of edges) {
454
+ if (!e || typeof e !== 'object') continue;
455
+ if (typeof e.from !== 'string' || typeof e.to !== 'string') continue;
456
+ if (e.from.toLowerCase() !== cur) continue;
457
+ const c = (typeof e.confidence === 'number') ? e.confidence : null;
458
+ if (c === null || c < NOISE_FLOOR) continue;
459
+ if (best === null || c > best.confidence) best = { to: e.to, confidence: c };
460
+ }
461
+ if (best === null) break;
462
+ const nextLc = best.to.toLowerCase();
463
+ if (seen.has(nextLc)) break;
464
+ chain.push(best.to);
465
+ seen.add(nextLc);
466
+ current = best.to;
467
+ }
468
+ return chain;
469
+ }
470
+
378
471
  /**
379
472
  * mapFrameworkToCommandSlug(name) -> slug
380
473
  *
381
- * Maps a framework name (in any case) to a /mos: command slug via the
382
- * frozen FRAMEWORK_TO_COMMAND_SLUG table. Falls back to FALLBACK_COMMAND_
383
- * SLUG ('beautiful-question') when no entry matches. Pure: no I/O.
474
+ * Maps a framework name (in any case) to a /mos: command slug via the resolver
475
+ * (lib/workflow/command-resolver.cjs commandsForFramework -- the ONLY door,
476
+ * reads only data/command-registry.json, never touches the Brain), falling
477
+ * back to FALLBACK_COMMAND_SLUG ('beautiful-question') when the registry has no
478
+ * command for `name` or the resolver is unavailable. Phase 122-05 removed the
479
+ * legacy in-module table (FRAMEWORK_TO_COMMAND_SLUG is now empty) -- the
480
+ * resolver is authoritative.
481
+ *
482
+ * Note: proposeNextFramework() does NOT use this helper -- it calls
483
+ * commandsForFramework() directly so it can degrade to command:null (the
484
+ * helper keeps a non-null fallback for back-compat with callers that expect
485
+ * a slug string).
384
486
  *
385
487
  * @param {string} name
386
488
  * @returns {string}
387
489
  */
388
490
  function mapFrameworkToCommandSlug(name) {
389
491
  if (typeof name !== 'string' || name.length === 0) return FALLBACK_COMMAND_SLUG;
390
- const lc = name.toLowerCase();
391
- if (Object.prototype.hasOwnProperty.call(FRAMEWORK_TO_COMMAND_SLUG, lc)) {
392
- return FRAMEWORK_TO_COMMAND_SLUG[lc];
492
+ // Resolver -- the only door.
493
+ try {
494
+ const cmds = require('../workflow/command-resolver.cjs').commandsForFramework(name);
495
+ if (Array.isArray(cmds) && cmds.length > 0) {
496
+ return cmds[0].replace(/^\/mos:/, '');
497
+ }
498
+ } catch (_e) {
499
+ // resolver unavailable -> fall through to the fallback slug
393
500
  }
394
501
  return FALLBACK_COMMAND_SLUG;
395
502
  }
@@ -400,7 +507,13 @@ module.exports = {
400
507
  parseFrameworkChainSection: parseFrameworkChainSection,
401
508
  detectCompletedFramework: detectCompletedFramework,
402
509
  proposeNextFramework: proposeNextFramework,
403
- // Frozen tables exposed for downstream introspection + test invariants.
510
+ collectForwardChain: collectForwardChain,
511
+ // Exported for back-compat: relies solely on the resolver, then the
512
+ // fallback slug. Phase 122-05 removed the legacy in-module table.
513
+ mapFrameworkToCommandSlug: mapFrameworkToCommandSlug,
514
+ // KNOWN_FRAMEWORKS is a name-recognition bootstrap (NOT the framework-to-
515
+ // command source). FRAMEWORK_TO_COMMAND_SLUG is an EMPTY back-compat export
516
+ // (Phase 122-05) -- the resolver (data/command-registry.json) is the only door.
404
517
  KNOWN_FRAMEWORKS: KNOWN_FRAMEWORKS,
405
518
  FRAMEWORK_TO_COMMAND_SLUG: FRAMEWORK_TO_COMMAND_SLUG,
406
519
  // Constants exposed for invariant tests + downstream callers.
@@ -242,6 +242,31 @@ function backfillAssumptionsAsGraphNodes(db) {
242
242
  return db.prepare("SELECT COUNT(*) AS n FROM assumptions").get().n;
243
243
  }
244
244
 
245
+ function dependentSchemaObjects(db) {
246
+ // Enumerate every view and trigger that mentions the legacy `nodes` table in
247
+ // its definition. The SQLite "making other kinds of table schema changes"
248
+ // recipe (the canonical 12-step procedure) requires these to be dropped
249
+ // BEFORE the rename-out-of-existence rebuild and recreated AFTER -- otherwise
250
+ // SQLite re-validates the schema during ALTER TABLE ... RENAME TO, finds the
251
+ // now-dangling view, and throws "error in view <name>: no such table:
252
+ // main.nodes". We do not hardcode rs_discoveries; any future view/trigger on
253
+ // `nodes` is picked up automatically. Drop-then-recreate is idempotent: views
254
+ // whose sql carries IF NOT EXISTS re-exec cleanly; ones without it are simply
255
+ // recreated fresh since we dropped them first.
256
+ const rows = db.prepare(
257
+ "SELECT type, name, sql FROM sqlite_master " +
258
+ "WHERE type IN ('view','trigger') AND sql IS NOT NULL " +
259
+ // \bnodes\b style match: the token "nodes" not immediately followed by an
260
+ // identifier char (so we do not mistakenly catch nodes_new). SQLite LIKE
261
+ // has no word boundaries, so over-match a little and trust the recreate to
262
+ // be a no-op for anything unrelated -- but exclude the obvious nodes_new.
263
+ "AND sql LIKE '%nodes%' AND sql NOT LIKE '%nodes_new%'"
264
+ ).all();
265
+ // Defensive: drop NULL/empty sql rows (autogenerated indexes never appear
266
+ // here because we filtered type, but be safe).
267
+ return rows.filter((r) => r && r.name && typeof r.sql === 'string' && r.sql.trim());
268
+ }
269
+
245
270
  function tightenSchemaWithCheckConstraints(db) {
246
271
  // Step 2: re-create-table-with-NOT-NULL plus CHECK constraints.
247
272
  // Canonical SQLite 12-step recipe (foreign_keys disabled for the duration;
@@ -251,6 +276,19 @@ function tightenSchemaWithCheckConstraints(db) {
251
276
  // do not flip it here; the BEGIN/COMMIT wrapper guarantees atomicity, and
252
277
  // FK behavior is unchanged because the only FK targeting nodes is from edges
253
278
  // which we do not drop.
279
+
280
+ // Step 2a: capture and drop every view/trigger that depends on `nodes`. Must
281
+ // happen before DROP TABLE nodes so the schema stays internally consistent
282
+ // through the rename. Recreated verbatim at the end of this function.
283
+ const dependents = dependentSchemaObjects(db);
284
+ for (const obj of dependents) {
285
+ if (obj.type === 'view') {
286
+ db.exec('DROP VIEW IF EXISTS "' + obj.name.replace(/"/g, '""') + '"');
287
+ } else {
288
+ db.exec('DROP TRIGGER IF EXISTS "' + obj.name.replace(/"/g, '""') + '"');
289
+ }
290
+ }
291
+
254
292
  db.exec(
255
293
  "CREATE TABLE nodes_new (" +
256
294
  " id TEXT PRIMARY KEY, " +
@@ -291,6 +329,15 @@ function tightenSchemaWithCheckConstraints(db) {
291
329
  'CREATE INDEX IF NOT EXISTS idx_nodes_confirmed_by ON nodes(confirmed_by) ' +
292
330
  'WHERE confirmed_by IS NOT NULL'
293
331
  );
332
+
333
+ // Step 2b: recreate the views/triggers we dropped in Step 2a, now that
334
+ // `nodes` exists again with the tightened schema. The captured `sql` is the
335
+ // exact CREATE statement from sqlite_master; many carry IF NOT EXISTS which
336
+ // keeps the recreate idempotent, and any that do not were dropped above so
337
+ // re-exec is still safe.
338
+ for (const obj of dependents) {
339
+ db.exec(obj.sql);
340
+ }
294
341
  }
295
342
 
296
343
  function insertSentinel(db) {
@@ -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,