mirror-mind 0.35.0 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.md +3 -3
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/docs/releases/v0.36.1.md +137 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +11 -1
- package/plugins/mirror-mind/rules/AGENTS.md +2 -2
- package/ts/src/conversation/loggerRuntime.ts +5 -18
- package/ts/src/conversation/runtimePolicy.ts +26 -3
- package/ts/src/conversation/sessionComposites.ts +5 -7
- package/ts/src/conversation/transcriptBackfill.ts +5 -1
- package/ts/src/db/migrations.ts +4 -7
- package/ts/src/extensions/bindings.ts +53 -2
- package/ts/src/extensions/catalogCommands.ts +82 -23
- package/ts/src/extensions/catalogWrites.ts +12 -0
- package/ts/src/extensions/contextRuntime.ts +36 -1
- package/ts/src/extensions/dispatch.ts +43 -10
- package/ts/src/extensions/extensionPlugin.ts +325 -0
- package/ts/src/extensions/manifest.ts +7 -1
- package/ts/src/extensions/settings.ts +147 -0
- package/ts/src/frontDoor/cli.ts +16 -1
- package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
- package/ts/src/frontDoor/runtimeRoute.ts +11 -0
- package/ts/src/guards/claudePlugin.ts +24 -7
- package/ts/src/guards/retiredSurfaces.ts +42 -5
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +12 -1
- package/ts/src/runtime/brokenPipe.ts +52 -0
- package/ts/src/runtime/diagnose.ts +122 -13
- package/ts/src/runtime/extensionPluginWiring.ts +222 -0
- package/ts/src/runtime/pluginWiring.ts +168 -29
- package/ts/src/runtime/status.ts +28 -1
- package/ts/src/runtime/update.ts +25 -11
- package/ts/src/runtime/wiring.ts +135 -3
- package/ts/src/conversation/backfill.ts +0 -271
- package/ts/src/conversation/sessionImport.ts +0 -90
|
@@ -280,9 +280,9 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
280
280
|
absentPaths: [],
|
|
281
281
|
forbiddenPatterns: [
|
|
282
282
|
// The flags and the two service methods. NOT the bare word `backfill`:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
283
|
+
// the transcript backfill (`ts/src/conversation/transcriptBackfill.ts`)
|
|
284
|
+
// is a different feature, ported and staying. (The Pi session backfill
|
|
285
|
+
// that stood beside it is retired by its own row, `pi-session-backfill`.)
|
|
286
286
|
"--metadata-backfill-",
|
|
287
287
|
"metadata_backfill",
|
|
288
288
|
"preview_metadata_backfill",
|
|
@@ -492,8 +492,6 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
492
492
|
exemptions: {
|
|
493
493
|
"docs/getting-started.md":
|
|
494
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
495
|
},
|
|
498
496
|
},
|
|
499
497
|
{
|
|
@@ -545,6 +543,45 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
545
543
|
],
|
|
546
544
|
exemptions: {},
|
|
547
545
|
},
|
|
546
|
+
{
|
|
547
|
+
surfaceId: "pi-session-backfill",
|
|
548
|
+
story: "CR153",
|
|
549
|
+
// Session maintenance's Pi backfill: a walk of Pi's session directory that
|
|
550
|
+
// imported every file this home's `runtime_sessions` did not track. That
|
|
551
|
+
// held while a machine had one home. With two, each imported the other's
|
|
552
|
+
// sessions -- 543 conversations on the maintainer's machine, both ways,
|
|
553
|
+
// each titled again through the provider -- and both imported the sessions
|
|
554
|
+
// of scratch homes and of Pi run without Mirror, while the gap it existed
|
|
555
|
+
// to fill, a session the home's own extension never logged, came up once
|
|
556
|
+
// in three months. A home's Pi conversations are the ones the Pi extension
|
|
557
|
+
// logged into it, and nothing reads a Pi session file. `importClosedConversation`
|
|
558
|
+
// went with it: the Codex importer that also wrote through it retired with
|
|
559
|
+
// DS4.TS5. `PI_SESSIONS_DIR` is inert, and only the records that say so,
|
|
560
|
+
// and the test that proves it, may name it.
|
|
561
|
+
absentPaths: [
|
|
562
|
+
"ts/src/conversation/backfill.ts",
|
|
563
|
+
"ts/src/conversation/sessionImport.ts",
|
|
564
|
+
"ts/test/conversation/backfill.test.ts",
|
|
565
|
+
"ts/test/db/homeBornAt.test.ts",
|
|
566
|
+
"ts/test/fixtures/backfill/pi-sessions/",
|
|
567
|
+
],
|
|
568
|
+
forbiddenPatterns: [
|
|
569
|
+
"backfillPiSessions",
|
|
570
|
+
"resolvePiSessionsDir",
|
|
571
|
+
"homeBornAt",
|
|
572
|
+
"importClosedConversation",
|
|
573
|
+
"PI_SESSIONS_DIR",
|
|
574
|
+
"Backfilled Pi sessions",
|
|
575
|
+
],
|
|
576
|
+
exemptions: {
|
|
577
|
+
"docs/reference/configuration.md":
|
|
578
|
+
"the inert-values record: a `.env` may still carry PI_SESSIONS_DIR, and the reference says nothing reads it",
|
|
579
|
+
".env.example.advanced":
|
|
580
|
+
"marks PI_SESSIONS_DIR inert, so a leftover line is recognized rather than trusted",
|
|
581
|
+
"ts/test/conversation/loggerCli.test.ts":
|
|
582
|
+
"sets PI_SESSIONS_DIR to a directory holding a fresh session and proves maintenance imports nothing from it",
|
|
583
|
+
},
|
|
584
|
+
},
|
|
548
585
|
];
|
|
549
586
|
|
|
550
587
|
export interface SurfaceProblem {
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// for every prompt and the logger decides), a missing session id still fails
|
|
12
12
|
// loud, and every failure is still non-fatal to the user's turn.
|
|
13
13
|
|
|
14
|
+
import { runtimeReadsPromptContext } from "#conversation/runtimePolicy.ts";
|
|
14
15
|
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
15
16
|
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
16
17
|
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
@@ -124,6 +125,11 @@ export async function claudeSessionEnd(stdin: string): Promise<number> {
|
|
|
124
125
|
*/
|
|
125
126
|
export async function claudeInject(stdin: string): Promise<number> {
|
|
126
127
|
const payload = parseHookPayload(stdin);
|
|
128
|
+
// CR143: on a runtime that discards what this hook returns (Grok), neither
|
|
129
|
+
// case is worth its load — nor the state read below, nor the "injected"
|
|
130
|
+
// mark, which would say the model received what it did not. Detected the
|
|
131
|
+
// way the sibling hooks detect it, from the payload and the environment.
|
|
132
|
+
if (!runtimeReadsPromptContext(detectHookRuntime(payload.raw))) return 0;
|
|
127
133
|
const sessionId = payload.sessionId;
|
|
128
134
|
|
|
129
135
|
// Case 1: the explicit invocation.
|
package/ts/src/hooks/main.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// why this file must exit 0: the wrappers read any other exit as Node failing.
|
|
36
36
|
|
|
37
37
|
import { pathToFileURL } from "node:url";
|
|
38
|
+
import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
|
|
38
39
|
import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
|
|
39
40
|
import { agyPreInvocation, agySessionStart, agyStop } from "./agy.ts";
|
|
40
41
|
import { claudeHook } from "./claude.ts";
|
|
@@ -77,8 +78,18 @@ async function dispatch(name: string): Promise<number> {
|
|
|
77
78
|
*/
|
|
78
79
|
export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
79
80
|
silenceExperimentalWarnings();
|
|
80
|
-
loadConfiguration({ entryPath: import.meta.filename });
|
|
81
81
|
const name = rawArgv[0] ?? "";
|
|
82
|
+
// The try/catch below is the last line of defense, and it has one hole: a
|
|
83
|
+
// stream's `error` event is not a throw in that `await` chain. A runtime
|
|
84
|
+
// that gives up on a hook closes its pipe, and until CR148 the hook answered
|
|
85
|
+
// the next write with an unhandled EPIPE, a stack trace into the host's log,
|
|
86
|
+
// and exit 1 -- the one exit code this entry promises never to return. One
|
|
87
|
+
// line in `hooks.log`, then the quiet exit the contract requires.
|
|
88
|
+
exitQuietlyOnBrokenPipe((code) => {
|
|
89
|
+
noteHookFailure(name || "hooks", "stdout closed early");
|
|
90
|
+
process.exit(code);
|
|
91
|
+
});
|
|
92
|
+
loadConfiguration({ entryPath: import.meta.filename });
|
|
82
93
|
try {
|
|
83
94
|
return await dispatch(name);
|
|
84
95
|
} catch (error) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A closed stdout or stderr ends the command quietly (CR148).
|
|
2
|
+
//
|
|
3
|
+
// `mirror ext <id> | head -1` is a shell idiom, not an error: `head`, `tail`,
|
|
4
|
+
// a pager that quit, a `grep -q` that matched -- every one of them closes its
|
|
5
|
+
// read end on purpose. Node ignores SIGPIPE and surfaces the next write as an
|
|
6
|
+
// `error` event on the stream, and with nobody listening that event is an
|
|
7
|
+
// uncaught exception: `Unhandled 'error' event`, `Error: write EPIPE`, a
|
|
8
|
+
// stack, exit 1. Nothing of ours failed; the reader asked us to stop.
|
|
9
|
+
//
|
|
10
|
+
// One listener at each entry (`frontDoor/cli.ts`, `hooks/main.ts`), nothing
|
|
11
|
+
// per route. On EPIPE: exit 0 and write nothing. The exit status follows
|
|
12
|
+
// `npm`, `cargo`, and `kubectl`, not git's 141: under `set -o pipefail`, 141
|
|
13
|
+
// fails a pipeline the person wrote on purpose, and the fix they reach for is
|
|
14
|
+
// `|| true`, which then hides the real failures too.
|
|
15
|
+
//
|
|
16
|
+
// Stop means stop. The exit happens at the next write after the reader
|
|
17
|
+
// closes, and work the command had not done by then -- a commit after a
|
|
18
|
+
// progress line, a log after an `await` -- is abandoned, exactly as SIGPIPE
|
|
19
|
+
// abandons git's. That is the shell's contract, recorded in the CR.
|
|
20
|
+
//
|
|
21
|
+
// Anything that is not EPIPE is rethrown: a real stream failure keeps the
|
|
22
|
+
// trace it had before. On Linux pipes are synchronous and on macOS
|
|
23
|
+
// asynchronous; in both the failure arrives as this event, never as a throw
|
|
24
|
+
// from `write()`. libuv maps ERROR_BROKEN_PIPE to EPIPE on Windows.
|
|
25
|
+
|
|
26
|
+
type Exit = (code: number) => void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install the handler. `exit` is `process.exit` for the CLI; the hook passes
|
|
30
|
+
* one that leaves a line in `hooks.log` first, because the hook's promise
|
|
31
|
+
* that every path returns 0 cannot be kept by its try/catch -- a stream's
|
|
32
|
+
* `error` event is not a throw in that `await` chain.
|
|
33
|
+
*
|
|
34
|
+
* Called once per process by `runAsEntry`; no guard against a second call,
|
|
35
|
+
* because there is no second call, and no test runs an entry in-process.
|
|
36
|
+
*/
|
|
37
|
+
export function exitQuietlyOnBrokenPipe(
|
|
38
|
+
exit: Exit,
|
|
39
|
+
streams: readonly NodeJS.WritableStream[] = [process.stdout, process.stderr],
|
|
40
|
+
): void {
|
|
41
|
+
let exiting = false;
|
|
42
|
+
for (const stream of streams) {
|
|
43
|
+
stream.on("error", (error: NodeJS.ErrnoException) => {
|
|
44
|
+
if (error.code !== "EPIPE") throw error;
|
|
45
|
+
// Both streams on one pipe (`2>&1 | head`) fail together. A real
|
|
46
|
+
// `process.exit` never returns; an injected one must still count once.
|
|
47
|
+
if (exiting) return;
|
|
48
|
+
exiting = true;
|
|
49
|
+
exit(0);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -25,19 +25,28 @@
|
|
|
25
25
|
// inconclusive without a key; the live fetch belongs to DS8.
|
|
26
26
|
|
|
27
27
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join } from "node:path";
|
|
29
29
|
import { openDatabaseReadOnly } from "#db/database.ts";
|
|
30
|
+
import {
|
|
31
|
+
type ExtensionPluginReport,
|
|
32
|
+
extensionPluginDirectory,
|
|
33
|
+
extensionPluginRoot,
|
|
34
|
+
readExtensionPlugin,
|
|
35
|
+
} from "#extensions/extensionPlugin.ts";
|
|
30
36
|
import { identityKeyBreaksGrammar, SLUG_KEYED_LAYERS } from "#identity/identityKey.ts";
|
|
31
37
|
import { pyRepr, sortByCodePoint } from "#util/pythonText.ts";
|
|
32
38
|
import { configFilePath } from "./config.ts";
|
|
39
|
+
import { extensionPluginStates } from "./extensionPluginWiring.ts";
|
|
33
40
|
import type { GitWorktreeEntry } from "./git.ts";
|
|
34
41
|
import {
|
|
35
|
-
claudePluginLinkState,
|
|
36
42
|
codexPluginCacheState,
|
|
43
|
+
grokPluginCopyState,
|
|
37
44
|
installedPluginVersion,
|
|
45
|
+
pluginLinkState,
|
|
38
46
|
} from "./pluginWiring.ts";
|
|
47
|
+
|
|
39
48
|
import type { RuntimeStatusReport } from "./status.ts";
|
|
40
|
-
import { CODEX_PLUGIN_ADD_COMMAND } from "./wiring.ts";
|
|
49
|
+
import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "./wiring.ts";
|
|
41
50
|
|
|
42
51
|
export interface DriftFinding {
|
|
43
52
|
code: string;
|
|
@@ -504,16 +513,19 @@ export function diagnoseRuntime(
|
|
|
504
513
|
}
|
|
505
514
|
|
|
506
515
|
/**
|
|
507
|
-
* The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried
|
|
508
|
-
*
|
|
509
|
-
* behind; Claude Code links it in place, so its failure mode is a
|
|
510
|
-
* link
|
|
511
|
-
*
|
|
516
|
+
* The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried; Grok in
|
|
517
|
+
* CV21.DS10.US1). Codex and Grok copy the plugin, so an npm update leaves
|
|
518
|
+
* the copy behind; Claude Code links it in place, so its failure mode is a
|
|
519
|
+
* dangling link, and Grok's rules link fails the same way. Grok adds one
|
|
520
|
+
* failure of its own: a respelt install path keys a SECOND copy, and Grok
|
|
521
|
+
* loads one of the two without saying which. Absence is not drift: no
|
|
522
|
+
* cache, no registry, and no link fire nothing, so an unwired machine stays
|
|
523
|
+
* green.
|
|
512
524
|
*
|
|
513
525
|
* The defaulting happens HERE, from the env the caller hands in — CODEX_HOME
|
|
514
|
-
* else ~/.codex, and ~/.claude — never inside the
|
|
515
|
-
* test is hermetic on a maintainer's machine with
|
|
516
|
-
* (CR123/128/137/142).
|
|
526
|
+
* else ~/.codex, GROK_HOME else ~/.grok, and ~/.claude — never inside the
|
|
527
|
+
* readers, so every reader test is hermetic on a maintainer's machine with
|
|
528
|
+
* real wiring (CR123/128/137/142).
|
|
517
529
|
*/
|
|
518
530
|
export function pluginWiringFindings(
|
|
519
531
|
installRoot: string | null,
|
|
@@ -539,7 +551,7 @@ export function pluginWiringFindings(
|
|
|
539
551
|
}
|
|
540
552
|
|
|
541
553
|
if (home) {
|
|
542
|
-
const link =
|
|
554
|
+
const link = pluginLinkState(join(home, ".claude", "skills", "mirror-mind"));
|
|
543
555
|
if (link.state === "dangling") {
|
|
544
556
|
findings.push({
|
|
545
557
|
code: "claude_plugin_link_dangling",
|
|
@@ -547,7 +559,50 @@ export function pluginWiringFindings(
|
|
|
547
559
|
subject: link.linkPath,
|
|
548
560
|
detail: `the link points at ${link.target ?? "an unreadable target"}, which cannot be read`,
|
|
549
561
|
recommendation:
|
|
550
|
-
'relink it — `ln -s "<pkg>/plugins/mirror-mind" ~/.claude/skills/mirror-mind` — or remove it if Claude Code is no longer wired',
|
|
562
|
+
'relink it — `rm ~/.claude/skills/mirror-mind && ln -s "<pkg>/plugins/mirror-mind" ~/.claude/skills/mirror-mind` — or remove it if Claude Code is no longer wired',
|
|
563
|
+
repair_route: "relink or remove",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const grokHome = env.GROK_HOME?.trim() || (home ? join(home, ".grok") : null);
|
|
569
|
+
if (grokHome && installedVersion && installRoot) {
|
|
570
|
+
const copy = grokPluginCopyState(grokHome, installedVersion);
|
|
571
|
+
const reinstall = grokPluginReinstallCommand(installRoot);
|
|
572
|
+
if (copy.copies.length > 1) {
|
|
573
|
+
findings.push({
|
|
574
|
+
code: "grok_plugin_copies_duplicated",
|
|
575
|
+
severity: "warning",
|
|
576
|
+
subject: copy.registryPath,
|
|
577
|
+
detail: `${copy.copies.length} copies of the plugin are installed (${copy.copies.map((c) => `${c.key} at ${c.version}`).join(", ")}); Grok loads one of them and does not say which`,
|
|
578
|
+
recommendation: `run \`grok plugin uninstall mirror-mind --confirm\` until \`grok plugin list\` shows none, then \`${reinstall}\` (a plugin installed from a differently spelt path is a second copy, not an update)`,
|
|
579
|
+
repair_route: reinstall,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (copy.state === "behind") {
|
|
583
|
+
findings.push({
|
|
584
|
+
code: "grok_plugin_copy_behind",
|
|
585
|
+
severity: "warning",
|
|
586
|
+
subject:
|
|
587
|
+
copy.copies.length === 1
|
|
588
|
+
? (copy.copies[0]?.path ?? copy.registryPath)
|
|
589
|
+
: copy.registryPath,
|
|
590
|
+
detail: `copied ${copy.newestCopied} < installed ${installedVersion}; Grok runs the copy it made at install`,
|
|
591
|
+
recommendation: `run \`${reinstall}\` to bring the copy level (\`grok plugin update\` leaves a local-path copy as it is, and an install over the same path is refused)`,
|
|
592
|
+
repair_route: reinstall,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (grokHome) {
|
|
597
|
+
const rules = pluginLinkState(join(grokHome, "rules", "mirror-mind.md"));
|
|
598
|
+
if (rules.state === "dangling") {
|
|
599
|
+
findings.push({
|
|
600
|
+
code: "grok_rules_link_dangling",
|
|
601
|
+
severity: "warning",
|
|
602
|
+
subject: rules.linkPath,
|
|
603
|
+
detail: `the link points at ${rules.target ?? "an unreadable target"}, which cannot be read; Grok loads no Operating Instructions and says nothing`,
|
|
604
|
+
recommendation:
|
|
605
|
+
'relink it — `ln -s "<pkg>/AGENTS.md" ~/.grok/rules/mirror-mind.md` — or remove it if Grok Build is no longer wired',
|
|
551
606
|
repair_route: "relink or remove",
|
|
552
607
|
});
|
|
553
608
|
}
|
|
@@ -798,3 +853,57 @@ export function hookNodeFindings(
|
|
|
798
853
|
},
|
|
799
854
|
];
|
|
800
855
|
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The extensions plugin findings (CV21.DS5.US2): the same reading the `extensions`
|
|
859
|
+
* verbs print, as drift. A home with no plugin, a runtime not wired, and a runtime
|
|
860
|
+
* off the PATH are silence; a dangling link, a stale copy (any version other than
|
|
861
|
+
* the home's), and two Grok copies are warnings, each naming its repair.
|
|
862
|
+
*/
|
|
863
|
+
export function extensionPluginFindings(
|
|
864
|
+
mirrorHome: string | null,
|
|
865
|
+
env: NodeJS.ProcessEnv,
|
|
866
|
+
isExecutable: (path: string) => boolean,
|
|
867
|
+
): DriftFinding[] {
|
|
868
|
+
if (mirrorHome === null) return [];
|
|
869
|
+
const previous = readExtensionPlugin(mirrorHome);
|
|
870
|
+
if (previous === null) return [];
|
|
871
|
+
const name = `mirror-ext-${basename(mirrorHome)}`;
|
|
872
|
+
const skills = Object.keys(previous.files).filter((path) => path.endsWith("/SKILL.md")).length;
|
|
873
|
+
const plugin: ExtensionPluginReport = {
|
|
874
|
+
name,
|
|
875
|
+
directory: extensionPluginDirectory(mirrorHome),
|
|
876
|
+
marketplaceRoot: extensionPluginRoot(mirrorHome),
|
|
877
|
+
version: `0.1.${previous.generation}`,
|
|
878
|
+
skills,
|
|
879
|
+
changed: false,
|
|
880
|
+
notCarried: [],
|
|
881
|
+
};
|
|
882
|
+
const findings: DriftFinding[] = [];
|
|
883
|
+
for (const state of extensionPluginStates(plugin, env, isExecutable)) {
|
|
884
|
+
if (state.state === "wired" || state.state === "not_wired") continue;
|
|
885
|
+
const code =
|
|
886
|
+
state.state === "dangling"
|
|
887
|
+
? "ext_plugin_claude_link_dangling"
|
|
888
|
+
: state.state === "duplicated"
|
|
889
|
+
? "ext_plugin_grok_copies_duplicated"
|
|
890
|
+
: state.runtime === "codex"
|
|
891
|
+
? "ext_plugin_codex_cache_stale"
|
|
892
|
+
: state.runtime === "antigravity"
|
|
893
|
+
? "ext_plugin_agy_copy_stale"
|
|
894
|
+
: "ext_plugin_grok_copy_stale";
|
|
895
|
+
const repair = state.steps.join("; then ");
|
|
896
|
+
findings.push({
|
|
897
|
+
code,
|
|
898
|
+
severity: "warning",
|
|
899
|
+
subject: state.subject,
|
|
900
|
+
detail:
|
|
901
|
+
state.state === "stale"
|
|
902
|
+
? `${state.detail}, the home generates ${plugin.version}; ${state.title} runs the copy it made at install`
|
|
903
|
+
: state.detail,
|
|
904
|
+
recommendation: `run \`${repair}\` to bring ${state.title}'s copy of ${plugin.name} level`,
|
|
905
|
+
repair_route: repair,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return findings;
|
|
909
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// CV21.DS5.US2 — what each runtime holds of a home's extensions plugin, read from
|
|
2
|
+
// the runtime's own files, and the lines a person reads about it.
|
|
3
|
+
//
|
|
4
|
+
// One reading serves two surfaces. `mirror extensions install|uninstall|sync` print
|
|
5
|
+
// the block after their report: the runtimes on the PATH that still need a step, or
|
|
6
|
+
// one line saying the plugin is wired everywhere. `mirror runtime diagnose` turns the
|
|
7
|
+
// same reading into findings. The two cannot disagree because they do not read twice.
|
|
8
|
+
//
|
|
9
|
+
// Every home is taken from the env the caller hands in (HOME, CODEX_HOME,
|
|
10
|
+
// GROK_HOME), never from the process: a test or a route passes stand-ins and a
|
|
11
|
+
// maintainer's real wiring never reaches a fixture (CR123/128/137/142).
|
|
12
|
+
//
|
|
13
|
+
// For this plugin, stale is NOT EQUAL, not "less than": the home generates every
|
|
14
|
+
// version, so a copy at a version the home does not hold is stale whichever way it
|
|
15
|
+
// differs. A `runtime/plugin/` removed by hand restarts the generation at 1, and a
|
|
16
|
+
// copy that then reads "ahead" must still fire (plan amendment 5).
|
|
17
|
+
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import type { ExtensionPluginReport } from "#extensions/extensionPlugin.ts";
|
|
20
|
+
import {
|
|
21
|
+
agyPluginCopyState,
|
|
22
|
+
codexPluginCacheState,
|
|
23
|
+
grokPluginCopyState,
|
|
24
|
+
pluginLinkState,
|
|
25
|
+
} from "./pluginWiring.ts";
|
|
26
|
+
import {
|
|
27
|
+
detectRuntimes,
|
|
28
|
+
EXTENSION_PLUGIN_GROK_TRUST_NOTE,
|
|
29
|
+
type ExtensionPluginPaths,
|
|
30
|
+
extensionPluginReinstallCommand,
|
|
31
|
+
extensionPluginWiringSteps,
|
|
32
|
+
RUNTIME_WIRING,
|
|
33
|
+
type RuntimeName,
|
|
34
|
+
} from "./wiring.ts";
|
|
35
|
+
|
|
36
|
+
export type ExtensionPluginState = "not_wired" | "wired" | "stale" | "dangling" | "duplicated";
|
|
37
|
+
|
|
38
|
+
export interface ExtensionPluginRuntimeState {
|
|
39
|
+
readonly runtime: RuntimeName;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly state: ExtensionPluginState;
|
|
42
|
+
/** What the runtime holds: `copy at 0.1.2`, `2 copies`, `link dangling`. */
|
|
43
|
+
readonly detail: string;
|
|
44
|
+
/** The path the finding names. */
|
|
45
|
+
readonly subject: string;
|
|
46
|
+
/** The commands that bring the runtime level, in order; empty when wired. */
|
|
47
|
+
readonly steps: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExtensionPluginHomes {
|
|
51
|
+
readonly home: string | null;
|
|
52
|
+
readonly codexHome: string | null;
|
|
53
|
+
readonly grokHome: string | null;
|
|
54
|
+
readonly geminiHome: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The homes, defaulted here from the env and never inside a reader. */
|
|
58
|
+
export function extensionPluginHomes(env: NodeJS.ProcessEnv): ExtensionPluginHomes {
|
|
59
|
+
const home = env.HOME?.trim() || null;
|
|
60
|
+
return {
|
|
61
|
+
home,
|
|
62
|
+
codexHome: env.CODEX_HOME?.trim() || (home ? join(home, ".codex") : null),
|
|
63
|
+
grokHome: env.GROK_HOME?.trim() || (home ? join(home, ".grok") : null),
|
|
64
|
+
geminiHome: home ? join(home, ".gemini") : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function extensionPluginPaths(plugin: ExtensionPluginReport): ExtensionPluginPaths {
|
|
69
|
+
return { dir: plugin.directory, marketplace: plugin.marketplaceRoot, name: plugin.name };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One runtime's reading. Pi takes extensions through its catalog and is never read. */
|
|
73
|
+
export function extensionPluginRuntimeState(
|
|
74
|
+
runtime: RuntimeName,
|
|
75
|
+
plugin: ExtensionPluginReport,
|
|
76
|
+
homes: ExtensionPluginHomes,
|
|
77
|
+
): ExtensionPluginRuntimeState | null {
|
|
78
|
+
const paths = extensionPluginPaths(plugin);
|
|
79
|
+
const title = RUNTIME_WIRING[runtime].title;
|
|
80
|
+
const wire = extensionPluginWiringSteps(runtime, paths);
|
|
81
|
+
const reinstall = extensionPluginReinstallCommand(runtime, paths);
|
|
82
|
+
const notWired = (subject: string): ExtensionPluginRuntimeState => ({
|
|
83
|
+
runtime,
|
|
84
|
+
title,
|
|
85
|
+
state: "not_wired",
|
|
86
|
+
detail: "not wired",
|
|
87
|
+
subject,
|
|
88
|
+
steps: wire,
|
|
89
|
+
});
|
|
90
|
+
const stale = (subject: string, held: string): ExtensionPluginRuntimeState => ({
|
|
91
|
+
runtime,
|
|
92
|
+
title,
|
|
93
|
+
state: "stale",
|
|
94
|
+
detail: `copy at ${held}`,
|
|
95
|
+
subject,
|
|
96
|
+
steps: reinstall === null ? [] : [reinstall],
|
|
97
|
+
});
|
|
98
|
+
const wired = (subject: string): ExtensionPluginRuntimeState => ({
|
|
99
|
+
runtime,
|
|
100
|
+
title,
|
|
101
|
+
state: "wired",
|
|
102
|
+
detail: "wired",
|
|
103
|
+
subject,
|
|
104
|
+
steps: [],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
switch (runtime) {
|
|
108
|
+
case "pi":
|
|
109
|
+
return null;
|
|
110
|
+
case "claude": {
|
|
111
|
+
if (homes.home === null) return null;
|
|
112
|
+
const link = pluginLinkState(join(homes.home, ".claude", "skills", plugin.name));
|
|
113
|
+
if (link.state === "absent") return notWired(link.linkPath);
|
|
114
|
+
if (link.state === "dangling") {
|
|
115
|
+
return {
|
|
116
|
+
runtime,
|
|
117
|
+
title,
|
|
118
|
+
state: "dangling",
|
|
119
|
+
detail: `link dangling (points at ${link.target ?? "an unreadable target"})`,
|
|
120
|
+
subject: link.linkPath,
|
|
121
|
+
steps: [
|
|
122
|
+
`rm ~/.claude/skills/${plugin.name} && ln -s "${paths.dir}" ~/.claude/skills/${plugin.name}`,
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return wired(link.linkPath);
|
|
127
|
+
}
|
|
128
|
+
case "codex": {
|
|
129
|
+
if (homes.codexHome === null) return null;
|
|
130
|
+
const cache = codexPluginCacheState(homes.codexHome, plugin.version, {
|
|
131
|
+
marketplace: plugin.name,
|
|
132
|
+
name: plugin.name,
|
|
133
|
+
});
|
|
134
|
+
if (cache.state === "absent") return notWired(cache.cachePath);
|
|
135
|
+
if (cache.state === "current") return wired(cache.cachePath);
|
|
136
|
+
return stale(cache.cachePath, cache.newestCached ?? "?");
|
|
137
|
+
}
|
|
138
|
+
case "antigravity": {
|
|
139
|
+
if (homes.geminiHome === null) return null;
|
|
140
|
+
const copy = agyPluginCopyState(homes.geminiHome, plugin.version, plugin.name);
|
|
141
|
+
if (copy.state === "absent") return notWired(copy.copyPath);
|
|
142
|
+
if (copy.state === "current") return wired(copy.copyPath);
|
|
143
|
+
return stale(copy.copyPath, copy.copiedVersion ?? "?");
|
|
144
|
+
}
|
|
145
|
+
case "grok": {
|
|
146
|
+
if (homes.grokHome === null) return null;
|
|
147
|
+
const copy = grokPluginCopyState(homes.grokHome, plugin.version, plugin.name);
|
|
148
|
+
if (copy.copies.length > 1) {
|
|
149
|
+
return {
|
|
150
|
+
runtime,
|
|
151
|
+
title,
|
|
152
|
+
state: "duplicated",
|
|
153
|
+
detail: `${copy.copies.length} copies (${copy.copies.map((c) => `${c.key} at ${c.version}`).join(", ")}); Grok loads one and does not say which`,
|
|
154
|
+
subject: copy.registryPath,
|
|
155
|
+
steps: [
|
|
156
|
+
`grok plugin uninstall ${plugin.name} --confirm (until \`grok plugin list\` shows none)`,
|
|
157
|
+
...(reinstall === null ? [] : [reinstall]),
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (copy.state === "absent") return notWired(copy.registryPath);
|
|
162
|
+
const subject = copy.copies[0]?.path ?? copy.registryPath;
|
|
163
|
+
if (copy.state === "current") return wired(subject);
|
|
164
|
+
return stale(subject, copy.newestCopied ?? "?");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The readings for the runtimes on the PATH, in display order; Pi never appears. */
|
|
170
|
+
export function extensionPluginStates(
|
|
171
|
+
plugin: ExtensionPluginReport,
|
|
172
|
+
env: NodeJS.ProcessEnv,
|
|
173
|
+
isExecutable: (path: string) => boolean,
|
|
174
|
+
): ExtensionPluginRuntimeState[] {
|
|
175
|
+
const homes = extensionPluginHomes(env);
|
|
176
|
+
const states: ExtensionPluginRuntimeState[] = [];
|
|
177
|
+
for (const runtime of detectRuntimes(env, isExecutable)) {
|
|
178
|
+
const state = extensionPluginRuntimeState(runtime, plugin, homes);
|
|
179
|
+
if (state !== null) states.push(state);
|
|
180
|
+
}
|
|
181
|
+
return states;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The block a person reads after an `extensions` report: what needs action and
|
|
186
|
+
* nothing else (plan amendment 18); after an uninstall, a stale copy "still has"
|
|
187
|
+
* what was removed (amendment 19). With no runtime on the PATH, the first line alone.
|
|
188
|
+
*/
|
|
189
|
+
export function renderExtensionPluginBlock(
|
|
190
|
+
plugin: ExtensionPluginReport,
|
|
191
|
+
states: readonly ExtensionPluginRuntimeState[],
|
|
192
|
+
removed: readonly string[] = [],
|
|
193
|
+
): string[] {
|
|
194
|
+
const skills = `${plugin.skills} skill${plugin.skills === 1 ? "" : "s"}`;
|
|
195
|
+
// An installed extension with no `claude` block is off the four runtimes, and the
|
|
196
|
+
// person is told which, not left to count (plan amendment 6 of the Debt Review).
|
|
197
|
+
const notCarried =
|
|
198
|
+
plugin.notCarried.length === 0
|
|
199
|
+
? []
|
|
200
|
+
: [` not carried (no claude block in skill.yaml): ${plugin.notCarried.join(", ")}`];
|
|
201
|
+
const actionable = states.filter((state) => state.state !== "wired");
|
|
202
|
+
if (states.length > 0 && actionable.length === 0) {
|
|
203
|
+
return [
|
|
204
|
+
` plugin: ${plugin.name} ${plugin.version}, ${skills}, wired on ${states.map((s) => s.title).join(", ")}`,
|
|
205
|
+
...notCarried,
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
const lines = [` plugin: ${plugin.name} ${plugin.version}, ${skills}`, ...notCarried];
|
|
209
|
+
for (const state of actionable) {
|
|
210
|
+
const detail =
|
|
211
|
+
state.state === "stale" && removed.length > 0
|
|
212
|
+
? `still has ${removed.join(", ")}`
|
|
213
|
+
: state.detail;
|
|
214
|
+
const [first, ...rest] = state.steps;
|
|
215
|
+
lines.push(` ${state.title}: ${detail}${first ? ` — ${first}` : ""}`);
|
|
216
|
+
for (const step of rest) lines.push(` ${" ".repeat(state.title.length + 2)}then ${step}`);
|
|
217
|
+
if (state.runtime === "grok" && state.state === "not_wired") {
|
|
218
|
+
lines.push(` ${" ".repeat(state.title.length + 2)}${EXTENSION_PLUGIN_GROK_TRUST_NOTE}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|