@junghanacs/entwurf 0.17.2 → 0.18.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.md +2 -1
- package/CHANGELOG.md +127 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
- package/mcp/entwurf-bridge/src/index.ts +16 -8
- package/package.json +3 -2
- package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
- package/pi-extensions/lib/entwurf-facts.ts +52 -3
- package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
- package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
- package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
- package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
- package/pi-extensions/lib/meta-session.ts +44 -0
- package/pi-extensions/meta-bridge-hook.ts +72 -3
- package/run.sh +21 -0
- package/scripts/check-bridge-delivery.ts +42 -1
- package/scripts/check-entwurf-fact-provider.ts +22 -1
- package/scripts/check-entwurf-facts.ts +17 -2
- package/scripts/check-entwurf-peers-surface.ts +20 -2
- package/scripts/check-entwurf-self-address.ts +20 -2
- package/scripts/check-entwurf-v2-decider.ts +6 -3
- package/scripts/check-entwurf-v2-matrix.ts +10 -5
- package/scripts/check-entwurf-v2-production.ts +72 -1
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/meta-bridge-doctor.sh +29 -4
- package/scripts/mutants/meta-hook-session-switch.json +223 -0
- package/scripts/raw-claude-session-switch/README.md +170 -0
- package/scripts/smoke-acp-cortex-live.ts +22 -2
- package/scripts/smoke-acp-v2-send-live.ts +23 -2
- package/scripts/smoke-entwurf-chain-live.ts +85 -13
- package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
- package/scripts/smoke-mux-fresh-call-live.ts +13 -3
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-meta-hook-session-switch — the regression line under a Claude Code SESSION
|
|
3
|
+
* SWITCH: one native process that stops serving one garden and starts serving another
|
|
4
|
+
* (#101). It is the gate that did not exist when the defect shipped, and its shape is
|
|
5
|
+
* the reason it did not: every claude-side cell the suite had asked about ONE session
|
|
6
|
+
* per process, so "one owner pid, two gardens" had no cell anywhere.
|
|
7
|
+
*
|
|
8
|
+
* WHAT A SESSION SWITCH IS. Opening `claude` mints a new session, and an in-session
|
|
9
|
+
* `/resume` (or `/clear`) then fires a SECOND SessionStart inside that one pid, for a
|
|
10
|
+
* DIFFERENT native id — the session the process started with is left behind. Measured on
|
|
11
|
+
* oracle 2026-09-04 (`~/.pi/agent/meta-bridge-hook.log`): the field case at pid 143742,
|
|
12
|
+
* 09:31:35 create `…-ac7a1a`, 09:31:39 attach `…-e09b66`, both arming a receiver marker
|
|
13
|
+
* under that one pid — the first garden's transcript was never written and a message
|
|
14
|
+
* enqueued to it at 09:33 was still an undelivered `.msg` an hour later; then reproduced
|
|
15
|
+
* deliberately at 13:13:04 → 13:13:37 (raw lab S4, `source=startup` → `source=resume`).
|
|
16
|
+
* `claude --resume`, with or without the picker, does NOT do this: it fires exactly one
|
|
17
|
+
* SessionStart carrying the real id (raw lab S2/S3), and compaction re-fires SessionStart
|
|
18
|
+
* for the SAME id (S6), which this rule leaves alone.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT PROVES, and each cell is a different half:
|
|
21
|
+
*
|
|
22
|
+
* A (hook) the second SessionStart RETIRES the marker of the garden this pid no
|
|
23
|
+
* longer serves — and only that one. A same-garden re-registration (every
|
|
24
|
+
* UserPromptSubmit) retires NOTHING, which is the cell that keeps the
|
|
25
|
+
* repair from eating the live citizen it was meant to protect.
|
|
26
|
+
* B (reader) even with a retired marker still on disk — an older deployed hook, a
|
|
27
|
+
* crash between the two events — the garden is NOT deliverable, because
|
|
28
|
+
* `watchArmed` is measured against the owner's sender marker rather than
|
|
29
|
+
* copied from the identity match. Fail-closed at the reader, so the repair
|
|
30
|
+
* does not depend on the writer having run.
|
|
31
|
+
* C (surface) the reject says WHICH axis failed, in the predicate's own words.
|
|
32
|
+
*
|
|
33
|
+
* THE HEADLINE. After a switch, exactly ONE of the two gardens is deliverable, and it is
|
|
34
|
+
* the one the operator is actually sitting in.
|
|
35
|
+
*
|
|
36
|
+
* HOW IT PLAYS CLAUDE. Like check-hook-launch-topology: the gate spawns the SHIPPED
|
|
37
|
+
* `hook-launch.sh` with the manifest's own argv, which `exec`s the payload, so the
|
|
38
|
+
* hook's parent is this gate process — one fake owner pid for every drive, which is
|
|
39
|
+
* exactly the topology the defect needs. Every meta root is sandboxed (agent dir + the
|
|
40
|
+
* four `ENTWURF_META_*_DIR`), so nothing here can read or write the operator's garden.
|
|
41
|
+
*
|
|
42
|
+
* WHAT IT DELIBERATELY DOES NOT PROVE. The vendor's `source` field (startup | resume |
|
|
43
|
+
* clear | compact) is LOGGED by the hook and branched on by nothing, so this gate asserts
|
|
44
|
+
* the log line, not a behaviour keyed to the value. The retirement is decided by what is
|
|
45
|
+
* on disk, which is true on every host and every Claude version; a live measurement of
|
|
46
|
+
* the envelope order belongs to the raw lab (scripts/raw-claude-session-switch), not here.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import assert from "node:assert/strict";
|
|
50
|
+
import { spawnSync } from "node:child_process";
|
|
51
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { tmpdir } from "node:os";
|
|
53
|
+
import * as path from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import { resolveMailboxReceiverFacts } from "../pi-extensions/lib/entwurf-deliverability.ts";
|
|
56
|
+
import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
|
|
57
|
+
import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
|
|
58
|
+
import type { DispatchDecision } from "../pi-extensions/lib/entwurf-v2-decider.ts";
|
|
59
|
+
import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
|
|
60
|
+
import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
61
|
+
import { renderEntwurfV2Result } from "../pi-extensions/lib/entwurf-v2-surface.ts";
|
|
62
|
+
import {
|
|
63
|
+
listAllMetaIdentitiesDir,
|
|
64
|
+
type MetaIdentity,
|
|
65
|
+
makeStoreRecordReader,
|
|
66
|
+
metaReceiverMarkerPath,
|
|
67
|
+
readActiveStoreEntries,
|
|
68
|
+
readMetaReceiverMarker,
|
|
69
|
+
readMetaSenderMarker,
|
|
70
|
+
upsertMetaSession,
|
|
71
|
+
writeMetaReceiverMarker,
|
|
72
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
73
|
+
|
|
74
|
+
let passed = 0;
|
|
75
|
+
function ok(label: string, cond: boolean): void {
|
|
76
|
+
assert.ok(cond, label);
|
|
77
|
+
console.log(` ok ${label}`);
|
|
78
|
+
passed++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
82
|
+
const PLUGIN_SRC = path.join(REPO_DIR, "pi", "meta-bridge", "entwurf-meta-receive");
|
|
83
|
+
const LAUNCHER = path.join(PLUGIN_SRC, "scripts", "hook-launch.sh");
|
|
84
|
+
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
|
85
|
+
|
|
86
|
+
const manifest = JSON.parse(readFileSync(path.join(PLUGIN_SRC, "hooks", "hooks.json"), "utf8")) as {
|
|
87
|
+
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command?: string; args?: string[] }> }>>;
|
|
88
|
+
};
|
|
89
|
+
const sessionStartLeaf = manifest.hooks.SessionStart?.[0]?.hooks[0] ?? {};
|
|
90
|
+
|
|
91
|
+
/** Stage a plugin bundle: entry + lib closure + the launcher, exactly as installed. */
|
|
92
|
+
function makeBundle(root: string): void {
|
|
93
|
+
mkdirSync(path.join(root, "lib"), { recursive: true });
|
|
94
|
+
mkdirSync(path.join(root, "scripts"), { recursive: true });
|
|
95
|
+
copyFileSync(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), path.join(root, "meta-bridge-hook.ts"));
|
|
96
|
+
copyFileSync(
|
|
97
|
+
path.join(REPO_DIR, "pi-extensions", "lib", "meta-session.ts"),
|
|
98
|
+
path.join(root, "lib", "meta-session.ts"),
|
|
99
|
+
);
|
|
100
|
+
copyFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "session-id.js"), path.join(root, "lib", "session-id.js"));
|
|
101
|
+
const launcher = path.join(root, "scripts", "hook-launch.sh");
|
|
102
|
+
copyFileSync(LAUNCHER, launcher);
|
|
103
|
+
chmodSync(launcher, 0o755);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve one manifest element the way Claude does: plain-string substitution, no shell. */
|
|
107
|
+
function resolveEl(value: string, pluginRoot: string): string {
|
|
108
|
+
return value
|
|
109
|
+
.replace("__NODE_BIN__", process.execPath)
|
|
110
|
+
.replaceAll(PLACEHOLDER, pluginRoot)
|
|
111
|
+
.replace("__HOOK_ENTRY__", "meta-bridge-hook.ts");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── one sandbox for the whole switch story (the pid is what ties it together) ──
|
|
115
|
+
const AGENT_ROOT = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-agent-"));
|
|
116
|
+
const PLUGIN_ROOT = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-plugin-"));
|
|
117
|
+
const CWD = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-cwd-"));
|
|
118
|
+
makeBundle(PLUGIN_ROOT);
|
|
119
|
+
|
|
120
|
+
const ROOTS = {
|
|
121
|
+
sessionsDir: path.join(AGENT_ROOT, "meta-sessions"),
|
|
122
|
+
sendersDir: path.join(AGENT_ROOT, "meta-senders"),
|
|
123
|
+
receiversDir: path.join(AGENT_ROOT, "meta-receivers"),
|
|
124
|
+
mailboxDir: path.join(AGENT_ROOT, "meta-mailbox"),
|
|
125
|
+
locksDir: path.join(AGENT_ROOT, "entwurf-v2-locks"),
|
|
126
|
+
socketsDir: path.join(AGENT_ROOT, "entwurf-control"),
|
|
127
|
+
};
|
|
128
|
+
const HOOK_LOG = path.join(AGENT_ROOT, "meta-bridge-hook.log");
|
|
129
|
+
|
|
130
|
+
function hookEnv(): NodeJS.ProcessEnv {
|
|
131
|
+
const env: NodeJS.ProcessEnv = {
|
|
132
|
+
...process.env,
|
|
133
|
+
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
|
|
134
|
+
PI_CODING_AGENT_DIR: AGENT_ROOT,
|
|
135
|
+
ENTWURF_META_SESSIONS_DIR: ROOTS.sessionsDir,
|
|
136
|
+
ENTWURF_META_SENDERS_DIR: ROOTS.sendersDir,
|
|
137
|
+
ENTWURF_META_RECEIVERS_DIR: ROOTS.receiversDir,
|
|
138
|
+
ENTWURF_META_MAILBOX_DIR: ROOTS.mailboxDir,
|
|
139
|
+
};
|
|
140
|
+
// Only the launcher may stamp the provenance token; an inherited one would let the
|
|
141
|
+
// owner join pass vacuously if this gate were ever run from inside a hook.
|
|
142
|
+
delete env.ENTWURF_META_HOOK_LAUNCH;
|
|
143
|
+
return env;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The gate's OWN readers must see the same sandbox as the hook children (the marker
|
|
147
|
+
// readers resolve their dirs from the environment at call time).
|
|
148
|
+
process.env.PI_CODING_AGENT_DIR = AGENT_ROOT;
|
|
149
|
+
process.env.ENTWURF_META_SESSIONS_DIR = ROOTS.sessionsDir;
|
|
150
|
+
process.env.ENTWURF_META_SENDERS_DIR = ROOTS.sendersDir;
|
|
151
|
+
process.env.ENTWURF_META_RECEIVERS_DIR = ROOTS.receiversDir;
|
|
152
|
+
process.env.ENTWURF_META_MAILBOX_DIR = ROOTS.mailboxDir;
|
|
153
|
+
|
|
154
|
+
/** Drive the shipped hook once, through the shipped launcher, as Claude would. */
|
|
155
|
+
function driveHook(opts: { event: string; nativeSessionId: string; source: string; transcriptPath: string }): void {
|
|
156
|
+
const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
|
|
157
|
+
const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
|
|
158
|
+
const result = spawnSync(exe, argv, {
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
input: JSON.stringify({
|
|
161
|
+
hook_event_name: opts.event,
|
|
162
|
+
session_id: opts.nativeSessionId,
|
|
163
|
+
transcript_path: opts.transcriptPath,
|
|
164
|
+
cwd: CWD,
|
|
165
|
+
source: opts.source,
|
|
166
|
+
}),
|
|
167
|
+
env: hookEnv(),
|
|
168
|
+
});
|
|
169
|
+
assert.equal(result.status, 0, `hook drive ${opts.nativeSessionId} exited ${result.status}: ${result.stderr}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The gate's own marker readers, pointed at the sandbox — one definition, so every cell that
|
|
173
|
+
* composes receiver facts reads the same two files the production seam would. */
|
|
174
|
+
const gateReaders = {
|
|
175
|
+
readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId, receiversDir: ROOTS.receiversDir }),
|
|
176
|
+
readSenderMarker: (backend: string, ownerPid: number) =>
|
|
177
|
+
readMetaSenderMarker({ backend: backend as "claude-code", ownerPid, sendersDir: ROOTS.sendersDir }),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
function gardenIdFor(nativeSessionId: string): string {
|
|
181
|
+
const { identities } = listAllMetaIdentitiesDir(ROOTS.sessionsDir);
|
|
182
|
+
const hit = identities.find((i) => i.nativeSessionId === nativeSessionId);
|
|
183
|
+
assert.ok(hit, `no record for native session ${nativeSessionId}`);
|
|
184
|
+
return hit.gardenId;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function identityFor(gardenId: string): MetaIdentity {
|
|
188
|
+
const { identities } = listAllMetaIdentitiesDir(ROOTS.sessionsDir);
|
|
189
|
+
const hit = identities.find((i) => i.gardenId === gardenId);
|
|
190
|
+
assert.ok(hit, `no record for garden ${gardenId}`);
|
|
191
|
+
return hit;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── the switch: one pid, a startup session, then the one the operator resumed ───
|
|
195
|
+
// The abandoned session's transcript is NEVER written — that is what a session opened and
|
|
196
|
+
// left before its first turn looks like on disk, and it is why it was invisible to every
|
|
197
|
+
// surface that only reads records.
|
|
198
|
+
const GHOST_NATIVE = "79e05f96-abandoned-startup";
|
|
199
|
+
const REAL_NATIVE = "f654eed7-resumed-conversation";
|
|
200
|
+
const REAL_TRANSCRIPT = path.join(CWD, `${REAL_NATIVE}.jsonl`);
|
|
201
|
+
writeFileSync(REAL_TRANSCRIPT, "");
|
|
202
|
+
|
|
203
|
+
driveHook({
|
|
204
|
+
event: "SessionStart",
|
|
205
|
+
nativeSessionId: GHOST_NATIVE,
|
|
206
|
+
source: "startup",
|
|
207
|
+
transcriptPath: path.join(CWD, `${GHOST_NATIVE}.jsonl`),
|
|
208
|
+
});
|
|
209
|
+
const GHOST_GID = gardenIdFor(GHOST_NATIVE);
|
|
210
|
+
ok(
|
|
211
|
+
"the startup session arms a receiver marker (the state the switch has to clean up)",
|
|
212
|
+
readMetaReceiverMarker({ gardenId: GHOST_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
driveHook({
|
|
216
|
+
event: "SessionStart",
|
|
217
|
+
nativeSessionId: REAL_NATIVE,
|
|
218
|
+
source: "resume",
|
|
219
|
+
transcriptPath: REAL_TRANSCRIPT,
|
|
220
|
+
});
|
|
221
|
+
const REAL_GID = gardenIdFor(REAL_NATIVE);
|
|
222
|
+
ok("the switch mints a SECOND garden id (two records, one pid)", GHOST_GID !== REAL_GID);
|
|
223
|
+
|
|
224
|
+
// ── A. the hook retires the garden this pid stopped serving ───────────────────
|
|
225
|
+
ok(
|
|
226
|
+
"both records survive the switch — a retirement takes the marker, never the record [QK:MHSS-RECORDS-SURVIVE]",
|
|
227
|
+
existsSync(path.join(ROOTS.sessionsDir, `${GHOST_GID}.meta.json`)) &&
|
|
228
|
+
existsSync(path.join(ROOTS.sessionsDir, `${REAL_GID}.meta.json`)),
|
|
229
|
+
);
|
|
230
|
+
ok(
|
|
231
|
+
"the retired garden's receiver marker is GONE after the switch [QK:MHSS-RETIRE-PREV-GARDEN]",
|
|
232
|
+
!existsSync(metaReceiverMarkerPath(GHOST_GID, ROOTS.receiversDir)),
|
|
233
|
+
);
|
|
234
|
+
ok(
|
|
235
|
+
"the resumed garden keeps its receiver marker, owned by this pid",
|
|
236
|
+
readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
|
|
237
|
+
);
|
|
238
|
+
ok(
|
|
239
|
+
"the sender marker names the garden this pid serves NOW",
|
|
240
|
+
readMetaSenderMarker({ backend: "claude-code", ownerPid: process.pid, sendersDir: ROOTS.sendersDir })?.gardenId ===
|
|
241
|
+
REAL_GID,
|
|
242
|
+
);
|
|
243
|
+
{
|
|
244
|
+
const log = readFileSync(HOOK_LOG, "utf8");
|
|
245
|
+
ok(
|
|
246
|
+
"the hook logs the vendor's own `source` for both events (the receipt this host lacked) [QK:MHSS-SOURCE-LOGGED]",
|
|
247
|
+
/source=startup/.test(log) && /source=resume/.test(log),
|
|
248
|
+
);
|
|
249
|
+
ok(
|
|
250
|
+
"the retirement is named in the log, with the garden it let go",
|
|
251
|
+
new RegExp(`retired receiver marker ${GHOST_GID}`).test(log),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── A negative. A same-garden re-registration must retire NOTHING ─────────────
|
|
256
|
+
// Every UserPromptSubmit rewrites the sender marker with the SAME garden, and a
|
|
257
|
+
// SessionStart can re-fire for the session already being served. A retirement rule that
|
|
258
|
+
// only asked "did the sender marker exist?" would delete the live citizen's own doorbell
|
|
259
|
+
// on the next keystroke — a worse failure than the one being repaired.
|
|
260
|
+
// A same-garden re-registration must retire nothing — and must not even CLAIM to. The marker
|
|
261
|
+
// alone cannot say so on this path: the arm block later in the same run rewrites it, so an
|
|
262
|
+
// unconditional retirement would be invisible in the filesystem and visible only in the log it
|
|
263
|
+
// wrote on the way past. The log is therefore the assertion.
|
|
264
|
+
{
|
|
265
|
+
const before = readFileSync(HOOK_LOG, "utf8").length;
|
|
266
|
+
driveHook({
|
|
267
|
+
event: "SessionStart",
|
|
268
|
+
nativeSessionId: REAL_NATIVE,
|
|
269
|
+
source: "startup",
|
|
270
|
+
transcriptPath: REAL_TRANSCRIPT,
|
|
271
|
+
});
|
|
272
|
+
const delta = readFileSync(HOOK_LOG, "utf8").slice(before);
|
|
273
|
+
ok(
|
|
274
|
+
"a same-garden re-registration retires nothing, and claims no retirement [QK:MHSS-RETIRE-KEEPS-CURRENT]",
|
|
275
|
+
!/retired receiver marker/.test(delta) &&
|
|
276
|
+
readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
// UserPromptSubmit is the cell that MATTERS here, and it is why the guard is a
|
|
280
|
+
// garden COMPARISON rather than "did a sender marker exist?". It rewrites the sender
|
|
281
|
+
// marker on every keystroke but CANNOT re-arm the watch (watchPaths is not emittable
|
|
282
|
+
// from that event), so a retirement that fired here would delete the live doorbell with
|
|
283
|
+
// nothing to put it back — the session would go silently undeliverable mid-conversation.
|
|
284
|
+
driveHook({
|
|
285
|
+
event: "UserPromptSubmit",
|
|
286
|
+
nativeSessionId: REAL_NATIVE,
|
|
287
|
+
source: "startup",
|
|
288
|
+
transcriptPath: REAL_TRANSCRIPT,
|
|
289
|
+
});
|
|
290
|
+
ok(
|
|
291
|
+
"a keystroke (UserPromptSubmit, which cannot re-arm) retires NOTHING",
|
|
292
|
+
readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
|
|
293
|
+
);
|
|
294
|
+
// …and the same holds when the garden DISAGREES (cross-review, 2026-09-04). The cell above
|
|
295
|
+
// exercises only the same-garden branch, so the retirement's guard could be the garden
|
|
296
|
+
// comparison alone and still look green; this one drives a UPS envelope naming a different
|
|
297
|
+
// session and pins the OTHER half — the event class. UPS cannot emit watchPaths, so a
|
|
298
|
+
// retirement reached from there could only ever subtract a doorbell.
|
|
299
|
+
//
|
|
300
|
+
// This is a mechanism cell, not a threat model: a keystroke from a session the process has
|
|
301
|
+
// already left is not something this host produces (measured: every UPS named its own pid's
|
|
302
|
+
// current session, 8 of 8). The rule it pins is simply that a watch is retired by a run that
|
|
303
|
+
// arms one, and this envelope is the cheapest way to reach the wrong branch on purpose.
|
|
304
|
+
driveHook({
|
|
305
|
+
event: "UserPromptSubmit",
|
|
306
|
+
nativeSessionId: GHOST_NATIVE,
|
|
307
|
+
source: "startup",
|
|
308
|
+
transcriptPath: path.join(CWD, `${GHOST_NATIVE}.jsonl`),
|
|
309
|
+
});
|
|
310
|
+
ok(
|
|
311
|
+
"a MISMATCHED keystroke cannot disarm the live citizen either [QK:MHSS-RETIRE-ARM-CAPABLE-ONLY]",
|
|
312
|
+
readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
|
|
313
|
+
);
|
|
314
|
+
// Put the pointer back where the operator's session left it, so the cells below measure the
|
|
315
|
+
// switch and not this deliberate corruption.
|
|
316
|
+
driveHook({
|
|
317
|
+
event: "UserPromptSubmit",
|
|
318
|
+
nativeSessionId: REAL_NATIVE,
|
|
319
|
+
source: "startup",
|
|
320
|
+
transcriptPath: REAL_TRANSCRIPT,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ── B. the reader is fail-closed even when the stale marker is still there ────
|
|
324
|
+
// Re-plant exactly what the pre-repair hook left behind: a receiver marker for the
|
|
325
|
+
// retired garden, owned by a LIVE pid, identical to the record. Every axis the old
|
|
326
|
+
// predicate looked at says "active"; only the owner's sender marker says otherwise.
|
|
327
|
+
writeMetaReceiverMarker({
|
|
328
|
+
gardenId: GHOST_GID,
|
|
329
|
+
backend: "claude-code",
|
|
330
|
+
nativeSessionId: GHOST_NATIVE,
|
|
331
|
+
ownerPid: process.pid,
|
|
332
|
+
armProvenance: "session-start",
|
|
333
|
+
receiversDir: ROOTS.receiversDir,
|
|
334
|
+
});
|
|
335
|
+
const ghostIdentity = identityFor(GHOST_GID);
|
|
336
|
+
const realIdentity = identityFor(REAL_GID);
|
|
337
|
+
{
|
|
338
|
+
const ghostFacts = resolveMailboxReceiverFacts(ghostIdentity, gateReaders);
|
|
339
|
+
ok("the re-planted stale marker still passes the identity match (ownerAlive)", ghostFacts.ownerAlive);
|
|
340
|
+
ok(
|
|
341
|
+
"…but its watch is NOT armed — measured against the owner's sender marker, never copied [QK:MHSS-SENDER-JOIN-MEASURED]",
|
|
342
|
+
ghostFacts.watchArmed === false,
|
|
343
|
+
);
|
|
344
|
+
const realFacts = resolveMailboxReceiverFacts(realIdentity, gateReaders);
|
|
345
|
+
ok(
|
|
346
|
+
"the served garden is alive AND armed (the join is not a blanket refusal)",
|
|
347
|
+
realFacts.ownerAlive && realFacts.watchArmed,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── B + C through the PRODUCTION seam, which is where the false success shipped ─
|
|
352
|
+
function deps() {
|
|
353
|
+
return makeProductionEntwurfV2Deps({
|
|
354
|
+
senderProvider: () => ({
|
|
355
|
+
sessionId: "gate-sender",
|
|
356
|
+
agentId: "meta-session/claude-code",
|
|
357
|
+
cwd: CWD,
|
|
358
|
+
timestamp: new Date().toISOString(),
|
|
359
|
+
origin: "meta-session",
|
|
360
|
+
replyable: true,
|
|
361
|
+
}),
|
|
362
|
+
sessionsDir: ROOTS.sessionsDir,
|
|
363
|
+
mailboxDir: ROOTS.mailboxDir,
|
|
364
|
+
lockDir: ROOTS.locksDir,
|
|
365
|
+
controlSocketDir: ROOTS.socketsDir,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const ghostDecision = (await deps().decide({
|
|
370
|
+
target: GHOST_GID,
|
|
371
|
+
intent: "fire-and-forget",
|
|
372
|
+
message: "into the void?",
|
|
373
|
+
})) as DispatchDecision;
|
|
374
|
+
ok(
|
|
375
|
+
"production dispatch REFUSES the retired garden — the false deliverable, at the seam [QK:MHSS-SEAM-USES-JOIN]",
|
|
376
|
+
ghostDecision.kind === "reject" && ghostDecision.receipt.reason === "mailbox-undeliverable",
|
|
377
|
+
);
|
|
378
|
+
ok(
|
|
379
|
+
"the reject carries the predicate's reason instead of dropping it at the decider boundary [QK:MHSS-REJECT-CARRIES-REASON]",
|
|
380
|
+
ghostDecision.kind === "reject" &&
|
|
381
|
+
ghostDecision.diagnostic?.kind === "mailbox-undeliverable" &&
|
|
382
|
+
/idle-watch not armed/.test(ghostDecision.diagnostic.reason),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const realDecision = (await deps().decide({
|
|
386
|
+
target: REAL_GID,
|
|
387
|
+
intent: "fire-and-forget",
|
|
388
|
+
message: "to the conversation the operator is in",
|
|
389
|
+
})) as DispatchDecision;
|
|
390
|
+
ok(
|
|
391
|
+
"the served garden is still deliverable — exactly ONE of the two, and it is the live one [QK:MHSS-EXACTLY-ONE-DELIVERABLE]",
|
|
392
|
+
realDecision.kind === "execute" && realDecision.plan.transport === "meta-mailbox" && ghostDecision.kind === "reject",
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// C — the surface has to SAY it. A caller that reads only "mailbox-undeliverable" goes
|
|
396
|
+
// looking for a dead session; this one was alive and had moved.
|
|
397
|
+
{
|
|
398
|
+
const rendered = renderEntwurfV2Result(
|
|
399
|
+
await runEntwurfV2({ target: GHOST_GID, intent: "fire-and-forget", message: "into the void?" }, deps()),
|
|
400
|
+
);
|
|
401
|
+
ok(
|
|
402
|
+
"the rendered reject names the failing receiver axis [QK:MHSS-REASON-RENDERED]",
|
|
403
|
+
rendered.isError && /mailbox-undeliverable: .*idle-watch not armed/.test(rendered.text),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A real delivery to the live garden still lands, in the sandbox mailbox.
|
|
408
|
+
{
|
|
409
|
+
const result = await runEntwurfV2(
|
|
410
|
+
{ target: REAL_GID, intent: "fire-and-forget", message: "hello, live citizen" },
|
|
411
|
+
deps(),
|
|
412
|
+
);
|
|
413
|
+
ok(
|
|
414
|
+
"a message to the served garden is enqueued into ITS mailbox",
|
|
415
|
+
result.kind === "executed" &&
|
|
416
|
+
result.transport === "meta-mailbox" &&
|
|
417
|
+
existsSync(path.join(ROOTS.mailboxDir, REAL_GID)),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── the join's THREE edges, each of which a mutant would otherwise walk through ─
|
|
422
|
+
// (cross-review, 2026-09-04: the cells above never varied ownerKind, never planted a stale
|
|
423
|
+
// sender start-key, and never disagreed on backend, so three real weakenings survived them.)
|
|
424
|
+
{
|
|
425
|
+
// EDGE 1 — the join is scoped by ownerKind, and outside that scope its ABSENCE is not a
|
|
426
|
+
// failure. A Copilot watch lives in a forked first-party extension child with its own pid,
|
|
427
|
+
// so `meta-senders/copilot/<that pid>.json` never exists by construction. If the join were
|
|
428
|
+
// applied there, every Copilot citizen would be permanently undeliverable — a regression on
|
|
429
|
+
// a shipped lane dressed as a fix.
|
|
430
|
+
const copilot = upsertMetaSession({
|
|
431
|
+
input: {
|
|
432
|
+
backend: "copilot",
|
|
433
|
+
nativeSessionId: "copilot-extension-owned",
|
|
434
|
+
transcriptPath: REAL_TRANSCRIPT,
|
|
435
|
+
cwd: CWD,
|
|
436
|
+
},
|
|
437
|
+
dir: ROOTS.sessionsDir,
|
|
438
|
+
});
|
|
439
|
+
writeMetaReceiverMarker({
|
|
440
|
+
gardenId: copilot.record.gardenId,
|
|
441
|
+
backend: "copilot",
|
|
442
|
+
nativeSessionId: copilot.record.nativeSessionId,
|
|
443
|
+
ownerPid: process.pid,
|
|
444
|
+
ownerKind: "copilot-extension",
|
|
445
|
+
armProvenance: "extension-join",
|
|
446
|
+
receiversDir: ROOTS.receiversDir,
|
|
447
|
+
});
|
|
448
|
+
const facts = resolveMailboxReceiverFacts(copilot.record, gateReaders);
|
|
449
|
+
ok(
|
|
450
|
+
"a copilot-extension watch is ARMED with no sender marker of its own — the join does not apply there [QK:MHSS-JOIN-SCOPE-OWNERKIND]",
|
|
451
|
+
facts.ownerAlive && facts.watchArmed,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
{
|
|
455
|
+
// EDGE 2 — the sender marker is trusted only while its owner is the SAME live process. A
|
|
456
|
+
// marker left by a dead session (or a reused pid) must not answer "which garden does this
|
|
457
|
+
// pid serve now?", and the reader's start-key guard is what refuses it. A production reader
|
|
458
|
+
// that asked with `verifyOwner: false` would accept this file, and every fixture pid here is
|
|
459
|
+
// live, so nothing else in this gate could tell the difference.
|
|
460
|
+
const senderFile = path.join(ROOTS.sendersDir, "claude-code", `${process.pid}.json`);
|
|
461
|
+
const good = readFileSync(senderFile, "utf8");
|
|
462
|
+
writeFileSync(senderFile, good.replace(/"ownerStartKey": "[^"]*"/, '"ownerStartKey": "STALE-FROM-A-DEAD-SESSION"'));
|
|
463
|
+
const decision = (await deps().decide({
|
|
464
|
+
target: REAL_GID,
|
|
465
|
+
intent: "fire-and-forget",
|
|
466
|
+
message: "with a stale sender marker",
|
|
467
|
+
})) as DispatchDecision;
|
|
468
|
+
ok(
|
|
469
|
+
"a sender marker whose owner start-key no longer matches is refused, not read [QK:MHSS-SENDER-START-KEY-VERIFIED]",
|
|
470
|
+
decision.kind === "reject" && decision.receipt.reason === "mailbox-undeliverable",
|
|
471
|
+
);
|
|
472
|
+
writeFileSync(senderFile, good);
|
|
473
|
+
}
|
|
474
|
+
{
|
|
475
|
+
// EDGE 3 — the join compares backend as well as garden id. Two backends can key markers under
|
|
476
|
+
// one pid (a Claude CLI that also hosts another harness's writer), and a marker whose body
|
|
477
|
+
// names a different backend than the receiver it is being joined to is not that receiver's
|
|
478
|
+
// evidence. Every fixture above shares one backend, so the comparison was free to disappear.
|
|
479
|
+
const senderFile = path.join(ROOTS.sendersDir, "claude-code", `${process.pid}.json`);
|
|
480
|
+
const good = readFileSync(senderFile, "utf8");
|
|
481
|
+
writeFileSync(senderFile, good.replace(/"backend": "claude-code"/, '"backend": "copilot"'));
|
|
482
|
+
const decision = (await deps().decide({
|
|
483
|
+
target: REAL_GID,
|
|
484
|
+
intent: "fire-and-forget",
|
|
485
|
+
message: "with a backend-drifted sender marker",
|
|
486
|
+
})) as DispatchDecision;
|
|
487
|
+
ok(
|
|
488
|
+
"a sender marker naming another backend does not vouch for this receiver [QK:MHSS-JOIN-BACKEND-EQUALITY]",
|
|
489
|
+
decision.kind === "reject" && decision.receipt.reason === "mailbox-undeliverable",
|
|
490
|
+
);
|
|
491
|
+
writeFileSync(senderFile, good);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── D. the listing has to SHOW the difference (#101 갭 D) ────────────────────
|
|
495
|
+
// This is the surface a caller actually reads before dispatching. Both citizens are
|
|
496
|
+
// `liveness=unsupported` (claude-code has no control socket to probe), same cwd, same
|
|
497
|
+
// backend — identical rows, which is how a sibling picked the phantom. The two observed
|
|
498
|
+
// axes are what separate them, and they are measured through the REAL observer the
|
|
499
|
+
// provider defaults to, against this sandbox.
|
|
500
|
+
{
|
|
501
|
+
const listing = await listEntwurfFacts({
|
|
502
|
+
metaEntries: readActiveStoreEntries(ROOTS.sessionsDir),
|
|
503
|
+
readRecord: makeStoreRecordReader(ROOTS.sessionsDir),
|
|
504
|
+
socket: { dir: ROOTS.socketsDir },
|
|
505
|
+
});
|
|
506
|
+
const ghostRow = listing.facts.peers.find((p) => p.gardenId === GHOST_GID);
|
|
507
|
+
const realRow = listing.facts.peers.find((p) => p.gardenId === REAL_GID);
|
|
508
|
+
ok(
|
|
509
|
+
"both citizens are listed, and both read liveness=unsupported (the axis that cannot tell them apart)",
|
|
510
|
+
ghostRow?.liveness === "unsupported" && realRow?.liveness === "unsupported",
|
|
511
|
+
);
|
|
512
|
+
ok(
|
|
513
|
+
"the abandoned row shows a transcript that was never written [QK:MHSS-PEERS-TRANSCRIPT-OBSERVED]",
|
|
514
|
+
ghostRow?.transcript === "absent" && realRow?.transcript === "exists",
|
|
515
|
+
);
|
|
516
|
+
// The stale marker re-planted above is still on disk, so the ghost reads `inactive`
|
|
517
|
+
// (a marker that fails the join) rather than `none` (no marker at all) — the split a
|
|
518
|
+
// reader needs to tell "was armed, no longer valid" from "never armed".
|
|
519
|
+
ok(
|
|
520
|
+
"the abandoned row shows an inactive receiver beside the live one [QK:MHSS-PEERS-RECEIVER-OBSERVED]",
|
|
521
|
+
ghostRow?.receiver === "inactive" && realRow?.receiver === "active",
|
|
522
|
+
);
|
|
523
|
+
const { text } = renderEntwurfPeers(listing);
|
|
524
|
+
ok(
|
|
525
|
+
"the rendered row carries both observed columns [QK:MHSS-PEERS-COLUMNS-RENDERED]",
|
|
526
|
+
new RegExp(`${GHOST_GID}.*receiver=inactive.*transcript=absent`).test(text) &&
|
|
527
|
+
new RegExp(`${REAL_GID}.*receiver=active.*transcript=exists`).test(text),
|
|
528
|
+
);
|
|
529
|
+
ok("the listing still routes no verbs (facts only)", !/sendable|resumable|dispatch|mailboxDeliverable/.test(text));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── a retirement must never reach a watch ANOTHER live process holds ─────────
|
|
533
|
+
// The switch rule is "this pid stopped serving that garden", so the marker it removes
|
|
534
|
+
// has to be this pid's own. Re-plant the served garden's marker under a different LIVE
|
|
535
|
+
// pid (this gate's parent) and switch again: the hook still wants to retire that garden,
|
|
536
|
+
// and must not, because the doorbell now belongs to someone else. Getting this wrong is
|
|
537
|
+
// not a stale marker — it is one session silently disarming another's inbox.
|
|
538
|
+
{
|
|
539
|
+
writeMetaReceiverMarker({
|
|
540
|
+
gardenId: REAL_GID,
|
|
541
|
+
backend: "claude-code",
|
|
542
|
+
nativeSessionId: REAL_NATIVE,
|
|
543
|
+
ownerPid: process.ppid,
|
|
544
|
+
armProvenance: "session-start",
|
|
545
|
+
receiversDir: ROOTS.receiversDir,
|
|
546
|
+
});
|
|
547
|
+
driveHook({
|
|
548
|
+
event: "SessionStart",
|
|
549
|
+
nativeSessionId: "cafe0000-another-conversation",
|
|
550
|
+
source: "clear",
|
|
551
|
+
transcriptPath: path.join(CWD, "cafe0000.jsonl"),
|
|
552
|
+
});
|
|
553
|
+
ok(
|
|
554
|
+
"a retirement leaves a marker owned by another live pid alone [QK:MHSS-RETIRE-ONLY-OWN-OWNER]",
|
|
555
|
+
readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir, verifyOwner: false })?.ownerPid ===
|
|
556
|
+
process.ppid,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
console.log(`\ncheck-meta-hook-session-switch: ${passed} assertions passed`);
|
|
@@ -736,19 +736,43 @@ writeMetaReceiverMarker({
|
|
|
736
736
|
receiversDir,
|
|
737
737
|
});
|
|
738
738
|
|
|
739
|
-
// RECEIVER — a DIFFERENT citizen, armed by a live owner pid
|
|
740
|
-
//
|
|
741
|
-
//
|
|
739
|
+
// RECEIVER — a DIFFERENT citizen, armed by a DIFFERENT live owner pid. An unarmed target
|
|
740
|
+
// is correctly fail-closed as mailbox-undeliverable, so an unarmed probe could never
|
|
741
|
+
// separate "the artifact cannot read its registry" from "nobody is listening".
|
|
742
|
+
//
|
|
743
|
+
// The owner is a spawned idle process, not this one (#101): a claude-code watch owner
|
|
744
|
+
// serves exactly one garden at a time, and deliverability asks whether that owner's sender
|
|
745
|
+
// marker still names the garden being addressed. This probe's own pid already carries the
|
|
746
|
+
// SENDER citizen's marker, so arming the receiver under it would model a session switch —
|
|
747
|
+
// a retired watch — and the probe would report a delivery failure that is really the
|
|
748
|
+
// fixture. It cannot be the probe's parent either: the bridge resolves its own sender
|
|
749
|
+
// identity through its ancestry, and an ancestor holding two gardens is the "ambiguous
|
|
750
|
+
// sender identity" refusal. A live pid outside that ancestry is what a second native CLI
|
|
751
|
+
// actually looks like.
|
|
752
|
+
const receiverOwner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 300000)'], { stdio: 'ignore' });
|
|
753
|
+
const receiverOwnerPid = receiverOwner.pid;
|
|
754
|
+
if (typeof receiverOwnerPid !== 'number') {
|
|
755
|
+
console.error('could not spawn the stand-in receiver owner process');
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
742
758
|
const receiver = upsertMetaSession({
|
|
743
759
|
input: { backend: 'claude-code', nativeSessionId: `doctor-delivery-receiver-${process.pid}`, cwd: tmp },
|
|
744
760
|
dir: sessionsDir,
|
|
745
761
|
});
|
|
746
762
|
const gid = receiver.record.gardenId;
|
|
763
|
+
writeMetaSenderMarker({
|
|
764
|
+
backend: 'claude-code',
|
|
765
|
+
gardenId: gid,
|
|
766
|
+
nativeSessionId: receiver.record.nativeSessionId,
|
|
767
|
+
cwd: tmp,
|
|
768
|
+
ownerPid: receiverOwnerPid,
|
|
769
|
+
sendersDir,
|
|
770
|
+
});
|
|
747
771
|
writeMetaReceiverMarker({
|
|
748
772
|
gardenId: gid,
|
|
749
773
|
backend: 'claude-code',
|
|
750
774
|
nativeSessionId: receiver.record.nativeSessionId,
|
|
751
|
-
ownerPid:
|
|
775
|
+
ownerPid: receiverOwnerPid,
|
|
752
776
|
armProvenance: 'session-start',
|
|
753
777
|
receiversDir,
|
|
754
778
|
});
|
|
@@ -845,6 +869,7 @@ try {
|
|
|
845
869
|
bail(String(e instanceof Error ? e.message : e));
|
|
846
870
|
} finally {
|
|
847
871
|
try { child?.kill('SIGTERM'); } catch {}
|
|
872
|
+
try { receiverOwner.kill('SIGTERM'); } catch {}
|
|
848
873
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
849
874
|
}
|
|
850
875
|
if (verdict === 0) console.log('delivered: one .msg landed + doorbell poked; seeded meta-sender identity joined');
|