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
@@ -8,7 +8,7 @@ import {
8
8
  CULTIVATION_SCAN_TRANSPORT,
9
9
  DESCRIPTOR_TRANSPORT,
10
10
  JOURNAL_TRANSPORT,
11
- MIRROR_QUERY_TRANSPORT,
11
+ mirrorQueryTransportFor,
12
12
  type ProviderTransportSpec,
13
13
  resolveComposedProviderTransport,
14
14
  resolveProviderTransport,
@@ -745,22 +745,23 @@ export function routeByFamily(
745
745
 
746
746
  if (command === "mirror") {
747
747
  const sub = argv[1];
748
- if (sub === "load") {
749
- const hasQuery = argv.includes("--query");
748
+ // CV9.DS8.TS2: the doorman decides before it resolves a provider, and a
749
+ // `nothing` turn resolves none -- but a `full` turn is `load --query`, so
750
+ // the route answers for the doorman exactly as it answers for that.
751
+ if (sub === "load" || sub === "doorman") {
752
+ const hasQuery = sub === "doorman" || argv.includes("--query");
750
753
  if (!hasQuery) {
751
754
  return { command, engine: "ts", reason: "DS7.US4 deterministic mirror load ported to TS" };
752
755
  }
753
- // `MEMORY_RECEPTION=0` skips the classifier on both engines, so the
754
- // chat fixture stops being required for a replay run; the embedding half
755
- // still is, because the query still searches attachments and journeys.
756
- const spec =
757
- env.MEMORY_RECEPTION === "0"
758
- ? {
759
- ...MIRROR_QUERY_TRANSPORT,
760
- replay: { embedding: MIRROR_QUERY_TRANSPORT.replay?.embedding },
761
- }
762
- : MIRROR_QUERY_TRANSPORT;
763
- return providerRoute(command, env, spec, "load --query");
756
+ // `MEMORY_RECEPTION=0` skips the classifier, so the chat fixture stops
757
+ // being required for a replay run; the embedding half still is, because
758
+ // the query still searches attachments and journeys.
759
+ return providerRoute(
760
+ command,
761
+ env,
762
+ mirrorQueryTransportFor(env),
763
+ sub === "doorman" ? "doorman" : "load --query",
764
+ );
764
765
  }
765
766
  if (sub === "deactivate" || sub === "log" || sub === "journeys") {
766
767
  return { command, engine: "ts", reason: `DS7.US4 mirror ${sub} ported to TS` };
@@ -952,7 +953,7 @@ const TASKS_SUBCOMMANDS = [
952
953
  const WEEK_SUBCOMMANDS = ["view", "plan", "save"];
953
954
  const CONSOLIDATE_SUBCOMMANDS = ["scan", "apply", "reject", "list"];
954
955
  const SHADOW_SUBCOMMANDS = ["scan", "apply", "reject", "list", "show"];
955
- const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys"];
956
+ const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys", "doorman"];
956
957
  const MODE_SUBCOMMANDS = ["activate", "deactivate", "status"];
957
958
 
958
959
  /** `mode`'s first positional, past the two options its parser declares; "" when none. */
@@ -15,7 +15,7 @@
15
15
  import type { Database, WritableDatabase } from "#db/database.ts";
16
16
  import { getIdentityContent } from "#identity/identityRead.ts";
17
17
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
18
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
18
+ import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
19
19
  import { activateOperatingMode } from "#mode/operatingMode.ts";
20
20
  import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
21
21
  import { generateEmbeddingSafely } from "#providers/embedding.ts";
@@ -310,7 +310,7 @@ function runLoad(
310
310
  const resolvedSessionId = resolveRuntimeSessionId(
311
311
  db,
312
312
  sessionId,
313
- process.env.MIRROR_SESSION_ID ?? null,
313
+ environmentSessionId(process.env),
314
314
  );
315
315
  activateOperatingMode(
316
316
  db,
@@ -355,7 +355,7 @@ function runFruit(
355
355
  sessionId: string | null,
356
356
  deps: SoulRouteDeps,
357
357
  ): number {
358
- const resolved = resolveCliSoulSessionId(db, sessionId, process.env.MIRROR_SESSION_ID ?? null);
358
+ const resolved = resolveCliSoulSessionId(db, sessionId, environmentSessionId(process.env));
359
359
  if (action === "set") {
360
360
  if (fruit === null) return usageError("soul fruit set requires a fruit");
361
361
  const state = setFruitInMaturation(db, fruit, resolved, deps.nowIso());
@@ -384,7 +384,7 @@ async function runHarvest(
384
384
  const resolved = resolveCliSoulSessionId(
385
385
  db,
386
386
  option("--session-id"),
387
- process.env.MIRROR_SESSION_ID ?? null,
387
+ environmentSessionId(process.env),
388
388
  );
389
389
  if (action === "set") {
390
390
  const state = harvestFruit(db, { fruit, sessionId: resolved }, deps.nowIso());
@@ -0,0 +1,92 @@
1
+ // CR163 — no test makes a temp home under a literal /tmp.
2
+ //
3
+ // The suite's temp homes are made with `tempDir` (ts/test/helpers/tempDir.ts),
4
+ // under os.tmpdir(), and removed when the test file's process exits. The
5
+ // run-level check (scripts/test_leaves_nothing.sh) proves a run leaves nothing
6
+ // by pointing TMPDIR at an empty directory; it sees every home only because
7
+ // none is made anywhere else. A home under a literal /tmp would escape it, so
8
+ // this guard keeps one from coming back: every `.ts` under ts/test, helpers and
9
+ // support included, in each form the tree has used.
10
+ //
11
+ // A line that is a comment is not a finding, nor is a /tmp that names a path
12
+ // no test creates (an asserted string, a golden's literal). An exception names
13
+ // its reason, and one that no longer matches anything is reported as stale, so
14
+ // it cannot outlive that reason.
15
+
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { join, relative, sep } from "node:path";
18
+
19
+ /** Files allowed a literal /tmp temp home, each with the reason it must stay. */
20
+ export const EXCEPTIONS: Readonly<Record<string, string>> = {
21
+ "ts/test/helpers/builderWorld.ts":
22
+ "CR164: pathsWalk sees a leaked absolute path only under a short root",
23
+ "ts/test/scripts/testTempHomes.test.ts": "this guard's own cases, held as strings",
24
+ };
25
+
26
+ /** Each form the tree has used to make a temp home under a literal /tmp. */
27
+ const FORMS: readonly RegExp[] = [
28
+ // mkdtempSync("/tmp/…"), a template, the async mkdtemp; the argument may open a new line.
29
+ /\bmkdtemp(?:Sync)?\(\s*["'`]\/tmp\//g,
30
+ // mkdtempSync(join("/tmp", …))
31
+ /\bmkdtemp(?:Sync)?\(\s*join\(\s*["'`]\/tmp["'`]/g,
32
+ // realpathSync("/tmp"), as in mkdtempSync(join(realpathSync("/tmp"), …))
33
+ /\brealpathSync\(\s*["'`]\/tmp["'`]\s*\)/g,
34
+ ];
35
+
36
+ export interface TempHomeFinding {
37
+ /** Repository-relative, with forward slashes. */
38
+ readonly file: string;
39
+ readonly line: number;
40
+ readonly text: string;
41
+ }
42
+
43
+ export interface TempHomeSweep {
44
+ readonly findings: readonly TempHomeFinding[];
45
+ /** Exceptions whose file no longer makes such a home. */
46
+ readonly staleExceptions: readonly string[];
47
+ }
48
+
49
+ function isComment(line: string): boolean {
50
+ const text = line.trimStart();
51
+ return text.startsWith("//") || text.startsWith("*") || text.startsWith("/*");
52
+ }
53
+
54
+ function testFiles(repoRoot: string): string[] {
55
+ const testRoot = join(repoRoot, "ts", "test");
56
+ return readdirSync(testRoot, { recursive: true, withFileTypes: true })
57
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
58
+ .map((entry) => relative(repoRoot, join(entry.parentPath, entry.name)).split(sep).join("/"))
59
+ .sort();
60
+ }
61
+
62
+ function scan(source: string): { line: number; text: string }[] {
63
+ const lines = source.split("\n");
64
+ const hits = new Map<number, string>();
65
+ for (const form of FORMS) {
66
+ for (const match of source.matchAll(form)) {
67
+ const line = source.slice(0, match.index).split("\n").length;
68
+ if (!isComment(lines[line - 1])) hits.set(line, lines[line - 1].trim());
69
+ }
70
+ }
71
+ return [...hits].sort(([a], [b]) => a - b).map(([line, text]) => ({ line, text }));
72
+ }
73
+
74
+ /** Every temp home under a literal /tmp in `ts/test` below `repoRoot`, but the exceptions. */
75
+ export function findLiteralTmpHomes(
76
+ repoRoot: string,
77
+ exceptions: Readonly<Record<string, string>> = EXCEPTIONS,
78
+ ): TempHomeSweep {
79
+ const findings: TempHomeFinding[] = [];
80
+ const excepted = new Set<string>();
81
+ for (const file of testFiles(repoRoot)) {
82
+ const hits = scan(readFileSync(join(repoRoot, file), "utf8"));
83
+ if (hits.length === 0) continue;
84
+ if (file in exceptions) {
85
+ excepted.add(file);
86
+ continue;
87
+ }
88
+ for (const hit of hits) findings.push({ file, ...hit });
89
+ }
90
+ const staleExceptions = Object.keys(exceptions).filter((file) => !excepted.has(file));
91
+ return { findings, staleExceptions };
92
+ }
@@ -1,45 +1,49 @@
1
- // Antigravity hook entries (CV21.DS7.TS2).
1
+ // Antigravity hook entries (CV21.DS7.TS2; through the one doorman since
2
+ // CV9.DS8.TS6).
2
3
  //
3
4
  // agy runs the plugin's `hooks.json` handlers with camelCase protojson on
4
5
  // stdin and reads JSON from stdout (`injectSteps`; a `systemMessage` step
5
6
  // nests its text, an `ephemeralMessage` is a plain string). The contracts
6
- // are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2).
7
+ // are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2) and in
8
+ // TS6's `evidence/probe-20261025.md` (agy 1.4.7).
7
9
  //
8
- // Two handlers:
10
+ // Three handlers:
9
11
  //
10
- // * `agy:session-start` — turn conversation logging on, the posture every
11
- // runtime's session-start shares, and answer `{}`. The Operating
12
- // Instructions are NOT delivered here: `rules/AGENTS.md` carries them
13
- // (US1), unless TS2's live route shows otherwise — the measured
14
- // fallback would add an `injectSteps` `systemMessage` in this handler.
12
+ // * `agy:session-start` — turn conversation logging on, turn Mirror Mode
13
+ // on for the conversation, and answer `{}`. The Operating Instructions
14
+ // are NOT delivered here: `rules/AGENTS.md` carries them (US1).
15
15
  //
16
- // * `agy:pre-invocation` — inject Mirror Mode context once per user turn.
17
- // `PreInvocation` fires before EVERY model call (32 times over two
18
- // turns, DS12.TS3 L2), with `invocationNum` resetting to 0 at each user
19
- // turn; protojson omits zero-valued fields, so an ABSENT invocationNum
20
- // is 0. The generated wrapper already gates on this before Node is
21
- // spawned; the gate here is defense in depth. The injection is an
22
- // `ephemeralMessage` — dropped after the turn — for two reasons: the
23
- // adapter re-injects per turn, so a persistent step would accumulate;
24
- // and the content is identity and memory, so ephemeral is the
25
- // data-minimizing choice against agy's stored trajectory.
16
+ // * `agy:pre-invocation` — put Mirror's context before the model once per
17
+ // user turn. `PreInvocation` fires before EVERY model call, with
18
+ // `invocationNum` resetting to 0 at each user turn; protojson omits
19
+ // zero-valued fields, so an ABSENT invocationNum is 0. The generated
20
+ // wrapper already gates on this before Node is spawned; the gate here is
21
+ // defense in depth. At invocation 0 the hook hands the turn to the one
22
+ // doorman and answers its block as ONE persistent `systemMessage` step.
26
23
  //
27
- // agy payloads carry no prompt or reply text (L7): there is no `--query`,
28
- // and the explicit Mirror form reaches the model through the skill (L4),
29
- // never through these hooks. The quiet session — a `conversationId` no
30
- // runtime session binds (every session until TS3 lands), or one bound with
31
- // Mirror Mode off — answers `{}`: Mirror on agy is invisible until invoked.
24
+ // * `agy:stop` — log the turns from the transcript (agy has no SessionEnd).
32
25
  //
33
- // Payload fields stay inert: `conversationId` flows only through array
34
- // argv, never a shell, and failures recorded in hooks.log are facts, never
35
- // payload text.
26
+ // Where the prompt comes from. On agy 1.4.2 no payload carried the user's
27
+ // text (DS12.TS3 L7), so this hook could only re-inject the whole folder on
28
+ // every turn, routed from nothing. agy 1.4.7 carries it in every payload, as
29
+ // `lastUserInput`, verbatim -- a skill turn in its slash form (TS6's probe) --
30
+ // so the hook reads the prompt from the payload and never from a transcript.
31
+ // A payload without it (agy before 1.4.7) keeps the pre-TS6 injection,
32
+ // unchanged: Navigator decision (b), 2026-10-25, removable when the runtime
33
+ // matrix's floor for agy reaches 1.4.7.
34
+ //
35
+ // Payload fields stay inert: `conversationId` and the prompt flow only
36
+ // through array argv, never a shell, and failures recorded in hooks.log are
37
+ // facts, never payload text.
36
38
 
37
39
  import { readAgyTurns } from "#conversation/agyTranscript.ts";
38
40
  import { isCommandTurn } from "#conversation/commandTurn.ts";
39
41
  import { logAssistantMessage, logUserMessage } from "#conversation/logger.ts";
40
42
  import { resolveDbPath } from "#frontDoor/dbPath.ts";
41
43
  import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
44
+ import { forgetDoormanDelivery } from "#mirror/doorman.ts";
42
45
  import { decodeMetadata, getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
46
+ import { activateOperatingMode } from "#mode/operatingMode.ts";
43
47
  import { newId, nowIso } from "#util/pyGenerators.ts";
44
48
  import { readMirrorState } from "./mirrorState.ts";
45
49
  import { noteHookFailure, runFrontDoor, runFrontDoorQuietly } from "./runtime.ts";
@@ -51,8 +55,21 @@ interface AgyPayload {
51
55
  readonly conversationId: string;
52
56
  readonly invocationNum: number;
53
57
  readonly transcriptPath: string;
58
+ /**
59
+ * The turn's prompt, verbatim (agy 1.4.7, every payload), or null: absent
60
+ * before 1.4.7, and protojson omits an empty string, so absent and empty
61
+ * are one case.
62
+ */
63
+ readonly lastUserInput: string | null;
54
64
  }
55
65
 
66
+ const EMPTY_PAYLOAD: AgyPayload = {
67
+ conversationId: "",
68
+ invocationNum: 0,
69
+ transcriptPath: "",
70
+ lastUserInput: null,
71
+ };
72
+
56
73
  /** The key under which the Stop handler stores its high-water step_index. */
57
74
  const HIGH_WATER_KEY = "agy_last_step_index";
58
75
 
@@ -63,35 +80,45 @@ const HIGH_WATER_KEY = "agy_last_step_index";
63
80
  export function parseAgyPayload(stdin: string): AgyPayload {
64
81
  try {
65
82
  const raw: unknown = JSON.parse(stdin);
66
- if (typeof raw !== "object" || raw === null) {
67
- return { conversationId: "", invocationNum: 0, transcriptPath: "" };
68
- }
83
+ if (typeof raw !== "object" || raw === null) return EMPTY_PAYLOAD;
69
84
  const record = raw as Record<string, unknown>;
70
85
  return {
71
86
  conversationId: typeof record.conversationId === "string" ? record.conversationId : "",
72
87
  // protojson omits zero-valued fields: absent IS 0.
73
88
  invocationNum: typeof record.invocationNum === "number" ? record.invocationNum : 0,
74
89
  transcriptPath: typeof record.transcriptPath === "string" ? record.transcriptPath : "",
90
+ lastUserInput:
91
+ typeof record.lastUserInput === "string" && record.lastUserInput.trim()
92
+ ? record.lastUserInput
93
+ : null,
75
94
  };
76
95
  } catch {
77
- return { conversationId: "", invocationNum: 0, transcriptPath: "" };
96
+ return EMPTY_PAYLOAD;
78
97
  }
79
98
  }
80
99
 
81
100
  /**
82
- * `SessionStart`: logging on, activate Mirror Mode for the session, answer
83
- * the contract.
101
+ * `SessionStart`: logging on, turn Mirror Mode on for the conversation,
102
+ * answer the contract.
103
+ *
104
+ * Mirror Mode activation (CV21.DS7.US4 scope change). The `/mirror-mind:mm:*`
105
+ * skill runs `mirror load` as an agent command that cannot name the agy
106
+ * session, and on agy 1.4.2 no hook carried the prompt to detect the Mirror
107
+ * form by. So SessionStart — the one hook that carries the `conversationId`
108
+ * at the start of a conversation — turns Mirror Mode on, always: Gemini CLI's
109
+ * always-on per-turn injection, the runtime Antigravity replaces.
84
110
  *
85
- * Mirror Mode activation (CV21.DS7.US4 scope change). agy's `PreInvocation`
86
- * carries no prompt text (DS12.TS3 L7), so the injection hook cannot detect
87
- * the Mirror form to self-activate, and the `/mirror-mind:mm:*` skill runs
88
- * `mirror load` as an agent command that cannot bind the agy session id. So
89
- * SessionStart — the one hook that carries the `conversationId` at the start
90
- * of the session — sets `mirror_active`, and the per-turn `PreInvocation`
91
- * injection (built in TS2, gated on that flag) fires from then on. This
92
- * matches Gemini CLI's always-on per-turn injection, the runtime Antigravity
93
- * replaces; a session that never ran SessionStart stays quiet, which the
94
- * negative probe pins.
111
+ * Three writes since CV9.DS8.TS6, because the doorman reads more than the
112
+ * flag. `mirror_active`, as before. The session's OWN operating-mode stamp,
113
+ * Mirror Mode: the doorman treats a session as in Mirror Mode only while its
114
+ * mode says so -- its own stamp, else the global one -- so without it a new
115
+ * conversation would answer to whatever mode was last stamped globally, and a
116
+ * home last switched to Builder would leave agy silent. And the doorman's
117
+ * refresh (`forgetDoormanDelivery`): nothing is in this conversation's
118
+ * transcript yet, so its first ordinary turn is the full folder, routed from
119
+ * that turn's prompt, and the deltas follow. agy fires SessionStart for a new
120
+ * conversation, not a resumed one (TS6's probe): a resumed conversation keeps
121
+ * the folder its persistent steps already hold.
95
122
  */
96
123
  export async function agySessionStart(stdin: string): Promise<number> {
97
124
  await runFrontDoorQuietly("agy:session-start", ["conversation-logger", "session-start"]);
@@ -102,12 +129,15 @@ export async function agySessionStart(stdin: string): Promise<number> {
102
129
  const dbPath = resolveDbPath([], process.env);
103
130
  const db = openLiveWriteDatabase(dbPath);
104
131
  try {
132
+ const now = nowIso();
105
133
  upsertRuntimeSession(
106
134
  db,
107
135
  conversationId,
108
136
  { interface: "antigravity", mirrorActive: true },
109
- nowIso(),
137
+ now,
110
138
  );
139
+ activateOperatingMode(db, { mode: "Mirror Mode", sessionId: conversationId }, now);
140
+ forgetDoormanDelivery(db, conversationId, now);
111
141
  } finally {
112
142
  db.close();
113
143
  }
@@ -190,40 +220,87 @@ export async function agyStop(stdin: string): Promise<number> {
190
220
  return 0;
191
221
  }
192
222
 
193
- /** `PreInvocation`: one ephemeral context step per user turn, when owed. */
223
+ /** One step of agy's `injectSteps`: the persistent kind, or the ephemeral one. */
224
+ type AgyStep = { systemMessage: { systemMessage: string } } | { ephemeralMessage: string };
225
+
226
+ const PRE_INVOCATION = "agy:pre-invocation";
227
+
228
+ /**
229
+ * `PreInvocation`: at a user turn's first model call, Mirror's context as ONE
230
+ * step, when one is owed.
231
+ */
194
232
  export async function agyPreInvocation(stdin: string): Promise<number> {
195
233
  const payload = parseAgyPayload(stdin);
196
234
 
197
235
  // Defense in depth behind the wrapper's shell gate: only the turn's first
198
- // invocation injects; the other ~30 answer instantly.
236
+ // invocation injects; the others answer instantly and touch nothing.
199
237
  if (payload.invocationNum !== 0 || !payload.conversationId) {
200
238
  process.stdout.write(EMPTY);
201
239
  return 0;
202
240
  }
203
241
 
242
+ const step =
243
+ payload.lastUserInput === null
244
+ ? await contextWithoutPrompt(payload.conversationId)
245
+ : await doormanStep(payload.conversationId, payload.lastUserInput);
246
+ process.stdout.write(step ? `${JSON.stringify({ injectSteps: [step] })}\n` : EMPTY);
247
+ return 0;
248
+ }
249
+
250
+ /**
251
+ * The turn through the one doorman (CV9.DS8.TS6), handed over as
252
+ * `claudeInject` hands Claude's: in-process, through the front door, the hook
253
+ * deciding nothing. `mirror doorman` answers the full folder on the explicit
254
+ * form -- even in a conversation no SessionStart activated -- a delta on an
255
+ * ordinary turn in Mirror Mode, and nothing on a skill turn or outside it; the
256
+ * session writes, the one embedding per delta turn, and the handle discipline
257
+ * are its own. The hook reads no transcript: the prompt goes to the doorman
258
+ * and nowhere else.
259
+ *
260
+ * The block rides in a `systemMessage` step, which agy keeps before the model
261
+ * on later turns; the pre-TS6 `ephemeralMessage` would drop the folder after
262
+ * this one, and every delta stands on the folder still being there. Ephemeral
263
+ * never kept identity off disk either: agy writes the step into the stored
264
+ * transcript all the same (TS6's probe, TS2's observation).
265
+ */
266
+ async function doormanStep(conversationId: string, prompt: string): Promise<AgyStep | null> {
267
+ // `--session-id` before `--prompt`: the route reads a flag's FIRST
268
+ // occurrence, so a prompt that is exactly `--session-id` stays the prompt.
269
+ const argv = ["mirror", "doorman", "--session-id", conversationId, "--prompt", prompt];
270
+ try {
271
+ const result = await runFrontDoor(argv);
272
+ if (result.code !== 0)
273
+ noteHookFailure(PRE_INVOCATION, `\`mirror doorman\` exited ${result.code}`);
274
+ return result.stdout.trim() ? { systemMessage: { systemMessage: result.stdout } } : null;
275
+ } catch (error) {
276
+ noteHookFailure(PRE_INVOCATION, error instanceof Error ? error.message : String(error));
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * agy before 1.4.7, whose payload carries no prompt: the injection this hook
283
+ * made before TS6, unchanged (Navigator decision (b)). The quiet session --
284
+ * unbound, or bound with Mirror Mode off -- gets nothing; an active one gets
285
+ * the whole folder with its session's persona and journey, re-injected each
286
+ * turn as an `ephemeralMessage`. Removable when the runtime matrix's floor
287
+ * for agy reaches 1.4.7.
288
+ */
289
+ async function contextWithoutPrompt(conversationId: string): Promise<AgyStep | null> {
204
290
  let dbPath: string;
205
291
  try {
206
292
  dbPath = resolveDbPath([], process.env);
207
293
  } catch {
208
- process.stdout.write(EMPTY);
209
- return 0;
294
+ return null;
210
295
  }
211
296
 
212
- // The quiet session: unbound, or bound with Mirror Mode off.
213
- const state = readMirrorState(dbPath, payload.conversationId);
214
- if (!state.active) {
215
- process.stdout.write(EMPTY);
216
- return 0;
217
- }
297
+ const state = readMirrorState(dbPath, conversationId);
298
+ if (!state.active) return null;
218
299
 
219
- const argv = ["mirror", "load", "--context-only", "--session-id", payload.conversationId];
300
+ const argv = ["mirror", "load", "--context-only", "--session-id", conversationId];
220
301
  if (state.persona) argv.push("--persona", state.persona);
221
302
  if (state.journey) argv.push("--journey", state.journey);
222
303
 
223
304
  const result = await runFrontDoor(argv);
224
- const context = result.stdout.trim() ? result.stdout : "";
225
- process.stdout.write(
226
- context ? `${JSON.stringify({ injectSteps: [{ ephemeralMessage: context }] })}\n` : EMPTY,
227
- );
228
- return 0;
305
+ return result.stdout.trim() ? { ephemeralMessage: result.stdout } : null;
229
306
  }
@@ -12,10 +12,6 @@
12
12
  // loud, and every failure is still non-fatal to the user's turn.
13
13
 
14
14
  import { runtimeReadsPromptContext } from "#conversation/runtimePolicy.ts";
15
- import { resolveDbPath } from "#frontDoor/dbPath.ts";
16
- import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
17
- import { mirrorForm } from "#mirror/mirrorForm.ts";
18
- import { markInjected, needsInject, readMirrorState } from "./mirrorState.ts";
19
15
  import { parseHookPayload, readStdin } from "./payload.ts";
20
16
  import {
21
17
  noteHookFailure,
@@ -108,105 +104,48 @@ export async function claudeSessionEnd(stdin: string): Promise<number> {
108
104
  }
109
105
 
110
106
  /**
111
- * `mirror-inject.sh`: inject identity context before the model answers.
107
+ * `mirror-inject.sh`: put Mirror's context before the model, by the one
108
+ * doorman (CV9.DS8.US4).
112
109
  *
113
- * Two cases, exactly as the shell had them:
110
+ * Until US4 this hook carried a policy of its own -- the explicit Mirror form
111
+ * loaded the folder; a session in Mirror Mode was loaded once more and marked
112
+ * `hook_injected` -- and neither block carried the line the `mm-mirror` skill
113
+ * reads (TS3), so every explicit turn loaded twice, and once the one injection
114
+ * was spent a follow-up in another domain re-routed nothing (CR165).
114
115
  *
115
- * 1. an explicit Mirror form -- `/mm:mirror [query]`, or the same skill in
116
- * any runtime's spelling (`#mirror/mirrorForm.ts`; CV21.DS2.US7) -- load context
117
- * for that query;
118
- * 2. Mirror Mode already active for this session with context not yet
119
- * injected -- load it with the session's persona and journey, then mark
120
- * it injected so it happens once.
116
+ * Now the hook decides nothing. It hands the prompt and the session to
117
+ * `mirror doorman` (TS2), in-process through the front door, and writes back
118
+ * what it delivers: the full folder on the explicit form, opened by the
119
+ * delivered line; a delta -- the newly routed persona, the memories and
120
+ * attachments for this prompt -- on an ordinary turn while the session is in
121
+ * Mirror Mode; nothing otherwise. The session writes, the one embedding per
122
+ * delta turn, the memory-score log, and the handle discipline (a quiet turn
123
+ * opens no writable handle) are the doorman's. The Pi extension reaches the
124
+ * same route (US5).
121
125
  *
122
- * `--context-only` matters in both: it loads context WITHOUT starting a
123
- * conversation session, because the model does that. A hook that silently
124
- * opened conversations would fork every session in two.
126
+ * Two things from before are kept on purpose. CR143: on a runtime that
127
+ * discards what this hook returns (Grok), return before any load. CR100: a
128
+ * payload with no session id is reported on stderr rather than passed over in
129
+ * silence -- it is the difference between "Mirror Mode is off" and "this hook
130
+ * is misconfigured" -- and the doorman still runs, so an explicit turn delivers
131
+ * its folder unnamed, as `mirror doorman` does without `--session-id`.
125
132
  */
126
133
  export async function claudeInject(stdin: string): Promise<number> {
127
134
  const payload = parseHookPayload(stdin);
128
- // CR143: on a runtime that discards what this hook returns (Grok), neither
129
- // case is worth its load — nor the state read below, nor the "injected"
130
- // mark, which would say the model received what it did not. Detected the
131
- // way the sibling hooks detect it, from the payload and the environment.
132
135
  if (!runtimeReadsPromptContext(detectHookRuntime(payload.raw))) return 0;
133
- const sessionId = payload.sessionId;
134
-
135
- // Case 1: the explicit invocation.
136
- const explicit = mirrorForm(payload.prompt);
137
- if (explicit) {
138
- const argv = ["mirror", "load", "--context-only", "--query", explicit.query];
139
- if (sessionId) argv.push("--session-id", sessionId);
140
- const result = await runFrontDoor(argv);
141
- if (result.stdout.trim()) process.stdout.write(result.stdout);
142
- return 0;
143
- }
144
136
 
145
- // Case 2: active Mirror Mode owed an injection.
137
+ const sessionId = payload.sessionId;
146
138
  if (!sessionId) {
147
- // Python's `mirror_state` warned on stderr rather than no-op'ing silently,
148
- // so a hook wired without a session id is visible. Kept: it is the
149
- // difference between "Mirror Mode is off" and "this hook is misconfigured".
150
139
  process.stderr.write("warning: mirror inject hook received no session_id; no state read.\n");
151
- return 0;
152
- }
153
-
154
- let dbPath: string;
155
- try {
156
- dbPath = resolveDbPath([], process.env);
157
- } catch (error) {
158
- noteHookFailure("claude:inject", error instanceof Error ? error.message : String(error));
159
- return 0;
160
140
  }
161
141
 
162
- const state = readMirrorState(dbPath, sessionId);
163
- if (!needsInject(state)) return 0;
164
-
165
- const argv = [
166
- "mirror",
167
- "load",
168
- "--context-only",
169
- "--query",
170
- payload.prompt,
171
- "--session-id",
172
- sessionId,
173
- ];
174
- if (state.persona) argv.push("--persona", state.persona);
175
- if (state.journey) argv.push("--journey", state.journey);
176
-
177
- const result = await runFrontDoor(argv);
178
- if (!result.stdout.trim()) return 0;
179
-
180
- process.stdout.write(result.stdout);
181
- // Only after the context actually reached the model: the shell marked it
182
- // inside `if [ -n "$CONTEXT" ]` for the same reason. Marking first would
183
- // lose the injection whenever the load produced nothing.
142
+ const argv = ["mirror", "doorman", "--prompt", payload.prompt];
143
+ if (sessionId) argv.push("--session-id", sessionId);
184
144
  try {
185
- // The sanctioned live-write path: a verified pre-write snapshot, same as
186
- // every other routed write.
187
- //
188
- // Python's `mark_injected` wrote UNGATED, so this adds a `VACUUM INTO`
189
- // that the old hook did not pay. Measured on a real 53 MB database:
190
- // **95 ms**, once per Mirror Mode session -- only the single prompt that
191
- // owes an injection reaches this line, and that turn is already loading
192
- // identity and memories. The ordinary turn returns above without opening
193
- // a writable handle at all (114 ms end to end, nothing written).
194
- //
195
- // Paying the front door's own safety contract for a real write to a real
196
- // user database beat inventing a second narrowed write handle inside a
197
- // deletion story. Recorded with the number so the trade is visible.
198
- //
199
- // This hook runs AT THE SAME TIME as `log-user-prompt`, which writes too.
200
- // Until finding F21 the two snapshots collided on one fixed file and one
201
- // write was lost -- in the Navigator walk, two injections in three
202
- // prompts. `openLiveWriteDatabase` gives each writer its own staging
203
- // snapshot, so concurrent hooks no longer race.
204
- const db = openLiveWriteDatabase(dbPath);
205
- try {
206
- markInjected(db, sessionId, new Date().toISOString());
207
- } finally {
208
- db.close();
209
- }
145
+ const result = await runFrontDoor(argv);
146
+ if (result.code !== 0)
147
+ noteHookFailure("claude:inject", `\`mirror doorman\` exited ${result.code}`);
148
+ if (result.stdout) process.stdout.write(result.stdout);
210
149
  } catch (error) {
211
150
  noteHookFailure("claude:inject", error instanceof Error ? error.message : String(error));
212
151
  }