mirror-mind 0.36.2 → 0.37.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.
Files changed (50) hide show
  1. package/.pi/extensions/mirror-logger.ts +293 -64
  2. package/.pi/skills/mm-help/SKILL.md +4 -1
  3. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  4. package/README.md +3 -3
  5. package/docs/releases/index.md +2 -0
  6. package/docs/releases/v0.36.3.md +142 -0
  7. package/docs/releases/v0.37.0.md +221 -0
  8. package/package.json +1 -1
  9. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  10. package/plugins/mirror-mind/plugin.json +1 -1
  11. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  12. package/ts/src/builder/commands.ts +9 -6
  13. package/ts/src/builder/load.ts +14 -2
  14. package/ts/src/conversation/commandTurn.ts +3 -2
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/piSkillTurns.ts +267 -0
  17. package/ts/src/db/migrateOnOpen.ts +1 -1
  18. package/ts/src/db/migrations.ts +16 -0
  19. package/ts/src/db/schema.ts +0 -1
  20. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  21. package/ts/src/db/schemaState.ts +2 -0
  22. package/ts/src/db/schemaTsDivergence.ts +25 -4
  23. package/ts/src/frontDoor/cli.ts +58 -2
  24. package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -2
  25. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  26. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  27. package/ts/src/frontDoor/routing.ts +26 -15
  28. package/ts/src/frontDoor/skillTurnsRoute.ts +213 -0
  29. package/ts/src/frontDoor/soulRoute.ts +4 -4
  30. package/ts/src/frontDoor/stdinSource.ts +5 -0
  31. package/ts/src/guards/testTempHomes.ts +92 -0
  32. package/ts/src/hooks/agy.ts +136 -59
  33. package/ts/src/hooks/claude.ts +29 -90
  34. package/ts/src/hooks/mirrorState.ts +10 -22
  35. package/ts/src/mirror/context.ts +19 -4
  36. package/ts/src/mirror/defaultResolution.ts +2 -1
  37. package/ts/src/mirror/doorman.ts +420 -0
  38. package/ts/src/mirror/mirrorForm.ts +4 -1
  39. package/ts/src/mirror/orchestration.ts +22 -3
  40. package/ts/src/mirror/render.ts +7 -1
  41. package/ts/src/mirror/runtimeSession.ts +66 -8
  42. package/ts/src/mode/operatingMode.ts +22 -4
  43. package/ts/src/providers/transport.ts +14 -0
  44. package/ts/src/runtime/diagnose.ts +32 -7
  45. package/ts/src/runtime/pluginWiring.ts +6 -4
  46. package/ts/src/runtime/status.ts +3 -2
  47. package/ts/src/runtime/update.ts +17 -1
  48. package/ts/src/runtime/wiring.ts +14 -1
  49. package/ts/src/search/memorySearch.ts +38 -18
  50. package/ts/src/search/ranker.ts +16 -2
@@ -0,0 +1,267 @@
1
+ // The Pi user turns an earlier version stored as a skill's body, and their repair
2
+ // (CR160).
3
+ //
4
+ // Pi turns a prompt that opens with `/skill:<name>`, which is also what its command
5
+ // menu sends, into the skill's whole body before its extensions see it. Until CR160
6
+ // Pi's extension logged that prompt, so a skill invocation was stored as
7
+ // `<skill name="…" location="…">` and the body, as if the person had said it; every
8
+ // later reader -- the title, the embedding, extraction -- read it so.
9
+ //
10
+ // The repair applies the rule the core logs by (`commandTurn.ts`): a Mirror
11
+ // question keeps its question, without the block (`mirrorForm.ts`); any other skill
12
+ // invocation is not conversation, and neither are its arguments (a `mm-build`
13
+ // slug), so its row goes. A conversation the repair leaves with no user turn goes
14
+ // too, through the shared deletion, which keeps its memories unlinked (the
15
+ // Navigator's decision, A7). A conversation titled from a block, and not titled by
16
+ // hand, gets the title every conversation gets from its first user turn, marked
17
+ // provisional so the title paths can improve it. Replies stay: they are what the
18
+ // mirror said, and a reply to a skipped turn cannot be told from another. Summaries
19
+ // and their embeddings stay: they describe a session in which a skill was invoked
20
+ // (A6).
21
+ //
22
+ // Only Pi's whole wrapper, only in Pi conversations (A8): a person may type or
23
+ // paste `<skill name=` at the start of a message anywhere. One more shape counts:
24
+ // the wrapper the old logger cut at its size limit, whose ending only it writes.
25
+
26
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
27
+ import { deleteConversation } from "#conversation/conversationDeletion.ts";
28
+ import { generateTitle, setProvisionalTitle } from "#conversation/logger.ts";
29
+ import { type Database, type WritableDatabase, withTransaction } from "#db/database.ts";
30
+
31
+ /**
32
+ * Pi's expansion of `/skill:<name> args`, whole: the opening tag, Pi's line naming
33
+ * where the skill's references resolve, the body, the closing tag, and the
34
+ * arguments after a blank line when there are any (Pi 1.3.0 and 1.4.0,
35
+ * `_expandSkillCommand`). Pi's own extension reads the same shape back
36
+ * (`.pi/extensions/mirror-logger.ts`); `piSkillTurns.test.ts` holds the two alike.
37
+ */
38
+ export const PI_SKILL_WRAPPER =
39
+ /^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]*))?$/;
40
+
41
+ /**
42
+ * The same block when the old logger cut it: the extension kept the first 50,000
43
+ * characters and added its own marker (`truncate`, `.pi/extensions/mirror-logger.ts`),
44
+ * so the closing tag never arrived. Pi's opening lines, then an ending only the
45
+ * logger writes: no person types that. Whatever followed the block was cut with it.
46
+ */
47
+ const PI_SKILL_WRAPPER_CUT =
48
+ /^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*\n\[… truncated\]$/;
49
+
50
+ /** The one skill whose turn is conversation: its question is the turn. */
51
+ const MIRROR_SKILL = "mm-mirror";
52
+
53
+ export interface StoredSkillTurn {
54
+ messageId: string;
55
+ conversationId: string;
56
+ skill: string;
57
+ /** What followed the block, trimmed; "" when nothing did. */
58
+ tail: string;
59
+ /** The Mirror question the row keeps, or null when the row goes. */
60
+ question: string | null;
61
+ }
62
+
63
+ export interface ConversationToDelete {
64
+ id: string;
65
+ title: string | null;
66
+ messageCount: number;
67
+ startedAt: string;
68
+ }
69
+
70
+ export interface ConversationToRetitle {
71
+ id: string;
72
+ title: string;
73
+ newTitle: string;
74
+ }
75
+
76
+ export interface SkillTurnRepairPlan {
77
+ /** Every stored skill turn, in the order the rows were written. */
78
+ turns: StoredSkillTurn[];
79
+ /** The Mirror questions: each row keeps its question. */
80
+ kept: StoredSkillTurn[];
81
+ /** Every other skill turn: each row goes. */
82
+ removed: StoredSkillTurn[];
83
+ conversationsToDelete: ConversationToDelete[];
84
+ conversationsToRetitle: ConversationToRetitle[];
85
+ }
86
+
87
+ export interface SkillTurnRepairResult {
88
+ kept: number;
89
+ removed: number;
90
+ conversationsDeleted: number;
91
+ /** Messages that went with the deleted conversations, beyond the removed turns. */
92
+ messagesDeletedWithConversations: number;
93
+ conversationsRetitled: number;
94
+ }
95
+
96
+ /** The set the transaction found is not the set the person was shown. */
97
+ export class SkillTurnRepairSetMovedError extends Error {
98
+ constructor() {
99
+ super("the stored skill turns changed since they were listed");
100
+ this.name = "SkillTurnRepairSetMovedError";
101
+ }
102
+ }
103
+
104
+ /** The skill turn a stored row holds, or null when the row is not Pi's whole wrapper. */
105
+ export function readSkillTurn(content: string): { skill: string; tail: string } | null {
106
+ const match = PI_SKILL_WRAPPER.exec(content);
107
+ if (match) return { skill: match[1] as string, tail: (match[2] ?? "").trim() };
108
+ const cut = PI_SKILL_WRAPPER_CUT.exec(content);
109
+ if (cut) return { skill: cut[1] as string, tail: "" };
110
+ return null;
111
+ }
112
+
113
+ function isManualTitle(rawMetadata: unknown): boolean {
114
+ if (typeof rawMetadata !== "string") return false;
115
+ try {
116
+ const metadata = JSON.parse(rawMetadata) as Record<string, unknown>;
117
+ return metadata.title_status === "manual" || metadata.title_source === "manual";
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * What the repair would change, decided once for the dry run and again inside the
125
+ * repair's transaction.
126
+ */
127
+ export function planSkillTurnRepair(db: Database): SkillTurnRepairPlan {
128
+ // SQL narrows to the prefix; `readSkillTurn` decides on the whole wrapper.
129
+ const candidates = db
130
+ .prepare(
131
+ `SELECT m.id, m.conversation_id, m.content
132
+ FROM messages m JOIN conversations c ON c.id = m.conversation_id
133
+ WHERE c.interface = 'pi' AND m.role = 'user' AND substr(m.content, 1, 13) = '<skill name="'
134
+ ORDER BY m.created_at, m.rowid`,
135
+ )
136
+ .all();
137
+ const turns: StoredSkillTurn[] = [];
138
+ for (const row of candidates) {
139
+ const read = readSkillTurn(String(row.content));
140
+ if (read === null) continue;
141
+ const question = read.skill === MIRROR_SKILL && read.tail !== "" ? read.tail : null;
142
+ turns.push({
143
+ messageId: String(row.id),
144
+ conversationId: String(row.conversation_id),
145
+ skill: read.skill,
146
+ tail: read.tail,
147
+ question,
148
+ });
149
+ }
150
+ const kept = turns.filter((turn) => turn.question !== null);
151
+ const removed = turns.filter((turn) => turn.question === null);
152
+ const removedIds = new Set(removed.map((turn) => turn.messageId));
153
+ const keptQuestion = new Map(kept.map((turn) => [turn.messageId, turn.question as string]));
154
+
155
+ // A conversation the repair leaves with no user turn goes. Otherwise a block-born
156
+ // title is rebuilt from its first remaining user turn that is not a command: a
157
+ // command is not conversation (`commandTurn.ts`), so it names one only when
158
+ // nothing else remains.
159
+ const touched = [...new Set(turns.map((turn) => turn.conversationId))];
160
+ const userRows = db.prepare(
161
+ `SELECT id, content FROM messages WHERE conversation_id = ? AND role = 'user'
162
+ ORDER BY created_at, rowid`,
163
+ );
164
+ const remainingUserTurns = (conversationId: string, removing: ReadonlySet<string>): string[] =>
165
+ userRows
166
+ .all(conversationId)
167
+ .filter((row) => !removing.has(String(row.id)))
168
+ .map((row) => keptQuestion.get(String(row.id)) ?? String(row.content))
169
+ .filter((content) => content.trim() !== "");
170
+ const titleSource = (remaining: readonly string[]): string | null =>
171
+ remaining.find((content) => !isCommandTurn(content)) ?? remaining[0] ?? null;
172
+ const remainingFirstUser = new Map<string, string | null>();
173
+ for (const conversationId of touched) {
174
+ remainingFirstUser.set(
175
+ conversationId,
176
+ titleSource(remainingUserTurns(conversationId, removedIds)),
177
+ );
178
+ }
179
+
180
+ const conversationRow = db.prepare(
181
+ `SELECT id, title, started_at, metadata,
182
+ (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count
183
+ FROM conversations c WHERE id = ?`,
184
+ );
185
+ const conversationsToDelete: ConversationToDelete[] = [];
186
+ for (const conversationId of touched) {
187
+ if (remainingFirstUser.get(conversationId) !== null) continue;
188
+ const row = conversationRow.get(conversationId);
189
+ if (!row) continue;
190
+ conversationsToDelete.push({
191
+ id: conversationId,
192
+ title: typeof row.title === "string" ? row.title : null,
193
+ messageCount: Number(row.message_count),
194
+ startedAt: String(row.started_at),
195
+ });
196
+ }
197
+ const deleting = new Set(conversationsToDelete.map((item) => item.id));
198
+
199
+ // Titles born of a block, in any Pi conversation that stays, unless set by hand.
200
+ const titled = db
201
+ .prepare(
202
+ `SELECT id, title, metadata FROM conversations
203
+ WHERE interface = 'pi' AND lower(substr(trim(title), 1, 6)) = '<skill'
204
+ ORDER BY started_at, id`,
205
+ )
206
+ .all();
207
+ const conversationsToRetitle: ConversationToRetitle[] = [];
208
+ for (const row of titled) {
209
+ const id = String(row.id);
210
+ if (deleting.has(id) || isManualTitle(row.metadata)) continue;
211
+ const source = remainingFirstUser.has(id)
212
+ ? remainingFirstUser.get(id)
213
+ : titleSource(remainingUserTurns(id, new Set()));
214
+ if (!source || readSkillTurn(source) !== null) continue;
215
+ const newTitle = generateTitle(source).trim();
216
+ if (newTitle === "") continue;
217
+ conversationsToRetitle.push({ id, title: String(row.title), newTitle });
218
+ }
219
+
220
+ return { turns, kept, removed, conversationsToDelete, conversationsToRetitle };
221
+ }
222
+
223
+ /** One string that names everything a plan changes, to compare a plan with the one shown. */
224
+ export function skillTurnRepairKey(plan: SkillTurnRepairPlan): string {
225
+ const sorted = (values: readonly string[]) => [...values].sort().join(",");
226
+ return [
227
+ sorted(plan.kept.map((turn) => turn.messageId)),
228
+ sorted(plan.removed.map((turn) => turn.messageId)),
229
+ sorted(plan.conversationsToDelete.map((item) => item.id)),
230
+ sorted(plan.conversationsToRetitle.map((item) => `${item.id}=${item.newTitle}`)),
231
+ ].join("|");
232
+ }
233
+
234
+ /**
235
+ * Repair what the plan names, in one transaction: all or nothing. The plan is
236
+ * decided again inside the transaction and must be the one the person was shown
237
+ * (`expectedKey`); if it moved, nothing changes.
238
+ */
239
+ export function applySkillTurnRepair(
240
+ db: WritableDatabase,
241
+ expectedKey: string,
242
+ ): SkillTurnRepairResult {
243
+ return withTransaction(db, () => {
244
+ const plan = planSkillTurnRepair(db);
245
+ if (skillTurnRepairKey(plan) !== expectedKey) throw new SkillTurnRepairSetMovedError();
246
+ const keepQuestion = db.prepare("UPDATE messages SET content = ? WHERE id = ?");
247
+ for (const turn of plan.kept) keepQuestion.run(turn.question as string, turn.messageId);
248
+ const removeTurn = db.prepare("DELETE FROM messages WHERE id = ?");
249
+ for (const turn of plan.removed) removeTurn.run(turn.messageId);
250
+ const remainingMessages = db.prepare(
251
+ "SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ?",
252
+ );
253
+ let messagesDeletedWithConversations = 0;
254
+ for (const item of plan.conversationsToDelete) {
255
+ messagesDeletedWithConversations += Number(remainingMessages.get(item.id)?.n ?? 0);
256
+ deleteConversation(db, item.id);
257
+ }
258
+ for (const item of plan.conversationsToRetitle) setProvisionalTitle(db, item.id, item.newTitle);
259
+ return {
260
+ kept: plan.kept.length,
261
+ removed: plan.removed.length,
262
+ conversationsDeleted: plan.conversationsToDelete.length,
263
+ messagesDeletedWithConversations,
264
+ conversationsRetitled: plan.conversationsToRetitle.length,
265
+ };
266
+ });
267
+ }
@@ -132,7 +132,7 @@ function appliedMigrationIds(dbPath: string, options: OpenOptions): Set<string>
132
132
  * No split any more. Until CV22.DS10.TS5 this separated the TS-authored tail
133
133
  * from Python-authored ids and declined the whole thing whenever a Python one
134
134
  * was pending -- correct while Python could still apply them. TypeScript
135
- * carries all seventeen and is the only engine left, so every pending known
135
+ * carries all of them and is the only engine left, so every pending known
136
136
  * migration is simply pending.
137
137
  */
138
138
  function pendingMigrations(applied: Set<string>): string[] {
@@ -514,6 +514,21 @@ function migrateJourneyParentColumn(db: WritableDatabase): void {
514
514
  );
515
515
  }
516
516
 
517
+ /**
518
+ * 018 (CV9.DS8.US5) -- `runtime_sessions.hook_injected` goes. It was the
519
+ * Claude-family inject hook's once-per-session flag (CV21.DS2); US4 handed the
520
+ * hook to the one doorman and retired its last reader, and the four writers
521
+ * that still filled it were carried as one debt item, paid here with the
522
+ * column. Guarded on the column's presence: a fresh database's `createSchema`
523
+ * no longer has it, and a re-run finds nothing to drop. `DROP COLUMN` is
524
+ * SQLite 3.35; the engine ships 3.54.
525
+ */
526
+ function migrateDropHookInjected(db: WritableDatabase): void {
527
+ if (!tableExists(db, "runtime_sessions")) return;
528
+ if (!tableColumns(db, "runtime_sessions").has("hook_injected")) return;
529
+ db.exec("ALTER TABLE runtime_sessions DROP COLUMN hook_injected");
530
+ }
531
+
517
532
  export type MigrationApply = (db: WritableDatabase) => void;
518
533
 
519
534
  export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
@@ -534,6 +549,7 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
534
549
  ["015_create_builder_workbench", migrateCreateBuilderWorkbench],
535
550
  ["016_builder_workbench_display_codes", migrateBuilderWorkbenchDisplayCodes],
536
551
  ["017_journey_parent_column", migrateJourneyParentColumn],
552
+ ["018_drop_hook_injected", migrateDropHookInjected],
537
553
  ];
538
554
 
539
555
  /**
@@ -84,7 +84,6 @@ CREATE TABLE IF NOT EXISTS runtime_sessions (
84
84
  mirror_active INTEGER NOT NULL DEFAULT 0,
85
85
  persona TEXT,
86
86
  journey TEXT,
87
- hook_injected INTEGER NOT NULL DEFAULT 0,
88
87
  active INTEGER NOT NULL DEFAULT 1,
89
88
  started_at TEXT NOT NULL,
90
89
  updated_at TEXT NOT NULL,
@@ -1925,13 +1925,6 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1925
1925
  "pk": 0,
1926
1926
  "type": "TEXT"
1927
1927
  },
1928
- {
1929
- "dflt_value": "0",
1930
- "name": "hook_injected",
1931
- "notnull": 1,
1932
- "pk": 0,
1933
- "type": "INTEGER"
1934
- },
1935
1928
  {
1936
1929
  "dflt_value": "1",
1937
1930
  "name": "active",
@@ -1979,7 +1972,7 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1979
1972
  }
1980
1973
  ],
1981
1974
  "sql":
1982
- "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, hook_injected INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1975
+ "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1983
1976
  },
1984
1977
  "tasks": {
1985
1978
  "columns": [
@@ -47,6 +47,8 @@ export const KNOWN_MIGRATION_IDS: readonly string[] = [
47
47
  "016_builder_workbench_display_codes",
48
48
  // TS-authored, no Python counterpart (CV22.DS6.US2 — first TS ⊋ Python migration).
49
49
  "017_journey_parent_column",
50
+ // CV9.DS8.US5: the inject hook's once-per-session flag, without a reader since US4.
51
+ "018_drop_hook_injected",
50
52
  ];
51
53
 
52
54
  /**
@@ -17,6 +17,15 @@ const TS_ONLY_COLUMNS: Record<string, ColumnInventory[]> = {
17
17
  identity: [{ name: "parent_journey", type: "TEXT", notnull: 0, dflt_value: null, pk: 0 }],
18
18
  };
19
19
 
20
+ /**
21
+ * Columns the Python snapshot has that TS has dropped (CV9.DS8.US5, migration
22
+ * 018): `runtime_sessions.hook_injected`, the inject hook's once-per-session
23
+ * flag, without a reader since CV9.DS8.US4.
24
+ */
25
+ const TS_REMOVED_COLUMNS: Record<string, string[]> = {
26
+ runtime_sessions: ["hook_injected"],
27
+ };
28
+
20
29
  /** Indexes TS creates that the Python snapshot does not have. */
21
30
  const TS_ONLY_INDEXES: Record<string, { table: string; unique: number; columns: string[] }> = {
22
31
  idx_identity_parent_journey: { table: "identity", unique: 0, columns: ["parent_journey"] },
@@ -46,21 +55,33 @@ export function diffTsInventoryAgainstSnapshot(
46
55
  const actualTable = actual.tables[name];
47
56
  if (!actualTable) continue;
48
57
  const additions = TS_ONLY_COLUMNS[name] ?? [];
49
- const expectedColumns = [...snapshot.tables[name].columns, ...additions];
58
+ const removals = TS_REMOVED_COLUMNS[name] ?? [];
59
+ const expectedColumns = [
60
+ ...snapshot.tables[name].columns.filter((column) => !removals.includes(column.name)),
61
+ ...additions,
62
+ ];
50
63
  if (!isDeepStrictEqual(actualTable.columns, expectedColumns)) {
51
- problems.push(`tables[${name}].columns differ from snapshot + enumerated TS-only additions`);
64
+ problems.push(
65
+ `tables[${name}].columns differ from snapshot + enumerated TS-only additions and removals`,
66
+ );
52
67
  }
53
68
  if (!isDeepStrictEqual(actualTable.foreign_keys, snapshot.tables[name].foreign_keys)) {
54
69
  problems.push(`tables[${name}].foreign_keys differ from snapshot`);
55
70
  }
56
- if (additions.length > 0) {
71
+ if (additions.length > 0 || removals.length > 0) {
57
72
  // The raw CREATE TABLE text diverges by exactly these columns; assert each
58
- // added column name is present rather than reproduce SQLite's stored text.
73
+ // added column name is present and each removed one absent, rather than
74
+ // reproduce SQLite's stored text.
59
75
  for (const column of additions) {
60
76
  if (!(actualTable.sql ?? "").includes(column.name)) {
61
77
  problems.push(`tables[${name}].sql is missing TS-only column ${column.name}`);
62
78
  }
63
79
  }
80
+ for (const column of removals) {
81
+ if ((actualTable.sql ?? "").includes(column)) {
82
+ problems.push(`tables[${name}].sql still carries the dropped column ${column}`);
83
+ }
84
+ }
64
85
  } else if (!isDeepStrictEqual(actualTable.sql, snapshot.tables[name].sql)) {
65
86
  problems.push(`tables[${name}].sql differs from snapshot`);
66
87
  }
@@ -52,6 +52,7 @@ import { setIdentity } from "#identity/setIdentity.ts";
52
52
  import { IdentityRootExistsError, initUserHome, TemplatesNotFoundError } from "#init/init.ts";
53
53
  import { JOURNEY_PATH_LAYER } from "#journey/journeyStatus.ts";
54
54
  import { JourneyNotFoundError } from "#journey/journeyWrite.ts";
55
+ import { environmentSessionId } from "#mirror/runtimeSession.ts";
55
56
  import {
56
57
  CallOutcomeTally,
57
58
  formatCallOutcome,
@@ -116,9 +117,11 @@ import {
116
117
  import { applyJourneySetPath } from "./journeyWriteRoute.ts";
117
118
  import { openLiveWriteDatabase } from "./liveBackup.ts";
118
119
  import {
120
+ isMirrorDoorman,
119
121
  isMirrorWrite,
120
122
  isModeWrite,
121
123
  mirrorDeactivateMissingSession,
124
+ runMirrorDoormanRoute,
122
125
  runMirrorRead,
123
126
  runMirrorWriteRoute,
124
127
  runModeRead,
@@ -172,6 +175,7 @@ import { runRuntimeReadRoute, runWelcomeRoute } from "./runtimeRoute.ts";
172
175
  import { runMemorySearchRoute } from "./searchRoute.ts";
173
176
  import { resolveSeedPaths } from "./seedPaths.ts";
174
177
  import { isSessionStartWelcome, runSessionStartWelcomeRoute } from "./sessionStartWelcomeRoute.ts";
178
+ import { isSkillTurnsCommand, runSkillTurnsRoute } from "./skillTurnsRoute.ts";
175
179
  import { runSoulRoute } from "./soulRoute.ts";
176
180
  import {
177
181
  applyTasksImport,
@@ -1476,7 +1480,10 @@ function runShadowWrite(argv: readonly string[]): number | Promise<number> {
1476
1480
  return runShadowScanWrite(argv); // "scan"
1477
1481
  }
1478
1482
 
1479
- function withMirrorReadDb(argv: readonly string[], read: (db: Database) => number): number {
1483
+ function withMirrorReadDb<T = number>(
1484
+ argv: readonly string[],
1485
+ read: (db: Database) => T,
1486
+ ): T | number {
1480
1487
  const dbPath = resolveDbPathForCli(argv.slice(1));
1481
1488
  if (dbPath === null) return 2;
1482
1489
  ensureDatabaseReadyForCli(dbPath, argv[0] ?? null);
@@ -1538,6 +1545,35 @@ function runModeWrite(argv: readonly string[]): Promise<number> {
1538
1545
  return withMirrorWriteDb(argv, (db) => runModeWriteRoute(db, argv));
1539
1546
  }
1540
1547
 
1548
+ /**
1549
+ * `mirror doorman` (CV9.DS8.TS2): decide on a read-only handle, write behind
1550
+ * the pre-write snapshot only for a turn that delivers. The memory scores a
1551
+ * delta turn ranks go to the front-door log as numbers -- content-free -- the
1552
+ * hybrid and the semantic the threshold reads (US5), every ranked memory's,
1553
+ * so the threshold is read against measurement.
1554
+ */
1555
+ function runMirrorDoorman(argv: readonly string[]): Promise<number> {
1556
+ const reporter = outcomeReporter(argv, "reception");
1557
+ const logPath = resolveLogPath(argv);
1558
+ return runMirrorDoormanRoute(argv, {
1559
+ withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
1560
+ withLiveWriteDatabase: (write) => withMirrorWriteDb(argv, write),
1561
+ onReceptionOutcome: reporter.onOutcome,
1562
+ onMemoryScores: (scores) =>
1563
+ logFrontDoor(logPath, {
1564
+ command: "mirror",
1565
+ route: "ts",
1566
+ exitCode: 0,
1567
+ detail:
1568
+ `doorman memory_scores=${scores.map((s) => s.score.toFixed(3)).join(",")}` +
1569
+ ` memory_semantic=${scores.map((s) => s.semantic.toFixed(3)).join(",")}`,
1570
+ }),
1571
+ }).then((exitCode) => {
1572
+ if (reporter.calls > 0) reporter.logSummary();
1573
+ return exitCode;
1574
+ });
1575
+ }
1576
+
1541
1577
  function runSoulWrite(argv: readonly string[]): Promise<number> {
1542
1578
  return withMirrorWriteDb(argv, (db) => runSoulRoute(db, argv));
1543
1579
  }
@@ -1738,7 +1774,11 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1738
1774
  createLoadRuntime: (db, dbPath, ignoreProductionRole) =>
1739
1775
  createBuildLoadRuntime({ db, dbPath, ignoreProductionRole }),
1740
1776
  nowIso,
1741
- environmentSessionId: process.env.MIRROR_SESSION_ID ?? null,
1777
+ // The session the harness named -- MIRROR_SESSION_ID, else Claude Code's or
1778
+ // Codex's own -- the same reader the mirror route uses (CV9.DS8.US4: read
1779
+ // MIRROR_SESSION_ID alone, a Builder switch on Claude Code stamped a guessed
1780
+ // session and the doorman never saw it).
1781
+ environmentSessionId: environmentSessionId(process.env),
1742
1782
  });
1743
1783
  }
1744
1784
  if (isInit(argv)) return runInit(argv);
@@ -1758,6 +1798,7 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1758
1798
  if (argv[0] === "descriptor" && argv[1] === "generate") return runDescriptorContentRoute(argv);
1759
1799
  if (isConsolidateSubcommandWrite(argv)) return runConsolidateWrite(argv);
1760
1800
  if (isShadowSubcommandWrite(argv)) return runShadowWrite(argv);
1801
+ if (isMirrorDoorman(argv)) return runMirrorDoorman(argv);
1761
1802
  if (isMirrorWrite(argv)) return runMirrorWrite(argv);
1762
1803
  if (isModeWrite(argv)) return runModeWrite(argv);
1763
1804
  // CV22.DS7.US6: the Soul ritual; `routing.ts` allowlists its subcommands by
@@ -1781,6 +1822,21 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1781
1822
  verifyBackup: (archive) => verifyBackupArchive(archive),
1782
1823
  });
1783
1824
  }
1825
+ // CR160: the same shape -- the dry run reads; `--apply` writes behind the
1826
+ // pre-write backup, after its own dated, verified archive.
1827
+ if (isSkillTurnsCommand(argv)) {
1828
+ return runSkillTurnsRoute(argv, {
1829
+ withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
1830
+ withLiveWriteDatabase: (write) => {
1831
+ const dbPath = resolveDbPathForCli(argv.slice(1));
1832
+ if (dbPath === null) return 2;
1833
+ return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
1834
+ },
1835
+ backup: (dbPath) =>
1836
+ createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
1837
+ verifyBackup: (archive) => verifyBackupArchive(archive),
1838
+ });
1839
+ }
1784
1840
  // CV22.DS7.TS1: the DB safety tools. `backup` never opens or bootstraps the
1785
1841
  // database; `repair-encoding --apply` rides the live-write seam.
1786
1842
  if (argv[0] === "backup") {
@@ -35,6 +35,24 @@ function subcommandOf(args: readonly string[]): string {
35
35
  */
36
36
  const readStdin = takeStdin;
37
37
 
38
+ /**
39
+ * `--payload <json>`: a hook subcommand's payload in argv rather than on stdin
40
+ * (CR160). Pi's extension reaches the core through `pi.exec`, which cannot write
41
+ * a child's stdin, and since CR160 it sends Pi's turns to `user-prompt` and
42
+ * `stop`. Pi's prompt has travelled in argv since CV9, as `log-user`'s argument,
43
+ * so this is the exposure Pi always had, no wider (`stdinSource.ts`). The option
44
+ * is taken out of argv before the subcommand sees it, and a route given one
45
+ * never reads fd 0: a stdin pipe nobody closes cannot hang it.
46
+ */
47
+ function takePayloadOption(args: readonly string[]): { payload: string | null; rest: string[] } {
48
+ const index = args.indexOf("--payload");
49
+ if (index === -1 || index + 1 >= args.length) return { payload: null, rest: [...args] };
50
+ return {
51
+ payload: args[index + 1] as string,
52
+ rest: [...args.slice(0, index), ...args.slice(index + 2)],
53
+ };
54
+ }
55
+
38
56
  /**
39
57
  * Where the route's lines go. The default is the process's own streams; the
40
58
  * `session-start --welcome` composite (CV9.E2.S32) captures them instead, so
@@ -51,7 +69,7 @@ export async function runConversationLoggerRoute(
51
69
  argv: readonly string[],
52
70
  io: ConversationLoggerRouteIo = {},
53
71
  ): Promise<number> {
54
- const args = argv.slice(1);
72
+ const { payload, rest: args } = takePayloadOption(argv.slice(1));
55
73
  const subcommand = subcommandOf(args);
56
74
  const runtime = createLoggerRuntime({
57
75
  db,
@@ -73,7 +91,7 @@ export async function runConversationLoggerRoute(
73
91
  }),
74
92
  });
75
93
  const result = await runConversationLoggerCommand(db, args, runtime, {
76
- stdin: STDIN_SUBCOMMANDS.has(subcommand) ? readStdin() : undefined,
94
+ stdin: STDIN_SUBCOMMANDS.has(subcommand) ? (payload ?? readStdin()) : undefined,
77
95
  });
78
96
  const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
79
97
  const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -61,7 +61,7 @@ import { getIdentityContent } from "#identity/identityRead.ts";
61
61
  import { getProjectPath } from "#journey/journeyStatus.ts";
62
62
  import { loadMirrorContext } from "#mirror/context.ts";
63
63
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
64
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
64
+ import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
65
65
  import { activateOperatingMode, deactivateOperatingMode } from "#mode/operatingMode.ts";
66
66
  import { newId, nowIso } from "#util/pyGenerators.ts";
67
67
  import { optionValue, stripOptionWithValue } from "./args.ts";
@@ -430,7 +430,7 @@ async function runPromote(
430
430
  // `--session-id`, which its promote parser does not even accept.
431
431
  const result = await runBuildLoad(
432
432
  db,
433
- { slug, environmentSessionId: process.env.MIRROR_SESSION_ID ?? null },
433
+ { slug, environmentSessionId: environmentSessionId(process.env) },
434
434
  runtime.deps,
435
435
  );
436
436
  process.stderr.write(result.stderr);