mirror-mind 0.32.1 → 0.33.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.
@@ -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
@@ -21,6 +21,7 @@ import {
21
21
  discardCurrentConversation,
22
22
  endSession,
23
23
  handleSessionEndHook,
24
+ handleStopHook,
24
25
  handleUserPromptHook,
25
26
  isMuted,
26
27
  logAssistantMessage,
@@ -54,6 +55,7 @@ export const CONVERSATION_LOGGER_SUBCOMMANDS = [
54
55
  "log-user",
55
56
  "log-assistant",
56
57
  "user-prompt",
58
+ "stop",
57
59
  "discard-current",
58
60
  "switch",
59
61
  "session-end-pi",
@@ -75,7 +77,11 @@ export const LLM_TAIL_SUBCOMMANDS: ReadonlySet<string> = new Set([
75
77
  ]);
76
78
 
77
79
  /** Subcommands that read their JSON payload from stdin. */
78
- export const STDIN_SUBCOMMANDS: ReadonlySet<string> = new Set(["user-prompt", "session-end"]);
80
+ export const STDIN_SUBCOMMANDS: ReadonlySet<string> = new Set([
81
+ "user-prompt",
82
+ "stop",
83
+ "session-end",
84
+ ]);
79
85
 
80
86
  function extractOption(
81
87
  args: string[],
@@ -146,10 +152,43 @@ export async function runConversationLoggerCommand(
146
152
  return ok([isMuted(home) ? "MUTED" : "ACTIVE"]);
147
153
 
148
154
  case "user-prompt":
149
- handleUserPromptHook(db, context.stdin ?? "", { mirrorHome: home }, deps);
155
+ // `--interface` is the runtime the hook detected (US8); without it the
156
+ // label is Python's hook default.
157
+ handleUserPromptHook(
158
+ db,
159
+ context.stdin ?? "",
160
+ { mirrorHome: home, interface: interfaceOption(args, "claude_code") },
161
+ deps,
162
+ );
150
163
  // The hook is silent and always exits 0, whatever the outcome.
151
164
  return ok([]);
152
165
 
166
+ case "stop": {
167
+ // The Stop hook's payload (US8): the turn's final answer, logged beside
168
+ // its prompt when one is waiting. Exit 0 whatever the outcome, and the
169
+ // outcome on stdout in one line: the hook entry runs this route
170
+ // in-process, reads the line, and forwards nothing -- a Stop hook that
171
+ // printed would keep the agent working on some runtimes -- while a
172
+ // person running the command from a shell sees what happened.
173
+ const outcome = handleStopHook(
174
+ db,
175
+ context.stdin ?? "",
176
+ { mirrorHome: home, interface: interfaceOption(args, "claude_code") },
177
+ deps,
178
+ );
179
+ switch (outcome.action) {
180
+ case "logged":
181
+ return ok([`reply logged (${outcome.pairedBy})`]);
182
+ case "skipped":
183
+ return ok([`reply skipped: ${outcome.reason}`]);
184
+ case "muted":
185
+ return ok(["reply not logged: muted"]);
186
+ default:
187
+ // The error text is the fault's, never the payload's.
188
+ return ok([`reply failed: ${outcome.error}`]);
189
+ }
190
+ }
191
+
153
192
  case "session-end":
154
193
  await handleSessionEndHook(
155
194
  db,
@@ -164,6 +164,7 @@ import { retiredRefusal, routeMemoryCommand } from "./routing.ts";
164
164
  import { runRuntimeReadRoute, runWelcomeRoute } from "./runtimeRoute.ts";
165
165
  import { runMemorySearchRoute } from "./searchRoute.ts";
166
166
  import { resolveSeedPaths } from "./seedPaths.ts";
167
+ import { isSessionStartWelcome, runSessionStartWelcomeRoute } from "./sessionStartWelcomeRoute.ts";
167
168
  import { runSoulRoute } from "./soulRoute.ts";
168
169
  import {
169
170
  applyTasksImport,
@@ -1692,7 +1693,13 @@ async function dispatch(argv: readonly string[]): Promise<DispatchOutcome> {
1692
1693
 
1693
1694
  /** Every route the front door answers once routing has chosen it. */
1694
1695
  async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOutcome> {
1695
- if (isConversationLoggerCommand(argv)) return runConversationLoggerWrite(argv);
1696
+ if (isConversationLoggerCommand(argv)) {
1697
+ // CV9.E2.S32: the Pi extension's one-process session open. The write is
1698
+ // the same `withMirrorWriteDb`; the welcome renders follow it in-process.
1699
+ if (isSessionStartWelcome(argv))
1700
+ return runSessionStartWelcomeRoute(argv, { withMirrorWriteDb });
1701
+ return runConversationLoggerWrite(argv);
1702
+ }
1696
1703
  if (argv[0] === "build") {
1697
1704
  // The Builder command tree is deliberately lazy: the other commands never
1698
1705
  // pay for loading it, and a broken Builder core cannot take them down.
@@ -6,13 +6,13 @@
6
6
  // contract. (Until CV22.DS10.TS5 it could also return `null` to hand an LLM-tail
7
7
  // subcommand back to Python; that path left with the fallback.)
8
8
 
9
- import { readFileSync } from "node:fs";
10
9
  import { homedir } from "node:os";
11
10
  import { dirname } from "node:path";
12
11
  import { createZipBackup } from "#backup/zipBackup.ts";
13
12
  import { runConversationLoggerCommand, STDIN_SUBCOMMANDS } from "#conversation/loggerCli.ts";
14
13
  import { createLoggerRuntime } from "#conversation/loggerRuntime.ts";
15
14
  import type { WritableDatabase } from "#db/database.ts";
15
+ import { takeStdin } from "#frontDoor/stdinSource.ts";
16
16
  import { newId, nowIso } from "#util/pyGenerators.ts";
17
17
 
18
18
  export function isConversationLoggerCommand(argv: readonly string[]): boolean {
@@ -29,19 +29,27 @@ function subcommandOf(args: readonly string[]): string {
29
29
  return remaining[0] ?? "";
30
30
  }
31
31
 
32
- /** The hook subcommands read their JSON payload from stdin. */
33
- function readStdin(): string {
34
- try {
35
- return readFileSync(0, "utf-8");
36
- } catch {
37
- return "";
38
- }
32
+ /**
33
+ * The hook subcommands read their JSON payload from stdin -- or take the one
34
+ * a hook entry already read, to detect its runtime (US8, `stdinSource.ts`).
35
+ */
36
+ const readStdin = takeStdin;
37
+
38
+ /**
39
+ * Where the route's lines go. The default is the process's own streams; the
40
+ * `session-start --welcome` composite (CV9.E2.S32) captures them instead, so
41
+ * the logger's stdout can travel inside one JSON envelope.
42
+ */
43
+ export interface ConversationLoggerRouteIo {
44
+ stdout?: (line: string) => void;
45
+ stderr?: (line: string) => void;
39
46
  }
40
47
 
41
48
  export async function runConversationLoggerRoute(
42
49
  db: WritableDatabase,
43
50
  dbPath: string,
44
51
  argv: readonly string[],
52
+ io: ConversationLoggerRouteIo = {},
45
53
  ): Promise<number> {
46
54
  const args = argv.slice(1);
47
55
  const subcommand = subcommandOf(args);
@@ -67,7 +75,9 @@ export async function runConversationLoggerRoute(
67
75
  const result = await runConversationLoggerCommand(db, args, runtime, {
68
76
  stdin: STDIN_SUBCOMMANDS.has(subcommand) ? readStdin() : undefined,
69
77
  });
70
- for (const line of result.stdout) process.stdout.write(`${line}\n`);
71
- for (const line of result.stderr) process.stderr.write(`${line}\n`);
78
+ const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
79
+ const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
80
+ for (const line of result.stdout) out(line);
81
+ for (const line of result.stderr) err(line);
72
82
  return result.exitCode;
73
83
  }
@@ -370,6 +370,8 @@ const TS_CONVERSATION_LOGGER_SUBCOMMANDS = new Set([
370
370
  "log-user",
371
371
  "log-assistant",
372
372
  "user-prompt",
373
+ // CV21.DS2.US8: the Stop hook's reply, deterministic like user-prompt.
374
+ "stop",
373
375
  "discard-current",
374
376
  // US10 slice F, group 2 (need slice E): diagnose, dry-run repair, Codex import.
375
377
  "diagnose-journeys",