@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,387 @@
|
|
|
1
|
+
// Deterministic gate for the S2d-1b-1 session store / signature / bootstrap
|
|
2
|
+
// decision. Pure + temp-dir record I/O — IN pnpm check. No child, no spawn.
|
|
3
|
+
//
|
|
4
|
+
// Locks the GPT 73b44d 1b-1 invariants:
|
|
5
|
+
// ① model-lock is a fail-loud throw in the pure decision (live mismatch).
|
|
6
|
+
// ② prefix-compat: only a prefix history reuses; mismatch/edited/compaction → new.
|
|
7
|
+
// ③ carrier drift (appendSystemPrompt) → signature change → incompatible.
|
|
8
|
+
// ④ bootstrapPath ⟂ lifecyclePolicy: turn-scoped is ALWAYS new (no in-memory
|
|
9
|
+
// reuse, no persisted resume/load in the first cut).
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
17
|
+
|
|
18
|
+
const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
|
|
19
|
+
const isSha256Hex = (v: string) => /^[0-9a-f]{64}$/.test(v);
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
type BridgeConfigInput,
|
|
23
|
+
bridgeConfigSignature,
|
|
24
|
+
buildSessionRecord,
|
|
25
|
+
contextMessageSignatures,
|
|
26
|
+
decideBootstrap,
|
|
27
|
+
deleteSessionRecord,
|
|
28
|
+
ENTWURF_CONTROL_FLAG,
|
|
29
|
+
type ExistingSession,
|
|
30
|
+
hasPrefix,
|
|
31
|
+
isCompatible,
|
|
32
|
+
parseSessionRecord,
|
|
33
|
+
readSessionRecord,
|
|
34
|
+
resolveLifecyclePolicy,
|
|
35
|
+
SESSION_RECORD_PROVIDER,
|
|
36
|
+
SESSION_RECORD_VERSION,
|
|
37
|
+
type SessionCompatFacts,
|
|
38
|
+
SessionModelLockedError,
|
|
39
|
+
type SessionRecord,
|
|
40
|
+
sessionRecordPath,
|
|
41
|
+
writeSessionRecord,
|
|
42
|
+
} from "../pi-extensions/lib/acp/session-store.ts";
|
|
43
|
+
|
|
44
|
+
const baseInput = (): BridgeConfigInput => ({
|
|
45
|
+
backend: "claude",
|
|
46
|
+
modelId: "claude-sonnet-4-6",
|
|
47
|
+
nativeModelId: "claude-sonnet-4-6",
|
|
48
|
+
appendSystemPrompt: "",
|
|
49
|
+
mcpServersHash: "deadbeef",
|
|
50
|
+
settingSources: [],
|
|
51
|
+
strictMcpConfig: true,
|
|
52
|
+
tools: ["Read", "Bash", "Edit", "Write"],
|
|
53
|
+
skillPlugins: [],
|
|
54
|
+
permissionAllow: ["Read(*)", "Bash(*)", "Edit(*)", "Write(*)", "mcp__*"],
|
|
55
|
+
disallowedTools: [],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 1) signature — pure/stable, carrier drift changes it, sha256 digest (no raw)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
{
|
|
62
|
+
const sig0 = bridgeConfigSignature(baseInput());
|
|
63
|
+
assert.ok(isSha256Hex(sig0), "config signature is a sha256 digest (no raw carrier text on disk)");
|
|
64
|
+
assert.ok(!sig0.includes("claude-sonnet-4-6"), "config signature is a digest — never embeds the raw modelId/carrier");
|
|
65
|
+
assert.equal(sig0, bridgeConfigSignature(baseInput()), "signature is deterministic");
|
|
66
|
+
// Array copies do not affect equality (same content).
|
|
67
|
+
assert.equal(bridgeConfigSignature({ ...baseInput(), settingSources: [] }), sig0, "empty settingSources stable");
|
|
68
|
+
// S2g: mcpServers hash drift → different signature (not just a name change).
|
|
69
|
+
assert.notEqual(
|
|
70
|
+
bridgeConfigSignature({ ...baseInput(), mcpServersHash: "cafef00d" }),
|
|
71
|
+
sig0,
|
|
72
|
+
"mcpServers hash drift changes the signature",
|
|
73
|
+
);
|
|
74
|
+
// S2g: tool-surface / skillPlugins drift → different signature.
|
|
75
|
+
assert.notEqual(
|
|
76
|
+
bridgeConfigSignature({ ...baseInput(), skillPlugins: ["/abs/plugin"] }),
|
|
77
|
+
sig0,
|
|
78
|
+
"skillPlugins drift changes the signature",
|
|
79
|
+
);
|
|
80
|
+
// Carrier (appendSystemPrompt) drift → different signature → incompatible (③).
|
|
81
|
+
assert.notEqual(
|
|
82
|
+
bridgeConfigSignature({ ...baseInput(), appendSystemPrompt: "ENGRAVED" }),
|
|
83
|
+
bridgeConfigSignature(baseInput()),
|
|
84
|
+
"carrier drift changes the signature",
|
|
85
|
+
);
|
|
86
|
+
// Model drift → different signature.
|
|
87
|
+
assert.notEqual(
|
|
88
|
+
bridgeConfigSignature({ ...baseInput(), modelId: "claude-opus-4-8" }),
|
|
89
|
+
bridgeConfigSignature(baseInput()),
|
|
90
|
+
"model drift changes the signature",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// 2) contextMessageSignatures — sha256(role:content), no raw text, toolResult
|
|
96
|
+
// folds in toolName/isError (GPT c617cb hardening)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
{
|
|
99
|
+
const assistant = (text: string) =>
|
|
100
|
+
({
|
|
101
|
+
role: "assistant" as const,
|
|
102
|
+
content: [{ type: "text" as const, text }],
|
|
103
|
+
api: "x",
|
|
104
|
+
provider: "x",
|
|
105
|
+
model: "x",
|
|
106
|
+
usage: {
|
|
107
|
+
input: 0,
|
|
108
|
+
output: 0,
|
|
109
|
+
cacheRead: 0,
|
|
110
|
+
cacheWrite: 0,
|
|
111
|
+
totalTokens: 0,
|
|
112
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
113
|
+
},
|
|
114
|
+
stopReason: "stop" as const,
|
|
115
|
+
timestamp: 0,
|
|
116
|
+
}) satisfies Context["messages"][number];
|
|
117
|
+
const ctx: Context = {
|
|
118
|
+
messages: [
|
|
119
|
+
{ role: "user", content: "hello", timestamp: 0 },
|
|
120
|
+
assistant("hi"),
|
|
121
|
+
{ role: "user", content: [{ type: "image", data: "RAWIMAGEBYTES", mimeType: "image/png" }], timestamp: 0 },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
const sigs = contextMessageSignatures(ctx);
|
|
125
|
+
// every entry is a sha256 digest of the pre-hash role:content form.
|
|
126
|
+
assert.ok(sigs.every(isSha256Hex), "every message signature is a sha256 digest");
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
sigs,
|
|
129
|
+
[sha256("user:text:hello"), sha256("assistant:text:hi"), sha256("user:image:image/png")],
|
|
130
|
+
"per-message sha256(role:content) signatures",
|
|
131
|
+
);
|
|
132
|
+
// digest never embeds raw image data or raw user text.
|
|
133
|
+
assert.ok(
|
|
134
|
+
!sigs.some((s) => s.includes("RAWIMAGEBYTES") || s.includes("hello")),
|
|
135
|
+
"signatures are digests — no raw image data or prompt text on disk",
|
|
136
|
+
);
|
|
137
|
+
// deterministic across calls.
|
|
138
|
+
assert.deepEqual(contextMessageSignatures(ctx), sigs, "contextMessageSignatures is deterministic");
|
|
139
|
+
|
|
140
|
+
// toolResult folds in toolName + isError → same text, different tool/flag = different sig.
|
|
141
|
+
const trContent = [{ type: "text" as const, text: "out" }];
|
|
142
|
+
const trBase: Context = {
|
|
143
|
+
messages: [
|
|
144
|
+
{ role: "toolResult", toolCallId: "t1", toolName: "bash", isError: false, content: trContent, timestamp: 0 },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
const trErr: Context = {
|
|
148
|
+
messages: [
|
|
149
|
+
{ role: "toolResult", toolCallId: "t1", toolName: "bash", isError: true, content: trContent, timestamp: 0 },
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
const trOther: Context = {
|
|
153
|
+
messages: [
|
|
154
|
+
{ role: "toolResult", toolCallId: "t1", toolName: "edit", isError: false, content: trContent, timestamp: 0 },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
assert.notEqual(
|
|
158
|
+
contextMessageSignatures(trBase)[0],
|
|
159
|
+
contextMessageSignatures(trErr)[0],
|
|
160
|
+
"toolResult isError flips the signature",
|
|
161
|
+
);
|
|
162
|
+
assert.notEqual(
|
|
163
|
+
contextMessageSignatures(trBase)[0],
|
|
164
|
+
contextMessageSignatures(trOther)[0],
|
|
165
|
+
"toolResult toolName changes the signature",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// 3) hasPrefix / isCompatible
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
{
|
|
173
|
+
assert.ok(hasPrefix(["a", "b"], ["a", "b", "c"]), "shorter prefix matches");
|
|
174
|
+
assert.ok(hasPrefix(["a", "b"], ["a", "b"]), "equal is a prefix");
|
|
175
|
+
assert.ok(hasPrefix([], ["a"]), "empty is a prefix");
|
|
176
|
+
assert.ok(!hasPrefix(["a", "b", "c"], ["a", "b"]), "longer existing is NOT a prefix");
|
|
177
|
+
assert.ok(!hasPrefix(["a", "x"], ["a", "b", "c"]), "divergent history breaks the prefix");
|
|
178
|
+
|
|
179
|
+
const facts = (over: Partial<SessionCompatFacts> = {}): SessionCompatFacts => ({
|
|
180
|
+
cwd: "/w",
|
|
181
|
+
modelId: "claude-sonnet-4-6",
|
|
182
|
+
bridgeConfigSignature: bridgeConfigSignature(baseInput()),
|
|
183
|
+
contextMessageSignatures: ["user:text:a"],
|
|
184
|
+
...over,
|
|
185
|
+
});
|
|
186
|
+
const params = facts({ contextMessageSignatures: ["user:text:a", "assistant:text:b"] });
|
|
187
|
+
assert.ok(isCompatible(facts(), params), "prefix history + same cfg → compatible");
|
|
188
|
+
assert.ok(!isCompatible(facts({ cwd: "/other" }), params), "cwd drift → incompatible");
|
|
189
|
+
assert.ok(!isCompatible(facts({ modelId: "claude-opus-4-8" }), params), "model drift → incompatible");
|
|
190
|
+
assert.ok(!isCompatible(facts({ bridgeConfigSignature: "different" }), params), "signature drift → incompatible");
|
|
191
|
+
assert.ok(
|
|
192
|
+
!isCompatible(facts({ contextMessageSignatures: ["user:text:EDITED"] }), params),
|
|
193
|
+
"edited history → prefix break → incompatible",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// 4) decideBootstrap matrix
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
{
|
|
201
|
+
const sig = bridgeConfigSignature(baseInput());
|
|
202
|
+
const compatFacts: SessionCompatFacts = {
|
|
203
|
+
cwd: "/w",
|
|
204
|
+
modelId: "claude-sonnet-4-6",
|
|
205
|
+
bridgeConfigSignature: sig,
|
|
206
|
+
contextMessageSignatures: ["user:text:a"],
|
|
207
|
+
};
|
|
208
|
+
const params = (policy: "process-scoped" | "turn-scoped") => ({
|
|
209
|
+
...compatFacts,
|
|
210
|
+
contextMessageSignatures: ["user:text:a", "assistant:text:b", "user:text:c"],
|
|
211
|
+
lifecyclePolicy: policy,
|
|
212
|
+
});
|
|
213
|
+
const aliveCompat: ExistingSession = { ...compatFacts, alive: true };
|
|
214
|
+
const persistedCompat: SessionRecord = buildSessionRecord(
|
|
215
|
+
{ sessionKey: "k", acpSessionId: "acp-123", ...compatFacts },
|
|
216
|
+
"2026-06-18T00:00:00Z",
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// turn-scoped: ALWAYS new, even with a compatible existing AND persisted (④).
|
|
220
|
+
assert.deepEqual(
|
|
221
|
+
decideBootstrap(params("turn-scoped"), {
|
|
222
|
+
existing: aliveCompat,
|
|
223
|
+
persisted: persistedCompat,
|
|
224
|
+
capabilities: { resumeSession: true, loadSession: true },
|
|
225
|
+
}).path,
|
|
226
|
+
"new",
|
|
227
|
+
"turn-scoped one-shot is always new",
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// process-scoped + alive compatible existing → reuse.
|
|
231
|
+
assert.equal(
|
|
232
|
+
decideBootstrap(params("process-scoped"), { existing: aliveCompat }).path,
|
|
233
|
+
"reuse",
|
|
234
|
+
"compatible alive → reuse",
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// process-scoped + persisted compatible + resume capability → resume.
|
|
238
|
+
{
|
|
239
|
+
const d = decideBootstrap(params("process-scoped"), {
|
|
240
|
+
persisted: persistedCompat,
|
|
241
|
+
capabilities: { resumeSession: true, loadSession: false },
|
|
242
|
+
});
|
|
243
|
+
assert.equal(d.path, "resume", "persisted compatible + resume cap → resume");
|
|
244
|
+
assert.equal(d.acpSessionId, "acp-123", "resume carries the persisted acpSessionId");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// process-scoped + persisted compatible + only load capability → load.
|
|
248
|
+
assert.equal(
|
|
249
|
+
decideBootstrap(params("process-scoped"), {
|
|
250
|
+
persisted: persistedCompat,
|
|
251
|
+
capabilities: { resumeSession: false, loadSession: true },
|
|
252
|
+
}).path,
|
|
253
|
+
"load",
|
|
254
|
+
"persisted compatible + load cap → load",
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// process-scoped + persisted compatible + NO capability → new.
|
|
258
|
+
assert.equal(
|
|
259
|
+
decideBootstrap(params("process-scoped"), {
|
|
260
|
+
persisted: persistedCompat,
|
|
261
|
+
capabilities: { resumeSession: false, loadSession: false },
|
|
262
|
+
}).path,
|
|
263
|
+
"new",
|
|
264
|
+
"persisted compatible but no resume/load capability → new",
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// process-scoped + alive INCOMPATIBLE existing → new + invalidatePersisted,
|
|
268
|
+
// and the persisted record is NOT trusted even if itself compatible.
|
|
269
|
+
{
|
|
270
|
+
const incompatAlive: ExistingSession = {
|
|
271
|
+
...compatFacts,
|
|
272
|
+
bridgeConfigSignature: "drifted",
|
|
273
|
+
alive: true,
|
|
274
|
+
};
|
|
275
|
+
const d = decideBootstrap(params("process-scoped"), { existing: incompatAlive, persisted: persistedCompat });
|
|
276
|
+
assert.equal(d.path, "new", "incompatible existing → new");
|
|
277
|
+
assert.equal(d.invalidatePersisted, true, "incompatible existing also invalidates persisted");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// process-scoped + persisted incompatible (no existing) → new + invalidate.
|
|
281
|
+
{
|
|
282
|
+
const incompatPersisted: SessionRecord = { ...persistedCompat, bridgeConfigSignature: "drifted" };
|
|
283
|
+
const d = decideBootstrap(params("process-scoped"), { persisted: incompatPersisted });
|
|
284
|
+
assert.equal(d.path, "new", "incompatible persisted → new");
|
|
285
|
+
assert.equal(d.invalidatePersisted, true, "incompatible persisted is invalidated");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// process-scoped + nothing → new.
|
|
289
|
+
assert.equal(decideBootstrap(params("process-scoped"), {}).path, "new", "no source → new");
|
|
290
|
+
|
|
291
|
+
// model-lock: live existing with a different model → fail-loud throw (①).
|
|
292
|
+
assert.throws(
|
|
293
|
+
() =>
|
|
294
|
+
decideBootstrap(params("process-scoped"), {
|
|
295
|
+
existing: { ...aliveCompat, modelId: "claude-opus-4-8" },
|
|
296
|
+
}),
|
|
297
|
+
SessionModelLockedError,
|
|
298
|
+
"live model mismatch throws SessionModelLockedError",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 5) record build (pure) + parse validate + roundtrip in a temp dir
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
{
|
|
306
|
+
const facts: SessionCompatFacts = {
|
|
307
|
+
cwd: "/w",
|
|
308
|
+
modelId: "claude-sonnet-4-6",
|
|
309
|
+
bridgeConfigSignature: bridgeConfigSignature(baseInput()),
|
|
310
|
+
contextMessageSignatures: ["user:text:a"],
|
|
311
|
+
};
|
|
312
|
+
const rec = buildSessionRecord({ sessionKey: "sess/key 1", acpSessionId: "acp-9", ...facts }, "2026-06-18T01:02:03Z");
|
|
313
|
+
assert.equal(rec.version, SESSION_RECORD_VERSION);
|
|
314
|
+
assert.equal(rec.provider, SESSION_RECORD_PROVIDER);
|
|
315
|
+
assert.equal(rec.updatedAt, "2026-06-18T01:02:03Z", "updatedAt is the injected clock (pure build)");
|
|
316
|
+
|
|
317
|
+
const dir = mkdtempSync(join(tmpdir(), "acp-session-store-"));
|
|
318
|
+
try {
|
|
319
|
+
writeSessionRecord(rec, dir);
|
|
320
|
+
const read = readSessionRecord("sess/key 1", dir);
|
|
321
|
+
assert.deepEqual(read, rec, "write→read roundtrip preserves the record");
|
|
322
|
+
// sessionKey with a slash/space is encoded into a single safe filename.
|
|
323
|
+
assert.ok(sessionRecordPath("sess/key 1", dir).endsWith(".json"), "record path is a .json file");
|
|
324
|
+
assert.ok(!sessionRecordPath("sess/key 1", dir).includes("/key 1"), "sessionKey is encoded, not a path segment");
|
|
325
|
+
|
|
326
|
+
// Unknown key → undefined.
|
|
327
|
+
assert.equal(readSessionRecord("missing", dir), undefined, "missing record → undefined");
|
|
328
|
+
|
|
329
|
+
// Corrupt file → undefined AND deleted.
|
|
330
|
+
const corruptPath = sessionRecordPath("corrupt", dir);
|
|
331
|
+
writeFileSync(corruptPath, "{ not json");
|
|
332
|
+
assert.equal(readSessionRecord("corrupt", dir), undefined, "corrupt JSON → undefined");
|
|
333
|
+
assert.ok(!existsSync(corruptPath), "corrupt record is DELETED on read, not left on disk");
|
|
334
|
+
|
|
335
|
+
// Wrong version/provider/sessionKey → parse rejects.
|
|
336
|
+
assert.equal(parseSessionRecord({ ...rec, version: 999 }, rec.sessionKey), undefined, "wrong version rejected");
|
|
337
|
+
assert.equal(
|
|
338
|
+
parseSessionRecord({ ...rec, provider: "other" }, rec.sessionKey),
|
|
339
|
+
undefined,
|
|
340
|
+
"wrong provider rejected",
|
|
341
|
+
);
|
|
342
|
+
assert.equal(parseSessionRecord(rec, "different-key"), undefined, "sessionKey mismatch rejected");
|
|
343
|
+
assert.equal(
|
|
344
|
+
parseSessionRecord({ ...rec, acpSessionId: "" }, rec.sessionKey),
|
|
345
|
+
undefined,
|
|
346
|
+
"empty acpSessionId rejected",
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// delete removes the file.
|
|
350
|
+
deleteSessionRecord("sess/key 1", dir);
|
|
351
|
+
assert.equal(readSessionRecord("sess/key 1", dir), undefined, "deleted record → undefined");
|
|
352
|
+
// On-disk JSON is pretty + has a provider field (sanity that it really wrote).
|
|
353
|
+
writeSessionRecord(rec, dir);
|
|
354
|
+
assert.match(readFileSync(sessionRecordPath("sess/key 1", dir), "utf8"), /"provider": "entwurf"/, "json written");
|
|
355
|
+
} finally {
|
|
356
|
+
rmSync(dir, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// 6) resolveLifecyclePolicy — only exact --entwurf-control is process-scoped
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
{
|
|
364
|
+
// A resident may ALSO carry -p (fire a first prompt, then stay alive) — it
|
|
365
|
+
// must still be process-scoped. Keying on -p (candidate B) would kill it.
|
|
366
|
+
const residentWithP = resolveLifecyclePolicy(["pi", ENTWURF_CONTROL_FLAG, "-p", "hi"]);
|
|
367
|
+
assert.equal(residentWithP, "process-scoped", "resident with -p is still process-scoped (B trap)");
|
|
368
|
+
assert.equal(resolveLifecyclePolicy(["pi", ENTWURF_CONTROL_FLAG]), "process-scoped", "resident → process-scoped");
|
|
369
|
+
// pi -p one-shot and plain interactive are turn-scoped (S2c hang-safe).
|
|
370
|
+
assert.equal(resolveLifecyclePolicy(["pi", "-p", "hi"]), "turn-scoped", "pi -p one-shot → turn-scoped");
|
|
371
|
+
assert.equal(resolveLifecyclePolicy(["pi"]), "turn-scoped", "interactive → turn-scoped (conservative)");
|
|
372
|
+
// Exact token only — a substring/look-alike flag must NOT qualify.
|
|
373
|
+
assert.equal(
|
|
374
|
+
resolveLifecyclePolicy(["pi", "--not-entwurf-control"]),
|
|
375
|
+
"turn-scoped",
|
|
376
|
+
"look-alike flag is not the exact token → turn-scoped",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log(
|
|
381
|
+
"[check-acp-session-store] ok — signature (pure sha256 digest, carrier/model drift detected, no raw on disk), " +
|
|
382
|
+
"contextMessageSignatures (sha256(role:content), no raw text, toolResult folds toolName/isError), " +
|
|
383
|
+
"hasPrefix/isCompatible (prefix-only, cwd/model/sig/edited drift → incompatible), " +
|
|
384
|
+
"decideBootstrap matrix (turn-scoped→always new, process reuse/resume/load/new, incompatible→invalidate, " +
|
|
385
|
+
"live model mismatch→throw), record build (injected clock) + parse validate + temp-dir roundtrip, " +
|
|
386
|
+
"resolveLifecyclePolicy (exact --entwurf-control → process-scoped incl. with -p; -p/interactive/look-alike → turn-scoped)",
|
|
387
|
+
);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Deterministic gate for the S2b Claude tool surface + truthfulness preflight.
|
|
2
|
+
//
|
|
3
|
+
// Matrix over assertExcludeToolsHonored (the preflight truthfulness check — NOT
|
|
4
|
+
// a wire read) and a shape lock on buildClaudeSessionMeta, including the carrier
|
|
5
|
+
// absent-branch guard (no _meta.systemPrompt unless a caller passes one). The
|
|
6
|
+
// shipped path passes the non-empty v1 engraving from loadEngraving.
|
|
7
|
+
//
|
|
8
|
+
// Pure/deterministic — IN pnpm check.
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from "node:assert";
|
|
11
|
+
import {
|
|
12
|
+
assertExcludeToolsHonored,
|
|
13
|
+
buildClaudeSessionMeta,
|
|
14
|
+
DEFAULT_CLAUDE_DISALLOWED_TOOLS,
|
|
15
|
+
DEFAULT_CLAUDE_PERMISSION_ALLOW,
|
|
16
|
+
DEFAULT_CLAUDE_TOOLS,
|
|
17
|
+
PI_BUILTIN_BACKED_TOOLS,
|
|
18
|
+
} from "../pi-extensions/lib/acp/tool-surface.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// assertExcludeToolsHonored — matrix
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const claudeSurface = { backend: "claude" as const, tools: DEFAULT_CLAUDE_TOOLS };
|
|
25
|
+
|
|
26
|
+
// 1) full baseline active (+ an extension tool) → honored, no throw.
|
|
27
|
+
assert.doesNotThrow(
|
|
28
|
+
() => assertExcludeToolsHonored([...PI_BUILTIN_BACKED_TOOLS, "entwurf"], claudeSurface),
|
|
29
|
+
"full baseline + extension active must be honored",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// 2) pi excluded `read` while Claude still receives `Read` → divergence, throw.
|
|
33
|
+
let threw = false;
|
|
34
|
+
try {
|
|
35
|
+
assertExcludeToolsHonored(["bash", "edit", "write"], claudeSurface);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
threw = true;
|
|
38
|
+
assert.match(
|
|
39
|
+
String((err as Error).message),
|
|
40
|
+
/cannot honor --exclude-tools \(read\)/,
|
|
41
|
+
"message must name the unhonored tool",
|
|
42
|
+
);
|
|
43
|
+
assert.match(
|
|
44
|
+
String((err as Error).message),
|
|
45
|
+
/provider settings 'tools' \/ 'disallowedTools'/,
|
|
46
|
+
"message must point at the claude remedy",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
assert.ok(threw, "excluding a backend-exposed builtin (read) must throw");
|
|
50
|
+
|
|
51
|
+
// 3) excluded `read` AND removed `Read` from the backend tools → honest, no throw.
|
|
52
|
+
assert.doesNotThrow(
|
|
53
|
+
() => assertExcludeToolsHonored(["bash", "edit", "write"], { backend: "claude", tools: ["Bash", "Edit", "Write"] }),
|
|
54
|
+
"removing Read from the backend surface makes excluding `read` honest",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// 4) only an extension tool excluded (entwurf), all builtins active → honored.
|
|
58
|
+
assert.doesNotThrow(
|
|
59
|
+
() => assertExcludeToolsHonored([...PI_BUILTIN_BACKED_TOOLS], claudeSurface),
|
|
60
|
+
"excluding only extension tools never trips the guard",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// 5) multi-tool divergence → plural phrasing.
|
|
64
|
+
let multiThrew = false;
|
|
65
|
+
try {
|
|
66
|
+
assertExcludeToolsHonored(["edit", "write"], claudeSurface);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
multiThrew = true;
|
|
69
|
+
assert.match(String((err as Error).message), /\(read, bash\)/, "message must list all unhonored tools");
|
|
70
|
+
assert.match(String((err as Error).message), /these capabilities/, "plural phrasing for multiple tools");
|
|
71
|
+
}
|
|
72
|
+
assert.ok(multiThrew, "multiple excluded builtins must throw");
|
|
73
|
+
|
|
74
|
+
// 6) non-claude backend assumes native exposure of the full builtin set.
|
|
75
|
+
let nativeThrew = false;
|
|
76
|
+
try {
|
|
77
|
+
assertExcludeToolsHonored(["read", "bash", "edit"], { backend: "codex", tools: [] });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
nativeThrew = true;
|
|
80
|
+
assert.match(
|
|
81
|
+
String((err as Error).message),
|
|
82
|
+
/\(write\)/,
|
|
83
|
+
"native backend exposes the full builtin set regardless of tools",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
assert.ok(nativeThrew, "non-claude backend exposing write natively must throw when pi excludes it");
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// buildClaudeSessionMeta — shape lock
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const baseParams = {
|
|
93
|
+
modelId: "claude-sonnet-4-6",
|
|
94
|
+
tools: DEFAULT_CLAUDE_TOOLS,
|
|
95
|
+
permissionAllow: DEFAULT_CLAUDE_PERMISSION_ALLOW,
|
|
96
|
+
disallowedTools: DEFAULT_CLAUDE_DISALLOWED_TOOLS,
|
|
97
|
+
settingSources: [] as string[],
|
|
98
|
+
strictMcpConfig: false,
|
|
99
|
+
skillPlugins: [] as string[],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const meta = buildClaudeSessionMeta(baseParams);
|
|
103
|
+
const opts = (meta.claudeCode as { options: Record<string, unknown> }).options;
|
|
104
|
+
assert.equal(opts.model, "claude-sonnet-4-6", "model must propagate");
|
|
105
|
+
assert.deepEqual(opts.tools, [...DEFAULT_CLAUDE_TOOLS], "tools must propagate (copied)");
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
(opts.settings as { permissions: { allow: string[] } }).permissions.allow,
|
|
108
|
+
[...DEFAULT_CLAUDE_PERMISSION_ALLOW],
|
|
109
|
+
"permission allow must propagate",
|
|
110
|
+
);
|
|
111
|
+
// Auto-memory containment seal (Detour C). Assert the opt-out is IN THE META, not
|
|
112
|
+
// only in the overlay settings.json — production runs `settingSources: []` (SDK
|
|
113
|
+
// filesystem isolation), so the overlay copy never reaches the query; this inline
|
|
114
|
+
// settings layer is the one the query actually honors. `check-acp-overlay.ts`
|
|
115
|
+
// asserts the file copy; THIS asserts the live wire value.
|
|
116
|
+
assert.equal(
|
|
117
|
+
(opts.settings as { autoMemoryEnabled?: unknown }).autoMemoryEnabled,
|
|
118
|
+
false,
|
|
119
|
+
"auto-memory containment: _meta.claudeCode.options.settings.autoMemoryEnabled must be false (live seal, survives settingSources:[])",
|
|
120
|
+
);
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
opts.disallowedTools,
|
|
123
|
+
[...DEFAULT_CLAUDE_DISALLOWED_TOOLS],
|
|
124
|
+
"non-empty disallowedTools must propagate",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Carrier contract: NO systemPrompt key unless a caller supplies one (the absent
|
|
128
|
+
// opt-out branch; the shipped default DOES supply the non-empty v1 engraving lever).
|
|
129
|
+
assert.ok(!("systemPrompt" in meta), "no-carrier-arg branch must omit _meta.systemPrompt (absent → no key)");
|
|
130
|
+
|
|
131
|
+
// empty disallowedTools → field omitted entirely (full opt-out).
|
|
132
|
+
const metaNoDisallow = buildClaudeSessionMeta({ ...baseParams, disallowedTools: [] });
|
|
133
|
+
const optsNoDisallow = (metaNoDisallow.claudeCode as { options: Record<string, unknown> }).options;
|
|
134
|
+
assert.ok(!("disallowedTools" in optsNoDisallow), "empty disallowedTools must be omitted, not sent as []");
|
|
135
|
+
|
|
136
|
+
// strictMcpConfig → extraArgs flag; skillPlugins → local plugin entries.
|
|
137
|
+
const metaStrict = buildClaudeSessionMeta({
|
|
138
|
+
...baseParams,
|
|
139
|
+
strictMcpConfig: true,
|
|
140
|
+
skillPlugins: ["/abs/skill-a"],
|
|
141
|
+
});
|
|
142
|
+
const optsStrict = (metaStrict.claudeCode as { options: Record<string, unknown> }).options;
|
|
143
|
+
assert.deepEqual(optsStrict.extraArgs, { "strict-mcp-config": null }, "strictMcpConfig must emit the extraArgs flag");
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
optsStrict.plugins,
|
|
146
|
+
[{ type: "local", path: "/abs/skill-a" }],
|
|
147
|
+
"skillPlugins must map to local plugin entries",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// explicit carrier passes through ONLY when supplied (S2d path — exercised here
|
|
151
|
+
// for completeness, never used by S2b callers).
|
|
152
|
+
const metaCarrier = buildClaudeSessionMeta(baseParams, "CARRIER");
|
|
153
|
+
assert.equal(metaCarrier.systemPrompt, "CARRIER", "an explicit carrier must pass through when supplied");
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
"[check-acp-tool-surface] ok — exclude-tools truthfulness matrix (claude narrows / native always-exposes / extension-free) " +
|
|
157
|
+
"+ buildClaudeSessionMeta shape (tools/allow/disallowed/extraArgs/plugins) + carrier absent-branch guard " +
|
|
158
|
+
"+ auto-memory containment seal (settings.autoMemoryEnabled=false IN THE META, live under settingSources:[])",
|
|
159
|
+
);
|