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.
Files changed (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +3 -3
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.36.0.md +222 -0
  5. package/docs/releases/v0.36.1.md +137 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/plugins/mirror-mind/plugin.json +11 -1
  9. package/plugins/mirror-mind/rules/AGENTS.md +2 -2
  10. package/ts/src/conversation/loggerRuntime.ts +5 -18
  11. package/ts/src/conversation/runtimePolicy.ts +26 -3
  12. package/ts/src/conversation/sessionComposites.ts +5 -7
  13. package/ts/src/conversation/transcriptBackfill.ts +5 -1
  14. package/ts/src/db/migrations.ts +4 -7
  15. package/ts/src/extensions/bindings.ts +53 -2
  16. package/ts/src/extensions/catalogCommands.ts +82 -23
  17. package/ts/src/extensions/catalogWrites.ts +12 -0
  18. package/ts/src/extensions/contextRuntime.ts +36 -1
  19. package/ts/src/extensions/dispatch.ts +43 -10
  20. package/ts/src/extensions/extensionPlugin.ts +325 -0
  21. package/ts/src/extensions/manifest.ts +7 -1
  22. package/ts/src/extensions/settings.ts +147 -0
  23. package/ts/src/frontDoor/cli.ts +16 -1
  24. package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
  25. package/ts/src/frontDoor/runtimeRoute.ts +11 -0
  26. package/ts/src/guards/claudePlugin.ts +24 -7
  27. package/ts/src/guards/retiredSurfaces.ts +42 -5
  28. package/ts/src/hooks/claude.ts +6 -0
  29. package/ts/src/hooks/main.ts +12 -1
  30. package/ts/src/runtime/brokenPipe.ts +52 -0
  31. package/ts/src/runtime/diagnose.ts +122 -13
  32. package/ts/src/runtime/extensionPluginWiring.ts +222 -0
  33. package/ts/src/runtime/pluginWiring.ts +168 -29
  34. package/ts/src/runtime/status.ts +28 -1
  35. package/ts/src/runtime/update.ts +25 -11
  36. package/ts/src/runtime/wiring.ts +135 -3
  37. package/ts/src/conversation/backfill.ts +0 -271
  38. package/ts/src/conversation/sessionImport.ts +0 -90
@@ -1,15 +1,20 @@
1
- // The plugin wiring readers (CV21.DS4.TS6).
1
+ // The plugin wiring readers (CV21.DS4.TS6; Grok in CV21.DS10.US1).
2
2
  //
3
- // Two runtimes hold the plugin outside the install tree. Codex copies it into
4
- // a versioned cache (`$CODEX_HOME/plugins/cache/mirror-mind/mirror-mind/
3
+ // Three runtimes hold the plugin outside the install tree. Codex copies it
4
+ // into a versioned cache (`$CODEX_HOME/plugins/cache/mirror-mind/mirror-mind/
5
5
  // <version>`; CV21.DS4.US1), so an npm update changes the install but not
6
- // that copy — the one wiring that goes stale. Claude Code links the plugin in
7
- // place (`~/.claude/skills/mirror-mind`), so its failure mode is a dangling
8
- // link, not a stale copy.
6
+ // that copy. Grok copies it too, under `$GROK_HOME/installed-plugins/<name>-
7
+ // <hash of the source path string>/`, recorded in `installed-plugins/
8
+ // registry.json` (grok 1.0.62); its copy carries the plugin's own
9
+ // `.claude-plugin/plugin.json`, so the version reads the same way the install
10
+ // tree's does. Claude Code links the plugin in place (`~/.claude/skills/
11
+ // mirror-mind`), so its failure mode is a dangling link, not a stale copy —
12
+ // and Grok's rules link (`~/.grok/rules/mirror-mind.md`) fails the same way.
9
13
  //
10
- // Both readers take their roots as ARGUMENTS, always. The defaulting —
11
- // `$CODEX_HOME` else `~/.codex`, and the Claude home — happens once, at the
12
- // diagnose entry, from the env the caller already carries, never in here.
14
+ // Every reader takes its root as an ARGUMENT, always. The defaulting —
15
+ // `$CODEX_HOME` else `~/.codex`, `$GROK_HOME` else `~/.grok`, and the Claude
16
+ // home — happens once, at the diagnose entry, from the env the caller
17
+ // already carries, never in here.
13
18
  // That is the CR123/128/137/142 lesson: a maintainer's machine has a real
14
19
  // ~/.codex and a real ~/.claude, and a reader that consults the environment
15
20
  // makes every fixture test machine-dependent exactly where maintainers work.
@@ -25,13 +30,30 @@ import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from "no
25
30
  import { join } from "node:path";
26
31
  import { compareSemver, parseSemver } from "#util/semver.ts";
27
32
 
28
- /** Where Codex keeps its copy, below a Codex home. */
29
- export const CODEX_PLUGIN_CACHE_SEGMENTS: readonly string[] = [
30
- "plugins",
31
- "cache",
32
- "mirror-mind",
33
- "mirror-mind",
34
- ];
33
+ /**
34
+ * A plugin as Codex caches it: `plugins/cache/<marketplace>/<name>/<version>`.
35
+ * The canonical plugin is `mirror-mind/mirror-mind`; a home's extensions
36
+ * plugin is `mirror-ext-<user>/mirror-ext-<user>` (CV21.DS5.US2).
37
+ */
38
+ export interface CodexPluginIdentity {
39
+ readonly marketplace: string;
40
+ readonly name: string;
41
+ }
42
+
43
+ export const CANONICAL_CODEX_PLUGIN: CodexPluginIdentity = {
44
+ marketplace: "mirror-mind",
45
+ name: "mirror-mind",
46
+ };
47
+
48
+ /** Where Codex keeps a plugin's copies, below a Codex home. */
49
+ export function codexPluginCacheSegments(
50
+ identity: CodexPluginIdentity = CANONICAL_CODEX_PLUGIN,
51
+ ): readonly string[] {
52
+ return ["plugins", "cache", identity.marketplace, identity.name];
53
+ }
54
+
55
+ /** The canonical plugin's cache path segments, as before DS5.US2. */
56
+ export const CODEX_PLUGIN_CACHE_SEGMENTS: readonly string[] = codexPluginCacheSegments();
35
57
 
36
58
  export interface CodexCacheState {
37
59
  /** `absent` covers no cache and a cache with no version-named entry. */
@@ -46,6 +68,13 @@ export interface CodexCacheState {
46
68
  * names do not parse as versions (a partial download, an editor's leavings)
47
69
  * are ignored; a cache holding only those claims nothing and reads as absent.
48
70
  *
71
+ * `codex plugin add` REPLACES the cache directory (measured on 0.163.0,
72
+ * CV21.DS5.US2's Debt Review): after a re-add at 0.1.2 only `0.1.2` remains,
73
+ * and after a re-add at a lower 0.1.0 only `0.1.0`, which the prompt then
74
+ * lists. So the newest entry is the only entry, and a reader that compared a
75
+ * generation restarted below an older cache would still be reading what Codex
76
+ * loads.
77
+ *
49
78
  * `ahead` is a state, not a finding: `runtime update` refuses downgrades but
50
79
  * `npm install -g mirror-mind@old` does not ask, and the person who
51
80
  * downgraded on purpose needs no Codex advice from the older tree.
@@ -53,8 +82,9 @@ export interface CodexCacheState {
53
82
  export function codexPluginCacheState(
54
83
  codexHome: string,
55
84
  installedVersion: string,
85
+ identity: CodexPluginIdentity = CANONICAL_CODEX_PLUGIN,
56
86
  ): CodexCacheState {
57
- const cachePath = join(codexHome, ...CODEX_PLUGIN_CACHE_SEGMENTS);
87
+ const cachePath = join(codexHome, ...codexPluginCacheSegments(identity));
58
88
  let entries: string[];
59
89
  try {
60
90
  entries = readdirSync(cachePath);
@@ -77,8 +107,8 @@ export function codexPluginCacheState(
77
107
  return { state, cachePath, newestCached: newest };
78
108
  }
79
109
 
80
- export interface ClaudeLinkState {
81
- /** `healthy` means the target reads — including a plain directory copy. */
110
+ export interface LinkState {
111
+ /** `healthy` means the target reads — including a plain directory or file copy. */
82
112
  state: "absent" | "healthy" | "dangling";
83
113
  linkPath: string;
84
114
  /** The link's own target when it is a symlink, else null. */
@@ -86,13 +116,13 @@ export interface ClaudeLinkState {
86
116
  }
87
117
 
88
118
  /**
89
- * Read the Claude Code wiring below `claudeHome`. Healthy means the target
90
- * reads; a link that resolves to *another* install tree is deliberately
91
- * healthy too (TS6's documented silence) — chasing where it points is a
92
- * one-line amendment here if that case ever bites.
119
+ * Read one wiring link: Claude Code's plugin link (`~/.claude/skills/
120
+ * mirror-mind`) or Grok's rules link (`~/.grok/rules/mirror-mind.md`).
121
+ * Healthy means the target reads; a link that resolves to *another* install
122
+ * tree is deliberately healthy too (TS6's documented silence) — chasing
123
+ * where it points is a one-line amendment here if that case ever bites.
93
124
  */
94
- export function claudePluginLinkState(claudeHome: string): ClaudeLinkState {
95
- const linkPath = join(claudeHome, "skills", "mirror-mind");
125
+ export function pluginLinkState(linkPath: string): LinkState {
96
126
  let isLink = false;
97
127
  try {
98
128
  isLink = lstatSync(linkPath).isSymbolicLink();
@@ -108,6 +138,111 @@ export function claudePluginLinkState(claudeHome: string): ClaudeLinkState {
108
138
  return { state: "healthy", linkPath, target };
109
139
  }
110
140
 
141
+ /** Where Grok keeps its copies and their registry, below a Grok home. */
142
+ export const GROK_INSTALLED_PLUGINS_SEGMENTS: readonly string[] = ["installed-plugins"];
143
+
144
+ export interface GrokCopy {
145
+ /** The registry key: `<name>-<hash of the source path string>`. */
146
+ key: string;
147
+ path: string;
148
+ version: string;
149
+ }
150
+
151
+ export interface GrokCopyState {
152
+ /** `absent` covers no registry, no Mirror entry, and entries with no readable manifest. */
153
+ state: "absent" | "current" | "behind" | "ahead";
154
+ registryPath: string;
155
+ /** Every copy carrying the plugin, with its manifest version. */
156
+ copies: GrokCopy[];
157
+ /** The newest copy's version, by semver — never a string sort. */
158
+ newestCopied: string | null;
159
+ }
160
+
161
+ /**
162
+ * Read Grok's install registry below `grokHome`, read-only. The registry
163
+ * names each repo's `path` and the plugins it carries; a repo whose
164
+ * `plugins` include `mirror-mind` is a copy, and its version is the copy's
165
+ * own `.claude-plugin/plugin.json`, read by the same function the install
166
+ * tree uses. A registry that does not parse, or a copy with no manifest,
167
+ * claims nothing.
168
+ *
169
+ * The state is judged by the newest copy, as Codex's cache is. Two copies
170
+ * are a finding of their own at the diagnose entry: a respelt install path
171
+ * keys a second copy (probe, grok 1.0.62), and Grok loads one of them
172
+ * without saying which.
173
+ */
174
+ export function grokPluginCopyState(
175
+ grokHome: string,
176
+ installedVersion: string,
177
+ pluginName = "mirror-mind",
178
+ ): GrokCopyState {
179
+ const registryPath = join(grokHome, ...GROK_INSTALLED_PLUGINS_SEGMENTS, "registry.json");
180
+ const copies: GrokCopy[] = [];
181
+ let repos: Record<string, unknown>;
182
+ try {
183
+ const parsed: unknown = JSON.parse(readFileSync(registryPath, "utf8"));
184
+ const candidate =
185
+ typeof parsed === "object" && parsed !== null
186
+ ? (parsed as Record<string, unknown>).repos
187
+ : undefined;
188
+ if (typeof candidate !== "object" || candidate === null) {
189
+ return { state: "absent", registryPath, copies, newestCopied: null };
190
+ }
191
+ repos = candidate as Record<string, unknown>;
192
+ } catch {
193
+ return { state: "absent", registryPath, copies, newestCopied: null };
194
+ }
195
+ for (const [key, entry] of Object.entries(repos)) {
196
+ if (typeof entry !== "object" || entry === null) continue;
197
+ const repo = entry as Record<string, unknown>;
198
+ const plugins = repo.plugins;
199
+ if (typeof plugins !== "object" || plugins === null || !(pluginName in plugins)) continue;
200
+ const path =
201
+ typeof repo.path === "string"
202
+ ? repo.path
203
+ : join(grokHome, ...GROK_INSTALLED_PLUGINS_SEGMENTS, key);
204
+ const version = pluginManifestVersion(join(path, ".claude-plugin", "plugin.json"));
205
+ if (version === null) continue;
206
+ copies.push({ key, path, version });
207
+ }
208
+ if (copies.length === 0) return { state: "absent", registryPath, copies, newestCopied: null };
209
+ let newest: string | null = null;
210
+ for (const copy of copies) {
211
+ if (newest === null || compareSemver(copy.version, newest) > 0) newest = copy.version;
212
+ }
213
+ const order = compareSemver(newest as string, installedVersion);
214
+ const state = order < 0 ? "behind" : order > 0 ? "ahead" : "current";
215
+ return { state, registryPath, copies, newestCopied: newest };
216
+ }
217
+
218
+ export interface AgyCopyState {
219
+ /** `absent` covers no staged tree and a staged tree with no readable manifest. */
220
+ state: "absent" | "current" | "behind" | "ahead";
221
+ copyPath: string;
222
+ copiedVersion: string | null;
223
+ }
224
+
225
+ /**
226
+ * Read agy's staged copy of a plugin below a `.gemini` home, read-only:
227
+ * `agy plugin install <dir>` stages the tree under `config/plugins/<name>/`
228
+ * (CV21.DS7.US1), and the copy's own `.claude-plugin/plugin.json` carries
229
+ * its version. Written for the extensions plugin (CV21.DS5.US2); the
230
+ * canonical plugin has no agy finding yet, and this reader serves it when
231
+ * one is added.
232
+ */
233
+ export function agyPluginCopyState(
234
+ geminiHome: string,
235
+ installedVersion: string,
236
+ pluginName: string,
237
+ ): AgyCopyState {
238
+ const copyPath = join(geminiHome, "config", "plugins", pluginName);
239
+ const copiedVersion = pluginManifestVersion(join(copyPath, ".claude-plugin", "plugin.json"));
240
+ if (copiedVersion === null) return { state: "absent", copyPath, copiedVersion: null };
241
+ const order = compareSemver(copiedVersion, installedVersion);
242
+ const state = order < 0 ? "behind" : order > 0 ? "ahead" : "current";
243
+ return { state, copyPath, copiedVersion };
244
+ }
245
+
111
246
  /**
112
247
  * The install tree's own plugin version, from the generated manifest.
113
248
  * `plugins/mirror-mind/.claude-plugin/plugin.json` is written by
@@ -118,11 +253,15 @@ export function claudePluginLinkState(claudeHome: string): ClaudeLinkState {
118
253
  * but the tree it reads has already landed the new one.
119
254
  */
120
255
  export function installedPluginVersion(installRoot: string): string | null {
256
+ return pluginManifestVersion(
257
+ join(installRoot, "plugins", "mirror-mind", ".claude-plugin", "plugin.json"),
258
+ );
259
+ }
260
+
261
+ /** The `version` of one plugin manifest on disk, or null when it does not read. */
262
+ function pluginManifestVersion(manifestPath: string): string | null {
121
263
  try {
122
- const raw = readFileSync(
123
- join(installRoot, "plugins", "mirror-mind", ".claude-plugin", "plugin.json"),
124
- "utf8",
125
- );
264
+ const raw = readFileSync(manifestPath, "utf8");
126
265
  const parsed: unknown = JSON.parse(raw);
127
266
  const version =
128
267
  typeof parsed === "object" && parsed !== null
@@ -17,10 +17,11 @@
17
17
  // the 2026-09-07 decision assigned to CV22.DS10.
18
18
 
19
19
  import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
20
- import { join, resolve } from "node:path";
20
+ import { basename, join, resolve } from "node:path";
21
21
  import { type Database, openDatabaseReadOnly } from "#db/database.ts";
22
22
  import { KNOWN_MIGRATION_IDS } from "#db/schemaState.ts";
23
23
  import { ExtensionError } from "#extensions/errors.ts";
24
+ import { readExtensionPlugin } from "#extensions/extensionPlugin.ts";
24
25
  import { loadExtensionManifest } from "#extensions/manifest.ts";
25
26
  import { inspectMigrationFiles } from "#extensions/migrations.ts";
26
27
  import { type DbPathEnv, dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
@@ -61,6 +62,12 @@ export interface ExtensionHealth {
61
62
  unknown_migrations: string[];
62
63
  }
63
64
 
65
+ export interface ExtensionPluginSummary {
66
+ name: string;
67
+ version: string;
68
+ skills: number;
69
+ }
70
+
64
71
  export interface RuntimeStatusReport {
65
72
  version: string;
66
73
  /** How the running front door is installed (CV22.DS10.US3 plateau 4; US2 D3). */
@@ -73,6 +80,8 @@ export interface RuntimeStatusReport {
73
80
  core_migrations: CoreMigrationHealth;
74
81
  extensions: string[];
75
82
  extension_health: ExtensionHealth[];
83
+ /** The home's generated extensions plugin (CV21.DS5.US2), or null when none. */
84
+ extension_plugin: ExtensionPluginSummary | null;
76
85
  clone_role: MarkerValue;
77
86
  memory_env: string;
78
87
  update_channel: MarkerValue;
@@ -425,6 +434,7 @@ export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeSta
425
434
  core_migrations: inspectCoreMigrations(dbPath, dbExists),
426
435
  extensions: listInstalledExtensions(mirrorHome),
427
436
  extension_health: inspectExtensionHealth(mirrorHome, dbPath, dbExists),
437
+ extension_plugin: inspectExtensionPlugin(mirrorHome),
428
438
  clone_role: cloneRoleFor(install, root),
429
439
  memory_env: memoryEnv,
430
440
  update_channel: channelForReport(install, root, env, undefined, options.channel ?? null),
@@ -461,6 +471,18 @@ export function renderCoreMigrations(item: CoreMigrationHealth): string {
461
471
  return `Core migrations: attention needed (${detail})`;
462
472
  }
463
473
 
474
+ /** The generated extensions plugin, read from its manifest; absent is null, not an error. */
475
+ export function inspectExtensionPlugin(mirrorHome: string | null): ExtensionPluginSummary | null {
476
+ if (mirrorHome === null) return null;
477
+ const previous = readExtensionPlugin(mirrorHome);
478
+ if (previous === null) return null;
479
+ return {
480
+ name: `mirror-ext-${basename(mirrorHome)}`,
481
+ version: `0.1.${previous.generation}`,
482
+ skills: Object.keys(previous.files).filter((path) => path.endsWith("/SKILL.md")).length,
483
+ };
484
+ }
485
+
464
486
  /** Port of `_render_extension_health`. */
465
487
  export function renderExtensionHealth(items: readonly ExtensionHealth[]): string[] {
466
488
  if (items.length === 0) return ["Extension health: ready (0 checked)"];
@@ -528,6 +550,11 @@ export function renderRuntimeStatus(
528
550
  lines.push("Installed extensions: 0");
529
551
  }
530
552
  lines.push(...renderExtensionHealth(report.extension_health));
553
+ // Printed only when the home has one, so a report without extensions reads as before.
554
+ if (report.extension_plugin) {
555
+ const { name, version, skills } = report.extension_plugin;
556
+ lines.push(`Extensions plugin: ${name} ${version} (${skills} skill${skills === 1 ? "" : "s"})`);
557
+ }
531
558
  lines.push(...renderCloneRoleLines(report.install, report.clone_role));
532
559
  lines.push(`Update channel: ${report.update_channel.value}`);
533
560
  if (report.update_channel.note) lines.push(`Update channel note: ${report.update_channel.note}`);
@@ -29,7 +29,11 @@ import { spawnSync } from "node:child_process";
29
29
  import { join } from "node:path";
30
30
  import { verifyBackupArchive } from "#runtime/backup.ts";
31
31
  import type { InstallKind } from "#runtime/installKind.ts";
32
- import { codexPluginCacheState, installedPluginVersion } from "#runtime/pluginWiring.ts";
32
+ import {
33
+ codexPluginCacheState,
34
+ grokPluginCopyState,
35
+ installedPluginVersion,
36
+ } from "#runtime/pluginWiring.ts";
33
37
  import { cloneStrategy, defaultGitRunner, type GitRunner } from "#runtime/strategies/clone.ts";
34
38
  import { defaultNpmRunner, type NpmRunner, packageStrategy } from "#runtime/strategies/package.ts";
35
39
  import type { statusAllowsUpdatePreflight } from "#runtime/updateGate.ts";
@@ -39,7 +43,7 @@ import {
39
43
  type UpdateResult,
40
44
  type UpdateStage,
41
45
  } from "#runtime/updatePipeline.ts";
42
- import { CODEX_PLUGIN_ADD_COMMAND } from "#runtime/wiring.ts";
46
+ import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "#runtime/wiring.ts";
43
47
  import { commandOnPath } from "#util/paths.ts";
44
48
  import { PROGRAM } from "#util/program.ts";
45
49
  import { shellWord } from "#util/shellWord.ts";
@@ -431,15 +435,25 @@ export function postUpdateHints(
431
435
  : null;
432
436
  const home = env.HOME?.trim() || null;
433
437
  const codexHome = env.CODEX_HOME?.trim() || (home ? join(home, ".codex") : null);
434
- if (installRoot && codexHome) {
435
- const landed = installedPluginVersion(installRoot);
436
- if (landed) {
437
- const cache = codexPluginCacheState(codexHome, landed);
438
- if (cache.state === "behind") {
439
- hints.push(
440
- `Codex's cached plugin is behind the update (${cache.newestCached} < ${landed}): run \`${CODEX_PLUGIN_ADD_COMMAND}\` to bring it level.`,
441
- );
442
- }
438
+ const grokHome = env.GROK_HOME?.trim() || (home ? join(home, ".grok") : null);
439
+ const landed = installRoot ? installedPluginVersion(installRoot) : null;
440
+ if (installRoot && codexHome && landed) {
441
+ const cache = codexPluginCacheState(codexHome, landed);
442
+ if (cache.state === "behind") {
443
+ hints.push(
444
+ `Codex's cached plugin is behind the update (${cache.newestCached} < ${landed}): run \`${CODEX_PLUGIN_ADD_COMMAND}\` to bring it level.`,
445
+ );
446
+ }
447
+ }
448
+ // CV21.DS10.US1: Grok copies the plugin too, and its update verb leaves a
449
+ // local-path copy as it is; the level-up is uninstall, then install with
450
+ // the path init printed.
451
+ if (installRoot && grokHome && landed) {
452
+ const copy = grokPluginCopyState(grokHome, landed);
453
+ if (copy.state === "behind") {
454
+ hints.push(
455
+ `Grok's copy of the plugin is behind the update (${copy.newestCopied} < ${landed}): run \`${grokPluginReinstallCommand(installRoot)}\` to bring it level.`,
456
+ );
443
457
  }
444
458
  }
445
459
 
@@ -29,6 +29,18 @@
29
29
  // the shipped wrapper.
30
30
  // Antigravity `agy plugin install <plugin dir>` stages the plugin with its
31
31
  // agy-native layer in one step (CV21.DS7.US1, agy 1.4.2).
32
+ // Grok Build `grok plugin install <plugin dir> --trust` copies the plugin
33
+ // under `~/.grok/installed-plugins/` and enables it: its skills,
34
+ // the hooks, and the MCP server, with no prompt left to answer
35
+ // (CV21.DS10.US1, probed on grok 1.0.62). Then a link under
36
+ // `~/.grok/rules/` carries the Operating Instructions as a rule
37
+ // Grok loads in every project (~2.2k tokens by Grok's count):
38
+ // Grok shows a SessionStart hook's context on screen only and
39
+ // ignores a prompt hook's (DS12.TS4 L2/L3), so the plugin's
40
+ // session-start delivery of D13 does not reach its model. Two
41
+ // steps. The shapes not chosen -- the Claude link alone (skills,
42
+ // no hooks), a link in `~/.grok/plugins/` (loads nothing until
43
+ // a config entry is hand-written) -- are in decisions.md.
32
44
  //
33
45
  // Gemini CLI had an entry here (a skills link, then four hooks merged into
34
46
  // `~/.gemini/settings.json` by absolute path) until CV21.DS8.US1 retired the
@@ -37,7 +49,7 @@
37
49
  import { join } from "node:path";
38
50
  import { commandOnPath } from "#util/paths.ts";
39
51
 
40
- export type RuntimeName = "pi" | "claude" | "codex" | "antigravity";
52
+ export type RuntimeName = "pi" | "claude" | "codex" | "antigravity" | "grok";
41
53
 
42
54
  /**
43
55
  * The one Codex level-up command (CV21.DS4.TS6). Codex 0.163.0 has no
@@ -48,6 +60,29 @@ export type RuntimeName = "pi" | "claude" | "codex" | "antigravity";
48
60
  */
49
61
  export const CODEX_PLUGIN_ADD_COMMAND = "codex plugin add mirror-mind@mirror-mind";
50
62
 
63
+ /** Where the plugin directory lives below an installed package. */
64
+ export function pluginDirectory(root: string): string {
65
+ return join(root, "plugins/mirror-mind");
66
+ }
67
+
68
+ /**
69
+ * The one Grok level-up command (CV21.DS10.US1). Grok copies a local-path
70
+ * plugin and keys the copy by the path STRING: `grok plugin update` is a
71
+ * no-op for it (it answers "local symlink, already live" over a real copy,
72
+ * grok 1.0.62), an install over the same string is refused as already
73
+ * installed, and a differently spelt string installs a second copy beside
74
+ * the first. So the level-up is uninstall, then install with the path `init`
75
+ * printed -- a function of the root, so `diagnose` and `update` cannot spell
76
+ * it differently (the CR071 lesson at the string level).
77
+ */
78
+ export function grokPluginReinstallCommand(root: string): string {
79
+ return `grok plugin uninstall mirror-mind --confirm && ${grokPluginInstallCommand(root)}`;
80
+ }
81
+
82
+ function grokPluginInstallCommand(root: string): string {
83
+ return `grok plugin install "${pluginDirectory(root)}" --trust`;
84
+ }
85
+
51
86
  export interface RuntimeWiring {
52
87
  /** The runtime, as a person names it. */
53
88
  readonly title: string;
@@ -67,8 +102,11 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
67
102
  title: "Claude Code",
68
103
  command: "claude",
69
104
  steps: [
105
+ // `mkdir -p` first: a home that has never held a personal skill has no
106
+ // ~/.claude/skills/ yet, and the link alone fails there (CV21.DS5.US2's
107
+ // route, on the extensions plugin's step; the same line, paid here).
70
108
  (root) => [
71
- `ln -s "${join(root, "plugins/mirror-mind")}" ~/.claude/skills/mirror-mind`,
109
+ `mkdir -p ~/.claude/skills && ln -s "${pluginDirectory(root)}" ~/.claude/skills/mirror-mind`,
72
110
  "(the plugin loads every session: its skills, the hooks, and the MCP server)",
73
111
  ],
74
112
  ],
@@ -86,6 +124,29 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
86
124
  (root) => [`agy plugin install "${root}/plugins/mirror-mind"`],
87
125
  ],
88
126
  },
127
+ grok: {
128
+ title: "Grok Build",
129
+ command: "grok",
130
+ steps: [
131
+ // CV21.DS10.US1, probed on grok 1.0.62: one command copies the plugin
132
+ // and enables it. Consent lives in the line: what --trust activates,
133
+ // and that Grok asks nothing afterwards (Codex's note says Codex asks
134
+ // once; Grok's must say it does not).
135
+ (root) => [
136
+ grokPluginInstallCommand(root),
137
+ "(--trust activates the plugin's hooks and MCP server, and Grok does not ask again;",
138
+ "Grok keeps a copy of the plugin, so after an update `mirror runtime diagnose`",
139
+ "names the command that brings it level)",
140
+ ],
141
+ // The Operating Instructions (D13) as a home-level rule. Grok loads
142
+ // every *.md under ~/.grok/rules/ in every project, link or file.
143
+ (root) => [
144
+ `ln -s "${join(root, "AGENTS.md")}" ~/.grok/rules/mirror-mind.md`,
145
+ "(the Operating Instructions, loaded as a rule in every Grok session, in",
146
+ "every project: about 2.2k tokens per session)",
147
+ ],
148
+ ],
149
+ },
89
150
  codex: {
90
151
  title: "Codex",
91
152
  command: "codex",
@@ -100,7 +161,7 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
100
161
  },
101
162
  };
102
163
 
103
- const ORDER: readonly RuntimeName[] = ["pi", "claude", "codex", "antigravity"];
164
+ const ORDER: readonly RuntimeName[] = ["pi", "claude", "codex", "antigravity", "grok"];
104
165
 
105
166
  /** The runtimes whose command is on the PATH, in display order. */
106
167
  export function detectRuntimes(
@@ -124,3 +185,74 @@ export function runtimeWiringLines(runtime: RuntimeName, root: string): string[]
124
185
  });
125
186
  return lines;
126
187
  }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // The per-home extensions plugin (CV21.DS5.US2). A Mirror home's installed
191
+ // extensions reach the four plugin runtimes as one generated plugin per home,
192
+ // `<home>/runtime/plugin/mirror-ext-<user>/`, in the canonical plugin's format
193
+ // but carrying skills only (DS5.TS1's decision). Each runtime takes it the way
194
+ // it takes the canonical plugin, and the three that copy it (Codex, agy, Grok)
195
+ // need one command per change to bring the copy level — measured in TS1.
196
+ // `mirror init` prints none of this: at init there is no extension. The
197
+ // `extensions` verbs print the step a runtime still needs, and `runtime
198
+ // diagnose` names the reinstall for a stale copy. Printing is not automating.
199
+
200
+ export interface ExtensionPluginPaths {
201
+ /** The plugin directory: linked by Claude Code, installed by agy and Grok. */
202
+ readonly dir: string;
203
+ /** The marketplace root Codex adds: the plugin's parent, carrying `.agents/plugins/marketplace.json`. */
204
+ readonly marketplace: string;
205
+ /** `mirror-ext-<user>`: the plugin's name and its marketplace's. */
206
+ readonly name: string;
207
+ }
208
+
209
+ /** The consent line printed under Grok's step: what `--trust` would activate here, which is nothing. */
210
+ export const EXTENSION_PLUGIN_GROK_TRUST_NOTE =
211
+ "(this plugin carries skills only — no hooks and no MCP server for --trust to activate)";
212
+
213
+ /** The one-time wiring per runtime; Pi takes extensions through its own catalog and needs none. */
214
+ export function extensionPluginWiringSteps(
215
+ runtime: RuntimeName,
216
+ paths: ExtensionPluginPaths,
217
+ ): string[] {
218
+ switch (runtime) {
219
+ case "claude":
220
+ // `mkdir -p` first: a home that has never held a personal skill has no
221
+ // ~/.claude/skills/ yet, and the link alone fails there (measured, US2's route).
222
+ return [`mkdir -p ~/.claude/skills && ln -s "${paths.dir}" ~/.claude/skills/${paths.name}`];
223
+ case "codex":
224
+ return [
225
+ `codex plugin marketplace add "${paths.marketplace}"`,
226
+ `codex plugin add ${paths.name}@${paths.name}`,
227
+ ];
228
+ case "antigravity":
229
+ return [`agy plugin install "${paths.dir}"`];
230
+ case "grok":
231
+ return [`grok plugin install "${paths.dir}" --trust`];
232
+ case "pi":
233
+ return [];
234
+ }
235
+ }
236
+
237
+ /**
238
+ * What brings a runtime's copy level after the plugin changed, measured in
239
+ * DS5.TS1: Codex re-adds at the same version when the marketplace serves the
240
+ * live directory; agy installs again; Grok refuses an install over the same
241
+ * path and must uninstall first. Claude Code links and has no copy.
242
+ */
243
+ export function extensionPluginReinstallCommand(
244
+ runtime: RuntimeName,
245
+ paths: ExtensionPluginPaths,
246
+ ): string | null {
247
+ switch (runtime) {
248
+ case "claude":
249
+ case "pi":
250
+ return null;
251
+ case "codex":
252
+ return `codex plugin add ${paths.name}@${paths.name}`;
253
+ case "antigravity":
254
+ return `agy plugin install "${paths.dir}"`;
255
+ case "grok":
256
+ return `grok plugin uninstall ${paths.name} --confirm && grok plugin install "${paths.dir}" --trust`;
257
+ }
258
+ }