mirror-mind 0.34.0 → 0.35.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 +1 -0
- package/docs/releases/v0.35.0.md +199 -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 +4 -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 +10 -1
- package/ts/src/docs/docsLint.ts +14 -1
- package/ts/src/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +74 -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/main.ts +11 -15
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- package/ts/src/runtime/wiring.ts +18 -36
- 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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Antigravity hook entries (CV21.DS7.TS2).
|
|
2
|
+
//
|
|
3
|
+
// agy runs the plugin's `hooks.json` handlers with camelCase protojson on
|
|
4
|
+
// stdin and reads JSON from stdout (`injectSteps`; a `systemMessage` step
|
|
5
|
+
// nests its text, an `ephemeralMessage` is a plain string). The contracts
|
|
6
|
+
// are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2).
|
|
7
|
+
//
|
|
8
|
+
// Two handlers:
|
|
9
|
+
//
|
|
10
|
+
// * `agy:session-start` — turn conversation logging on, the posture every
|
|
11
|
+
// runtime's session-start shares, and answer `{}`. The Operating
|
|
12
|
+
// Instructions are NOT delivered here: `rules/AGENTS.md` carries them
|
|
13
|
+
// (US1), unless TS2's live route shows otherwise — the measured
|
|
14
|
+
// fallback would add an `injectSteps` `systemMessage` in this handler.
|
|
15
|
+
//
|
|
16
|
+
// * `agy:pre-invocation` — inject Mirror Mode context once per user turn.
|
|
17
|
+
// `PreInvocation` fires before EVERY model call (32 times over two
|
|
18
|
+
// turns, DS12.TS3 L2), with `invocationNum` resetting to 0 at each user
|
|
19
|
+
// turn; protojson omits zero-valued fields, so an ABSENT invocationNum
|
|
20
|
+
// is 0. The generated wrapper already gates on this before Node is
|
|
21
|
+
// spawned; the gate here is defense in depth. The injection is an
|
|
22
|
+
// `ephemeralMessage` — dropped after the turn — for two reasons: the
|
|
23
|
+
// adapter re-injects per turn, so a persistent step would accumulate;
|
|
24
|
+
// and the content is identity and memory, so ephemeral is the
|
|
25
|
+
// data-minimizing choice against agy's stored trajectory.
|
|
26
|
+
//
|
|
27
|
+
// agy payloads carry no prompt or reply text (L7): there is no `--query`,
|
|
28
|
+
// and the explicit Mirror form reaches the model through the skill (L4),
|
|
29
|
+
// never through these hooks. The quiet session — a `conversationId` no
|
|
30
|
+
// runtime session binds (every session until TS3 lands), or one bound with
|
|
31
|
+
// Mirror Mode off — answers `{}`: Mirror on agy is invisible until invoked.
|
|
32
|
+
//
|
|
33
|
+
// Payload fields stay inert: `conversationId` flows only through array
|
|
34
|
+
// argv, never a shell, and failures recorded in hooks.log are facts, never
|
|
35
|
+
// payload text.
|
|
36
|
+
|
|
37
|
+
import { readAgyTurns } from "#conversation/agyTranscript.ts";
|
|
38
|
+
import { isCommandTurn } from "#conversation/commandTurn.ts";
|
|
39
|
+
import { logAssistantMessage, logUserMessage } from "#conversation/logger.ts";
|
|
40
|
+
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
41
|
+
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
42
|
+
import { decodeMetadata, getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
|
|
43
|
+
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
44
|
+
import { readMirrorState } from "./mirrorState.ts";
|
|
45
|
+
import { noteHookFailure, runFrontDoor, runFrontDoorQuietly } from "./runtime.ts";
|
|
46
|
+
|
|
47
|
+
/** agy's empty answer, which every path that injects nothing must print. */
|
|
48
|
+
const EMPTY = "{}\n";
|
|
49
|
+
|
|
50
|
+
interface AgyPayload {
|
|
51
|
+
readonly conversationId: string;
|
|
52
|
+
readonly invocationNum: number;
|
|
53
|
+
readonly transcriptPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The key under which the Stop handler stores its high-water step_index. */
|
|
57
|
+
const HIGH_WATER_KEY = "agy_last_step_index";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse agy's camelCase protojson payload. Anything malformed reads as the
|
|
61
|
+
* empty payload: a hook never fails the turn over stdin it did not write.
|
|
62
|
+
*/
|
|
63
|
+
export function parseAgyPayload(stdin: string): AgyPayload {
|
|
64
|
+
try {
|
|
65
|
+
const raw: unknown = JSON.parse(stdin);
|
|
66
|
+
if (typeof raw !== "object" || raw === null) {
|
|
67
|
+
return { conversationId: "", invocationNum: 0, transcriptPath: "" };
|
|
68
|
+
}
|
|
69
|
+
const record = raw as Record<string, unknown>;
|
|
70
|
+
return {
|
|
71
|
+
conversationId: typeof record.conversationId === "string" ? record.conversationId : "",
|
|
72
|
+
// protojson omits zero-valued fields: absent IS 0.
|
|
73
|
+
invocationNum: typeof record.invocationNum === "number" ? record.invocationNum : 0,
|
|
74
|
+
transcriptPath: typeof record.transcriptPath === "string" ? record.transcriptPath : "",
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return { conversationId: "", invocationNum: 0, transcriptPath: "" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `SessionStart`: logging on, activate Mirror Mode for the session, answer
|
|
83
|
+
* the contract.
|
|
84
|
+
*
|
|
85
|
+
* Mirror Mode activation (CV21.DS7.US4 scope change). agy's `PreInvocation`
|
|
86
|
+
* carries no prompt text (DS12.TS3 L7), so the injection hook cannot detect
|
|
87
|
+
* the Mirror form to self-activate, and the `/mirror-mind:mm:*` skill runs
|
|
88
|
+
* `mirror load` as an agent command that cannot bind the agy session id. So
|
|
89
|
+
* SessionStart — the one hook that carries the `conversationId` at the start
|
|
90
|
+
* of the session — sets `mirror_active`, and the per-turn `PreInvocation`
|
|
91
|
+
* injection (built in TS2, gated on that flag) fires from then on. This
|
|
92
|
+
* matches Gemini CLI's always-on per-turn injection, the runtime Antigravity
|
|
93
|
+
* replaces; a session that never ran SessionStart stays quiet, which the
|
|
94
|
+
* negative probe pins.
|
|
95
|
+
*/
|
|
96
|
+
export async function agySessionStart(stdin: string): Promise<number> {
|
|
97
|
+
await runFrontDoorQuietly("agy:session-start", ["conversation-logger", "session-start"]);
|
|
98
|
+
|
|
99
|
+
const conversationId = parseAgyPayload(stdin).conversationId;
|
|
100
|
+
if (conversationId) {
|
|
101
|
+
try {
|
|
102
|
+
const dbPath = resolveDbPath([], process.env);
|
|
103
|
+
const db = openLiveWriteDatabase(dbPath);
|
|
104
|
+
try {
|
|
105
|
+
upsertRuntimeSession(
|
|
106
|
+
db,
|
|
107
|
+
conversationId,
|
|
108
|
+
{ interface: "antigravity", mirrorActive: true },
|
|
109
|
+
nowIso(),
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
noteHookFailure("agy:session-start", error instanceof Error ? error.message : String(error));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process.stdout.write(EMPTY);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `Stop`: fires at the end of each user turn (agy has no SessionEnd hook).
|
|
125
|
+
* Read the transcript, append the turns past the stored high-water
|
|
126
|
+
* `step_index` as `antigravity`, and leave the conversation open — session
|
|
127
|
+
* maintenance closes it as a stale orphan, the path Codex uses (Shape A).
|
|
128
|
+
*
|
|
129
|
+
* A re-fired Stop, or a grown transcript whose turns are all logged, adds
|
|
130
|
+
* nothing: the high-water mark is the idempotency key. A skill turn is
|
|
131
|
+
* skipped, the one-home rule. Any failure is recorded in hooks.log, never
|
|
132
|
+
* fails the turn, and the handler always answers agy's contract.
|
|
133
|
+
*/
|
|
134
|
+
export async function agyStop(stdin: string): Promise<number> {
|
|
135
|
+
const payload = parseAgyPayload(stdin);
|
|
136
|
+
if (!payload.conversationId || !payload.transcriptPath) {
|
|
137
|
+
process.stdout.write(EMPTY);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const turns = readAgyTurns(payload.transcriptPath);
|
|
142
|
+
if (turns.length === 0) {
|
|
143
|
+
process.stdout.write(EMPTY);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let dbPath: string;
|
|
148
|
+
try {
|
|
149
|
+
dbPath = resolveDbPath([], process.env);
|
|
150
|
+
} catch {
|
|
151
|
+
process.stdout.write(EMPTY);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const db = openLiveWriteDatabase(dbPath);
|
|
157
|
+
try {
|
|
158
|
+
const sessionId = payload.conversationId;
|
|
159
|
+
const existing = getRuntimeSession(db, sessionId);
|
|
160
|
+
const stored = decodeMetadata(existing?.metadata ?? null);
|
|
161
|
+
const highWater =
|
|
162
|
+
typeof stored[HIGH_WATER_KEY] === "number" ? (stored[HIGH_WATER_KEY] as number) : -1;
|
|
163
|
+
const deps = { newId, nowIso };
|
|
164
|
+
const options = { interface: "antigravity" };
|
|
165
|
+
|
|
166
|
+
let newHighWater = highWater;
|
|
167
|
+
for (const turn of turns) {
|
|
168
|
+
if (turn.lastStepIndex <= highWater) continue;
|
|
169
|
+
if (!isCommandTurn(turn.userText)) {
|
|
170
|
+
logUserMessage(db, sessionId, turn.userText, options, deps);
|
|
171
|
+
logAssistantMessage(db, sessionId, turn.replyText, options, deps);
|
|
172
|
+
}
|
|
173
|
+
if (turn.lastStepIndex > newHighWater) newHighWater = turn.lastStepIndex;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (newHighWater !== highWater) {
|
|
177
|
+
const current = getRuntimeSession(db, sessionId);
|
|
178
|
+
const metadata = decodeMetadata(current?.metadata ?? null);
|
|
179
|
+
metadata[HIGH_WATER_KEY] = newHighWater;
|
|
180
|
+
upsertRuntimeSession(db, sessionId, { metadata: JSON.stringify(metadata) }, nowIso());
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
db.close();
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
noteHookFailure("agy:stop", error instanceof Error ? error.message : String(error));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
process.stdout.write(EMPTY);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `PreInvocation`: one ephemeral context step per user turn, when owed. */
|
|
194
|
+
export async function agyPreInvocation(stdin: string): Promise<number> {
|
|
195
|
+
const payload = parseAgyPayload(stdin);
|
|
196
|
+
|
|
197
|
+
// Defense in depth behind the wrapper's shell gate: only the turn's first
|
|
198
|
+
// invocation injects; the other ~30 answer instantly.
|
|
199
|
+
if (payload.invocationNum !== 0 || !payload.conversationId) {
|
|
200
|
+
process.stdout.write(EMPTY);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let dbPath: string;
|
|
205
|
+
try {
|
|
206
|
+
dbPath = resolveDbPath([], process.env);
|
|
207
|
+
} catch {
|
|
208
|
+
process.stdout.write(EMPTY);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The quiet session: unbound, or bound with Mirror Mode off.
|
|
213
|
+
const state = readMirrorState(dbPath, payload.conversationId);
|
|
214
|
+
if (!state.active) {
|
|
215
|
+
process.stdout.write(EMPTY);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const argv = ["mirror", "load", "--context-only", "--session-id", payload.conversationId];
|
|
220
|
+
if (state.persona) argv.push("--persona", state.persona);
|
|
221
|
+
if (state.journey) argv.push("--journey", state.journey);
|
|
222
|
+
|
|
223
|
+
const result = await runFrontDoor(argv);
|
|
224
|
+
const context = result.stdout.trim() ? result.stdout : "";
|
|
225
|
+
process.stdout.write(
|
|
226
|
+
context ? `${JSON.stringify({ injectSteps: [{ ephemeralMessage: context }] })}\n` : EMPTY,
|
|
227
|
+
);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
package/ts/src/hooks/main.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// CV22.DS10.TS5 plateau 1, decision D4. Twelve shell scripts across four
|
|
6
6
|
// runtimes each spawned the interpreter one to five times per event. They now
|
|
7
|
-
// run one Node process and name which hook they are
|
|
7
|
+
// run one Node process and name which hook they are (the Gemini CLI family,
|
|
8
|
+
// `gemini:*`, retired with CV21.DS8.US1; Antigravity is its successor):
|
|
8
9
|
//
|
|
9
10
|
// node ts/src/hooks/main.ts claude:inject
|
|
10
11
|
//
|
|
@@ -35,13 +36,8 @@
|
|
|
35
36
|
|
|
36
37
|
import { pathToFileURL } from "node:url";
|
|
37
38
|
import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
|
|
39
|
+
import { agyPreInvocation, agySessionStart, agyStop } from "./agy.ts";
|
|
38
40
|
import { claudeHook } from "./claude.ts";
|
|
39
|
-
import {
|
|
40
|
-
geminiLogAssistant,
|
|
41
|
-
geminiLogUser,
|
|
42
|
-
geminiSessionEnd,
|
|
43
|
-
geminiSessionStart,
|
|
44
|
-
} from "./gemini.ts";
|
|
45
41
|
import { readStdin } from "./payload.ts";
|
|
46
42
|
import { noteHookFailure } from "./runtime.ts";
|
|
47
43
|
|
|
@@ -51,16 +47,16 @@ async function dispatch(name: string): Promise<number> {
|
|
|
51
47
|
switch (runtime) {
|
|
52
48
|
case "claude":
|
|
53
49
|
return claudeHook(hook ?? "");
|
|
54
|
-
case "
|
|
50
|
+
case "agy":
|
|
51
|
+
// The Antigravity plugin's handlers (CV21.DS7.TS2), called through the
|
|
52
|
+
// generated agy-form wrappers in `plugins/mirror-mind/agy/`.
|
|
55
53
|
switch (hook) {
|
|
56
|
-
case "log-user":
|
|
57
|
-
return geminiLogUser(readStdin());
|
|
58
|
-
case "log-assistant":
|
|
59
|
-
return geminiLogAssistant(readStdin());
|
|
60
|
-
case "session-end":
|
|
61
|
-
return geminiSessionEnd(readStdin());
|
|
62
54
|
case "session-start":
|
|
63
|
-
return
|
|
55
|
+
return agySessionStart(readStdin());
|
|
56
|
+
case "pre-invocation":
|
|
57
|
+
return agyPreInvocation(readStdin());
|
|
58
|
+
case "stop":
|
|
59
|
+
return agyStop(readStdin());
|
|
64
60
|
default:
|
|
65
61
|
break;
|
|
66
62
|
}
|
package/ts/src/hooks/payload.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Reading a runtime's hook payload from stdin.
|
|
2
2
|
//
|
|
3
3
|
// Replaces the Python hook module `extract_prompt` (25 lines) and the four
|
|
4
|
-
// inline interpreter one-liners the Claude and Gemini hooks used to parse
|
|
5
|
-
// same JSON. None of them was a command, so none was in
|
|
4
|
+
// inline interpreter one-liners the Claude and Gemini CLI hooks used to parse
|
|
5
|
+
// the same JSON (the Gemini CLI hooks retired with CV21.DS8.US1). None of them was a command, so none was in
|
|
6
6
|
// DS7's denominator of 29 -- which is why TS5's inventory had to find them by
|
|
7
7
|
// reading the hooks rather than by counting commands.
|
|
8
8
|
//
|
|
@@ -23,17 +23,11 @@ export interface HookPayload {
|
|
|
23
23
|
readonly prompt: string;
|
|
24
24
|
/** The runtime's session id (`session_id`, or Grok's `sessionId`), or "". */
|
|
25
25
|
readonly sessionId: string;
|
|
26
|
-
/**
|
|
27
|
-
readonly response: string;
|
|
28
|
-
/** Everything else, for hooks that need a field these three do not cover. */
|
|
26
|
+
/** Everything else, for hooks that need a field these two do not cover. */
|
|
29
27
|
readonly raw: HookPayloadObject;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
const EMPTY: HookPayload = { prompt: "", sessionId: "",
|
|
33
|
-
|
|
34
|
-
function text(value: unknown): string {
|
|
35
|
-
return typeof value === "string" ? value : "";
|
|
36
|
-
}
|
|
30
|
+
const EMPTY: HookPayload = { prompt: "", sessionId: "", raw: {} };
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* Parse a hook payload. Never throws: an unparseable payload is empty.
|
|
@@ -47,11 +41,10 @@ export function parseHookPayload(input: string): HookPayload {
|
|
|
47
41
|
return {
|
|
48
42
|
prompt: promptOf(raw),
|
|
49
43
|
sessionId: sessionIdOf(raw),
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
response: text(raw.prompt_response),
|
|
44
|
+
// A `response` field read Gemini CLI's `prompt_response` here until
|
|
45
|
+
// CV21.DS8.US1; no living runtime hands the reply to a hook payload by
|
|
46
|
+
// name (Claude-family hooks read the transcript, agy's Stop reads its
|
|
47
|
+
// JSONL), so the field went with its only reader.
|
|
55
48
|
raw,
|
|
56
49
|
};
|
|
57
50
|
}
|
package/ts/src/hooks/runtime.ts
CHANGED
|
@@ -61,11 +61,11 @@ export function noteHookFailure(hook: string, reason: string, env = process.env)
|
|
|
61
61
|
* The Operating Instructions a SessionStart hook should hand its runtime, or
|
|
62
62
|
* null when the project already carries this same file.
|
|
63
63
|
*
|
|
64
|
-
* CV22.DS10.US3, decision D13. An installed package reaches Claude Code and
|
|
65
|
-
* Gemini CLI through hooks, skills, and an MCP
|
|
66
|
-
*
|
|
67
|
-
* `/mm-mirror` and still route no mode, sign no persona, and hold
|
|
68
|
-
* boundary.
|
|
64
|
+
* CV22.DS10.US3, decision D13. An installed package reaches Claude Code (and
|
|
65
|
+
* reached Gemini CLI, until CV21.DS8.US1) through hooks, skills, and an MCP
|
|
66
|
+
* server; none of those reads a CLAUDE.md, so a session wired to the package
|
|
67
|
+
* would answer `/mm-mirror` and still route no mode, sign no persona, and hold
|
|
68
|
+
* no Builder boundary. The runtime reads SessionStart's
|
|
69
69
|
* `hookSpecificOutput.additionalContext`, so the session-start hook returns
|
|
70
70
|
* `AGENTS.md` from the tree THIS code runs from -- the instructions always
|
|
71
71
|
* match the core they ship with -- unless the project the session opened in
|
|
@@ -92,8 +92,9 @@ export function operatingInstructionsFor(
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* The SessionStart answer
|
|
96
|
-
* -- what
|
|
95
|
+
* The SessionStart answer the runtime reads: context as JSON, or `nothing`
|
|
96
|
+
* -- what the runtime's hook printed before D13 (Claude none; Gemini CLI's
|
|
97
|
+
* `{}`, until CV21.DS8.US1 retired it).
|
|
97
98
|
*/
|
|
98
99
|
export function sessionStartOutput(instructions: string | null, nothing = ""): string {
|
|
99
100
|
if (instructions === null) return nothing;
|
|
@@ -148,9 +149,9 @@ export async function runFrontDoor(
|
|
|
148
149
|
/**
|
|
149
150
|
* The command a failure line may name: the family and its subcommand, never
|
|
150
151
|
* the arguments. Arguments are where hooks put what the user said and what the
|
|
151
|
-
* model answered -- the Gemini logger
|
|
152
|
-
* argv -- and the first version of the failure
|
|
153
|
-
* handoff review, finding P1).
|
|
152
|
+
* model answered -- the Gemini CLI logger, retired by CV21.DS8.US1, passed the
|
|
153
|
+
* prompt and the response as argv -- and the first version of the failure
|
|
154
|
+
* line printed all of them (TS5 handoff review, finding P1).
|
|
154
155
|
*/
|
|
155
156
|
function commandName(argv: readonly string[]): string {
|
|
156
157
|
return argv.slice(0, 2).join(" ");
|
package/ts/src/runtime/wiring.ts
CHANGED
|
@@ -17,12 +17,6 @@
|
|
|
17
17
|
// server (`claude plugin list` / `details`, 2.1.283). One step.
|
|
18
18
|
// The Operating Instructions arrive through the plugin's
|
|
19
19
|
// SessionStart hook (D13).
|
|
20
|
-
// Gemini CLI `gemini skills link --consent <dir>` links every skill in the
|
|
21
|
-
// directory into `~/.gemini/skills/` (0.61.0; a nested directory
|
|
22
|
-
// symlink is NOT discovered, the user tier is one level deep),
|
|
23
|
-
// and hooks live in `~/.gemini/settings.json`, absolute paths.
|
|
24
|
-
// Two steps. The Operating Instructions arrive through the
|
|
25
|
-
// SessionStart hook (D13).
|
|
26
20
|
// Codex `codex plugin marketplace add <root>` reads the package's
|
|
27
21
|
// `.agents/plugins/marketplace.json`, and `codex plugin add
|
|
28
22
|
// mirror-mind@mirror-mind` copies the plugin into Codex's cache
|
|
@@ -33,11 +27,17 @@
|
|
|
33
27
|
// developer message (L4). Until DS4.US1 the two steps were a
|
|
34
28
|
// skills link and an AGENTS.md link, with sessions run through
|
|
35
29
|
// the shipped wrapper.
|
|
30
|
+
// Antigravity `agy plugin install <plugin dir>` stages the plugin with its
|
|
31
|
+
// agy-native layer in one step (CV21.DS7.US1, agy 1.4.2).
|
|
32
|
+
//
|
|
33
|
+
// Gemini CLI had an entry here (a skills link, then four hooks merged into
|
|
34
|
+
// `~/.gemini/settings.json` by absolute path) until CV21.DS8.US1 retired the
|
|
35
|
+
// integration; Antigravity is its successor.
|
|
36
36
|
|
|
37
37
|
import { join } from "node:path";
|
|
38
38
|
import { commandOnPath } from "#util/paths.ts";
|
|
39
39
|
|
|
40
|
-
export type RuntimeName = "pi" | "claude" | "
|
|
40
|
+
export type RuntimeName = "pi" | "claude" | "codex" | "antigravity";
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* The one Codex level-up command (CV21.DS4.TS6). Codex 0.163.0 has no
|
|
@@ -57,24 +57,6 @@ export interface RuntimeWiring {
|
|
|
57
57
|
readonly steps: readonly ((root: string) => readonly string[])[];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** Gemini CLI's four hooks, as the user-level settings.json wants them. */
|
|
61
|
-
function geminiHooksSettings(root: string): string {
|
|
62
|
-
const hook = (event: string, name: string, script: string) =>
|
|
63
|
-
` "${event}": [{ "hooks": [{ "name": "mirror-${name}", "type": "command", "command": "${join(root, ".gemini/hooks", script)}", "timeout": 30000 }] }]`;
|
|
64
|
-
return [
|
|
65
|
-
"{",
|
|
66
|
-
' "hooks": {',
|
|
67
|
-
[
|
|
68
|
-
hook("SessionStart", "session-start", "session-start.sh"),
|
|
69
|
-
hook("BeforeAgent", "log-user", "log-user.sh"),
|
|
70
|
-
hook("AfterAgent", "log-assistant", "log-assistant.sh"),
|
|
71
|
-
hook("SessionEnd", "session-end", "session-end.sh"),
|
|
72
|
-
].join(",\n"),
|
|
73
|
-
" }",
|
|
74
|
-
"}",
|
|
75
|
-
].join("\n");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
60
|
export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
|
|
79
61
|
pi: {
|
|
80
62
|
title: "Pi",
|
|
@@ -91,17 +73,17 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
|
|
|
91
73
|
],
|
|
92
74
|
],
|
|
93
75
|
},
|
|
94
|
-
|
|
95
|
-
title: "
|
|
96
|
-
command: "
|
|
76
|
+
antigravity: {
|
|
77
|
+
title: "Antigravity",
|
|
78
|
+
command: "agy",
|
|
97
79
|
steps: [
|
|
98
|
-
|
|
99
|
-
(root
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
],
|
|
80
|
+
// CV21.DS7.US1, probed on agy 1.4.2: one command stages the 25 skills
|
|
81
|
+
// and the agy-native layer (the root plugin.json marker makes agy
|
|
82
|
+
// treat the directory as a native plugin; without it, install converts
|
|
83
|
+
// the Claude surfaces and overwrites the layer's hooks file). Per-turn
|
|
84
|
+
// injection and logging land with DS7's TS2/TS3; this line promises
|
|
85
|
+
// the install, nothing more.
|
|
86
|
+
(root) => [`agy plugin install "${root}/plugins/mirror-mind"`],
|
|
105
87
|
],
|
|
106
88
|
},
|
|
107
89
|
codex: {
|
|
@@ -118,7 +100,7 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
|
|
|
118
100
|
},
|
|
119
101
|
};
|
|
120
102
|
|
|
121
|
-
const ORDER: readonly RuntimeName[] = ["pi", "claude", "
|
|
103
|
+
const ORDER: readonly RuntimeName[] = ["pi", "claude", "codex", "antigravity"];
|
|
122
104
|
|
|
123
105
|
/** The runtimes whose command is on the PATH, in display order. */
|
|
124
106
|
export function detectRuntimes(
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
|
|
3
|
-
# Edit the generator, never this file: every wrapper shares these bytes and
|
|
4
|
-
# differs only in the hook name and in how it finds the Mirror entry
|
|
5
|
-
# (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
|
|
6
|
-
set -uo pipefail
|
|
7
|
-
HOOK="gemini:log-assistant"
|
|
8
|
-
|
|
9
|
-
# Resolve the tree from THIS FILE, never from the cwd: a runtime spawns hooks
|
|
10
|
-
# from whatever directory the session is in. The tree is a checkout or the
|
|
11
|
-
# installed package; the wrapper's depth to its root is the same in both.
|
|
12
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
13
|
-
REPO_ROOT="$(cd "$HERE/../.." && pwd -P)"
|
|
14
|
-
|
|
15
|
-
# A hook never fails the user's turn, and never fails in silence: when it
|
|
16
|
-
# cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
|
|
17
|
-
# The home is found the way the core finds it -- MIRROR_HOME, else
|
|
18
|
-
# MIRROR_USER -- from the environment when set there, else from the tree's
|
|
19
|
-
# .env when the wrapper is in a tree, else from the user's config file
|
|
20
|
-
# (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
|
|
21
|
-
# neither variable, and the first wrappers then wrote to a home nobody reads.
|
|
22
|
-
env_file_value() {
|
|
23
|
-
[ -f "$2" ] || return 0
|
|
24
|
-
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
|
|
25
|
-
| tail -n 1 | tr -d "\"'\r"
|
|
26
|
-
}
|
|
27
|
-
env_value() {
|
|
28
|
-
local value=""
|
|
29
|
-
if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
|
|
30
|
-
[ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
|
|
31
|
-
[ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
|
|
32
|
-
printf '%s' "$value"
|
|
33
|
-
}
|
|
34
|
-
note() {
|
|
35
|
-
local home user
|
|
36
|
-
home="$(env_value MIRROR_HOME)"
|
|
37
|
-
user="$(env_value MIRROR_USER)"
|
|
38
|
-
if [ -n "$home" ]; then
|
|
39
|
-
home="${home/#\~/$HOME}"
|
|
40
|
-
elif [ -n "$user" ]; then
|
|
41
|
-
home="$HOME/.mirror-minds/$user"
|
|
42
|
-
[ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
|
|
43
|
-
else
|
|
44
|
-
return 0
|
|
45
|
-
fi
|
|
46
|
-
mkdir -p "$home" 2>/dev/null \
|
|
47
|
-
&& printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
51
|
-
|
|
52
|
-
# Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
|
|
53
|
-
# nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
|
|
54
|
-
# hooks could end in `|| true` because they always found their interpreter;
|
|
55
|
-
# the same silence here would hide a new failure — no logging, no inject, no
|
|
56
|
-
# close tail — that a user would meet weeks later as "Mirror stopped
|
|
57
|
-
# remembering". So an unresolvable Node is RECORDED, never swallowed. The
|
|
58
|
-
# directory the entry was found in is searched first: an npm global bin holds
|
|
59
|
-
# the `node` that installed the package beside `mirror-hook`, while a system
|
|
60
|
-
# `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
|
|
61
|
-
NODE="${MIRROR_NODE:-}"
|
|
62
|
-
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
63
|
-
NODE="$(command -v node 2>/dev/null || true)"
|
|
64
|
-
fi
|
|
65
|
-
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
66
|
-
[ -n "$NODE" ] && break
|
|
67
|
-
[ -x "$candidate" ] && NODE="$candidate"
|
|
68
|
-
done
|
|
69
|
-
|
|
70
|
-
if [ -z "$NODE" ]; then
|
|
71
|
-
note "node not found on PATH; hook skipped. Set MIRROR_NODE."
|
|
72
|
-
exit 0
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
|
|
76
|
-
# means Node itself could not run the hook -- too old for the loader shim, or
|
|
77
|
-
# it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
|
|
78
|
-
# review, finding N1). No flags: the entry reads configuration and silences
|
|
79
|
-
# its own warnings (US3, D3).
|
|
80
|
-
"$NODE" "$ENTRY" "$HOOK" "$@"
|
|
81
|
-
status=$?
|
|
82
|
-
if [ "$status" -ne 0 ]; then
|
|
83
|
-
note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
|
|
84
|
-
fi
|
|
85
|
-
exit 0
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
|
|
3
|
-
# Edit the generator, never this file: every wrapper shares these bytes and
|
|
4
|
-
# differs only in the hook name and in how it finds the Mirror entry
|
|
5
|
-
# (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
|
|
6
|
-
set -uo pipefail
|
|
7
|
-
HOOK="gemini:log-user"
|
|
8
|
-
|
|
9
|
-
# Resolve the tree from THIS FILE, never from the cwd: a runtime spawns hooks
|
|
10
|
-
# from whatever directory the session is in. The tree is a checkout or the
|
|
11
|
-
# installed package; the wrapper's depth to its root is the same in both.
|
|
12
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
13
|
-
REPO_ROOT="$(cd "$HERE/../.." && pwd -P)"
|
|
14
|
-
|
|
15
|
-
# A hook never fails the user's turn, and never fails in silence: when it
|
|
16
|
-
# cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
|
|
17
|
-
# The home is found the way the core finds it -- MIRROR_HOME, else
|
|
18
|
-
# MIRROR_USER -- from the environment when set there, else from the tree's
|
|
19
|
-
# .env when the wrapper is in a tree, else from the user's config file
|
|
20
|
-
# (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
|
|
21
|
-
# neither variable, and the first wrappers then wrote to a home nobody reads.
|
|
22
|
-
env_file_value() {
|
|
23
|
-
[ -f "$2" ] || return 0
|
|
24
|
-
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
|
|
25
|
-
| tail -n 1 | tr -d "\"'\r"
|
|
26
|
-
}
|
|
27
|
-
env_value() {
|
|
28
|
-
local value=""
|
|
29
|
-
if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
|
|
30
|
-
[ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
|
|
31
|
-
[ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
|
|
32
|
-
printf '%s' "$value"
|
|
33
|
-
}
|
|
34
|
-
note() {
|
|
35
|
-
local home user
|
|
36
|
-
home="$(env_value MIRROR_HOME)"
|
|
37
|
-
user="$(env_value MIRROR_USER)"
|
|
38
|
-
if [ -n "$home" ]; then
|
|
39
|
-
home="${home/#\~/$HOME}"
|
|
40
|
-
elif [ -n "$user" ]; then
|
|
41
|
-
home="$HOME/.mirror-minds/$user"
|
|
42
|
-
[ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
|
|
43
|
-
else
|
|
44
|
-
return 0
|
|
45
|
-
fi
|
|
46
|
-
mkdir -p "$home" 2>/dev/null \
|
|
47
|
-
&& printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
51
|
-
|
|
52
|
-
# Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
|
|
53
|
-
# nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
|
|
54
|
-
# hooks could end in `|| true` because they always found their interpreter;
|
|
55
|
-
# the same silence here would hide a new failure — no logging, no inject, no
|
|
56
|
-
# close tail — that a user would meet weeks later as "Mirror stopped
|
|
57
|
-
# remembering". So an unresolvable Node is RECORDED, never swallowed. The
|
|
58
|
-
# directory the entry was found in is searched first: an npm global bin holds
|
|
59
|
-
# the `node` that installed the package beside `mirror-hook`, while a system
|
|
60
|
-
# `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
|
|
61
|
-
NODE="${MIRROR_NODE:-}"
|
|
62
|
-
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
63
|
-
NODE="$(command -v node 2>/dev/null || true)"
|
|
64
|
-
fi
|
|
65
|
-
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
66
|
-
[ -n "$NODE" ] && break
|
|
67
|
-
[ -x "$candidate" ] && NODE="$candidate"
|
|
68
|
-
done
|
|
69
|
-
|
|
70
|
-
if [ -z "$NODE" ]; then
|
|
71
|
-
note "node not found on PATH; hook skipped. Set MIRROR_NODE."
|
|
72
|
-
exit 0
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
|
|
76
|
-
# means Node itself could not run the hook -- too old for the loader shim, or
|
|
77
|
-
# it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
|
|
78
|
-
# review, finding N1). No flags: the entry reads configuration and silences
|
|
79
|
-
# its own warnings (US3, D3).
|
|
80
|
-
"$NODE" "$ENTRY" "$HOOK" "$@"
|
|
81
|
-
status=$?
|
|
82
|
-
if [ "$status" -ne 0 ]; then
|
|
83
|
-
note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
|
|
84
|
-
fi
|
|
85
|
-
exit 0
|