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
@@ -280,9 +280,9 @@ export const RETIRED: readonly RetiredSurface[] = [
280
280
  absentPaths: [],
281
281
  forbiddenPatterns: [
282
282
  // The flags and the two service methods. NOT the bare word `backfill`:
283
- // `conversation-logger backfill-pi-sessions` and
284
- // `ts/src/conversation/backfill.ts` are the TRANSCRIPT backfills, a
285
- // different feature that is ported and staying.
283
+ // the transcript backfill (`ts/src/conversation/transcriptBackfill.ts`)
284
+ // is a different feature, ported and staying. (The Pi session backfill
285
+ // that stood beside it is retired by its own row, `pi-session-backfill`.)
286
286
  "--metadata-backfill-",
287
287
  "metadata_backfill",
288
288
  "preview_metadata_backfill",
@@ -492,8 +492,6 @@ export const RETIRED: readonly RetiredSurface[] = [
492
492
  exemptions: {
493
493
  "docs/getting-started.md":
494
494
  "the un-wiring guidance: a user who started codex through the retired wrapper needs its name to undo that wiring",
495
- "ts/src/conversation/backfill.ts":
496
- "the header names the retired importer to say where it went, beside the Pi importer that stays",
497
495
  },
498
496
  },
499
497
  {
@@ -545,6 +543,45 @@ export const RETIRED: readonly RetiredSurface[] = [
545
543
  ],
546
544
  exemptions: {},
547
545
  },
546
+ {
547
+ surfaceId: "pi-session-backfill",
548
+ story: "CR153",
549
+ // Session maintenance's Pi backfill: a walk of Pi's session directory that
550
+ // imported every file this home's `runtime_sessions` did not track. That
551
+ // held while a machine had one home. With two, each imported the other's
552
+ // sessions -- 543 conversations on the maintainer's machine, both ways,
553
+ // each titled again through the provider -- and both imported the sessions
554
+ // of scratch homes and of Pi run without Mirror, while the gap it existed
555
+ // to fill, a session the home's own extension never logged, came up once
556
+ // in three months. A home's Pi conversations are the ones the Pi extension
557
+ // logged into it, and nothing reads a Pi session file. `importClosedConversation`
558
+ // went with it: the Codex importer that also wrote through it retired with
559
+ // DS4.TS5. `PI_SESSIONS_DIR` is inert, and only the records that say so,
560
+ // and the test that proves it, may name it.
561
+ absentPaths: [
562
+ "ts/src/conversation/backfill.ts",
563
+ "ts/src/conversation/sessionImport.ts",
564
+ "ts/test/conversation/backfill.test.ts",
565
+ "ts/test/db/homeBornAt.test.ts",
566
+ "ts/test/fixtures/backfill/pi-sessions/",
567
+ ],
568
+ forbiddenPatterns: [
569
+ "backfillPiSessions",
570
+ "resolvePiSessionsDir",
571
+ "homeBornAt",
572
+ "importClosedConversation",
573
+ "PI_SESSIONS_DIR",
574
+ "Backfilled Pi sessions",
575
+ ],
576
+ exemptions: {
577
+ "docs/reference/configuration.md":
578
+ "the inert-values record: a `.env` may still carry PI_SESSIONS_DIR, and the reference says nothing reads it",
579
+ ".env.example.advanced":
580
+ "marks PI_SESSIONS_DIR inert, so a leftover line is recognized rather than trusted",
581
+ "ts/test/conversation/loggerCli.test.ts":
582
+ "sets PI_SESSIONS_DIR to a directory holding a fresh session and proves maintenance imports nothing from it",
583
+ },
584
+ },
548
585
  ];
549
586
 
550
587
  export interface SurfaceProblem {
@@ -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
+ }
@@ -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
+ }