mirror-mind 0.36.3 → 0.38.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 (52) hide show
  1. package/.pi/extensions/mirror-logger.ts +173 -46
  2. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  3. package/README.md +2 -2
  4. package/docs/releases/index.md +2 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/docs/releases/v0.38.0.md +215 -0
  7. package/package.json +1 -1
  8. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  9. package/plugins/mirror-mind/plugin.json +1 -1
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  11. package/ts/src/builder/commands.ts +9 -6
  12. package/ts/src/builder/load.ts +14 -2
  13. package/ts/src/conversation/closeTail.ts +7 -3
  14. package/ts/src/conversation/extraction.ts +1 -11
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/userName.ts +44 -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/extraction/conversationMetadata.ts +53 -8
  24. package/ts/src/extraction/prompts.ts +8 -3
  25. package/ts/src/frontDoor/cli.ts +42 -2
  26. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  27. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  28. package/ts/src/frontDoor/routing.ts +16 -15
  29. package/ts/src/frontDoor/runtimeRoute.ts +4 -0
  30. package/ts/src/frontDoor/soulRoute.ts +4 -4
  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/orchestration.ts +22 -3
  39. package/ts/src/mirror/render.ts +7 -1
  40. package/ts/src/mirror/runtimeSession.ts +66 -8
  41. package/ts/src/mode/operatingMode.ts +22 -4
  42. package/ts/src/providers/config.ts +105 -1
  43. package/ts/src/providers/cost.ts +5 -1
  44. package/ts/src/providers/llm.ts +40 -0
  45. package/ts/src/providers/transport.ts +14 -0
  46. package/ts/src/runtime/diagnose.ts +64 -7
  47. package/ts/src/runtime/pluginWiring.ts +6 -4
  48. package/ts/src/runtime/status.ts +24 -4
  49. package/ts/src/runtime/update.ts +17 -1
  50. package/ts/src/runtime/wiring.ts +14 -1
  51. package/ts/src/search/memorySearch.ts +38 -18
  52. package/ts/src/search/ranker.ts +16 -2
@@ -188,6 +188,40 @@ function pythonStr(value: unknown): string {
188
188
  * Generate a 3-4 sentence summary. Returns "" on empty messages, provider
189
189
  * failure, or trivial conversation.
190
190
  */
191
+ /**
192
+ * The opening the summary prompt forbids, as a model writes it (CR180): a first
193
+ * clause whose subject is the conversation itself rather than its content. One
194
+ * adjective is allowed between the article and the noun ("The Mirror conversation").
195
+ */
196
+ const CONVERSATION_FRAME =
197
+ /^\s*(?:the|this)\s+(?:[\p{L}\d.]+\s+)?(?:conversation|discussion|session|exchange|chat|dialogue|transcript|user)\b/iu;
198
+
199
+ /**
200
+ * Appended to the prompt for the one regeneration (CR180): what the first sentence
201
+ * must do, said positively. It never quotes the frame it is replacing, because a
202
+ * quoted phrase comes back as the phrase.
203
+ */
204
+ export const SUMMARY_RETRY_NOTE = `
205
+
206
+ Rewrite the summary. Its first sentence states the matter itself with its status, or begins with the person's name and what they decided or asked. Nothing in it describes what kind of text this is.`;
207
+
208
+ /** Whether a summary opens by describing the conversation instead of stating its content. */
209
+ export function opensWithConversationFrame(summary: string): boolean {
210
+ return CONVERSATION_FRAME.test(summary);
211
+ }
212
+
213
+ /**
214
+ * The summary, with one regeneration when it opens with the frame (CR180).
215
+ *
216
+ * After the prompt stopped naming the conversation, ordinary conversations opened
217
+ * clean 21 of 21 on the pin, but a conversation that is itself about sessions and
218
+ * roadmaps still opened "The conversation centers on…" about half the time, and a
219
+ * rule that names the frame gets echoed. So the guard is here, not in the words:
220
+ * a framed summary is generated once more, the prompt plus a positive retry note,
221
+ * and the second is kept only if it opens clean. Every call that happened reaches `onLlmCall`, so
222
+ * the ledger prices both. An empty summary (the trivial-conversation answer) and
223
+ * a failed second call keep the first result; failure never costs the summary.
224
+ */
191
225
  export async function generateConversationSummary(
192
226
  llm: LlmProvider,
193
227
  messages: readonly ExtractionMessage[],
@@ -195,12 +229,23 @@ export async function generateConversationSummary(
195
229
  ): Promise<string> {
196
230
  if (messages.length === 0) return "";
197
231
  const prompt = buildConversationSummaryPrompt(messages, options.userName ?? "User");
198
- let response: LlmResponse;
199
- try {
200
- response = await llm.complete({ role: "conversation_summary", prompt, temperature: 0.3 });
201
- } catch {
202
- return "";
203
- }
204
- options.onLlmCall?.(response, prompt);
205
- return response.content.trim();
232
+ const once = async (sent: string): Promise<string | null> => {
233
+ let response: LlmResponse;
234
+ try {
235
+ response = await llm.complete({
236
+ role: "conversation_summary",
237
+ prompt: sent,
238
+ temperature: 0.3,
239
+ });
240
+ } catch {
241
+ return null;
242
+ }
243
+ options.onLlmCall?.(response, sent);
244
+ return response.content.trim();
245
+ };
246
+ const first = await once(prompt);
247
+ if (first === null) return "";
248
+ if (!opensWithConversationFrame(first)) return first;
249
+ const second = await once(`${prompt}${SUMMARY_RETRY_NOTE}`);
250
+ return second !== null && second !== "" && !opensWithConversationFrame(second) ? second : first;
206
251
  }
@@ -238,15 +238,20 @@ Example of the exact attack you must resist:
238
238
 
239
239
  export const CONVERSATION_SUMMARY_PROMPT = `You are the memory system for Mirror Mind, a Jungian mirror AI.
240
240
 
241
- Write a 3-4 sentence summary of the conversation below. Use flowing prose, not a list.
241
+ Write 3-4 sentences of flowing prose, not a list. It will be read on its own, months
242
+ from now, by someone deciding whether the full transcript is worth opening.
242
243
 
243
244
  ## Rules
244
245
 
245
- - Open with the main topic or question the conversation addressed.
246
+ - Open with the subject itself, carrying its real status: a decision as decided, a
247
+ question as open. "The move to Porto is set for March; what stays open is whether
248
+ to sell the car."
246
249
  - Include the key decision, insight, or commitment reached, if any.
247
250
  - Note emotional tone or psychological layer only when clearly present and significant.
248
251
  - Standalone: a reader six months from now must understand what happened from the
249
- summary alone. Do not write "we discussed", "the user said", or "the conversation".
252
+ summary alone. Write what is now known or decided, not that it was talked about.
253
+ Call the person by the name the transcript gives them. Do not write "we discussed",
254
+ "the user said", "the conversation", or "the discussion".
250
255
  - If the conversation is trivial (greetings, scheduling, one-line exchange), return
251
256
  an empty string and nothing else.
252
257
 
@@ -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,
@@ -1477,7 +1480,10 @@ function runShadowWrite(argv: readonly string[]): number | Promise<number> {
1477
1480
  return runShadowScanWrite(argv); // "scan"
1478
1481
  }
1479
1482
 
1480
- 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 {
1481
1487
  const dbPath = resolveDbPathForCli(argv.slice(1));
1482
1488
  if (dbPath === null) return 2;
1483
1489
  ensureDatabaseReadyForCli(dbPath, argv[0] ?? null);
@@ -1539,6 +1545,35 @@ function runModeWrite(argv: readonly string[]): Promise<number> {
1539
1545
  return withMirrorWriteDb(argv, (db) => runModeWriteRoute(db, argv));
1540
1546
  }
1541
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
+
1542
1577
  function runSoulWrite(argv: readonly string[]): Promise<number> {
1543
1578
  return withMirrorWriteDb(argv, (db) => runSoulRoute(db, argv));
1544
1579
  }
@@ -1739,7 +1774,11 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1739
1774
  createLoadRuntime: (db, dbPath, ignoreProductionRole) =>
1740
1775
  createBuildLoadRuntime({ db, dbPath, ignoreProductionRole }),
1741
1776
  nowIso,
1742
- 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),
1743
1782
  });
1744
1783
  }
1745
1784
  if (isInit(argv)) return runInit(argv);
@@ -1759,6 +1798,7 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1759
1798
  if (argv[0] === "descriptor" && argv[1] === "generate") return runDescriptorContentRoute(argv);
1760
1799
  if (isConsolidateSubcommandWrite(argv)) return runConsolidateWrite(argv);
1761
1800
  if (isShadowSubcommandWrite(argv)) return runShadowWrite(argv);
1801
+ if (isMirrorDoorman(argv)) return runMirrorDoorman(argv);
1762
1802
  if (isMirrorWrite(argv)) return runMirrorWrite(argv);
1763
1803
  if (isModeWrite(argv)) return runModeWrite(argv);
1764
1804
  // CV22.DS7.US6: the Soul ritual; `routing.ts` allowlists its subcommands by
@@ -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);
@@ -1,12 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import type { Database, WritableDatabase } from "#db/database.ts";
4
+ import {
5
+ type DoormanDecision,
6
+ decideDoorman,
7
+ doormanPersonas,
8
+ forgetDoormanDelivery,
9
+ type MemoryScore,
10
+ readDoormanSessionState,
11
+ runDoorman,
12
+ } from "#mirror/doorman.ts";
4
13
  import {
5
14
  deactivateMirrorState,
6
15
  listActiveMirrorJourneys,
7
16
  logMirrorResponse,
8
17
  runMirrorLoad,
9
18
  } from "#mirror/orchestration.ts";
19
+ import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
10
20
  import {
11
21
  activateOperatingMode,
12
22
  deactivateOperatingMode,
@@ -16,7 +26,7 @@ import {
16
26
  } from "#mode/operatingMode.ts";
17
27
  import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
18
28
  import { resolveFamilyProviders } from "#providers/familyProviders.ts";
19
- import { MIRROR_QUERY_TRANSPORT } from "#providers/transport.ts";
29
+ import { MIRROR_QUERY_TRANSPORT, mirrorQueryTransportFor } from "#providers/transport.ts";
20
30
  import { newId, nowIso } from "#util/pyGenerators.ts";
21
31
  import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
22
32
 
@@ -28,6 +38,8 @@ import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
28
38
  */
29
39
  export type MirrorRouteEnvironment = {
30
40
  MIRROR_SESSION_ID?: string;
41
+ CLAUDE_CODE_SESSION_ID?: string;
42
+ CODEX_SESSION_ID?: string;
31
43
  MEMORY_RECEPTION?: string;
32
44
  MIRROR_TS_MIRROR_LLM_REPLAY?: string;
33
45
  MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
@@ -37,14 +49,119 @@ export function isMirrorWrite(argv: readonly string[]): boolean {
37
49
  return argv[0] === "mirror" && ["load", "deactivate", "log"].includes(argv[1] ?? "");
38
50
  }
39
51
 
52
+ /**
53
+ * `mirror doorman` is a write surface with its own handle discipline
54
+ * (CV9.DS8.TS2): it is not `isMirrorWrite`, because the front door's shared
55
+ * write seam opens the live-write path -- a database snapshot -- before any
56
+ * route runs, and a doorman turn that owes nothing must never pay that.
57
+ */
58
+ export function isMirrorDoorman(argv: readonly string[]): boolean {
59
+ return argv[0] === "mirror" && argv[1] === "doorman";
60
+ }
61
+
62
+ export interface DoormanRouteDeps {
63
+ /** The database, read-only: enough to decide. */
64
+ withReadOnlyDatabase: (read: (db: Database) => DoormanDecision) => DoormanDecision | number;
65
+ /** The database behind the pre-write snapshot: only for a turn that delivers. */
66
+ withLiveWriteDatabase: (
67
+ write: (db: WritableDatabase, dbPath: string) => Promise<number>,
68
+ ) => Promise<number>;
69
+ onReceptionOutcome?: OnProviderCallOutcome;
70
+ /** Content-free: the turn's memory scores, hybrid and semantic, for the front-door log. */
71
+ onMemoryScores?: (scores: readonly MemoryScore[]) => void;
72
+ }
73
+
74
+ /**
75
+ * `mirror doorman --prompt "<text>" [--session-id <id>]`: the one doorman as a
76
+ * command, for the Pi extension (US5). Prints the block or nothing; exit 0.
77
+ * `mirror doorman --forget [--session-id <id>]`: the runtime compacted the
78
+ * transcript; the named session's next ordinary turn is full again (US5's
79
+ * debt). Nothing printed; an unnamed session is nothing to mark.
80
+ *
81
+ * Two steps on purpose. The decision is read from a read-only handle; the
82
+ * live-write handle -- `VACUUM INTO` first, 95 ms measured on a 53 MB database
83
+ * -- opens only when the decision is `full` or `delta`. The ordinary turn
84
+ * outside Mirror Mode costs a read and nothing else, as the inject hook's does.
85
+ */
86
+ export async function runMirrorDoormanRoute(
87
+ argv: readonly string[],
88
+ deps: DoormanRouteDeps,
89
+ env: MirrorRouteEnvironment = process.env,
90
+ ): Promise<number> {
91
+ const args = argv.slice(2);
92
+ const sessionId = resolveNamedRuntimeSessionId(
93
+ optionValue(args, "--session-id") ?? null,
94
+ environmentSessionId(env),
95
+ );
96
+ if (hasOption(args, "--forget")) {
97
+ if (!sessionId) return 0;
98
+ return deps.withLiveWriteDatabase(async (db) => {
99
+ forgetDoormanDelivery(db, sessionId, nowIso());
100
+ return 0;
101
+ });
102
+ }
103
+ const prompt = optionValue(args, "--prompt");
104
+ if (prompt === null || prompt === undefined) {
105
+ console.error("mirror doorman requires --prompt");
106
+ return 2;
107
+ }
108
+ const decided = deps.withReadOnlyDatabase((db) =>
109
+ decideDoorman({
110
+ prompt,
111
+ state: readDoormanSessionState(db, sessionId),
112
+ personas: doormanPersonas(db),
113
+ }),
114
+ );
115
+ if (typeof decided === "number") return decided;
116
+ if (decided.kind === "nothing") return 0;
117
+
118
+ // Providers only now: a turn that delivers may embed (delta) or route
119
+ // (full); the turn above resolved none, let alone a key.
120
+ const receptionEnabled = env.MEMORY_RECEPTION !== "0";
121
+ const family = await resolveFamilyProviders(env, mirrorQueryTransportFor(env));
122
+ return deps.withLiveWriteDatabase(async (db, dbPath) => {
123
+ const mirrorHome = dirname(dbPath);
124
+ const rendered = await runDoorman(db, decided, {
125
+ identity: basename(mirrorHome),
126
+ databasePath: dbPath,
127
+ mirrorHome,
128
+ user: basename(mirrorHome),
129
+ sessionId,
130
+ receptionEnabled,
131
+ onReceptionOutcome: deps.onReceptionOutcome,
132
+ llmProvider: receptionEnabled ? family.llm : undefined,
133
+ embeddingProvider: family.embedding,
134
+ onMemoryScores: deps.onMemoryScores,
135
+ newId,
136
+ nowIso,
137
+ });
138
+ process.stdout.write(rendered.stdout);
139
+ process.stderr.write(rendered.stderr);
140
+ return 0;
141
+ });
142
+ }
143
+
40
144
  export function isModeWrite(argv: readonly string[]): boolean {
41
145
  if (argv[0] !== "mode") return false;
42
146
  const args = modePositionals(argv.slice(1));
43
147
  return args[0] === "activate" || args[0] === "deactivate";
44
148
  }
45
149
 
46
- export function mirrorDeactivateMissingSession(argv: readonly string[]): boolean {
47
- return argv[0] === "mirror" && argv[1] === "deactivate" && !optionValue(argv, "--session-id");
150
+ /**
151
+ * `mirror deactivate` with nobody naming a session: `--session-id` absent AND
152
+ * no harness variable (CV9.DS8.US5 -- until then `--session-id` alone, so
153
+ * `/mm-new` on a Pi whose extension had named the session still refused).
154
+ */
155
+ export function mirrorDeactivateMissingSession(
156
+ argv: readonly string[],
157
+ env: MirrorRouteEnvironment = process.env,
158
+ ): boolean {
159
+ return (
160
+ argv[0] === "mirror" &&
161
+ argv[1] === "deactivate" &&
162
+ !optionValue(argv, "--session-id") &&
163
+ environmentSessionId(env) === null
164
+ );
48
165
  }
49
166
 
50
167
  export function runMirrorRead(db: Database, args: readonly string[]): number {
@@ -70,7 +187,10 @@ export async function runMirrorWriteRoute(
70
187
  const args = argv.slice(2);
71
188
  const sessionId = optionValue(args, "--session-id");
72
189
  if (sub === "deactivate") {
73
- deactivateMirrorState(db, sessionId as string, nowIso());
190
+ // The session the caller named, else the harness's (TS2's reader): the
191
+ // unnamed case was refused before this route ran.
192
+ const named = resolveNamedRuntimeSessionId(sessionId ?? null, environmentSessionId(env));
193
+ deactivateMirrorState(db, named as string, nowIso());
74
194
  console.error("Mirror Mode deactivated.");
75
195
  return 0;
76
196
  }
@@ -112,7 +232,11 @@ export async function runMirrorWriteRoute(
112
232
  org: hasOption(args, "--org"),
113
233
  contextOnly: hasOption(args, "--context-only"),
114
234
  sessionId,
115
- environmentSessionId: env.MIRROR_SESSION_ID ?? null,
235
+ // The harness's variable, whichever runtime set it (CV9.DS8.TS2): the
236
+ // skill's own load on Claude Code or Codex names the session the hooks
237
+ // write. `mirror log` keeps reading `MIRROR_SESSION_ID` alone: its
238
+ // fallback is a guess, and widening that is a CR008 question.
239
+ environmentSessionId: environmentSessionId(env),
116
240
  receptionEnabled,
117
241
  onReceptionOutcome: options.onReceptionOutcome,
118
242
  llmProvider: receptionEnabled ? family?.llm : undefined,
@@ -8,7 +8,7 @@ import {
8
8
  CULTIVATION_SCAN_TRANSPORT,
9
9
  DESCRIPTOR_TRANSPORT,
10
10
  JOURNAL_TRANSPORT,
11
- MIRROR_QUERY_TRANSPORT,
11
+ mirrorQueryTransportFor,
12
12
  type ProviderTransportSpec,
13
13
  resolveComposedProviderTransport,
14
14
  resolveProviderTransport,
@@ -745,22 +745,23 @@ export function routeByFamily(
745
745
 
746
746
  if (command === "mirror") {
747
747
  const sub = argv[1];
748
- if (sub === "load") {
749
- const hasQuery = argv.includes("--query");
748
+ // CV9.DS8.TS2: the doorman decides before it resolves a provider, and a
749
+ // `nothing` turn resolves none -- but a `full` turn is `load --query`, so
750
+ // the route answers for the doorman exactly as it answers for that.
751
+ if (sub === "load" || sub === "doorman") {
752
+ const hasQuery = sub === "doorman" || argv.includes("--query");
750
753
  if (!hasQuery) {
751
754
  return { command, engine: "ts", reason: "DS7.US4 deterministic mirror load ported to TS" };
752
755
  }
753
- // `MEMORY_RECEPTION=0` skips the classifier on both engines, so the
754
- // chat fixture stops being required for a replay run; the embedding half
755
- // still is, because the query still searches attachments and journeys.
756
- const spec =
757
- env.MEMORY_RECEPTION === "0"
758
- ? {
759
- ...MIRROR_QUERY_TRANSPORT,
760
- replay: { embedding: MIRROR_QUERY_TRANSPORT.replay?.embedding },
761
- }
762
- : MIRROR_QUERY_TRANSPORT;
763
- return providerRoute(command, env, spec, "load --query");
756
+ // `MEMORY_RECEPTION=0` skips the classifier, so the chat fixture stops
757
+ // being required for a replay run; the embedding half still is, because
758
+ // the query still searches attachments and journeys.
759
+ return providerRoute(
760
+ command,
761
+ env,
762
+ mirrorQueryTransportFor(env),
763
+ sub === "doorman" ? "doorman" : "load --query",
764
+ );
764
765
  }
765
766
  if (sub === "deactivate" || sub === "log" || sub === "journeys") {
766
767
  return { command, engine: "ts", reason: `DS7.US4 mirror ${sub} ported to TS` };
@@ -952,7 +953,7 @@ const TASKS_SUBCOMMANDS = [
952
953
  const WEEK_SUBCOMMANDS = ["view", "plan", "save"];
953
954
  const CONSOLIDATE_SUBCOMMANDS = ["scan", "apply", "reject", "list"];
954
955
  const SHADOW_SUBCOMMANDS = ["scan", "apply", "reject", "list", "show"];
955
- const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys"];
956
+ const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys", "doorman"];
956
957
  const MODE_SUBCOMMANDS = ["activate", "deactivate", "status"];
957
958
 
958
959
  /** `mode`'s first positional, past the two options its parser declares; "" when none. */
@@ -25,6 +25,7 @@ import { channelFor, channelForReport, renderChannel, setChannel } from "#runtim
25
25
  import {
26
26
  diagnoseRuntime,
27
27
  extensionPluginFindings,
28
+ extractionReasoningFindings,
28
29
  hookFailureFindings,
29
30
  hookNodeFindings,
30
31
  pluginWiringFindings,
@@ -324,6 +325,9 @@ export async function runRuntimeReadRoute(
324
325
  // skip; and hooks.log holds what the hooks met in their own context.
325
326
  // All three are invisible without being asked for.
326
327
  ...staleRevertGateFindings(env),
328
+ // CV9.DS9.TS1: a reasoning setting the transport ignores rather than fail
329
+ // on (CR178) is silently not in force unless diagnose says so.
330
+ ...extractionReasoningFindings(env),
327
331
  ...hookFailureFindings(report),
328
332
  ...hookNodeFindings(env, (path) => {
329
333
  try {
@@ -15,7 +15,7 @@
15
15
  import type { Database, WritableDatabase } from "#db/database.ts";
16
16
  import { getIdentityContent } from "#identity/identityRead.ts";
17
17
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
18
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
18
+ import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
19
19
  import { activateOperatingMode } from "#mode/operatingMode.ts";
20
20
  import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
21
21
  import { generateEmbeddingSafely } from "#providers/embedding.ts";
@@ -310,7 +310,7 @@ function runLoad(
310
310
  const resolvedSessionId = resolveRuntimeSessionId(
311
311
  db,
312
312
  sessionId,
313
- process.env.MIRROR_SESSION_ID ?? null,
313
+ environmentSessionId(process.env),
314
314
  );
315
315
  activateOperatingMode(
316
316
  db,
@@ -355,7 +355,7 @@ function runFruit(
355
355
  sessionId: string | null,
356
356
  deps: SoulRouteDeps,
357
357
  ): number {
358
- const resolved = resolveCliSoulSessionId(db, sessionId, process.env.MIRROR_SESSION_ID ?? null);
358
+ const resolved = resolveCliSoulSessionId(db, sessionId, environmentSessionId(process.env));
359
359
  if (action === "set") {
360
360
  if (fruit === null) return usageError("soul fruit set requires a fruit");
361
361
  const state = setFruitInMaturation(db, fruit, resolved, deps.nowIso());
@@ -384,7 +384,7 @@ async function runHarvest(
384
384
  const resolved = resolveCliSoulSessionId(
385
385
  db,
386
386
  option("--session-id"),
387
- process.env.MIRROR_SESSION_ID ?? null,
387
+ environmentSessionId(process.env),
388
388
  );
389
389
  if (action === "set") {
390
390
  const state = harvestFruit(db, { fruit, sessionId: resolved }, deps.nowIso());
@@ -0,0 +1,92 @@
1
+ // CR163 — no test makes a temp home under a literal /tmp.
2
+ //
3
+ // The suite's temp homes are made with `tempDir` (ts/test/helpers/tempDir.ts),
4
+ // under os.tmpdir(), and removed when the test file's process exits. The
5
+ // run-level check (scripts/test_leaves_nothing.sh) proves a run leaves nothing
6
+ // by pointing TMPDIR at an empty directory; it sees every home only because
7
+ // none is made anywhere else. A home under a literal /tmp would escape it, so
8
+ // this guard keeps one from coming back: every `.ts` under ts/test, helpers and
9
+ // support included, in each form the tree has used.
10
+ //
11
+ // A line that is a comment is not a finding, nor is a /tmp that names a path
12
+ // no test creates (an asserted string, a golden's literal). An exception names
13
+ // its reason, and one that no longer matches anything is reported as stale, so
14
+ // it cannot outlive that reason.
15
+
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { join, relative, sep } from "node:path";
18
+
19
+ /** Files allowed a literal /tmp temp home, each with the reason it must stay. */
20
+ export const EXCEPTIONS: Readonly<Record<string, string>> = {
21
+ "ts/test/helpers/builderWorld.ts":
22
+ "CR164: pathsWalk sees a leaked absolute path only under a short root",
23
+ "ts/test/scripts/testTempHomes.test.ts": "this guard's own cases, held as strings",
24
+ };
25
+
26
+ /** Each form the tree has used to make a temp home under a literal /tmp. */
27
+ const FORMS: readonly RegExp[] = [
28
+ // mkdtempSync("/tmp/…"), a template, the async mkdtemp; the argument may open a new line.
29
+ /\bmkdtemp(?:Sync)?\(\s*["'`]\/tmp\//g,
30
+ // mkdtempSync(join("/tmp", …))
31
+ /\bmkdtemp(?:Sync)?\(\s*join\(\s*["'`]\/tmp["'`]/g,
32
+ // realpathSync("/tmp"), as in mkdtempSync(join(realpathSync("/tmp"), …))
33
+ /\brealpathSync\(\s*["'`]\/tmp["'`]\s*\)/g,
34
+ ];
35
+
36
+ export interface TempHomeFinding {
37
+ /** Repository-relative, with forward slashes. */
38
+ readonly file: string;
39
+ readonly line: number;
40
+ readonly text: string;
41
+ }
42
+
43
+ export interface TempHomeSweep {
44
+ readonly findings: readonly TempHomeFinding[];
45
+ /** Exceptions whose file no longer makes such a home. */
46
+ readonly staleExceptions: readonly string[];
47
+ }
48
+
49
+ function isComment(line: string): boolean {
50
+ const text = line.trimStart();
51
+ return text.startsWith("//") || text.startsWith("*") || text.startsWith("/*");
52
+ }
53
+
54
+ function testFiles(repoRoot: string): string[] {
55
+ const testRoot = join(repoRoot, "ts", "test");
56
+ return readdirSync(testRoot, { recursive: true, withFileTypes: true })
57
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
58
+ .map((entry) => relative(repoRoot, join(entry.parentPath, entry.name)).split(sep).join("/"))
59
+ .sort();
60
+ }
61
+
62
+ function scan(source: string): { line: number; text: string }[] {
63
+ const lines = source.split("\n");
64
+ const hits = new Map<number, string>();
65
+ for (const form of FORMS) {
66
+ for (const match of source.matchAll(form)) {
67
+ const line = source.slice(0, match.index).split("\n").length;
68
+ if (!isComment(lines[line - 1])) hits.set(line, lines[line - 1].trim());
69
+ }
70
+ }
71
+ return [...hits].sort(([a], [b]) => a - b).map(([line, text]) => ({ line, text }));
72
+ }
73
+
74
+ /** Every temp home under a literal /tmp in `ts/test` below `repoRoot`, but the exceptions. */
75
+ export function findLiteralTmpHomes(
76
+ repoRoot: string,
77
+ exceptions: Readonly<Record<string, string>> = EXCEPTIONS,
78
+ ): TempHomeSweep {
79
+ const findings: TempHomeFinding[] = [];
80
+ const excepted = new Set<string>();
81
+ for (const file of testFiles(repoRoot)) {
82
+ const hits = scan(readFileSync(join(repoRoot, file), "utf8"));
83
+ if (hits.length === 0) continue;
84
+ if (file in exceptions) {
85
+ excepted.add(file);
86
+ continue;
87
+ }
88
+ for (const hit of hits) findings.push({ file, ...hit });
89
+ }
90
+ const staleExceptions = Object.keys(exceptions).filter((file) => !excepted.has(file));
91
+ return { findings, staleExceptions };
92
+ }