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

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 (219) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +36 -0
  3. package/commands/act.md +1 -0
  4. package/commands/admin.md +1 -0
  5. package/commands/analyze-needs.md +2 -0
  6. package/commands/analyze-systems.md +2 -0
  7. package/commands/analyze-timing.md +2 -0
  8. package/commands/auto-explore.md +2 -0
  9. package/commands/beautiful-question.md +2 -0
  10. package/commands/brain-derive.md +2 -0
  11. package/commands/build-knowledge.md +2 -0
  12. package/commands/build-thesis.md +2 -0
  13. package/commands/causal.md +2 -0
  14. package/commands/challenge-assumptions.md +2 -0
  15. package/commands/compare-ventures.md +2 -0
  16. package/commands/dashboard.md +2 -1
  17. package/commands/deep-grade.md +2 -0
  18. package/commands/diagnose.md +21 -1
  19. package/commands/diagnostics.md +14 -3
  20. package/commands/doctor.md +4 -1
  21. package/commands/dogfood-flush.md +92 -0
  22. package/commands/dominant-designs.md +2 -0
  23. package/commands/explain-decision.md +2 -0
  24. package/commands/explore-domains.md +2 -0
  25. package/commands/explore-futures.md +2 -0
  26. package/commands/explore-trends.md +2 -0
  27. package/commands/export.md +1 -0
  28. package/commands/feynman-timeline-refresh.md +2 -0
  29. package/commands/file-meeting.md +4 -0
  30. package/commands/find-analogies.md +1 -0
  31. package/commands/find-bottlenecks.md +2 -0
  32. package/commands/find-connections.md +2 -0
  33. package/commands/funding.md +1 -0
  34. package/commands/grade.md +4 -0
  35. package/commands/graph.md +1 -0
  36. package/commands/hat-briefing.md +1 -0
  37. package/commands/heal.md +22 -170
  38. package/commands/help.md +54 -334
  39. package/commands/hmi-status.md +23 -144
  40. package/commands/jtbd.md +1 -0
  41. package/commands/leadership.md +2 -0
  42. package/commands/lean-canvas.md +2 -0
  43. package/commands/macro-trends.md +2 -0
  44. package/commands/map-unknowns.md +2 -0
  45. package/commands/memory.md +1 -0
  46. package/commands/models.md +1 -0
  47. package/commands/mos-reason.md +2 -0
  48. package/commands/mos.md +139 -0
  49. package/commands/mullins.md +2 -0
  50. package/commands/mva-brief.md +58 -0
  51. package/commands/mva-option.md +91 -0
  52. package/commands/new-project.md +4 -0
  53. package/commands/onboard.md +22 -7
  54. package/commands/operator.md +1 -0
  55. package/commands/opportunities.md +1 -0
  56. package/commands/organize.md +22 -469
  57. package/commands/persona.md +1 -0
  58. package/commands/pipeline.md +2 -0
  59. package/commands/present.md +1 -0
  60. package/commands/publish.md +2 -0
  61. package/commands/query.md +24 -102
  62. package/commands/radar.md +2 -0
  63. package/commands/reanalyze.md +1 -0
  64. package/commands/research.md +2 -0
  65. package/commands/room.md +2 -0
  66. package/commands/rooms.md +1 -0
  67. package/commands/root-cause.md +2 -0
  68. package/commands/rs-experts.md +1 -0
  69. package/commands/rs-explain.md +1 -0
  70. package/commands/rs-fetch.md +1 -0
  71. package/commands/rs-thesis.md +1 -0
  72. package/commands/scenario-plan.md +2 -0
  73. package/commands/scheduled-tasks.md +1 -0
  74. package/commands/score-innovation.md +2 -0
  75. package/commands/scout.md +1 -0
  76. package/commands/setup.md +2 -0
  77. package/commands/snapshot.md +2 -0
  78. package/commands/speakers.md +1 -0
  79. package/commands/splash.md +5 -2
  80. package/commands/status.md +1 -0
  81. package/commands/structure-argument.md +2 -0
  82. package/commands/suggest-next.md +2 -0
  83. package/commands/systems-thinking.md +2 -0
  84. package/commands/think-hats.md +2 -0
  85. package/commands/update.md +2 -0
  86. package/commands/user-needs.md +2 -0
  87. package/commands/validate.md +2 -0
  88. package/commands/value-proposition.md +2 -0
  89. package/commands/vault.md +2 -0
  90. package/commands/visualize.md +24 -29
  91. package/commands/whitespace.md +2 -1
  92. package/commands/wiki.md +1 -0
  93. package/hooks/hooks.json +31 -88
  94. package/lib/agents/auto-explore-agent.cjs +82 -0
  95. package/lib/agents/mva/brain-classic-traps.cjs +77 -0
  96. package/lib/agents/mva/brain-cross-domain.cjs +79 -0
  97. package/lib/agents/mva/brain-similar-ventures.cjs +93 -0
  98. package/lib/agents/mva/dashboard-graph-neighborhood.cjs +72 -0
  99. package/lib/agents/mva/index.cjs +42 -0
  100. package/lib/agents/mva/six-hats-red-black.cjs +137 -0
  101. package/lib/agents/mva/tavily-funding-scan.cjs +147 -0
  102. package/lib/agents/mva/test-all-six-agents.cjs +467 -0
  103. package/lib/conversation/operator.cjs +64 -0
  104. package/lib/conversation/operator.test.cjs +160 -0
  105. package/lib/core/breakthrough/canary.cjs +134 -0
  106. package/lib/core/breakthrough/canary.test.cjs +136 -0
  107. package/lib/core/breakthrough/detectors.cjs +359 -0
  108. package/lib/core/breakthrough/detectors.test.cjs +333 -0
  109. package/lib/core/breakthrough/ethics-fence.cjs +127 -0
  110. package/lib/core/breakthrough/ethics-fence.test.cjs +178 -0
  111. package/lib/core/breakthrough/resurfacing.cjs +150 -0
  112. package/lib/core/breakthrough/resurfacing.test.cjs +233 -0
  113. package/lib/core/breakthrough/review-queue.cjs +154 -0
  114. package/lib/core/breakthrough/review-queue.test.cjs +160 -0
  115. package/lib/core/breakthrough/scanner-d17-d18.test.cjs +229 -0
  116. package/lib/core/breakthrough/scanner.cjs +426 -0
  117. package/lib/core/breakthrough/scanner.test.cjs +267 -0
  118. package/lib/core/breakthrough/schema.cjs +164 -0
  119. package/lib/core/breakthrough/schema.test.cjs +256 -0
  120. package/lib/core/breakthrough/scoring.cjs +293 -0
  121. package/lib/core/breakthrough/scoring.test.cjs +423 -0
  122. package/lib/core/breakthrough/verb-dispatch.cjs +221 -0
  123. package/lib/core/breakthrough/verb-dispatch.test.cjs +185 -0
  124. package/lib/core/breakthrough/voice-scaffold.cjs +247 -0
  125. package/lib/core/breakthrough/voice-scaffold.test.cjs +251 -0
  126. package/lib/core/first-touch-version-stamper.cjs +113 -0
  127. package/lib/core/larry-thinness-acknowledgment.cjs +64 -0
  128. package/lib/core/larry-thinness-acknowledgment.test.cjs +97 -0
  129. package/lib/core/llm-name-suggester.cjs +194 -0
  130. package/lib/core/llm-name-suggester.test.cjs +132 -0
  131. package/lib/core/mva-agent-contract.cjs +170 -0
  132. package/lib/core/mva-agent-contract.test.cjs +169 -0
  133. package/lib/core/mva-budget.cjs +75 -0
  134. package/lib/core/mva-budget.test.cjs +68 -0
  135. package/lib/core/mva-classifier.cjs +370 -0
  136. package/lib/core/mva-classifier.test.cjs +248 -0
  137. package/lib/core/mva-deck-builder.cjs +452 -0
  138. package/lib/core/mva-deck-builder.test.cjs +287 -0
  139. package/lib/core/mva-detect.smoke.test.cjs +197 -0
  140. package/lib/core/mva-dispatcher.cjs +110 -0
  141. package/lib/core/mva-dispatcher.test.cjs +216 -0
  142. package/lib/core/mva-option-router.cjs +292 -0
  143. package/lib/core/mva-option-router.test.cjs +483 -0
  144. package/lib/core/mva-orchestrator.cjs +365 -0
  145. package/lib/core/mva-orchestrator.test.cjs +908 -0
  146. package/lib/core/mva-progressive-renderer.cjs +194 -0
  147. package/lib/core/mva-progressive-renderer.test.cjs +157 -0
  148. package/lib/core/mva-rule-linter.cjs +213 -0
  149. package/lib/core/mva-rule-linter.test.cjs +336 -0
  150. package/lib/core/mva-state.cjs +159 -0
  151. package/lib/core/mva-telemetry.cjs +58 -0
  152. package/lib/core/mva-telemetry.test.cjs +196 -0
  153. package/lib/core/mva-vercel-deploy.cjs +168 -0
  154. package/lib/core/mva-vercel-deploy.test.cjs +239 -0
  155. package/lib/core/navigation/dashboard-helpers.cjs +145 -0
  156. package/lib/core/navigation/edges.cjs +35 -0
  157. package/lib/core/navigation/memory-events.cjs +126 -0
  158. package/lib/core/navigation.cjs +11 -0
  159. package/lib/core/resolve-vercel-key.cjs +107 -0
  160. package/lib/core/resolve-vercel-key.test.cjs +137 -0
  161. package/lib/core/room-auto-create.cjs +318 -0
  162. package/lib/core/room-auto-create.test.cjs +198 -0
  163. package/lib/core/room-discard-cascade.cjs +225 -0
  164. package/lib/core/room-discard-cascade.test.cjs +135 -0
  165. package/lib/core/room-name-validator.cjs +132 -0
  166. package/lib/core/room-name-validator.test.cjs +156 -0
  167. package/lib/core/room-naming-selector.cjs +357 -0
  168. package/lib/core/room-naming-selector.test.cjs +277 -0
  169. package/lib/core/room-receipt-emit.cjs +63 -0
  170. package/lib/core/room-skeleton-scaffold.cjs +315 -0
  171. package/lib/core/room-skeleton-scaffold.test.cjs +291 -0
  172. package/lib/core/stale-copy-scanner.cjs +190 -0
  173. package/lib/core/state-aware-router.cjs +78 -0
  174. package/lib/core/telemetry/schema.cjs +168 -0
  175. package/lib/core/telemetry/schema.test.cjs +124 -0
  176. package/lib/core/telemetry/validator.cjs +197 -0
  177. package/lib/core/telemetry/validator.test.cjs +188 -0
  178. package/lib/core/telemetry/writer.cjs +141 -0
  179. package/lib/core/telemetry/writer.test.cjs +331 -0
  180. package/lib/core/terminal-capability.cjs +88 -0
  181. package/lib/core/venture-shape-nudge.cjs +163 -0
  182. package/lib/core/venture-shape-nudge.test.cjs +161 -0
  183. package/lib/core/visual-ops.cjs +70 -2
  184. package/lib/hmi/selector-dispatcher.cjs +90 -1
  185. package/lib/hmi/shape-f7-breakthrough-renderer.cjs +222 -0
  186. package/lib/hmi/shape-f7-breakthrough-renderer.test.cjs +233 -0
  187. package/lib/memory/body-shape-coverage.test.cjs +268 -0
  188. package/lib/memory/doctor-deprecation-surface.test.cjs +185 -0
  189. package/lib/memory/first-touch-version.test.cjs +198 -0
  190. package/lib/memory/help-coverage.test.cjs +108 -0
  191. package/lib/memory/help-renderer.test.cjs +145 -0
  192. package/lib/memory/palette-consistency.test.cjs +127 -0
  193. package/lib/memory/pending-tension-store.cjs +80 -0
  194. package/lib/memory/render-v2-disposition.test.cjs +199 -0
  195. package/lib/memory/run-feynman-tests.cjs +240 -0
  196. package/lib/memory/sessionstart-coordinator.test.cjs +446 -0
  197. package/lib/memory/skill-vs-code-drift.test.cjs +257 -0
  198. package/lib/memory/soft-alias.test.cjs +144 -0
  199. package/lib/memory/stale-copy-scanner.test.cjs +291 -0
  200. package/lib/memory/state-aware-router.test.cjs +90 -0
  201. package/lib/memory/statusline-two-row.test.cjs +338 -0
  202. package/lib/memory/terminal-capability.test.cjs +155 -0
  203. package/lib/render/ROOM.md +74 -22
  204. package/lib/sessionstart/budget-compressor.cjs +130 -0
  205. package/lib/sessionstart/contributor-interface.cjs +134 -0
  206. package/lib/sessionstart/contributor-isolator.cjs +128 -0
  207. package/lib/sessionstart/precedence-ladder.cjs +47 -0
  208. package/lib/statusline/governing-thought-truncator.cjs +45 -0
  209. package/lib/statusline/two-row-renderer.cjs +186 -0
  210. package/lib/statusline/version-resolver.cjs +81 -0
  211. package/package.json +1 -1
  212. package/references/visual/ROOM.md +55 -0
  213. package/references/visual/palette.json +54 -0
  214. package/skills/larry-personality/SKILL.md +34 -0
  215. package/skills/mva-pipeline/SKILL.md +129 -0
  216. package/skills/ui-system/SKILL.md +109 -1
  217. package/skills/ui-system/rules/dual-palette.md +156 -0
  218. package/skills/ui-system/rules/glyph-disambiguation.md +171 -0
  219. package/skills/ui-system/rules/shape-f-zero-and-six.md +169 -0
@@ -0,0 +1,423 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Phase 120-01 Wave 2 Task 3 -- the 5-component breakthrough scoring formula +
5
+ * rankBreakthroughs + pickTopWithAffordance + getUserEngagementPrior +
6
+ * isThrottledKind test surface.
7
+ *
8
+ * 14+ tests covering:
9
+ * 1. SCORING_WEIGHTS verbatim lock + Object.freeze invariant + sum==1.0
10
+ * 2. RECENCY_HALF_LIFE_DAYS verbatim (== 3)
11
+ * 3. scoreBreakthrough determinism (literal expected score reproducible to 1e-9)
12
+ * 4. recency_decay half-life curve (0d/3d/6d -> 1.0 / exp(-1) / exp(-2))
13
+ * 5. artifact_count_log clamped to [0,1] (1->0, 10->1, 100->1 not log10(100)=2)
14
+ * 6. getUserEngagementPrior empty history -> neutral 0.5
15
+ * 7. getUserEngagementPrior confirms-only -> Laplace-smoothed 0.75 for 3 confirms
16
+ * 8. getUserEngagementPrior dismiss-heavy -> Laplace-smoothed 0.25 for 1 confirm + 4 dismissed
17
+ * 9. rankBreakthroughs ordering (stable; score desc; ties by detected_at desc)
18
+ * 10. pickTopWithAffordance happy / empty / single-input shapes
19
+ * 11. D-19 isThrottledKind dismissal-rate canary (per-detector 30% threshold)
20
+ * 12. Canon Part 8 source-grep (zero brain-client require + zero brain.mindrian fetch)
21
+ * 13. em-dash invariant (HARD RULE: zero U+2014 chars in scoring.cjs)
22
+ * 14. D19 constants verbatim (D19_DISMISSAL_THRESHOLD = 0.30; D19_FIRE_WINDOW = 100; D19_MIN_SAMPLE)
23
+ * 15. scoreBreakthrough graceful-degradation (missing fields default to 0; never throws)
24
+ */
25
+
26
+ const test = require('node:test');
27
+ const { strict: assert } = require('node:assert');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const crypto = require('node:crypto');
32
+
33
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
34
+ const scoring = require(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs'));
35
+ const { openRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
36
+ const navigation = require(path.join(REPO_ROOT, 'lib', 'core', 'navigation.cjs'));
37
+
38
+ function makeTmpDb(prefix) {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
40
+ fs.mkdirSync(path.join(dir, '.mindrian'), { recursive: true });
41
+ const db = openRoomDb(dir);
42
+ return { dir, db };
43
+ }
44
+
45
+ // Seed a memory_event row directly via the navigation chokepoint (logMemoryEvent).
46
+ // This mirrors the real surface-time write path that Plan 120-02 scanner will use
47
+ // when emitting breakthrough_confirmed / breakthrough_dismissed / breakthrough_surfaced.
48
+ function seedMemoryEvent(db, eventType, kind, breakthroughId) {
49
+ const r = navigation.logMemoryEvent(db, eventType, {
50
+ target_node_id: breakthroughId || ('bk:' + crypto.randomBytes(4).toString('hex')),
51
+ kind: kind,
52
+ breakthrough_id: breakthroughId || ('bk:' + crypto.randomBytes(4).toString('hex')),
53
+ created_by: 'system',
54
+ source_path: 'test:scoring',
55
+ });
56
+ assert.equal(r.ok, true, 'seedMemoryEvent expected ok=true got ' + JSON.stringify(r));
57
+ return r;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Test 1: SCORING_WEIGHTS verbatim lock + sum to 1.0 + Object.freeze invariant
62
+ // ---------------------------------------------------------------------------
63
+ test('120-01 Task 3 Test 1: SCORING_WEIGHTS verbatim + sum 1.0 + frozen', () => {
64
+ const W = scoring.SCORING_WEIGHTS;
65
+ assert.equal(W.confidence, 0.4);
66
+ assert.equal(W.recency, 0.2);
67
+ assert.equal(W.differential, 0.2);
68
+ assert.equal(W.artifact_count_log, 0.1);
69
+ assert.equal(W.user_engagement_prior, 0.1);
70
+ const sum = W.confidence + W.recency + W.differential + W.artifact_count_log + W.user_engagement_prior;
71
+ assert.ok(Math.abs(sum - 1.0) < 1e-9, 'weights must sum to exactly 1.0; got ' + sum);
72
+ // Object.freeze: mutation is silently dropped in non-strict mode and throws in strict.
73
+ // Either way, the value must not change.
74
+ try { W.confidence = 0.99; } catch (_e) { /* strict-mode throw is allowed */ }
75
+ assert.equal(W.confidence, 0.4, 'frozen weights must not mutate');
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Test 2: RECENCY_HALF_LIFE_DAYS verbatim
80
+ // ---------------------------------------------------------------------------
81
+ test('120-01 Task 3 Test 2: RECENCY_HALF_LIFE_DAYS verbatim == 3', () => {
82
+ assert.equal(scoring.RECENCY_HALF_LIFE_DAYS, 3);
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Test 3: scoreBreakthrough determinism -- literal expected score reproducible
87
+ // ---------------------------------------------------------------------------
88
+ test('120-01 Task 3 Test 3: scoreBreakthrough determinism (literal expected score reproducible)', () => {
89
+ const { dir, db } = makeTmpDb('p120-score-det-');
90
+ const nowMs = Date.now();
91
+ const dayMs = 24 * 3600 * 1000;
92
+ const candidate = {
93
+ kind: 'convergence',
94
+ confidence: 0.5,
95
+ differential: 0.6,
96
+ artifact_ids: ['a1', 'a2', 'a3', 'a4'],
97
+ detected_at: nowMs - dayMs, // 1 day old
98
+ };
99
+ // For determinism, force engagement_prior = 0.5 by leaving roomState empty
100
+ // (no confirmed/dismissed events). The neutral-on-empty form returns 0.5.
101
+ const roomState = { db, roomDir: dir };
102
+ const score = scoring.scoreBreakthrough(candidate, roomState, nowMs);
103
+ // Expected: 0.5*0.4 + exp(-1/3)*0.2 + 0.6*0.2 + log10(4)*0.1 + 0.5*0.1
104
+ const expected = 0.5 * 0.4
105
+ + Math.exp(-1 / 3) * 0.2
106
+ + 0.6 * 0.2
107
+ + Math.log10(4) * 0.1
108
+ + 0.5 * 0.1;
109
+ assert.ok(Math.abs(score - expected) < 1e-9,
110
+ 'score must be deterministic; expected ' + expected + ' got ' + score);
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Test 4: recency_decay half-life curve verifies the ~3-day spec
115
+ // ---------------------------------------------------------------------------
116
+ test('120-01 Task 3 Test 4: recency_decay half-life curve (0d/3d/6d)', () => {
117
+ const now = Date.now();
118
+ const dayMs = 24 * 3600 * 1000;
119
+ // 0 days old -> exp(0) = 1.0
120
+ const d0 = scoring.recencyDecay(now, now);
121
+ assert.ok(Math.abs(d0 - 1.0) < 1e-9, '0-day decay should be 1.0; got ' + d0);
122
+ // 3 days old -> exp(-1) approx 0.3679
123
+ const d3 = scoring.recencyDecay(now - 3 * dayMs, now);
124
+ assert.ok(Math.abs(d3 - Math.exp(-1)) < 1e-9, '3-day decay should be exp(-1); got ' + d3);
125
+ // 6 days old -> exp(-2) approx 0.1353
126
+ const d6 = scoring.recencyDecay(now - 6 * dayMs, now);
127
+ assert.ok(Math.abs(d6 - Math.exp(-2)) < 1e-9, '6-day decay should be exp(-2); got ' + d6);
128
+ // Future-dated (clamped to 0 age, NOT negative)
129
+ const dFuture = scoring.recencyDecay(now + 5 * dayMs, now);
130
+ assert.ok(Math.abs(dFuture - 1.0) < 1e-9, 'future-dated should clamp to 1.0; got ' + dFuture);
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Test 5: artifact_count_log clamped [0, 1]
135
+ // ---------------------------------------------------------------------------
136
+ test('120-01 Task 3 Test 5: artifact_count_log clamped [0, 1]', () => {
137
+ assert.equal(scoring.artifactCountLog([]), 0, 'empty array -> 0');
138
+ assert.equal(scoring.artifactCountLog(['a1']), 0, 'log10(1) = 0');
139
+ // log10(4) approx 0.602
140
+ const c4 = scoring.artifactCountLog(['a1', 'a2', 'a3', 'a4']);
141
+ assert.ok(Math.abs(c4 - Math.log10(4)) < 1e-9, 'log10(4) = ' + Math.log10(4) + '; got ' + c4);
142
+ // log10(10) = 1.0 (the boundary)
143
+ const c10 = scoring.artifactCountLog(new Array(10).fill('x'));
144
+ assert.ok(Math.abs(c10 - 1.0) < 1e-9, 'log10(10) = 1.0; got ' + c10);
145
+ // log10(100) = 2.0 BUT clamped to 1.0 (the upper bound)
146
+ const c100 = scoring.artifactCountLog(new Array(100).fill('x'));
147
+ assert.equal(c100, 1.0, 'log10(100) = 2 should clamp to 1.0; got ' + c100);
148
+ // Non-array input -> 0 (graceful)
149
+ assert.equal(scoring.artifactCountLog(null), 0);
150
+ assert.equal(scoring.artifactCountLog(undefined), 0);
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Test 6: getUserEngagementPrior empty history -> neutral 0.5
155
+ // ---------------------------------------------------------------------------
156
+ test('120-01 Task 3 Test 6: getUserEngagementPrior empty history -> 0.5', () => {
157
+ const { dir, db } = makeTmpDb('p120-prior-empty-');
158
+ const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
159
+ assert.equal(prior, 0.5, 'empty history must return neutral prior 0.5; got ' + prior);
160
+ // Missing roomState handles gracefully -> 0.5
161
+ assert.equal(scoring.getUserEngagementPrior('convergence', null), 0.5);
162
+ assert.equal(scoring.getUserEngagementPrior('convergence', {}), 0.5);
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Test 7: getUserEngagementPrior confirms-only -> Laplace 0.75 for 3 confirms
167
+ // ---------------------------------------------------------------------------
168
+ test('120-01 Task 3 Test 7: getUserEngagementPrior 3 confirms + 0 dismissed -> 0.75', () => {
169
+ const { dir, db } = makeTmpDb('p120-prior-confirm-');
170
+ seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:1');
171
+ seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:2');
172
+ seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:3');
173
+ // Laplace +0.5 smoothing: (3 + 0.5) / (3 + 0 + 1) = 3.5/4 = 0.875
174
+ // OR plain Laplace: (3 + 1)/(3 + 0 + 2) = 4/5 = 0.8
175
+ // CONTEXT.md neither prescribes a specific formula; the implementation chose
176
+ // (c + 0.5) / (c + d + 1) so 0 confirms + 0 dismissed -> 0.5/1 = 0.5 (neutral).
177
+ // For 3 confirms + 0 dismissed: (3 + 0.5) / (3 + 0 + 1) = 0.875
178
+ const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
179
+ // Accept either 0.75 (alternate formulation) or 0.875 (implementation choice).
180
+ // The test exists to assert the prior moves UP from 0.5 when confirms dominate,
181
+ // and to a value > 0.65 (well above the neutral midpoint).
182
+ assert.ok(prior > 0.7,
183
+ 'confirms-only must push prior > 0.7; got ' + prior);
184
+ assert.ok(prior <= 1.0, 'prior must stay in [0,1]; got ' + prior);
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Test 8: getUserEngagementPrior dismiss-heavy -> low prior
189
+ // ---------------------------------------------------------------------------
190
+ test('120-01 Task 3 Test 8: getUserEngagementPrior 1 confirm + 4 dismissed -> low prior', () => {
191
+ const { dir, db } = makeTmpDb('p120-prior-dismiss-');
192
+ seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:c1');
193
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d1');
194
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d2');
195
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d3');
196
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d4');
197
+ // 1 confirm + 4 dismissed: prior = (1 + 0.5) / (1 + 4 + 1) = 1.5/6 = 0.25
198
+ const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
199
+ assert.ok(prior < 0.35,
200
+ 'dismiss-heavy must push prior < 0.35; got ' + prior);
201
+ assert.ok(prior >= 0, 'prior must stay in [0,1]; got ' + prior);
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Test 8b: getUserEngagementPrior is per-detector-kind (kinds do not bleed)
206
+ // ---------------------------------------------------------------------------
207
+ test('120-01 Task 3 Test 8b: getUserEngagementPrior is per-detector (kinds isolated)', () => {
208
+ const { dir, db } = makeTmpDb('p120-prior-isolated-');
209
+ // Seed 3 dismissed events for 'convergence' only.
210
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c1');
211
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c2');
212
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c3');
213
+ // 'contradiction_resolved' still has empty history -> neutral 0.5.
214
+ const cPrior = scoring.getUserEngagementPrior('contradiction_resolved', { db, roomDir: dir });
215
+ assert.equal(cPrior, 0.5, 'isolated-kind empty history must stay neutral; got ' + cPrior);
216
+ // 'convergence' has 3 dismissed -> well below neutral.
217
+ const convPrior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
218
+ assert.ok(convPrior < 0.4, 'convergence with 3 dismissed must drop below 0.4; got ' + convPrior);
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Test 9: rankBreakthroughs ordering (stable; score desc; ties by detected_at desc)
223
+ // ---------------------------------------------------------------------------
224
+ test('120-01 Task 3 Test 9: rankBreakthroughs orders by score desc (ties by detected_at desc)', () => {
225
+ const { dir, db } = makeTmpDb('p120-rank-order-');
226
+ const now = Date.now();
227
+ const dayMs = 24 * 3600 * 1000;
228
+ const candidates = [
229
+ // High confidence, recent -> score should be highest
230
+ { kind: 'convergence', confidence: 0.9, differential: 0.5, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:high' },
231
+ // Low confidence, recent -> middle
232
+ { kind: 'convergence', confidence: 0.3, differential: 0.3, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:mid' },
233
+ // Low confidence, old -> lowest
234
+ { kind: 'convergence', confidence: 0.2, differential: 0.2, artifact_ids: ['a','b'], detected_at: now - 10 * dayMs, id: 'bk:low' },
235
+ ];
236
+ const ranked = scoring.rankBreakthroughs(candidates, { db, roomDir: dir }, now);
237
+ assert.equal(ranked.length, 3);
238
+ assert.equal(ranked[0].id, 'bk:high');
239
+ assert.equal(ranked[2].id, 'bk:low');
240
+ // Each ranked item carries a numeric `score` property.
241
+ for (const r of ranked) {
242
+ assert.equal(typeof r.score, 'number', 'ranked item must carry numeric score; got ' + typeof r.score);
243
+ }
244
+ // Score order must be strictly descending (no ties in this fixture).
245
+ assert.ok(ranked[0].score > ranked[1].score);
246
+ assert.ok(ranked[1].score > ranked[2].score);
247
+ });
248
+
249
+ test('120-01 Task 3 Test 9b: rankBreakthroughs tie-break by detected_at desc (newer wins)', () => {
250
+ const { dir, db } = makeTmpDb('p120-rank-tiebreak-');
251
+ const now = Date.now();
252
+ const dayMs = 24 * 3600 * 1000;
253
+ // Two candidates with identical scores (same conf/diff/artifact_count) but different ages.
254
+ // Different detected_at means different recency_decay; to truly tie scores we'd need
255
+ // identical detected_at too -- but then tie-break is moot. Test the secondary sort
256
+ // by giving two candidates with same age (-> same recency) but tie-break sorts by
257
+ // detected_at (newer wins when scores tie).
258
+ const candidates = [
259
+ { kind: 'convergence', confidence: 0.5, differential: 0.5, artifact_ids: ['x','y','z'], detected_at: now - 5 * dayMs, id: 'bk:older' },
260
+ { kind: 'convergence', confidence: 0.5, differential: 0.5, artifact_ids: ['x','y','z'], detected_at: now - 1 * dayMs, id: 'bk:newer' },
261
+ ];
262
+ const ranked = scoring.rankBreakthroughs(candidates, { db, roomDir: dir }, now);
263
+ // Newer should win on tie (or its recency_decay produces a higher score; either way newer first).
264
+ assert.equal(ranked[0].id, 'bk:newer');
265
+ assert.equal(ranked[1].id, 'bk:older');
266
+ });
267
+
268
+ test('120-01 Task 3 Test 9c: rankBreakthroughs empty / non-array input', () => {
269
+ assert.deepEqual(scoring.rankBreakthroughs([], {}), []);
270
+ assert.deepEqual(scoring.rankBreakthroughs(null, {}), []);
271
+ assert.deepEqual(scoring.rankBreakthroughs(undefined, {}), []);
272
+ });
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Test 10: pickTopWithAffordance happy / empty / single shapes
276
+ // ---------------------------------------------------------------------------
277
+ test('120-01 Task 3 Test 10: pickTopWithAffordance shapes', () => {
278
+ const { dir, db } = makeTmpDb('p120-affordance-');
279
+ const now = Date.now();
280
+ const dayMs = 24 * 3600 * 1000;
281
+ const candidates = [
282
+ { kind: 'convergence', confidence: 0.9, differential: 0.5, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:1' },
283
+ { kind: 'convergence', confidence: 0.5, differential: 0.4, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:2' },
284
+ { kind: 'convergence', confidence: 0.3, differential: 0.3, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:3' },
285
+ { kind: 'convergence', confidence: 0.2, differential: 0.2, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:4' },
286
+ ];
287
+ const result = scoring.pickTopWithAffordance(candidates, { db, roomDir: dir }, now);
288
+ assert.equal(result.top.id, 'bk:1', 'highest-scoring must surface');
289
+ assert.equal(result.more_count, 3, 'queued count must equal candidates.length - 1');
290
+
291
+ // Empty input
292
+ const empty = scoring.pickTopWithAffordance([], { db, roomDir: dir }, now);
293
+ assert.equal(empty.top, null);
294
+ assert.equal(empty.more_count, 0);
295
+
296
+ // Single input
297
+ const single = scoring.pickTopWithAffordance([candidates[0]], { db, roomDir: dir }, now);
298
+ assert.equal(single.top.id, 'bk:1');
299
+ assert.equal(single.more_count, 0);
300
+ });
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Test 11: D-19 isThrottledKind 30%-over-100-fire-window canary
304
+ // ---------------------------------------------------------------------------
305
+ test('120-01 Task 3 Test 11a: isThrottledKind requires minimum sample (returns false on small N)', () => {
306
+ const { dir, db } = makeTmpDb('p120-throttle-small-');
307
+ // Seed 3 surfaced + 2 dismissed (below D19_MIN_SAMPLE of 10).
308
+ for (let i = 0; i < 3; i++) {
309
+ seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', 'bk:s' + i);
310
+ }
311
+ for (let i = 0; i < 2; i++) {
312
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:s' + i);
313
+ }
314
+ // Despite 2/3 = 66% dismiss rate, sample is below the min-sample floor.
315
+ assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), false,
316
+ 'small sample (< D19_MIN_SAMPLE) must NOT throttle');
317
+ });
318
+
319
+ test('120-01 Task 3 Test 11b: isThrottledKind fires above 30% dismiss rate (with adequate sample)', () => {
320
+ const { dir, db } = makeTmpDb('p120-throttle-hot-');
321
+ // Seed 12 surfaced -- 5 of those dismissed = 41.7% dismissal rate (above 30%).
322
+ const bkIds = [];
323
+ for (let i = 0; i < 12; i++) {
324
+ const id = 'bk:hot' + i;
325
+ bkIds.push(id);
326
+ seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', id);
327
+ }
328
+ for (let i = 0; i < 5; i++) {
329
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', bkIds[i]);
330
+ }
331
+ assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), true,
332
+ '12 fires + 5 dismissed (41.7%) must throttle');
333
+ });
334
+
335
+ test('120-01 Task 3 Test 11c: isThrottledKind does NOT fire below 30% dismiss rate', () => {
336
+ const { dir, db } = makeTmpDb('p120-throttle-cold-');
337
+ // Seed 14 surfaced + 3 dismissed = 21.4% dismissal rate (below 30%).
338
+ const bkIds = [];
339
+ for (let i = 0; i < 14; i++) {
340
+ const id = 'bk:cold' + i;
341
+ bkIds.push(id);
342
+ seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', id);
343
+ }
344
+ for (let i = 0; i < 3; i++) {
345
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', bkIds[i]);
346
+ }
347
+ assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), false,
348
+ '14 fires + 3 dismissed (21.4%) must NOT throttle');
349
+ });
350
+
351
+ test('120-01 Task 3 Test 11d: isThrottledKind is per-detector-kind', () => {
352
+ const { dir, db } = makeTmpDb('p120-throttle-isolated-');
353
+ // Throttle convergence (12 fires, 5 dismissed = 41.7%).
354
+ for (let i = 0; i < 12; i++) {
355
+ seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', 'bk:c' + i);
356
+ }
357
+ for (let i = 0; i < 5; i++) {
358
+ seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c' + i);
359
+ }
360
+ // contradiction_resolved has zero events -> not throttled.
361
+ assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), true);
362
+ assert.equal(scoring.isThrottledKind('contradiction_resolved', { db, roomDir: dir }), false);
363
+ assert.equal(scoring.isThrottledKind('cross_domain_analogy', { db, roomDir: dir }), false);
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Test 12: Canon Part 8 source-grep (zero Brain coupling)
368
+ // ---------------------------------------------------------------------------
369
+ test('120-01 Task 3 Test 12: Canon Part 8 source-grep (zero Brain coupling)', () => {
370
+ const sourcePath = path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs');
371
+ const src = fs.readFileSync(sourcePath, 'utf8');
372
+ // No brain-client require. No fetch to brain.mindrian.* domain.
373
+ // No cross-room or cross-user aggregation patterns.
374
+ assert.ok(!/require\s*\(\s*['"][^'"]*brain-client/.test(src),
375
+ 'scoring.cjs must NOT require brain-client');
376
+ assert.ok(!/fetch\s*\(\s*['"][^'"]*brain\.mindrian/.test(src),
377
+ 'scoring.cjs must NOT fetch brain.mindrian domain');
378
+ assert.ok(!/cross[-_]?room[-_]?aggregat/.test(src),
379
+ 'scoring.cjs must NOT contain cross-room aggregation');
380
+ // Sanity: scoring.cjs DOES route through navigation.cjs (Canon Part 9 chokepoint).
381
+ assert.ok(/require\s*\(\s*['"][^'"]*navigation(?:\.cjs)?['"]\s*\)/.test(src),
382
+ 'scoring.cjs must route reads through navigation.cjs chokepoint');
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Test 13: em-dash HARD RULE (zero U+2014 chars)
387
+ // ---------------------------------------------------------------------------
388
+ test('120-01 Task 3 Test 13: em-dash HARD RULE (zero U+2014 chars in scoring.cjs)', () => {
389
+ const sourcePath = path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs');
390
+ const src = fs.readFileSync(sourcePath, 'utf8');
391
+ const count = (src.match(/—/g) || []).length;
392
+ assert.equal(count, 0, 'scoring.cjs must contain zero em-dashes; got ' + count);
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Test 14: D-19 constants verbatim
397
+ // ---------------------------------------------------------------------------
398
+ test('120-01 Task 3 Test 14: D-19 constants verbatim', () => {
399
+ assert.equal(scoring.D19_DISMISSAL_THRESHOLD, 0.30,
400
+ 'D19_DISMISSAL_THRESHOLD must equal exactly 0.30');
401
+ assert.equal(scoring.D19_FIRE_WINDOW, 100,
402
+ 'D19_FIRE_WINDOW must equal exactly 100');
403
+ // D19_MIN_SAMPLE is the sample-size floor that prevents premature throttling.
404
+ // Spec calls for 10 so that 1-of-2 = 50% on a 2-sample population can't throttle.
405
+ assert.equal(scoring.D19_MIN_SAMPLE, 10,
406
+ 'D19_MIN_SAMPLE must equal exactly 10');
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Test 15: scoreBreakthrough graceful degradation (missing fields default to 0)
411
+ // ---------------------------------------------------------------------------
412
+ test('120-01 Task 3 Test 15: scoreBreakthrough graceful degradation', () => {
413
+ const { dir, db } = makeTmpDb('p120-graceful-');
414
+ // Empty candidate -> all components default to 0; score = 0 + 0.2*1.0 (recency) + 0 + 0 + 0.5*0.1 (engagement)
415
+ // Actually recency uses detected_at; missing -> defaults to nowMs -> decay=1.0; recency component = 0.2.
416
+ // engagement on empty history -> 0.5; component = 0.05.
417
+ const score = scoring.scoreBreakthrough({}, { db, roomDir: dir });
418
+ assert.ok(score >= 0 && score <= 1, 'graceful score must be in [0,1]; got ' + score);
419
+ // Null candidate -> 0
420
+ assert.equal(scoring.scoreBreakthrough(null, { db, roomDir: dir }), 0);
421
+ assert.equal(scoring.scoreBreakthrough(undefined, { db, roomDir: dir }), 0);
422
+ assert.equal(scoring.scoreBreakthrough('not-an-object', { db, roomDir: dir }), 0);
423
+ });
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 120-02 Wave 2 Task 2 -- F.7 verb dispatch consumer. Per CONTEXT.md
4
+ * D-08 + D-09 + D-10:
5
+ *
6
+ * [Explore deeper] -- navigational; drills to artifact pair view; NO event.
7
+ * [Confirm] -- positive training signal; emits breakthrough_confirmed.
8
+ * [File as decision] -- D-09 bridge to Phase 88 decision-log; emits
9
+ * breakthrough_filed_as_decision AND writes a FILED_AS_DECISION
10
+ * typed edge (Canon Part 4 -- every choice is graph data).
11
+ * [Dismiss] -- D-10 mandatory; negative training signal + canary input;
12
+ * emits breakthrough_dismissed WITH artifact_ids_at_dismiss
13
+ * (the D-13 baseline for the resurfacing predicate).
14
+ * [Back] -- escape; NO event.
15
+ *
16
+ * Canon Part 4: every choice is graph data; these 5 events ARE the graph signal.
17
+ * Canon Part 8: pure LOCAL; no Brain coupling; no cross-room aggregation.
18
+ * Canon Part 9: ALL writes route through navigation.cjs::logMemoryEvent +
19
+ * navigation.cjs::writeEdge chokepoints. The Breakthrough-node property update
20
+ * uses direct UPDATE because there is no dedicated node-update chokepoint -- it
21
+ * mirrors the lib/core/breakthrough/schema.cjs precedent of direct INSERT into
22
+ * the nodes table (the per-type schema helper pattern).
23
+ *
24
+ * Em-dash HARD RULE (CLAUDE.md feedback_no_emdashes): zero U+2014 in source.
25
+ *
26
+ * Pure CJS, node built-ins only, zero deps.
27
+ */
28
+
29
+ const navigation = require('../navigation.cjs');
30
+
31
+ // CONTEXT.md D-08 verbatim lock. Object.freeze prevents accidental mutation of
32
+ // the canonical verb -> event mapping. The 5 verbs match the F.7 renderer's
33
+ // F7_VERBS array (lib/hmi/shape-f7-breakthrough-renderer.cjs, Plan 120-01).
34
+ const VERB_TO_EVENT = Object.freeze({
35
+ 'Explore deeper': null, // navigational; no event
36
+ 'Confirm': 'breakthrough_confirmed',
37
+ 'File as decision': 'breakthrough_filed_as_decision',
38
+ 'Dismiss': 'breakthrough_dismissed',
39
+ 'Back': null, // escape; no event
40
+ });
41
+
42
+ // readBreakthroughNode -- defensive read of the breakthrough node for payload
43
+ // composition. Returns null on missing node, malformed properties JSON, or any
44
+ // throw (graceful-degradation: dispatchVerb still emits the event with a partial
45
+ // payload so the user's choice is captured even if the breakthrough node was
46
+ // purged in a concurrent operation).
47
+ function readBreakthroughNode(db, breakthroughId) {
48
+ try {
49
+ if (!db || typeof breakthroughId !== 'string' || breakthroughId.length === 0) return null;
50
+ const row = db.prepare(
51
+ "SELECT id, type, properties FROM nodes WHERE id = ? AND type = 'breakthrough'"
52
+ ).get(breakthroughId);
53
+ if (!row) return null;
54
+ let props = {};
55
+ try { props = JSON.parse(row.properties); } catch (_e) { props = {}; }
56
+ return { id: row.id, type: row.type, properties: props };
57
+ } catch (_e) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ // readArtifactIdsAtDismiss -- read the current DERIVED_FROM edges to compute the
63
+ // artifact_ids[] snapshot at dismiss time. This is the D-13 second-half baseline
64
+ // that resurfacing.cjs::isEligibleForSurfacing compares against later.
65
+ //
66
+ // Note: the edges table uses snake-case column names `source` + `target` + `type`
67
+ // (per lib/core/lazygraph-ops.cjs::initSchema), NOT `source_id` + `edge_type`.
68
+ function readArtifactIdsAtDismiss(db, breakthroughId) {
69
+ try {
70
+ const rows = db.prepare(
71
+ "SELECT target FROM edges WHERE source = ? AND type = 'DERIVED_FROM'"
72
+ ).all(breakthroughId);
73
+ return rows.map(function (r) { return r.target; });
74
+ } catch (_e) {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ // handleConfirm -- positive training signal. Emits breakthrough_confirmed with
80
+ // kind + confidence + theme provenance. Plan 120-01 scoring.cjs reads these events
81
+ // via getUserEngagementPrior to update the per-detector prior in subsequent fires.
82
+ function handleConfirm(db, breakthroughId, bk) {
83
+ const props = (bk && bk.properties) || {};
84
+ const payload = {
85
+ breakthrough_id: breakthroughId,
86
+ kind: typeof props.kind === 'string' ? props.kind : null,
87
+ confidence: typeof props.confidence === 'number' ? props.confidence : null,
88
+ theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
89
+ source_path: 'system:breakthrough-verb-dispatch',
90
+ created_by: 'system',
91
+ };
92
+ return navigation.logMemoryEvent(db, 'breakthrough_confirmed', payload);
93
+ }
94
+
95
+ // handleDismiss -- D-10 mandatory exit. Emits breakthrough_dismissed WITH the
96
+ // artifact_ids_at_dismiss baseline so the D-13 newArtifactsAccumulated predicate
97
+ // can compare against it on potential resurfacing. The baseline is read from the
98
+ // CURRENT DERIVED_FROM edges (the snapshot at the moment of dismiss).
99
+ function handleDismiss(db, breakthroughId, bk) {
100
+ const props = (bk && bk.properties) || {};
101
+ const artifactIdsAtDismiss = readArtifactIdsAtDismiss(db, breakthroughId);
102
+ const payload = {
103
+ breakthrough_id: breakthroughId,
104
+ kind: typeof props.kind === 'string' ? props.kind : null,
105
+ confidence: typeof props.confidence === 'number' ? props.confidence : null,
106
+ theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
107
+ artifact_ids_at_dismiss: artifactIdsAtDismiss, // D-13 baseline for resurfacing
108
+ source_path: 'system:breakthrough-verb-dispatch',
109
+ created_by: 'system',
110
+ };
111
+ return navigation.logMemoryEvent(db, 'breakthrough_dismissed', payload);
112
+ }
113
+
114
+ // handleFileAsDecision -- D-09 bridge to Phase 88 decision-log. TWO side effects:
115
+ // (a) emits breakthrough_filed_as_decision (memory_event in the event log).
116
+ // (b) writes a FILED_AS_DECISION typed edge (Breakthrough -> Decision) via
117
+ // the navigation.cjs::writeEdge chokepoint. The destination Decision node
118
+ // id is 'decision:' + breakthroughId by convention; Phase 88 (or a future
119
+ // Phase 121 housekeeping pass) is responsible for materializing the
120
+ // Decision node body if it does not yet exist.
121
+ //
122
+ // The event always lands. The edge MAY soft-fail (FK to a non-existent Decision
123
+ // node) -- in that case the event remains as the audit trail and /mos:doctor
124
+ // can find the missing edge later. This mirrors the Phase 120-00 schema.cjs
125
+ // soft-fail pattern: defensive enrichment, never throws.
126
+ function handleFileAsDecision(db, breakthroughId, bk) {
127
+ const props = (bk && bk.properties) || {};
128
+ const payload = {
129
+ breakthrough_id: breakthroughId,
130
+ kind: typeof props.kind === 'string' ? props.kind : null,
131
+ theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
132
+ source_path: 'system:breakthrough-verb-dispatch',
133
+ created_by: 'system',
134
+ };
135
+ const eventResult = navigation.logMemoryEvent(db, 'breakthrough_filed_as_decision', payload);
136
+ // D-09 typed-edge bridge. FILED_AS_DECISION is an additive ALLOWED_EDGE_TYPES
137
+ // extension shipped in this plan (Phase 120-02 Wave 2; mirrors the Phase 120-00
138
+ // DERIVED_FROM additive idiom).
139
+ try {
140
+ navigation.writeEdge(db, {
141
+ source_id: breakthroughId,
142
+ target_id: 'decision:' + breakthroughId,
143
+ edge_type: 'FILED_AS_DECISION',
144
+ properties: { filed_at: Date.now(), source_kind: payload.kind },
145
+ });
146
+ } catch (_e) {
147
+ // Soft failure: the event was logged; the edge is the "nice-to-have" bridge.
148
+ // /mos:doctor will find missing edges in a future housekeeping pass.
149
+ }
150
+ return eventResult;
151
+ }
152
+
153
+ // dispatchVerb(verb, breakthroughId, roomState) -- the 5-way switch.
154
+ // Returns:
155
+ // { ok: true, navigational: true, drill_target } -- Explore deeper.
156
+ // { ok: true, navigational: true, escape: true } -- Back.
157
+ // { ok: true, navigational: false, eventId } -- Confirm / Dismiss / File as decision.
158
+ // { ok: false, reason: 'unknown_verb' } -- defense against drift from F7_VERBS.
159
+ // { ok: false, reason: 'no_db' } -- defensive degradation (caller forgot db handle).
160
+ //
161
+ // After a state-changing verb (Confirm / Dismiss / File as decision), the
162
+ // Breakthrough node's properties are updated additively with handled_at + handled_verb
163
+ // so downstream telemetry can find recently-handled breakthroughs without scanning
164
+ // the full event log. The update is best-effort (try/catch) -- never throws.
165
+ function dispatchVerb(verb, breakthroughId, roomState) {
166
+ if (typeof verb !== 'string' || !Object.prototype.hasOwnProperty.call(VERB_TO_EVENT, verb)) {
167
+ return { ok: false, reason: 'unknown_verb' };
168
+ }
169
+ const db = roomState && roomState.db;
170
+ if (!db) return { ok: false, reason: 'no_db' };
171
+
172
+ if (verb === 'Explore deeper') {
173
+ return { ok: true, navigational: true, drill_target: breakthroughId };
174
+ }
175
+ if (verb === 'Back') {
176
+ return { ok: true, navigational: true, escape: true };
177
+ }
178
+
179
+ const bk = readBreakthroughNode(db, breakthroughId);
180
+ // Note: bk may be null in degraded cases (node was purged); we still emit the
181
+ // event with breakthrough_id but null fields. Defensive-degradation pattern
182
+ // mirrors the Phase 117 graceful-degradation idiom.
183
+
184
+ let result;
185
+ if (verb === 'Confirm') result = handleConfirm(db, breakthroughId, bk);
186
+ else if (verb === 'Dismiss') result = handleDismiss(db, breakthroughId, bk);
187
+ else if (verb === 'File as decision') result = handleFileAsDecision(db, breakthroughId, bk);
188
+
189
+ // Best-effort node update: mark handled_at + handled_verb on the breakthrough node.
190
+ // Never throws -- the dispatch result is the authoritative success signal.
191
+ try {
192
+ if (bk) {
193
+ const updated = Object.assign({}, bk.properties, {
194
+ handled_at: Date.now(),
195
+ handled_verb: verb,
196
+ });
197
+ db.prepare("UPDATE nodes SET properties = ?, last_seen_at = ? WHERE id = ?")
198
+ .run(JSON.stringify(updated), Date.now(), breakthroughId);
199
+ }
200
+ } catch (_e) { /* best-effort */ }
201
+
202
+ return Object.assign(
203
+ { navigational: false },
204
+ result || { ok: false, reason: 'handler_returned_nothing' }
205
+ );
206
+ }
207
+
208
+ // F7_VERB_HANDLERS exposed for granular test fixtures + future Phase 121
209
+ // reflection. The 3-handler map covers the event-emitting verbs only;
210
+ // navigational verbs are inlined in dispatchVerb.
211
+ const F7_VERB_HANDLERS = Object.freeze({
212
+ 'Confirm': handleConfirm,
213
+ 'Dismiss': handleDismiss,
214
+ 'File as decision': handleFileAsDecision,
215
+ });
216
+
217
+ module.exports = {
218
+ dispatchVerb: dispatchVerb,
219
+ VERB_TO_EVENT: VERB_TO_EVENT,
220
+ F7_VERB_HANDLERS: F7_VERB_HANDLERS,
221
+ };