@junghanacs/entwurf 0.17.1 → 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 +248 -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-mailbox.js +9 -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-runner.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
- 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/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
- 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-mailbox.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
- package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
- package/pi-extensions/lib/meta-session.ts +76 -7
- 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-mailbox.ts +33 -0
- package/scripts/check-entwurf-v2-matrix.ts +10 -5
- package/scripts/check-entwurf-v2-production.ts +72 -1
- package/scripts/check-entwurf-v2-runner.ts +18 -0
- package/scripts/check-entwurf-v2-send.ts +13 -1
- package/scripts/check-entwurf-v2-surface.ts +75 -3
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +70 -1
- package/scripts/check-mailbox-receipt-state.ts +6 -0
- package/scripts/check-meta-doctor-oracle.sh +73 -0
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/check-meta-mailbox-state-write.ts +9 -2
- package/scripts/check-meta-manifest-schema.py +19 -1
- package/scripts/check-meta-session.ts +10 -2
- package/scripts/meta-bridge-doctor.sh +47 -7
- package/scripts/meta-bridge-state.py +23 -5
- package/scripts/meta-bridge-statusline.sh +65 -2
- package/scripts/mutants/meta-hook-session-switch.json +223 -0
- package/scripts/raw-async-delivery/README.md +280 -9
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
- package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
- package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
- package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -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-meta-async-drift.sh +31 -8
- package/scripts/smoke-meta-install-state.sh +170 -11
- package/scripts/smoke-meta-keyset-guard.sh +4 -1
- package/scripts/smoke-mux-fresh-call-live.ts +13 -3
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type DispatchDecision,
|
|
43
43
|
decideDispatch,
|
|
44
44
|
type ExecutionPlan,
|
|
45
|
+
type RejectDiagnostic,
|
|
45
46
|
type TargetResolution,
|
|
46
47
|
} from "../pi-extensions/lib/entwurf-v2-decider.ts";
|
|
47
48
|
import type { AcquireLockResult, LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
|
|
@@ -156,7 +157,10 @@ type LockClass = "none" | "held" | "mailbox-null" | "released" | "acquire-fail";
|
|
|
156
157
|
|
|
157
158
|
type Expect =
|
|
158
159
|
| { decision: "execute"; transport: EntwurfV2Transport; lock: LockClass }
|
|
159
|
-
|
|
160
|
+
// `diagnostic` names the KIND a reject must carry, not a boolean: two reject cells now
|
|
161
|
+
// carry machine-readable evidence, and the axis has to say which — a reject that carried
|
|
162
|
+
// the wrong diagnostic would pass a yes/no cell (#101 갭 C).
|
|
163
|
+
| { decision: "reject"; reason: string; lock: LockClass; diagnostic?: RejectDiagnostic["kind"] };
|
|
160
164
|
|
|
161
165
|
/**
|
|
162
166
|
* Which IO seams the decider is ALLOWED to touch — the "어느 축을 만지면 안 되는지"
|
|
@@ -211,7 +215,7 @@ const ROWS: Row[] = [
|
|
|
211
215
|
targetKind: "pi, lock held by another",
|
|
212
216
|
intent: "fire-and-forget",
|
|
213
217
|
scenario: { resolution: { identity: identity("pi"), preProbeAddressConflict: false }, lock: "conflict" },
|
|
214
|
-
expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic:
|
|
218
|
+
expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic: "target-locked" },
|
|
215
219
|
},
|
|
216
220
|
{
|
|
217
221
|
name: "unsupported self-fetch active → meta-mailbox",
|
|
@@ -231,7 +235,8 @@ const ROWS: Row[] = [
|
|
|
231
235
|
resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
|
|
232
236
|
mailboxDeliverable: false,
|
|
233
237
|
},
|
|
234
|
-
|
|
238
|
+
// #101 갭 C: an undeliverable mailbox reject carries the receiver axis that failed.
|
|
239
|
+
expect: { decision: "reject", reason: "mailbox-undeliverable", lock: "none", diagnostic: "mailbox-undeliverable" },
|
|
235
240
|
},
|
|
236
241
|
{
|
|
237
242
|
name: "in-domain live ff → control-socket",
|
|
@@ -338,8 +343,8 @@ async function runRow(row: Row): Promise<void> {
|
|
|
338
343
|
}
|
|
339
344
|
if (row.expect.diagnostic) {
|
|
340
345
|
ok(
|
|
341
|
-
`${row.name}: carries
|
|
342
|
-
d.kind === "reject" && d.diagnostic?.kind ===
|
|
346
|
+
`${row.name}: carries the ${row.expect.diagnostic} diagnostic`,
|
|
347
|
+
d.kind === "reject" && d.diagnostic?.kind === row.expect.diagnostic,
|
|
343
348
|
);
|
|
344
349
|
} else {
|
|
345
350
|
ok(`${row.name}: no diagnostic`, d.kind === "reject" && d.diagnostic === undefined);
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
|
|
14
14
|
* enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
|
|
15
15
|
* the direct hand used — direct send and fallback send never drift.
|
|
16
|
+
* E4. #101 — the deliverability seam reads BOTH markers: a live receiver marker whose
|
|
17
|
+
* owner's sender marker names ANOTHER garden is undeliverable. The SAME closure serves
|
|
18
|
+
* the direct send and the dead-control fallback, so neither can be fooled alone.
|
|
16
19
|
*
|
|
17
20
|
* No real IO — every seam is a spy; the factory's COMPOSITION is what is under test.
|
|
18
21
|
*/
|
|
@@ -26,7 +29,7 @@ import {
|
|
|
26
29
|
} from "../pi-extensions/lib/entwurf-v2-production.ts";
|
|
27
30
|
import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
28
31
|
import type { ControlSocketPlan, MetaMailboxPlan } from "../pi-extensions/lib/entwurf-v2-send.ts";
|
|
29
|
-
import type { MetaIdentity, MetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
|
|
32
|
+
import type { MetaIdentity, MetaReceiverMarker, MetaSenderMarker } from "../pi-extensions/lib/meta-session.ts";
|
|
30
33
|
import type { NativePushAdapter, NativePushProbeResult } from "../pi-extensions/lib/native-push/adapter.ts";
|
|
31
34
|
import type { TargetSocketInspection } from "../pi-extensions/lib/socket-discovery.ts";
|
|
32
35
|
|
|
@@ -38,6 +41,8 @@ function ok(label: string, cond: boolean): void {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
const GID = "20260613T100000-aaaaaa";
|
|
44
|
+
/** The garden a switched-away owner serves instead (#101). */
|
|
45
|
+
const OTHER_GID = "20260613T100000-bbbbbb";
|
|
41
46
|
const LOCK_DIR = "/fake/locks";
|
|
42
47
|
const SESSIONS_DIR = "/fake/sessions";
|
|
43
48
|
const MAILBOX_DIR = "/fake/mailbox";
|
|
@@ -74,6 +79,19 @@ function receiverMarker(gid: string, backend: string, nativeSessionId = "n"): Me
|
|
|
74
79
|
};
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
/** The receiver owner's sender marker — "which garden does this pid serve NOW?" (#101). */
|
|
83
|
+
function senderMarker(gardenId: string, backend: string, ownerPid: number): MetaSenderMarker {
|
|
84
|
+
return {
|
|
85
|
+
gardenId,
|
|
86
|
+
backend: backend as MetaSenderMarker["backend"],
|
|
87
|
+
nativeSessionId: "n",
|
|
88
|
+
cwd: "/cwd",
|
|
89
|
+
ownerPid,
|
|
90
|
+
ownerStartKey: "x",
|
|
91
|
+
updatedAt: "t",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
function lockClaim(gardenId = GID): LockClaim {
|
|
78
96
|
return {
|
|
79
97
|
gardenId,
|
|
@@ -133,6 +151,11 @@ function makeSpiedFactory(over: {
|
|
|
133
151
|
/** SE-2 2d-3 — the target's receiver presence marker: "active" (matches identity,
|
|
134
152
|
* default), "absent" (terminated/never-armed), or "mismatch" (drifted native id). */
|
|
135
153
|
receiverMarker?: "active" | "absent" | "mismatch";
|
|
154
|
+
/** #101 — the receiver owner's SENDER marker, i.e. which garden that pid serves NOW:
|
|
155
|
+
* "same" (still this one, default), "other-garden" (the owner switched sessions in
|
|
156
|
+
* place — a live marker whose watch is retired), or "absent" (no sender marker at all).
|
|
157
|
+
* Injected rather than defaulted so this gate never stats the operator's real roots. */
|
|
158
|
+
senderMarker?: "same" | "other-garden" | "absent";
|
|
136
159
|
}) {
|
|
137
160
|
const spies: Spies = {
|
|
138
161
|
acquire: [],
|
|
@@ -161,6 +184,10 @@ function makeSpiedFactory(over: {
|
|
|
161
184
|
const nsid = over.receiverMarker === "mismatch" ? "DRIFT" : "n";
|
|
162
185
|
return receiverMarker(gid, over.backend ?? "pi", nsid);
|
|
163
186
|
},
|
|
187
|
+
readSenderMarker: (backend, ownerPid) => {
|
|
188
|
+
if (over.senderMarker === "absent") return null;
|
|
189
|
+
return senderMarker(over.senderMarker === "other-garden" ? OTHER_GID : GID, backend, ownerPid);
|
|
190
|
+
},
|
|
164
191
|
inspectPath: async (socketPath) => {
|
|
165
192
|
spies.inspectPath.push({ socketPath });
|
|
166
193
|
if (over.inspectKind === "indeterminate") {
|
|
@@ -388,6 +415,50 @@ async function main(): Promise<void> {
|
|
|
388
415
|
ok("E3: drifted marker → enqueue NEVER called (presence ≠ identity match)", spies.enqueue.length === 0);
|
|
389
416
|
}
|
|
390
417
|
|
|
418
|
+
// ── E4: #101 — a LIVE receiver marker whose owner switched gardens. Every axis the
|
|
419
|
+
// pre-#101 seam looked at says "active": the marker exists, its owner is live, and it
|
|
420
|
+
// matches this identity exactly. Only the owner's sender marker — which garden that pid
|
|
421
|
+
// serves NOW — says the watch is retired. This is the cell where a real message was
|
|
422
|
+
// enqueued into a mailbox nobody was draining (oracle, 2026-09-04). ─────────────────
|
|
423
|
+
{
|
|
424
|
+
const { deps, spies } = makeSpiedFactory({
|
|
425
|
+
backend: "claude-code",
|
|
426
|
+
rpc: "dead-throw",
|
|
427
|
+
classifyDead: true,
|
|
428
|
+
receiverMarker: "active",
|
|
429
|
+
senderMarker: "other-garden",
|
|
430
|
+
});
|
|
431
|
+
const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
|
|
432
|
+
ok(
|
|
433
|
+
"E4: owner switched gardens → rejected (mailbox-undeliverable), never fallback-sent",
|
|
434
|
+
res.outcome === "rejected" && res.rejectReason === "mailbox-undeliverable",
|
|
435
|
+
);
|
|
436
|
+
ok("E4: nothing enqueued into the retired garden's mailbox", spies.enqueue.length === 0);
|
|
437
|
+
// …and the same wiring still DELIVERS when that owner is serving this garden, so the
|
|
438
|
+
// join is a measurement and not a blanket refusal.
|
|
439
|
+
const served = makeSpiedFactory({
|
|
440
|
+
backend: "claude-code",
|
|
441
|
+
rpc: "dead-throw",
|
|
442
|
+
classifyDead: true,
|
|
443
|
+
receiverMarker: "active",
|
|
444
|
+
senderMarker: "same",
|
|
445
|
+
});
|
|
446
|
+
const ok2 = await served.deps.executor.sendControl(CONTROL_PLAN, lockClaim());
|
|
447
|
+
ok("E4: the served garden still falls back to a real mailbox enqueue", ok2.outcome === "fallback-sent");
|
|
448
|
+
ok(
|
|
449
|
+
"E4: a MISSING sender marker is fail-closed, not optimistic",
|
|
450
|
+
(
|
|
451
|
+
await makeSpiedFactory({
|
|
452
|
+
backend: "claude-code",
|
|
453
|
+
rpc: "dead-throw",
|
|
454
|
+
classifyDead: true,
|
|
455
|
+
receiverMarker: "active",
|
|
456
|
+
senderMarker: "absent",
|
|
457
|
+
}).deps.executor.sendControl(CONTROL_PLAN, lockClaim())
|
|
458
|
+
).outcome === "rejected",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
391
462
|
// ── F: #50 C4 — record-LESS control socket → pre-probe record-less-socket reject ──
|
|
392
463
|
// resolveTarget finds no meta-record, does ONE record-side lstat (inspectPath), sees a
|
|
393
464
|
// non-symlink socket → recordLessSocket. EVERY intent then rejects pre-probe as
|
|
@@ -212,6 +212,24 @@ async function main(): Promise<void> {
|
|
|
212
212
|
"4: executed{meta-mailbox, success}",
|
|
213
213
|
res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.success === true,
|
|
214
214
|
);
|
|
215
|
+
// #98 R: a dep that reports no path leaves the outcome's receipt undefined. The
|
|
216
|
+
// runner must not invent one — a guessed path is worse than no path.
|
|
217
|
+
ok(
|
|
218
|
+
"4: no enqueue receipt from the dep → messagePath stays undefined (never invented)",
|
|
219
|
+
res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.messagePath === undefined,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── 4a: #98 R — the mailbox enqueue receipt is carried through verbatim ───
|
|
224
|
+
{
|
|
225
|
+
const { deps } = makeDeps({ mailbox: { result: { success: true, messagePath: "/fake/mailbox/g/2026.msg" } } });
|
|
226
|
+
const res = await executeDispatch(executeDecision(MAILBOX_PLAN, null), deps);
|
|
227
|
+
ok(
|
|
228
|
+
"4a: executed{meta-mailbox} carries the dep's messagePath verbatim",
|
|
229
|
+
res.kind === "executed" &&
|
|
230
|
+
res.outcome.transport === "meta-mailbox" &&
|
|
231
|
+
res.outcome.messagePath === "/fake/mailbox/g/2026.msg",
|
|
232
|
+
);
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
// ── 4b: native-push execute → sendNativePush(plan, null), lock-free (봉인 4) ──
|
|
@@ -213,6 +213,9 @@ async function main(): Promise<void> {
|
|
|
213
213
|
fallbackSend: { result: { success: true } },
|
|
214
214
|
});
|
|
215
215
|
ok("dead → re-resolve(control) success → fallback-sent", result.outcome === "fallback-sent");
|
|
216
|
+
// A socket retry hands the body to a live receiver and writes no file: no receipt to
|
|
217
|
+
// invent (#98 R is a per-FILE identifier, not a per-delivery one).
|
|
218
|
+
ok("dead → socket retry carries NO messagePath (no file exists)", result.messagePath === undefined);
|
|
216
219
|
ok("dead → deadFallback called exactly once", trace.deadFallbackCalls === 1);
|
|
217
220
|
ok("dead → re-resolve used the alt socket", trace.socketSends[1]?.socketPath === "/fake/ctl/alt.sock");
|
|
218
221
|
ok("dead → release ×1", trace.releases.length === 1);
|
|
@@ -277,9 +280,16 @@ async function main(): Promise<void> {
|
|
|
277
280
|
const sent = await run({
|
|
278
281
|
firstSend: { throwCode: "ECONNREFUSED" },
|
|
279
282
|
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
280
|
-
fallbackSend: { result: { success: true } },
|
|
283
|
+
fallbackSend: { result: { success: true, messagePath: "/fake/mb/gid/2026-fallback.msg" } },
|
|
281
284
|
});
|
|
282
285
|
ok("dead → re-resolve(mailbox) enqueue → fallback-sent", sent.result.outcome === "fallback-sent");
|
|
286
|
+
// #98 R, fallback leg: this enqueue wrote a file, so the sender gets its name — the
|
|
287
|
+
// same per-message receipt the primary mailbox rail hands back. Dropping it here was
|
|
288
|
+
// the one mailbox delivery with no identifier.
|
|
289
|
+
ok(
|
|
290
|
+
"dead → mailbox fallback carries the #98 R messagePath",
|
|
291
|
+
sent.result.messagePath === "/fake/mb/gid/2026-fallback.msg",
|
|
292
|
+
);
|
|
283
293
|
ok("dead → mailbox helper called once", sent.trace.mailboxSends.length === 1);
|
|
284
294
|
ok("dead → mailbox reached only via resolver, release ×1", sent.trace.releases.length === 1);
|
|
285
295
|
|
|
@@ -289,6 +299,8 @@ async function main(): Promise<void> {
|
|
|
289
299
|
fallbackSend: { result: { success: false } },
|
|
290
300
|
});
|
|
291
301
|
ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
|
|
302
|
+
// No file was written, so there is nothing to name — never echo a dep's stray path.
|
|
303
|
+
ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
|
|
292
304
|
}
|
|
293
305
|
|
|
294
306
|
// ── 9: single-release across every outcome (release at most once) ─────────
|
|
@@ -361,20 +361,92 @@ async function main(): Promise<void> {
|
|
|
361
361
|
cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
|
|
362
362
|
);
|
|
363
363
|
|
|
364
|
+
// control-socket fallback-sent that re-resolved to the MAILBOX: same file, same
|
|
365
|
+
// receipt. Before this the fallback leg was the one mailbox delivery whose sender
|
|
366
|
+
// line carried no per-message identifier.
|
|
367
|
+
const fallback: EntwurfV2RunResult = {
|
|
368
|
+
kind: "executed",
|
|
369
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
|
|
370
|
+
transport: "control-socket",
|
|
371
|
+
outcome: {
|
|
372
|
+
transport: "control-socket",
|
|
373
|
+
outcome: "fallback-sent",
|
|
374
|
+
messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T09-58-02-114Z-ab12cd.msg",
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const fb = renderEntwurfV2Result(fallback);
|
|
378
|
+
ok("2: fallback-sent is a delivery (not an error)", !fb.isError && fb.text.includes("fallback-sent"));
|
|
379
|
+
ok(
|
|
380
|
+
"2: fallback-sent names the enqueued FILE (#98 R, fallback leg)",
|
|
381
|
+
fb.text.includes("2026-09-03T09-58-02-114Z-ab12cd.msg"),
|
|
382
|
+
);
|
|
383
|
+
ok(
|
|
384
|
+
"2: fallback-sent prints the basename, not the whole path",
|
|
385
|
+
!fb.text.includes("/home/x/") && !fb.text.includes("meta-mailbox/20260903T134455-e55e87"),
|
|
386
|
+
);
|
|
387
|
+
ok(
|
|
388
|
+
"2: fallback-sent carries NO read stamp (same ban as the primary rail)",
|
|
389
|
+
!/lastReadAt|lastDeliveredAt/i.test(fb.text),
|
|
390
|
+
);
|
|
391
|
+
// A socket-to-socket retry writes no file. No receipt, and no "(enqueued undefined)".
|
|
392
|
+
const fallbackSocket: EntwurfV2RunResult = {
|
|
393
|
+
kind: "executed",
|
|
394
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
|
|
395
|
+
transport: "control-socket",
|
|
396
|
+
outcome: { transport: "control-socket", outcome: "fallback-sent" },
|
|
397
|
+
};
|
|
398
|
+
const fbs = renderEntwurfV2Result(fallbackSocket);
|
|
399
|
+
ok(
|
|
400
|
+
"2: socket-retry fallback-sent degrades to the bare outcome (no file to name)",
|
|
401
|
+
fbs.text === "entwurf_v2 control-socket → fallback-sent" && !fbs.isError,
|
|
402
|
+
);
|
|
403
|
+
|
|
364
404
|
// The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
|
|
365
405
|
// transport itself. Nothing renders them anymore, and `EntwurfV2Transport` no longer
|
|
366
406
|
// admits the literal — a cell kept here would only prove the type is still wrong.
|
|
367
407
|
|
|
368
|
-
// meta-mailbox → enqueued
|
|
408
|
+
// meta-mailbox → enqueued, carrying the #98 R send receipt (the enqueued FILE).
|
|
369
409
|
const mailbox: EntwurfV2RunResult = {
|
|
410
|
+
kind: "executed",
|
|
411
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
|
|
412
|
+
transport: "meta-mailbox",
|
|
413
|
+
outcome: {
|
|
414
|
+
transport: "meta-mailbox",
|
|
415
|
+
success: true,
|
|
416
|
+
messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T07-19-31-803Z-113443.msg",
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
const mb = renderEntwurfV2Result(mailbox);
|
|
420
|
+
ok("2: meta-mailbox → not error + enqueued", !mb.isError && mb.text.includes("enqueued"));
|
|
421
|
+
ok(
|
|
422
|
+
"2: meta-mailbox names the enqueued message FILE (#98 R send receipt)",
|
|
423
|
+
mb.text.includes("2026-09-03T07-19-31-803Z-113443.msg"),
|
|
424
|
+
);
|
|
425
|
+
// The basename alone — printing the whole path would put the operator's home and the
|
|
426
|
+
// target garden id (which the caller just typed) into every send line.
|
|
427
|
+
ok(
|
|
428
|
+
"2: meta-mailbox prints the basename, not the whole path",
|
|
429
|
+
!mb.text.includes("/home/x/") && !mb.text.includes("meta-mailbox/20260903T134455-e55e87"),
|
|
430
|
+
);
|
|
431
|
+
// The assertion #98 asks for BY NAME: an enqueue receipt must never carry a read
|
|
432
|
+
// stamp. At enqueue time `lastReadAt` is the PREVIOUS message's read, so surfacing it
|
|
433
|
+
// would read as "my message was read" — the misreading that opened the issue.
|
|
434
|
+
ok(
|
|
435
|
+
"2: meta-mailbox carries NO read stamp (lastReadAt is forbidden on a send receipt)",
|
|
436
|
+
!/lastReadAt|lastDeliveredAt|\bread\b/i.test(mb.text),
|
|
437
|
+
);
|
|
438
|
+
// A dep that hands back no receipt must degrade to the old literal — never
|
|
439
|
+
// "enqueued (undefined)". The delivery happened either way.
|
|
440
|
+
const mailboxNoReceipt: EntwurfV2RunResult = {
|
|
370
441
|
kind: "executed",
|
|
371
442
|
receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
|
|
372
443
|
transport: "meta-mailbox",
|
|
373
444
|
outcome: { transport: "meta-mailbox", success: true },
|
|
374
445
|
};
|
|
446
|
+
const mbn = renderEntwurfV2Result(mailboxNoReceipt);
|
|
375
447
|
ok(
|
|
376
|
-
"2: meta-mailbox
|
|
377
|
-
!
|
|
448
|
+
"2: meta-mailbox with no receipt degrades to the bare literal (never 'undefined')",
|
|
449
|
+
!mbn.isError && mbn.text === "entwurf_v2 meta-mailbox → enqueued",
|
|
378
450
|
);
|
|
379
451
|
|
|
380
452
|
// native-push → delivered (no retry)
|
|
@@ -58,7 +58,20 @@ const PLUGIN_SRC = path.join(REPO_DIR, "pi", "meta-bridge", "entwurf-meta-receiv
|
|
|
58
58
|
const LAUNCHER = path.join(PLUGIN_SRC, "scripts", "hook-launch.sh");
|
|
59
59
|
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
|
60
60
|
|
|
61
|
-
type Leaf = {
|
|
61
|
+
type Leaf = {
|
|
62
|
+
type?: string;
|
|
63
|
+
command?: string;
|
|
64
|
+
args?: string[];
|
|
65
|
+
asyncRewake?: boolean;
|
|
66
|
+
timeout?: number;
|
|
67
|
+
rewakeSummary?: string;
|
|
68
|
+
rewakeMessage?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** #98 A/A″ length ceiling for the two rewake strings. The operator row is ONE line in a
|
|
72
|
+
* terminal status area; the model prefix is glued in front of the doorbell's stderr. There
|
|
73
|
+
* is no engine cap on the config path (see the lint block below), so this is ours. */
|
|
74
|
+
const REWAKE_MAX_LEN = 80;
|
|
62
75
|
const manifest = JSON.parse(readFileSync(path.join(PLUGIN_SRC, "hooks", "hooks.json"), "utf8")) as {
|
|
63
76
|
hooks: Record<string, Array<{ matcher?: string; hooks: Leaf[] }>>;
|
|
64
77
|
};
|
|
@@ -99,6 +112,62 @@ for (const event of OWNER_EVENTS) {
|
|
|
99
112
|
ok("FileChanged: timeout stays declared", typeof bell.timeout === "number" && bell.timeout > 0);
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
// ── 1b. #98 A/A″: the two rewake strings, and the lint the engine does NOT do ─
|
|
116
|
+
// WHY A LINT AT ALL. Measured on Claude 2.1.236/2.1.258/2.1.259 (#98 Phase 1): the
|
|
117
|
+
// stdout-JSON form of `rewakeSummary` is sanitised by the engine
|
|
118
|
+
// (`.trim().replace(/\s+/g," ").slice(0,cap)`), but the hooks.json CONFIG form is not —
|
|
119
|
+
// it reaches the operator's row verbatim, guarded only by the schema's `min(1)`. Two
|
|
120
|
+
// concrete failures that guard cannot see:
|
|
121
|
+
// - a value containing a newline breaks the one-row status area;
|
|
122
|
+
// - a whitespace-only value passes `min(1)` and draws a BLANK row, which is worse than
|
|
123
|
+
// the default `Stop hook feedback` because it looks like nothing arrived.
|
|
124
|
+
// (A third, `<summary>` missing entirely → the whole notification is hidden, is covered
|
|
125
|
+
// by asserting the field is present and non-empty at all.)
|
|
126
|
+
{
|
|
127
|
+
const bell = leafOf("FileChanged");
|
|
128
|
+
// A″ — the model-facing prefix. It REPLACES `Stop hook blocking error from command "…":`,
|
|
129
|
+
// so without it every arriving letter is named an error in the transcript.
|
|
130
|
+
ok(
|
|
131
|
+
"FileChanged: rewakeMessage is declared (model wakes to mail, not to a 'blocking error')",
|
|
132
|
+
typeof bell.rewakeMessage === "string",
|
|
133
|
+
);
|
|
134
|
+
// A — the operator-facing row. Static by nature: the renderer draws this ONE string, so
|
|
135
|
+
// it cannot carry the unread count (the statusline badge does that half).
|
|
136
|
+
ok(
|
|
137
|
+
"FileChanged: rewakeSummary is declared (operator row is not 'Stop hook feedback')",
|
|
138
|
+
typeof bell.rewakeSummary === "string",
|
|
139
|
+
);
|
|
140
|
+
for (const field of ["rewakeSummary", "rewakeMessage"] as const) {
|
|
141
|
+
const value = bell[field];
|
|
142
|
+
ok(
|
|
143
|
+
`FileChanged: ${field} is a single line (a newline would break the one-row status area)`,
|
|
144
|
+
typeof value === "string" && !/[\r\n]/.test(value),
|
|
145
|
+
);
|
|
146
|
+
ok(
|
|
147
|
+
`FileChanged: ${field} is not whitespace-only (min(1) passes a blank row; we must not)`,
|
|
148
|
+
typeof value === "string" && value.trim().length > 0,
|
|
149
|
+
);
|
|
150
|
+
ok(
|
|
151
|
+
`FileChanged: ${field} is <= ${REWAKE_MAX_LEN} chars (unsanitised config path, no engine cap)`,
|
|
152
|
+
typeof value === "string" && value.length <= REWAKE_MAX_LEN,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Prefix hygiene (#98 (b)): the model body is `rewakeMessage + " " + stderr`, and the
|
|
156
|
+
// doorbell's stderr ALREADY opens with `[entwurf inbox]`. A bracketed prefix here would
|
|
157
|
+
// render `[entwurf …] [entwurf inbox] …` — one tag per line, and the stderr owns it.
|
|
158
|
+
ok(
|
|
159
|
+
"FileChanged: rewakeMessage carries no bracket tag (doorbell stderr already opens with [entwurf inbox])",
|
|
160
|
+
typeof bell.rewakeMessage === "string" && !bell.rewakeMessage.includes("["),
|
|
161
|
+
);
|
|
162
|
+
// The doorbell's own opening tag, read from the shipped script rather than assumed —
|
|
163
|
+
// if someone changes the stderr tag, the no-duplicate-prefix rule above must be re-read.
|
|
164
|
+
const doorbellSrc = readFileSync(path.join(PLUGIN_SRC, "scripts", "doorbell.sh"), "utf8");
|
|
165
|
+
ok(
|
|
166
|
+
"doorbell stderr still opens with the [entwurf inbox] tag the prefix rule assumes",
|
|
167
|
+
doorbellSrc.includes('echo "[entwurf inbox]'),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
102
171
|
// ── 2. no shell-form regression anywhere in the shipped manifest ─────────────
|
|
103
172
|
// The retired form is recognizable by its carrier; assert the whole file is free of
|
|
104
173
|
// it rather than only the leaves we happen to read above.
|
|
@@ -120,6 +120,12 @@ try {
|
|
|
120
120
|
now: new Date("2026-03-01T12:05:00.000Z"),
|
|
121
121
|
});
|
|
122
122
|
ok("stamp lastReadAt: state.json created on first stamp", fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)));
|
|
123
|
+
// The two `lastDeliveredAt === null` pins below are FIELD-ISOLATION pins, not a
|
|
124
|
+
// "waiting for the doorbell" state. #98 5a: no writer stamps lastDeliveredAt at all —
|
|
125
|
+
// the shipped doorbell.sh does not touch state.json — so this null is the field's
|
|
126
|
+
// permanent value. state.json holds garden-wide last-activity; the per-message receipts
|
|
127
|
+
// are the file suffixes (`.msg` → `.msg.delivered` → `.msg.delivered.read`). Removing
|
|
128
|
+
// the field is a migration over existing on-disk v1 states (#98 5b), not a comment fix.
|
|
123
129
|
ok(
|
|
124
130
|
"stamp lastReadAt: only lastReadAt set, others null (읽음이 남는다)",
|
|
125
131
|
afterRead.lastReadAt === "2026-03-01T12:05:00.000Z" &&
|
|
@@ -631,6 +631,79 @@ else
|
|
|
631
631
|
fi
|
|
632
632
|
unset FAKE_MCP_TAIL
|
|
633
633
|
|
|
634
|
+
# M14 — a MANAGED SETTINGS SCALAR drifted. Added with #94, which retired the two
|
|
635
|
+
# compaction keys and so deleted two of this doctor's drift cells. Until now the
|
|
636
|
+
# mutation set planted only hook/cache/receiver/MCP/delivery defects: not one cell
|
|
637
|
+
# asked whether the managed-scalar sweep still detects anything at all. That made
|
|
638
|
+
# "retiring these two costs no detection power" an unprovable claim — the gate
|
|
639
|
+
# could not have noticed if the whole sweep had gone silent. `verbose` stands in
|
|
640
|
+
# for the ten scalars that remain owned; it is the cheapest of them and carries no
|
|
641
|
+
# policy weight of its own.
|
|
642
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
643
|
+
import json, sys
|
|
644
|
+
p = sys.argv[1]
|
|
645
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
646
|
+
d["verbose"] = True
|
|
647
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
648
|
+
PY
|
|
649
|
+
expect_red "a managed settings scalar (verbose) drifted" "settings verbose missing/drifted at verbose"
|
|
650
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
651
|
+
import json, sys
|
|
652
|
+
p = sys.argv[1]
|
|
653
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
654
|
+
d["verbose"] = False
|
|
655
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
656
|
+
PY
|
|
657
|
+
|
|
658
|
+
# M15 (negative) — the same sweep must NOT claim a RETIRED key. Planted here is the
|
|
659
|
+
# EXACT state oracle sat in on 2026-09-02, the one that opened #94: Claude Code had
|
|
660
|
+
# put `autoCompactEnabled` back to true while entwurf's `env.DISABLE_AUTOCOMPACT`
|
|
661
|
+
# stayed "1". Under the old code that drift is what turned the doctor red; under #94
|
|
662
|
+
# both keys are the operator's and it must be green.
|
|
663
|
+
#
|
|
664
|
+
# Two things this cell will not do. It does not plant entwurf's own former desired
|
|
665
|
+
# value (`false`), which is green under BOTH codes and so could not tell "retired"
|
|
666
|
+
# apart from "still checked, message reworded". And it does not REMOVE the env key
|
|
667
|
+
# to make the state tidier: leaving "1" in place is the stronger claim, because a
|
|
668
|
+
# retired key must be ignored while still carrying the value we used to force, not
|
|
669
|
+
# merely once the operator has finished cleaning up after us.
|
|
670
|
+
cp "$CLAUDE_CONFIG_DIR/settings.json" "$TMP/settings-m15.bak"
|
|
671
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
672
|
+
import json, sys
|
|
673
|
+
p = sys.argv[1]
|
|
674
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
675
|
+
d["autoCompactEnabled"] = True
|
|
676
|
+
d.setdefault("env", {})["DISABLE_AUTOCOMPACT"] = "1"
|
|
677
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
678
|
+
PY
|
|
679
|
+
run_doctor
|
|
680
|
+
if [ "$DOC_RC" -eq 0 ]; then
|
|
681
|
+
ok "an operator who turned compaction back ON is not drift (retired keys leave the doctor green)"
|
|
682
|
+
else
|
|
683
|
+
bad "compaction re-enabled by the operator still reddens the doctor (the keys are not really retired):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ FAIL' | sed 's/^/ /')"
|
|
684
|
+
fi
|
|
685
|
+
cp "$TMP/settings-m15.bak" "$CLAUDE_CONFIG_DIR/settings.json"
|
|
686
|
+
|
|
687
|
+
# M16 — the ONE nudge a retired key still owes. `check()` refuses while the ledger
|
|
688
|
+
# still carries ownership entwurf has not yet relinquished, which is the state an
|
|
689
|
+
# upgraded host is in between `git pull` and `install-meta-bridge`. No other
|
|
690
|
+
# mutation reaches that branch, so without this cell the whole retirement path
|
|
691
|
+
# could stop refusing and every gate would stay green.
|
|
692
|
+
cp "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" "$TMP/state-m16.bak"
|
|
693
|
+
python3 - "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" <<'PY'
|
|
694
|
+
import json, sys
|
|
695
|
+
p = sys.argv[1]
|
|
696
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
697
|
+
d["files"]["settings"]["keys"]["autoCompactEnabled"] = {
|
|
698
|
+
"kind": "scalar",
|
|
699
|
+
"path": ["autoCompactEnabled"],
|
|
700
|
+
"original": {"existed": False, "value": None},
|
|
701
|
+
}
|
|
702
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
703
|
+
PY
|
|
704
|
+
expect_red "install-state still owns a retired scalar (upgrade not yet relinquished)" "install-state still owns retired scalar autoCompactEnabled"
|
|
705
|
+
cp "$TMP/state-m16.bak" "$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
|
|
706
|
+
|
|
634
707
|
# --- 5. the control must still hold after every restore ----------------------
|
|
635
708
|
echo "[control] re-run after all mutations were reverted"
|
|
636
709
|
run_doctor
|