mirror-mind 0.33.3 → 0.34.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 (35) hide show
  1. package/.agents/plugins/marketplace.json +16 -0
  2. package/AGENTS.md +4 -4
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.33.4.md +104 -0
  5. package/docs/releases/v0.34.0.md +142 -0
  6. package/package.json +2 -3
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/ts/src/conversation/backfill.ts +13 -129
  9. package/ts/src/conversation/commandTurn.ts +42 -0
  10. package/ts/src/conversation/deferredCloseTail.ts +154 -0
  11. package/ts/src/conversation/logger.ts +104 -20
  12. package/ts/src/conversation/loggerCli.ts +6 -18
  13. package/ts/src/conversation/loggerRuntime.ts +6 -1
  14. package/ts/src/conversation/runtimePolicy.ts +50 -0
  15. package/ts/src/conversation/sessionComposites.ts +54 -8
  16. package/ts/src/frontDoor/cli.ts +12 -2
  17. package/ts/src/frontDoor/routing.ts +3 -2
  18. package/ts/src/frontDoor/runtimeRoute.ts +13 -1
  19. package/ts/src/guards/claudePlugin.ts +39 -2
  20. package/ts/src/guards/packContents.ts +4 -4
  21. package/ts/src/guards/retiredSurfaces.ts +36 -0
  22. package/ts/src/hooks/claude.ts +16 -4
  23. package/ts/src/hooks/gemini.ts +3 -1
  24. package/ts/src/hooks/main.ts +6 -16
  25. package/ts/src/identity/identityEdit.ts +11 -2
  26. package/ts/src/identity/identityKey.ts +18 -2
  27. package/ts/src/providers/config.ts +11 -0
  28. package/ts/src/runtime/diagnose.ts +115 -1
  29. package/ts/src/runtime/pluginWiring.ts +135 -0
  30. package/ts/src/runtime/update.ts +39 -6
  31. package/ts/src/runtime/wiring.ts +24 -10
  32. package/scripts/codex-hooks/session-end.sh +0 -85
  33. package/scripts/codex-hooks/session-start.sh +0 -85
  34. package/scripts/codex-mirror.sh +0 -48
  35. package/ts/src/hooks/codex.ts +0 -45
@@ -43,7 +43,7 @@ import { type Database, openDatabaseReadOnly, type WritableDatabase } from "#db/
43
43
  import { ensureDatabaseReady } from "#db/readyOnOpen.ts";
44
44
  import { assertSchemaState, SchemaStateError } from "#db/schemaState.ts";
45
45
  import { allDescriptors, descriptorsByLayer } from "#descriptor/descriptorRead.ts";
46
- import { newIdentityKeyProblem } from "#identity/identityKey.ts";
46
+ import { newIdentityKeyProblem, newUserNameProblem } from "#identity/identityKey.ts";
47
47
  import { identityRowExists, listIdentityByLayer } from "#identity/identityRead.ts";
48
48
  import { listJourneysForListCommand } from "#identity/journeyListing.ts";
49
49
  import { listPersonas } from "#identity/personaListing.ts";
@@ -885,10 +885,20 @@ function isInit(argv: readonly string[]): boolean {
885
885
  */
886
886
  function runInit(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): number {
887
887
  const user = argv[1];
888
- if (!user) {
888
+ // A word that starts with `-` is a flag this command does not have, not a
889
+ // user: `init --help` once created a home named `--help`.
890
+ if (!user || user.startsWith("-")) {
889
891
  console.error("Usage: init <user>");
890
892
  return 2;
891
893
  }
894
+ // And any other word is a key: the home's directory name and the value of
895
+ // MIRROR_USER in the wiring commands printed below. CR104's rule for slugs
896
+ // and persona ids, asked before anything is created (CR141).
897
+ const problem = newUserNameProblem(user);
898
+ if (problem !== null) {
899
+ console.error(`Error: ${problem}`);
900
+ return 2;
901
+ }
892
902
  try {
893
903
  const identityRoot = initUserHome(user);
894
904
  const mirrorHome = dirname(identityRoot);
@@ -373,10 +373,11 @@ const TS_CONVERSATION_LOGGER_SUBCOMMANDS = new Set([
373
373
  // CV21.DS2.US8: the Stop hook's reply, deterministic like user-prompt.
374
374
  "stop",
375
375
  "discard-current",
376
- // US10 slice F, group 2 (need slice E): diagnose, dry-run repair, Codex import.
376
+ // US10 slice F, group 2 (need slice E): diagnose and dry-run repair. (The
377
+ // Codex import routed here too, until CV21.DS4.TS5 retired it with the
378
+ // v0.32.1 Codex wrapper.)
377
379
  "diagnose-journeys",
378
380
  "repair-journeys",
379
- "backfill-codex-session",
380
381
  ]);
381
382
 
382
383
  // CV22.DS7.US10 slice F. These reach Python's `end_conversation`, which runs
@@ -26,6 +26,7 @@ import {
26
26
  diagnoseRuntime,
27
27
  hookFailureFindings,
28
28
  hookNodeFindings,
29
+ pluginWiringFindings,
29
30
  probeModelPins,
30
31
  renderRuntimeDiagnosis,
31
32
  rootStateFindings,
@@ -301,12 +302,19 @@ export async function runRuntimeReadRoute(
301
302
  if (subcommand === "diagnose") {
302
303
  // The install's own tree, so its worktree findings are Mirror's and never
303
304
  // the cwd project's (CR138).
305
+ const install = io.install ?? detectThisInstall();
304
306
  const report = buildRuntimeStatus({
305
307
  mirrorHome: optionValue(args, "--mirror-home"),
306
308
  env,
307
309
  version,
308
- install: io.install ?? detectThisInstall(),
310
+ install,
309
311
  });
312
+ const installRoot =
313
+ install.kind === "clone"
314
+ ? install.repository
315
+ : install.kind === "package"
316
+ ? install.root
317
+ : null;
310
318
  const findings = [
311
319
  ...diagnoseRuntime(report, inspectGitWorktree(report.git.repository)),
312
320
  ...rootStateFindings(homesRoot(env)),
@@ -324,6 +332,10 @@ export async function runRuntimeReadRoute(
324
332
  return false;
325
333
  }
326
334
  }),
335
+ // CV21.DS4.TS6: the plugin wiring. Codex's versioned cache goes stale
336
+ // after an npm update; Claude Code's link can dangle. Absence is not
337
+ // drift, so an unwired machine stays green.
338
+ ...pluginWiringFindings(installRoot, env),
327
339
  // No catalog provider until DS8: inconclusive, which is also the
328
340
  // oracle's answer when it cannot reach OpenRouter.
329
341
  ...(await probeModelPins(null, "")),
@@ -1,5 +1,6 @@
1
- // Generate the canonical Mirror Mind Claude plugin (manifest + skills), and the
2
- // Claude Code copies of Pi-sourced skills.
1
+ // Generate the canonical Mirror Mind Claude plugin (manifest + skills), the
2
+ // Codex marketplace that names it (CV21.DS4.US1), and the Claude Code copies of
3
+ // Pi-sourced skills.
3
4
  //
4
5
  // The Node port of `src/memory/plugins/claude.py` and its wrapper
5
6
  // `scripts/build_claude_plugin.py` (CV22.DS10.TS5, slice B).
@@ -43,6 +44,12 @@ export const SKILLS_SOURCE_DIR = ".claude/skills";
43
44
  export const PLUGIN_DIR = "plugins/mirror-mind";
44
45
  export const SKILL_FILENAME = "SKILL.md";
45
46
 
47
+ /**
48
+ * Where Codex reads a marketplace from, under the marketplace root, which for
49
+ * the shipped package is the package root (CV21.DS4.US1; DS12.TS2 static 5).
50
+ */
51
+ export const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
52
+
46
53
  /** A file the generator owns, addressed relative to the repo root. */
47
54
  export interface GeneratedFile {
48
55
  readonly relativePath: string;
@@ -142,6 +149,35 @@ export function manifestJson(version: string): string {
142
149
  return `${JSON.stringify(buildManifest(version), null, 2)}\n`;
143
150
  }
144
151
 
152
+ /**
153
+ * The Codex marketplace the package ships (CV21.DS4.US1).
154
+ *
155
+ * `codex plugin marketplace add <package root>` reads it, and
156
+ * `codex plugin add mirror-mind@mirror-mind` copies the plugin it names into
157
+ * Codex's cache, at the version the plugin manifest carries: the marketplace
158
+ * itself has none. One local source, the plugin directory, relative to the
159
+ * root. Generated beside the manifest so the plugin's name lives in one
160
+ * constant and `--check` guards both files.
161
+ */
162
+ export function buildMarketplace(): Record<string, unknown> {
163
+ return {
164
+ name: PLUGIN_NAME,
165
+ owner: { name: PLUGIN_AUTHOR },
166
+ plugins: [
167
+ {
168
+ name: PLUGIN_NAME,
169
+ source: { source: "local", path: `./${PLUGIN_DIR}` },
170
+ description: PLUGIN_DESCRIPTION,
171
+ },
172
+ ],
173
+ };
174
+ }
175
+
176
+ /** The marketplace as deterministic JSON text. */
177
+ export function marketplaceJson(): string {
178
+ return `${JSON.stringify(buildMarketplace(), null, 2)}\n`;
179
+ }
180
+
145
181
  /**
146
182
  * The skill markdown file, matching `skill.md` case-insensitively.
147
183
  *
@@ -232,6 +268,7 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
232
268
  relativePath: join(PLUGIN_DIR, ".claude-plugin", "plugin.json"),
233
269
  content: manifestJson(version),
234
270
  },
271
+ { relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
235
272
  ...claudeCopies.values(),
236
273
  ...[...pluginSkills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, file]) => file),
237
274
  ];
@@ -28,7 +28,6 @@ export const ALLOWED_PREFIXES: readonly string[] = [
28
28
  ".claude/hooks/",
29
29
  ".gemini/hooks/",
30
30
  "plugins/mirror-mind/",
31
- "scripts/codex-hooks/",
32
31
  ];
33
32
 
34
33
  export const ALLOWED_FILES: readonly string[] = [
@@ -37,7 +36,9 @@ export const ALLOWED_FILES: readonly string[] = [
37
36
  "LICENSE",
38
37
  "AGENTS.md",
39
38
  "ts/README.md",
40
- "scripts/codex-mirror.sh",
39
+ // CV21.DS4.US1: the Codex marketplace, read from the package root. The
40
+ // `.agents/skills/` symlinks beside it are the checkout's and stay out.
41
+ ".agents/plugins/marketplace.json",
41
42
  ];
42
43
 
43
44
  /** Files that must be in every tarball: the entries and one of each runtime's wiring. */
@@ -57,8 +58,7 @@ export const REQUIRED_FILES: readonly string[] = [
57
58
  ".claude/hooks/session-start.sh",
58
59
  ".gemini/hooks/session-start.sh",
59
60
  "plugins/mirror-mind/hooks/hooks.json",
60
- "scripts/codex-hooks/session-start.sh",
61
- "scripts/codex-mirror.sh",
61
+ ".agents/plugins/marketplace.json",
62
62
  ];
63
63
 
64
64
  /** Prefixes that must have at least one file: content the program reads at runtime. */
@@ -460,6 +460,42 @@ export const RETIRED: readonly RetiredSurface[] = [
460
460
  // exploration's record is `docs/project/exploration/es-004-*.md`.
461
461
  exemptions: {},
462
462
  },
463
+ {
464
+ surfaceId: "codex-wrapper",
465
+ story: "CV21.DS4.TS5",
466
+ // The v0.32.1 Codex wiring: a wrapper script around the `codex` command,
467
+ // its two generated hooks, the `codex:*` hook family, and the JSONL
468
+ // backfill the wrapper drove at exit. Retired once US4 measured Codex at
469
+ // L4 through the shipped plugin, live: the plugin's Claude-family hooks
470
+ // log Codex turns as they happen, so nothing re-imports the transcript.
471
+ // The backfill's only caller was the wrapper's session-end hook, and the
472
+ // plugin's SessionEnd closes the session without reading it -- the
473
+ // condition DS4 set for keeping it was not met.
474
+ absentPaths: [
475
+ "scripts/codex-mirror.sh",
476
+ "scripts/codex-hooks/",
477
+ "ts/src/hooks/codex.ts",
478
+ "scripts/smoke_codex.sh",
479
+ "ts/test/fixtures/backfill/codex/",
480
+ ],
481
+ // File and route names, never vocabulary: "codex" is a living word (the
482
+ // plugin serves Codex; `runtimeDetection.ts` labels its payloads).
483
+ forbiddenPatterns: [
484
+ "codex-mirror\\.sh",
485
+ "codex-hooks",
486
+ "codex:session-(start|end)",
487
+ "backfill-codex-session",
488
+ "backfill_codex_session",
489
+ "backfillCodexSession",
490
+ "smoke_codex",
491
+ ],
492
+ exemptions: {
493
+ "docs/getting-started.md":
494
+ "the un-wiring guidance: a user who started codex through the retired wrapper needs its name to undo that wiring",
495
+ "ts/src/conversation/backfill.ts":
496
+ "the header names the retired importer to say where it went, beside the Pi importer that stays",
497
+ },
498
+ },
463
499
  {
464
500
  // Not a deleted surface: a retired IDIOM. A URL's pathname is not a
465
501
  // filesystem path -- it percent-encodes `Código` and `John Doe`, and on
@@ -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:
@@ -14,6 +14,7 @@
14
14
  // `GEMINI_SESSION_ID` still wins over the payload's `session_id`, because the
15
15
  // runtime sets it and the payload is the fallback.
16
16
 
17
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
17
18
  import { parseHookPayload } from "./payload.ts";
18
19
  import {
19
20
  operatingInstructionsFor,
@@ -37,7 +38,8 @@ export async function geminiLogUser(stdin: string): Promise<number> {
37
38
  const payload = parseHookPayload(stdin);
38
39
  const sessionId = sessionIdFrom(payload.sessionId);
39
40
 
40
- if (payload.prompt.startsWith("/")) {
41
+ // The same rule as the Claude-family logger's (CV21.DS4.TS3): one home.
42
+ if (isCommandTurn(payload.prompt)) {
41
43
  process.stdout.write("{}\n");
42
44
  return 0;
43
45
  }
@@ -8,8 +8,10 @@
8
8
  //
9
9
  // node ts/src/hooks/main.ts claude:inject
10
10
  //
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`).
11
+ // Arguments after the name are the hook's own; every current hook reads its
12
+ // payload from stdin and passes none. (The Codex wrapper's `codex:*` entries
13
+ // were the exception, until CV21.DS4.TS5 retired them with the wrapper: the
14
+ // plugin's Claude-family hooks are the Codex path now.)
13
15
  //
14
16
  // One entry rather than one file per hook because the generated wrappers then
15
17
  // differ only in the hook name and their depth, and `hooks.test.ts` asserts
@@ -34,7 +36,6 @@
34
36
  import { pathToFileURL } from "node:url";
35
37
  import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
36
38
  import { claudeHook } from "./claude.ts";
37
- import { codexSessionEnd, codexSessionStart } from "./codex.ts";
38
39
  import {
39
40
  geminiLogAssistant,
40
41
  geminiLogUser,
@@ -44,7 +45,7 @@ import {
44
45
  import { readStdin } from "./payload.ts";
45
46
  import { noteHookFailure } from "./runtime.ts";
46
47
 
47
- async function dispatch(name: string, args: readonly string[]): Promise<number> {
48
+ async function dispatch(name: string): Promise<number> {
48
49
  const [runtime, hook] = name.split(":");
49
50
 
50
51
  switch (runtime) {
@@ -64,17 +65,6 @@ async function dispatch(name: string, args: readonly string[]): Promise<number>
64
65
  break;
65
66
  }
66
67
  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] ?? "");
74
- default:
75
- break;
76
- }
77
- break;
78
68
  default:
79
69
  break;
80
70
  }
@@ -94,7 +84,7 @@ export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<numbe
94
84
  loadConfiguration({ entryPath: import.meta.filename });
95
85
  const name = rawArgv[0] ?? "";
96
86
  try {
97
- return await dispatch(name, rawArgv.slice(1));
87
+ return await dispatch(name);
98
88
  } catch (error) {
99
89
  // The last line of defense. A hook that throws must still not break the
100
90
  // turn, and must still leave a trace that it happened.
@@ -34,6 +34,8 @@ export interface IdentityEditDeps {
34
34
  /** The editor argv[0], resolved the way Python resolves it. */
35
35
  editor?: string | undefined;
36
36
  visual?: string | undefined;
37
+ /** Decides the editor when neither variable is set. Default: this process's. */
38
+ platform?: NodeJS.Platform;
37
39
  nowIso: () => string;
38
40
  /** Injected so a test can watch the buffer without driving a real editor. */
39
41
  spawn?: typeof spawnSync;
@@ -42,9 +44,16 @@ export interface IdentityEditDeps {
42
44
  newId: () => string;
43
45
  }
44
46
 
45
- /** Python's `os.environ.get("EDITOR") or os.environ.get("VISUAL") or "nano"`. */
47
+ /**
48
+ * Python's `os.environ.get("EDITOR") or os.environ.get("VISUAL") or "nano"`,
49
+ * with one platform default: Windows has no nano outside Git Bash, so a
50
+ * PowerShell user with neither variable set saw `Editor 'nano' could not be
51
+ * started.` Notepad ships with every Windows and blocks until it is closed,
52
+ * which is what the edit needs. Either variable still wins when set.
53
+ */
46
54
  export function resolveEditor(deps: IdentityEditDeps): string {
47
- return deps.editor || deps.visual || "nano";
55
+ const platform = deps.platform ?? process.platform;
56
+ return deps.editor || deps.visual || (platform === "win32" ? "notepad" : "nano");
48
57
  }
49
58
 
50
59
  /**
@@ -44,11 +44,27 @@ export function identityKeyBreaksGrammar(layer: string, key: string): boolean {
44
44
  */
45
45
  export function newIdentityKeyProblem(layer: string, key: string): string | null {
46
46
  const keyName = KEY_NAMES.get(layer);
47
- if (keyName === undefined || isKebabSlug(key)) return null;
47
+ if (keyName === undefined) return null;
48
+ return keyProblem(layer, keyName, key);
49
+ }
50
+
51
+ /**
52
+ * Why `init` cannot take `user`, or null when it can (CR141). A user name is
53
+ * a key of the same kind: it becomes `~/.mirror-minds/<user>` and the value of
54
+ * `MIRROR_USER` in every wiring command `init` prints. `init ../escaped` once
55
+ * created `~/escaped`, outside the directory the product owns.
56
+ */
57
+ export function newUserNameProblem(user: string): string | null {
58
+ return keyProblem("user home", "user name", user);
59
+ }
60
+
61
+ /** The one sentence every key refusal is, or null when `key` is a kebab-case slug. */
62
+ function keyProblem(created: string, keyName: string, key: string): string | null {
63
+ if (isKebabSlug(key)) return null;
48
64
  const suggestion = kebabSlug(key);
49
65
  const example = suggestion ? `, for example ${pyRepr(suggestion)}` : "";
50
66
  return (
51
- `no ${layer} was created: ${pyRepr(key)} is not a ${keyName}. ` +
67
+ `no ${created} was created: ${pyRepr(key)} is not a ${keyName}. ` +
52
68
  `Use lowercase letters, digits, and single hyphens, up to 80 characters${example}.`
53
69
  );
54
70
  }
@@ -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>>;