@junghanacs/entwurf 0.12.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 +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-entwurf-v2-surface — deterministic gate for the 5d-3a surface adapter
|
|
3
|
+
* (`entwurf-v2-surface.ts`) + the `entwurf-control.ts` wiring contract. It proves the PURE
|
|
4
|
+
* parts (the rest — production assembly — is check-entwurf-v2-production + the 5d-5 matrix):
|
|
5
|
+
*
|
|
6
|
+
* 1. toDispatchInput — `wants_reply`→`wantsReply` (snake→camel), `intent`/`message`/`target`
|
|
7
|
+
* pass through, absent `mode`/`wants_reply` stay undefined (decider defaults, no double).
|
|
8
|
+
* 2. renderEntwurfV2Result — each result kind → the right `{ text, isError }`, surfacing the
|
|
9
|
+
* carry-overs: reject reason + target-locked diagnostic / control N3 rejectReason /
|
|
10
|
+
* spawn lock-retained diagnostic / N1 delivered-but-lock-dirty.
|
|
11
|
+
* 3. surface source guard — `entwurf-v2-surface.ts` is ctx-free (no ExtensionContext/API).
|
|
12
|
+
* 4. control wiring guard — `entwurf-control.ts` registers `entwurf_v2`, reaches the fence
|
|
13
|
+
* ONLY via a NON-LITERAL dynamic import (a string-const specifier), NEVER a static import
|
|
14
|
+
* of the fence v2 modules (which would break the emit-capable root tsc with TS5097), and
|
|
15
|
+
* decorates the sender as `origin:"pi-session"` with HONEST replyability (SE-1 2e-a:
|
|
16
|
+
* computeSelfAddressability + socket existsSync, not a hardcoded `replyable:true`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import type { EntwurfV2RunResult } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
24
|
+
import {
|
|
25
|
+
actionableRejectHint,
|
|
26
|
+
ENTWURF_PREFIX_ROOTS_ENV,
|
|
27
|
+
parseEntwurfPrefixRootsEnv,
|
|
28
|
+
renderEntwurfV2Result,
|
|
29
|
+
type SurfaceEntwurfV2Params,
|
|
30
|
+
toDispatchInput,
|
|
31
|
+
} from "../pi-extensions/lib/entwurf-v2-surface.ts";
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
function ok(label: string, cond: boolean): void {
|
|
35
|
+
assert.ok(cond, label);
|
|
36
|
+
console.log(` ok ${label}`);
|
|
37
|
+
passed++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const REPO = path.join(HERE, "..");
|
|
42
|
+
const SURFACE_SRC = path.join(REPO, "pi-extensions/lib/entwurf-v2-surface.ts");
|
|
43
|
+
const CONTROL_SRC = path.join(REPO, "pi-extensions/entwurf-control.ts");
|
|
44
|
+
const MCP_SRC = path.join(REPO, "mcp/entwurf-bridge/src/index.ts");
|
|
45
|
+
|
|
46
|
+
const GID = "20260613T100000-aaaaaa";
|
|
47
|
+
const SUCCESS_RECEIPT = {
|
|
48
|
+
ok: true as const,
|
|
49
|
+
action: "send" as const,
|
|
50
|
+
transport: "control-socket" as const,
|
|
51
|
+
ownership: "ack-only" as const,
|
|
52
|
+
observedLiveness: "alive" as const,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
async function main(): Promise<void> {
|
|
56
|
+
// ── 1: toDispatchInput mapping ────────────────────────────────────────────
|
|
57
|
+
{
|
|
58
|
+
const full: SurfaceEntwurfV2Params = {
|
|
59
|
+
target: GID,
|
|
60
|
+
intent: "owned-outcome",
|
|
61
|
+
message: "hi",
|
|
62
|
+
mode: "steer",
|
|
63
|
+
wants_reply: true,
|
|
64
|
+
};
|
|
65
|
+
const di = toDispatchInput(full);
|
|
66
|
+
ok(
|
|
67
|
+
"1: target/intent/message pass through",
|
|
68
|
+
di.target === GID && di.intent === "owned-outcome" && di.message === "hi",
|
|
69
|
+
);
|
|
70
|
+
ok("1: wants_reply → wantsReply (snake→camel)", di.wantsReply === true);
|
|
71
|
+
ok("1: mode passes through", di.mode === "steer");
|
|
72
|
+
|
|
73
|
+
const minimal: SurfaceEntwurfV2Params = { target: GID, intent: "fire-and-forget", message: "m" };
|
|
74
|
+
const dm = toDispatchInput(minimal);
|
|
75
|
+
ok("1: absent mode stays undefined (decider default)", dm.mode === undefined);
|
|
76
|
+
ok("1: absent wants_reply stays undefined (no double default)", dm.wantsReply === undefined);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 2: renderEntwurfV2Result per kind ─────────────────────────────────────
|
|
80
|
+
{
|
|
81
|
+
// reject + target-locked diagnostic
|
|
82
|
+
const rejected: EntwurfV2RunResult = {
|
|
83
|
+
kind: "rejected",
|
|
84
|
+
receipt: { ok: false, reason: "target-locked", observedLiveness: null },
|
|
85
|
+
diagnostic: {
|
|
86
|
+
kind: "target-locked",
|
|
87
|
+
conflict: {
|
|
88
|
+
reason: "target-locked",
|
|
89
|
+
lockPath: "/locks/x.lock",
|
|
90
|
+
holder: {
|
|
91
|
+
gardenId: GID,
|
|
92
|
+
pid: 999,
|
|
93
|
+
hostname: "h",
|
|
94
|
+
createdAt: "2026-06-13T01:00:00.000Z",
|
|
95
|
+
nonce: "n",
|
|
96
|
+
owner: "entwurf_v2",
|
|
97
|
+
lockPath: "/locks/x.lock",
|
|
98
|
+
},
|
|
99
|
+
detail: "held by pid 999",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const rr = renderEntwurfV2Result(rejected);
|
|
104
|
+
ok(
|
|
105
|
+
"2: reject → isError + reason + diagnostic surfaced",
|
|
106
|
+
rr.isError && rr.text.includes("target-locked") && rr.text.includes("pid 999"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// control sent → delivered
|
|
110
|
+
const sent: EntwurfV2RunResult = {
|
|
111
|
+
kind: "executed",
|
|
112
|
+
receipt: SUCCESS_RECEIPT,
|
|
113
|
+
transport: "control-socket",
|
|
114
|
+
outcome: { transport: "control-socket", outcome: "sent" },
|
|
115
|
+
};
|
|
116
|
+
ok(
|
|
117
|
+
"2: control sent → not error",
|
|
118
|
+
!renderEntwurfV2Result(sent).isError && renderEntwurfV2Result(sent).text.includes("sent"),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// control in-band rejected with N3 rejectReason → non-delivery
|
|
122
|
+
const ctlReject: EntwurfV2RunResult = {
|
|
123
|
+
kind: "executed",
|
|
124
|
+
receipt: SUCCESS_RECEIPT,
|
|
125
|
+
transport: "control-socket",
|
|
126
|
+
outcome: { transport: "control-socket", outcome: "rejected", rejectReason: "dormant-fire-forget-unsupported" },
|
|
127
|
+
};
|
|
128
|
+
const cr = renderEntwurfV2Result(ctlReject);
|
|
129
|
+
ok(
|
|
130
|
+
"2: control rejected → isError + N3 rejectReason surfaced",
|
|
131
|
+
cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// spawn lock-retained → fail-closed diagnostic
|
|
135
|
+
const retained: EntwurfV2RunResult = {
|
|
136
|
+
kind: "executed",
|
|
137
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
|
|
138
|
+
transport: "spawn-bg",
|
|
139
|
+
outcome: {
|
|
140
|
+
transport: "spawn-bg",
|
|
141
|
+
result: {
|
|
142
|
+
kind: "lock-retained",
|
|
143
|
+
released: false,
|
|
144
|
+
reason: "observe-failed",
|
|
145
|
+
diagnostic: {
|
|
146
|
+
targetGardenId: GID,
|
|
147
|
+
lockPath: "/locks/x.lock",
|
|
148
|
+
expectedSocketPath: "/ctl/x.sock",
|
|
149
|
+
observeTimeoutMs: 30000,
|
|
150
|
+
killGraceMs: 5000,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const ret = renderEntwurfV2Result(retained);
|
|
156
|
+
ok(
|
|
157
|
+
"2: spawn lock-retained → isError + diagnostic surfaced",
|
|
158
|
+
ret.isError && ret.text.includes("LOCK RETAINED") && ret.text.includes("/locks/x.lock"),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// spawn socket-alive → delivered
|
|
162
|
+
const alive: EntwurfV2RunResult = {
|
|
163
|
+
kind: "executed",
|
|
164
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
|
|
165
|
+
transport: "spawn-bg",
|
|
166
|
+
outcome: { transport: "spawn-bg", result: { kind: "socket-alive", released: true, pid: 7 } },
|
|
167
|
+
};
|
|
168
|
+
ok("2: spawn socket-alive → not error", !renderEntwurfV2Result(alive).isError);
|
|
169
|
+
|
|
170
|
+
// meta-mailbox → enqueued
|
|
171
|
+
const mailbox: EntwurfV2RunResult = {
|
|
172
|
+
kind: "executed",
|
|
173
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
|
|
174
|
+
transport: "meta-mailbox",
|
|
175
|
+
outcome: { transport: "meta-mailbox", success: true },
|
|
176
|
+
};
|
|
177
|
+
ok(
|
|
178
|
+
"2: meta-mailbox → not error + enqueued",
|
|
179
|
+
!renderEntwurfV2Result(mailbox).isError && renderEntwurfV2Result(mailbox).text.includes("enqueued"),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
|
|
183
|
+
const n1: EntwurfV2RunResult = {
|
|
184
|
+
kind: "execution-failed",
|
|
185
|
+
receipt: SUCCESS_RECEIPT,
|
|
186
|
+
transport: "control-socket",
|
|
187
|
+
error: "release boom",
|
|
188
|
+
finalizedOutcome: "sent",
|
|
189
|
+
releaseFailed: true,
|
|
190
|
+
retrySafe: false,
|
|
191
|
+
};
|
|
192
|
+
const n1r = renderEntwurfV2Result(n1);
|
|
193
|
+
ok(
|
|
194
|
+
"2: N1 → isError + 'do NOT retry' + DIRTY surfaced",
|
|
195
|
+
n1r.isError && n1r.text.includes("DIRTY") && n1r.text.includes("do NOT retry"),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// plain execution-failed → error
|
|
199
|
+
const failed: EntwurfV2RunResult = {
|
|
200
|
+
kind: "execution-failed",
|
|
201
|
+
receipt: SUCCESS_RECEIPT,
|
|
202
|
+
transport: "control-socket",
|
|
203
|
+
error: "boom",
|
|
204
|
+
retrySafe: false,
|
|
205
|
+
};
|
|
206
|
+
ok("2: plain execution-failed → isError", renderEntwurfV2Result(failed).isError);
|
|
207
|
+
|
|
208
|
+
// Detour B (B-a): backend-liveness-unsupported reject → still a reject (isError),
|
|
209
|
+
// but the text carries the actionable "use fire-and-forget → mailbox" hint. The
|
|
210
|
+
// reject stays honest (reason unchanged, no auto-convert) — only the render guides.
|
|
211
|
+
const metaReject: EntwurfV2RunResult = {
|
|
212
|
+
kind: "rejected",
|
|
213
|
+
receipt: { ok: false, reason: "backend-liveness-unsupported", observedLiveness: "unsupported" },
|
|
214
|
+
};
|
|
215
|
+
const mr = renderEntwurfV2Result(metaReject);
|
|
216
|
+
ok(
|
|
217
|
+
"2: backend-liveness-unsupported reject → isError + actionable fire-and-forget/mailbox hint",
|
|
218
|
+
mr.isError &&
|
|
219
|
+
mr.text.includes("backend-liveness-unsupported") &&
|
|
220
|
+
mr.text.includes("fire-and-forget") &&
|
|
221
|
+
mr.text.includes("mailbox"),
|
|
222
|
+
);
|
|
223
|
+
ok(
|
|
224
|
+
"2B: actionableRejectHint guides meta-session owned → fire-and-forget mailbox",
|
|
225
|
+
(actionableRejectHint("backend-liveness-unsupported") ?? "").includes("fire-and-forget"),
|
|
226
|
+
);
|
|
227
|
+
ok(
|
|
228
|
+
"2B: actionableRejectHint guides owned-live → fire-and-forget",
|
|
229
|
+
(actionableRejectHint("owned-live-no-autosend") ?? "").includes("fire-and-forget"),
|
|
230
|
+
);
|
|
231
|
+
ok(
|
|
232
|
+
"2B: actionableRejectHint returns undefined for a reject with no next step",
|
|
233
|
+
actionableRejectHint("bad-target") === undefined,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── 6: parseEntwurfPrefixRootsEnv (5d-4b operator-policy SSOT) ─────────────
|
|
238
|
+
{
|
|
239
|
+
const D = path.delimiter;
|
|
240
|
+
ok("6: env name is ENTWURF_PREFIX_ROOTS", ENTWURF_PREFIX_ROOTS_ENV === "ENTWURF_PREFIX_ROOTS");
|
|
241
|
+
ok("6: undefined → [] (no prefix promotion)", parseEntwurfPrefixRootsEnv(undefined).length === 0);
|
|
242
|
+
ok("6: empty string → []", parseEntwurfPrefixRootsEnv("").length === 0);
|
|
243
|
+
ok("6: delimiters-only → []", parseEntwurfPrefixRootsEnv(`${D}${D}`).length === 0);
|
|
244
|
+
const two = parseEntwurfPrefixRootsEnv(`/repos/gh${D}/repos/work`);
|
|
245
|
+
ok("6: delimiter-separated → entries", two.length === 2 && two[0] === "/repos/gh" && two[1] === "/repos/work");
|
|
246
|
+
const trimmed = parseEntwurfPrefixRootsEnv(` /a ${D} ${D} /b `);
|
|
247
|
+
ok("6: trims + drops empty segments", trimmed.length === 2 && trimmed[0] === "/a" && trimmed[1] === "/b");
|
|
248
|
+
// A nonexistent/typo path is KEPT verbatim (no throw, no validation) — preflight's
|
|
249
|
+
// normalize handles it, and a typo must never broaden approve nor fail the dispatch.
|
|
250
|
+
const typo = parseEntwurfPrefixRootsEnv("/this/does/not/exist");
|
|
251
|
+
ok("6: nonexistent path kept verbatim (no throw)", typo.length === 1 && typo[0] === "/this/does/not/exist");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 3: surface source guard — ctx-free ────────────────────────────────────
|
|
255
|
+
{
|
|
256
|
+
const src = await fs.readFile(SURFACE_SRC, "utf8");
|
|
257
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
258
|
+
ok("3: surface is ctx-free — no ExtensionContext", !code.includes("ExtensionContext"));
|
|
259
|
+
ok("3: surface is ctx-free — no ExtensionAPI", !code.includes("ExtensionAPI"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── 4: pi-native control wiring guard ─────────────────────────────────────
|
|
263
|
+
{
|
|
264
|
+
const src = await fs.readFile(CONTROL_SRC, "utf8");
|
|
265
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
266
|
+
ok("4: pi-native — entwurf-control registers entwurf_v2", /name:\s*"entwurf_v2"/.test(src));
|
|
267
|
+
ok(
|
|
268
|
+
"4: pi-native — reaches the fence via a NON-LITERAL dynamic import (string-const specifier)",
|
|
269
|
+
/const ENTWURF_V2_SURFACE_MODULE\s*=/.test(code) && /await import\(ENTWURF_V2_SURFACE_MODULE\)/.test(code),
|
|
270
|
+
);
|
|
271
|
+
// The whole point of the dynamic import: NO static import of the fence v2 chain into the
|
|
272
|
+
// emit-capable root program (those literal `.ts` imports would be TS5097).
|
|
273
|
+
ok(
|
|
274
|
+
"4: pi-native — NO static import of the v2 fence (runner/production/surface) — TS5097 stays closed",
|
|
275
|
+
!/import[^;]*from\s*"\.\/lib\/entwurf-v2-(runner|production|surface)\.(js|ts)"/.test(code),
|
|
276
|
+
);
|
|
277
|
+
// SE-1 2e-a: senderProvider decorates origin:'pi-session' but `replyable` is now an
|
|
278
|
+
// HONEST fact (canonical socket existsSync via computeSelfAddressability), NOT a
|
|
279
|
+
// hardcoded true. The self-address fence lib is reached through the same non-literal
|
|
280
|
+
// dynamic import pattern as the v2 surface.
|
|
281
|
+
ok(
|
|
282
|
+
"4: pi-native — senderProvider decorates origin:'pi-session' (no hardcoded replyable:true)",
|
|
283
|
+
/origin:\s*"pi-session"/.test(code) && !/replyable:\s*true/.test(code),
|
|
284
|
+
);
|
|
285
|
+
ok(
|
|
286
|
+
"4: pi-native — replyability via computeSelfAddressability + existsSync, dynamic-imported",
|
|
287
|
+
/const ENTWURF_SELF_ADDRESS_MODULE\s*=/.test(code) &&
|
|
288
|
+
/await import\(ENTWURF_SELF_ADDRESS_MODULE\)/.test(code) &&
|
|
289
|
+
/computeSelfAddressability/.test(code) &&
|
|
290
|
+
/existsSync\(/.test(code),
|
|
291
|
+
);
|
|
292
|
+
ok(
|
|
293
|
+
"4: pi-native — NO static import of the self-address fence (TS5097 stays closed)",
|
|
294
|
+
!/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
|
|
295
|
+
);
|
|
296
|
+
// Caller-intent steer (live-peer owned-outcome bug): the description must tell the model
|
|
297
|
+
// to use fire-and-forget for a LIVE/alive peer and that owned-outcome is dormant-only and
|
|
298
|
+
// NEVER auto-converted — the preventive fix (the decider must not auto-convert).
|
|
299
|
+
ok(
|
|
300
|
+
"4: pi-native — description steers live/alive peer → fire-and-forget",
|
|
301
|
+
/liveness=alive/.test(src) && /fire-and-forget/.test(src),
|
|
302
|
+
);
|
|
303
|
+
ok(
|
|
304
|
+
"4: pi-native — description says owned-outcome is dormant-only + never auto-converted",
|
|
305
|
+
/owned-outcome is ONLY for waking a DORMANT pi/.test(src) && /NEVER auto-converted/.test(src),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── 5: MCP bridge wiring guard ────────────────────────────────────────────
|
|
310
|
+
// The MCP bridge is a `.ts`-import fence consumer (mcp/tsconfig allowImportingTsExtensions),
|
|
311
|
+
// so it STATICALLY imports the surface adapter (no dynamic import). The v2 handler runs the
|
|
312
|
+
// production runner IN-PROCESS via runAndRenderEntwurfV2FromSurface — it does NOT route the
|
|
313
|
+
// v2 dispatch through legacy rpcCall/enqueueMetaMessage (those stay as the v2 runner's transport primitives).
|
|
314
|
+
{
|
|
315
|
+
const src = await fs.readFile(MCP_SRC, "utf8");
|
|
316
|
+
ok("5: MCP — registers entwurf_v2 server.tool", /server\.tool\(\s*"entwurf_v2"/.test(src));
|
|
317
|
+
ok(
|
|
318
|
+
"5: MCP — static imports runAndRenderEntwurfV2FromSurface from the surface fence module",
|
|
319
|
+
/import\s*\{[^}]*runAndRenderEntwurfV2FromSurface[^}]*\}\s*from\s*"[^"]*entwurf-v2-surface\.ts"/.test(src),
|
|
320
|
+
);
|
|
321
|
+
// Isolate the entwurf_v2 server.tool(...) block: from its name literal to the NEXT
|
|
322
|
+
// server.tool( registration. The v2 handler must build the sender + call the surface
|
|
323
|
+
// adapter, and must NOT itself reach for a legacy transport (the decider routes).
|
|
324
|
+
const v2Start = src.indexOf('"entwurf_v2"');
|
|
325
|
+
const after = src.indexOf("server.tool(", v2Start + 1);
|
|
326
|
+
const v2Block = src.slice(v2Start, after === -1 ? undefined : after);
|
|
327
|
+
ok("5: MCP — v2 handler builds buildSendSenderEnvelope()", /buildSendSenderEnvelope\(\)/.test(v2Block));
|
|
328
|
+
ok("5: MCP — v2 handler passes senderProvider: () => sender", /senderProvider:\s*\(\)\s*=>\s*sender/.test(v2Block));
|
|
329
|
+
ok(
|
|
330
|
+
"5: MCP — v2 handler calls runAndRenderEntwurfV2FromSurface",
|
|
331
|
+
/runAndRenderEntwurfV2FromSurface\(/.test(v2Block),
|
|
332
|
+
);
|
|
333
|
+
ok(
|
|
334
|
+
"5: MCP — v2 handler routes through the runner, NOT legacy rpcCall/enqueueMetaMessage",
|
|
335
|
+
!/\brpcCall\(/.test(v2Block) && !/\benqueueMetaMessage\(/.test(v2Block),
|
|
336
|
+
);
|
|
337
|
+
// Caller-intent steer — same preventive guidance on the MCP surface (a sibling reaching
|
|
338
|
+
// in over MCP reads this description, not the pi-native one).
|
|
339
|
+
ok(
|
|
340
|
+
"5: MCP — description steers live/alive peer → fire-and-forget",
|
|
341
|
+
/liveness=alive/.test(src) && /fire-and-forget/.test(src),
|
|
342
|
+
);
|
|
343
|
+
ok(
|
|
344
|
+
"5: MCP — description says owned-outcome is dormant-only + never auto-converted",
|
|
345
|
+
/owned-outcome is ONLY for waking a DORMANT pi/.test(src) && /NEVER auto-converted/.test(src),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log(`\ncheck-entwurf-v2-surface: ${passed} checks passed`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await main();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""check-keyset-overlap — guard the keyset-owner invariant across consumers.
|
|
3
|
+
|
|
4
|
+
entwurf's meta-bridge install OWNS a fixed set of `~/.claude/settings.json`
|
|
5
|
+
(+ `~/.claude.json`) keys (the SSOT is `meta-bridge-state.py managed-keys`).
|
|
6
|
+
agent-config (and any future consumer) merges its OWN fragment into the same
|
|
7
|
+
file. The invariant: each side sets only its own keys and never the other's —
|
|
8
|
+
otherwise a later `agent-config` merge (jq `.[0] * .[1]`, which REPLACES arrays
|
|
9
|
+
and overwrites scalars) silently clobbers pi-owned policy, or vice-versa.
|
|
10
|
+
|
|
11
|
+
This is the PREVENTIVE half of the guard (doctor's `state.py check` is the
|
|
12
|
+
after-the-fact survival half). It reads pi's owned key paths and one or more
|
|
13
|
+
consumer fragment JSON files, and fails loud if any consumer key collides with a
|
|
14
|
+
pi-owned key — exact match OR ancestor/descendant (a fragment that sets
|
|
15
|
+
`statusLine.command` collides with pi's whole-`statusLine` ownership).
|
|
16
|
+
|
|
17
|
+
Cross-repo and not hermetic (the fragment path is an argument), so it lives
|
|
18
|
+
OUTSIDE `pnpm check` / release-gate — it is for agent-config CI and manual
|
|
19
|
+
cross-checks. The synthetic-fixture regression of this script's own logic lives
|
|
20
|
+
in `smoke-meta-keyset-guard.sh`, which IS in `pnpm check`.
|
|
21
|
+
|
|
22
|
+
usage: check-keyset-overlap.py <consumer-fragment.json> [more.json ...]
|
|
23
|
+
exit 0 = disjoint (invariant holds); exit 1 = overlap (invariant violated);
|
|
24
|
+
exit 2 = usage / unreadable input.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
HERE = Path(__file__).resolve().parent
|
|
35
|
+
STATE = HERE / "meta-bridge-state.py"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def die(msg: str, code: int = 2) -> "None":
|
|
39
|
+
print(f"check-keyset-overlap: {msg}", file=sys.stderr)
|
|
40
|
+
raise SystemExit(code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pi_owned_paths() -> list[str]:
|
|
44
|
+
"""Flat dotted key paths entwurf owns, from the state-manager SSOT."""
|
|
45
|
+
try:
|
|
46
|
+
out = subprocess.run(
|
|
47
|
+
[sys.executable, str(STATE), "managed-keys"],
|
|
48
|
+
check=True,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
).stdout
|
|
52
|
+
except subprocess.CalledProcessError as exc: # pragma: no cover - defensive
|
|
53
|
+
die(f"could not read pi managed-keys SSOT: {exc.stderr.strip() or exc}")
|
|
54
|
+
data = json.loads(out)
|
|
55
|
+
paths: list[str] = []
|
|
56
|
+
for scope in ("settings", "claudeRoot"):
|
|
57
|
+
for _kind, keys in data.get(scope, {}).items():
|
|
58
|
+
paths.extend(keys)
|
|
59
|
+
return paths
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def flatten(obj: object, prefix: str = "") -> list[str]:
|
|
63
|
+
"""Leaf dotted paths of a JSON object. Arrays/scalars are leaves."""
|
|
64
|
+
if isinstance(obj, dict) and obj:
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for key, value in obj.items():
|
|
67
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
68
|
+
out.extend(flatten(value, path))
|
|
69
|
+
return out
|
|
70
|
+
return [prefix] if prefix else []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def collides(pi_path: str, frag_path: str) -> bool:
|
|
74
|
+
"""True if the two key paths refer to the same key or a nested ancestor."""
|
|
75
|
+
return (
|
|
76
|
+
pi_path == frag_path
|
|
77
|
+
or frag_path.startswith(pi_path + ".")
|
|
78
|
+
or pi_path.startswith(frag_path + ".")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> int:
|
|
83
|
+
if len(sys.argv) < 2:
|
|
84
|
+
die("usage: check-keyset-overlap.py <consumer-fragment.json> [more.json ...]")
|
|
85
|
+
|
|
86
|
+
pi_paths = pi_owned_paths()
|
|
87
|
+
overlaps: list[str] = []
|
|
88
|
+
for raw in sys.argv[1:]:
|
|
89
|
+
path = Path(raw)
|
|
90
|
+
if not path.exists():
|
|
91
|
+
die(f"fragment not found: {path}")
|
|
92
|
+
try:
|
|
93
|
+
fragment = json.loads(path.read_text())
|
|
94
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
95
|
+
die(f"unreadable fragment {path}: {exc}")
|
|
96
|
+
if not isinstance(fragment, dict):
|
|
97
|
+
die(f"fragment {path} root must be a JSON object")
|
|
98
|
+
frag_paths = flatten(fragment)
|
|
99
|
+
for frag in frag_paths:
|
|
100
|
+
for pi in pi_paths:
|
|
101
|
+
if collides(pi, frag):
|
|
102
|
+
overlaps.append(f"{path.name}: consumer key '{frag}' collides with pi-owned '{pi}'")
|
|
103
|
+
|
|
104
|
+
if overlaps:
|
|
105
|
+
print("KEYSET OVERLAP — keyset-owner invariant violated:", file=sys.stderr)
|
|
106
|
+
for line in overlaps:
|
|
107
|
+
print(f" FAIL: {line}", file=sys.stderr)
|
|
108
|
+
print(
|
|
109
|
+
" Fix: remove the colliding key(s) from the consumer fragment; "
|
|
110
|
+
"entwurf owns them (see ./run.sh meta-bridge-managed-keys).",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
print(f"keyset disjoint ok: {len(sys.argv) - 1} fragment(s) vs {len(pi_paths)} pi-owned key(s), no overlap")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-mailbox-receipt-state — deterministic gate for 0.11 Stage 0 step 3B:
|
|
3
|
+
* the mailbox receipt state schema + store. A real temp mailbox dir; no backend,
|
|
4
|
+
* no network, no hook. Safe in the `pnpm check` static floor.
|
|
5
|
+
*
|
|
6
|
+
* Proves the new receipt authority's home (`<mailbox>/<gardenId>/state.json`):
|
|
7
|
+
* pure schema round-trip + crash-on-malformed + strict keyset, then the fs store
|
|
8
|
+
* (stamp → persist → read-back) including the observable result GPT named for
|
|
9
|
+
* 3B — "read receipt가 record.delivery가 아니라 mailbox state로: stamp 후 state를
|
|
10
|
+
* 보면 lastReadAt가 남는다". ENTWURF_META_MAILBOX_DIR / explicit mailboxDir keep this
|
|
11
|
+
* off the operator's real ~/.pi/agent/meta-mailbox.
|
|
12
|
+
*
|
|
13
|
+
* Scope is 3B ONLY: schema + store. The live enqueue/read dual-write and the
|
|
14
|
+
* record.delivery removal land in step 3D (NEXT.md 끊을 지점 ②) — no live-path
|
|
15
|
+
* assertions here, and wakeMode/deliveryLevel (capability, step 3C) are out.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
emptyMailboxReceiptState,
|
|
24
|
+
type MailboxReceiptField,
|
|
25
|
+
MetaRecordError,
|
|
26
|
+
mailboxReceiptStatePath,
|
|
27
|
+
parseMailboxReceiptState,
|
|
28
|
+
readMailboxReceiptState,
|
|
29
|
+
serializeMailboxReceiptState,
|
|
30
|
+
stampMailboxReceipt,
|
|
31
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
function ok(label: string, cond: boolean): void {
|
|
35
|
+
assert.ok(cond, label);
|
|
36
|
+
console.log(` ok ${label}`);
|
|
37
|
+
passed++;
|
|
38
|
+
}
|
|
39
|
+
function golden(label: string, actual: unknown, expected: unknown): void {
|
|
40
|
+
assert.deepStrictEqual(actual, expected, label);
|
|
41
|
+
console.log(` ok ${label}`);
|
|
42
|
+
passed++;
|
|
43
|
+
}
|
|
44
|
+
function throws(label: string, fn: () => unknown): void {
|
|
45
|
+
assert.throws(fn, MetaRecordError, label);
|
|
46
|
+
console.log(` ok ${label}`);
|
|
47
|
+
passed++;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const GID = "20260301T120000-abc123";
|
|
51
|
+
const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-receipt-")));
|
|
52
|
+
const mailboxDir = path.join(tmpRoot, "meta-mailbox");
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// --- pure schema ---------------------------------------------------------
|
|
56
|
+
const empty = emptyMailboxReceiptState(GID);
|
|
57
|
+
golden("emptyMailboxReceiptState: schema v1, gardenId, all timestamps null", empty, {
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
gardenId: GID,
|
|
60
|
+
lastEnqueuedAt: null,
|
|
61
|
+
lastDeliveredAt: null,
|
|
62
|
+
lastReadAt: null,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const seeded = { ...empty, lastEnqueuedAt: "2026-03-01T12:00:00.000Z", lastReadAt: "2026-03-01T12:05:00.000Z" };
|
|
66
|
+
golden(
|
|
67
|
+
"serialize → parse round-trips deep-equal",
|
|
68
|
+
parseMailboxReceiptState(serializeMailboxReceiptState(seeded)),
|
|
69
|
+
seeded,
|
|
70
|
+
);
|
|
71
|
+
ok(
|
|
72
|
+
"serialize: stable key order, 2-space indent, trailing newline",
|
|
73
|
+
serializeMailboxReceiptState(empty) ===
|
|
74
|
+
`{\n "schemaVersion": 1,\n "gardenId": "${GID}",\n "lastEnqueuedAt": null,\n "lastDeliveredAt": null,\n "lastReadAt": null\n}\n`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// --- crash, don't warn ---------------------------------------------------
|
|
78
|
+
throws("parse: invalid JSON throws", () => parseMailboxReceiptState("{not json"));
|
|
79
|
+
throws("parse: array (non-object) throws", () => parseMailboxReceiptState("[]"));
|
|
80
|
+
throws("parse: wrong schemaVersion throws", () =>
|
|
81
|
+
parseMailboxReceiptState(JSON.stringify({ ...empty, schemaVersion: 2 })),
|
|
82
|
+
);
|
|
83
|
+
throws("parse: malformed gardenId throws", () =>
|
|
84
|
+
parseMailboxReceiptState(JSON.stringify({ ...empty, gardenId: "nope" })),
|
|
85
|
+
);
|
|
86
|
+
throws("parse: non-string/non-null timestamp throws", () =>
|
|
87
|
+
parseMailboxReceiptState(JSON.stringify({ ...empty, lastReadAt: 123 })),
|
|
88
|
+
);
|
|
89
|
+
// strict keyset — wakeMode is CAPABILITY (step 3C), never receipt state.
|
|
90
|
+
throws("parse: capability field `wakeMode` is rejected (not receipt state)", () =>
|
|
91
|
+
parseMailboxReceiptState(JSON.stringify({ ...empty, wakeMode: "self-fetch" })),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// --- path derivation -----------------------------------------------------
|
|
95
|
+
ok(
|
|
96
|
+
"mailboxReceiptStatePath = <mailbox>/<gardenId>/state.json",
|
|
97
|
+
mailboxReceiptStatePath(mailboxDir, GID) === path.join(mailboxDir, GID, "state.json"),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// --- fs store ------------------------------------------------------------
|
|
101
|
+
// absent → empty, and reading must NOT create the file (no side effect).
|
|
102
|
+
golden(
|
|
103
|
+
"readMailboxReceiptState: absent → empty state",
|
|
104
|
+
readMailboxReceiptState({ gardenId: GID, mailboxDir }),
|
|
105
|
+
empty,
|
|
106
|
+
);
|
|
107
|
+
ok(
|
|
108
|
+
"readMailboxReceiptState: absent read leaves no state.json on disk",
|
|
109
|
+
!fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// THE 3B observable: stamp lastReadAt → state.json exists, lastReadAt set.
|
|
113
|
+
const afterRead = stampMailboxReceipt({
|
|
114
|
+
gardenId: GID,
|
|
115
|
+
mailboxDir,
|
|
116
|
+
field: "lastReadAt",
|
|
117
|
+
now: new Date("2026-03-01T12:05:00.000Z"),
|
|
118
|
+
});
|
|
119
|
+
ok("stamp lastReadAt: state.json created on first stamp", fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)));
|
|
120
|
+
ok(
|
|
121
|
+
"stamp lastReadAt: only lastReadAt set, others null (읽음이 남는다)",
|
|
122
|
+
afterRead.lastReadAt === "2026-03-01T12:05:00.000Z" &&
|
|
123
|
+
afterRead.lastEnqueuedAt === null &&
|
|
124
|
+
afterRead.lastDeliveredAt === null,
|
|
125
|
+
);
|
|
126
|
+
golden(
|
|
127
|
+
"read-back equals the stamped state (persisted to disk)",
|
|
128
|
+
readMailboxReceiptState({ gardenId: GID, mailboxDir }),
|
|
129
|
+
afterRead,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// field isolation: a second stamp touches only its field, retains the first.
|
|
133
|
+
const afterEnqueue = stampMailboxReceipt({
|
|
134
|
+
gardenId: GID,
|
|
135
|
+
mailboxDir,
|
|
136
|
+
field: "lastEnqueuedAt",
|
|
137
|
+
now: new Date("2026-03-01T12:10:00.000Z"),
|
|
138
|
+
});
|
|
139
|
+
ok(
|
|
140
|
+
"stamp lastEnqueuedAt: retains lastReadAt, sets lastEnqueuedAt, lastDeliveredAt still null",
|
|
141
|
+
afterEnqueue.lastReadAt === "2026-03-01T12:05:00.000Z" &&
|
|
142
|
+
afterEnqueue.lastEnqueuedAt === "2026-03-01T12:10:00.000Z" &&
|
|
143
|
+
afterEnqueue.lastDeliveredAt === null,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// atomic write leaves no .tmp residue in the citizen dir.
|
|
147
|
+
const citizenDir = path.join(mailboxDir, GID);
|
|
148
|
+
ok("stamp is atomic: no .tmp residue left behind", !fs.readdirSync(citizenDir).some((f) => f.includes(".tmp-")));
|
|
149
|
+
|
|
150
|
+
// Blocker 1 — body/path gardenId drift is corruption (body is SSOT). Plant a
|
|
151
|
+
// state.json whose body claims a DIFFERENT citizen under GID's path; reading
|
|
152
|
+
// it back by GID must fail-fast, not silently return the wrong identity.
|
|
153
|
+
const OTHER = "20260301T120000-def456";
|
|
154
|
+
const driftFile = mailboxReceiptStatePath(mailboxDir, GID);
|
|
155
|
+
fs.writeFileSync(driftFile, serializeMailboxReceiptState(emptyMailboxReceiptState(OTHER)));
|
|
156
|
+
throws("readMailboxReceiptState: body/path gardenId drift is corruption", () =>
|
|
157
|
+
readMailboxReceiptState({ gardenId: GID, mailboxDir }),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Blocker 2 — stamp field runtime validation (the TS type does not survive a
|
|
161
|
+
// JS/cast call site). An unknown field must crash, not create a stray key.
|
|
162
|
+
throws("stampMailboxReceipt rejects an unknown receipt field", () =>
|
|
163
|
+
// cast bypasses the TS field type on purpose, to prove the runtime guard
|
|
164
|
+
stampMailboxReceipt({ gardenId: OTHER, mailboxDir, field: "lastBogusAt" as unknown as MailboxReceiptField }),
|
|
165
|
+
);
|
|
166
|
+
} finally {
|
|
167
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`[check-mailbox-receipt-state] ${passed} assertions ok`);
|