mirror-mind 0.32.1 → 0.33.1

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 (35) hide show
  1. package/.claude/hooks/log-assistant-reply.sh +85 -0
  2. package/.pi/extensions/mirror-logger.ts +107 -22
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.33.0.md +164 -0
  5. package/docs/releases/v0.33.1.md +174 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +6 -2
  8. package/plugins/mirror-mind/hooks/hooks.json +18 -6
  9. package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +4 -2
  11. package/ts/src/conversation/hookPayload.ts +51 -0
  12. package/ts/src/conversation/logger.ts +300 -25
  13. package/ts/src/conversation/loggerCli.ts +41 -2
  14. package/ts/src/frontDoor/cli.ts +8 -1
  15. package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -10
  16. package/ts/src/frontDoor/routing.ts +2 -0
  17. package/ts/src/frontDoor/runtimeRoute.ts +142 -24
  18. package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
  19. package/ts/src/frontDoor/stdinSource.ts +37 -0
  20. package/ts/src/guards/claudePlugin.ts +56 -10
  21. package/ts/src/guards/packContents.ts +1 -1
  22. package/ts/src/hooks/claude.ts +52 -14
  23. package/ts/src/hooks/payload.ts +18 -13
  24. package/ts/src/hooks/runtime.ts +24 -4
  25. package/ts/src/hooks/runtimeDetection.ts +34 -0
  26. package/ts/src/mirror/mirrorForm.ts +43 -0
  27. package/ts/src/runtime/git.ts +8 -2
  28. package/ts/src/runtime/releaseNotes.ts +230 -52
  29. package/ts/src/runtime/strategies/clone.ts +10 -3
  30. package/ts/src/runtime/strategies/package.ts +112 -23
  31. package/ts/src/util/tarEntries.ts +128 -0
  32. package/ts/src/welcome/card.ts +182 -60
  33. package/ts/src/welcome/statusLine.ts +12 -2
  34. package/ts/src/welcome/updateCache.ts +45 -27
  35. package/plugins/mirror-mind/mcp/launch.sh +0 -52
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
7
7
  },
8
8
  "mcpServers": {
9
9
  "mirror-mind": {
10
- "command": "${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh"
10
+ "command": "/bin/sh",
11
+ "args": [
12
+ "-c",
13
+ "mmbin=\"\"; [ -n \"$MIRROR_BIN\" ] && [ -x \"$MIRROR_BIN/mirror\" ] && mmbin=\"$MIRROR_BIN/mirror\"; [ -n \"$mmbin\" ] || mmbin=\"$(command -v mirror 2>/dev/null)\"; for mmdir in \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmbin\" ] && break; [ -x \"$mmdir/mirror\" ] && mmbin=\"$mmdir/mirror\"; done; [ -n \"$mmbin\" ] || { echo \"mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory.\" >&2; exit 1; }; mmnode=\"$MIRROR_NODE\"; [ -n \"$mmnode\" ] && [ -x \"$mmnode\" ] || mmnode=\"$(command -v node 2>/dev/null)\"; for mmdir in \"$(dirname \"$mmbin\")\" \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmnode\" ] && break; [ -x \"$mmdir/node\" ] && mmnode=\"$mmdir/node\"; done; [ -n \"$mmnode\" ] || { echo \"mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE.\" >&2; exit 1; }; exec \"$mmnode\" \"$mmbin\" mcp"
14
+ ]
11
15
  }
12
16
  }
13
17
  }
@@ -6,8 +6,8 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh",
10
- "timeout": 15000
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
10
+ "timeout": 300
11
11
  }
12
12
  ]
13
13
  }
@@ -18,8 +18,8 @@
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh",
22
- "timeout": 30000
21
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh\"",
22
+ "timeout": 30
23
23
  }
24
24
  ]
25
25
  },
@@ -28,7 +28,19 @@
28
28
  "hooks": [
29
29
  {
30
30
  "type": "command",
31
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh",
31
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh\"",
32
+ "async": true
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "Stop": [
38
+ {
39
+ "matcher": "",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-assistant-reply.sh\"",
32
44
  "async": true
33
45
  }
34
46
  ]
@@ -40,7 +52,7 @@
40
52
  "hooks": [
41
53
  {
42
54
  "type": "command",
43
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh",
55
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh\"",
44
56
  "async": true
45
57
  }
46
58
  ]
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
3
+ # Edit the generator, never this file: every wrapper shares these bytes and
4
+ # differs only in the hook name and in how it finds the Mirror entry
5
+ # (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
6
+ set -uo pipefail
7
+ HOOK="claude:stop"
8
+
9
+ # This wrapper is the Claude plugin's, and a plugin is copied into Claude
10
+ # Code's own cache: no relative path from here reaches the Mirror tree. It
11
+ # runs the installed `mirror-hook` bin instead, and reads no tree .env.
12
+ REPO_ROOT=""
13
+
14
+ # A hook never fails the user's turn, and never fails in silence: when it
15
+ # cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
16
+ # The home is found the way the core finds it -- MIRROR_HOME, else
17
+ # MIRROR_USER -- from the environment when set there, else from the tree's
18
+ # .env when the wrapper is in a tree, else from the user's config file
19
+ # (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
20
+ # neither variable, and the first wrappers then wrote to a home nobody reads.
21
+ env_file_value() {
22
+ [ -f "$2" ] || return 0
23
+ sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
24
+ | tail -n 1 | tr -d "\"'\r"
25
+ }
26
+ env_value() {
27
+ local value=""
28
+ if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
29
+ [ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
30
+ [ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
31
+ printf '%s' "$value"
32
+ }
33
+ note() {
34
+ local home user
35
+ home="$(env_value MIRROR_HOME)"
36
+ user="$(env_value MIRROR_USER)"
37
+ if [ -n "$home" ]; then
38
+ home="${home/#\~/$HOME}"
39
+ elif [ -n "$user" ]; then
40
+ home="$HOME/.mirror-minds/$user"
41
+ [ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
42
+ else
43
+ return 0
44
+ fi
45
+ mkdir -p "$home" 2>/dev/null \
46
+ && printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
47
+ }
48
+
49
+ # Find the bin. MIRROR_BIN names the directory holding `mirror` and
50
+ # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
51
+ # then the PATH; then the global bin directories a GUI launch leaves off it.
52
+ ENTRY=""
53
+ [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
54
+ [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
55
+ for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
56
+ [ -n "$ENTRY" ] && break
57
+ [ -x "$candidate/mirror-hook" ] && ENTRY="$candidate/mirror-hook"
58
+ done
59
+
60
+ if [ -z "$ENTRY" ]; then
61
+ note "mirror-hook not found on PATH; hook skipped. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory."
62
+ exit 0
63
+ fi
64
+
65
+ # Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
66
+ # nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
67
+ # hooks could end in `|| true` because they always found their interpreter;
68
+ # the same silence here would hide a new failure — no logging, no inject, no
69
+ # close tail — that a user would meet weeks later as "Mirror stopped
70
+ # remembering". So an unresolvable Node is RECORDED, never swallowed. The
71
+ # directory the entry was found in is searched first: an npm global bin holds
72
+ # the `node` that installed the package beside `mirror-hook`, while a system
73
+ # `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
74
+ NODE="${MIRROR_NODE:-}"
75
+ if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
76
+ NODE="$(command -v node 2>/dev/null || true)"
77
+ fi
78
+ for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
79
+ [ -n "$NODE" ] && break
80
+ [ -x "$candidate" ] && NODE="$candidate"
81
+ done
82
+
83
+ if [ -z "$NODE" ]; then
84
+ note "node not found on PATH; hook skipped. Set MIRROR_NODE."
85
+ exit 0
86
+ fi
87
+
88
+ # Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
89
+ # means Node itself could not run the hook -- too old for the loader shim, or
90
+ # it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
91
+ # review, finding N1). No flags: the entry reads configuration and silences
92
+ # its own warnings (US3, D3).
93
+ "$NODE" "$ENTRY" "$HOOK" "$@"
94
+ status=$?
95
+ if [ "$status" -ne 0 ]; then
96
+ note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
97
+ fi
98
+ exit 0
@@ -29,7 +29,7 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
29
29
 
30
30
  This is always the first action, before any visible output to the user.
31
31
 
32
- > **Automatic hook:** When the user invokes `/mm:mirror` explicitly, `mirror-inject.sh` already performs the session-aware mirror activation and injects the base context using the Claude hook `session_id`. You should still run `mirror mirror load` to satisfy the load-first rule and refresh context, but session routing is hook-owned in Claude Code.
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).
33
33
 
34
34
  ```bash
35
35
  mirror mirror load \
@@ -62,4 +62,6 @@ Use the `load` output as the complete response context.
62
62
 
63
63
  ## 3. Record Response
64
64
 
65
- If the runtime has an explicit session-aware logging path, record a concise 2-3 sentence summary there. In Claude Code, do **not** invent a session id — runtime logging and transcript backfill already capture the response safely.
65
+ Where Mirror's hooks run (Claude Code, Codex, Grok), record **nothing** yourself: do **not** run `mirror mirror log`, and do not invent a session id. The prompt hook logs the question (without the `/mm:mirror` form) and the Stop hook logs your reply beside it, under the runtime's own name; a summary logged by hand would sit beside the reply as a second answer.
66
+
67
+ Elsewhere, if the runtime has an explicit session-aware logging path, record a concise 2-3 sentence summary there.
@@ -0,0 +1,51 @@
1
+ // The fields a Claude-schema hook payload carries, in both spellings
2
+ // (CV21.DS2.US8).
3
+ //
4
+ // Three runtimes hand the shared hooks a payload: Claude Code and Codex in
5
+ // snake_case, Grok in camelCase with a snake_case alias on some fields but
6
+ // not all (DS12.TS4 L8). The turn is correlated under three names: Claude
7
+ // Code's `prompt_id`, Codex's `turn_id`, Grok's `promptId`. One reader, so
8
+ // the logger and the hook entries cannot disagree about where a field lives.
9
+ //
10
+ // Every reader returns "" for a missing or non-string field, and the parser
11
+ // returns null rather than throwing: a hook that threw on a payload it did
12
+ // not recognize would break the user's turn (the `extract_prompt` posture).
13
+
14
+ export type HookPayloadObject = Record<string, unknown>;
15
+
16
+ /** Parse a hook payload. Null for anything but a JSON object. */
17
+ export function parseHookPayloadObject(raw: string): HookPayloadObject | null {
18
+ try {
19
+ const value: unknown = JSON.parse(raw);
20
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
21
+ return value as HookPayloadObject;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** The first of the named fields that holds a string, else "". */
28
+ export function payloadText(payload: HookPayloadObject, ...names: string[]): string {
29
+ for (const name of names) {
30
+ const value = payload[name];
31
+ if (typeof value === "string") return value;
32
+ }
33
+ return "";
34
+ }
35
+
36
+ export const sessionIdOf = (payload: HookPayloadObject): string =>
37
+ payloadText(payload, "session_id", "sessionId");
38
+
39
+ export const promptOf = (payload: HookPayloadObject): string => payloadText(payload, "prompt");
40
+
41
+ /** The runtime's correlation id for the turn, whatever it calls it. */
42
+ export const turnIdOf = (payload: HookPayloadObject): string =>
43
+ payloadText(payload, "prompt_id", "turn_id", "promptId");
44
+
45
+ /** The turn's final answer, as the Stop hook receives it. */
46
+ export const lastAssistantMessageOf = (payload: HookPayloadObject): string =>
47
+ payloadText(payload, "last_assistant_message", "lastAssistantMessage");
48
+
49
+ /** Set only inside a subagent (Grok names it; Claude Code and Codex fire SubagentStop instead). */
50
+ export const subagentTypeOf = (payload: HookPayloadObject): string =>
51
+ payloadText(payload, "subagentType", "subagent_type");
@@ -12,8 +12,17 @@
12
12
 
13
13
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ import {
16
+ lastAssistantMessageOf,
17
+ parseHookPayloadObject,
18
+ promptOf,
19
+ sessionIdOf,
20
+ subagentTypeOf,
21
+ turnIdOf,
22
+ } from "#conversation/hookPayload.ts";
15
23
  import { backfillAssistantMessages } from "#conversation/transcriptBackfill.ts";
16
24
  import { type WritableDatabase, withTransaction } from "#db/database.ts";
25
+ import { mirrorForm } from "#mirror/mirrorForm.ts";
17
26
  import {
18
27
  decodeMetadata,
19
28
  getRuntimeSession,
@@ -187,17 +196,39 @@ function discardMarkerActive(session: RuntimeSessionRow | null): boolean {
187
196
  return Boolean(decodeMetadata(session?.metadata ?? null).discard_current_conversation);
188
197
  }
189
198
 
199
+ /**
200
+ * What a message row may carry beside its text. The runtime's turn id (US8)
201
+ * is the one field today: written by the prompt and Stop hooks, read by the
202
+ * reply rule, and NULL for every message logged another way, as before.
203
+ */
204
+ export interface MessageMetadata {
205
+ readonly turn_id?: string;
206
+ }
207
+
208
+ export interface LogMessageOptions {
209
+ interface: string;
210
+ metadata?: MessageMetadata | null;
211
+ }
212
+
190
213
  function addMessage(
191
214
  db: WritableDatabase,
192
215
  conversationId: string,
193
216
  role: "user" | "assistant",
194
217
  content: string,
195
218
  deps: LoggerDeps,
219
+ metadata: MessageMetadata | null = null,
196
220
  ): void {
197
221
  db.prepare(
198
222
  `INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
199
- VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
200
- ).run(deps.newId(), conversationId, role, content, deps.nowIso());
223
+ VALUES (?, ?, ?, ?, ?, NULL, ?)`,
224
+ ).run(
225
+ deps.newId(),
226
+ conversationId,
227
+ role,
228
+ content,
229
+ deps.nowIso(),
230
+ metadata ? pythonJsonDumps(metadata) : null,
231
+ );
201
232
  }
202
233
 
203
234
  /** Python `log_user_message`: discard-marker reset, bind, title-on-first, append. */
@@ -205,7 +236,7 @@ export function logUserMessage(
205
236
  db: WritableDatabase,
206
237
  sessionId: string,
207
238
  content: string,
208
- options: { interface: string },
239
+ options: LogMessageOptions,
209
240
  deps: LoggerDeps,
210
241
  ): void {
211
242
  let existing = getRuntimeSession(db, sessionId);
@@ -234,7 +265,7 @@ export function logUserMessage(
234
265
  if (isNew) {
235
266
  setProvisionalTitle(db, conversationId, generateTitle(content));
236
267
  }
237
- addMessage(db, conversationId, "user", content, deps);
268
+ addMessage(db, conversationId, "user", content, deps, options.metadata ?? null);
238
269
  }
239
270
 
240
271
  /** Python `log_assistant_message`: silent no-op while the discard marker holds. */
@@ -242,7 +273,7 @@ export function logAssistantMessage(
242
273
  db: WritableDatabase,
243
274
  sessionId: string,
244
275
  content: string,
245
- options: { interface: string },
276
+ options: LogMessageOptions,
246
277
  deps: LoggerDeps,
247
278
  ): void {
248
279
  const existing = getRuntimeSession(db, sessionId);
@@ -253,7 +284,7 @@ export function logAssistantMessage(
253
284
  { interface: options.interface },
254
285
  deps,
255
286
  );
256
- addMessage(db, conversationId, "assistant", content, deps);
287
+ addMessage(db, conversationId, "assistant", content, deps, options.metadata ?? null);
257
288
  }
258
289
 
259
290
  /** Python `ConversationService.start_conversation`: always a fresh row. */
@@ -444,53 +475,139 @@ export type UserPromptHookOutcome =
444
475
  | { action: "muted" }
445
476
  | {
446
477
  action: "skipped";
447
- reason: "malformed_payload" | "missing_session" | "missing_prompt" | "slash_command";
478
+ reason:
479
+ | "malformed_payload"
480
+ | "missing_session"
481
+ | "missing_prompt"
482
+ | "slash_command"
483
+ | "bare_mirror_form";
448
484
  }
449
485
  | { action: "logged"; sessionId: string }
450
486
  | { action: "failed"; error: string };
451
487
 
488
+ export type StopHookOutcome =
489
+ | { action: "muted" }
490
+ | {
491
+ action: "skipped";
492
+ reason:
493
+ | "malformed_payload"
494
+ | "missing_session"
495
+ | "subagent"
496
+ | "empty_message"
497
+ | "discarded"
498
+ | "already_answered"
499
+ // Expected: the turn's prompt was a command, which history skips.
500
+ | "command_turn"
501
+ // Expected: the Stop names no turn (Grok's at session end).
502
+ | "session_event"
503
+ // Unexplained: the turn's prompt was neither logged nor skipped as a
504
+ // command -- the reply is lost. The only skip the caller reports.
505
+ | "nothing_waiting";
506
+ }
507
+ | { action: "logged"; sessionId: string; pairedBy: "turn_id" | "last_message" }
508
+ | { action: "failed"; error: string };
509
+
510
+ export interface HookLoggingOptions {
511
+ mirrorHome: string;
512
+ /** The runtime the hook detected (`claude_code`, `codex`, `grok_build`). */
513
+ interface?: string;
514
+ }
515
+
452
516
  export type SessionEndHookOutcome =
453
517
  | { action: "skipped"; reason: "malformed_payload" | "nothing_to_do" }
454
518
  | { action: "ended"; sessionId: string; transcriptPath: string | null; backfilled: boolean }
455
519
  | { action: "backfilled"; transcriptPath: string }
456
520
  | { action: "failed"; error: string };
457
521
 
458
- function parsePayload(raw: string): Record<string, unknown> | null {
459
- try {
460
- const value: unknown = JSON.parse(raw);
461
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
462
- return value as Record<string, unknown>;
463
- } catch {
464
- return null;
465
- }
466
- }
522
+ const parsePayload = parseHookPayloadObject;
467
523
 
468
524
  function payloadString(payload: Record<string, unknown>, key: string): string {
469
525
  const value = payload[key];
470
526
  return typeof value === "string" ? value : "";
471
527
  }
472
528
 
473
- /** Python `hook_user_prompt`: mute gate, slash-command skip, never throw. */
529
+ function turnMetadata(turnId: string): MessageMetadata | null {
530
+ return turnId ? { turn_id: turnId } : null;
531
+ }
532
+
533
+ /**
534
+ * The session-metadata key that names the last turn the prompt hook skipped as a
535
+ * command, so the Stop hook can tell that turn's reply -- expected to go
536
+ * unrecorded -- from a reply lost because its prompt never reached Mirror.
537
+ */
538
+ const SKIPPED_TURN_KEY = "skipped_turn_id";
539
+
540
+ /**
541
+ * Record the turn a command prompt occupied. `runtime_sessions.metadata` is a
542
+ * shared JSON object (the discard marker, the operating mode, Soul, Explorer,
543
+ * and Builder keep keys there), so the key is merged in, never written over.
544
+ */
545
+ function recordSkippedTurn(
546
+ db: WritableDatabase,
547
+ sessionId: string,
548
+ turnId: string,
549
+ deps: LoggerDeps,
550
+ ): void {
551
+ if (!turnId) return;
552
+ const metadata = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null);
553
+ metadata[SKIPPED_TURN_KEY] = turnId;
554
+ upsertRuntimeSession(db, sessionId, { metadata: pythonJsonDumps(metadata) }, deps.nowIso());
555
+ }
556
+
557
+ /**
558
+ * Python `hook_user_prompt`: mute gate, slash-command skip, never throw.
559
+ *
560
+ * Two additions since CV21.DS2.US8. The session's label is stamped on every
561
+ * prompt, skipped or not, before anything else can create the conversation:
562
+ * the Mirror Mode path creates one with `session?.interface ?? "claude_code"`,
563
+ * and `guessRuntimeSessionId` ignores a session row with no interface, so a
564
+ * Grok session whose first turn was a Mirror turn used to be labelled
565
+ * `claude_code` and bound to someone else's session (DS12.TS4 L7, TS2 L9).
566
+ * And a Mirror turn is a conversation turn: its question is logged without
567
+ * the form, where every other slash command stays a command.
568
+ */
474
569
  export function handleUserPromptHook(
475
570
  db: WritableDatabase,
476
571
  rawPayload: string,
477
- options: { mirrorHome: string; interface?: string },
572
+ options: HookLoggingOptions,
478
573
  deps: LoggerDeps,
479
574
  ): UserPromptHookOutcome {
480
575
  try {
481
- if (isMuted(options.mirrorHome)) return { action: "muted" };
482
-
483
576
  const payload = parsePayload(rawPayload);
484
577
  if (!payload) return { action: "skipped", reason: "malformed_payload" };
485
578
 
486
- const sessionId = payloadString(payload, "session_id");
487
- const prompt = payloadString(payload, "prompt");
579
+ const sessionId = sessionIdOf(payload);
580
+ const prompt = promptOf(payload);
488
581
  if (!sessionId) return { action: "skipped", reason: "missing_session" };
582
+
583
+ const interfaceName = options.interface ?? "claude_code";
584
+ upsertRuntimeSession(db, sessionId, { interface: interfaceName }, deps.nowIso());
585
+
586
+ if (isMuted(options.mirrorHome)) return { action: "muted" };
489
587
  if (!prompt) return { action: "skipped", reason: "missing_prompt" };
490
- // Slash commands never enter conversation history.
491
- if (prompt.startsWith("/")) return { action: "skipped", reason: "slash_command" };
492
588
 
493
- logUserMessage(db, sessionId, prompt, { interface: options.interface ?? "claude_code" }, deps);
589
+ const mirror = mirrorForm(prompt);
590
+ let content = prompt;
591
+ if (mirror) {
592
+ if (!mirror.query) {
593
+ recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
594
+ return { action: "skipped", reason: "bare_mirror_form" };
595
+ }
596
+ content = mirror.query;
597
+ } else if (prompt.startsWith("/")) {
598
+ // Slash commands never enter conversation history, and neither does
599
+ // their reply: the turn is recorded so the Stop hook knows why.
600
+ recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
601
+ return { action: "skipped", reason: "slash_command" };
602
+ }
603
+
604
+ logUserMessage(
605
+ db,
606
+ sessionId,
607
+ content,
608
+ { interface: interfaceName, metadata: turnMetadata(turnIdOf(payload)) },
609
+ deps,
610
+ );
494
611
  return { action: "logged", sessionId };
495
612
  } catch (error) {
496
613
  // Python swallows everything here; the runtime must survive a logging fault.
@@ -498,6 +615,164 @@ export function handleUserPromptHook(
498
615
  }
499
616
  }
500
617
 
618
+ interface MessageRow {
619
+ rowid: number;
620
+ role: string;
621
+ created_at: string;
622
+ metadata: string | null;
623
+ }
624
+
625
+ /** The conversation's last message, in the order the rows were written. */
626
+ function lastMessage(db: WritableDatabase, conversationId: string): MessageRow | null {
627
+ const row = db
628
+ .prepare(
629
+ `SELECT rowid, role, created_at, metadata FROM messages
630
+ WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
631
+ )
632
+ .get(conversationId);
633
+ return (row as MessageRow | undefined) ?? null;
634
+ }
635
+
636
+ /** The latest logged prompt of the turn, if the turn's prompt was logged. */
637
+ function promptOfTurn(
638
+ db: WritableDatabase,
639
+ conversationId: string,
640
+ turnId: string,
641
+ ): MessageRow | null {
642
+ const row = db
643
+ .prepare(
644
+ `SELECT rowid, role, created_at, metadata FROM messages
645
+ WHERE conversation_id = ? AND role = 'user' AND json_extract(metadata, '$.turn_id') = ?
646
+ ORDER BY created_at DESC, rowid DESC LIMIT 1`,
647
+ )
648
+ .get(conversationId, turnId);
649
+ return (row as MessageRow | undefined) ?? null;
650
+ }
651
+
652
+ /**
653
+ * Whether the turn already has its reply: an assistant row carrying the same
654
+ * turn id, after the prompt. Only a Stop reply carries one, so an assistant row
655
+ * written another way -- a summary `mirror mirror log` appended by hand, a
656
+ * transcript backfill -- never stands in for the turn's answer.
657
+ */
658
+ function answeredAfter(
659
+ db: WritableDatabase,
660
+ conversationId: string,
661
+ prompt: MessageRow,
662
+ turnId: string,
663
+ ): boolean {
664
+ const row = db
665
+ .prepare(
666
+ `SELECT 1 FROM messages
667
+ WHERE conversation_id = ? AND role = 'assistant'
668
+ AND json_extract(metadata, '$.turn_id') = ?
669
+ AND (created_at > ? OR (created_at = ? AND rowid > ?)) LIMIT 1`,
670
+ )
671
+ .get(conversationId, turnId, prompt.created_at, prompt.created_at, prompt.rowid);
672
+ return row !== undefined;
673
+ }
674
+
675
+ function turnIdOfRow(row: MessageRow): string {
676
+ const value = decodeMetadata(row.metadata).turn_id;
677
+ return typeof value === "string" ? value : "";
678
+ }
679
+
680
+ /**
681
+ * The Stop hook (CV21.DS2.US8): log the turn's final answer beside its prompt.
682
+ *
683
+ * A reply is logged when it answers a logged prompt, and never otherwise:
684
+ *
685
+ * * by turn id, when the payload carries one and a logged prompt carries the
686
+ * same -- unless a reply with that turn id already follows it, which is a
687
+ * repeat Stop (a Stop re-fired after another hook blocked). An assistant
688
+ * row without the turn id (a hand-logged summary) does not count;
689
+ * * else by position, when the conversation's last message is a logged
690
+ * prompt -- unless both sides carry a turn id and they differ, which means
691
+ * this turn's prompt was a command and an older prompt is waiting because
692
+ * its own Stop was lost; attaching the new answer to the old question
693
+ * would record a conversation that never happened.
694
+ *
695
+ * The handler never creates a conversation. When nothing is waiting, it says
696
+ * why: `command_turn` when the prompt hook skipped this turn as a command,
697
+ * `session_event` when the Stop names no turn (Grok's at session end), and
698
+ * `nothing_waiting` only when nothing explains it -- the turn's prompt never
699
+ * reached Mirror, the async race the plan names, a reply lost. The caller
700
+ * writes a `hooks.log` line for that one alone: `runtime diagnose` reads every
701
+ * line there as a failure, and an expected skip is not one (Debt Review,
702
+ * 2026-10-13).
703
+ */
704
+ export function handleStopHook(
705
+ db: WritableDatabase,
706
+ rawPayload: string,
707
+ options: HookLoggingOptions,
708
+ deps: LoggerDeps,
709
+ ): StopHookOutcome {
710
+ try {
711
+ const payload = parsePayload(rawPayload);
712
+ if (!payload) return { action: "skipped", reason: "malformed_payload" };
713
+
714
+ const sessionId = sessionIdOf(payload);
715
+ if (!sessionId) return { action: "skipped", reason: "missing_session" };
716
+ if (isMuted(options.mirrorHome)) return { action: "muted" };
717
+ if (subagentTypeOf(payload)) return { action: "skipped", reason: "subagent" };
718
+
719
+ const message = lastAssistantMessageOf(payload);
720
+ if (!message.trim()) return { action: "skipped", reason: "empty_message" };
721
+
722
+ const session = getRuntimeSession(db, sessionId);
723
+ if (discardMarkerActive(session)) return { action: "skipped", reason: "discarded" };
724
+ const turnId = turnIdOf(payload);
725
+ const nothingWaiting = (): StopHookOutcome => {
726
+ if (!turnId) return { action: "skipped", reason: "session_event" };
727
+ if (decodeMetadata(session?.metadata ?? null)[SKIPPED_TURN_KEY] === turnId) {
728
+ return { action: "skipped", reason: "command_turn" };
729
+ }
730
+ return { action: "skipped", reason: "nothing_waiting" };
731
+ };
732
+ const conversationId = session?.conversationId ?? null;
733
+ if (!conversationId) return nothingWaiting();
734
+
735
+ const interfaceName = options.interface ?? session?.interface ?? "claude_code";
736
+ const last = lastMessage(db, conversationId);
737
+
738
+ if (turnId) {
739
+ const prompt = promptOfTurn(db, conversationId, turnId);
740
+ if (prompt) {
741
+ if (answeredAfter(db, conversationId, prompt, turnId)) {
742
+ return { action: "skipped", reason: "already_answered" };
743
+ }
744
+ logAssistantMessage(
745
+ db,
746
+ sessionId,
747
+ message,
748
+ { interface: interfaceName, metadata: turnMetadata(turnId) },
749
+ deps,
750
+ );
751
+ return { action: "logged", sessionId, pairedBy: "turn_id" };
752
+ }
753
+ if (last && last.role === "user" && !turnIdOfRow(last)) {
754
+ logAssistantMessage(
755
+ db,
756
+ sessionId,
757
+ message,
758
+ { interface: interfaceName, metadata: turnMetadata(turnId) },
759
+ deps,
760
+ );
761
+ return { action: "logged", sessionId, pairedBy: "last_message" };
762
+ }
763
+ return nothingWaiting();
764
+ }
765
+
766
+ if (last && last.role === "user") {
767
+ logAssistantMessage(db, sessionId, message, { interface: interfaceName }, deps);
768
+ return { action: "logged", sessionId, pairedBy: "last_message" };
769
+ }
770
+ return nothingWaiting();
771
+ } catch (error) {
772
+ return { action: "failed", error: error instanceof Error ? error.message : String(error) };
773
+ }
774
+ }
775
+
501
776
  /**
502
777
  * Python `hook_session_end`: end the session when the payload names one, then
503
778
  * backfill assistant turns from the transcript when it exists -- in that