mirror-mind 0.34.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.
- package/.pi/skills/mm-build/SKILL.md +1 -1
- package/.pi/skills/mm-explore/SKILL.md +1 -1
- package/.pi/skills/mm-help/SKILL.md +2 -2
- package/.pi/skills/mm-soul/SKILL.md +1 -1
- package/AGENTS.md +7 -6
- package/README.md +5 -6
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.35.0.md +199 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/package.json +2 -4
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
- package/plugins/mirror-mind/agy/session-start.sh +92 -0
- package/plugins/mirror-mind/agy/stop.sh +92 -0
- package/plugins/mirror-mind/hooks.json +26 -0
- package/plugins/mirror-mind/mcp_config.json +11 -0
- package/plugins/mirror-mind/plugin.json +14 -0
- package/plugins/mirror-mind/rules/AGENTS.md +189 -0
- package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
- package/ts/src/conversation/agyTranscript.ts +105 -0
- package/ts/src/conversation/commandTurn.ts +1 -1
- package/ts/src/conversation/deferredCloseTail.ts +2 -2
- package/ts/src/conversation/logger.ts +1 -1
- package/ts/src/conversation/runtimePolicy.ts +36 -4
- package/ts/src/docs/docsLint.ts +14 -1
- 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/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +91 -0
- package/ts/src/guards/packContents.ts +3 -2
- package/ts/src/guards/piSourcedSkills.ts +1 -1
- package/ts/src/guards/retiredSurfaces.ts +31 -0
- package/ts/src/hooks/agy.ts +229 -0
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +23 -16
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- 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 +149 -35
- package/.gemini/hooks/log-assistant.sh +0 -85
- package/.gemini/hooks/log-user.sh +0 -85
- package/.gemini/hooks/session-end.sh +0 -85
- package/.gemini/hooks/session-start.sh +0 -85
- package/ts/src/hooks/gemini.ts +0 -128
|
@@ -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
|
+
}
|
|
@@ -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
|
-
//
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
// `$CODEX_HOME` else `~/.codex`, and the Claude
|
|
12
|
-
// diagnose entry, from the env the caller
|
|
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
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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, ...
|
|
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
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|