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

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 (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +21 -11
  3. package/README.md +74 -572
  4. package/commands/act.md +1 -0
  5. package/commands/admin.md +1 -0
  6. package/commands/analyze-needs.md +1 -0
  7. package/commands/analyze-systems.md +1 -0
  8. package/commands/analyze-timing.md +1 -0
  9. package/commands/auto-explore.md +1 -0
  10. package/commands/beautiful-question.md +1 -0
  11. package/commands/brain-derive.md +1 -0
  12. package/commands/build-knowledge.md +1 -0
  13. package/commands/build-thesis.md +1 -0
  14. package/commands/causal.md +1 -0
  15. package/commands/challenge-assumptions.md +1 -0
  16. package/commands/compare-ventures.md +1 -0
  17. package/commands/dashboard.md +1 -0
  18. package/commands/deep-grade.md +1 -0
  19. package/commands/diagnose.md +1 -0
  20. package/commands/diagnostics.md +1 -0
  21. package/commands/doctor.md +1 -0
  22. package/commands/dominant-designs.md +1 -0
  23. package/commands/explain-decision.md +1 -0
  24. package/commands/explore-domains.md +1 -0
  25. package/commands/explore-futures.md +1 -0
  26. package/commands/explore-trends.md +1 -0
  27. package/commands/export.md +1 -0
  28. package/commands/feynman-timeline-refresh.md +78 -0
  29. package/commands/file-meeting.md +1 -0
  30. package/commands/find-analogies.md +1 -0
  31. package/commands/find-bottlenecks.md +1 -0
  32. package/commands/find-connections.md +1 -0
  33. package/commands/funding.md +1 -0
  34. package/commands/grade.md +1 -0
  35. package/commands/graph.md +1 -0
  36. package/commands/hat-briefing.md +1 -0
  37. package/commands/heal.md +1 -0
  38. package/commands/help.md +1 -0
  39. package/commands/hmi-status.md +1 -0
  40. package/commands/jtbd.md +1 -0
  41. package/commands/leadership.md +1 -0
  42. package/commands/lean-canvas.md +1 -0
  43. package/commands/macro-trends.md +1 -0
  44. package/commands/map-unknowns.md +1 -0
  45. package/commands/memory.md +1 -0
  46. package/commands/models.md +1 -0
  47. package/commands/mos-reason.md +1 -0
  48. package/commands/mullins.md +1 -0
  49. package/commands/new-project.md +1 -0
  50. package/commands/onboard.md +1 -0
  51. package/commands/operator.md +1 -0
  52. package/commands/opportunities.md +1 -0
  53. package/commands/organize.md +1 -0
  54. package/commands/persona.md +1 -0
  55. package/commands/pipeline.md +1 -0
  56. package/commands/present.md +1 -0
  57. package/commands/publish.md +1 -0
  58. package/commands/query.md +1 -0
  59. package/commands/radar.md +1 -0
  60. package/commands/reanalyze.md +1 -0
  61. package/commands/research.md +1 -0
  62. package/commands/room.md +1 -0
  63. package/commands/rooms.md +1 -0
  64. package/commands/root-cause.md +1 -0
  65. package/commands/rs-experts.md +1 -0
  66. package/commands/rs-explain.md +1 -0
  67. package/commands/rs-fetch.md +1 -0
  68. package/commands/rs-thesis.md +1 -0
  69. package/commands/scenario-plan.md +1 -0
  70. package/commands/scheduled-tasks.md +1 -0
  71. package/commands/score-innovation.md +1 -0
  72. package/commands/scout.md +1 -0
  73. package/commands/setup.md +1 -0
  74. package/commands/snapshot.md +1 -0
  75. package/commands/speakers.md +1 -0
  76. package/commands/splash.md +1 -0
  77. package/commands/status.md +1 -0
  78. package/commands/structure-argument.md +1 -0
  79. package/commands/suggest-next.md +1 -0
  80. package/commands/systems-thinking.md +1 -0
  81. package/commands/think-hats.md +1 -0
  82. package/commands/update.md +1 -0
  83. package/commands/user-needs.md +1 -0
  84. package/commands/validate.md +1 -0
  85. package/commands/value-proposition.md +1 -0
  86. package/commands/vault.md +1 -0
  87. package/commands/visualize.md +1 -0
  88. package/commands/whitespace.md +1 -0
  89. package/commands/wiki.md +1 -0
  90. package/lib/brain/framework-chain-slice.cjs +193 -0
  91. package/lib/core/cache-prune.cjs +114 -8
  92. package/lib/core/feynman/ROOM.md +25 -0
  93. package/lib/core/feynman/timeline-renderer.cjs +197 -0
  94. package/lib/core/feynman/timeline-runner.cjs +281 -0
  95. package/lib/core/install-state.cjs +242 -0
  96. package/lib/core/navigation/edges.cjs +86 -0
  97. package/lib/core/navigation/insights.cjs +37 -0
  98. package/lib/core/navigation/memory-events.cjs +39 -0
  99. package/lib/core/navigation/packet.cjs +89 -9
  100. package/lib/core/navigation/projections.cjs +201 -0
  101. package/lib/core/navigation.cjs +25 -0
  102. package/lib/mcp/larry-server-instructions.md +1 -1
  103. package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
  104. package/lib/memory/f-selector-ranker.test.cjs +593 -0
  105. package/lib/memory/navigation-projections.test.cjs +241 -0
  106. package/lib/memory/navigation-write-edge.test.cjs +206 -0
  107. package/lib/memory/packet-chain-hint.test.cjs +407 -0
  108. package/lib/memory/packet-schema-validation.test.cjs +317 -0
  109. package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
  110. package/lib/memory/per-command-teaching.test.cjs +110 -0
  111. package/lib/memory/run-feynman-tests.cjs +36 -0
  112. package/lib/memory/selector-decisions.test.cjs +417 -0
  113. package/lib/memory/selector-miss.test.cjs +290 -0
  114. package/lib/workflow/f-selector-ranker.cjs +420 -0
  115. package/lib/workflow/selector-decisions.cjs +368 -0
  116. package/package.json +1 -1
  117. package/references/design/email-template-standard.md +1 -1
  118. package/references/user-research/2026-04-05-leah-lawrence-session.md +3 -3
@@ -0,0 +1,417 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 125-06 -- lib/workflow/selector-decisions.cjs test suite.
5
+ *
6
+ * Covers 16 acceptance behaviors from 125-06-PLAN.md <behavior> block:
7
+ * Tests 1-7 Decision writes (D7): recordSelectorDecision
8
+ * Tests 8-14 Decay weight (D7): applyDecayWeight + shouldExclude
9
+ * Tests 15-16 Integration with Plan 05 ranker (opts._applyDecayWeight)
10
+ *
11
+ * Three-surface compatibility: pure CJS + node:test. Uses fs.mkdtempSync +
12
+ * openRoomDb fixture pattern from Plan 00's navigation-write-edge.test.cjs.
13
+ * All writes route through the navigation.cjs chokepoint; tests verify both
14
+ * the memory_event row AND the typed cascade edge row land in room.db.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { test } = require('node:test');
20
+ const { ok, equal, deepStrictEqual } = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
26
+ const SELECTOR_DECISIONS_PATH = path.join(REPO_ROOT, 'lib', 'workflow', 'selector-decisions.cjs');
27
+ const RANKER_PATH = path.join(REPO_ROOT, 'lib', 'workflow', 'f-selector-ranker.cjs');
28
+ const { openRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
29
+ const selectorDecisions = require(SELECTOR_DECISIONS_PATH);
30
+ const ranker = require(RANKER_PATH);
31
+
32
+ function freshDb() {
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p125-06-selector-decisions-'));
34
+ const db = openRoomDb(dir);
35
+ return { dir, db };
36
+ }
37
+
38
+ // FK fixture (mirrors Plan 00's seedAnchorNode pattern in
39
+ // lib/memory/navigation-write-edge.test.cjs). The shipped edges table has
40
+ // FK (source, target) -> nodes(id), so we must seed anchor nodes for the
41
+ // command + framework before writeEdge can succeed.
42
+ function seedAnchorNode(db, id, type) {
43
+ const nowMs = Date.now();
44
+ db.prepare(
45
+ "INSERT OR IGNORE INTO nodes (id, type, properties, source_path, created_by, confidence, review_status, created_at, last_seen_at) " +
46
+ "VALUES (?, ?, '{}', 'p125-06-fixture', 'system', NULL, 'confirmed', ?, ?)"
47
+ ).run(id, type, nowMs, nowMs);
48
+ }
49
+
50
+ function seedAnchorsFor(db, command, framework) {
51
+ seedAnchorNode(db, 'cmd:' + command, 'command');
52
+ seedAnchorNode(db, 'framework:' + framework, 'framework');
53
+ }
54
+
55
+ function fetchMemoryEvent(db, decisionId) {
56
+ return db.prepare(
57
+ "SELECT id, type, properties FROM nodes WHERE id = ?"
58
+ ).get(decisionId);
59
+ }
60
+
61
+ function fetchEdge(db, source, target, type) {
62
+ return db.prepare(
63
+ "SELECT source, target, type, properties FROM edges WHERE source = ? AND target = ? AND type = ?"
64
+ ).get(source, target, type);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Tests 1-7: Decision writes (D7)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ test('Test 1: recordSelectorDecision({decision: "defer"}) writes memory_event with decision="defer"', () => {
72
+ const { db } = freshDb();
73
+ seedAnchorsFor(db, 'mos:beautiful-question', 'Beautiful Question Framework');
74
+ const r = selectorDecisions.recordSelectorDecision({
75
+ decision: 'defer',
76
+ command: 'mos:beautiful-question',
77
+ framework: 'Beautiful Question Framework',
78
+ reason: 'not now',
79
+ roomState: { db, investment_level: 0.3 },
80
+ });
81
+ ok(r.ok, 'recordSelectorDecision should return ok: true; got reason=' + (r && r.reason));
82
+ ok(typeof r.decision_id === 'string' && r.decision_id.length > 0, 'decision_id is non-empty string');
83
+ ok(typeof r.edge_id === 'string' && r.edge_id.length > 0, 'edge_id is non-empty string');
84
+ const row = fetchMemoryEvent(db, r.decision_id);
85
+ ok(row, 'memory_event row present in nodes table');
86
+ equal(row.type, 'memory_event');
87
+ const props = JSON.parse(row.properties);
88
+ equal(props.event_type, 'f_selector_decision');
89
+ equal(props.decision, 'defer');
90
+ equal(props.command, 'mos:beautiful-question');
91
+ });
92
+
93
+ test('Test 2: recordSelectorDecision with decision="reject" writes REJECTED edge', () => {
94
+ const { db } = freshDb();
95
+ seedAnchorsFor(db, 'mos:swot', 'SWOT');
96
+ const r = selectorDecisions.recordSelectorDecision({
97
+ decision: 'reject',
98
+ command: 'mos:swot',
99
+ framework: 'SWOT',
100
+ reason: 'wrong approach for our stage',
101
+ roomState: { db, investment_level: 0.5 },
102
+ });
103
+ ok(r.ok);
104
+ // memory_event has decision='reject'.
105
+ const memRow = fetchMemoryEvent(db, r.decision_id);
106
+ ok(memRow);
107
+ const memProps = JSON.parse(memRow.properties);
108
+ equal(memProps.decision, 'reject');
109
+ equal(memProps.edge_semantic, 'REJECTED');
110
+ // edge of type REJECTED is present.
111
+ const edge = fetchEdge(db, 'cmd:mos:swot', 'framework:SWOT', 'REJECTED');
112
+ ok(edge, 'REJECTED edge row should be present');
113
+ equal(edge.type, 'REJECTED');
114
+ });
115
+
116
+ test('Test 3: DEFERRED edge has expires_at ~30 days from now; REJECTED edge does NOT', () => {
117
+ const { db } = freshDb();
118
+ seedAnchorsFor(db, 'mos:c1', 'F1');
119
+ seedAnchorsFor(db, 'mos:c2', 'F2');
120
+
121
+ // Defer: expires_at present and within 5min of now+30 days.
122
+ const r1 = selectorDecisions.recordSelectorDecision({
123
+ decision: 'defer',
124
+ command: 'mos:c1',
125
+ framework: 'F1',
126
+ reason: null,
127
+ roomState: { db },
128
+ });
129
+ ok(r1.ok);
130
+ const e1 = fetchEdge(db, 'cmd:mos:c1', 'framework:F1', 'DEFERRED');
131
+ ok(e1);
132
+ const props1 = JSON.parse(e1.properties);
133
+ ok(typeof props1.expires_at === 'string' && props1.expires_at.length > 0, 'expires_at present on DEFERRED');
134
+ const expiresMs = Date.parse(props1.expires_at);
135
+ const expectedMs = Date.now() + 30 * 24 * 3600 * 1000;
136
+ const deltaMs = Math.abs(expiresMs - expectedMs);
137
+ ok(deltaMs < 5 * 60 * 1000, 'expires_at ~30 days from now (within 5 min tolerance); delta=' + deltaMs);
138
+
139
+ // Reject: expires_at absent.
140
+ const r2 = selectorDecisions.recordSelectorDecision({
141
+ decision: 'reject',
142
+ command: 'mos:c2',
143
+ framework: 'F2',
144
+ reason: null,
145
+ roomState: { db },
146
+ });
147
+ ok(r2.ok);
148
+ const e2 = fetchEdge(db, 'cmd:mos:c2', 'framework:F2', 'REJECTED');
149
+ ok(e2);
150
+ const props2 = JSON.parse(e2.properties);
151
+ equal(Object.prototype.hasOwnProperty.call(props2, 'expires_at'), false, 'REJECTED edge has no expires_at');
152
+ });
153
+
154
+ test('Test 4: edge properties include reason verbatim and decision_id', () => {
155
+ const { db } = freshDb();
156
+ seedAnchorsFor(db, 'mos:c4', 'F4');
157
+ const reasonText = 'team is split; defer until next milestone';
158
+ const r = selectorDecisions.recordSelectorDecision({
159
+ decision: 'defer',
160
+ command: 'mos:c4',
161
+ framework: 'F4',
162
+ reason: reasonText,
163
+ roomState: { db },
164
+ });
165
+ ok(r.ok);
166
+ const edge = fetchEdge(db, 'cmd:mos:c4', 'framework:F4', 'DEFERRED');
167
+ ok(edge);
168
+ const props = JSON.parse(edge.properties);
169
+ equal(props.reason, reasonText);
170
+ equal(props.decision_id, r.decision_id);
171
+ });
172
+
173
+ test('Test 5: invalid decision (e.g. "maybe") returns ok:false; no writes', () => {
174
+ const { db } = freshDb();
175
+ seedAnchorsFor(db, 'mos:c5', 'F5');
176
+ const r = selectorDecisions.recordSelectorDecision({
177
+ decision: 'maybe',
178
+ command: 'mos:c5',
179
+ framework: 'F5',
180
+ roomState: { db },
181
+ });
182
+ equal(r.ok, false);
183
+ equal(r.reason, 'invalid_decision');
184
+ // No edges of any type for this command.
185
+ const edges = db.prepare("SELECT 1 FROM edges WHERE source = ?").all('cmd:mos:c5');
186
+ equal(edges.length, 0, 'no edges should be written on invalid decision');
187
+ // No memory_event for f_selector_decision tied to this command.
188
+ const mems = db.prepare(
189
+ "SELECT 1 FROM nodes WHERE type = 'memory_event' AND json_extract(properties, '$.command') = ?"
190
+ ).all('mos:c5');
191
+ equal(mems.length, 0, 'no memory_event should be written on invalid decision');
192
+ });
193
+
194
+ test('Test 6: reason is optional -- when omitted, payload.reason is null AND edge.properties.reason is null', () => {
195
+ const { db } = freshDb();
196
+ seedAnchorsFor(db, 'mos:c6', 'F6');
197
+ const r = selectorDecisions.recordSelectorDecision({
198
+ decision: 'defer',
199
+ command: 'mos:c6',
200
+ framework: 'F6',
201
+ // reason omitted intentionally
202
+ roomState: { db },
203
+ });
204
+ ok(r.ok);
205
+ const mem = fetchMemoryEvent(db, r.decision_id);
206
+ const memProps = JSON.parse(mem.properties);
207
+ equal(memProps.reason, null, 'payload.reason is null when omitted');
208
+ const edge = fetchEdge(db, 'cmd:mos:c6', 'framework:F6', 'DEFERRED');
209
+ const edgeProps = JSON.parse(edge.properties);
210
+ equal(edgeProps.reason, null, 'edge.properties.reason is null when omitted');
211
+ });
212
+
213
+ test('Test 7: all writes route through navigation.cjs chokepoint (grep audit)', () => {
214
+ const src = fs.readFileSync(SELECTOR_DECISIONS_PATH, 'utf8');
215
+ ok(/require\(['"]\.\.\/core\/navigation\.cjs['"]\)/.test(src),
216
+ "source must require('../core/navigation.cjs')");
217
+ ok(src.indexOf('navigation.writeEdge') !== -1, 'must call navigation.writeEdge');
218
+ ok(src.indexOf('navigation.logMemoryEvent') !== -1, 'must call navigation.logMemoryEvent');
219
+ // No direct room-db.cjs require allowed.
220
+ ok(!/require\([^)]*room-db/.test(src),
221
+ 'must NOT require room-db.cjs directly (Canon Part 8 chokepoint invariant)');
222
+ // No direct internal memory-events module require.
223
+ ok(!/require\([^)]*navigation\/memory-events/.test(src),
224
+ 'must NOT require navigation/memory-events.cjs directly; route via navigation.cjs');
225
+ // No direct better-sqlite3 / sqlite3 require.
226
+ ok(!/require\(['"]better-sqlite3['"]\)/.test(src),
227
+ 'must NOT require better-sqlite3 directly');
228
+ ok(!/require\(['"]sqlite3['"]\)/.test(src),
229
+ 'must NOT require sqlite3 directly');
230
+ });
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Tests 8-14: Decay weight (D7) + shouldExclude helper
234
+ // ---------------------------------------------------------------------------
235
+
236
+ test('Test 8: applyDecayWeight at invocation 0 since decision returns 0 (factor = 0)', () => {
237
+ const roomState = { invocationsSinceDecision: { 'mos:x': 0 } };
238
+ const out = selectorDecisions.applyDecayWeight(0.8, 'mos:x', roomState);
239
+ ok(typeof out === 'number' && isFinite(out), 'returns finite number');
240
+ ok(Math.abs(out - 0) < 1e-9, 'at invocation 0, factor = 0; got ' + out);
241
+ });
242
+
243
+ test('Test 9: applyDecayWeight at invocation 5 returns ~0.632 * base (within 0.05)', () => {
244
+ // factor = 1 - exp(-5/5) = 1 - 1/e ~= 0.6321...
245
+ const roomState = { invocationsSinceDecision: { 'mos:x': 5 } };
246
+ const out = selectorDecisions.applyDecayWeight(0.8, 'mos:x', roomState);
247
+ const expected = 0.8 * (1 - Math.exp(-1));
248
+ ok(Math.abs(out - expected) < 0.05, 'invocation 5 -> ~0.5057; got ' + out);
249
+ });
250
+
251
+ test('Test 10: applyDecayWeight at invocation 10 returns factor ~0.865 (within 0.05)', () => {
252
+ // factor = 1 - exp(-10/5) = 1 - exp(-2) ~= 0.8647
253
+ const roomState = { invocationsSinceDecision: { 'mos:x': 10 } };
254
+ const out = selectorDecisions.applyDecayWeight(1.0, 'mos:x', roomState);
255
+ const expected = 1 - Math.exp(-2);
256
+ ok(Math.abs(out - expected) < 0.05, 'invocation 10 -> ~0.865; got ' + out);
257
+ });
258
+
259
+ test('Test 11: applyDecayWeight at invocation 15 returns >= 0.95 of base (effectively expired)', () => {
260
+ const roomState = { invocationsSinceDecision: { 'mos:x': 15 } };
261
+ const out = selectorDecisions.applyDecayWeight(1.0, 'mos:x', roomState);
262
+ ok(out >= 0.95, 'at invocation 15, factor >= 0.95 of base; got ' + out);
263
+ });
264
+
265
+ test('Test 12: shouldExclude returns true when decay factor < 0.1; false when >= 0.1', () => {
266
+ // factor < 0.1 means 1 - exp(-n/5) < 0.1 => exp(-n/5) > 0.9 => n/5 < ln(1/0.9) ~ 0.1054
267
+ // => n < 0.527. So at n=0 -> exclude; at n=1 -> factor ~= 0.181 -> include.
268
+ const exclude0 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 0 } });
269
+ equal(exclude0, true, 'at invocation 0 (freshly deferred), shouldExclude=true');
270
+ const include1 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 1 } });
271
+ equal(include1, false, 'at invocation 1, factor ~= 0.181, shouldExclude=false');
272
+ const include5 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 5 } });
273
+ equal(include5, false, 'at invocation 5, factor ~= 0.632, shouldExclude=false');
274
+ });
275
+
276
+ test('Test 13: applyDecayWeight is pure -- same inputs produce same output', () => {
277
+ const roomState = { invocationsSinceDecision: { 'mos:x': 3 } };
278
+ const a = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
279
+ const b = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
280
+ const c = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
281
+ equal(a, b);
282
+ equal(b, c);
283
+ });
284
+
285
+ test('Test 14: applyDecayWeight gracefully handles roomState without decision history -- returns base_score unchanged', () => {
286
+ // No invocationsSinceDecision counter; no db; no decision recorded => no decay.
287
+ const out1 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', {});
288
+ equal(out1, 0.65, 'returns base_score when no history');
289
+ // Counter present but missing this command key -> no decay.
290
+ const out2 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', { invocationsSinceDecision: {} });
291
+ equal(out2, 0.65);
292
+ // Null/undefined roomState -> returns base_score gracefully.
293
+ const out3 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', null);
294
+ equal(out3, 0.65);
295
+ const out4 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', undefined);
296
+ equal(out4, 0.65);
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Tests 15-16: Integration with Plan 05 ranker (opts._applyDecayWeight)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ test('Test 15: ranker with opts._applyDecayWeight applies decay; freshly-deferred command ranks at bottom (or filtered)', () => {
304
+ // Use the ranker's fake-registry seam to inject a deterministic 2-command set.
305
+ ranker._test._resetCaches();
306
+ ranker._test._setRegistry({
307
+ commands: [
308
+ {
309
+ command: '/mos:beautiful-question',
310
+ kind: 'methodology',
311
+ frameworks: ['Beautiful Question Framework'],
312
+ serves_jtbd: ['find-problem'],
313
+ jtbd_label: 'Find root problem',
314
+ jtbd_summary: 'BQ surfaces real problems under the symptom.',
315
+ teaching: 'Widen before narrowing.',
316
+ },
317
+ {
318
+ command: '/mos:swot',
319
+ kind: 'methodology',
320
+ frameworks: ['SWOT'],
321
+ serves_jtbd: ['validate-thesis'],
322
+ jtbd_label: 'Audit S/W/O/T',
323
+ jtbd_summary: 'SWOT structures internal vs external.',
324
+ teaching: 'SWOT after JTBD.',
325
+ },
326
+ ],
327
+ });
328
+ // Mark beautiful-question as freshly deferred (invocation 0 since decision).
329
+ const roomState = {
330
+ invocationsSinceDecision: { '/mos:beautiful-question': 0 },
331
+ };
332
+ const out = ranker.rankForSelector({
333
+ roomState,
334
+ k: 3,
335
+ _applyDecayWeight: selectorDecisions.applyDecayWeight,
336
+ });
337
+ // BQ is at invocation 0 => factor 0 => score multiplied by 0 => 0. SWOT untouched.
338
+ ok(out.length >= 1, 'returns at least one ranked command');
339
+ const bq = out.find((x) => x.command === '/mos:beautiful-question');
340
+ const swot = out.find((x) => x.command === '/mos:swot');
341
+ ok(bq, 'BQ should still appear in the result (decay does not filter from ranker output)');
342
+ ok(swot, 'SWOT should appear');
343
+ ok(bq.score === 0, 'freshly-deferred BQ has score 0 after decay; got ' + bq.score);
344
+ // SWOT outranks BQ when BQ score = 0.
345
+ ok(swot.score >= bq.score, 'SWOT ranks at least as high as decayed BQ');
346
+ // Reset for downstream tests.
347
+ ranker._test._resetCaches();
348
+ });
349
+
350
+ test('Test 16: after 15 invocations since decision, decayed score ~= un-decayed score (decision effectively expired)', () => {
351
+ ranker._test._resetCaches();
352
+ ranker._test._setRegistry({
353
+ commands: [
354
+ {
355
+ command: '/mos:beautiful-question',
356
+ kind: 'methodology',
357
+ frameworks: ['Beautiful Question Framework'],
358
+ serves_jtbd: ['find-problem'],
359
+ jtbd_label: 'Find root problem',
360
+ jtbd_summary: 'BQ surfaces real problems.',
361
+ teaching: 'Widen before narrowing.',
362
+ },
363
+ ],
364
+ });
365
+ // Un-decayed baseline (no decay hook).
366
+ const baseline = ranker.rankForSelector({
367
+ roomState: {},
368
+ k: 3,
369
+ });
370
+ // After 15 invocations since decision, factor ~= 0.9502, effectively expired.
371
+ const decayed = ranker.rankForSelector({
372
+ roomState: { invocationsSinceDecision: { '/mos:beautiful-question': 15 } },
373
+ k: 3,
374
+ _applyDecayWeight: selectorDecisions.applyDecayWeight,
375
+ });
376
+ const baseBq = baseline.find((x) => x.command === '/mos:beautiful-question');
377
+ const decayBq = decayed.find((x) => x.command === '/mos:beautiful-question');
378
+ ok(baseBq);
379
+ ok(decayBq);
380
+ // Decayed score should be within ~5% of baseline (decay factor >= 0.95 at n=15).
381
+ const ratio = decayBq.score / Math.max(baseBq.score, 1e-9);
382
+ ok(ratio >= 0.94, 'decayed score is at least 94% of baseline at n=15; got ratio=' + ratio);
383
+ ranker._test._resetCaches();
384
+ });
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Bonus regression: db-backed _invocationsSinceDecision counts framework_invoked
388
+ // events newer than the most recent f_selector_decision for the command.
389
+ // ---------------------------------------------------------------------------
390
+
391
+ test('Bonus: _invocationsSinceDecision counts framework_invoked events after the last decision', () => {
392
+ const { db } = freshDb();
393
+ seedAnchorsFor(db, 'mos:bonus-decay', 'BonusFramework');
394
+ // Record a decision at t0.
395
+ const r = selectorDecisions.recordSelectorDecision({
396
+ decision: 'defer',
397
+ command: 'mos:bonus-decay',
398
+ framework: 'BonusFramework',
399
+ reason: null,
400
+ roomState: { db },
401
+ });
402
+ ok(r.ok);
403
+ // Sleep at least 2 ms so the framework_invoked rows land strictly after the
404
+ // memory_event row (createdAt > lastDecisionAt). Phase 109's findRecentChanges
405
+ // uses strict '>' on created_at.
406
+ const target = Date.now() + 3;
407
+ while (Date.now() < target) { /* busy-wait <= 3ms */ }
408
+ // Log a few framework_invoked events via the chokepoint.
409
+ const navigation = require(path.join(REPO_ROOT, 'lib', 'core', 'navigation.cjs'));
410
+ navigation.logMemoryEvent(db, 'framework_invoked', { command: 'mos:bonus-decay', framework: 'BonusFramework' });
411
+ navigation.logMemoryEvent(db, 'framework_invoked', { command: 'mos:bonus-decay', framework: 'BonusFramework' });
412
+ // Now applyDecayWeight should compute decay relative to 2 invocations since decision.
413
+ // factor = 1 - exp(-2/5) ~= 0.3297; 0.5 * 0.3297 ~= 0.1648.
414
+ const decayed = selectorDecisions.applyDecayWeight(0.5, 'mos:bonus-decay', { db });
415
+ const expected = 0.5 * (1 - Math.exp(-2 / 5));
416
+ ok(Math.abs(decayed - expected) < 0.1, 'db-backed decay factor with 2 framework_invoked events; got ' + decayed + ' expected ~' + expected);
417
+ });