mirror-mind 0.33.4 → 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/.agents/plugins/marketplace.json +16 -0
- 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 +9 -8
- package/README.md +5 -6
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/docs/releases/v0.35.0.md +199 -0
- package/package.json +3 -6
- 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/backfill.ts +13 -129
- package/ts/src/conversation/commandTurn.ts +42 -0
- package/ts/src/conversation/deferredCloseTail.ts +154 -0
- package/ts/src/conversation/logger.ts +104 -20
- package/ts/src/conversation/loggerCli.ts +6 -18
- package/ts/src/conversation/loggerRuntime.ts +6 -1
- package/ts/src/conversation/runtimePolicy.ts +59 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/docs/docsLint.ts +14 -1
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +113 -2
- package/ts/src/guards/packContents.ts +7 -6
- package/ts/src/guards/piSourcedSkills.ts +1 -1
- package/ts/src/guards/retiredSurfaces.ts +67 -0
- package/ts/src/hooks/agy.ts +229 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/main.ts +17 -31
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- package/ts/src/providers/config.ts +11 -0
- package/ts/src/runtime/diagnose.ts +115 -1
- package/ts/src/runtime/pluginWiring.ts +135 -0
- package/ts/src/runtime/update.ts +39 -6
- package/ts/src/runtime/wiring.ts +42 -46
- 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/scripts/codex-hooks/session-end.sh +0 -85
- package/scripts/codex-hooks/session-start.sh +0 -85
- package/scripts/codex-mirror.sh +0 -48
- package/ts/src/hooks/codex.ts +0 -45
- package/ts/src/hooks/gemini.ts +0 -126
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that cross the LLM close tail -- the replay providers and the maintenance
|
|
8
8
|
// dependencies built on them.
|
|
9
9
|
//
|
|
10
|
-
// The providers are loaded lazily. `diagnose-journeys
|
|
10
|
+
// The providers are loaded lazily. `diagnose-journeys`
|
|
11
11
|
// or `session-start --fast` must never fail because a replay fixture is not
|
|
12
12
|
// configured, and `switch` or `session-end` must fail loudly (and fall back
|
|
13
13
|
// to Python at the routing layer) when it is not: "unconfigured installs keep
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveExtractionMaxAttempts,
|
|
30
30
|
resolveExtractionModel,
|
|
31
31
|
resolveMaintenanceMaxExtractions,
|
|
32
|
+
resolveStaleOrphanMinutes,
|
|
32
33
|
resolveSummarizeEnabled,
|
|
33
34
|
resolveTwoPassEnabled,
|
|
34
35
|
} from "#providers/config.ts";
|
|
@@ -242,6 +243,9 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
242
243
|
source: "startup_maintenance",
|
|
243
244
|
}),
|
|
244
245
|
runExtraction,
|
|
246
|
+
// CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
|
|
247
|
+
finalizeCloseTail: (database, conversationId) =>
|
|
248
|
+
hooks.finalizeMetadata(database, conversationId),
|
|
245
249
|
// CR121: only the sessions newer than this home; its birth is read from
|
|
246
250
|
// the ledger once per maintenance run, and an unknown birth is an error.
|
|
247
251
|
backfillPiSessions: (database) =>
|
|
@@ -251,6 +255,7 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
251
255
|
deps,
|
|
252
256
|
),
|
|
253
257
|
extractionLimit: resolveMaintenanceMaxExtractions({ env }),
|
|
258
|
+
staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
|
|
254
259
|
monotonic: options.monotonic,
|
|
255
260
|
now: deps.nowIso,
|
|
256
261
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a runtime's session lifecycle lets Mirror do at its edges, by the label
|
|
2
|
+
// US8 stamps on the session row (`claude_code`, `codex`, `grok_build`). One home
|
|
3
|
+
// for the two policies the close path reads (CV21.DS4.TS2, US4).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a runtime's SessionEnd hook has the time to run the LLM tail at exit
|
|
7
|
+
* (CV21.DS4.TS2). Measured bounds, with the evidence:
|
|
8
|
+
*
|
|
9
|
+
* claude_code the plugin registers the hook `async`; Claude Code runs it in
|
|
10
|
+
* the background after exit, unbounded by the user's wait.
|
|
11
|
+
* codex synchronous ("running async SessionEnd hook synchronously",
|
|
12
|
+
* DS12.TS2 L2), 1 s for a handler with no `timeout` (DS2's live
|
|
13
|
+
* `/hooks` table, 2026-10-13, Codex 0.162.1).
|
|
14
|
+
* grok_build 1.5 s for a handler with no `timeout` (DS12.TS4, Grok 1.0.52).
|
|
15
|
+
* DS10 re-measures it and may flip this entry.
|
|
16
|
+
*
|
|
17
|
+
* Any other label, and no label, keeps today's behavior: the tail runs.
|
|
18
|
+
*/
|
|
19
|
+
export function sessionEndRunsLlmTail(interfaceName: string | null | undefined): boolean {
|
|
20
|
+
switch (interfaceName) {
|
|
21
|
+
case "codex":
|
|
22
|
+
case "grok_build":
|
|
23
|
+
return false;
|
|
24
|
+
default:
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a runtime's session outlives its TUI, so that `active = 1` on the
|
|
31
|
+
* session row means RESUMABLE rather than attended (CV21.DS4.US4).
|
|
32
|
+
*
|
|
33
|
+
* codex 0.163.0: `/quit` and Ctrl-D print "Disconnected from this task.
|
|
34
|
+
* Any running work continues. To reconnect, run: codex resume
|
|
35
|
+
* <id>". The thread persists in the shared app-server daemon, and
|
|
36
|
+
* SessionEnd does not fire at TUI exit: Codex's own log shows no
|
|
37
|
+
* hook started after the last turn, and a probe hook pair recorded
|
|
38
|
+
* nothing on either quit path (US4's probe, 2026-10-17). On 0.162.1,
|
|
39
|
+
* four days earlier, `/quit` ended the session and SessionEnd fired
|
|
40
|
+
* (DS2). The session is still re-measured by the next story that
|
|
41
|
+
* depends on it.
|
|
42
|
+
*
|
|
43
|
+
* antigravity 1.4.2: agy exposes no SessionEnd hook at all (its contracts
|
|
44
|
+
* are SessionStart, PreTool, PostTool, PreInvocation,
|
|
45
|
+
* PostInvocation, Stop; CV21.DS7.TS2 probe). Stop fires at each
|
|
46
|
+
* turn's end, never at session end, so nothing closes the
|
|
47
|
+
* conversation at TUI exit — the same situation as Codex, reached
|
|
48
|
+
* by a different absence. The agy Stop handler logs each turn and
|
|
49
|
+
* leaves the conversation open (CV21.DS7.TS3); the orphan close
|
|
50
|
+
* is what ends it, and the next SessionStart sweeps it.
|
|
51
|
+
*
|
|
52
|
+
* For such a runtime, an open conversation idle past the orphan threshold is an
|
|
53
|
+
* orphan even while its session is active, and the close deactivates the session
|
|
54
|
+
* (`closeStaleOrphans`). Every other runtime's session ends with its TUI, and an
|
|
55
|
+
* active session there means a person who may still be typing.
|
|
56
|
+
*/
|
|
57
|
+
export function sessionOutlivesTui(interfaceName: string | null | undefined): boolean {
|
|
58
|
+
return interfaceName === "codex" || interfaceName === "antigravity";
|
|
59
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// report still fails. Freezing real timings would have faked the evidence
|
|
17
17
|
// instead of isolating it.
|
|
18
18
|
|
|
19
|
+
import { finishDeferredCloseTails } from "#conversation/deferredCloseTail.ts";
|
|
19
20
|
import {
|
|
20
21
|
countCarriedOverConversations,
|
|
21
22
|
countConversationsWithExtractionStatus,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
extractPending,
|
|
24
25
|
} from "#conversation/extractionDriver.ts";
|
|
25
26
|
import { setMute } from "#conversation/logger.ts";
|
|
27
|
+
import { sessionOutlivesTui } from "#conversation/runtimePolicy.ts";
|
|
26
28
|
import type { WritableDatabase } from "#db/database.ts";
|
|
27
29
|
import { GLOBAL_STICKY_DEFAULTS_SESSION_ID } from "#mirror/runtimeSession.ts";
|
|
28
30
|
import { deactivateOperatingMode } from "#mode/operatingMode.ts";
|
|
@@ -40,6 +42,12 @@ export interface MaintenanceDeps {
|
|
|
40
42
|
retitleConversation: (db: WritableDatabase, conversationId: string) => Promise<boolean> | boolean;
|
|
41
43
|
/** The DS5 orchestration behind replay with its accounting, as the driver injects it. */
|
|
42
44
|
runExtraction: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
45
|
+
/**
|
|
46
|
+
* The close-time finalization (title, summary, tags) for a conversation a
|
|
47
|
+
* bounded runtime's SessionEnd closed without it (CV21.DS4.TS2). Optional
|
|
48
|
+
* only for callers written before the step existed; the front door wires it.
|
|
49
|
+
*/
|
|
50
|
+
finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
43
51
|
/**
|
|
44
52
|
* `backfillPiSessions` (slice E) bound to its resolved sessions directory.
|
|
45
53
|
* Injected rather than resolved here so the composite never reads the
|
|
@@ -48,6 +56,8 @@ export interface MaintenanceDeps {
|
|
|
48
56
|
backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
|
|
49
57
|
/** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
|
|
50
58
|
extractionLimit?: number;
|
|
59
|
+
/** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
|
|
60
|
+
staleOrphanMinutes?: number;
|
|
51
61
|
/** Monotonic seconds, injected so report timings are pinnable. */
|
|
52
62
|
monotonic?: () => number;
|
|
53
63
|
now?: () => string;
|
|
@@ -64,14 +74,38 @@ export function resetSessionOrientation(db: WritableDatabase, now: () => string
|
|
|
64
74
|
);
|
|
65
75
|
}
|
|
66
76
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Conversation ids bound to an ATTENDED active runtime session: one whose
|
|
79
|
+
* runtime ends the session with its TUI, so `active = 1` means a person may
|
|
80
|
+
* still be typing. A runtime whose session outlives the TUI (Codex 0.163.0,
|
|
81
|
+
* `runtimePolicy.ts`) is left out: there `active` means resumable, and an idle
|
|
82
|
+
* conversation is an orphan like any other (CV21.DS4.US4).
|
|
83
|
+
*/
|
|
84
|
+
function attendedConversationIds(db: WritableDatabase): Set<string> {
|
|
69
85
|
const rows = db
|
|
70
86
|
.prepare(
|
|
71
|
-
"SELECT conversation_id FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
|
|
87
|
+
"SELECT conversation_id, interface FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
|
|
72
88
|
)
|
|
73
89
|
.all() as Record<string, unknown>[];
|
|
74
|
-
return new Set(
|
|
90
|
+
return new Set(
|
|
91
|
+
rows
|
|
92
|
+
.filter(
|
|
93
|
+
(row) => !sessionOutlivesTui(typeof row.interface === "string" ? row.interface : null),
|
|
94
|
+
)
|
|
95
|
+
.map((row) => String(row.conversation_id)),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The runtime sessions still active on a conversation just closed as an orphan:
|
|
101
|
+
* marked inactive with `closed_at`, so `guessRuntimeSessionId` stops reading
|
|
102
|
+
* them as "the session" (DS12.TS2 L9's class), and a later prompt on the same
|
|
103
|
+
* id binds a new conversation.
|
|
104
|
+
*/
|
|
105
|
+
function deactivateSessionsOf(db: WritableDatabase, conversationId: string, now: string): void {
|
|
106
|
+
db.prepare(
|
|
107
|
+
"UPDATE runtime_sessions SET active = 0, closed_at = ?, updated_at = ? WHERE conversation_id = ? AND active = 1",
|
|
108
|
+
).run(now, now, conversationId);
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
/** Python's `get_open_conversations_idle_since`. */
|
|
@@ -91,7 +125,10 @@ function openConversationsIdleSince(db: WritableDatabase, threshold: string): st
|
|
|
91
125
|
|
|
92
126
|
/**
|
|
93
127
|
* Python's `close_stale_orphans`: end open conversations idle longer than the
|
|
94
|
-
* threshold, skipping any still bound to an active runtime session
|
|
128
|
+
* threshold, skipping any still bound to an attended active runtime session
|
|
129
|
+
* (`attendedConversationIds`). Since CV21.DS4.US4 a session on a runtime that
|
|
130
|
+
* outlives its TUI does not protect its conversation, and the close
|
|
131
|
+
* deactivates every session the orphan was bound to.
|
|
95
132
|
*
|
|
96
133
|
* The count increments even when closing raises: `ended_at` is written before
|
|
97
134
|
* the tail, so the orphan IS closed and an extraction failure must not stop the
|
|
@@ -109,15 +146,16 @@ export async function closeStaleOrphans(
|
|
|
109
146
|
const now = deps.now ?? nowIso;
|
|
110
147
|
const threshold = new Date(Date.parse(now()) - thresholdMinutes * 60_000).toISOString();
|
|
111
148
|
|
|
112
|
-
const
|
|
149
|
+
const attended = attendedConversationIds(db);
|
|
113
150
|
let count = 0;
|
|
114
151
|
for (const conversationId of openConversationsIdleSince(db, threshold)) {
|
|
115
|
-
if (
|
|
152
|
+
if (attended.has(conversationId)) continue;
|
|
116
153
|
try {
|
|
117
154
|
await deps.closeConversation(db, conversationId);
|
|
118
155
|
} catch {
|
|
119
156
|
// The orphan is closed regardless; a failed tail must not stop the loop.
|
|
120
157
|
}
|
|
158
|
+
deactivateSessionsOf(db, conversationId, now());
|
|
121
159
|
count += 1;
|
|
122
160
|
}
|
|
123
161
|
return count;
|
|
@@ -142,6 +180,8 @@ export async function retitlePendingConversations(
|
|
|
142
180
|
`SELECT c.id
|
|
143
181
|
FROM conversations c
|
|
144
182
|
WHERE c.ended_at IS NOT NULL
|
|
183
|
+
-- Owed its deferred finalization, which titles it later this run (CV21.DS4.US4).
|
|
184
|
+
AND (c.metadata IS NULL OR json_extract(c.metadata, '$.close_tail_pending') IS NOT 1)
|
|
145
185
|
AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'user')
|
|
146
186
|
AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'assistant')
|
|
147
187
|
ORDER BY c.ended_at DESC, c.started_at DESC
|
|
@@ -203,7 +243,7 @@ export async function sessionMaintenance(
|
|
|
203
243
|
closeStaleOrphans(db, {
|
|
204
244
|
closeConversation: deps.closeConversation,
|
|
205
245
|
now: deps.now,
|
|
206
|
-
thresholdMinutes: STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
246
|
+
thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
207
247
|
}),
|
|
208
248
|
),
|
|
209
249
|
await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
|
|
@@ -213,6 +253,12 @@ export async function sessionMaintenance(
|
|
|
213
253
|
await timedStep("Extracted pending conversations", monotonic, () =>
|
|
214
254
|
extractPending(db, { runExtraction: deps.runExtraction, limit: deps.extractionLimit }),
|
|
215
255
|
),
|
|
256
|
+
// After extraction, so a stored summary is kept rather than generated twice.
|
|
257
|
+
await timedStep("Finished deferred close tails", monotonic, () =>
|
|
258
|
+
deps.finalizeCloseTail
|
|
259
|
+
? finishDeferredCloseTails(db, { finalizeCloseTail: deps.finalizeCloseTail })
|
|
260
|
+
: 0,
|
|
261
|
+
),
|
|
216
262
|
];
|
|
217
263
|
|
|
218
264
|
const parts = ["Conversation maintenance complete."];
|
package/ts/src/docs/docsLint.ts
CHANGED
|
@@ -49,6 +49,14 @@ const FIXTURE_DIR_NAME = "fixtures";
|
|
|
49
49
|
// itself has no `index.md`, by design. Not a broken link.
|
|
50
50
|
const TEMPLATE_DIR_MARKER = "/roadmap/templates/";
|
|
51
51
|
|
|
52
|
+
// Generated byte copies of a root document, each held equal to its source by
|
|
53
|
+
// a guard (`claudePlugin.ts`: the plugin's `rules/AGENTS.md` is `AGENTS.md`'s
|
|
54
|
+
// bytes, CV21.DS7.US1). Their links are the source's links, authored at the
|
|
55
|
+
// repo root, so they resolve there -- a root-relative `REFERENCE.md` can never
|
|
56
|
+
// exist under `rules/`, and skipping the file would hide a link the source
|
|
57
|
+
// itself cannot resolve.
|
|
58
|
+
const ROOT_COPIES = new Set(["plugins/mirror-mind/rules/AGENTS.md"]);
|
|
59
|
+
|
|
52
60
|
export interface BrokenLink {
|
|
53
61
|
readonly sourceFile: string;
|
|
54
62
|
readonly line: number;
|
|
@@ -115,6 +123,11 @@ function isTemplatePlaceholder(sourceFile: string, targetPath: string): boolean
|
|
|
115
123
|
return toPosix(sourceFile).includes(TEMPLATE_DIR_MARKER) && targetPath === "index.md";
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/** The directory a file's relative links resolve from: its own, or the root for a root copy. */
|
|
127
|
+
function linkBase(sourceFile: string, repoRoot: string): string {
|
|
128
|
+
return ROOT_COPIES.has(toPosix(relative(repoRoot, sourceFile))) ? repoRoot : dirname(sourceFile);
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
/** True for a `fixtures` directory nested under a `test`/`tests` one. */
|
|
119
132
|
function isTestFixturePath(path: string): boolean {
|
|
120
133
|
const parts = toPosix(path).split("/");
|
|
@@ -185,7 +198,7 @@ export function checkFile(sourceFile: string, repoRoot: string): BrokenLink[] {
|
|
|
185
198
|
const anchorPart = hashIndex === -1 ? "" : link.slice(hashIndex + 1);
|
|
186
199
|
if (isTemplatePlaceholder(sourceFile, pathPart)) continue;
|
|
187
200
|
|
|
188
|
-
const target = normalize(join(
|
|
201
|
+
const target = normalize(join(linkBase(sourceFile, repoRoot), pathPart));
|
|
189
202
|
if (!existsSync(target)) {
|
|
190
203
|
problems.push({
|
|
191
204
|
sourceFile: relSource,
|
|
@@ -373,10 +373,11 @@ const TS_CONVERSATION_LOGGER_SUBCOMMANDS = new Set([
|
|
|
373
373
|
// CV21.DS2.US8: the Stop hook's reply, deterministic like user-prompt.
|
|
374
374
|
"stop",
|
|
375
375
|
"discard-current",
|
|
376
|
-
// US10 slice F, group 2 (need slice E): diagnose
|
|
376
|
+
// US10 slice F, group 2 (need slice E): diagnose and dry-run repair. (The
|
|
377
|
+
// Codex import routed here too, until CV21.DS4.TS5 retired it with the
|
|
378
|
+
// v0.32.1 Codex wrapper.)
|
|
377
379
|
"diagnose-journeys",
|
|
378
380
|
"repair-journeys",
|
|
379
|
-
"backfill-codex-session",
|
|
380
381
|
]);
|
|
381
382
|
|
|
382
383
|
// CV22.DS7.US10 slice F. These reach Python's `end_conversation`, which runs
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
diagnoseRuntime,
|
|
27
27
|
hookFailureFindings,
|
|
28
28
|
hookNodeFindings,
|
|
29
|
+
pluginWiringFindings,
|
|
29
30
|
probeModelPins,
|
|
30
31
|
renderRuntimeDiagnosis,
|
|
31
32
|
rootStateFindings,
|
|
@@ -301,12 +302,19 @@ export async function runRuntimeReadRoute(
|
|
|
301
302
|
if (subcommand === "diagnose") {
|
|
302
303
|
// The install's own tree, so its worktree findings are Mirror's and never
|
|
303
304
|
// the cwd project's (CR138).
|
|
305
|
+
const install = io.install ?? detectThisInstall();
|
|
304
306
|
const report = buildRuntimeStatus({
|
|
305
307
|
mirrorHome: optionValue(args, "--mirror-home"),
|
|
306
308
|
env,
|
|
307
309
|
version,
|
|
308
|
-
install
|
|
310
|
+
install,
|
|
309
311
|
});
|
|
312
|
+
const installRoot =
|
|
313
|
+
install.kind === "clone"
|
|
314
|
+
? install.repository
|
|
315
|
+
: install.kind === "package"
|
|
316
|
+
? install.root
|
|
317
|
+
: null;
|
|
310
318
|
const findings = [
|
|
311
319
|
...diagnoseRuntime(report, inspectGitWorktree(report.git.repository)),
|
|
312
320
|
...rootStateFindings(homesRoot(env)),
|
|
@@ -324,6 +332,10 @@ export async function runRuntimeReadRoute(
|
|
|
324
332
|
return false;
|
|
325
333
|
}
|
|
326
334
|
}),
|
|
335
|
+
// CV21.DS4.TS6: the plugin wiring. Codex's versioned cache goes stale
|
|
336
|
+
// after an npm update; Claude Code's link can dangle. Absence is not
|
|
337
|
+
// drift, so an unwired machine stays green.
|
|
338
|
+
...pluginWiringFindings(installRoot, env),
|
|
327
339
|
// No catalog provider until DS8: inconclusive, which is also the
|
|
328
340
|
// oracle's answer when it cannot reach OpenRouter.
|
|
329
341
|
...(await probeModelPins(null, "")),
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// A one-shot handoff rather than an argument because the hooks run the front
|
|
11
11
|
// door in-process (`runFrontDoor`), through the same `main(argv)` every other
|
|
12
12
|
// caller uses, and a payload does not belong in argv: argv is what `ps`
|
|
13
|
-
// shows, and what the Gemini hooks
|
|
14
|
-
// this seam avoids repeating.
|
|
13
|
+
// shows, and what the Gemini CLI hooks (retired by CV21.DS8.US1) put a prompt
|
|
14
|
+
// into is the exception this seam avoids repeating.
|
|
15
15
|
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Generate the canonical Mirror Mind Claude plugin (manifest + skills),
|
|
2
|
-
// Claude Code copies of
|
|
1
|
+
// Generate the canonical Mirror Mind Claude plugin (manifest + skills), the
|
|
2
|
+
// Codex marketplace that names it (CV21.DS4.US1), and the Claude Code copies of
|
|
3
|
+
// Pi-sourced skills.
|
|
3
4
|
//
|
|
4
5
|
// The Node port of `src/memory/plugins/claude.py` and its wrapper
|
|
5
6
|
// `scripts/build_claude_plugin.py` (CV22.DS10.TS5, slice B).
|
|
@@ -43,6 +44,12 @@ export const SKILLS_SOURCE_DIR = ".claude/skills";
|
|
|
43
44
|
export const PLUGIN_DIR = "plugins/mirror-mind";
|
|
44
45
|
export const SKILL_FILENAME = "SKILL.md";
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Where Codex reads a marketplace from, under the marketplace root, which for
|
|
49
|
+
* the shipped package is the package root (CV21.DS4.US1; DS12.TS2 static 5).
|
|
50
|
+
*/
|
|
51
|
+
export const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
|
|
52
|
+
|
|
46
53
|
/** A file the generator owns, addressed relative to the repo root. */
|
|
47
54
|
export interface GeneratedFile {
|
|
48
55
|
readonly relativePath: string;
|
|
@@ -142,6 +149,100 @@ export function manifestJson(version: string): string {
|
|
|
142
149
|
return `${JSON.stringify(buildManifest(version), null, 2)}\n`;
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The Codex marketplace the package ships (CV21.DS4.US1).
|
|
154
|
+
*
|
|
155
|
+
* `codex plugin marketplace add <package root>` reads it, and
|
|
156
|
+
* `codex plugin add mirror-mind@mirror-mind` copies the plugin it names into
|
|
157
|
+
* Codex's cache, at the version the plugin manifest carries: the marketplace
|
|
158
|
+
* itself has none. One local source, the plugin directory, relative to the
|
|
159
|
+
* root. Generated beside the manifest so the plugin's name lives in one
|
|
160
|
+
* constant and `--check` guards both files.
|
|
161
|
+
*/
|
|
162
|
+
export function buildMarketplace(): Record<string, unknown> {
|
|
163
|
+
return {
|
|
164
|
+
name: PLUGIN_NAME,
|
|
165
|
+
owner: { name: PLUGIN_AUTHOR },
|
|
166
|
+
plugins: [
|
|
167
|
+
{
|
|
168
|
+
name: PLUGIN_NAME,
|
|
169
|
+
source: { source: "local", path: `./${PLUGIN_DIR}` },
|
|
170
|
+
description: PLUGIN_DESCRIPTION,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The marketplace as deterministic JSON text. */
|
|
177
|
+
export function marketplaceJson(): string {
|
|
178
|
+
return `${JSON.stringify(buildMarketplace(), null, 2)}\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- the agy-native layer (CV21.DS7.US1) -------------------------------------
|
|
182
|
+
//
|
|
183
|
+
// Antigravity treats a directory as a NATIVE plugin only when a root
|
|
184
|
+
// `plugin.json` marks it (or `.antigravity-plugin/`/`.gemini-plugin/`).
|
|
185
|
+
// Without the marker, `agy plugin install` converts the Claude surfaces as an
|
|
186
|
+
// extension and OVERWRITES a shipped root `hooks.json` with Claude's — the
|
|
187
|
+
// probe on agy 1.4.2 showed both behaviors (evidence in the US1 story
|
|
188
|
+
// package). With the marker, validation passes, our root `hooks.json`
|
|
189
|
+
// survives staging, `mcpServers: 1 processed`, and the 25 skills ride along.
|
|
190
|
+
//
|
|
191
|
+
// The layer is four generated files at agy's documented paths: the marker,
|
|
192
|
+
// the hooks skeleton (TS2 adds injection, TS3 adds logging), the MCP map
|
|
193
|
+
// (DERIVED from the manifest's entry — one locator, every carrier), and
|
|
194
|
+
// `rules/AGENTS.md` (agy merges `rules/` into the active rule set; the guard
|
|
195
|
+
// comparing committed to generated bytes is the sync with `AGENTS.md`).
|
|
196
|
+
|
|
197
|
+
/** The native-plugin marker. Claude Code and Codex read `.claude-plugin/`. */
|
|
198
|
+
export function buildAgyPluginMarker(): Record<string, unknown> {
|
|
199
|
+
return { name: PLUGIN_NAME, description: PLUGIN_DESCRIPTION };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function agyPluginMarkerJson(): string {
|
|
203
|
+
return `${JSON.stringify(buildAgyPluginMarker(), null, 2)}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The named hook with its handlers (CV21.DS7.TS2). Commands are relative:
|
|
208
|
+
* agy sets a handler's cwd to the directory holding `hooks.json`, and the
|
|
209
|
+
* wrappers live beside it in `agy/` (generated agy-form; the pre-invocation
|
|
210
|
+
* wrapper gates on stdin before Node is ever resolved, because
|
|
211
|
+
* `PreInvocation` fires before every model call and blocks the agent loop).
|
|
212
|
+
* Timeouts are explicit and small: the fast path is shell-instant, the slow
|
|
213
|
+
* path is one `mirror load`, and a hang here freezes the agent — which a
|
|
214
|
+
* user reads as "Antigravity broke", not "Mirror hung". `Stop` is TS3's.
|
|
215
|
+
*/
|
|
216
|
+
export function buildAgyHooks(): Record<string, unknown> {
|
|
217
|
+
return {
|
|
218
|
+
[PLUGIN_NAME]: {
|
|
219
|
+
enabled: true,
|
|
220
|
+
SessionStart: [{ type: "command", command: "agy/session-start.sh", timeout: 60 }],
|
|
221
|
+
PreInvocation: [{ type: "command", command: "agy/pre-invocation.sh", timeout: 15 }],
|
|
222
|
+
// CV21.DS7.TS3: Stop fires at each turn's end (agy has no SessionEnd);
|
|
223
|
+
// the handler reads the transcript and logs the turn as antigravity.
|
|
224
|
+
Stop: [{ type: "command", command: "agy/stop.sh", timeout: 30 }],
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function agyHooksJson(): string {
|
|
230
|
+
return `${JSON.stringify(buildAgyHooks(), null, 2)}\n`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** agy's `mcp_config.json`: the same `mcpServers` map the manifest carries. */
|
|
234
|
+
export function buildAgyMcpConfig(): Record<string, unknown> {
|
|
235
|
+
return {
|
|
236
|
+
mcpServers: {
|
|
237
|
+
[PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function agyMcpConfigJson(): string {
|
|
243
|
+
return `${JSON.stringify(buildAgyMcpConfig(), null, 2)}\n`;
|
|
244
|
+
}
|
|
245
|
+
|
|
145
246
|
/**
|
|
146
247
|
* The skill markdown file, matching `skill.md` case-insensitively.
|
|
147
248
|
*
|
|
@@ -232,6 +333,16 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
|
|
|
232
333
|
relativePath: join(PLUGIN_DIR, ".claude-plugin", "plugin.json"),
|
|
233
334
|
content: manifestJson(version),
|
|
234
335
|
},
|
|
336
|
+
{ relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
|
|
337
|
+
// The agy-native layer (CV21.DS7.US1).
|
|
338
|
+
{ relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson() },
|
|
339
|
+
{ relativePath: join(PLUGIN_DIR, "hooks.json"), content: agyHooksJson() },
|
|
340
|
+
{ relativePath: join(PLUGIN_DIR, "mcp_config.json"), content: agyMcpConfigJson() },
|
|
341
|
+
{
|
|
342
|
+
relativePath: join(PLUGIN_DIR, "rules", "AGENTS.md"),
|
|
343
|
+
content: readFileSync(join(repoRoot, "AGENTS.md"), "utf8"),
|
|
344
|
+
source: "AGENTS.md",
|
|
345
|
+
},
|
|
235
346
|
...claudeCopies.values(),
|
|
236
347
|
...[...pluginSkills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, file]) => file),
|
|
237
348
|
];
|
|
@@ -26,9 +26,7 @@ export const ALLOWED_PREFIXES: readonly string[] = [
|
|
|
26
26
|
".pi/extensions/",
|
|
27
27
|
".pi/skills/",
|
|
28
28
|
".claude/hooks/",
|
|
29
|
-
".gemini/hooks/",
|
|
30
29
|
"plugins/mirror-mind/",
|
|
31
|
-
"scripts/codex-hooks/",
|
|
32
30
|
];
|
|
33
31
|
|
|
34
32
|
export const ALLOWED_FILES: readonly string[] = [
|
|
@@ -37,7 +35,9 @@ export const ALLOWED_FILES: readonly string[] = [
|
|
|
37
35
|
"LICENSE",
|
|
38
36
|
"AGENTS.md",
|
|
39
37
|
"ts/README.md",
|
|
40
|
-
|
|
38
|
+
// CV21.DS4.US1: the Codex marketplace, read from the package root. The
|
|
39
|
+
// `.agents/skills/` symlinks beside it are the checkout's and stay out.
|
|
40
|
+
".agents/plugins/marketplace.json",
|
|
41
41
|
];
|
|
42
42
|
|
|
43
43
|
/** Files that must be in every tarball: the entries and one of each runtime's wiring. */
|
|
@@ -55,10 +55,11 @@ export const REQUIRED_FILES: readonly string[] = [
|
|
|
55
55
|
".pi/extensions/mirror-logger.ts",
|
|
56
56
|
".pi/skills/mm-mirror/SKILL.md",
|
|
57
57
|
".claude/hooks/session-start.sh",
|
|
58
|
-
".gemini/hooks/session-start.sh",
|
|
59
58
|
"plugins/mirror-mind/hooks/hooks.json",
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// CV21.DS7.US1: the agy-native layer's hooks file. A tarball cannot ship
|
|
60
|
+
// the skills without the layer Antigravity runs them beside.
|
|
61
|
+
"plugins/mirror-mind/hooks.json",
|
|
62
|
+
".agents/plugins/marketplace.json",
|
|
62
63
|
];
|
|
63
64
|
|
|
64
65
|
/** Prefixes that must have at least one file: content the program reads at runtime. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Skills with one body in every runtime (CR102).
|
|
2
2
|
//
|
|
3
3
|
// Most Mirror skills have two authored bodies. `.pi/skills/` serves Pi, and
|
|
4
|
-
// through the `.agents/skills/` links Codex
|
|
4
|
+
// through the `.agents/skills/` links Codex too. `.claude/skills/`
|
|
5
5
|
// serves Claude Code, and `claudePlugin.ts` copies it into the plugin. CR071
|
|
6
6
|
// kept the two apart on purpose: Usage sections, example language, and in
|
|
7
7
|
// `mm-soul` and `mm-mirror` whole lines that are true for one runtime only.
|
|
@@ -460,6 +460,42 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
460
460
|
// exploration's record is `docs/project/exploration/es-004-*.md`.
|
|
461
461
|
exemptions: {},
|
|
462
462
|
},
|
|
463
|
+
{
|
|
464
|
+
surfaceId: "codex-wrapper",
|
|
465
|
+
story: "CV21.DS4.TS5",
|
|
466
|
+
// The v0.32.1 Codex wiring: a wrapper script around the `codex` command,
|
|
467
|
+
// its two generated hooks, the `codex:*` hook family, and the JSONL
|
|
468
|
+
// backfill the wrapper drove at exit. Retired once US4 measured Codex at
|
|
469
|
+
// L4 through the shipped plugin, live: the plugin's Claude-family hooks
|
|
470
|
+
// log Codex turns as they happen, so nothing re-imports the transcript.
|
|
471
|
+
// The backfill's only caller was the wrapper's session-end hook, and the
|
|
472
|
+
// plugin's SessionEnd closes the session without reading it -- the
|
|
473
|
+
// condition DS4 set for keeping it was not met.
|
|
474
|
+
absentPaths: [
|
|
475
|
+
"scripts/codex-mirror.sh",
|
|
476
|
+
"scripts/codex-hooks/",
|
|
477
|
+
"ts/src/hooks/codex.ts",
|
|
478
|
+
"scripts/smoke_codex.sh",
|
|
479
|
+
"ts/test/fixtures/backfill/codex/",
|
|
480
|
+
],
|
|
481
|
+
// File and route names, never vocabulary: "codex" is a living word (the
|
|
482
|
+
// plugin serves Codex; `runtimeDetection.ts` labels its payloads).
|
|
483
|
+
forbiddenPatterns: [
|
|
484
|
+
"codex-mirror\\.sh",
|
|
485
|
+
"codex-hooks",
|
|
486
|
+
"codex:session-(start|end)",
|
|
487
|
+
"backfill-codex-session",
|
|
488
|
+
"backfill_codex_session",
|
|
489
|
+
"backfillCodexSession",
|
|
490
|
+
"smoke_codex",
|
|
491
|
+
],
|
|
492
|
+
exemptions: {
|
|
493
|
+
"docs/getting-started.md":
|
|
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
|
+
},
|
|
498
|
+
},
|
|
463
499
|
{
|
|
464
500
|
// Not a deleted surface: a retired IDIOM. A URL's pathname is not a
|
|
465
501
|
// filesystem path -- it percent-encodes `Código` and `John Doe`, and on
|
|
@@ -478,6 +514,37 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
478
514
|
residueScope: ["ts/src/", "ts/scripts/"],
|
|
479
515
|
exemptions: {},
|
|
480
516
|
},
|
|
517
|
+
{
|
|
518
|
+
surfaceId: "gemini-cli",
|
|
519
|
+
story: "CV21.DS8.US1",
|
|
520
|
+
// The Gemini CLI integration: four generated wrappers under `.gemini/hooks`,
|
|
521
|
+
// the `gemini:*` hook family behind them, the two `mirror init` steps, and
|
|
522
|
+
// the Gemini smoke. Retired once DS7.US4 measured Antigravity at L4 on agy
|
|
523
|
+
// 1.4.2 -- a Gemini CLI user loses nothing by moving. `.gemini/` is an
|
|
524
|
+
// absent path and not merely an unallowed one because `mirror init` told
|
|
525
|
+
// users to write absolute paths into that directory into their
|
|
526
|
+
// `~/.gemini/settings.json`: a standing instruction to execute whatever a
|
|
527
|
+
// later release might ship there. This row is what keeps that from
|
|
528
|
+
// happening.
|
|
529
|
+
absentPaths: [".gemini/", "ts/src/hooks/gemini.ts", "scripts/smoke_gemini_cli.sh"],
|
|
530
|
+
// File and route names, never vocabulary: "gemini" is a living word (the
|
|
531
|
+
// extraction and consult models, Antigravity's own `~/.gemini/` home, the
|
|
532
|
+
// `gemini_cli` label on conversations already logged). The row landed in
|
|
533
|
+
// two steps: US1 carried the names only code and CI used, and
|
|
534
|
+
// CV21.DS8.US2 added the doc-facing ones when it cleared the runtime-facing
|
|
535
|
+
// docs, so no exemption was ever staged. Not `.gemini/settings.json` and
|
|
536
|
+
// not `~/.gemini`: that directory is Antigravity's home too, and the
|
|
537
|
+
// cutoff note names the file a Gemini CLI user edits.
|
|
538
|
+
forbiddenPatterns: [
|
|
539
|
+
"smoke_gemini_cli",
|
|
540
|
+
"geminiHooksSettings",
|
|
541
|
+
"hooks/gemini\\.ts",
|
|
542
|
+
"\\.gemini/hooks",
|
|
543
|
+
"gemini:(session-start|session-end|log-user|log-assistant)",
|
|
544
|
+
"gemini skills link",
|
|
545
|
+
],
|
|
546
|
+
exemptions: {},
|
|
547
|
+
},
|
|
481
548
|
];
|
|
482
549
|
|
|
483
550
|
export interface SurfaceProblem {
|