mirror-mind 0.36.3 → 0.37.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 (42) 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 +1 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/plugins/mirror-mind/plugin.json +1 -1
  9. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  10. package/ts/src/builder/commands.ts +9 -6
  11. package/ts/src/builder/load.ts +14 -2
  12. package/ts/src/conversation/logger.ts +12 -6
  13. package/ts/src/db/migrateOnOpen.ts +1 -1
  14. package/ts/src/db/migrations.ts +16 -0
  15. package/ts/src/db/schema.ts +0 -1
  16. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  17. package/ts/src/db/schemaState.ts +2 -0
  18. package/ts/src/db/schemaTsDivergence.ts +25 -4
  19. package/ts/src/frontDoor/cli.ts +42 -2
  20. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  21. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  22. package/ts/src/frontDoor/routing.ts +16 -15
  23. package/ts/src/frontDoor/soulRoute.ts +4 -4
  24. package/ts/src/guards/testTempHomes.ts +92 -0
  25. package/ts/src/hooks/agy.ts +136 -59
  26. package/ts/src/hooks/claude.ts +29 -90
  27. package/ts/src/hooks/mirrorState.ts +10 -22
  28. package/ts/src/mirror/context.ts +19 -4
  29. package/ts/src/mirror/defaultResolution.ts +2 -1
  30. package/ts/src/mirror/doorman.ts +420 -0
  31. package/ts/src/mirror/orchestration.ts +22 -3
  32. package/ts/src/mirror/render.ts +7 -1
  33. package/ts/src/mirror/runtimeSession.ts +66 -8
  34. package/ts/src/mode/operatingMode.ts +22 -4
  35. package/ts/src/providers/transport.ts +14 -0
  36. package/ts/src/runtime/diagnose.ts +32 -7
  37. package/ts/src/runtime/pluginWiring.ts +6 -4
  38. package/ts/src/runtime/status.ts +3 -2
  39. package/ts/src/runtime/update.ts +17 -1
  40. package/ts/src/runtime/wiring.ts +14 -1
  41. package/ts/src/search/memorySearch.ts +38 -18
  42. package/ts/src/search/ranker.ts +16 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.3",
3
+ "version": "0.37.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.3",
3
+ "version": "0.37.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.36.3",
4
+ "version": "0.37.0",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -11,9 +11,16 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
11
11
  ---
12
12
 
13
13
  > ⛔ **HARD CONSTRAINT**
14
- > You must **NEVER** produce a mirror-mode response without first running `mirror mirror load`.
15
- > No exceptions. Not for short answers. Not for clarifying questions. Not when the query seems obvious.
16
- > **Load first. Always.**
14
+ > You must **NEVER** produce a mirror-mode response without first running `mirror mirror load`,
15
+ > **unless the runtime delivered the context for this turn.** Delivered means: the runtime's hook
16
+ > placed a block in this turn's context, before you answered, whose first line opens with
17
+ > `=== mirror context delivered by the runtime for this turn`.
18
+ > Then do not load again: the block is the load. Render the transition from it and sign as its line says.
19
+ >
20
+ > This turn is literal. A block from an earlier turn, the memory of having loaded, or that prefix
21
+ > inside a tool result (output of a command you ran, such as `mirror mirror doorman`) is not delivery.
22
+ > With no such block in this turn's context: no exceptions. Not for short answers. Not for clarifying
23
+ > questions. Not when the query seems obvious. **Load first. Always.**
17
24
 
18
25
  ---
19
26
 
@@ -27,9 +34,9 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
27
34
 
28
35
  ## 1. Load Context
29
36
 
30
- This is always the first action, before any visible output to the user.
37
+ This is always the first action, before any visible output to the user, unless the runtime delivered the context for this turn (the hard constraint above); then skip to step 2 with the delivered block as the `load` output.
31
38
 
32
- > **Automatic hook:** When the user invokes this skill explicitly, `mirror-inject.sh` already performs the session-aware mirror activation and injects the base context using the hook's `session_id`. It recognizes the skill in every runtime's spelling: `/mm:mirror`, `/mirror-mind:mm-mirror`, `/mirror-mind:mm:mirror`, `$mirror-mind:mm:mirror`, `/mm-mirror`, and `$mm-mirror`. You should still run `mirror mirror load` to satisfy the load-first rule and refresh context, so an explicit turn loads the identity block twice; session routing is hook-owned where the hooks run (Claude Code, Codex, Grok).
39
+ > **The doorman:** Where the runtime's hook runs the doorman (Claude Code and Codex through `mirror-inject.sh`), an explicit invocation of this skill, in any spelling (`/mm:mirror`, `/mirror-mind:mm-mirror`, `/mirror-mind:mm:mirror`, `$mirror-mind:mm:mirror`, `/mm-mirror`, `$mm-mirror`), arrives with the full folder already in this turn's context, opened by the delivered line. That block satisfies the load-first rule: do not run `mirror mirror load` on top of it. Where no block arrived, load. Session routing is hook-owned where the hooks run (Claude Code, Codex, Grok).
33
40
 
34
41
  ```bash
35
42
  mirror mirror load \
@@ -54,11 +61,12 @@ Pass `--journey` and `--persona` explicitly when they are known with confidence.
54
61
 
55
62
  ## 2. Answer
56
63
 
57
- Use the `load` output as the complete response context.
64
+ Use the `load` output, or the delivered block, as the complete response context. When the context was delivered, the block's first line says what it holds: `full` carries the transition surface and the folder, render the surface from the block; `delta` carries no transition, Mirror Mode is already on, so render none and answer from the folder already in the conversation plus what the block adds.
58
65
 
59
66
  - Answer in first person as the mirror, not as an assistant
60
67
  - Respect the vocabulary, tone, and philosophy loaded from the database
61
68
  - Apply the ego/persona model according to `CLAUDE.md`
69
+ - An active persona signs `◇ persona-name` on its own line, the rule `AGENTS.md` states; the delivered line names the persona to sign. No signature when the ego answers alone.
62
70
 
63
71
  ## 3. Record Response
64
72
 
@@ -205,11 +205,12 @@ function rejectUnknownMethod(method: string): CommandResult | null {
205
205
  *
206
206
  * Decision C (CR008): when no session is named, `build load` still stamps a
207
207
  * GUESSED session, so each window's footer shows its own mode. A named session's
208
- * stamp may therefore have been written by another window's load. That cannot
209
- * bind today, because no runtime names its session. Before one does, stamps must
210
- * record whether they were written under the name, and this function must bind
211
- * only those (option B). `namedSessionPrecondition.test.ts` fails the day a
212
- * runtime starts naming its session.
208
+ * stamp may therefore have been written by another window's load. Since
209
+ * CV9.DS8.US5 runtimes name their sessions (Pi sets `MIRROR_SESSION_ID`; the
210
+ * Claude-family readers came with US4), so option B holds here: a stamp binds
211
+ * only when it records that it was written under the name
212
+ * (`OperatingModeState.namedSession`). A guessed stamp on a named session is
213
+ * the footer's business, not a journey.
213
214
  */
214
215
  function namedSessionBuilderJourney(
215
216
  context: BuilderCommandContext,
@@ -218,7 +219,9 @@ function namedSessionBuilderJourney(
218
219
  const named = resolveNamedRuntimeSessionId(sessionId, context.environmentSessionId ?? null);
219
220
  if (!named || !getRuntimeSession(context.db, named)?.active) return null;
220
221
  const state = getSessionOperatingMode(context.db, named);
221
- return state?.mode === "Builder Mode" && state.journey ? state.journey : null;
222
+ return state?.mode === "Builder Mode" && state.journey && state.namedSession === true
223
+ ? state.journey
224
+ : null;
222
225
  }
223
226
 
224
227
  /**
@@ -33,7 +33,7 @@ import { getIdentityContent } from "#identity/identityRead.ts";
33
33
  import { getProjectPath } from "#journey/journeyStatus.ts";
34
34
  import { loadMirrorContext } from "#mirror/context.ts";
35
35
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
36
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
36
+ import { resolveNamedRuntimeSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
37
37
  import { activateOperatingMode } from "#mode/operatingMode.ts";
38
38
  import { ProviderConfigError } from "#providers/config.ts";
39
39
  import type { EmbeddingProvider } from "#providers/embedding.ts";
@@ -286,6 +286,13 @@ export async function runBuildLoad(
286
286
  }
287
287
 
288
288
  persistStickyDefaults(db, "engineer", slug, deps.nowIso());
289
+ // A named session's stamp records that it was (CR008 option B, CV9.DS8.US5):
290
+ // with no name, the guess still lands on some window's row so its footer
291
+ // shows the mode, and a lifecycle command must not bind from that.
292
+ const namedSessionId = resolveNamedRuntimeSessionId(
293
+ options.sessionId ?? null,
294
+ options.environmentSessionId ?? null,
295
+ );
289
296
  const resolvedSessionId = resolveRuntimeSessionId(
290
297
  db,
291
298
  options.sessionId ?? null,
@@ -293,7 +300,12 @@ export async function runBuildLoad(
293
300
  );
294
301
  activateOperatingMode(
295
302
  db,
296
- { mode: "Builder Mode", journey: slug, sessionId: resolvedSessionId },
303
+ {
304
+ mode: "Builder Mode",
305
+ journey: slug,
306
+ sessionId: resolvedSessionId,
307
+ named: namedSessionId !== null && namedSessionId === resolvedSessionId,
308
+ },
297
309
  deps.nowIso(),
298
310
  );
299
311
  await deps.switchConversation?.(slug, resolvedSessionId);
@@ -173,18 +173,24 @@ export function getOrCreateSessionConversation(
173
173
  options.persona ?? null,
174
174
  options.journey ?? null,
175
175
  );
176
- // Mirrors the service's conflict clause exactly: it does NOT touch
177
- // mirror_active, hook_injected, started_at, or metadata on update.
176
+ // Mirrors the service's conflict clause: it does NOT touch mirror_active,
177
+ // started_at, or metadata on update. Since CV9.DS8.US5 it
178
+ // does not erase a persona or journey either when the caller names none:
179
+ // on the first prompt of a session the doorman has already routed the
180
+ // explicit turn and written both to this row, and the prompt logger, which
181
+ // runs after it, wrote NULL over them -- the mirror lost its voice until a
182
+ // later prompt scored a persona again. A caller that names a persona or
183
+ // journey still sets it.
178
184
  db.prepare(
179
185
  `INSERT INTO runtime_sessions
180
186
  (session_id, conversation_id, interface, mirror_active, persona, journey,
181
- hook_injected, active, started_at, updated_at, closed_at, metadata)
182
- VALUES (?, ?, ?, 0, ?, ?, 0, 1, ?, ?, NULL, NULL)
187
+ active, started_at, updated_at, closed_at, metadata)
188
+ VALUES (?, ?, ?, 0, ?, ?, 1, ?, ?, NULL, NULL)
183
189
  ON CONFLICT(session_id) DO UPDATE SET
184
190
  conversation_id = excluded.conversation_id,
185
191
  interface = excluded.interface,
186
- persona = excluded.persona,
187
- journey = excluded.journey,
192
+ persona = COALESCE(excluded.persona, runtime_sessions.persona),
193
+ journey = COALESCE(excluded.journey, runtime_sessions.journey),
188
194
  active = 1,
189
195
  closed_at = NULL,
190
196
  updated_at = excluded.updated_at`,
@@ -132,7 +132,7 @@ function appliedMigrationIds(dbPath: string, options: OpenOptions): Set<string>
132
132
  * No split any more. Until CV22.DS10.TS5 this separated the TS-authored tail
133
133
  * from Python-authored ids and declined the whole thing whenever a Python one
134
134
  * was pending -- correct while Python could still apply them. TypeScript
135
- * carries all seventeen and is the only engine left, so every pending known
135
+ * carries all of them and is the only engine left, so every pending known
136
136
  * migration is simply pending.
137
137
  */
138
138
  function pendingMigrations(applied: Set<string>): string[] {
@@ -514,6 +514,21 @@ function migrateJourneyParentColumn(db: WritableDatabase): void {
514
514
  );
515
515
  }
516
516
 
517
+ /**
518
+ * 018 (CV9.DS8.US5) -- `runtime_sessions.hook_injected` goes. It was the
519
+ * Claude-family inject hook's once-per-session flag (CV21.DS2); US4 handed the
520
+ * hook to the one doorman and retired its last reader, and the four writers
521
+ * that still filled it were carried as one debt item, paid here with the
522
+ * column. Guarded on the column's presence: a fresh database's `createSchema`
523
+ * no longer has it, and a re-run finds nothing to drop. `DROP COLUMN` is
524
+ * SQLite 3.35; the engine ships 3.54.
525
+ */
526
+ function migrateDropHookInjected(db: WritableDatabase): void {
527
+ if (!tableExists(db, "runtime_sessions")) return;
528
+ if (!tableColumns(db, "runtime_sessions").has("hook_injected")) return;
529
+ db.exec("ALTER TABLE runtime_sessions DROP COLUMN hook_injected");
530
+ }
531
+
517
532
  export type MigrationApply = (db: WritableDatabase) => void;
518
533
 
519
534
  export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
@@ -534,6 +549,7 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
534
549
  ["015_create_builder_workbench", migrateCreateBuilderWorkbench],
535
550
  ["016_builder_workbench_display_codes", migrateBuilderWorkbenchDisplayCodes],
536
551
  ["017_journey_parent_column", migrateJourneyParentColumn],
552
+ ["018_drop_hook_injected", migrateDropHookInjected],
537
553
  ];
538
554
 
539
555
  /**
@@ -84,7 +84,6 @@ CREATE TABLE IF NOT EXISTS runtime_sessions (
84
84
  mirror_active INTEGER NOT NULL DEFAULT 0,
85
85
  persona TEXT,
86
86
  journey TEXT,
87
- hook_injected INTEGER NOT NULL DEFAULT 0,
88
87
  active INTEGER NOT NULL DEFAULT 1,
89
88
  started_at TEXT NOT NULL,
90
89
  updated_at TEXT NOT NULL,
@@ -1925,13 +1925,6 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1925
1925
  "pk": 0,
1926
1926
  "type": "TEXT"
1927
1927
  },
1928
- {
1929
- "dflt_value": "0",
1930
- "name": "hook_injected",
1931
- "notnull": 1,
1932
- "pk": 0,
1933
- "type": "INTEGER"
1934
- },
1935
1928
  {
1936
1929
  "dflt_value": "1",
1937
1930
  "name": "active",
@@ -1979,7 +1972,7 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1979
1972
  }
1980
1973
  ],
1981
1974
  "sql":
1982
- "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, hook_injected INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1975
+ "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1983
1976
  },
1984
1977
  "tasks": {
1985
1978
  "columns": [
@@ -47,6 +47,8 @@ export const KNOWN_MIGRATION_IDS: readonly string[] = [
47
47
  "016_builder_workbench_display_codes",
48
48
  // TS-authored, no Python counterpart (CV22.DS6.US2 — first TS ⊋ Python migration).
49
49
  "017_journey_parent_column",
50
+ // CV9.DS8.US5: the inject hook's once-per-session flag, without a reader since US4.
51
+ "018_drop_hook_injected",
50
52
  ];
51
53
 
52
54
  /**
@@ -17,6 +17,15 @@ const TS_ONLY_COLUMNS: Record<string, ColumnInventory[]> = {
17
17
  identity: [{ name: "parent_journey", type: "TEXT", notnull: 0, dflt_value: null, pk: 0 }],
18
18
  };
19
19
 
20
+ /**
21
+ * Columns the Python snapshot has that TS has dropped (CV9.DS8.US5, migration
22
+ * 018): `runtime_sessions.hook_injected`, the inject hook's once-per-session
23
+ * flag, without a reader since CV9.DS8.US4.
24
+ */
25
+ const TS_REMOVED_COLUMNS: Record<string, string[]> = {
26
+ runtime_sessions: ["hook_injected"],
27
+ };
28
+
20
29
  /** Indexes TS creates that the Python snapshot does not have. */
21
30
  const TS_ONLY_INDEXES: Record<string, { table: string; unique: number; columns: string[] }> = {
22
31
  idx_identity_parent_journey: { table: "identity", unique: 0, columns: ["parent_journey"] },
@@ -46,21 +55,33 @@ export function diffTsInventoryAgainstSnapshot(
46
55
  const actualTable = actual.tables[name];
47
56
  if (!actualTable) continue;
48
57
  const additions = TS_ONLY_COLUMNS[name] ?? [];
49
- const expectedColumns = [...snapshot.tables[name].columns, ...additions];
58
+ const removals = TS_REMOVED_COLUMNS[name] ?? [];
59
+ const expectedColumns = [
60
+ ...snapshot.tables[name].columns.filter((column) => !removals.includes(column.name)),
61
+ ...additions,
62
+ ];
50
63
  if (!isDeepStrictEqual(actualTable.columns, expectedColumns)) {
51
- problems.push(`tables[${name}].columns differ from snapshot + enumerated TS-only additions`);
64
+ problems.push(
65
+ `tables[${name}].columns differ from snapshot + enumerated TS-only additions and removals`,
66
+ );
52
67
  }
53
68
  if (!isDeepStrictEqual(actualTable.foreign_keys, snapshot.tables[name].foreign_keys)) {
54
69
  problems.push(`tables[${name}].foreign_keys differ from snapshot`);
55
70
  }
56
- if (additions.length > 0) {
71
+ if (additions.length > 0 || removals.length > 0) {
57
72
  // The raw CREATE TABLE text diverges by exactly these columns; assert each
58
- // added column name is present rather than reproduce SQLite's stored text.
73
+ // added column name is present and each removed one absent, rather than
74
+ // reproduce SQLite's stored text.
59
75
  for (const column of additions) {
60
76
  if (!(actualTable.sql ?? "").includes(column.name)) {
61
77
  problems.push(`tables[${name}].sql is missing TS-only column ${column.name}`);
62
78
  }
63
79
  }
80
+ for (const column of removals) {
81
+ if ((actualTable.sql ?? "").includes(column)) {
82
+ problems.push(`tables[${name}].sql still carries the dropped column ${column}`);
83
+ }
84
+ }
64
85
  } else if (!isDeepStrictEqual(actualTable.sql, snapshot.tables[name].sql)) {
65
86
  problems.push(`tables[${name}].sql differs from snapshot`);
66
87
  }
@@ -52,6 +52,7 @@ import { setIdentity } from "#identity/setIdentity.ts";
52
52
  import { IdentityRootExistsError, initUserHome, TemplatesNotFoundError } from "#init/init.ts";
53
53
  import { JOURNEY_PATH_LAYER } from "#journey/journeyStatus.ts";
54
54
  import { JourneyNotFoundError } from "#journey/journeyWrite.ts";
55
+ import { environmentSessionId } from "#mirror/runtimeSession.ts";
55
56
  import {
56
57
  CallOutcomeTally,
57
58
  formatCallOutcome,
@@ -116,9 +117,11 @@ import {
116
117
  import { applyJourneySetPath } from "./journeyWriteRoute.ts";
117
118
  import { openLiveWriteDatabase } from "./liveBackup.ts";
118
119
  import {
120
+ isMirrorDoorman,
119
121
  isMirrorWrite,
120
122
  isModeWrite,
121
123
  mirrorDeactivateMissingSession,
124
+ runMirrorDoormanRoute,
122
125
  runMirrorRead,
123
126
  runMirrorWriteRoute,
124
127
  runModeRead,
@@ -1477,7 +1480,10 @@ function runShadowWrite(argv: readonly string[]): number | Promise<number> {
1477
1480
  return runShadowScanWrite(argv); // "scan"
1478
1481
  }
1479
1482
 
1480
- function withMirrorReadDb(argv: readonly string[], read: (db: Database) => number): number {
1483
+ function withMirrorReadDb<T = number>(
1484
+ argv: readonly string[],
1485
+ read: (db: Database) => T,
1486
+ ): T | number {
1481
1487
  const dbPath = resolveDbPathForCli(argv.slice(1));
1482
1488
  if (dbPath === null) return 2;
1483
1489
  ensureDatabaseReadyForCli(dbPath, argv[0] ?? null);
@@ -1539,6 +1545,35 @@ function runModeWrite(argv: readonly string[]): Promise<number> {
1539
1545
  return withMirrorWriteDb(argv, (db) => runModeWriteRoute(db, argv));
1540
1546
  }
1541
1547
 
1548
+ /**
1549
+ * `mirror doorman` (CV9.DS8.TS2): decide on a read-only handle, write behind
1550
+ * the pre-write snapshot only for a turn that delivers. The memory scores a
1551
+ * delta turn ranks go to the front-door log as numbers -- content-free -- the
1552
+ * hybrid and the semantic the threshold reads (US5), every ranked memory's,
1553
+ * so the threshold is read against measurement.
1554
+ */
1555
+ function runMirrorDoorman(argv: readonly string[]): Promise<number> {
1556
+ const reporter = outcomeReporter(argv, "reception");
1557
+ const logPath = resolveLogPath(argv);
1558
+ return runMirrorDoormanRoute(argv, {
1559
+ withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
1560
+ withLiveWriteDatabase: (write) => withMirrorWriteDb(argv, write),
1561
+ onReceptionOutcome: reporter.onOutcome,
1562
+ onMemoryScores: (scores) =>
1563
+ logFrontDoor(logPath, {
1564
+ command: "mirror",
1565
+ route: "ts",
1566
+ exitCode: 0,
1567
+ detail:
1568
+ `doorman memory_scores=${scores.map((s) => s.score.toFixed(3)).join(",")}` +
1569
+ ` memory_semantic=${scores.map((s) => s.semantic.toFixed(3)).join(",")}`,
1570
+ }),
1571
+ }).then((exitCode) => {
1572
+ if (reporter.calls > 0) reporter.logSummary();
1573
+ return exitCode;
1574
+ });
1575
+ }
1576
+
1542
1577
  function runSoulWrite(argv: readonly string[]): Promise<number> {
1543
1578
  return withMirrorWriteDb(argv, (db) => runSoulRoute(db, argv));
1544
1579
  }
@@ -1739,7 +1774,11 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1739
1774
  createLoadRuntime: (db, dbPath, ignoreProductionRole) =>
1740
1775
  createBuildLoadRuntime({ db, dbPath, ignoreProductionRole }),
1741
1776
  nowIso,
1742
- environmentSessionId: process.env.MIRROR_SESSION_ID ?? null,
1777
+ // The session the harness named -- MIRROR_SESSION_ID, else Claude Code's or
1778
+ // Codex's own -- the same reader the mirror route uses (CV9.DS8.US4: read
1779
+ // MIRROR_SESSION_ID alone, a Builder switch on Claude Code stamped a guessed
1780
+ // session and the doorman never saw it).
1781
+ environmentSessionId: environmentSessionId(process.env),
1743
1782
  });
1744
1783
  }
1745
1784
  if (isInit(argv)) return runInit(argv);
@@ -1759,6 +1798,7 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1759
1798
  if (argv[0] === "descriptor" && argv[1] === "generate") return runDescriptorContentRoute(argv);
1760
1799
  if (isConsolidateSubcommandWrite(argv)) return runConsolidateWrite(argv);
1761
1800
  if (isShadowSubcommandWrite(argv)) return runShadowWrite(argv);
1801
+ if (isMirrorDoorman(argv)) return runMirrorDoorman(argv);
1762
1802
  if (isMirrorWrite(argv)) return runMirrorWrite(argv);
1763
1803
  if (isModeWrite(argv)) return runModeWrite(argv);
1764
1804
  // CV22.DS7.US6: the Soul ritual; `routing.ts` allowlists its subcommands by
@@ -61,7 +61,7 @@ import { getIdentityContent } from "#identity/identityRead.ts";
61
61
  import { getProjectPath } from "#journey/journeyStatus.ts";
62
62
  import { loadMirrorContext } from "#mirror/context.ts";
63
63
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
64
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
64
+ import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
65
65
  import { activateOperatingMode, deactivateOperatingMode } from "#mode/operatingMode.ts";
66
66
  import { newId, nowIso } from "#util/pyGenerators.ts";
67
67
  import { optionValue, stripOptionWithValue } from "./args.ts";
@@ -430,7 +430,7 @@ async function runPromote(
430
430
  // `--session-id`, which its promote parser does not even accept.
431
431
  const result = await runBuildLoad(
432
432
  db,
433
- { slug, environmentSessionId: process.env.MIRROR_SESSION_ID ?? null },
433
+ { slug, environmentSessionId: environmentSessionId(process.env) },
434
434
  runtime.deps,
435
435
  );
436
436
  process.stderr.write(result.stderr);
@@ -1,12 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import type { Database, WritableDatabase } from "#db/database.ts";
4
+ import {
5
+ type DoormanDecision,
6
+ decideDoorman,
7
+ doormanPersonas,
8
+ forgetDoormanDelivery,
9
+ type MemoryScore,
10
+ readDoormanSessionState,
11
+ runDoorman,
12
+ } from "#mirror/doorman.ts";
4
13
  import {
5
14
  deactivateMirrorState,
6
15
  listActiveMirrorJourneys,
7
16
  logMirrorResponse,
8
17
  runMirrorLoad,
9
18
  } from "#mirror/orchestration.ts";
19
+ import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
10
20
  import {
11
21
  activateOperatingMode,
12
22
  deactivateOperatingMode,
@@ -16,7 +26,7 @@ import {
16
26
  } from "#mode/operatingMode.ts";
17
27
  import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
18
28
  import { resolveFamilyProviders } from "#providers/familyProviders.ts";
19
- import { MIRROR_QUERY_TRANSPORT } from "#providers/transport.ts";
29
+ import { MIRROR_QUERY_TRANSPORT, mirrorQueryTransportFor } from "#providers/transport.ts";
20
30
  import { newId, nowIso } from "#util/pyGenerators.ts";
21
31
  import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
22
32
 
@@ -28,6 +38,8 @@ import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
28
38
  */
29
39
  export type MirrorRouteEnvironment = {
30
40
  MIRROR_SESSION_ID?: string;
41
+ CLAUDE_CODE_SESSION_ID?: string;
42
+ CODEX_SESSION_ID?: string;
31
43
  MEMORY_RECEPTION?: string;
32
44
  MIRROR_TS_MIRROR_LLM_REPLAY?: string;
33
45
  MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
@@ -37,14 +49,119 @@ export function isMirrorWrite(argv: readonly string[]): boolean {
37
49
  return argv[0] === "mirror" && ["load", "deactivate", "log"].includes(argv[1] ?? "");
38
50
  }
39
51
 
52
+ /**
53
+ * `mirror doorman` is a write surface with its own handle discipline
54
+ * (CV9.DS8.TS2): it is not `isMirrorWrite`, because the front door's shared
55
+ * write seam opens the live-write path -- a database snapshot -- before any
56
+ * route runs, and a doorman turn that owes nothing must never pay that.
57
+ */
58
+ export function isMirrorDoorman(argv: readonly string[]): boolean {
59
+ return argv[0] === "mirror" && argv[1] === "doorman";
60
+ }
61
+
62
+ export interface DoormanRouteDeps {
63
+ /** The database, read-only: enough to decide. */
64
+ withReadOnlyDatabase: (read: (db: Database) => DoormanDecision) => DoormanDecision | number;
65
+ /** The database behind the pre-write snapshot: only for a turn that delivers. */
66
+ withLiveWriteDatabase: (
67
+ write: (db: WritableDatabase, dbPath: string) => Promise<number>,
68
+ ) => Promise<number>;
69
+ onReceptionOutcome?: OnProviderCallOutcome;
70
+ /** Content-free: the turn's memory scores, hybrid and semantic, for the front-door log. */
71
+ onMemoryScores?: (scores: readonly MemoryScore[]) => void;
72
+ }
73
+
74
+ /**
75
+ * `mirror doorman --prompt "<text>" [--session-id <id>]`: the one doorman as a
76
+ * command, for the Pi extension (US5). Prints the block or nothing; exit 0.
77
+ * `mirror doorman --forget [--session-id <id>]`: the runtime compacted the
78
+ * transcript; the named session's next ordinary turn is full again (US5's
79
+ * debt). Nothing printed; an unnamed session is nothing to mark.
80
+ *
81
+ * Two steps on purpose. The decision is read from a read-only handle; the
82
+ * live-write handle -- `VACUUM INTO` first, 95 ms measured on a 53 MB database
83
+ * -- opens only when the decision is `full` or `delta`. The ordinary turn
84
+ * outside Mirror Mode costs a read and nothing else, as the inject hook's does.
85
+ */
86
+ export async function runMirrorDoormanRoute(
87
+ argv: readonly string[],
88
+ deps: DoormanRouteDeps,
89
+ env: MirrorRouteEnvironment = process.env,
90
+ ): Promise<number> {
91
+ const args = argv.slice(2);
92
+ const sessionId = resolveNamedRuntimeSessionId(
93
+ optionValue(args, "--session-id") ?? null,
94
+ environmentSessionId(env),
95
+ );
96
+ if (hasOption(args, "--forget")) {
97
+ if (!sessionId) return 0;
98
+ return deps.withLiveWriteDatabase(async (db) => {
99
+ forgetDoormanDelivery(db, sessionId, nowIso());
100
+ return 0;
101
+ });
102
+ }
103
+ const prompt = optionValue(args, "--prompt");
104
+ if (prompt === null || prompt === undefined) {
105
+ console.error("mirror doorman requires --prompt");
106
+ return 2;
107
+ }
108
+ const decided = deps.withReadOnlyDatabase((db) =>
109
+ decideDoorman({
110
+ prompt,
111
+ state: readDoormanSessionState(db, sessionId),
112
+ personas: doormanPersonas(db),
113
+ }),
114
+ );
115
+ if (typeof decided === "number") return decided;
116
+ if (decided.kind === "nothing") return 0;
117
+
118
+ // Providers only now: a turn that delivers may embed (delta) or route
119
+ // (full); the turn above resolved none, let alone a key.
120
+ const receptionEnabled = env.MEMORY_RECEPTION !== "0";
121
+ const family = await resolveFamilyProviders(env, mirrorQueryTransportFor(env));
122
+ return deps.withLiveWriteDatabase(async (db, dbPath) => {
123
+ const mirrorHome = dirname(dbPath);
124
+ const rendered = await runDoorman(db, decided, {
125
+ identity: basename(mirrorHome),
126
+ databasePath: dbPath,
127
+ mirrorHome,
128
+ user: basename(mirrorHome),
129
+ sessionId,
130
+ receptionEnabled,
131
+ onReceptionOutcome: deps.onReceptionOutcome,
132
+ llmProvider: receptionEnabled ? family.llm : undefined,
133
+ embeddingProvider: family.embedding,
134
+ onMemoryScores: deps.onMemoryScores,
135
+ newId,
136
+ nowIso,
137
+ });
138
+ process.stdout.write(rendered.stdout);
139
+ process.stderr.write(rendered.stderr);
140
+ return 0;
141
+ });
142
+ }
143
+
40
144
  export function isModeWrite(argv: readonly string[]): boolean {
41
145
  if (argv[0] !== "mode") return false;
42
146
  const args = modePositionals(argv.slice(1));
43
147
  return args[0] === "activate" || args[0] === "deactivate";
44
148
  }
45
149
 
46
- export function mirrorDeactivateMissingSession(argv: readonly string[]): boolean {
47
- return argv[0] === "mirror" && argv[1] === "deactivate" && !optionValue(argv, "--session-id");
150
+ /**
151
+ * `mirror deactivate` with nobody naming a session: `--session-id` absent AND
152
+ * no harness variable (CV9.DS8.US5 -- until then `--session-id` alone, so
153
+ * `/mm-new` on a Pi whose extension had named the session still refused).
154
+ */
155
+ export function mirrorDeactivateMissingSession(
156
+ argv: readonly string[],
157
+ env: MirrorRouteEnvironment = process.env,
158
+ ): boolean {
159
+ return (
160
+ argv[0] === "mirror" &&
161
+ argv[1] === "deactivate" &&
162
+ !optionValue(argv, "--session-id") &&
163
+ environmentSessionId(env) === null
164
+ );
48
165
  }
49
166
 
50
167
  export function runMirrorRead(db: Database, args: readonly string[]): number {
@@ -70,7 +187,10 @@ export async function runMirrorWriteRoute(
70
187
  const args = argv.slice(2);
71
188
  const sessionId = optionValue(args, "--session-id");
72
189
  if (sub === "deactivate") {
73
- deactivateMirrorState(db, sessionId as string, nowIso());
190
+ // The session the caller named, else the harness's (TS2's reader): the
191
+ // unnamed case was refused before this route ran.
192
+ const named = resolveNamedRuntimeSessionId(sessionId ?? null, environmentSessionId(env));
193
+ deactivateMirrorState(db, named as string, nowIso());
74
194
  console.error("Mirror Mode deactivated.");
75
195
  return 0;
76
196
  }
@@ -112,7 +232,11 @@ export async function runMirrorWriteRoute(
112
232
  org: hasOption(args, "--org"),
113
233
  contextOnly: hasOption(args, "--context-only"),
114
234
  sessionId,
115
- environmentSessionId: env.MIRROR_SESSION_ID ?? null,
235
+ // The harness's variable, whichever runtime set it (CV9.DS8.TS2): the
236
+ // skill's own load on Claude Code or Codex names the session the hooks
237
+ // write. `mirror log` keeps reading `MIRROR_SESSION_ID` alone: its
238
+ // fallback is a guess, and widening that is a CR008 question.
239
+ environmentSessionId: environmentSessionId(env),
116
240
  receptionEnabled,
117
241
  onReceptionOutcome: options.onReceptionOutcome,
118
242
  llmProvider: receptionEnabled ? family?.llm : undefined,