@mindrian_os/install 1.13.0-beta.17 → 1.13.0-beta.21

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 (199) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.mcp.json +6 -1
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +51 -56
  5. package/bin/mindrian-brain-mcp-client.cjs +152 -0
  6. package/commands/act.md +1 -0
  7. package/commands/admin.md +1 -0
  8. package/commands/analyze-needs.md +2 -0
  9. package/commands/analyze-systems.md +2 -0
  10. package/commands/analyze-timing.md +2 -0
  11. package/commands/auto-explore.md +2 -0
  12. package/commands/beautiful-question.md +2 -0
  13. package/commands/brain-derive.md +2 -0
  14. package/commands/build-knowledge.md +2 -0
  15. package/commands/build-thesis.md +2 -0
  16. package/commands/causal.md +2 -0
  17. package/commands/challenge-assumptions.md +2 -0
  18. package/commands/compare-ventures.md +2 -0
  19. package/commands/dashboard.md +2 -1
  20. package/commands/deep-grade.md +2 -0
  21. package/commands/diagnose.md +21 -1
  22. package/commands/diagnostics.md +14 -3
  23. package/commands/doctor.md +6 -2
  24. package/commands/dogfood-flush.md +92 -0
  25. package/commands/dominant-designs.md +2 -0
  26. package/commands/explain-decision.md +2 -0
  27. package/commands/explore-domains.md +2 -0
  28. package/commands/explore-futures.md +2 -0
  29. package/commands/explore-trends.md +2 -0
  30. package/commands/export.md +1 -0
  31. package/commands/feynman-timeline-refresh.md +2 -0
  32. package/commands/file-meeting.md +2 -0
  33. package/commands/find-analogies.md +1 -0
  34. package/commands/find-bottlenecks.md +2 -0
  35. package/commands/find-connections.md +2 -0
  36. package/commands/funding.md +1 -0
  37. package/commands/grade.md +2 -0
  38. package/commands/graph.md +1 -0
  39. package/commands/hat-briefing.md +1 -0
  40. package/commands/heal.md +22 -170
  41. package/commands/help.md +54 -334
  42. package/commands/hmi-status.md +23 -144
  43. package/commands/jtbd.md +1 -0
  44. package/commands/leadership.md +2 -0
  45. package/commands/lean-canvas.md +2 -0
  46. package/commands/macro-trends.md +2 -0
  47. package/commands/map-unknowns.md +2 -0
  48. package/commands/memory.md +1 -0
  49. package/commands/models.md +1 -0
  50. package/commands/mos-reason.md +2 -0
  51. package/commands/mos.md +139 -0
  52. package/commands/mullins.md +2 -0
  53. package/commands/mva-brief.md +2 -0
  54. package/commands/mva-option.md +2 -0
  55. package/commands/new-project.md +2 -0
  56. package/commands/onboard.md +20 -7
  57. package/commands/operator.md +1 -0
  58. package/commands/opportunities.md +1 -0
  59. package/commands/organize.md +22 -469
  60. package/commands/persona.md +1 -0
  61. package/commands/pipeline.md +2 -0
  62. package/commands/present.md +1 -0
  63. package/commands/publish.md +2 -0
  64. package/commands/query.md +24 -102
  65. package/commands/radar.md +2 -0
  66. package/commands/reanalyze.md +1 -0
  67. package/commands/research.md +2 -0
  68. package/commands/room.md +2 -0
  69. package/commands/rooms.md +1 -0
  70. package/commands/root-cause.md +2 -0
  71. package/commands/rs-experts.md +1 -0
  72. package/commands/rs-explain.md +1 -0
  73. package/commands/rs-fetch.md +1 -0
  74. package/commands/rs-thesis.md +1 -0
  75. package/commands/scenario-plan.md +2 -0
  76. package/commands/scheduled-tasks.md +1 -0
  77. package/commands/score-innovation.md +2 -0
  78. package/commands/scout.md +1 -0
  79. package/commands/setup.md +2 -0
  80. package/commands/snapshot.md +2 -0
  81. package/commands/speakers.md +1 -0
  82. package/commands/splash.md +5 -2
  83. package/commands/status.md +1 -0
  84. package/commands/structure-argument.md +2 -0
  85. package/commands/suggest-next.md +2 -0
  86. package/commands/systems-thinking.md +2 -0
  87. package/commands/think-hats.md +2 -0
  88. package/commands/update.md +2 -0
  89. package/commands/user-needs.md +2 -0
  90. package/commands/validate.md +2 -0
  91. package/commands/value-proposition.md +2 -0
  92. package/commands/vault.md +2 -0
  93. package/commands/visualize.md +24 -29
  94. package/commands/whitespace.md +2 -1
  95. package/commands/wiki.md +1 -0
  96. package/hooks/hooks.json +22 -88
  97. package/lib/agents/auto-explore-agent.cjs +82 -0
  98. package/lib/core/breakthrough/canary.cjs +134 -0
  99. package/lib/core/breakthrough/canary.test.cjs +136 -0
  100. package/lib/core/breakthrough/detectors.cjs +359 -0
  101. package/lib/core/breakthrough/detectors.test.cjs +333 -0
  102. package/lib/core/breakthrough/ethics-fence.cjs +127 -0
  103. package/lib/core/breakthrough/ethics-fence.test.cjs +178 -0
  104. package/lib/core/breakthrough/resurfacing.cjs +150 -0
  105. package/lib/core/breakthrough/resurfacing.test.cjs +233 -0
  106. package/lib/core/breakthrough/review-queue.cjs +154 -0
  107. package/lib/core/breakthrough/review-queue.test.cjs +160 -0
  108. package/lib/core/breakthrough/scanner-d17-d18.test.cjs +229 -0
  109. package/lib/core/breakthrough/scanner.cjs +426 -0
  110. package/lib/core/breakthrough/scanner.test.cjs +267 -0
  111. package/lib/core/breakthrough/schema.cjs +164 -0
  112. package/lib/core/breakthrough/schema.test.cjs +256 -0
  113. package/lib/core/breakthrough/scoring.cjs +293 -0
  114. package/lib/core/breakthrough/scoring.test.cjs +423 -0
  115. package/lib/core/breakthrough/verb-dispatch.cjs +221 -0
  116. package/lib/core/breakthrough/verb-dispatch.test.cjs +185 -0
  117. package/lib/core/breakthrough/voice-scaffold.cjs +247 -0
  118. package/lib/core/breakthrough/voice-scaffold.test.cjs +251 -0
  119. package/lib/core/directive-envelope.cjs +175 -0
  120. package/lib/core/directive-envelope.test.cjs +225 -0
  121. package/lib/core/doctor/class-m-brain-smoke.cjs +278 -0
  122. package/lib/core/doctor/class-m-brain-smoke.test.cjs +310 -0
  123. package/lib/core/first-touch-version-stamper.cjs +113 -0
  124. package/lib/core/larry-thinness-acknowledgment.cjs +64 -0
  125. package/lib/core/larry-thinness-acknowledgment.test.cjs +97 -0
  126. package/lib/core/llm-name-suggester.cjs +194 -0
  127. package/lib/core/llm-name-suggester.test.cjs +132 -0
  128. package/lib/core/mcp-profiles.cjs +1 -1
  129. package/lib/core/migration-snapshot.cjs +172 -0
  130. package/lib/core/migration-snapshot.test.cjs +174 -0
  131. package/lib/core/mindrian-brain-shim.test.cjs +214 -0
  132. package/lib/core/mva-orchestrator.cjs +41 -0
  133. package/lib/core/mva-telemetry.cjs +31 -143
  134. package/lib/core/navigation/edges.cjs +35 -0
  135. package/lib/core/navigation/memory-events.cjs +126 -0
  136. package/lib/core/room-auto-create.cjs +318 -0
  137. package/lib/core/room-auto-create.test.cjs +198 -0
  138. package/lib/core/room-discard-cascade.cjs +225 -0
  139. package/lib/core/room-discard-cascade.test.cjs +135 -0
  140. package/lib/core/room-name-validator.cjs +132 -0
  141. package/lib/core/room-name-validator.test.cjs +156 -0
  142. package/lib/core/room-naming-selector.cjs +357 -0
  143. package/lib/core/room-naming-selector.test.cjs +277 -0
  144. package/lib/core/room-receipt-emit.cjs +63 -0
  145. package/lib/core/room-skeleton-scaffold.cjs +315 -0
  146. package/lib/core/room-skeleton-scaffold.test.cjs +291 -0
  147. package/lib/core/rs-nl-to-query.cjs +1 -1
  148. package/lib/core/stale-copy-scanner.cjs +190 -0
  149. package/lib/core/state-aware-router.cjs +78 -0
  150. package/lib/core/telemetry/schema.cjs +168 -0
  151. package/lib/core/telemetry/schema.test.cjs +124 -0
  152. package/lib/core/telemetry/validator.cjs +200 -0
  153. package/lib/core/telemetry/validator.test.cjs +188 -0
  154. package/lib/core/telemetry/writer.cjs +141 -0
  155. package/lib/core/telemetry/writer.test.cjs +331 -0
  156. package/lib/core/terminal-capability.cjs +88 -0
  157. package/lib/core/tier0-messaging.cjs +109 -0
  158. package/lib/core/tier0-messaging.test.cjs +218 -0
  159. package/lib/core/venture-shape-nudge.cjs +163 -0
  160. package/lib/core/venture-shape-nudge.test.cjs +161 -0
  161. package/lib/core/visual-ops.cjs +70 -2
  162. package/lib/hmi/selector-dispatcher.cjs +90 -1
  163. package/lib/hmi/shape-f7-breakthrough-renderer.cjs +222 -0
  164. package/lib/hmi/shape-f7-breakthrough-renderer.test.cjs +233 -0
  165. package/lib/memory/body-shape-coverage.test.cjs +268 -0
  166. package/lib/memory/brain-derivation-graceful-degradation.test.cjs +2 -2
  167. package/lib/memory/doctor-deprecation-surface.test.cjs +185 -0
  168. package/lib/memory/first-touch-version.test.cjs +198 -0
  169. package/lib/memory/help-coverage.test.cjs +108 -0
  170. package/lib/memory/help-renderer.test.cjs +145 -0
  171. package/lib/memory/mos-status-renderer.test.cjs +2 -2
  172. package/lib/memory/navigation-engine-core.test.cjs +1 -1
  173. package/lib/memory/palette-consistency.test.cjs +127 -0
  174. package/lib/memory/pending-tension-store.cjs +80 -0
  175. package/lib/memory/render-v2-disposition.test.cjs +199 -0
  176. package/lib/memory/run-feynman-tests.cjs +223 -0
  177. package/lib/memory/sessionstart-coordinator.test.cjs +446 -0
  178. package/lib/memory/skill-vs-code-drift.test.cjs +257 -0
  179. package/lib/memory/soft-alias.test.cjs +144 -0
  180. package/lib/memory/stale-copy-scanner.test.cjs +291 -0
  181. package/lib/memory/state-aware-router.test.cjs +90 -0
  182. package/lib/memory/statusline-two-row.test.cjs +338 -0
  183. package/lib/memory/terminal-capability.test.cjs +155 -0
  184. package/lib/render/ROOM.md +74 -22
  185. package/lib/sessionstart/budget-compressor.cjs +130 -0
  186. package/lib/sessionstart/contributor-interface.cjs +134 -0
  187. package/lib/sessionstart/contributor-isolator.cjs +128 -0
  188. package/lib/sessionstart/precedence-ladder.cjs +47 -0
  189. package/lib/statusline/governing-thought-truncator.cjs +45 -0
  190. package/lib/statusline/two-row-renderer.cjs +186 -0
  191. package/lib/statusline/version-resolver.cjs +81 -0
  192. package/package.json +1 -1
  193. package/references/visual/ROOM.md +55 -0
  194. package/references/visual/palette.json +54 -0
  195. package/skills/larry-personality/SKILL.md +34 -0
  196. package/skills/ui-system/SKILL.md +109 -1
  197. package/skills/ui-system/rules/dual-palette.md +156 -0
  198. package/skills/ui-system/rules/glyph-disambiguation.md +171 -0
  199. package/skills/ui-system/rules/shape-f-zero-and-six.md +169 -0
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+ /*
3
+ * Phase 119-01 -- Room naming selector orchestrator.
4
+ *
5
+ * Fires AFTER Phase 118's mva-orchestrator emits the terminal mva_brief_rendered
6
+ * telemetry event. Renders an F.1 selector with the four LOCKED option labels
7
+ * from CONTEXT.md D-06; resolves the user's choice across CLI / Desktop / Cowork
8
+ * surfaces per CLAUDE.md tri-polar rule; on resolution, runs the rename ceremony
9
+ * OR the discard cascade OR no-op (keep-untitled); emits room_naming_decided
10
+ * memory_event via the navigation.cjs chokepoint.
11
+ *
12
+ * Canon Part 3: F.1 selector is the tri-context Decision Gate primitive.
13
+ * Canon Part 4: every choice (including [keep as untitled] and [discard room])
14
+ * becomes a typed graph event.
15
+ * Canon Part 8: LOCAL only; no Brain MCP coupling.
16
+ * Canon Part 9: ALL writes through navigation.cjs::logMemoryEvent.
17
+ * Canon Part 10 sub-claim 3: the retroactive naming IS the receipt being completed.
18
+ *
19
+ * Em-dash discipline: uses `--` never the U+2014 character per HARD RULE.
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const child_process = require('node:child_process');
25
+
26
+ const { suggestRoomName, FALLBACK_SUGGESTION } = require('./llm-name-suggester.cjs');
27
+ const { validateRoomName } = require('./room-name-validator.cjs');
28
+ const { discardPlaceholderRoom } = require('./room-discard-cascade.cjs');
29
+
30
+ const DECISION_PATHS = Object.freeze(['llm-suggested', 'user-typed', 'kept-untitled', 'discarded']);
31
+
32
+ // Per CONTEXT.md D-06 -- LOCKED verbatim labels. The {{SUGGESTED}} placeholder
33
+ // is interpolated at render time via interpolateLlmSuggested.
34
+ // Verbatim option labels present in this module body (grep target for scaffold harness Gate 6):
35
+ // [name this room: {{SUGGESTED}}]
36
+ // [type your own name]
37
+ // [keep as untitled]
38
+ // [discard room]
39
+ const F1_OPTION_LABELS = Object.freeze({
40
+ LLM_SUGGESTED: '[name this room: {{SUGGESTED}}]',
41
+ USER_TYPED: '[type your own name]',
42
+ KEEP_UNTITLED: '[keep as untitled]',
43
+ DISCARD: '[discard room]',
44
+ });
45
+
46
+ function interpolateLlmSuggested(suggestedName) {
47
+ return F1_OPTION_LABELS.LLM_SUGGESTED.replace('{{SUGGESTED}}', suggestedName || FALLBACK_SUGGESTION);
48
+ }
49
+
50
+ function readThinnessFromState(roomDir) {
51
+ try {
52
+ const statePath = path.join(roomDir, 'STATE.md');
53
+ if (!fs.existsSync(statePath)) return false;
54
+ const raw = fs.readFileSync(statePath, 'utf8');
55
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
56
+ if (!m) return false;
57
+ return /^\s*auto_explore_thin:\s*true\s*$/m.test(m[1]);
58
+ } catch (_e) { return false; }
59
+ }
60
+
61
+ function maybeLoadThinnessVoiceLine() {
62
+ try {
63
+ const t = require('./larry-thinness-acknowledgment.cjs');
64
+ return (t && typeof t.voiceLine === 'function') ? t.voiceLine() : null;
65
+ } catch (_e) { return null; }
66
+ }
67
+
68
+ /**
69
+ * fireNamingSelector(args) -> Promise<{decision_path, new_slug?, room_dir?, decision_envelope}>
70
+ *
71
+ * @param {object} args
72
+ * @param {string} args.roomDir absolute path to the placeholder room
73
+ * @param {object} args.mvaCompletionPayload the {sentence_sha256, total_duration_ms, ...} from
74
+ * mva_brief_rendered (LOCAL only, no Brain content)
75
+ * @param {string} args.surface 'cli' | 'desktop' | 'cowork' (default 'cli')
76
+ * @param {string} args.decidedBy user identity for the memory_event payload
77
+ * @param {string} [args.userPick] when present (REVISION 2026-05-16 IN-path of
78
+ * directive-file duplex), skip dispatcher + channel
79
+ * and resolve directly from the verb-string
80
+ * @param {object} [args.dispatcher] injectable for tests
81
+ * @param {object} [args.userInputChannel] injectable for tests
82
+ */
83
+ async function fireNamingSelector(args) {
84
+ const opts = args || {};
85
+ const roomDir = opts.roomDir;
86
+ if (typeof roomDir !== 'string' || !fs.existsSync(roomDir)) {
87
+ return { decision_path: null, new_slug: null, room_dir: null, decision_envelope: { shape: 'F.1', surface: false, reason: 'room_dir_not_found' } };
88
+ }
89
+ const roomsHome = path.dirname(roomDir);
90
+ const previousSlug = path.basename(roomDir);
91
+
92
+ // REVISION 2026-05-16 IN-path: when userPick is provided, bypass dispatcher
93
+ // + channel and resolve directly to the appropriate branch. Larry calls this
94
+ // form from her conversational layer after AskUserQuestion returns.
95
+ const decidedBy = opts.decidedBy || process.env.USER || 'anonymous';
96
+ if (typeof opts.userPick === 'string' && opts.userPick.length > 0) {
97
+ return await _resolveFromUserPick(roomsHome, previousSlug, opts.userPick, decidedBy, opts.suggestedName);
98
+ }
99
+
100
+ const dispatcher = opts.dispatcher || require('../hmi/selector-dispatcher.cjs');
101
+
102
+ // Build the LLM-suggested name FIRST (we need it for the option-0 label).
103
+ let suggestion;
104
+ try {
105
+ const sha8 = (opts.mvaCompletionPayload && opts.mvaCompletionPayload.sentence_sha256)
106
+ ? opts.mvaCompletionPayload.sentence_sha256.slice(0, 8)
107
+ : 'unknown';
108
+ const autoExplorePath = path.join(roomDir, '.mindrian', 'auto-explore-' + sha8 + '.json');
109
+ const autoExploreFinding = fs.existsSync(autoExplorePath) ? JSON.parse(fs.readFileSync(autoExplorePath, 'utf8')) : null;
110
+ suggestion = await suggestRoomName({
111
+ auto_explore_finding: autoExploreFinding,
112
+ mva_brief_sentence: (opts.mvaCompletionPayload && opts.mvaCompletionPayload.mva_brief_sentence) || '',
113
+ llmClient: opts.llmClient || null,
114
+ });
115
+ } catch (_e) {
116
+ suggestion = { ok: false, suggested_name: FALLBACK_SUGGESTION };
117
+ }
118
+
119
+ const verbs = [
120
+ interpolateLlmSuggested(suggestion.suggested_name),
121
+ F1_OPTION_LABELS.USER_TYPED,
122
+ F1_OPTION_LABELS.KEEP_UNTITLED,
123
+ F1_OPTION_LABELS.DISCARD,
124
+ ];
125
+
126
+ // Prepend thinness voice line if D-05 condition is met.
127
+ const thin = readThinnessFromState(roomDir);
128
+ const voiceLine = thin ? maybeLoadThinnessVoiceLine() : null;
129
+
130
+ // First-round F.1 dispatch.
131
+ const envelope = dispatcher.pickShape({
132
+ requestedShape: 'F.1',
133
+ roomDir: roomDir,
134
+ tier: 1,
135
+ payload: {
136
+ header: voiceLine || undefined,
137
+ previous_slug: previousSlug,
138
+ verbs: verbs,
139
+ },
140
+ });
141
+
142
+ // Resolve user choice via the surface-specific channel.
143
+ const userInputChannel = opts.userInputChannel || _defaultUserInputChannel;
144
+ const choice = await userInputChannel(envelope, { surface: opts.surface || 'cli' });
145
+
146
+ if (choice.choice_index === 0) {
147
+ return await _resolveRename(roomsHome, previousSlug, suggestion.suggested_name, 'llm-suggested', decidedBy, envelope, dispatcher, userInputChannel, opts.surface);
148
+ } else if (choice.choice_index === 1) {
149
+ return await _resolveUserTyped(roomsHome, previousSlug, choice.free_text, decidedBy, envelope, dispatcher, userInputChannel, opts.surface);
150
+ } else if (choice.choice_index === 2) {
151
+ await _emitNamingDecided(roomDir, previousSlug, previousSlug, 'kept-untitled', decidedBy);
152
+ return { decision_path: 'kept-untitled', new_slug: previousSlug, room_dir: roomDir, decision_envelope: envelope };
153
+ } else if (choice.choice_index === 3) {
154
+ const cascadeResult = discardPlaceholderRoom(roomsHome, previousSlug, { decided_by: decidedBy });
155
+ await _emitNamingDecidedToMetaDb(roomsHome, previousSlug, null, 'discarded', decidedBy);
156
+ return { decision_path: 'discarded', new_slug: null, room_dir: null, decision_envelope: envelope, cascade_result: cascadeResult };
157
+ }
158
+
159
+ return { decision_path: null, new_slug: previousSlug, room_dir: roomDir, decision_envelope: envelope, reason: 'unknown_choice_index' };
160
+ }
161
+
162
+ // REVISION 2026-05-16 IN-path: resolve from a verb-string the user picked via
163
+ // Larry's AskUserQuestion render. Larry passes the verbatim verb-string;
164
+ // we map it back to the canonical decision_path.
165
+ async function _resolveFromUserPick(roomsHome, previousSlug, userPick, decidedBy, suggestedName) {
166
+ const roomDir = path.join(roomsHome, previousSlug);
167
+ // The verb-string the user picked. We compare against the locked labels.
168
+ const llmLabel = interpolateLlmSuggested(suggestedName || FALLBACK_SUGGESTION);
169
+ if (userPick === llmLabel || userPick.indexOf('[name this room:') === 0) {
170
+ return await _resolveRename(roomsHome, previousSlug, suggestedName || FALLBACK_SUGGESTION, 'llm-suggested', decidedBy, null, null, null, null);
171
+ }
172
+ if (userPick === F1_OPTION_LABELS.USER_TYPED) {
173
+ // User-typed path requires a free_text payload; in the IN-path duplex,
174
+ // Larry would call again with userPick = the free-text slug.
175
+ return { decision_path: null, new_slug: previousSlug, room_dir: roomDir, decision_envelope: null, reason: 'user_typed_requires_free_text' };
176
+ }
177
+ if (userPick === F1_OPTION_LABELS.KEEP_UNTITLED) {
178
+ await _emitNamingDecided(roomDir, previousSlug, previousSlug, 'kept-untitled', decidedBy);
179
+ return { decision_path: 'kept-untitled', new_slug: previousSlug, room_dir: roomDir, decision_envelope: null };
180
+ }
181
+ if (userPick === F1_OPTION_LABELS.DISCARD) {
182
+ const cascadeResult = discardPlaceholderRoom(roomsHome, previousSlug, { decided_by: decidedBy });
183
+ await _emitNamingDecidedToMetaDb(roomsHome, previousSlug, null, 'discarded', decidedBy);
184
+ return { decision_path: 'discarded', new_slug: null, room_dir: null, decision_envelope: null, cascade_result: cascadeResult };
185
+ }
186
+ // Otherwise treat as user-typed free-text input.
187
+ return await _resolveRename(roomsHome, previousSlug, userPick, 'user-typed', decidedBy, null, null, null, null);
188
+ }
189
+
190
+ async function _resolveRename(roomsHome, previousSlug, candidate, decisionPath, decidedBy, envelope, dispatcher, userInputChannel, surface) {
191
+ const validation = validateRoomName(candidate, { roomsHome });
192
+ if (!validation.ok) {
193
+ // Re-prompt inline with the rejection reason -- only if dispatcher + channel
194
+ // are available (live F.1 flow). When called from the IN-path (userPick), we
195
+ // return the failure so Larry can re-prompt at the conversational layer.
196
+ if (!dispatcher || !userInputChannel) {
197
+ return {
198
+ decision_path: null,
199
+ new_slug: null,
200
+ room_dir: path.join(roomsHome, previousSlug),
201
+ decision_envelope: null,
202
+ reason: 'validation_failed:' + validation.reasons.join(','),
203
+ validation_failure: validation,
204
+ };
205
+ }
206
+ const repromptVerbs = [
207
+ F1_OPTION_LABELS.USER_TYPED,
208
+ F1_OPTION_LABELS.KEEP_UNTITLED,
209
+ F1_OPTION_LABELS.DISCARD,
210
+ ];
211
+ const reprompt = dispatcher.pickShape({
212
+ requestedShape: 'F.1',
213
+ roomDir: path.join(roomsHome, previousSlug),
214
+ tier: 1,
215
+ payload: {
216
+ header: 'That name was rejected: ' + validation.reasons.join(', ') + '. Try again or pick another option.',
217
+ previous_slug: previousSlug,
218
+ validation_failure: validation,
219
+ verbs: repromptVerbs,
220
+ },
221
+ });
222
+ const choice = await userInputChannel(reprompt, { surface });
223
+ if (choice.choice_index === 0) {
224
+ return await _resolveUserTyped(roomsHome, previousSlug, choice.free_text, decidedBy, reprompt, dispatcher, userInputChannel, surface);
225
+ } else if (choice.choice_index === 1) {
226
+ await _emitNamingDecided(path.join(roomsHome, previousSlug), previousSlug, previousSlug, 'kept-untitled', decidedBy);
227
+ return { decision_path: 'kept-untitled', new_slug: previousSlug, room_dir: path.join(roomsHome, previousSlug), decision_envelope: reprompt };
228
+ } else {
229
+ const cascadeResult = discardPlaceholderRoom(roomsHome, previousSlug, { decided_by: decidedBy });
230
+ await _emitNamingDecidedToMetaDb(roomsHome, previousSlug, null, 'discarded', decidedBy);
231
+ return { decision_path: 'discarded', new_slug: null, room_dir: null, decision_envelope: reprompt, cascade_result: cascadeResult };
232
+ }
233
+ }
234
+ // Validation OK -- proceed with rename ceremony.
235
+ const newSlug = validation.normalized_slug;
236
+ const newRoomDir = path.join(roomsHome, newSlug);
237
+
238
+ // Registry update via room-registry CLI (venture_name field) followed by
239
+ // direct registry-key rename in the JSON.
240
+ try {
241
+ const registryScript = path.join(__dirname, '..', '..', 'scripts', 'room-registry');
242
+ if (fs.existsSync(registryScript)) {
243
+ try {
244
+ child_process.execFileSync('bash', [registryScript, 'update', previousSlug, 'venture_name', newSlug], {
245
+ cwd: process.cwd(),
246
+ env: Object.assign({}, process.env, { MINDRIAN_ROOMS_HOME: roomsHome }),
247
+ stdio: 'pipe',
248
+ timeout: 5000,
249
+ });
250
+ } catch (_e) { /* registry update failure non-fatal; direct mutation below covers it */ }
251
+ }
252
+ } catch (_e) { /* swallow */ }
253
+
254
+ // Direct registry mutation: rename the key.
255
+ try {
256
+ const registryPath = path.join(roomsHome, '.rooms', 'registry.json');
257
+ if (fs.existsSync(registryPath)) {
258
+ const raw = fs.readFileSync(registryPath, 'utf8');
259
+ const reg = JSON.parse(raw);
260
+ if (reg && reg.rooms && reg.rooms[previousSlug]) {
261
+ reg.rooms[newSlug] = reg.rooms[previousSlug];
262
+ reg.rooms[newSlug].venture_name = newSlug;
263
+ reg.rooms[newSlug].path = newRoomDir;
264
+ delete reg.rooms[previousSlug];
265
+ if (reg.active === previousSlug) reg.active = newSlug;
266
+ const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 8);
267
+ fs.writeFileSync(tmp, JSON.stringify(reg, null, 2), 'utf8');
268
+ fs.renameSync(tmp, registryPath);
269
+ }
270
+ }
271
+ } catch (_e) { /* surface via the rename failure below */ }
272
+
273
+ // fs.renameSync the directory itself. Preserves room.db row IDs (atomic
274
+ // intra-filesystem rename).
275
+ try {
276
+ fs.renameSync(path.join(roomsHome, previousSlug), newRoomDir);
277
+ } catch (renameErr) {
278
+ return {
279
+ decision_path: null,
280
+ new_slug: null,
281
+ room_dir: path.join(roomsHome, previousSlug),
282
+ decision_envelope: envelope,
283
+ reason: 'rename_failed:' + String(renameErr.message || renameErr).slice(0, 60),
284
+ };
285
+ }
286
+
287
+ // Emit the memory_event AFTER the rename (so we open the NEW room.db location).
288
+ await _emitNamingDecided(newRoomDir, previousSlug, newSlug, decisionPath, decidedBy);
289
+
290
+ return { decision_path: decisionPath, new_slug: newSlug, room_dir: newRoomDir, decision_envelope: envelope };
291
+ }
292
+
293
+ async function _resolveUserTyped(roomsHome, previousSlug, freeText, decidedBy, envelope, dispatcher, userInputChannel, surface) {
294
+ return await _resolveRename(roomsHome, previousSlug, freeText, 'user-typed', decidedBy, envelope, dispatcher, userInputChannel, surface);
295
+ }
296
+
297
+ async function _emitNamingDecided(roomDir, previousSlug, newSlug, decisionPath, decidedBy) {
298
+ try {
299
+ const dbPath = path.join(roomDir, '.mindrian', 'room.db');
300
+ if (!fs.existsSync(dbPath)) return;
301
+ const { openRoomDb, closeRoomDb } = require('./room-db.cjs');
302
+ const handle = openRoomDb(roomDir);
303
+ if (!handle) return;
304
+ try {
305
+ const nav = require('./navigation.cjs');
306
+ nav.logMemoryEvent(handle, 'room_naming_decided', {
307
+ previous_slug: previousSlug,
308
+ new_slug: newSlug,
309
+ decision_path: decisionPath,
310
+ decided_by: decidedBy,
311
+ source_path: 'system:room-naming-selector',
312
+ created_by: 'system',
313
+ });
314
+ } finally {
315
+ try { closeRoomDb(handle); } catch (_e) {}
316
+ }
317
+ } catch (_e) { /* memory_event emission is best-effort */ }
318
+ }
319
+
320
+ async function _emitNamingDecidedToMetaDb(roomsHome, previousSlug, newSlug, decisionPath, decidedBy) {
321
+ try {
322
+ const metaRoomDir = path.join(roomsHome, '.rooms', '_meta');
323
+ fs.mkdirSync(path.join(metaRoomDir, '.mindrian'), { recursive: true, mode: 0o755 });
324
+ const { openRoomDb, closeRoomDb } = require('./room-db.cjs');
325
+ const handle = openRoomDb(metaRoomDir);
326
+ if (!handle) return;
327
+ try {
328
+ const nav = require('./navigation.cjs');
329
+ nav.logMemoryEvent(handle, 'room_naming_decided', {
330
+ previous_slug: previousSlug,
331
+ new_slug: newSlug,
332
+ decision_path: decisionPath,
333
+ decided_by: decidedBy,
334
+ source_path: 'system:room-naming-selector',
335
+ created_by: 'system',
336
+ });
337
+ } finally {
338
+ try { closeRoomDb(handle); } catch (_e) {}
339
+ }
340
+ } catch (_e) { /* best-effort */ }
341
+ }
342
+
343
+ // Default user-input channel: production resolves to AskUserQuestion via the
344
+ // directive-file handoff (scripts/room-naming-selector.cjs writes a directive
345
+ // at <roomDir>/.context/pending-naming-decision.md; Larry dispatches on the
346
+ // next conversational turn). Tests inject a stub.
347
+ async function _defaultUserInputChannel(envelope, opts) {
348
+ throw new Error('user_input_channel_required:inject_via_opts');
349
+ }
350
+
351
+ module.exports = {
352
+ fireNamingSelector: fireNamingSelector,
353
+ DECISION_PATHS: DECISION_PATHS,
354
+ F1_OPTION_LABELS: F1_OPTION_LABELS,
355
+ interpolateLlmSuggested: interpolateLlmSuggested,
356
+ readThinnessFromState: readThinnessFromState,
357
+ };
@@ -0,0 +1,277 @@
1
+ 'use strict';
2
+ // Phase 119-01 Task 3 tests for lib/core/room-naming-selector.cjs.
3
+ // Validates F1_OPTION_LABELS verbatim values, DECISION_PATHS enum, the four
4
+ // decision branches (llm-suggested / user-typed / kept-untitled / discarded),
5
+ // the rename ceremony preserving room.db row IDs, the thinness prepend,
6
+ // and the Canon Part 8/9/10 invariants.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert');
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const os = require('node:os');
13
+ const cp = require('node:child_process');
14
+
15
+ const selector = require('./room-naming-selector.cjs');
16
+ const { openRoomDb, closeRoomDb } = require('./room-db.cjs');
17
+
18
+ function _mkPlaceholderRoom(slug, opts) {
19
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'naming-selector-test-'));
20
+ fs.mkdirSync(path.join(tmp, '.rooms'), { recursive: true });
21
+ const roomDir = path.join(tmp, slug);
22
+ fs.mkdirSync(path.join(roomDir, '.mindrian'), { recursive: true });
23
+ fs.writeFileSync(path.join(roomDir, '.room-root'), '');
24
+ const thinness = (opts && opts.thinness) ? 'true' : 'false';
25
+ fs.writeFileSync(path.join(roomDir, 'STATE.md'),
26
+ '---\nphase: scoping\nauto_explore_thin: ' + thinness + '\n---\n');
27
+ const db = openRoomDb(roomDir);
28
+ closeRoomDb(db);
29
+ const reg = {
30
+ version: 1,
31
+ active: slug,
32
+ rooms: {
33
+ [slug]: {
34
+ path: roomDir,
35
+ venture_name: 'untitled',
36
+ venture_stage: 'Pre-Opportunity',
37
+ status: 'active',
38
+ last_opened: Date.now(),
39
+ },
40
+ },
41
+ };
42
+ fs.writeFileSync(path.join(tmp, '.rooms', 'registry.json'), JSON.stringify(reg, null, 2), 'utf8');
43
+ return { roomsHome: tmp, roomDir, slug };
44
+ }
45
+
46
+ function _cleanup(roomsHome) {
47
+ try { fs.rmSync(roomsHome, { recursive: true, force: true }); } catch (_e) {}
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Static surface tests
52
+ // ---------------------------------------------------------------------------
53
+
54
+ test('Test 1: F1_OPTION_LABELS exposed verbatim per CONTEXT.md D-06', function () {
55
+ assert.strictEqual(selector.F1_OPTION_LABELS.LLM_SUGGESTED, '[name this room: {{SUGGESTED}}]');
56
+ assert.strictEqual(selector.F1_OPTION_LABELS.USER_TYPED, '[type your own name]');
57
+ assert.strictEqual(selector.F1_OPTION_LABELS.KEEP_UNTITLED, '[keep as untitled]');
58
+ assert.strictEqual(selector.F1_OPTION_LABELS.DISCARD, '[discard room]');
59
+ });
60
+
61
+ test('Test 2: DECISION_PATHS frozen enum -- four canonical values', function () {
62
+ assert.deepStrictEqual(selector.DECISION_PATHS, ['llm-suggested', 'user-typed', 'kept-untitled', 'discarded']);
63
+ assert.ok(Object.isFrozen(selector.DECISION_PATHS));
64
+ });
65
+
66
+ test('Test 2b: interpolateLlmSuggested produces verbatim label with interpolated suggestion', function () {
67
+ assert.strictEqual(selector.interpolateLlmSuggested('acme-robotics'), '[name this room: acme-robotics]');
68
+ assert.strictEqual(selector.interpolateLlmSuggested(null), '[name this room: untitled]');
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Decision-branch tests (via injected dispatcher + userInputChannel stubs)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function _stubDispatcher() {
76
+ return {
77
+ pickShape: function (args) {
78
+ return { shape: 'F.1', rendered: { options: (args.payload && args.payload.verbs) || [] }, payload: args.payload };
79
+ },
80
+ };
81
+ }
82
+
83
+ function _stubChannel(choiceIndex, freeText) {
84
+ return async function () {
85
+ return { choice_index: choiceIndex, free_text: freeText };
86
+ };
87
+ }
88
+
89
+ test('Test 3: llm-suggested branch -- rename + memory_event emission', async function () {
90
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1845');
91
+ try {
92
+ const result = await selector.fireNamingSelector({
93
+ roomDir: roomDir,
94
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
95
+ surface: 'cli',
96
+ decidedBy: 'jsagi',
97
+ dispatcher: _stubDispatcher(),
98
+ userInputChannel: _stubChannel(0),
99
+ llmClient: { complete: async function () { return { content: 'acme-robotics' }; } },
100
+ });
101
+ assert.strictEqual(result.decision_path, 'llm-suggested');
102
+ assert.strictEqual(result.new_slug, 'acme-robotics');
103
+ assert.ok(result.room_dir.endsWith('acme-robotics'));
104
+ assert.ok(fs.existsSync(result.room_dir), 'renamed room dir must exist on disk');
105
+ assert.ok(!fs.existsSync(roomDir), 'old placeholder dir must be gone');
106
+ } finally { _cleanup(roomsHome); }
107
+ });
108
+
109
+ test('Test 4: user-typed branch with reserved-prefix rejection -> re-prompt', async function () {
110
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1846');
111
+ try {
112
+ // First channel response: option 1 = user-typed with reserved-prefix free_text
113
+ // Second channel response (after re-prompt): option 1 = user-typed with valid free_text
114
+ let callCount = 0;
115
+ const channel = async function () {
116
+ callCount++;
117
+ if (callCount === 1) return { choice_index: 1, free_text: 'untitled-mine' };
118
+ return { choice_index: 0, free_text: 'acme-robotics' };
119
+ };
120
+ const result = await selector.fireNamingSelector({
121
+ roomDir: roomDir,
122
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
123
+ surface: 'cli',
124
+ decidedBy: 'jsagi',
125
+ dispatcher: _stubDispatcher(),
126
+ userInputChannel: channel,
127
+ llmClient: { complete: async function () { return { content: 'biotech' }; } },
128
+ });
129
+ assert.ok(callCount >= 2, 'expected the channel to be called >= 2 times (re-prompt path); got ' + callCount);
130
+ assert.strictEqual(result.decision_path, 'user-typed');
131
+ assert.strictEqual(result.new_slug, 'acme-robotics');
132
+ } finally { _cleanup(roomsHome); }
133
+ });
134
+
135
+ test('Test 5: kept-untitled branch -- no rename, memory_event with previous_slug == new_slug', async function () {
136
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1847');
137
+ try {
138
+ const result = await selector.fireNamingSelector({
139
+ roomDir: roomDir,
140
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
141
+ surface: 'cli',
142
+ decidedBy: 'jsagi',
143
+ dispatcher: _stubDispatcher(),
144
+ userInputChannel: _stubChannel(2),
145
+ llmClient: { complete: async function () { return { content: 'foo' }; } },
146
+ });
147
+ assert.strictEqual(result.decision_path, 'kept-untitled');
148
+ assert.strictEqual(result.new_slug, slug);
149
+ assert.strictEqual(result.room_dir, roomDir);
150
+ assert.ok(fs.existsSync(roomDir), 'roomDir must remain unchanged');
151
+ } finally { _cleanup(roomsHome); }
152
+ });
153
+
154
+ test('Test 6: discarded branch -- cascade fires + decision_envelope returned', async function () {
155
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1848');
156
+ try {
157
+ const result = await selector.fireNamingSelector({
158
+ roomDir: roomDir,
159
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
160
+ surface: 'cli',
161
+ decidedBy: 'jsagi',
162
+ dispatcher: _stubDispatcher(),
163
+ userInputChannel: _stubChannel(3),
164
+ llmClient: { complete: async function () { return { content: 'foo' }; } },
165
+ });
166
+ assert.strictEqual(result.decision_path, 'discarded');
167
+ assert.strictEqual(result.new_slug, null);
168
+ assert.strictEqual(result.room_dir, null);
169
+ assert.ok(!fs.existsSync(roomDir), 'placeholder room must be removed by cascade');
170
+ } finally { _cleanup(roomsHome); }
171
+ });
172
+
173
+ test('Test 7: rename ceremony preserves room.db existence (row-IDs preserved by atomic rename)', async function () {
174
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1849');
175
+ try {
176
+ // Insert a marker memory_event via navigation.cjs (the chokepoint).
177
+ const dbBefore = openRoomDb(roomDir);
178
+ try {
179
+ const nav = require('./navigation.cjs');
180
+ nav.logMemoryEvent(dbBefore, 'room_auto_created', {
181
+ slug: slug,
182
+ source_path: 'system:test',
183
+ created_by: 'system',
184
+ });
185
+ } finally {
186
+ closeRoomDb(dbBefore);
187
+ }
188
+ const result = await selector.fireNamingSelector({
189
+ roomDir: roomDir,
190
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
191
+ surface: 'cli',
192
+ decidedBy: 'jsagi',
193
+ dispatcher: _stubDispatcher(),
194
+ userInputChannel: _stubChannel(0),
195
+ llmClient: { complete: async function () { return { content: 'preserved-name' }; } },
196
+ });
197
+ assert.strictEqual(result.decision_path, 'llm-suggested');
198
+ // The renamed db file must exist; the row count must include both the original
199
+ // room_auto_created AND the newly emitted room_naming_decided.
200
+ const dbAfter = openRoomDb(result.room_dir);
201
+ try {
202
+ const rows = dbAfter.prepare("SELECT COUNT(*) AS c FROM nodes WHERE type='memory_event'").get();
203
+ assert.ok(rows.c >= 2, 'expected >= 2 memory_events; got ' + rows.c);
204
+ } finally {
205
+ closeRoomDb(dbAfter);
206
+ }
207
+ } finally { _cleanup(roomsHome); }
208
+ });
209
+
210
+ test('Test 8: rename ceremony updates registry -- new key + active flips', async function () {
211
+ const { roomsHome, roomDir, slug } = _mkPlaceholderRoom('untitled-2026-05-16-1850');
212
+ try {
213
+ await selector.fireNamingSelector({
214
+ roomDir: roomDir,
215
+ mvaCompletionPayload: { sentence_sha256: 'deadbeef0123' },
216
+ surface: 'cli',
217
+ decidedBy: 'jsagi',
218
+ dispatcher: _stubDispatcher(),
219
+ userInputChannel: _stubChannel(0),
220
+ llmClient: { complete: async function () { return { content: 'new-venture' }; } },
221
+ });
222
+ const reg = JSON.parse(fs.readFileSync(path.join(roomsHome, '.rooms', 'registry.json'), 'utf8'));
223
+ assert.ok(reg.rooms['new-venture'], 'new-venture key must exist after rename');
224
+ assert.ok(!reg.rooms[slug], 'old placeholder key must be removed');
225
+ assert.strictEqual(reg.active, 'new-venture');
226
+ } finally { _cleanup(roomsHome); }
227
+ });
228
+
229
+ test('Test 9: thinness voice line is prepended when auto_explore_thin: true', function () {
230
+ const { roomsHome, roomDir } = _mkPlaceholderRoom('untitled-2026-05-16-1851', { thinness: true });
231
+ try {
232
+ assert.strictEqual(selector.readThinnessFromState(roomDir), true);
233
+ const { voiceLine } = require('./larry-thinness-acknowledgment.cjs');
234
+ assert.ok(typeof voiceLine === 'function');
235
+ const line = voiceLine();
236
+ assert.ok(typeof line === 'string' && line.length > 0, 'thinness voice line must be a non-empty string');
237
+ } finally { _cleanup(roomsHome); }
238
+ });
239
+
240
+ test('Test 10: Canon Part 8 LOCAL invariant -- no Brain coupling in module + shim', function () {
241
+ const srcModule = fs.readFileSync(require.resolve('./room-naming-selector.cjs'), 'utf8');
242
+ const srcShim = fs.readFileSync(path.resolve(__dirname, '..', '..', 'scripts', 'room-naming-selector.cjs'), 'utf8');
243
+ for (const src of [srcModule, srcShim]) {
244
+ assert.ok(src.indexOf('brain.mindrian') === -1, 'brain.mindrian substring present (Canon Part 8 breach)');
245
+ assert.ok(!/require\([^)]*brain-client[^)]*\)/.test(src), 'brain-client require (Canon Part 8 breach)');
246
+ assert.ok(!/fetch\([^)]*['\"][^'\"]*brain[^'\"]*['\"]/.test(src), 'fetch to brain.* host (Canon Part 8 breach)');
247
+ }
248
+ });
249
+
250
+ test('Test 11: CLAUDE.md tri-polar header -- CLI / Desktop / Cowork mentioned in shim', function () {
251
+ const srcShim = fs.readFileSync(path.resolve(__dirname, '..', '..', 'scripts', 'room-naming-selector.cjs'), 'utf8');
252
+ assert.ok(srcShim.indexOf('CLI') !== -1, 'CLI must be acknowledged in shim header');
253
+ assert.ok(srcShim.indexOf('Desktop') !== -1, 'Desktop must be acknowledged in shim header');
254
+ assert.ok(srcShim.indexOf('Cowork') !== -1, 'Cowork must be acknowledged in shim header');
255
+ });
256
+
257
+ test('Test 12: em-dash invariant across orchestrator + shim + test file', function () {
258
+ const EMDASH = String.fromCharCode(0x2014);
259
+ const srcModule = fs.readFileSync(require.resolve('./room-naming-selector.cjs'), 'utf8');
260
+ const srcShim = fs.readFileSync(path.resolve(__dirname, '..', '..', 'scripts', 'room-naming-selector.cjs'), 'utf8');
261
+ const srcTest = fs.readFileSync(__filename, 'utf8');
262
+ for (const [name, src] of [['module', srcModule], ['shim', srcShim], ['test', srcTest]]) {
263
+ assert.ok(src.indexOf(EMDASH) === -1, 'em-dash present in ' + name + ' (HARD RULE)');
264
+ }
265
+ });
266
+
267
+ test('Test 13: CLI shim exits 0 on missing room with structured JSON error', function () {
268
+ const shimPath = path.resolve(__dirname, '..', '..', 'scripts', 'room-naming-selector.cjs');
269
+ const out = cp.spawnSync('node', [shimPath, '--room-dir', '/tmp/nonexistent-119-01-test-' + Date.now()], {
270
+ encoding: 'utf8',
271
+ });
272
+ assert.strictEqual(out.status, 0);
273
+ const parsed = JSON.parse(out.stdout.trim().split('\n').filter(Boolean).pop());
274
+ assert.strictEqual(parsed.shape, 'F.1');
275
+ assert.strictEqual(parsed.surface, false);
276
+ assert.strictEqual(parsed.reason, 'room_dir_not_found');
277
+ });