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.
- package/.agents/plugins/marketplace.json +16 -0
- package/AGENTS.md +4 -4
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.4.md +104 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/package.json +2 -3
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/conversation/backfill.ts +13 -129
- package/ts/src/conversation/commandTurn.ts +42 -0
- package/ts/src/conversation/deferredCloseTail.ts +154 -0
- package/ts/src/conversation/logger.ts +104 -20
- package/ts/src/conversation/loggerCli.ts +6 -18
- package/ts/src/conversation/loggerRuntime.ts +6 -1
- package/ts/src/conversation/runtimePolicy.ts +50 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/frontDoor/cli.ts +12 -2
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/guards/claudePlugin.ts +39 -2
- package/ts/src/guards/packContents.ts +4 -4
- package/ts/src/guards/retiredSurfaces.ts +36 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/gemini.ts +3 -1
- package/ts/src/hooks/main.ts +6 -16
- package/ts/src/identity/identityEdit.ts +11 -2
- package/ts/src/identity/identityKey.ts +18 -2
- package/ts/src/providers/config.ts +11 -0
- package/ts/src/runtime/diagnose.ts +115 -1
- package/ts/src/runtime/pluginWiring.ts +135 -0
- package/ts/src/runtime/update.ts +39 -6
- package/ts/src/runtime/wiring.ts +24 -10
- package/scripts/codex-hooks/session-end.sh +0 -85
- package/scripts/codex-hooks/session-start.sh +0 -85
- package/scripts/codex-mirror.sh +0 -48
- package/ts/src/hooks/codex.ts +0 -45
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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),
|
|
2
|
-
// Claude Code copies of
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -87,9 +87,21 @@ export async function claudeStop(stdin: string): Promise<number> {
|
|
|
87
87
|
return 0;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
package/ts/src/hooks/gemini.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/ts/src/hooks/main.ts
CHANGED
|
@@ -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;
|
|
12
|
-
//
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
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>>;
|