mirror-mind 0.35.0 → 0.36.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.
@@ -0,0 +1,147 @@
1
+ // An extension's installation settings: `<mirror-home>/extensions/<id>.env` (CR145).
2
+ //
3
+ // The `mirror-cli-v1` and `mirror-context-v1` contracts hand a spawned
4
+ // extension the environment the `mirror` process had, and nothing else. That
5
+ // left an extension's own installation settings -- which host, which TLS mode,
6
+ // which certificate on this machine -- with nowhere to live but the login
7
+ // shell, where two of them (`PGSSLMODE`, `NODE_EXTRA_CA_CERTS`) broke every
8
+ // other Postgres client and Node process on the machine. Node reads
9
+ // `NODE_EXTRA_CA_CERTS` before any extension code runs, so only the spawner can
10
+ // set it, and the spawner is Mirror.
11
+ //
12
+ // The file sits BESIDE the installed directory, not inside it, for two reasons
13
+ // measured on the maintainer's machine: `extensions install` rewrites the
14
+ // directory, and in a development home the directory is a symlink into the
15
+ // extension's own git repository, where a `.env` would be one `git add .` away
16
+ // from a push.
17
+ //
18
+ // Precedence is the ecosystem's and Mirror's own (`runtime/config.ts`): a
19
+ // variable already in the environment wins, the file fills the gaps, and the
20
+ // five `MIRROR_*` names the dispatcher owns can never come from the file. The
21
+ // merged environment reaches the one spawned process; `process.env` is read,
22
+ // never written.
23
+
24
+ import { readFileSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { parseEnv } from "node:util";
27
+ import { extensionsRootForHome } from "./catalog.ts";
28
+ import { SKILL_ID_RE } from "./manifest.ts";
29
+
30
+ /**
31
+ * The names the dispatcher sets last for a subcommand, and that a provider
32
+ * receives on stdin rather than in its environment. Dropped from the file at
33
+ * the merge, at both spawn points, so the file can steer neither.
34
+ */
35
+ export const RESERVED_SETTINGS_NAMES: readonly string[] = [
36
+ "MIRROR_HOME",
37
+ "MIRROR_DATABASE_PATH",
38
+ "MIRROR_EXTENSION_ID",
39
+ "MIRROR_EXTENSION_ROOT",
40
+ "MIRROR_TABLE_PREFIX",
41
+ ];
42
+
43
+ export type ExtensionSettings =
44
+ | { kind: "absent"; path: string }
45
+ | {
46
+ kind: "read";
47
+ path: string;
48
+ /** Sorted by name, as `parseEnv` yields them; a duplicate key's last value. */
49
+ variables: Record<string, string>;
50
+ /** The mode, when group or others can read the file; null when only the owner can. */
51
+ exposedMode: string | null;
52
+ }
53
+ | { kind: "unreadable"; path: string; reason: string };
54
+
55
+ export function extensionSettingsPath(mirrorHome: string, extensionId: string): string {
56
+ return join(extensionsRootForHome(mirrorHome), `${extensionId}.env`);
57
+ }
58
+
59
+ /**
60
+ * Read the settings file, as a result and never a throw.
61
+ *
62
+ * An id outside the manifest's grammar is `unreadable` before any path is
63
+ * built: the context runtime takes ids from `_ext_bindings` rows, and a path
64
+ * must not be steerable by database content. "Unreadable" is the fail-closed
65
+ * answer at both call sites -- the dispatcher refuses, the provider reports.
66
+ *
67
+ * A UTF-8 BOM is stripped before parsing: `parseEnv` would otherwise keep it
68
+ * in the first key's name, and the editor that wrote it never shows it.
69
+ */
70
+ export function readExtensionSettings(mirrorHome: string, extensionId: string): ExtensionSettings {
71
+ if (!SKILL_ID_RE.test(extensionId)) {
72
+ return {
73
+ kind: "unreadable",
74
+ path: join(extensionsRootForHome(mirrorHome), "<id>.env"),
75
+ reason: `'${extensionId}' is not a valid extension id`,
76
+ };
77
+ }
78
+ const path = extensionSettingsPath(mirrorHome, extensionId);
79
+ let text: string;
80
+ let mode: number;
81
+ try {
82
+ const stat = statSync(path, { throwIfNoEntry: false });
83
+ if (stat === undefined) return { kind: "absent", path };
84
+ mode = stat.mode;
85
+ text = readFileSync(path, "utf8");
86
+ } catch (error) {
87
+ const code = (error as NodeJS.ErrnoException).code;
88
+ return { kind: "unreadable", path, reason: code ?? (error as Error).message };
89
+ }
90
+ const variables: Record<string, string> = {};
91
+ for (const [key, value] of Object.entries(parseEnv(text.replace(/^\uFEFF/, "")))) {
92
+ if (value !== undefined) variables[key] = value;
93
+ }
94
+ return { kind: "read", path, variables, exposedMode: exposedModeOf(mode) };
95
+ }
96
+
97
+ /**
98
+ * The file's mode as `0644` when group or others can read it, else null.
99
+ * Mode bits mean nothing on Windows, where every file would read as exposed.
100
+ */
101
+ function exposedModeOf(mode: number): string | null {
102
+ if (process.platform === "win32") return null;
103
+ const permissions = mode & 0o777;
104
+ return (permissions & 0o044) === 0 ? null : `0${permissions.toString(8)}`;
105
+ }
106
+
107
+ /**
108
+ * The file UNDER the environment: `{ ...file, ...environment }`, with the
109
+ * reserved names removed from the file first. A key present in the
110
+ * environment -- even as an empty string -- is already set and wins, the rule
111
+ * `loadEnvFileWithoutOverride` applies to the home's own files.
112
+ */
113
+ export function settingsUnderEnvironment(
114
+ environment: NodeJS.ProcessEnv,
115
+ settings: ExtensionSettings,
116
+ ): NodeJS.ProcessEnv {
117
+ if (settings.kind !== "read") return { ...environment };
118
+ const fromFile: Record<string, string> = {};
119
+ for (const [key, value] of Object.entries(settings.variables)) {
120
+ if (RESERVED_SETTINGS_NAMES.includes(key)) continue;
121
+ fromFile[key] = value;
122
+ }
123
+ return { ...fromFile, ...environment };
124
+ }
125
+
126
+ /**
127
+ * The `ext <id>` listing's last line. Keys only, never a value: a key's name
128
+ * says a setting exists, which is what a person checking the file needs; its
129
+ * value is what must not end up in a transcript.
130
+ */
131
+ export function describeExtensionSettings(settings: ExtensionSettings): string {
132
+ switch (settings.kind) {
133
+ case "absent":
134
+ return `settings: ${settings.path} (not present — create it to pass settings to this extension)`;
135
+ case "unreadable":
136
+ return `settings: ${settings.path} (unreadable: ${settings.reason})`;
137
+ case "read": {
138
+ const keys = Object.keys(settings.variables);
139
+ const state = keys.length === 0 ? "no variables" : keys.join(", ");
140
+ const exposure =
141
+ settings.exposedMode === null
142
+ ? ""
143
+ : `; mode ${settings.exposedMode}, readable by other users`;
144
+ return `settings: ${settings.path} (${state}${exposure})`;
145
+ }
146
+ }
147
+ }
@@ -19,7 +19,7 @@
19
19
  // so neither a `bin` shim nor a skill line needs `--env-file` or
20
20
  // `NODE_OPTIONS=--no-warnings`. Importing this module does neither.
21
21
 
22
- import { existsSync, readFileSync } from "node:fs";
22
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
23
23
  import { basename, dirname } from "node:path";
24
24
  import { pathToFileURL } from "node:url";
25
25
  import {
@@ -68,6 +68,7 @@ import {
68
68
  JOURNAL_TRANSPORT,
69
69
  WEEK_PLAN_TRANSPORT,
70
70
  } from "#providers/transport.ts";
71
+ import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
71
72
  import {
72
73
  configFilePath,
73
74
  loadConfiguration,
@@ -769,6 +770,17 @@ async function runExtensionCatalog(argv: readonly string[]): Promise<DispatchOut
769
770
  deps: { nowIso: () => pythonUtcIsoformat() },
770
771
  withReadOnlyDatabase: (run) => withMirrorReadDb(argv, run),
771
772
  withWritableDatabase: (run) => withMirrorWriteDb(argv, run),
773
+ // CV21.DS5.US2: the extensions-plugin readers take their homes and the
774
+ // PATH from the process environment here, and from nowhere else.
775
+ environment: process.env,
776
+ isExecutable: (path) => {
777
+ try {
778
+ accessSync(path, constants.X_OK);
779
+ return true;
780
+ } catch {
781
+ return false;
782
+ }
783
+ },
772
784
  });
773
785
  }
774
786
 
@@ -1871,6 +1883,9 @@ export async function main(rawArgv = process.argv.slice(2)): Promise<number> {
1871
1883
  */
1872
1884
  export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
1873
1885
  silenceExperimentalWarnings();
1886
+ // Before anything that could write (CR148): `mirror … | head -1` ends
1887
+ // quietly, exit 0, instead of an unhandled EPIPE and a stack trace.
1888
+ exitQuietlyOnBrokenPipe(process.exit);
1874
1889
  loadConfiguration({ entryPath: import.meta.filename });
1875
1890
  return main(rawArgv);
1876
1891
  }
@@ -62,6 +62,8 @@ export interface ExtensionRouteDeps {
62
62
  run: (db: WritableDatabase) => Promise<number> | number,
63
63
  ) => Promise<number>;
64
64
  environment?: NodeJS.ProcessEnv;
65
+ /** Whether a PATH candidate is executable, for the extensions-plugin block (CV21.DS5.US2). */
66
+ isExecutable?: (path: string) => boolean;
65
67
  }
66
68
 
67
69
  /** The `extensions` verbs that need a writable handle; the rest read. */
@@ -140,7 +142,11 @@ function firstPositional(args: readonly string[]): string | null {
140
142
  }
141
143
 
142
144
  function readContextFor(route: ExtensionRouteDeps): CatalogContext {
143
- return { mirrorHome: route.mirrorHome };
145
+ return {
146
+ mirrorHome: route.mirrorHome,
147
+ ...(route.environment ? { env: route.environment } : {}),
148
+ ...(route.isExecutable ? { isExecutable: route.isExecutable } : {}),
149
+ };
144
150
  }
145
151
 
146
152
  function dispatchOptions(route: ExtensionRouteDeps) {
@@ -151,7 +157,7 @@ function dispatchOptions(route: ExtensionRouteDeps) {
151
157
  }
152
158
 
153
159
  function writeContextFor(route: ExtensionRouteDeps, db: WritableDatabase): ExtWriteContext {
154
- return { mirrorHome: route.mirrorHome, db, deps: route.deps };
160
+ return { ...readContextFor(route), db, deps: route.deps };
155
161
  }
156
162
 
157
163
  export async function runExtensionCatalogRoute(
@@ -24,6 +24,7 @@ import {
24
24
  import { channelFor, channelForReport, renderChannel, setChannel } from "#runtime/channel.ts";
25
25
  import {
26
26
  diagnoseRuntime,
27
+ extensionPluginFindings,
27
28
  hookFailureFindings,
28
29
  hookNodeFindings,
29
30
  pluginWiringFindings,
@@ -336,6 +337,16 @@ export async function runRuntimeReadRoute(
336
337
  // after an npm update; Claude Code's link can dangle. Absence is not
337
338
  // drift, so an unwired machine stays green.
338
339
  ...pluginWiringFindings(installRoot, env),
340
+ // CV21.DS5.US2: the home's extensions plugin, as each runtime on the
341
+ // PATH holds it. A stale copy is any version other than the home's.
342
+ ...extensionPluginFindings(report.mirror_home, env, (path) => {
343
+ try {
344
+ accessSync(path, constants.X_OK);
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }),
339
350
  // No catalog provider until DS8: inconclusive, which is also the
340
351
  // oracle's answer when it cannot reach OpenRouter.
341
352
  ...(await probeModelPins(null, "")),
@@ -193,14 +193,31 @@ export function marketplaceJson(): string {
193
193
  // (DERIVED from the manifest's entry — one locator, every carrier), and
194
194
  // `rules/AGENTS.md` (agy merges `rules/` into the active rule set; the guard
195
195
  // comparing committed to generated bytes is the sync with `AGENTS.md`).
196
-
197
- /** The native-plugin marker. Claude Code and Codex read `.claude-plugin/`. */
198
- export function buildAgyPluginMarker(): Record<string, unknown> {
199
- return { name: PLUGIN_NAME, description: PLUGIN_DESCRIPTION };
196
+ //
197
+ // The marker is also what GROK reads (CV21.DS10.US1). Grok accepts a root
198
+ // `plugin.json` and the `.claude-plugin/` equivalent, and takes the root one
199
+ // first and alone: with a name-only marker, `grok plugin validate` dropped
200
+ // "MCP servers" from the components it had listed on 1.0.52, `plugin list`
201
+ // reported `version: null`, and no shape of install offered the MCP server
202
+ // (probe on grok 1.0.62, 2026-10-18, story package). So the marker carries
203
+ // the manifest's `version` and `mcpServers`, from the same source; agy 1.4.2
204
+ // warns `unknown field "mcpServers" is ignored` and stages the same tree.
205
+ // Hooks stay out of it: agy reads the root `hooks.json` beside it.
206
+
207
+ /** The native-plugin marker: agy's detection, and the manifest Grok reads. */
208
+ export function buildAgyPluginMarker(version: string): Record<string, unknown> {
209
+ return {
210
+ name: PLUGIN_NAME,
211
+ description: PLUGIN_DESCRIPTION,
212
+ version,
213
+ mcpServers: {
214
+ [PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
215
+ },
216
+ };
200
217
  }
201
218
 
202
- export function agyPluginMarkerJson(): string {
203
- return `${JSON.stringify(buildAgyPluginMarker(), null, 2)}\n`;
219
+ export function agyPluginMarkerJson(version: string): string {
220
+ return `${JSON.stringify(buildAgyPluginMarker(version), null, 2)}\n`;
204
221
  }
205
222
 
206
223
  /**
@@ -335,7 +352,7 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
335
352
  },
336
353
  { relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
337
354
  // The agy-native layer (CV21.DS7.US1).
338
- { relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson() },
355
+ { relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson(version) },
339
356
  { relativePath: join(PLUGIN_DIR, "hooks.json"), content: agyHooksJson() },
340
357
  { relativePath: join(PLUGIN_DIR, "mcp_config.json"), content: agyMcpConfigJson() },
341
358
  {
@@ -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.
@@ -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). 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.
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 readers, so every reader
515
- * test is hermetic on a maintainer's machine with real wiring
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 = claudePluginLinkState(join(home, ".claude"));
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
+ }