@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
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+ /*
3
+ * lib/core/cache-prune.cjs -- prune stale marketplace-cache version dirs.
4
+ *
5
+ * Phase 123 Plan-05 (HARNESS-123-13). Canon Part 8: this reads LOCAL files
6
+ * only (~/.claude/plugins/) and writes LOCAL files only (fs.rmSync on
7
+ * stale version dirs). Zero network surface.
8
+ *
9
+ * Background: Claude Code's marketplace install path is
10
+ * ~/.claude/plugins/cache/<marketplace>/mos/<version>/
11
+ * where each <version> subdir is a self-contained install of that
12
+ * version. On `claude plugin update`, the new version is downloaded
13
+ * into a new <version> dir, but the old <version> dir is NEVER removed.
14
+ * The Windows live test of v1.13.0-beta.12 surfaced two orphans on disk
15
+ * (`1.12.0/`, `1.13.0-beta.9/`) alongside the active `1.13.0-beta.12/`.
16
+ *
17
+ * This helper prunes the cache, keying off Claude Code's own
18
+ * installed_plugins.json (the source of truth for "which version is active"):
19
+ *
20
+ * - active version (from installed_plugins.json's mos@mindrian-marketplace
21
+ * entry) is ALWAYS kept, regardless of mtime.
22
+ * - the N most recent non-active versions (default N=2, sorted by mtime
23
+ * DESC) are also kept.
24
+ * - everything else is removed.
25
+ *
26
+ * Belt + suspenders: before every fs.rmSync call, the path's basename is
27
+ * cross-checked against the active version string -- if it matches, the
28
+ * removal is SKIPPED with a logged reason (defends against a malformed
29
+ * installed_plugins.json that somehow lists two different basenames as
30
+ * "active", or a race between this function and a concurrent install).
31
+ *
32
+ * If installed_plugins.json is unreadable (ENOENT, parse error, or no
33
+ * mos@mindrian-marketplace entry), the function returns
34
+ * { kept: [], removed: [], skipped: true, reason: '...' }
35
+ * with NO mutation. Never guesses; never deletes anything in the absence
36
+ * of an authoritative active-version answer.
37
+ *
38
+ * Signature:
39
+ * pruneMarketplaceCache({
40
+ * home = os.homedir(),
41
+ * marketplace = 'mindrian-marketplace',
42
+ * retainCount = 2,
43
+ * dryRun = false,
44
+ * } = {}) => {
45
+ * kept: string[], // version basenames kept on disk (incl. active)
46
+ * removed: string[], // version basenames removed (or would be, in dryRun)
47
+ * skipped: boolean, // true if we declined to act (see `reason`)
48
+ * reason: string|null // human-readable explanation, or null on success
49
+ * }
50
+ *
51
+ * Canon Part 8 sanity check (cp.6): the forbidden-network-token grep
52
+ * exits 1 (no match) on this source file. Test cp.6 enforces it.
53
+ */
54
+
55
+ const fs = require('node:fs');
56
+ const path = require('node:path');
57
+ const os = require('node:os');
58
+
59
+ // Read installed_plugins.json and pull the mos@mindrian-marketplace active
60
+ // version. Returns { activeVersion, activeInstallPath } on success, or
61
+ // { skipped: true, reason } on any failure.
62
+ function readActiveVersion(home) {
63
+ const installedPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
64
+ let raw;
65
+ try {
66
+ raw = fs.readFileSync(installedPath, 'utf8');
67
+ } catch (e) {
68
+ return { skipped: true, reason: 'installed_plugins.json unreadable (' + (e && e.code || e.message) + ') -- skipping prune' };
69
+ }
70
+ let ip;
71
+ try {
72
+ ip = JSON.parse(raw);
73
+ } catch (e) {
74
+ return { skipped: true, reason: 'installed_plugins.json parse error -- skipping prune' };
75
+ }
76
+ if (!ip || typeof ip !== 'object' || !ip.plugins || typeof ip.plugins !== 'object') {
77
+ return { skipped: true, reason: 'installed_plugins.json has no plugins map -- skipping prune' };
78
+ }
79
+ // Accept either key shape: 'mos@mindrian-marketplace' or 'mos'.
80
+ let entry = ip.plugins['mos@mindrian-marketplace'];
81
+ if (!entry) {
82
+ // Defensive: find any key whose left-of-@ is mos or mindrian-os.
83
+ const keys = Object.keys(ip.plugins);
84
+ for (const k of keys) {
85
+ const name = String(k).split('@')[0];
86
+ if (name === 'mos' || name === 'mindrian-os') { entry = ip.plugins[k]; break; }
87
+ }
88
+ }
89
+ if (!entry) {
90
+ return { skipped: true, reason: 'no active mos@mindrian-marketplace entry in installed_plugins.json -- skipping prune' };
91
+ }
92
+ if (Array.isArray(entry)) entry = entry[0];
93
+ if (!entry || !entry.version) {
94
+ return { skipped: true, reason: 'mos@mindrian-marketplace entry has no version field -- skipping prune' };
95
+ }
96
+ return {
97
+ activeVersion: String(entry.version),
98
+ activeInstallPath: entry.installPath || entry.path || entry.dir || null,
99
+ };
100
+ }
101
+
102
+ // List child directory names under <home>/.claude/plugins/cache/<marketplace>/mos/.
103
+ // Returns [] if the cache dir does not exist (no-op case).
104
+ function listCacheVersions(home, marketplace) {
105
+ const cacheDir = path.join(home, '.claude', 'plugins', 'cache', marketplace, 'mos');
106
+ if (!fs.existsSync(cacheDir)) return { cacheDir, names: [] };
107
+ let names = [];
108
+ try {
109
+ names = fs.readdirSync(cacheDir).filter(function (name) {
110
+ try { return fs.statSync(path.join(cacheDir, name)).isDirectory(); }
111
+ catch (_) { return false; }
112
+ });
113
+ } catch (_) { /* unreadable -- treat as empty */ }
114
+ return { cacheDir, names };
115
+ }
116
+
117
+ // Sort version names by mtime DESC (newest first). Falls back to lexicographic
118
+ // order if statSync fails on any path (defensive).
119
+ function sortByMtimeDesc(cacheDir, names) {
120
+ const stats = names.map(function (name) {
121
+ let mtimeMs = 0;
122
+ try { mtimeMs = fs.statSync(path.join(cacheDir, name)).mtimeMs || 0; }
123
+ catch (_) { mtimeMs = 0; }
124
+ return { name, mtimeMs };
125
+ });
126
+ stats.sort(function (a, b) {
127
+ if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
128
+ return a.name < b.name ? 1 : -1; // tie-break lexicographic DESC (stable)
129
+ });
130
+ return stats.map(function (s) { return s.name; });
131
+ }
132
+
133
+ function pruneMarketplaceCache(opts) {
134
+ const o = opts || {};
135
+ const home = o.home || os.homedir();
136
+ const marketplace = o.marketplace || 'mindrian-marketplace';
137
+ const retainCount = (typeof o.retainCount === 'number' && o.retainCount >= 0) ? o.retainCount : 2;
138
+ const dryRun = !!o.dryRun;
139
+
140
+ // Step 1: read active version. Skip entirely if unavailable -- never guess.
141
+ const active = readActiveVersion(home);
142
+ if (active.skipped) {
143
+ return { kept: [], removed: [], skipped: true, reason: active.reason };
144
+ }
145
+ const activeVersion = active.activeVersion;
146
+
147
+ // Step 2: list cache version dirs.
148
+ const { cacheDir, names } = listCacheVersions(home, marketplace);
149
+ if (names.length === 0) {
150
+ // No cache dir to prune. The active version is still "kept" conceptually
151
+ // (it lives in the cache when it exists; absent cache = nothing to do).
152
+ return { kept: [activeVersion], removed: [], skipped: false, reason: 'no cache dir to prune' };
153
+ }
154
+
155
+ // Step 3: build the keep-set.
156
+ // - the active version is ALWAYS kept (regardless of mtime).
157
+ // - then the N most recent non-active versions (by mtime DESC).
158
+ const sorted = sortByMtimeDesc(cacheDir, names);
159
+ const keep = new Set();
160
+ keep.add(activeVersion);
161
+ for (const name of sorted) {
162
+ if (name === activeVersion) continue;
163
+ if (keep.size >= retainCount + 1) break; // +1 reserves the slot for active
164
+ keep.add(name);
165
+ }
166
+
167
+ // Step 4: compute the prune set and act on it.
168
+ const removed = [];
169
+ for (const name of names) {
170
+ if (keep.has(name)) continue;
171
+ // Belt + suspenders: NEVER rm the active version's dir, no matter what.
172
+ if (name === activeVersion) {
173
+ // (Cannot reach here under normal flow -- the keep-set above always
174
+ // contains activeVersion. Defensive sentinel for malformed inputs.)
175
+ continue;
176
+ }
177
+ const target = path.join(cacheDir, name);
178
+ if (dryRun) {
179
+ removed.push(name);
180
+ continue;
181
+ }
182
+ try {
183
+ fs.rmSync(target, { recursive: true, force: true });
184
+ removed.push(name);
185
+ } catch (e) {
186
+ // Best-effort: a failed removal does not abort the whole prune; we
187
+ // record it in the reason field at the end if any failures occurred.
188
+ // The caller (session-start with `|| true`; doctor with try/catch +
189
+ // report.recoveries) handles the partial state.
190
+ removed.push(name + ' (rm-failed: ' + (e && e.code || e.message) + ')');
191
+ }
192
+ }
193
+
194
+ return {
195
+ kept: Array.from(keep),
196
+ removed,
197
+ skipped: false,
198
+ reason: null,
199
+ };
200
+ }
201
+
202
+ module.exports = {
203
+ pruneMarketplaceCache,
204
+ // Internal helpers exposed for testability + future reuse.
205
+ readActiveVersion,
206
+ listCacheVersions,
207
+ sortByMtimeDesc,
208
+ };
@@ -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) {
@@ -47,13 +47,29 @@ const EVENT_TYPES = Object.freeze(new Set([
47
47
  // fired (fingerprint detection -> spawn) -> finding_surfaced (drain -> F.1)
48
48
  // -> user_response (Explore/Skip/Later/Free-text) OR skipped (suppression).
49
49
  // brain_canon_drift_observed (FourLenses Brain vs FiveLenses Canon; emitted once per
50
- // session by 117-05 emitBrainCanonDrift via this EVENT_TYPES string; size 31 invariant).
50
+ // session by 117-05 emitBrainCanonDrift via this EVENT_TYPES string).
51
+ // Size-invariant note: additive set; downstream phases extend (see the Phase 88.2-00,
52
+ // 89-07-00, 116-00, 117-00, 110-02 blocks). Tests assert a FLOOR + named membership,
53
+ // not an exact count -- so a future phase adding an event type cannot regress baseline.
51
54
  'auto_explore_fired',
52
55
  'auto_explore_finding_surfaced',
53
56
  'auto_explore_user_response',
54
57
  'auto_explore_skipped',
55
58
  'auto_explore_sanitizer_hit',
56
59
  'brain_canon_drift_observed',
60
+ // Phase 110-02 extension (Brain Context Packet Contract; D-07 + D-10 telemetry mirror):
61
+ // brain_packet_rejected -> an outbound packet failed in-schema validation in
62
+ // brain-client.sendPacket (reject hard -- thrown error).
63
+ // brain_response_rejected -> a Brain response failed out-schema validation -> degraded
64
+ // soft, NOT ingested, no partial-ingest.
65
+ // brain_legacy_path_used -> the forward-looking deprecation guard fired (no current
66
+ // call site shipped in 110-02; see brain-client.cjs).
67
+ // Additive extension only; mirrors the Phase 116-00 5-tension-strings idiom and the
68
+ // 117-00 6-auto_explore-strings idiom. logEvent already rejects event_type values
69
+ // outside EVENT_TYPES -- so these are accepted only because they are now IN the Set.
70
+ 'brain_packet_rejected',
71
+ 'brain_response_rejected',
72
+ 'brain_legacy_path_used',
57
73
  ]));
58
74
 
59
75
  function isPlainObject(v) {
@@ -11,20 +11,20 @@
11
11
  // Canon Part 9: SELECT supersedes folder scanning; this is the load-bearing
12
12
  // query for the acceptance test.
13
13
 
14
- const NEIGHBORHOOD_SQL = "WITH RECURSIVE neighborhood(id, type, edge_path, depth, edge_type_in, last_seen_at, confidence, source_section, review_status, created_by, source_path) AS ( "
14
+ const NEIGHBORHOOD_SQL = "WITH RECURSIVE neighborhood(id, type, edge_path, depth, edge_type_in, last_seen_at, created_at, confidence, source_section, review_status, created_by, source_path) AS ( "
15
15
  + "SELECT n.id, n.type, json_array(n.id) AS edge_path, 0 AS depth, NULL AS edge_type_in, "
16
- + "n.last_seen_at, n.confidence, n.source_section, n.review_status, n.created_by, n.source_path "
16
+ + "n.last_seen_at, n.created_at, n.confidence, n.source_section, n.review_status, n.created_by, n.source_path "
17
17
  + "FROM nodes n WHERE n.id = :focus_node_id "
18
18
  + "UNION ALL "
19
19
  + "SELECT next_n.id, next_n.type, json_insert(nh.edge_path, '$[#]', next_n.id) AS edge_path, "
20
20
  + "nh.depth + 1 AS depth, e.type AS edge_type_in, "
21
- + "next_n.last_seen_at, next_n.confidence, next_n.source_section, next_n.review_status, next_n.created_by, next_n.source_path "
21
+ + "next_n.last_seen_at, next_n.created_at, next_n.confidence, next_n.source_section, next_n.review_status, next_n.created_by, next_n.source_path "
22
22
  + "FROM neighborhood nh JOIN edges e ON e.source = nh.id JOIN nodes next_n ON next_n.id = e.target "
23
23
  + "WHERE nh.depth < :max_depth "
24
24
  + "AND json_array_length(nh.edge_path) < (:max_depth + 1) "
25
25
  + "AND nh.id != next_n.id "
26
26
  + ") "
27
- + "SELECT id, type, edge_path, depth, edge_type_in, source_path, review_status, created_by, confidence, last_seen_at, "
27
+ + "SELECT id, type, edge_path, depth, edge_type_in, source_path, review_status, created_by, created_at, confidence, last_seen_at, "
28
28
  + "( "
29
29
  + "CASE edge_type_in "
30
30
  + "WHEN 'CONTRADICTS' THEN 1.0 "
@@ -70,6 +70,7 @@ function getNeighborhood(db, focusNodeId, opts) {
70
70
  sourcePath: r.source_path,
71
71
  reviewStatus: r.review_status,
72
72
  createdBy: r.created_by,
73
+ createdAt: r.created_at,
73
74
  confidence: r.confidence,
74
75
  lastSeenAt: r.last_seen_at,
75
76
  }));