mirror-mind 0.36.3 → 0.38.0

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 (52) hide show
  1. package/.pi/extensions/mirror-logger.ts +173 -46
  2. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  3. package/README.md +2 -2
  4. package/docs/releases/index.md +2 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/docs/releases/v0.38.0.md +215 -0
  7. package/package.json +1 -1
  8. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  9. package/plugins/mirror-mind/plugin.json +1 -1
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  11. package/ts/src/builder/commands.ts +9 -6
  12. package/ts/src/builder/load.ts +14 -2
  13. package/ts/src/conversation/closeTail.ts +7 -3
  14. package/ts/src/conversation/extraction.ts +1 -11
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/userName.ts +44 -0
  17. package/ts/src/db/migrateOnOpen.ts +1 -1
  18. package/ts/src/db/migrations.ts +16 -0
  19. package/ts/src/db/schema.ts +0 -1
  20. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  21. package/ts/src/db/schemaState.ts +2 -0
  22. package/ts/src/db/schemaTsDivergence.ts +25 -4
  23. package/ts/src/extraction/conversationMetadata.ts +53 -8
  24. package/ts/src/extraction/prompts.ts +8 -3
  25. package/ts/src/frontDoor/cli.ts +42 -2
  26. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  27. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  28. package/ts/src/frontDoor/routing.ts +16 -15
  29. package/ts/src/frontDoor/runtimeRoute.ts +4 -0
  30. package/ts/src/frontDoor/soulRoute.ts +4 -4
  31. package/ts/src/guards/testTempHomes.ts +92 -0
  32. package/ts/src/hooks/agy.ts +136 -59
  33. package/ts/src/hooks/claude.ts +29 -90
  34. package/ts/src/hooks/mirrorState.ts +10 -22
  35. package/ts/src/mirror/context.ts +19 -4
  36. package/ts/src/mirror/defaultResolution.ts +2 -1
  37. package/ts/src/mirror/doorman.ts +420 -0
  38. package/ts/src/mirror/orchestration.ts +22 -3
  39. package/ts/src/mirror/render.ts +7 -1
  40. package/ts/src/mirror/runtimeSession.ts +66 -8
  41. package/ts/src/mode/operatingMode.ts +22 -4
  42. package/ts/src/providers/config.ts +105 -1
  43. package/ts/src/providers/cost.ts +5 -1
  44. package/ts/src/providers/llm.ts +40 -0
  45. package/ts/src/providers/transport.ts +14 -0
  46. package/ts/src/runtime/diagnose.ts +64 -7
  47. package/ts/src/runtime/pluginWiring.ts +6 -4
  48. package/ts/src/runtime/status.ts +24 -4
  49. package/ts/src/runtime/update.ts +17 -1
  50. package/ts/src/runtime/wiring.ts +14 -1
  51. package/ts/src/search/memorySearch.ts +38 -18
  52. package/ts/src/search/ranker.ts +16 -2
@@ -0,0 +1,420 @@
1
+ // The one doorman (CV9.DS8.TS2).
2
+ //
3
+ // Mirror Mode is the product: the folder `mirror mirror load` builds from the
4
+ // database, open before the model answers. What is at stake is always whether
5
+ // the folder reaches the model, and when. Until DS8 there were three doormen
6
+ // with three policies (the Claude-family inject hook, Antigravity's
7
+ // PreInvocation, and none on Pi), and wherever one existed the skill's own
8
+ // hard constraint made an explicit turn load twice (CR165, measured on Pi
9
+ // 1.4.0, 2026-10-24).
10
+ //
11
+ // This module is the one decision. Given a turn's prompt and the session's
12
+ // state it answers NOTHING, THE FULL FOLDER, or A DELTA:
13
+ //
14
+ // 1. the explicit Mirror form in the prompt -> the full folder, routed from
15
+ // the query, before the first token; the session marked active by its id;
16
+ // 2. Mirror Mode active, an ordinary turn -> the prompt is routed with
17
+ // `detectPersona` (a keyword score, no model call); a persona that newly
18
+ // scores has its section delivered and becomes the session's; the
19
+ // memories and attachments relevant to the prompt follow; the identity
20
+ // layers are not repeated -- they already sit in the transcript;
21
+ // a skill invocation other than the Mirror form is not a question and
22
+ // gets nothing (CR160's command-turn rule);
23
+ // 3. Mirror Mode not active -> nothing. The model's own routing may invoke
24
+ // the skill; the session learns that load from the harness (TS1), and the
25
+ // doorman takes over from the next turn.
26
+ //
27
+ // `decideDoorman` is pure: no database, no clock, no provider. `runDoorman`
28
+ // renders and writes, at the edge. The hooks (US4, TS6) call both in-process;
29
+ // the Pi extension (US5) reaches them through `mirror doorman`. Neither is
30
+ // rewired here.
31
+ //
32
+ // Every block the doorman delivers opens with one line the model reads
33
+ // (`deliveredLine`), on every active turn, even with nothing under it: it is
34
+ // what carries the persona across the conversation. On an ordinary turn the
35
+ // skill is never invoked, so this line is the only Mirror text that reaches
36
+ // the model -- the signature rule (`AGENTS.md`: `◇ persona-name`) rides in it.
37
+ //
38
+ // What a turn costs: nothing when Mirror Mode is off; on an ordinary active
39
+ // turn, ONE embedding, shared by the memory and attachment searches and
40
+ // counted once in `llm_calls` (DS8 decision 1); on the explicit turn, what
41
+ // `mirror load --query` costs today (reception's LLM call when enabled, the
42
+ // attachment embedding), plus the skill's own load until TS3 teaches it to
43
+ // trust this line.
44
+
45
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
46
+ import type { Database, WritableDatabase } from "#db/database.ts";
47
+ import { memoriesById } from "#frontDoor/searchRoute.ts";
48
+ import { getIdentityContent } from "#identity/identityRead.ts";
49
+ import { getActiveOperatingMode } from "#mode/operatingMode.ts";
50
+ import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
51
+ import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
52
+ import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
53
+ import { searchMemoriesWithStatus } from "#search/memorySearch.ts";
54
+ import { pythonJsonDumps } from "#util/pyGenerators.ts";
55
+ import { relevantAttachments } from "./context.ts";
56
+ import { personaRoutingRows } from "./defaultResolution.ts";
57
+ import { mirrorForm } from "./mirrorForm.ts";
58
+ import { type MirrorLoadInput, runMirrorLoad } from "./orchestration.ts";
59
+ import {
60
+ decodeMetadata,
61
+ getRuntimeSession,
62
+ updateRuntimeSessionColumns,
63
+ } from "./runtimeSession.ts";
64
+
65
+ /** The session's Mirror fields as the decision reads them; null when unnamed or unknown. */
66
+ export interface DoormanSessionState {
67
+ readonly active: boolean;
68
+ readonly persona: string | null;
69
+ readonly journey: string | null;
70
+ /**
71
+ * The folder left the transcript (the runtime compacted it, and said so
72
+ * through `forgetDoormanDelivery`): the next ordinary turn is full again.
73
+ */
74
+ readonly refresh?: boolean;
75
+ }
76
+
77
+ export type DoormanDecision =
78
+ | { readonly kind: "nothing" }
79
+ | { readonly kind: "full"; readonly query: string }
80
+ | {
81
+ readonly kind: "delta";
82
+ readonly query: string;
83
+ /** The persona that newly scored, or null when the session's stands. */
84
+ readonly persona: string | null;
85
+ };
86
+
87
+ /** The stable prefix TS3's skill reads; everything after the colon varies. */
88
+ export const DELIVERED_LINE_PREFIX = "=== mirror context delivered by the runtime for this turn";
89
+
90
+ /** How many memories a delta carries at most. */
91
+ export const DOORMAN_MEMORY_LIMIT = 5;
92
+
93
+ /**
94
+ * The threshold DS8's policy names ("injected when any score above the
95
+ * threshold"), on the SEMANTIC score the ranker reports -- the cosine of the
96
+ * prompt and the memory -- not the hybrid one (CV9.DS8.US5). The hybrid is
97
+ * half recency, reinforcement, relevance, and lexical: a fresh, reinforced,
98
+ * relevance-1 memory floors at 0.35 with no semantic match and would pass a
99
+ * composite 0.40 at cosine 0.10, which unrelated texts reach routinely; the 27
100
+ * composite rows TS2 and US4 logged (off-topic deltas topping at 0.331-0.377,
101
+ * 3 of 5 memories repeating; on-topic at 0.444-0.448 and 0.587-0.591) show the
102
+ * gap in one home, not a property. 0.4 is the rule `relevantAttachments`
103
+ * already applies to attachments: one rule for both. Inclusive. A degraded
104
+ * search (no embedding) reports no semantic score and keeps TS2's FTS
105
+ * behavior: a lexical hit is relevance by another signal, not a miss.
106
+ */
107
+ export const DOORMAN_MEMORY_SEMANTIC_THRESHOLD = 0.4;
108
+
109
+ /**
110
+ * Where the session remembers the memories the doorman has delivered
111
+ * (`runtime_sessions.metadata`, beside `operating_mode`). Every delta is a
112
+ * message that stays in the transcript, and US4 measured 3 of 5 memories
113
+ * repeating on a follow-up: a delta skips what the conversation already holds
114
+ * and fills from the next ones above the threshold. A full load starts the
115
+ * list over -- the folder is fresh, and so is what the model has read.
116
+ */
117
+ export const DOORMAN_DELIVERED_METADATA_KEY = "doorman_delivered";
118
+
119
+ /**
120
+ * Set on the session when the runtime compacted the transcript (the Pi
121
+ * extension's `session_compact`, CV9.DS8.US5): a delta's premise -- the
122
+ * identity layers already sit in the transcript -- no longer holds, so the
123
+ * next ordinary turn in Mirror Mode delivers the full folder, routed from that
124
+ * prompt, and the full load clears the flag.
125
+ */
126
+ export const DOORMAN_REFRESH_METADATA_KEY = "doorman_refresh";
127
+
128
+ /**
129
+ * The runtime says the folder left the transcript: the next ordinary turn is
130
+ * full, and the delivered list is forgotten with it. Unknown session: no-op.
131
+ */
132
+ export function forgetDoormanDelivery(
133
+ db: WritableDatabase,
134
+ sessionId: string,
135
+ nowIso: string,
136
+ ): void {
137
+ const session = getRuntimeSession(db, sessionId);
138
+ if (!session) return;
139
+ const metadata = decodeMetadata(session.metadata);
140
+ metadata[DOORMAN_REFRESH_METADATA_KEY] = true;
141
+ metadata[DOORMAN_DELIVERED_METADATA_KEY] = [];
142
+ updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
143
+ }
144
+
145
+ /** How many ranked memories a delta may read through to fill its five. */
146
+ const DOORMAN_SEARCH_CEILING = 25;
147
+
148
+ function deliveredMemoryIds(db: Database, sessionId: string | null): Set<string> {
149
+ if (!sessionId) return new Set();
150
+ const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[
151
+ DOORMAN_DELIVERED_METADATA_KEY
152
+ ];
153
+ return new Set(
154
+ Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [],
155
+ );
156
+ }
157
+
158
+ function rememberDelivered(
159
+ db: WritableDatabase,
160
+ sessionId: string | null,
161
+ ids: readonly string[],
162
+ nowIso: string,
163
+ options: { clearRefresh?: boolean } = {},
164
+ ): void {
165
+ if (!sessionId) return;
166
+ const session = getRuntimeSession(db, sessionId);
167
+ if (!session) return;
168
+ const metadata = decodeMetadata(session.metadata);
169
+ metadata[DOORMAN_DELIVERED_METADATA_KEY] = [...ids];
170
+ if (options.clearRefresh) delete metadata[DOORMAN_REFRESH_METADATA_KEY];
171
+ updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
172
+ }
173
+
174
+ /** One ranked memory's scores, content-free, for the front-door log. */
175
+ export interface MemoryScore {
176
+ readonly score: number;
177
+ readonly semantic: number;
178
+ }
179
+
180
+ /**
181
+ * The session's state as the decision reads it. "Mirror Mode on" is
182
+ * `mirror_active` -- which only `mirror deactivate` clears -- AND the active
183
+ * operating mode (the session's own row, else the global one) still being
184
+ * Mirror Mode (CV9.DS8.US4). `build load`, `explore`, and `soul load` switch the
185
+ * mode and leave the column set; read alone, it would put "answer as the
186
+ * mirror" on every Builder turn of a session that was once in Mirror Mode. A
187
+ * home with no mode record at all leaves the column to decide.
188
+ */
189
+ export function readDoormanSessionState(
190
+ db: Database,
191
+ sessionId: string | null,
192
+ ): DoormanSessionState | null {
193
+ if (!sessionId) return null;
194
+ const session = getRuntimeSession(db, sessionId);
195
+ if (!session) return null;
196
+ const mode = getActiveOperatingMode(db, sessionId);
197
+ const inMirrorMode = mode === null || mode.mode === "Mirror Mode";
198
+ return {
199
+ active: session.mirrorActive && inMirrorMode,
200
+ persona: session.persona,
201
+ journey: session.journey,
202
+ refresh: decodeMetadata(session.metadata)[DOORMAN_REFRESH_METADATA_KEY] === true,
203
+ };
204
+ }
205
+
206
+ export function decideDoorman(input: {
207
+ prompt: string;
208
+ state: DoormanSessionState | null;
209
+ personas: readonly PersonaRoutingRow[];
210
+ }): DoormanDecision {
211
+ const explicit = mirrorForm(input.prompt);
212
+ if (explicit) return { kind: "full", query: explicit.query };
213
+ if (!input.state?.active) return { kind: "nothing" };
214
+ // A skill invocation is not a question to the mirror (CR160's rule for the
215
+ // logger, held here since CV9.DS8.US5's walk): `/mm-build`, `/mm-journeys`,
216
+ // `/mm-explore` paid an embedding and got a delta, and one Builder card was
217
+ // signed. The Mirror form, the one skill whose argument IS the question,
218
+ // was answered above.
219
+ if (isCommandTurn(input.prompt)) return { kind: "nothing" };
220
+ // The runtime compacted the transcript: the folder is a summary now, so this
221
+ // turn delivers it whole, routed from the prompt, and the deltas resume after.
222
+ if (input.state.refresh) return { kind: "full", query: input.prompt };
223
+ const match = detectPersona(input.prompt, input.personas)[0]?.key ?? null;
224
+ const persona = match !== null && match !== input.state.persona ? match : null;
225
+ return { kind: "delta", query: input.prompt, persona };
226
+ }
227
+
228
+ export function deliveredLine(input: {
229
+ kind: "full" | "delta";
230
+ persona: string | null;
231
+ journey: string | null;
232
+ }): string {
233
+ const clause = input.persona
234
+ ? `answer as the mirror; sign ◇ ${input.persona}`
235
+ : "answer as the mirror";
236
+ const what =
237
+ input.kind === "full"
238
+ ? "full"
239
+ : `delta (persona: ${input.persona ?? "none"} · journey: ${input.journey ?? "none"})`;
240
+ return `${DELIVERED_LINE_PREFIX}: ${what} — ${clause} ===`;
241
+ }
242
+
243
+ export interface RunDoormanInput
244
+ extends Omit<MirrorLoadInput, "query" | "contextOnly" | "sessionId" | "environmentSessionId"> {
245
+ /** The named session, or null: an unnamed explicit turn loads without marking one. */
246
+ sessionId: string | null;
247
+ embeddingProvider?: EmbeddingProvider;
248
+ /**
249
+ * Where a delta turn's memory scores go -- every ranked memory's, the ones
250
+ * under the threshold included -- so the threshold is read against
251
+ * measurement (US5's live walk), not invented. Content-free: numbers only.
252
+ */
253
+ onMemoryScores?: (scores: readonly MemoryScore[]) => void;
254
+ }
255
+
256
+ export interface RenderedDoorman {
257
+ stdout: string;
258
+ stderr: string;
259
+ }
260
+
261
+ /**
262
+ * Render and write for a decision already taken. The caller opened `db` for
263
+ * writing only because the decision is `full` or `delta`: a `nothing` turn must
264
+ * never reach a writable handle (the live-write path snapshots the database
265
+ * first, 95 ms measured), which is why decide and run are two steps.
266
+ */
267
+ export async function runDoorman(
268
+ db: WritableDatabase,
269
+ decision: DoormanDecision,
270
+ input: RunDoormanInput,
271
+ ): Promise<RenderedDoorman> {
272
+ if (decision.kind === "nothing") return { stdout: "", stderr: "" };
273
+ if (decision.kind === "full") return runFull(db, decision.query, input);
274
+ return runDelta(db, decision, input);
275
+ }
276
+
277
+ async function runFull(
278
+ db: WritableDatabase,
279
+ query: string,
280
+ input: RunDoormanInput,
281
+ ): Promise<RenderedDoorman> {
282
+ const loaded = await runMirrorLoad(db, {
283
+ ...input,
284
+ query: query || null,
285
+ contextOnly: true,
286
+ sessionId: input.sessionId,
287
+ });
288
+ if (input.sessionId) {
289
+ // The conversation's context starts over with the folder: so does the
290
+ // list of memories the deltas have put in it, and a compaction's refresh
291
+ // is answered.
292
+ rememberDelivered(db, input.sessionId, [], input.nowIso(), { clearRefresh: true });
293
+ }
294
+ const line = deliveredLine({
295
+ kind: "full",
296
+ persona: loaded.resolved.persona,
297
+ journey: loaded.resolved.journey,
298
+ });
299
+ return { stdout: `${line}\n${loaded.stdout}`, stderr: loaded.stderr };
300
+ }
301
+
302
+ async function runDelta(
303
+ db: WritableDatabase,
304
+ decision: Extract<DoormanDecision, { kind: "delta" }>,
305
+ input: RunDoormanInput,
306
+ ): Promise<RenderedDoorman> {
307
+ const state = readDoormanSessionState(db, input.sessionId);
308
+ const persona = decision.persona ?? state?.persona ?? null;
309
+ const journey = state?.journey ?? null;
310
+ const parts: string[] = [deliveredLine({ kind: "delta", persona, journey })];
311
+
312
+ if (decision.persona) {
313
+ const content = getIdentityContent(db, "persona", decision.persona);
314
+ if (content) parts.push(`=== persona/${decision.persona} ===\n${content}`);
315
+ if (input.sessionId) {
316
+ updateRuntimeSessionColumns(
317
+ db,
318
+ input.sessionId,
319
+ { persona: decision.persona },
320
+ input.nowIso(),
321
+ );
322
+ }
323
+ }
324
+
325
+ // One embedding for the turn, in the ledger once; both searches share it.
326
+ // Without a provider, or when it fails, the memory search degrades to FTS
327
+ // and the attachments are skipped: the delta still carries the line and the
328
+ // persona.
329
+ const queryEmbedding = await embedOnce(db, decision.query, input);
330
+
331
+ const delivered = deliveredMemoryIds(db, input.sessionId);
332
+ const memories = await searchMemoriesWithStatus(db, {
333
+ query: decision.query,
334
+ // Read past what the transcript already holds, so a delta still fills.
335
+ limit: Math.min(DOORMAN_MEMORY_LIMIT + delivered.size, DOORMAN_SEARCH_CEILING),
336
+ provider: input.embeddingProvider,
337
+ ...(queryEmbedding ? { queryEmbedding } : {}),
338
+ // The doorman searches on the system's behalf N times a session: a turn's
339
+ // injection must not raise a memory's reinforcement for the next turn
340
+ // (AI-12). Reinforcement stays with the explicit load.
341
+ logAccess: false,
342
+ recordEmbeddingLedger: false,
343
+ now: input.nowIso(),
344
+ });
345
+ input.onMemoryScores?.(
346
+ memories.results.map((result) => ({ score: result.score, semantic: result.semantic })),
347
+ );
348
+ const relevant = (
349
+ memories.degraded
350
+ ? memories.results
351
+ : memories.results.filter((result) => result.semantic >= DOORMAN_MEMORY_SEMANTIC_THRESHOLD)
352
+ )
353
+ .filter((result) => !delivered.has(result.id))
354
+ .slice(0, DOORMAN_MEMORY_LIMIT);
355
+ if (relevant.length > 0) {
356
+ rememberDelivered(
357
+ db,
358
+ input.sessionId,
359
+ [...delivered, ...relevant.map((result) => result.id)],
360
+ input.nowIso(),
361
+ );
362
+ const rows = memoriesById(
363
+ db,
364
+ relevant.map((result) => result.id),
365
+ );
366
+ const block = ["=== relevant memories ==="];
367
+ for (const result of relevant) {
368
+ const memory = rows.get(result.id);
369
+ if (!memory) continue;
370
+ block.push(`\n[${memory.layer}] ${memory.title}\n${memory.content}`);
371
+ }
372
+ parts.push(block.join("\n"));
373
+ }
374
+
375
+ if (queryEmbedding) {
376
+ const attachments = await relevantAttachments(
377
+ db,
378
+ decision.query,
379
+ journey,
380
+ undefined,
381
+ undefined,
382
+ queryEmbedding,
383
+ );
384
+ if (attachments.length > 0) {
385
+ const block = ["=== relevant attachments ==="];
386
+ for (const { row, score } of attachments) {
387
+ const source = journey ? "" : ` [${row.journeyId}]`;
388
+ block.push(`--- ${row.name}${source} (score: ${score.toFixed(3)}) ---`);
389
+ if (row.description) block.push(`Description: ${row.description}`);
390
+ block.push(row.content);
391
+ }
392
+ parts.push(block.join("\n"));
393
+ }
394
+ }
395
+
396
+ return { stdout: `${parts.join("\n\n")}\n`, stderr: "" };
397
+ }
398
+
399
+ async function embedOnce(
400
+ db: WritableDatabase,
401
+ query: string,
402
+ input: RunDoormanInput,
403
+ ): Promise<readonly number[] | null> {
404
+ if (!input.embeddingProvider) return null;
405
+ try {
406
+ return await generateEmbeddingSafely(input.embeddingProvider, query, {
407
+ onAttempt: embeddingLedgerHook(db, {
408
+ sessionId: input.sessionId,
409
+ now: input.nowIso,
410
+ }),
411
+ });
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ /** The persona routing table the decision scores against; read once per turn. */
418
+ export function doormanPersonas(db: Database): PersonaRoutingRow[] {
419
+ return personaRoutingRows(db);
420
+ }
@@ -23,6 +23,7 @@ import {
23
23
  decodeMetadata,
24
24
  GLOBAL_STICKY_DEFAULTS_SESSION_ID,
25
25
  getRuntimeSession,
26
+ resolveNamedRuntimeSessionId,
26
27
  resolveRuntimeSessionId,
27
28
  upsertRuntimeSession,
28
29
  } from "./runtimeSession.ts";
@@ -120,13 +121,33 @@ export async function runMirrorLoad(
120
121
  });
121
122
  persistStickyDefaults(db, resolved.persona, resolved.journey, input.nowIso());
122
123
  activateOperatingMode(db, { mode: "Mirror Mode", journey: resolved.journey }, input.nowIso());
124
+ // The NAMED session -- explicit, else the one the harness put in the
125
+ // environment -- never a guess (CV9.DS8.TS2, closing TS1's measured gap:
126
+ // the skill's own load passed the environment id into the route and this
127
+ // write ignored it, so a self-started Mirror Mode left `mirror_active`
128
+ // unwritten and the doorman had nothing to learn from).
129
+ const namedSessionId = resolveNamedRuntimeSessionId(
130
+ input.sessionId ?? null,
131
+ input.environmentSessionId ?? null,
132
+ );
133
+ if (namedSessionId) {
134
+ // The session's own mode row as well as the global one (CV9.DS8.US4): the
135
+ // doorman reads the session's row first, and `build load` stamps that same
136
+ // row Builder -- without this write a Mirror re-entry in the same session
137
+ // would read as Builder and the deltas would never resume.
138
+ activateOperatingMode(
139
+ db,
140
+ { mode: "Mirror Mode", journey: resolved.journey, sessionId: namedSessionId },
141
+ input.nowIso(),
142
+ );
143
+ }
123
144
  writeMirrorState(
124
145
  db,
125
146
  {
126
147
  active: true,
127
148
  persona: resolved.persona,
128
149
  journey: resolved.journey,
129
- sessionId: input.sessionId ?? null,
150
+ sessionId: namedSessionId,
130
151
  },
131
152
  input.nowIso(),
132
153
  );
@@ -246,7 +267,6 @@ export function persistStickyDefaults(
246
267
  mirrorActive: false,
247
268
  ...(persona !== null ? { persona } : {}),
248
269
  ...(journey !== null ? { journey } : {}),
249
- hookInjected: true,
250
270
  active: false,
251
271
  },
252
272
  nowIso,
@@ -271,7 +291,6 @@ function writeMirrorState(
271
291
  mirrorActive: input.active,
272
292
  ...(input.persona !== undefined && input.persona !== null ? { persona: input.persona } : {}),
273
293
  ...(input.journey !== undefined && input.journey !== null ? { journey: input.journey } : {}),
274
- hookInjected: !input.active,
275
294
  },
276
295
  nowIso,
277
296
  );
@@ -16,10 +16,16 @@ export function renderMirrorModeTransition(input: {
16
16
  return box("◌ MIRROR MODE ACTIVE", rows);
17
17
  }
18
18
 
19
+ /**
20
+ * The stderr banner `mirror mirror load` prints. An active persona is signed
21
+ * `◇ <persona>`, the one rule `AGENTS.md` states (DS8 decision 2) -- on Pi the
22
+ * model reads stderr inside the tool result, so the banner is also an example
23
+ * of the signature it is asked to write.
24
+ */
19
25
  export function renderMirrorBanner(persona: string | null): string {
20
26
  const first = "\u001b[38;5;183m⏺ Mirror Mode active\u001b[0m\n";
21
27
  return persona
22
- ? `${first}\u001b[38;5;183m ✦ Persona: ${persona}\u001b[0m\n`
28
+ ? `${first}\u001b[38;5;183m ◇ ${persona}\u001b[0m\n`
23
29
  : `${first}\u001b[38;5;183m Ego responding without persona\u001b[0m\n`;
24
30
  }
25
31
 
@@ -10,7 +10,6 @@ export interface RuntimeSessionRow {
10
10
  mirrorActive: boolean;
11
11
  persona: string | null;
12
12
  journey: string | null;
13
- hookInjected: boolean;
14
13
  active: boolean;
15
14
  startedAt: string;
16
15
  updatedAt: string;
@@ -24,7 +23,6 @@ export interface RuntimeSessionPatch {
24
23
  mirrorActive?: boolean;
25
24
  persona?: string | null;
26
25
  journey?: string | null;
27
- hookInjected?: boolean;
28
26
  active?: boolean;
29
27
  closedAt?: string | null;
30
28
  metadata?: string | null;
@@ -40,7 +38,6 @@ export function getRuntimeSession(db: Database, sessionId: string): RuntimeSessi
40
38
  mirrorActive: Boolean(row.mirror_active),
41
39
  persona: nullableString(row.persona),
42
40
  journey: nullableString(row.journey),
43
- hookInjected: Boolean(row.hook_injected),
44
41
  active: Boolean(row.active),
45
42
  startedAt: String(row.started_at),
46
43
  updatedAt: String(row.updated_at),
@@ -67,7 +64,6 @@ export function upsertRuntimeSession(
67
64
  mirrorActive: value("mirrorActive", existing?.mirrorActive ?? false) as boolean,
68
65
  persona: value("persona", existing?.persona ?? null) as string | null,
69
66
  journey: value("journey", existing?.journey ?? null) as string | null,
70
- hookInjected: value("hookInjected", existing?.hookInjected ?? false) as boolean,
71
67
  active: value("active", existing?.active ?? true) as boolean,
72
68
  startedAt: existing?.startedAt ?? nowIso,
73
69
  updatedAt: nowIso,
@@ -77,15 +73,14 @@ export function upsertRuntimeSession(
77
73
  db.prepare(
78
74
  `INSERT INTO runtime_sessions
79
75
  (session_id, conversation_id, interface, mirror_active, persona, journey,
80
- hook_injected, active, started_at, updated_at, closed_at, metadata)
81
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ active, started_at, updated_at, closed_at, metadata)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
78
  ON CONFLICT(session_id) DO UPDATE SET
83
79
  conversation_id = excluded.conversation_id,
84
80
  interface = excluded.interface,
85
81
  mirror_active = excluded.mirror_active,
86
82
  persona = excluded.persona,
87
83
  journey = excluded.journey,
88
- hook_injected = excluded.hook_injected,
89
84
  active = excluded.active,
90
85
  updated_at = excluded.updated_at,
91
86
  closed_at = excluded.closed_at,
@@ -97,7 +92,6 @@ export function upsertRuntimeSession(
97
92
  Number(row.mirrorActive),
98
93
  row.persona,
99
94
  row.journey,
100
- Number(row.hookInjected),
101
95
  Number(row.active),
102
96
  row.startedAt,
103
97
  row.updatedAt,
@@ -136,6 +130,70 @@ export function compareAndSwapRuntimeSessionMetadata(
136
130
  return Number(result.changes) === 1;
137
131
  }
138
132
 
133
+ /**
134
+ * Columns the doorman moves one at a time (CV9.DS8.TS2).
135
+ *
136
+ * `upsertRuntimeSession` reads the row, rebuilds it, and writes every column
137
+ * back, with no transaction around the read and the write. Two writers on the
138
+ * same turn -- the prompt logger setting `conversation_id`/`active`, the doorman
139
+ * setting `persona` -- would each overwrite the other's column with the stale
140
+ * copy it read. A single-column UPDATE touches nothing it was not asked to.
141
+ */
142
+ export interface RuntimeSessionColumnPatch {
143
+ persona?: string | null;
144
+ /** The whole metadata JSON, as the caller re-dumped it after its own read. */
145
+ metadata?: string | null;
146
+ }
147
+
148
+ /** Set only the named columns on an existing row; false when there is no row. */
149
+ export function updateRuntimeSessionColumns(
150
+ db: WritableDatabase,
151
+ sessionId: string,
152
+ patch: RuntimeSessionColumnPatch,
153
+ nowIso: string,
154
+ ): boolean {
155
+ const assignments: string[] = [];
156
+ const params: (string | number | null)[] = [];
157
+ if (patch.persona !== undefined) {
158
+ assignments.push("persona = ?");
159
+ params.push(patch.persona);
160
+ }
161
+ if (patch.metadata !== undefined) {
162
+ assignments.push("metadata = ?");
163
+ params.push(patch.metadata);
164
+ }
165
+ if (assignments.length === 0) return false;
166
+ assignments.push("updated_at = ?");
167
+ params.push(nowIso, sessionId);
168
+ const result = db
169
+ .prepare(`UPDATE runtime_sessions SET ${assignments.join(", ")} WHERE session_id = ?`)
170
+ .run(...params);
171
+ return Number(result.changes) === 1;
172
+ }
173
+
174
+ /**
175
+ * The session the harness named in the environment (CV9.DS8.TS1, measured
176
+ * 2026-10-24): `MIRROR_SESSION_ID`, which the Pi extension sets at
177
+ * `session_start`; else `CLAUDE_CODE_SESSION_ID` (Claude Code 2.1.310) or
178
+ * `CODEX_SESSION_ID` (Codex 0.164.0), both undocumented, both equal to the
179
+ * session row the runtime's hooks write, both present in the model's own shell
180
+ * -- so a `mirror mirror load` the model runs by itself names the same session
181
+ * the hooks do. One reader, as `runtimeDetection` reads Grok's variables.
182
+ *
183
+ * Read by the mirror load route and the doorman only (TS2): widening it to the
184
+ * Builder, Soul, and Explorer routes would bind those sessions on Claude Code
185
+ * and Codex too, a CR008 question a later story may open.
186
+ */
187
+ export function environmentSessionId(
188
+ env: Readonly<Record<string, string | undefined>>,
189
+ ): string | null {
190
+ for (const name of ["MIRROR_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_SESSION_ID"]) {
191
+ const value = env[name]?.trim();
192
+ if (value) return value;
193
+ }
194
+ return null;
195
+ }
196
+
139
197
  /**
140
198
  * The session a caller was TOLD about: `--session-id`, else `MIRROR_SESSION_ID`.
141
199
  * `null` means nobody named one. It never guesses, so it is the only resolver a
@@ -36,18 +36,36 @@ export function modeLabel(state: OperatingModeState): string {
36
36
  export interface OperatingModeState {
37
37
  mode: string;
38
38
  journey: string | null;
39
+ /**
40
+ * Present, and true, only on a session's stamp written under the session's
41
+ * NAME (`--session-id` or the harness's variable), never on a guessed one
42
+ * or the global row (CR008 option B, landed by CV9.DS8.US5). A lifecycle
43
+ * command binds a journey from a named session's Builder stamp only when
44
+ * this is set: `build load` with no name still stamps a guessed session,
45
+ * so another window's load can land on this one's row.
46
+ */
47
+ namedSession?: true;
39
48
  }
40
49
 
41
50
  export function activateOperatingMode(
42
51
  db: WritableDatabase,
43
- input: { mode: string; journey?: string | null; sessionId?: string | null },
52
+ input: { mode: string; journey?: string | null; sessionId?: string | null; named?: boolean },
44
53
  nowIso: string,
45
54
  ): OperatingModeState {
46
55
  const mode = input.mode.trim();
47
56
  if (!mode) throw new Error("mode must not be empty");
48
57
  const journey = input.journey?.trim() || null;
49
- const state = { mode, journey };
50
- const payload = { active_mode: mode, active_journey: journey };
58
+ const named = Boolean(input.sessionId) && input.named === true;
59
+ const state: OperatingModeState = named
60
+ ? { mode, journey, namedSession: true }
61
+ : { mode, journey };
62
+ // The key only when true: an unnamed stamp's bytes are what they were
63
+ // (`operating-mode-metadata.golden.json`).
64
+ const payload = {
65
+ active_mode: mode,
66
+ active_journey: journey,
67
+ ...(named ? { named_session: true } : {}),
68
+ };
51
69
  if (input.sessionId) {
52
70
  const session = getRuntimeSession(db, input.sessionId);
53
71
  const metadata = decodeMetadata(session?.metadata ?? null);
@@ -133,7 +151,7 @@ function stateFromPayload(payload: unknown): OperatingModeState | null {
133
151
  typeof record.active_journey === "string" && record.active_journey.trim()
134
152
  ? record.active_journey.trim()
135
153
  : null;
136
- return { mode, journey };
154
+ return record.named_session === true ? { mode, journey, namedSession: true } : { mode, journey };
137
155
  }
138
156
 
139
157
  export function renderModeActivation(state: OperatingModeState): string {