@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,256 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Phase 120-00 Wave 1 Task 3 -- the Breakthrough graph schema + the D-20 HARD FLOOR
5
+ * structural enforcement test surface.
6
+ *
7
+ * 12 tests covering:
8
+ * 1. validateProvenance happy path
9
+ * 2. validateProvenance rejects empty artifact_ids (D-20 HARD FLOOR)
10
+ * 3. validateProvenance rejects empty-string artifact ids
11
+ * 4. validateProvenance rejects missing breakthrough.id
12
+ * 5. writeBreakthrough happy path -- node + edges land via atomic transaction
13
+ * 6. writeBreakthrough rejects provenance-less input
14
+ * 7. D-20 CONSTITUTIONAL TEST: post-write SQL invariant
15
+ * SELECT COUNT(*) FROM edges WHERE source=$bk AND type='DERIVED_FROM' >= 1
16
+ * 8. Atomic rollback on writeEdge failure (FK violation -- target missing)
17
+ * 9. Initial surfaced flag is false
18
+ * 10. D-20 BATCH INVARIANT: zero orphaned breakthroughs after all writes
19
+ * 11. Canon Part 8 source-grep (zero brain-client require)
20
+ * 12. writeBreakthrough does NOT emit breakthrough_surfaced (scanner does that)
21
+ */
22
+
23
+ const test = require('node:test');
24
+ const { strict: assert } = require('node:assert');
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
30
+ const schema = require(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'schema.cjs'));
31
+ const { openRoomDb, closeRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
32
+
33
+ function makeTmpDb(prefix) {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
35
+ fs.mkdirSync(path.join(dir, '.mindrian'), { recursive: true });
36
+ const db = openRoomDb(dir);
37
+ return { dir, db };
38
+ }
39
+
40
+ function seedArtifact(db, id) {
41
+ const nowMs = Date.now();
42
+ db.prepare(
43
+ "INSERT INTO nodes (id, type, properties, source_path, created_by, review_status, created_at, last_seen_at) " +
44
+ "VALUES (?, 'artifact', '{}', 'test', 'system', 'confirmed', ?, ?)"
45
+ ).run(id, nowMs, nowMs);
46
+ }
47
+
48
+ test('120-00 Task 3 Test 1: validateProvenance happy path', () => {
49
+ const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: ['a1', 'a2'] });
50
+ assert.equal(r.ok, true);
51
+ assert.deepEqual(r.sanitized_artifact_ids, ['a1', 'a2']);
52
+ });
53
+
54
+ test('120-00 Task 3 Test 2: validateProvenance D-20 HARD FLOOR rejects empty artifact_ids', () => {
55
+ const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: [] });
56
+ assert.equal(r.ok, false);
57
+ assert.equal(r.reason, 'provenance_required');
58
+ });
59
+
60
+ test('120-00 Task 3 Test 3: validateProvenance rejects empty-string artifact ids', () => {
61
+ const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: ['', null, undefined] });
62
+ assert.equal(r.ok, false);
63
+ assert.equal(r.reason, 'provenance_required');
64
+ });
65
+
66
+ test('120-00 Task 3 Test 4: validateProvenance rejects missing breakthrough.id', () => {
67
+ const r = schema.validateProvenance({ artifact_ids: ['a1'] });
68
+ assert.equal(r.ok, false);
69
+ assert.equal(r.reason, 'breakthrough_id_required');
70
+ });
71
+
72
+ test('120-00 Task 3 Test 4b: validateProvenance rejects non-object input', () => {
73
+ assert.equal(schema.validateProvenance(null).ok, false);
74
+ assert.equal(schema.validateProvenance(undefined).ok, false);
75
+ assert.equal(schema.validateProvenance('string').ok, false);
76
+ });
77
+
78
+ test('120-00 Task 3 Test 5: writeBreakthrough happy path -- node + edges land atomically', () => {
79
+ const { dir, db } = makeTmpDb('p120-bk-happy-');
80
+ seedArtifact(db, 'a1');
81
+ seedArtifact(db, 'a2');
82
+ const nowMs = Date.now();
83
+ const candidate = {
84
+ id: 'bk:happy',
85
+ kind: 'convergence',
86
+ confidence: 0.55,
87
+ artifact_ids: ['a1', 'a2'],
88
+ theme: 'X',
89
+ differential: 0.6,
90
+ cross_section_linked: false,
91
+ detected_at: nowMs,
92
+ window_start: nowMs - 14 * 86400000,
93
+ window_end: nowMs,
94
+ };
95
+ const r = schema.writeBreakthrough(db, candidate);
96
+ assert.equal(r.ok, true, 'writeBreakthrough should succeed; got reason=' + (r.reason || ''));
97
+ assert.equal(r.breakthroughId, 'bk:happy');
98
+ assert.equal(r.edgeIds.length, 2);
99
+
100
+ // Verify the breakthrough node landed.
101
+ const bkRow = db.prepare("SELECT id, type, properties FROM nodes WHERE id = 'bk:happy'").get();
102
+ assert.equal(bkRow.type, 'breakthrough');
103
+ const bkProps = JSON.parse(bkRow.properties);
104
+ assert.equal(bkProps.kind, 'convergence');
105
+ assert.equal(bkProps.confidence, 0.55);
106
+
107
+ // Verify both DERIVED_FROM edges landed.
108
+ const edgeCount = db.prepare(
109
+ "SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:happy' AND type = 'DERIVED_FROM'"
110
+ ).get().c;
111
+ assert.equal(edgeCount, 2, 'two DERIVED_FROM edges expected');
112
+
113
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
114
+ });
115
+
116
+ test('120-00 Task 3 Test 6: writeBreakthrough rejects provenance-less input', () => {
117
+ const { dir, db } = makeTmpDb('p120-bk-bad-');
118
+ const r = schema.writeBreakthrough(db, { id: 'bk:bad', artifact_ids: [] });
119
+ assert.equal(r.ok, false);
120
+ assert.equal(r.reason, 'provenance_required');
121
+
122
+ // Verify NO breakthrough node landed.
123
+ const bkRow = db.prepare("SELECT id FROM nodes WHERE id = 'bk:bad'").get();
124
+ assert.equal(bkRow, undefined, 'no breakthrough node should land');
125
+
126
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
127
+ });
128
+
129
+ test('120-00 Task 3 Test 7: D-20 CONSTITUTIONAL TEST -- Cypher invariant SQL query', () => {
130
+ const { dir, db } = makeTmpDb('p120-bk-d20-');
131
+ seedArtifact(db, 'a1');
132
+ seedArtifact(db, 'a2');
133
+ const r = schema.writeBreakthrough(db, {
134
+ id: 'bk:d20',
135
+ kind: 'convergence',
136
+ confidence: 0.55,
137
+ artifact_ids: ['a1', 'a2'],
138
+ });
139
+ assert.equal(r.ok, true);
140
+ // The SQL equivalent of:
141
+ // MATCH (b:Breakthrough)-[:DERIVED_FROM]->(a:Artifact) WHERE b.id = 'bk:d20'
142
+ // RETURN count(a)
143
+ // -- must return >= 1.
144
+ const count = db.prepare(
145
+ "SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:d20' AND type = 'DERIVED_FROM'"
146
+ ).get().c;
147
+ assert.equal(count >= 1, true, 'D-20 invariant: count=' + count + ', expected >= 1');
148
+
149
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
150
+ });
151
+
152
+ test('120-00 Task 3 Test 8: atomic rollback on writeEdge failure -- partial state cannot land', () => {
153
+ const { dir, db } = makeTmpDb('p120-bk-rb-');
154
+ seedArtifact(db, 'a-exists');
155
+ // a-missing intentionally NOT seeded -- FK constraint will fail on the second edge insert.
156
+ const r = schema.writeBreakthrough(db, {
157
+ id: 'bk:partial',
158
+ kind: 'convergence',
159
+ confidence: 0.55,
160
+ artifact_ids: ['a-exists', 'a-missing'],
161
+ });
162
+ assert.equal(r.ok, false, 'writeBreakthrough should fail when second edge target missing');
163
+ assert.match(r.reason, /writeEdge_failed|edge_write_failed/, 'reason should reference edge failure');
164
+
165
+ // Verify NO breakthrough node landed (ROLLBACK).
166
+ const bkRow = db.prepare("SELECT id FROM nodes WHERE id = 'bk:partial'").get();
167
+ assert.equal(bkRow, undefined, 'transaction rolled back -- no node landed');
168
+
169
+ // Verify NO DERIVED_FROM edges landed.
170
+ const edgeCount = db.prepare(
171
+ "SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:partial'"
172
+ ).get().c;
173
+ assert.equal(edgeCount, 0, 'transaction rolled back -- zero edges landed');
174
+
175
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
176
+ });
177
+
178
+ test('120-00 Task 3 Test 9: initial surfaced flag is false', () => {
179
+ const { dir, db } = makeTmpDb('p120-bk-sfc-');
180
+ seedArtifact(db, 'a1');
181
+ const r = schema.writeBreakthrough(db, {
182
+ id: 'bk:surf',
183
+ kind: 'convergence',
184
+ confidence: 0.55,
185
+ artifact_ids: ['a1'],
186
+ });
187
+ assert.equal(r.ok, true);
188
+ const row = db.prepare("SELECT properties FROM nodes WHERE id = 'bk:surf'").get();
189
+ const props = JSON.parse(row.properties);
190
+ assert.equal(props.surfaced, false, 'surfaced flag initially false; flipped by Plan 120-02 scanner');
191
+
192
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
193
+ });
194
+
195
+ test('120-00 Task 3 Test 10: D-20 BATCH INVARIANT -- zero orphaned breakthroughs', () => {
196
+ const { dir, db } = makeTmpDb('p120-bk-batch-');
197
+ seedArtifact(db, 'a1');
198
+ seedArtifact(db, 'a2');
199
+ seedArtifact(db, 'a3');
200
+ // Two successful writes.
201
+ assert.equal(schema.writeBreakthrough(db, { id: 'bk:1', kind: 'convergence', confidence: 0.55, artifact_ids: ['a1', 'a2'] }).ok, true);
202
+ assert.equal(schema.writeBreakthrough(db, { id: 'bk:2', kind: 'convergence', confidence: 0.55, artifact_ids: ['a3'] }).ok, true);
203
+ // One failed write (provenance-less).
204
+ assert.equal(schema.writeBreakthrough(db, { id: 'bk:3', artifact_ids: [] }).ok, false);
205
+ // One failed write (FK violation).
206
+ assert.equal(schema.writeBreakthrough(db, { id: 'bk:4', kind: 'convergence', confidence: 0.55, artifact_ids: ['a-missing'] }).ok, false);
207
+
208
+ // The batch invariant: every breakthrough node has >= 1 DERIVED_FROM edge.
209
+ const orphans = db.prepare(
210
+ "SELECT b.id FROM nodes b WHERE b.type = 'breakthrough' " +
211
+ "AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.source = b.id AND e.type = 'DERIVED_FROM')"
212
+ ).all();
213
+ assert.equal(orphans.length, 0, 'D-20 batch invariant: orphaned breakthroughs=' + JSON.stringify(orphans));
214
+
215
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
216
+ });
217
+
218
+ test('120-00 Task 3 Test 11: Canon Part 8 invariant -- zero Brain client require', () => {
219
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'schema.cjs'), 'utf8');
220
+ assert.equal(/require\([^)]*brain-client/.test(src), false);
221
+ assert.equal(/fetch\([^)]*brain\.mindrian/.test(src), false);
222
+ });
223
+
224
+ test('120-00 Task 3 Test 12: writeBreakthrough does NOT emit breakthrough_surfaced', () => {
225
+ const { dir, db } = makeTmpDb('p120-bk-no-surf-');
226
+ seedArtifact(db, 'a1');
227
+ const r = schema.writeBreakthrough(db, {
228
+ id: 'bk:no-surf',
229
+ kind: 'convergence',
230
+ confidence: 0.55,
231
+ artifact_ids: ['a1'],
232
+ });
233
+ assert.equal(r.ok, true);
234
+ // Verify no memory_event with event_type='breakthrough_surfaced' landed.
235
+ const surfacedRows = db.prepare(
236
+ "SELECT id FROM nodes WHERE type = 'memory_event' " +
237
+ "AND json_extract(properties, '$.event_type') = 'breakthrough_surfaced'"
238
+ ).all();
239
+ assert.equal(surfacedRows.length, 0, 'writeBreakthrough must NOT emit breakthrough_surfaced; Plan 120-02 scanner does that');
240
+
241
+ try { closeRoomDb(db); } catch (_e) { /* graceful */ }
242
+ });
243
+
244
+ test('120-00 Task 3 Test 13 bonus: writeBreakthrough validates db handle', () => {
245
+ const r = schema.writeBreakthrough(null, { id: 'bk:x', artifact_ids: ['a1'] });
246
+ assert.equal(r.ok, false);
247
+ assert.equal(r.reason, 'invalid_db');
248
+ });
249
+
250
+ test('120-00 Task 3 Test 14 bonus: BREAKTHROUGH_KIND constants', () => {
251
+ assert.equal(schema.BREAKTHROUGH_KIND.CONVERGENCE, 'convergence');
252
+ assert.equal(schema.BREAKTHROUGH_KIND.CONTRADICTION_RESOLVED, 'contradiction_resolved');
253
+ assert.equal(schema.BREAKTHROUGH_KIND.CROSS_DOMAIN_ANALOGY, 'cross_domain_analogy');
254
+ assert.equal(schema.BREAKTHROUGH_KIND.REVERSE_SALIENT_CLOSED, 'reverse_salient_closed');
255
+ assert.equal(schema.BREAKTHROUGH_NODE_TYPE, 'breakthrough');
256
+ });
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 120-01 Wave 2 Task 3 -- the 5-component breakthrough scoring formula +
4
+ * rankBreakthroughs + pickTopWithAffordance + getUserEngagementPrior +
5
+ * isThrottledKind. Per CONTEXT.md D-11..D-12 + D-19 verbatim:
6
+ *
7
+ * score = (confidence x 0.4)
8
+ * + (recency_decay x 0.2) // half-life ~3 days
9
+ * + (differential x 0.2)
10
+ * + (artifact_count_log x 0.1) // clamped [0, 1]
11
+ * + (user_engagement_prior x 0.1) // per-detector-type Laplace-smoothed prior
12
+ *
13
+ * Tunable via config.json; the weights are the documented defaults (NOT magic
14
+ * numbers). Sum of weights MUST equal 1.0 (verified by SCORING_WEIGHTS unit test).
15
+ *
16
+ * Canon Part 8: ALL reads from breakthrough_confirmed / breakthrough_dismissed
17
+ * / breakthrough_surfaced events stay LOCAL to the room.db. NO cross-room
18
+ * aggregation. NO Brain coupling. NO require of brain-client. NO fetch to
19
+ * brain.mindrian.* domain.
20
+ *
21
+ * Canon Part 9: ALL reads route through navigation.findRecentChanges chokepoint.
22
+ * No direct sqlite reads in scoring.cjs (defense in depth on top of room-db.cjs
23
+ * allow-list). Test 12 source-greps this invariant.
24
+ *
25
+ * Canon Part 10 sub-claim 5: variable reward fires automatically; the score IS
26
+ * the math. The 5 components are deterministic functions of room state -- no
27
+ * stochastic surfacing, no engagement-optimizer drift.
28
+ *
29
+ * Em-dash HARD RULE (CLAUDE.md feedback_no_emdashes): use "--" not the U+2014
30
+ * character anywhere in this file (comments, log lines, strings).
31
+ *
32
+ * Pure CJS, node built-ins only, zero deps (Phase 87 invariant). Reads route
33
+ * through ../navigation.cjs::findRecentChanges (the only Canon Part 9-compliant
34
+ * door for memory_event queries).
35
+ */
36
+
37
+ const navigation = require('../navigation.cjs');
38
+
39
+ // CONTEXT.md D-12 verbatim lock. Object.freeze prevents accidental mutation of
40
+ // the canonical weight map; if a downstream caller needs a mutable copy they
41
+ // must clone explicitly. The 5 weights MUST sum to exactly 1.0 (Test 1 asserts
42
+ // this within 1e-9 floating-point tolerance).
43
+ const SCORING_WEIGHTS = Object.freeze({
44
+ confidence: 0.4,
45
+ recency: 0.2,
46
+ differential: 0.2,
47
+ artifact_count_log: 0.1,
48
+ user_engagement_prior: 0.1,
49
+ });
50
+
51
+ // CONTEXT.md D-12 verbatim: recency half-life ~3 days. The exponential-decay
52
+ // kernel is exp(-ageDays / RECENCY_HALF_LIFE_DAYS); 3-day-old fires decay to
53
+ // exp(-1) ~= 0.368, 6-day-old fires decay to exp(-2) ~= 0.135.
54
+ const RECENCY_HALF_LIFE_DAYS = 3;
55
+
56
+ // Neutral-prior return value when getUserEngagementPrior has no history to
57
+ // learn from (or graceful degradation path on chokepoint failure). The +0.5
58
+ // Laplace smoothing pivots around this midpoint.
59
+ const ENGAGEMENT_NEUTRAL_PRIOR = 0.5;
60
+
61
+ // CONTEXT.md D-19 verbatim: per-detector dismissal-rate canary. If a detector's
62
+ // dismiss rate over the rolling D19_FIRE_WINDOW most-recent fires exceeds
63
+ // D19_DISMISSAL_THRESHOLD, isThrottledKind returns true (Plan 120-02 scanner
64
+ // then auto-throttles that detector to soft-fire-only until manually reviewed).
65
+ //
66
+ // D19_MIN_SAMPLE prevents premature throttling on small populations: a 1-of-2
67
+ // dismiss = 50% would otherwise trip the canary on essentially noise. The
68
+ // floor of 10 ensures statistical adequacy before throttling kicks in.
69
+ const D19_DISMISSAL_THRESHOLD = 0.30;
70
+ const D19_FIRE_WINDOW = 100;
71
+ const D19_MIN_SAMPLE = 10;
72
+
73
+ // Per-kind engagement-prior + canary window. Reads memory_event rows from the
74
+ // trailing 90 days; older history is considered stale and excluded from the
75
+ // learning signal.
76
+ const PRIOR_WINDOW_DAYS = 90;
77
+ const PRIOR_QUERY_LIMIT = 500;
78
+
79
+ const DAY_MS = 24 * 3600 * 1000;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // recencyDecay -- exp(-ageDays / RECENCY_HALF_LIFE_DAYS). Clamped to [0, 1].
83
+ // Future-dated candidates (ageMs < 0) clamp to age=0 (decay=1.0); they never
84
+ // produce a decay > 1.0 even on clock skew.
85
+ // ---------------------------------------------------------------------------
86
+ function recencyDecay(detected_at, nowMs) {
87
+ const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
88
+ const ts = (typeof detected_at === 'number' && Number.isFinite(detected_at)) ? detected_at : now;
89
+ const ageMs = Math.max(0, now - ts);
90
+ const ageDays = ageMs / DAY_MS;
91
+ const decay = Math.exp(-ageDays / RECENCY_HALF_LIFE_DAYS);
92
+ // Defense in depth: clamp to [0, 1] in case of floating-point edge cases.
93
+ return Math.min(1, Math.max(0, decay));
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // artifactCountLog -- log10(N) clamped to [0, 1]. CONTEXT.md D-12 specifies
98
+ // the component is bounded so a 100-artifact convergence can't dominate the
99
+ // score. log10(1)=0, log10(10)=1.0 (the ceiling), log10(100)=2.0 -> clamped 1.
100
+ // ---------------------------------------------------------------------------
101
+ function artifactCountLog(artifact_ids) {
102
+ if (!Array.isArray(artifact_ids)) return 0;
103
+ const n = artifact_ids.length;
104
+ if (n <= 0) return 0;
105
+ const raw = Math.log10(n);
106
+ return Math.min(1, Math.max(0, raw));
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // getUserEngagementPrior -- per-detector-kind Laplace-smoothed prior in [0, 1].
111
+ //
112
+ // Reads via navigation.findRecentChanges (Canon Part 9 chokepoint). Filters
113
+ // the recent breakthrough_confirmed + breakthrough_dismissed events by kind
114
+ // (extracted from properties.kind on each memory_event row).
115
+ //
116
+ // Smoothing formula: (confirmed + 0.5) / (confirmed + dismissed + 1)
117
+ // - empty history (0 confirmed + 0 dismissed): 0.5/1 = 0.5 (neutral)
118
+ // - 3 confirmed + 0 dismissed: 3.5/4 = 0.875 (pushed up)
119
+ // - 1 confirmed + 4 dismissed: 1.5/6 = 0.25 (pushed down)
120
+ //
121
+ // The +0.5 / +1 form pivots around the neutral midpoint and avoids the
122
+ // division-by-zero failure mode of a naive ratio.
123
+ //
124
+ // Graceful degradation: missing/invalid roomState -> neutral 0.5. Chokepoint
125
+ // throw (e.g., db handle closed) -> neutral 0.5. The prior must NEVER throw
126
+ // from scoreBreakthrough's hot path.
127
+ // ---------------------------------------------------------------------------
128
+ function getUserEngagementPrior(kind, roomState) {
129
+ try {
130
+ if (!roomState || !roomState.db) return ENGAGEMENT_NEUTRAL_PRIOR;
131
+ const since = Date.now() - (PRIOR_WINDOW_DAYS * DAY_MS);
132
+ const confirmed = navigation.findRecentChanges(roomState.db, since, {
133
+ eventType: 'breakthrough_confirmed',
134
+ limit: PRIOR_QUERY_LIMIT,
135
+ }) || [];
136
+ const dismissed = navigation.findRecentChanges(roomState.db, since, {
137
+ eventType: 'breakthrough_dismissed',
138
+ limit: PRIOR_QUERY_LIMIT,
139
+ }) || [];
140
+ const cConfirmed = confirmed.filter((e) => e.properties && e.properties.kind === kind).length;
141
+ const cDismissed = dismissed.filter((e) => e.properties && e.properties.kind === kind).length;
142
+ return (cConfirmed + 0.5) / (cConfirmed + cDismissed + 1);
143
+ } catch (_e) {
144
+ return ENGAGEMENT_NEUTRAL_PRIOR;
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // isThrottledKind -- CONTEXT.md D-19 dismissal-rate canary.
150
+ //
151
+ // Returns true if the per-kind dismissal rate over the rolling D19_FIRE_WINDOW
152
+ // most-recent fires exceeds D19_DISMISSAL_THRESHOLD (with D19_MIN_SAMPLE floor
153
+ // to avoid throttling on small populations).
154
+ //
155
+ // Implementation:
156
+ // 1. Read breakthrough_surfaced + breakthrough_dismissed events for the kind
157
+ // via navigation.findRecentChanges (Canon Part 9 chokepoint).
158
+ // 2. Take the most-recent D19_FIRE_WINDOW surfaces for the kind.
159
+ // 3. Match dismisses against the surfaced breakthrough_ids (only dismisses
160
+ // that targeted a fire in the window count).
161
+ // 4. If fired.length < D19_MIN_SAMPLE, return false (statistical floor).
162
+ // 5. Compute rate = dismissed_in_window / fired.length. Return rate > D19_DISMISSAL_THRESHOLD.
163
+ //
164
+ // Graceful degradation: missing/invalid roomState -> false (not throttled).
165
+ // Chokepoint throw -> false (fail-open; never lock out a detector on infra hiccup).
166
+ // ---------------------------------------------------------------------------
167
+ function isThrottledKind(kind, roomState) {
168
+ try {
169
+ if (!roomState || !roomState.db) return false;
170
+ const since = Date.now() - (PRIOR_WINDOW_DAYS * DAY_MS);
171
+ const surfaced = navigation.findRecentChanges(roomState.db, since, {
172
+ eventType: 'breakthrough_surfaced',
173
+ limit: PRIOR_QUERY_LIMIT,
174
+ }) || [];
175
+ const dismissed = navigation.findRecentChanges(roomState.db, since, {
176
+ eventType: 'breakthrough_dismissed',
177
+ limit: PRIOR_QUERY_LIMIT,
178
+ }) || [];
179
+ // Filter by kind and take most-recent D19_FIRE_WINDOW fires (findRecentChanges
180
+ // already returns rows ORDER BY created_at DESC).
181
+ const fired = surfaced
182
+ .filter((e) => e.properties && e.properties.kind === kind)
183
+ .slice(0, D19_FIRE_WINDOW);
184
+ if (fired.length < D19_MIN_SAMPLE) return false;
185
+ // Match dismisses against the in-window fire ids. Only dismisses that target
186
+ // a fire in the window count; a dismiss for an out-of-window fire is noise.
187
+ const fireIds = new Set(
188
+ fired
189
+ .map((e) => e.properties && e.properties.breakthrough_id)
190
+ .filter((id) => typeof id === 'string' && id.length > 0)
191
+ );
192
+ const dismissedInWindow = dismissed.filter(
193
+ (e) => e.properties && e.properties.kind === kind &&
194
+ typeof e.properties.breakthrough_id === 'string' &&
195
+ fireIds.has(e.properties.breakthrough_id)
196
+ ).length;
197
+ const rate = dismissedInWindow / fired.length;
198
+ return rate > D19_DISMISSAL_THRESHOLD;
199
+ } catch (_e) {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // scoreBreakthrough -- the 5-component formula (CONTEXT.md D-12 verbatim).
206
+ //
207
+ // score = W.confidence * candidate.confidence
208
+ // + W.recency * recencyDecay(candidate.detected_at, nowMs)
209
+ // + W.differential * candidate.differential
210
+ // + W.artifact_count_log * artifactCountLog(candidate.artifact_ids)
211
+ // + W.user_engagement_prior * getUserEngagementPrior(candidate.kind, roomState)
212
+ //
213
+ // Each component is independently bounded; the weighted sum is bounded in [0, 1]
214
+ // (assuming candidate.confidence and candidate.differential are themselves in
215
+ // [0, 1] -- the detectors enforce this via the buildCandidate clip in Plan 120-00).
216
+ //
217
+ // Graceful degradation: null/non-object candidate -> 0. Missing fields default
218
+ // to 0 (or to nowMs for detected_at -> decay=1.0). The function must NEVER throw.
219
+ // ---------------------------------------------------------------------------
220
+ function scoreBreakthrough(candidate, roomState, nowMs) {
221
+ if (!candidate || typeof candidate !== 'object') return 0;
222
+ const conf = (typeof candidate.confidence === 'number') ? candidate.confidence : 0;
223
+ const diff = (typeof candidate.differential === 'number') ? candidate.differential : 0;
224
+ const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
225
+ const recency = recencyDecay(
226
+ (typeof candidate.detected_at === 'number') ? candidate.detected_at : now,
227
+ now
228
+ );
229
+ const acLog = artifactCountLog(candidate.artifact_ids);
230
+ const engagement = getUserEngagementPrior(candidate.kind, roomState);
231
+ const W = SCORING_WEIGHTS;
232
+ return (W.confidence * conf)
233
+ + (W.recency * recency)
234
+ + (W.differential * diff)
235
+ + (W.artifact_count_log * acLog)
236
+ + (W.user_engagement_prior * engagement);
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // rankBreakthroughs -- stable sort by score descending; ties broken by
241
+ // detected_at descending (newer wins). Each ranked item is shallow-cloned with
242
+ // a numeric `score` property attached.
243
+ // ---------------------------------------------------------------------------
244
+ function rankBreakthroughs(candidates, roomState, nowMs) {
245
+ if (!Array.isArray(candidates)) return [];
246
+ const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
247
+ const scored = candidates.map(function (c) {
248
+ return { candidate: c, score: scoreBreakthrough(c, roomState, now) };
249
+ });
250
+ scored.sort(function (a, b) {
251
+ if (b.score !== a.score) return b.score - a.score;
252
+ // Tie-break: newer detected_at wins (descending). Missing detected_at sorts
253
+ // last (treat as 0).
254
+ const aTs = (a.candidate && typeof a.candidate.detected_at === 'number') ? a.candidate.detected_at : 0;
255
+ const bTs = (b.candidate && typeof b.candidate.detected_at === 'number') ? b.candidate.detected_at : 0;
256
+ return bTs - aTs;
257
+ });
258
+ return scored.map(function (s) {
259
+ return Object.assign({}, s.candidate, { score: s.score });
260
+ });
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // pickTopWithAffordance -- CONTEXT.md D-11 verbatim: surface the top-1; expose
265
+ // the rest as a "More breakthroughs (N)" affordance count + the queued list.
266
+ //
267
+ // Returns { top: <highest-scoring | null>, more_count: <N | 0>, queued: <[]> }.
268
+ // ---------------------------------------------------------------------------
269
+ function pickTopWithAffordance(candidates, roomState, nowMs) {
270
+ const ranked = rankBreakthroughs(candidates, roomState, nowMs);
271
+ if (ranked.length === 0) return { top: null, more_count: 0, queued: [] };
272
+ return {
273
+ top: ranked[0],
274
+ more_count: ranked.length - 1,
275
+ queued: ranked.slice(1),
276
+ };
277
+ }
278
+
279
+ module.exports = {
280
+ scoreBreakthrough: scoreBreakthrough,
281
+ rankBreakthroughs: rankBreakthroughs,
282
+ pickTopWithAffordance: pickTopWithAffordance,
283
+ getUserEngagementPrior: getUserEngagementPrior,
284
+ isThrottledKind: isThrottledKind,
285
+ recencyDecay: recencyDecay,
286
+ artifactCountLog: artifactCountLog,
287
+ SCORING_WEIGHTS: SCORING_WEIGHTS,
288
+ RECENCY_HALF_LIFE_DAYS: RECENCY_HALF_LIFE_DAYS,
289
+ ENGAGEMENT_NEUTRAL_PRIOR: ENGAGEMENT_NEUTRAL_PRIOR,
290
+ D19_DISMISSAL_THRESHOLD: D19_DISMISSAL_THRESHOLD,
291
+ D19_FIRE_WINDOW: D19_FIRE_WINDOW,
292
+ D19_MIN_SAMPLE: D19_MIN_SAMPLE,
293
+ };