mirror-mind 0.33.4 → 0.35.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 (56) hide show
  1. package/.agents/plugins/marketplace.json +16 -0
  2. package/.pi/skills/mm-build/SKILL.md +1 -1
  3. package/.pi/skills/mm-explore/SKILL.md +1 -1
  4. package/.pi/skills/mm-help/SKILL.md +2 -2
  5. package/.pi/skills/mm-soul/SKILL.md +1 -1
  6. package/AGENTS.md +9 -8
  7. package/README.md +5 -6
  8. package/docs/releases/index.md +2 -0
  9. package/docs/releases/v0.34.0.md +142 -0
  10. package/docs/releases/v0.35.0.md +199 -0
  11. package/package.json +3 -6
  12. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  13. package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
  14. package/plugins/mirror-mind/agy/session-start.sh +92 -0
  15. package/plugins/mirror-mind/agy/stop.sh +92 -0
  16. package/plugins/mirror-mind/hooks.json +26 -0
  17. package/plugins/mirror-mind/mcp_config.json +11 -0
  18. package/plugins/mirror-mind/plugin.json +4 -0
  19. package/plugins/mirror-mind/rules/AGENTS.md +189 -0
  20. package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
  21. package/ts/src/conversation/agyTranscript.ts +105 -0
  22. package/ts/src/conversation/backfill.ts +13 -129
  23. package/ts/src/conversation/commandTurn.ts +42 -0
  24. package/ts/src/conversation/deferredCloseTail.ts +154 -0
  25. package/ts/src/conversation/logger.ts +104 -20
  26. package/ts/src/conversation/loggerCli.ts +6 -18
  27. package/ts/src/conversation/loggerRuntime.ts +6 -1
  28. package/ts/src/conversation/runtimePolicy.ts +59 -0
  29. package/ts/src/conversation/sessionComposites.ts +54 -8
  30. package/ts/src/docs/docsLint.ts +14 -1
  31. package/ts/src/frontDoor/routing.ts +3 -2
  32. package/ts/src/frontDoor/runtimeRoute.ts +13 -1
  33. package/ts/src/frontDoor/stdinSource.ts +2 -2
  34. package/ts/src/guards/claudePlugin.ts +113 -2
  35. package/ts/src/guards/packContents.ts +7 -6
  36. package/ts/src/guards/piSourcedSkills.ts +1 -1
  37. package/ts/src/guards/retiredSurfaces.ts +67 -0
  38. package/ts/src/hooks/agy.ts +229 -0
  39. package/ts/src/hooks/claude.ts +16 -4
  40. package/ts/src/hooks/main.ts +17 -31
  41. package/ts/src/hooks/payload.ts +8 -15
  42. package/ts/src/hooks/runtime.ts +11 -10
  43. package/ts/src/providers/config.ts +11 -0
  44. package/ts/src/runtime/diagnose.ts +115 -1
  45. package/ts/src/runtime/pluginWiring.ts +135 -0
  46. package/ts/src/runtime/update.ts +39 -6
  47. package/ts/src/runtime/wiring.ts +42 -46
  48. package/.gemini/hooks/log-assistant.sh +0 -85
  49. package/.gemini/hooks/log-user.sh +0 -85
  50. package/.gemini/hooks/session-end.sh +0 -85
  51. package/.gemini/hooks/session-start.sh +0 -85
  52. package/scripts/codex-hooks/session-end.sh +0 -85
  53. package/scripts/codex-hooks/session-start.sh +0 -85
  54. package/scripts/codex-mirror.sh +0 -48
  55. package/ts/src/hooks/codex.ts +0 -45
  56. package/ts/src/hooks/gemini.ts +0 -126
@@ -0,0 +1,229 @@
1
+ // Antigravity hook entries (CV21.DS7.TS2).
2
+ //
3
+ // agy runs the plugin's `hooks.json` handlers with camelCase protojson on
4
+ // stdin and reads JSON from stdout (`injectSteps`; a `systemMessage` step
5
+ // 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
+ //
8
+ // Two handlers:
9
+ //
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.
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.
26
+ //
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.
32
+ //
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.
36
+
37
+ import { readAgyTurns } from "#conversation/agyTranscript.ts";
38
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
39
+ import { logAssistantMessage, logUserMessage } from "#conversation/logger.ts";
40
+ import { resolveDbPath } from "#frontDoor/dbPath.ts";
41
+ import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
42
+ import { decodeMetadata, getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
43
+ import { newId, nowIso } from "#util/pyGenerators.ts";
44
+ import { readMirrorState } from "./mirrorState.ts";
45
+ import { noteHookFailure, runFrontDoor, runFrontDoorQuietly } from "./runtime.ts";
46
+
47
+ /** agy's empty answer, which every path that injects nothing must print. */
48
+ const EMPTY = "{}\n";
49
+
50
+ interface AgyPayload {
51
+ readonly conversationId: string;
52
+ readonly invocationNum: number;
53
+ readonly transcriptPath: string;
54
+ }
55
+
56
+ /** The key under which the Stop handler stores its high-water step_index. */
57
+ const HIGH_WATER_KEY = "agy_last_step_index";
58
+
59
+ /**
60
+ * Parse agy's camelCase protojson payload. Anything malformed reads as the
61
+ * empty payload: a hook never fails the turn over stdin it did not write.
62
+ */
63
+ export function parseAgyPayload(stdin: string): AgyPayload {
64
+ try {
65
+ const raw: unknown = JSON.parse(stdin);
66
+ if (typeof raw !== "object" || raw === null) {
67
+ return { conversationId: "", invocationNum: 0, transcriptPath: "" };
68
+ }
69
+ const record = raw as Record<string, unknown>;
70
+ return {
71
+ conversationId: typeof record.conversationId === "string" ? record.conversationId : "",
72
+ // protojson omits zero-valued fields: absent IS 0.
73
+ invocationNum: typeof record.invocationNum === "number" ? record.invocationNum : 0,
74
+ transcriptPath: typeof record.transcriptPath === "string" ? record.transcriptPath : "",
75
+ };
76
+ } catch {
77
+ return { conversationId: "", invocationNum: 0, transcriptPath: "" };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * `SessionStart`: logging on, activate Mirror Mode for the session, answer
83
+ * the contract.
84
+ *
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.
95
+ */
96
+ export async function agySessionStart(stdin: string): Promise<number> {
97
+ await runFrontDoorQuietly("agy:session-start", ["conversation-logger", "session-start"]);
98
+
99
+ const conversationId = parseAgyPayload(stdin).conversationId;
100
+ if (conversationId) {
101
+ try {
102
+ const dbPath = resolveDbPath([], process.env);
103
+ const db = openLiveWriteDatabase(dbPath);
104
+ try {
105
+ upsertRuntimeSession(
106
+ db,
107
+ conversationId,
108
+ { interface: "antigravity", mirrorActive: true },
109
+ nowIso(),
110
+ );
111
+ } finally {
112
+ db.close();
113
+ }
114
+ } catch (error) {
115
+ noteHookFailure("agy:session-start", error instanceof Error ? error.message : String(error));
116
+ }
117
+ }
118
+
119
+ process.stdout.write(EMPTY);
120
+ return 0;
121
+ }
122
+
123
+ /**
124
+ * `Stop`: fires at the end of each user turn (agy has no SessionEnd hook).
125
+ * Read the transcript, append the turns past the stored high-water
126
+ * `step_index` as `antigravity`, and leave the conversation open — session
127
+ * maintenance closes it as a stale orphan, the path Codex uses (Shape A).
128
+ *
129
+ * A re-fired Stop, or a grown transcript whose turns are all logged, adds
130
+ * nothing: the high-water mark is the idempotency key. A skill turn is
131
+ * skipped, the one-home rule. Any failure is recorded in hooks.log, never
132
+ * fails the turn, and the handler always answers agy's contract.
133
+ */
134
+ export async function agyStop(stdin: string): Promise<number> {
135
+ const payload = parseAgyPayload(stdin);
136
+ if (!payload.conversationId || !payload.transcriptPath) {
137
+ process.stdout.write(EMPTY);
138
+ return 0;
139
+ }
140
+
141
+ const turns = readAgyTurns(payload.transcriptPath);
142
+ if (turns.length === 0) {
143
+ process.stdout.write(EMPTY);
144
+ return 0;
145
+ }
146
+
147
+ let dbPath: string;
148
+ try {
149
+ dbPath = resolveDbPath([], process.env);
150
+ } catch {
151
+ process.stdout.write(EMPTY);
152
+ return 0;
153
+ }
154
+
155
+ try {
156
+ const db = openLiveWriteDatabase(dbPath);
157
+ try {
158
+ const sessionId = payload.conversationId;
159
+ const existing = getRuntimeSession(db, sessionId);
160
+ const stored = decodeMetadata(existing?.metadata ?? null);
161
+ const highWater =
162
+ typeof stored[HIGH_WATER_KEY] === "number" ? (stored[HIGH_WATER_KEY] as number) : -1;
163
+ const deps = { newId, nowIso };
164
+ const options = { interface: "antigravity" };
165
+
166
+ let newHighWater = highWater;
167
+ for (const turn of turns) {
168
+ if (turn.lastStepIndex <= highWater) continue;
169
+ if (!isCommandTurn(turn.userText)) {
170
+ logUserMessage(db, sessionId, turn.userText, options, deps);
171
+ logAssistantMessage(db, sessionId, turn.replyText, options, deps);
172
+ }
173
+ if (turn.lastStepIndex > newHighWater) newHighWater = turn.lastStepIndex;
174
+ }
175
+
176
+ if (newHighWater !== highWater) {
177
+ const current = getRuntimeSession(db, sessionId);
178
+ const metadata = decodeMetadata(current?.metadata ?? null);
179
+ metadata[HIGH_WATER_KEY] = newHighWater;
180
+ upsertRuntimeSession(db, sessionId, { metadata: JSON.stringify(metadata) }, nowIso());
181
+ }
182
+ } finally {
183
+ db.close();
184
+ }
185
+ } catch (error) {
186
+ noteHookFailure("agy:stop", error instanceof Error ? error.message : String(error));
187
+ }
188
+
189
+ process.stdout.write(EMPTY);
190
+ return 0;
191
+ }
192
+
193
+ /** `PreInvocation`: one ephemeral context step per user turn, when owed. */
194
+ export async function agyPreInvocation(stdin: string): Promise<number> {
195
+ const payload = parseAgyPayload(stdin);
196
+
197
+ // Defense in depth behind the wrapper's shell gate: only the turn's first
198
+ // invocation injects; the other ~30 answer instantly.
199
+ if (payload.invocationNum !== 0 || !payload.conversationId) {
200
+ process.stdout.write(EMPTY);
201
+ return 0;
202
+ }
203
+
204
+ let dbPath: string;
205
+ try {
206
+ dbPath = resolveDbPath([], process.env);
207
+ } catch {
208
+ process.stdout.write(EMPTY);
209
+ return 0;
210
+ }
211
+
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
+ }
218
+
219
+ const argv = ["mirror", "load", "--context-only", "--session-id", payload.conversationId];
220
+ if (state.persona) argv.push("--persona", state.persona);
221
+ if (state.journey) argv.push("--journey", state.journey);
222
+
223
+ 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;
229
+ }
@@ -87,9 +87,21 @@ export async function claudeStop(stdin: string): Promise<number> {
87
87
  return 0;
88
88
  }
89
89
 
90
- /** `log-session-end.sh`: close the session, then take the silent backup. */
91
- export async function claudeSessionEnd(): Promise<number> {
92
- await runFrontDoorQuietly("claude:session-end", ["conversation-logger", "session-end"]);
90
+ /**
91
+ * `log-session-end.sh`: close the session, then take the silent backup.
92
+ *
93
+ * The runtime goes along as `--interface`, as Stop's does, for a session row
94
+ * with no label. On a runtime that bounds this hook (Codex 1 s, Grok 1.5 s)
95
+ * the close runs no LLM work and the next session start finishes the tail
96
+ * (CV21.DS4.TS2); both calls then fit inside the bound.
97
+ */
98
+ export async function claudeSessionEnd(stdin: string): Promise<number> {
99
+ const runtime = detectHookRuntime(parseHookPayload(stdin).raw);
100
+ await runFrontDoorQuietly(
101
+ "claude:session-end",
102
+ ["conversation-logger", "session-end", "--interface", runtime],
103
+ { stdin },
104
+ );
93
105
  await runFrontDoorQuietly("claude:session-end", ["backup", "--silent"]);
94
106
  return 0;
95
107
  }
@@ -204,7 +216,7 @@ export async function claudeHook(name: string): Promise<number> {
204
216
  case "stop":
205
217
  return claudeStop(readStdin());
206
218
  case "session-end":
207
- return claudeSessionEnd();
219
+ return claudeSessionEnd(readStdin());
208
220
  case "inject":
209
221
  return claudeInject(readStdin());
210
222
  default:
@@ -4,12 +4,15 @@
4
4
  //
5
5
  // CV22.DS10.TS5 plateau 1, decision D4. Twelve shell scripts across four
6
6
  // runtimes each spawned the interpreter one to five times per event. They now
7
- // run one Node process and name which hook they are:
7
+ // run one Node process and name which hook they are (the Gemini CLI family,
8
+ // `gemini:*`, retired with CV21.DS8.US1; Antigravity is its successor):
8
9
  //
9
10
  // node ts/src/hooks/main.ts claude:inject
10
11
  //
11
- // Arguments after the name are the hook's own; only the Codex wrapper passes
12
- // any, since Codex has no hook payload to read (`codex.ts`).
12
+ // Arguments after the name are the hook's own; every current hook reads its
13
+ // payload from stdin and passes none. (The Codex wrapper's `codex:*` entries
14
+ // were the exception, until CV21.DS4.TS5 retired them with the wrapper: the
15
+ // plugin's Claude-family hooks are the Codex path now.)
13
16
  //
14
17
  // One entry rather than one file per hook because the generated wrappers then
15
18
  // differ only in the hook name and their depth, and `hooks.test.ts` asserts
@@ -33,44 +36,27 @@
33
36
 
34
37
  import { pathToFileURL } from "node:url";
35
38
  import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
39
+ import { agyPreInvocation, agySessionStart, agyStop } from "./agy.ts";
36
40
  import { claudeHook } from "./claude.ts";
37
- import { codexSessionEnd, codexSessionStart } from "./codex.ts";
38
- import {
39
- geminiLogAssistant,
40
- geminiLogUser,
41
- geminiSessionEnd,
42
- geminiSessionStart,
43
- } from "./gemini.ts";
44
41
  import { readStdin } from "./payload.ts";
45
42
  import { noteHookFailure } from "./runtime.ts";
46
43
 
47
- async function dispatch(name: string, args: readonly string[]): Promise<number> {
44
+ async function dispatch(name: string): Promise<number> {
48
45
  const [runtime, hook] = name.split(":");
49
46
 
50
47
  switch (runtime) {
51
48
  case "claude":
52
49
  return claudeHook(hook ?? "");
53
- case "gemini":
50
+ case "agy":
51
+ // The Antigravity plugin's handlers (CV21.DS7.TS2), called through the
52
+ // generated agy-form wrappers in `plugins/mirror-mind/agy/`.
54
53
  switch (hook) {
55
- case "log-user":
56
- return geminiLogUser(readStdin());
57
- case "log-assistant":
58
- return geminiLogAssistant(readStdin());
59
- case "session-end":
60
- return geminiSessionEnd(readStdin());
61
54
  case "session-start":
62
- return geminiSessionStart();
63
- default:
64
- break;
65
- }
66
- break;
67
- case "codex":
68
- // Called by `scripts/codex-mirror.sh` through its generated wrappers.
69
- switch (hook) {
70
- case "session-start":
71
- return codexSessionStart();
72
- case "session-end":
73
- return codexSessionEnd(args[0] ?? "", args[1] ?? "");
55
+ return agySessionStart(readStdin());
56
+ case "pre-invocation":
57
+ return agyPreInvocation(readStdin());
58
+ case "stop":
59
+ return agyStop(readStdin());
74
60
  default:
75
61
  break;
76
62
  }
@@ -94,7 +80,7 @@ export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<numbe
94
80
  loadConfiguration({ entryPath: import.meta.filename });
95
81
  const name = rawArgv[0] ?? "";
96
82
  try {
97
- return await dispatch(name, rawArgv.slice(1));
83
+ return await dispatch(name);
98
84
  } catch (error) {
99
85
  // The last line of defense. A hook that throws must still not break the
100
86
  // turn, and must still leave a trace that it happened.
@@ -1,8 +1,8 @@
1
1
  // Reading a runtime's hook payload from stdin.
2
2
  //
3
3
  // Replaces the Python hook module `extract_prompt` (25 lines) and the four
4
- // inline interpreter one-liners the Claude and Gemini hooks used to parse the
5
- // same JSON. None of them was a command, so none was in
4
+ // inline interpreter one-liners the Claude and Gemini CLI hooks used to parse
5
+ // the same JSON (the Gemini CLI hooks retired with CV21.DS8.US1). None of them was a command, so none was in
6
6
  // DS7's denominator of 29 -- which is why TS5's inventory had to find them by
7
7
  // reading the hooks rather than by counting commands.
8
8
  //
@@ -23,17 +23,11 @@ export interface HookPayload {
23
23
  readonly prompt: string;
24
24
  /** The runtime's session id (`session_id`, or Grok's `sessionId`), or "". */
25
25
  readonly sessionId: string;
26
- /** The assistant's response text (Gemini's `prompt_response`), or "". */
27
- readonly response: string;
28
- /** Everything else, for hooks that need a field these three do not cover. */
26
+ /** Everything else, for hooks that need a field these two do not cover. */
29
27
  readonly raw: HookPayloadObject;
30
28
  }
31
29
 
32
- const EMPTY: HookPayload = { prompt: "", sessionId: "", response: "", raw: {} };
33
-
34
- function text(value: unknown): string {
35
- return typeof value === "string" ? value : "";
36
- }
30
+ const EMPTY: HookPayload = { prompt: "", sessionId: "", raw: {} };
37
31
 
38
32
  /**
39
33
  * Parse a hook payload. Never throws: an unparseable payload is empty.
@@ -47,11 +41,10 @@ export function parseHookPayload(input: string): HookPayload {
47
41
  return {
48
42
  prompt: promptOf(raw),
49
43
  sessionId: sessionIdOf(raw),
50
- // Gemini's AfterAgent payload names it `prompt_response`, and that is the
51
- // field the Python hook read. The first port read three guessed names
52
- // instead and dropped every Gemini assistant turn in silence until
53
- // `smoke_gemini_cli.sh` caught it (CV22.DS10.TS5 plateau 3).
54
- response: text(raw.prompt_response),
44
+ // A `response` field read Gemini CLI's `prompt_response` here until
45
+ // CV21.DS8.US1; no living runtime hands the reply to a hook payload by
46
+ // name (Claude-family hooks read the transcript, agy's Stop reads its
47
+ // JSONL), so the field went with its only reader.
55
48
  raw,
56
49
  };
57
50
  }
@@ -61,11 +61,11 @@ export function noteHookFailure(hook: string, reason: string, env = process.env)
61
61
  * The Operating Instructions a SessionStart hook should hand its runtime, or
62
62
  * null when the project already carries this same file.
63
63
  *
64
- * CV22.DS10.US3, decision D13. An installed package reaches Claude Code and
65
- * Gemini CLI through hooks, skills, and an MCP server; none of those reads a
66
- * CLAUDE.md or GEMINI.md, so a session wired to the package would answer
67
- * `/mm-mirror` and still route no mode, sign no persona, and hold no Builder
68
- * boundary. Both runtimes read SessionStart's
64
+ * CV22.DS10.US3, decision D13. An installed package reaches Claude Code (and
65
+ * reached Gemini CLI, until CV21.DS8.US1) through hooks, skills, and an MCP
66
+ * server; none of those reads a CLAUDE.md, so a session wired to the package
67
+ * would answer `/mm-mirror` and still route no mode, sign no persona, and hold
68
+ * no Builder boundary. The runtime reads SessionStart's
69
69
  * `hookSpecificOutput.additionalContext`, so the session-start hook returns
70
70
  * `AGENTS.md` from the tree THIS code runs from -- the instructions always
71
71
  * match the core they ship with -- unless the project the session opened in
@@ -92,8 +92,9 @@ export function operatingInstructionsFor(
92
92
  }
93
93
 
94
94
  /**
95
- * The SessionStart answer both runtimes read: context as JSON, or `nothing`
96
- * -- what each runtime's hook printed before D13 (Gemini `{}`, Claude none).
95
+ * The SessionStart answer the runtime reads: context as JSON, or `nothing`
96
+ * -- what the runtime's hook printed before D13 (Claude none; Gemini CLI's
97
+ * `{}`, until CV21.DS8.US1 retired it).
97
98
  */
98
99
  export function sessionStartOutput(instructions: string | null, nothing = ""): string {
99
100
  if (instructions === null) return nothing;
@@ -148,9 +149,9 @@ export async function runFrontDoor(
148
149
  /**
149
150
  * The command a failure line may name: the family and its subcommand, never
150
151
  * the arguments. Arguments are where hooks put what the user said and what the
151
- * model answered -- the Gemini logger passes the prompt and the response as
152
- * argv -- and the first version of the failure line printed all of them (TS5
153
- * handoff review, finding P1).
152
+ * model answered -- the Gemini CLI logger, retired by CV21.DS8.US1, passed the
153
+ * prompt and the response as argv -- and the first version of the failure
154
+ * line printed all of them (TS5 handoff review, finding P1).
154
155
  */
155
156
  function commandName(argv: readonly string[]): string {
156
157
  return argv.slice(0, 2).join(" ");
@@ -120,6 +120,17 @@ export function resolveMaintenanceMaxExtractions(options: ModelPinOptions = {}):
120
120
  return parsePythonInt(env.MEMORY_MAINTENANCE_MAX_EXTRACTIONS, 10);
121
121
  }
122
122
 
123
+ /**
124
+ * `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4): how long an open conversation
125
+ * may sit idle before session maintenance closes it as an orphan. On a runtime
126
+ * whose session outlives the TUI (Codex 0.163.0) this is the close path, so an
127
+ * operator may shorten it; a route sets it to 0 to prove the close now.
128
+ */
129
+ export function resolveStaleOrphanMinutes(options: ModelPinOptions = {}): number {
130
+ const env = options.env ?? process.env;
131
+ return parsePythonInt(env.MIRROR_STALE_ORPHAN_MINUTES, 30);
132
+ }
133
+
123
134
  /** Python `int(os.getenv("MEMORY_EXTRACTION_MAX_ATTEMPTS", "3"))` (CV9.E2.S7). */
124
135
  export function resolveExtractionMaxAttempts(options: ModelPinOptions = {}): number {
125
136
  const env = options.env ?? process.env;
@@ -31,7 +31,13 @@ import { identityKeyBreaksGrammar, SLUG_KEYED_LAYERS } from "#identity/identityK
31
31
  import { pyRepr, sortByCodePoint } from "#util/pythonText.ts";
32
32
  import { configFilePath } from "./config.ts";
33
33
  import type { GitWorktreeEntry } from "./git.ts";
34
+ import {
35
+ claudePluginLinkState,
36
+ codexPluginCacheState,
37
+ installedPluginVersion,
38
+ } from "./pluginWiring.ts";
34
39
  import type { RuntimeStatusReport } from "./status.ts";
40
+ import { CODEX_PLUGIN_ADD_COMMAND } from "./wiring.ts";
35
41
 
36
42
  export interface DriftFinding {
37
43
  code: string;
@@ -114,7 +120,7 @@ export function rootStateFindings(homesRoot: string): DriftFinding[] {
114
120
  detail: path,
115
121
  recommendation:
116
122
  "relocate into the owning mirror home " +
117
- "(see REFERENCE.md \u2014 Relocating legacy root runtime state)",
123
+ "(see REFERENCE.md — Relocating legacy root runtime state)",
118
124
  repair_route: "manual relocation",
119
125
  }));
120
126
  }
@@ -319,6 +325,60 @@ export function ftsConsistencyFindings(report: RuntimeStatusReport): DriftFindin
319
325
  return [];
320
326
  }
321
327
 
328
+ /** A deferred close tail waits for a session start; after this long, nobody started one. */
329
+ const DEFERRED_CLOSE_TAIL_STALE_MS = 24 * 60 * 60 * 1000;
330
+
331
+ /**
332
+ * CV21.DS4.TS2: a runtime that bounds its SessionEnd hook (Codex, Grok) closes
333
+ * without the LLM tail and flags the conversation for the next session start to
334
+ * finish. A user whose last session was on such a runtime, and who starts no
335
+ * session for a day, has memories and titles waiting and nothing saying so:
336
+ * this is the one place that says so. Younger flags are the normal state
337
+ * between two sessions, not a finding.
338
+ */
339
+ export function deferredCloseTailFindings(
340
+ report: Pick<RuntimeStatusReport, "db_path" | "db_exists">,
341
+ now: Date = new Date(),
342
+ ): DriftFinding[] {
343
+ if (report.db_path === null || !report.db_exists) return [];
344
+ let row: { n: number; oldest: string } | null;
345
+ try {
346
+ const db = openDatabaseReadOnly(report.db_path);
347
+ try {
348
+ const found = db
349
+ .prepare(
350
+ `SELECT COUNT(*) AS n, MIN(ended_at) AS oldest FROM conversations
351
+ WHERE ended_at IS NOT NULL
352
+ AND json_extract(metadata, '$.close_tail_pending') IS 1`,
353
+ )
354
+ .get();
355
+ row =
356
+ found && Number(found.n) > 0 ? { n: Number(found.n), oldest: String(found.oldest) } : null;
357
+ } finally {
358
+ db.close();
359
+ }
360
+ } catch {
361
+ return [];
362
+ }
363
+ if (row === null) return [];
364
+ const oldest = Date.parse(row.oldest);
365
+ if (Number.isNaN(oldest) || now.getTime() - oldest < DEFERRED_CLOSE_TAIL_STALE_MS) return [];
366
+ return [
367
+ {
368
+ code: "deferred_close_tails_waiting",
369
+ severity: "attention",
370
+ subject: "conversations",
371
+ detail:
372
+ `${row.n} conversation(s) closed by a runtime that defers the close tail are waiting ` +
373
+ `for a session start to finish it; the oldest ended ${row.oldest}`,
374
+ recommendation:
375
+ "start a session on any runtime: its maintenance extracts and finalizes them, " +
376
+ "five per start",
377
+ repair_route: "session start",
378
+ },
379
+ ];
380
+ }
381
+
322
382
  export interface DiagnoseOptions {
323
383
  /** Injected only so the 24h boundary is testable; production passes none. */
324
384
  now?: Date;
@@ -340,6 +400,7 @@ export function diagnoseRuntime(
340
400
  findings.push(...frontDoorErrorFindings(report, options.now));
341
401
  findings.push(...ftsConsistencyFindings(report));
342
402
  findings.push(...identityKeyFindings(report));
403
+ findings.push(...deferredCloseTailFindings(report, options.now));
343
404
 
344
405
  if (report.mirror_home_error) {
345
406
  findings.push({
@@ -442,6 +503,59 @@ export function diagnoseRuntime(
442
503
  return findings;
443
504
  }
444
505
 
506
+ /**
507
+ * The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried). Codex
508
+ * copies the plugin into a versioned cache, so an npm update leaves it
509
+ * behind; Claude Code links it in place, so its failure mode is a dangling
510
+ * link. Absence is not drift: no cache and no link fire nothing, so an
511
+ * unwired machine stays green.
512
+ *
513
+ * The defaulting happens HERE, from the env the caller hands in — CODEX_HOME
514
+ * else ~/.codex, and ~/.claude — never inside the readers, so every reader
515
+ * test is hermetic on a maintainer's machine with real wiring
516
+ * (CR123/128/137/142).
517
+ */
518
+ export function pluginWiringFindings(
519
+ installRoot: string | null,
520
+ env: NodeJS.ProcessEnv,
521
+ ): DriftFinding[] {
522
+ const findings: DriftFinding[] = [];
523
+ const home = env.HOME?.trim() || null;
524
+
525
+ const codexHome = env.CODEX_HOME?.trim() || (home ? join(home, ".codex") : null);
526
+ const installedVersion = installRoot ? installedPluginVersion(installRoot) : null;
527
+ if (codexHome && installedVersion) {
528
+ const cache = codexPluginCacheState(codexHome, installedVersion);
529
+ if (cache.state === "behind") {
530
+ findings.push({
531
+ code: "codex_plugin_cache_behind",
532
+ severity: "warning",
533
+ subject: cache.cachePath,
534
+ detail: `cached ${cache.newestCached} < installed ${installedVersion}; Codex runs the copy it cached at install`,
535
+ recommendation: `run \`${CODEX_PLUGIN_ADD_COMMAND}\` to bring the cached plugin level (Codex has no plugin-update verb; re-adding refreshes the cache)`,
536
+ repair_route: CODEX_PLUGIN_ADD_COMMAND,
537
+ });
538
+ }
539
+ }
540
+
541
+ if (home) {
542
+ const link = claudePluginLinkState(join(home, ".claude"));
543
+ if (link.state === "dangling") {
544
+ findings.push({
545
+ code: "claude_plugin_link_dangling",
546
+ severity: "warning",
547
+ subject: link.linkPath,
548
+ detail: `the link points at ${link.target ?? "an unreadable target"}, which cannot be read`,
549
+ recommendation:
550
+ 'relink it — `ln -s "<pkg>/plugins/mirror-mind" ~/.claude/skills/mirror-mind` — or remove it if Claude Code is no longer wired',
551
+ repair_route: "relink or remove",
552
+ });
553
+ }
554
+ }
555
+
556
+ return findings;
557
+ }
558
+
445
559
  /** The catalog read `probeModelPins` needs. DS5 replay only until DS8. */
446
560
  export interface ModelCatalogProvider {
447
561
  listAvailableModels(): Promise<Set<string>>;