@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,154 @@
|
|
|
1
|
+
// Deterministic gate for the S2b Claude config overlay materializer.
|
|
2
|
+
//
|
|
3
|
+
// Drives ensureClaudeConfigOverlay against INJECTED temp realDir/overlayDir
|
|
4
|
+
// (no operator ~/.claude touched), then asserts the overlay shape the ACP
|
|
5
|
+
// child must see:
|
|
6
|
+
// - settings.json: permissions.defaultMode "default", autoMemoryEnabled
|
|
7
|
+
// false, hooks === {} (configured-but-empty → mailbox absence by design);
|
|
8
|
+
// - whitelisted operator entries symlinked to their real path;
|
|
9
|
+
// - projects/sessions are overlay-PRIVATE real dirs, NOT symlinks;
|
|
10
|
+
// - non-whitelist operator entries (CLAUDE.md, settings.local.json, plugins,
|
|
11
|
+
// agents) never appear in the overlay;
|
|
12
|
+
// - stale symlinks (a prior "linked everything" overlay) are cleaned up,
|
|
13
|
+
// including a binary-owned STALE SYMLINK (backups → operator data) torn down
|
|
14
|
+
// and a whitelist symlink whose real source is gone (telemetry) removed;
|
|
15
|
+
// - binary-owned real files (.claude.json) are preserved across re-runs;
|
|
16
|
+
// - claudeLaunchEnvDefaults plants CLAUDE_CONFIG_DIR = overlayDir;
|
|
17
|
+
// - idempotent (a second run yields the identical shape).
|
|
18
|
+
//
|
|
19
|
+
// Pure/deterministic — no live model, no network, OUT of nothing (IN pnpm check).
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from "node:assert";
|
|
22
|
+
import {
|
|
23
|
+
lstatSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
readlinkSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
symlinkSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import {
|
|
35
|
+
claudeLaunchEnvDefaults,
|
|
36
|
+
ensureClaudeConfigOverlay,
|
|
37
|
+
OVERLAY_BINARY_OWNED,
|
|
38
|
+
OVERLAY_EMPTY_DIRS,
|
|
39
|
+
OVERLAY_PASSTHROUGH,
|
|
40
|
+
} from "../pi-extensions/lib/acp/overlay.ts";
|
|
41
|
+
|
|
42
|
+
const root = mkdtempSync(join(tmpdir(), "entwurf-s2b-overlay-"));
|
|
43
|
+
const realDir = join(root, "real-claude");
|
|
44
|
+
const overlayDir = join(root, "overlay");
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// --- seed a fake operator ~/.claude ---
|
|
48
|
+
mkdirSync(realDir, { recursive: true });
|
|
49
|
+
// whitelisted (must be linked)
|
|
50
|
+
writeFileSync(join(realDir, ".credentials.json"), '{"fake":"creds"}\n', "utf8");
|
|
51
|
+
mkdirSync(join(realDir, "skills"), { recursive: true });
|
|
52
|
+
mkdirSync(join(realDir, "cache"), { recursive: true });
|
|
53
|
+
writeFileSync(join(realDir, "stats-cache.json"), "{}\n", "utf8");
|
|
54
|
+
// NON-whitelist operator personal config (must NOT appear in overlay)
|
|
55
|
+
writeFileSync(join(realDir, "CLAUDE.md"), "# personal\n", "utf8");
|
|
56
|
+
writeFileSync(join(realDir, "settings.local.json"), '{"env":{"SECRET":"x"}}\n', "utf8");
|
|
57
|
+
mkdirSync(join(realDir, "plugins"), { recursive: true });
|
|
58
|
+
mkdirSync(join(realDir, "agents"), { recursive: true });
|
|
59
|
+
// operator data the overlay must shadow with empty dirs
|
|
60
|
+
mkdirSync(join(realDir, "projects", "some-cwd"), { recursive: true });
|
|
61
|
+
mkdirSync(join(realDir, "sessions"), { recursive: true });
|
|
62
|
+
|
|
63
|
+
// --- pre-seed a STALE overlay from an earlier "linked everything" version ---
|
|
64
|
+
mkdirSync(overlayDir, { recursive: true });
|
|
65
|
+
symlinkSync(join(realDir, "plugins"), join(overlayDir, "plugins")); // stale non-whitelist symlink
|
|
66
|
+
symlinkSync(join(realDir, "projects"), join(overlayDir, "projects")); // stale projects symlink → must become empty dir
|
|
67
|
+
writeFileSync(join(overlayDir, ".claude.json"), '{"binary":"owned"}\n', "utf8"); // binary-owned real file → preserved
|
|
68
|
+
symlinkSync(join(realDir, "plugins"), join(overlayDir, "backups")); // binary-owned STALE SYMLINK → must be torn down (not preserved)
|
|
69
|
+
symlinkSync(join(realDir, "telemetry"), join(overlayDir, "telemetry")); // whitelist symlink whose real source is GONE → must be removed
|
|
70
|
+
|
|
71
|
+
// --- run ---
|
|
72
|
+
ensureClaudeConfigOverlay(realDir, overlayDir);
|
|
73
|
+
|
|
74
|
+
// settings.json
|
|
75
|
+
const settings = JSON.parse(readFileSync(join(overlayDir, "settings.json"), "utf8"));
|
|
76
|
+
assert.equal(settings.permissions?.defaultMode, "default", "settings.permissions.defaultMode must be 'default'");
|
|
77
|
+
assert.equal(settings.autoMemoryEnabled, false, "settings.autoMemoryEnabled must be false");
|
|
78
|
+
assert.ok(
|
|
79
|
+
settings.hooks && typeof settings.hooks === "object" && Object.keys(settings.hooks).length === 0,
|
|
80
|
+
"settings.hooks must be a configured-but-EMPTY object {} (mailbox absence by design)",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// whitelisted entries → symlinks to the real path
|
|
84
|
+
for (const entry of OVERLAY_PASSTHROUGH) {
|
|
85
|
+
const overlayPath = join(overlayDir, entry);
|
|
86
|
+
const realPath = join(realDir, entry);
|
|
87
|
+
if (entry === ".credentials.json" || entry === "skills" || entry === "cache" || entry === "stats-cache.json") {
|
|
88
|
+
const st = lstatSync(overlayPath);
|
|
89
|
+
assert.ok(st.isSymbolicLink(), `whitelist entry ${entry} must be a symlink`);
|
|
90
|
+
assert.equal(readlinkSync(overlayPath), realPath, `whitelist entry ${entry} must point at the real path`);
|
|
91
|
+
} else {
|
|
92
|
+
// not seeded in realDir → must be absent in overlay
|
|
93
|
+
assert.throws(() => lstatSync(overlayPath), `unseeded whitelist entry ${entry} must not exist in overlay`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// projects/sessions → real dirs, NOT symlinks
|
|
98
|
+
for (const entry of OVERLAY_EMPTY_DIRS) {
|
|
99
|
+
const st = lstatSync(join(overlayDir, entry));
|
|
100
|
+
assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${entry} must be an overlay-private real dir, not a symlink`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// non-whitelist operator config never leaks
|
|
104
|
+
for (const leak of ["CLAUDE.md", "settings.local.json", "plugins", "agents"]) {
|
|
105
|
+
assert.throws(() => lstatSync(join(overlayDir, leak)), `non-whitelist entry ${leak} must NOT appear in overlay`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// binary-owned real file preserved
|
|
109
|
+
assert.ok(OVERLAY_BINARY_OWNED.has(".claude.json"), "test premise: .claude.json is binary-owned");
|
|
110
|
+
assert.equal(
|
|
111
|
+
readFileSync(join(overlayDir, ".claude.json"), "utf8"),
|
|
112
|
+
'{"binary":"owned"}\n',
|
|
113
|
+
"binary-owned .claude.json must be preserved across overlay build",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// binary-owned STALE SYMLINK torn down — a binary-owned name is preserved only
|
|
117
|
+
// when it is a real binary-authored file/dir; a symlink pointing at operator
|
|
118
|
+
// data (migration residue) must be removed so the binary re-inits fresh.
|
|
119
|
+
assert.ok(OVERLAY_BINARY_OWNED.has("backups"), "test premise: backups is binary-owned");
|
|
120
|
+
assert.throws(
|
|
121
|
+
() => lstatSync(join(overlayDir, "backups")),
|
|
122
|
+
"binary-owned stale symlink (backups → operator data) must be torn down, not preserved",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// whitelist entry whose real source is GONE → stale overlay copy removed (the
|
|
126
|
+
// telemetry symlink was preseeded but realDir/telemetry was never created).
|
|
127
|
+
assert.throws(
|
|
128
|
+
() => lstatSync(join(overlayDir, "telemetry")),
|
|
129
|
+
"whitelist entry must be removed when its operator-side source no longer exists",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// launch env builder
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
claudeLaunchEnvDefaults(overlayDir),
|
|
135
|
+
{ CLAUDE_CONFIG_DIR: overlayDir },
|
|
136
|
+
"claudeLaunchEnvDefaults must plant CLAUDE_CONFIG_DIR = overlayDir",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// idempotency — a second run yields the identical critical shape
|
|
140
|
+
ensureClaudeConfigOverlay(realDir, overlayDir);
|
|
141
|
+
const credSt = lstatSync(join(overlayDir, ".credentials.json"));
|
|
142
|
+
assert.ok(credSt.isSymbolicLink(), "credentials symlink must survive a second run");
|
|
143
|
+
const projSt = lstatSync(join(overlayDir, "projects"));
|
|
144
|
+
assert.ok(projSt.isDirectory() && !projSt.isSymbolicLink(), "projects must stay a real dir on re-run");
|
|
145
|
+
assert.throws(() => lstatSync(join(overlayDir, "plugins")), "stale plugins symlink must stay gone on re-run");
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
`[check-acp-overlay] ok — overlay authors hooks:{} settings, symlinks ${OVERLAY_PASSTHROUGH.size}-entry whitelist, ` +
|
|
149
|
+
`shadows projects/sessions with private dirs, hides operator personal config, preserves binary-owned real files ` +
|
|
150
|
+
`while tearing down binary-owned/whitelist stale symlinks, plants CLAUDE_CONFIG_DIR, idempotent`,
|
|
151
|
+
);
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(root, { recursive: true, force: true });
|
|
154
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Deterministic gate for the S2d bootstrapPath-scoped ACP prompt builder (핀4).
|
|
2
|
+
//
|
|
3
|
+
// The S2c→S2d hazard: contextToAcpPrompt flattens the WHOLE transcript because
|
|
4
|
+
// spawn-per-turn sessions hold no memory. Once S2d adds session reuse, sending
|
|
5
|
+
// that whole transcript to a session that ALREADY remembers the prior turns
|
|
6
|
+
// duplicates history. So the prompt SCOPE must follow bootstrapPath:
|
|
7
|
+
// - "new" → full transcript (the only history carrier)
|
|
8
|
+
// - "reuse" | "resume" | "load"→ latest user delta only (first user after the
|
|
9
|
+
// last assistant; the trailing-group FIRST user
|
|
10
|
+
// so the SessionStart hook message is skipped).
|
|
11
|
+
//
|
|
12
|
+
// This gate proves the split deterministically — BEFORE the session store that
|
|
13
|
+
// produces the reuse paths exists, so the builder is correct the moment it is
|
|
14
|
+
// wired. Pure/deterministic — IN pnpm check.
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from "node:assert";
|
|
17
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
18
|
+
import {
|
|
19
|
+
type AcpBootstrapPath,
|
|
20
|
+
buildAcpPrompt,
|
|
21
|
+
contextToAcpPrompt,
|
|
22
|
+
latestUserDelta,
|
|
23
|
+
} from "../pi-extensions/lib/acp/context.ts";
|
|
24
|
+
|
|
25
|
+
const REUSE_PATHS: AcpBootstrapPath[] = ["reuse", "resume", "load"];
|
|
26
|
+
|
|
27
|
+
// A multi-turn context: a prior turn, an assistant answer, the real latest user
|
|
28
|
+
// turn (with an image), and a SessionStart hook user-message appended AFTER it.
|
|
29
|
+
function multiTurn(): Context {
|
|
30
|
+
return {
|
|
31
|
+
systemPrompt: "SECRET-SYSTEM-PROMPT",
|
|
32
|
+
tools: [{ name: "x", description: "d", parameters: {} as never }],
|
|
33
|
+
messages: [
|
|
34
|
+
{ role: "user", content: "first question", timestamp: 0 },
|
|
35
|
+
{
|
|
36
|
+
role: "assistant",
|
|
37
|
+
content: [{ type: "text", text: "an answer" }],
|
|
38
|
+
api: "x",
|
|
39
|
+
provider: "x",
|
|
40
|
+
model: "x",
|
|
41
|
+
usage: {
|
|
42
|
+
input: 0,
|
|
43
|
+
output: 0,
|
|
44
|
+
cacheRead: 0,
|
|
45
|
+
cacheWrite: 0,
|
|
46
|
+
totalTokens: 0,
|
|
47
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
48
|
+
},
|
|
49
|
+
stopReason: "stop",
|
|
50
|
+
timestamp: 0,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
role: "user",
|
|
54
|
+
content: [
|
|
55
|
+
{ type: "image", data: "BASE64IMAGEDATA", mimeType: "image/png" },
|
|
56
|
+
{ type: "text", text: "follow up" },
|
|
57
|
+
],
|
|
58
|
+
timestamp: 0,
|
|
59
|
+
},
|
|
60
|
+
// pi appends the SessionStart hook AFTER the real prompt — the builder
|
|
61
|
+
// must NOT pick this as the latest user delta.
|
|
62
|
+
{ role: "user", content: "device=thinkpad time_kst=20260618T160000", timestamp: 0 },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// 1) "new" → full transcript (identical to contextToAcpPrompt)
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
{
|
|
71
|
+
const ctx = multiTurn();
|
|
72
|
+
const prompt = buildAcpPrompt(ctx, "new");
|
|
73
|
+
assert.deepEqual(prompt, contextToAcpPrompt(ctx), '"new" is the full-transcript builder');
|
|
74
|
+
assert.equal(prompt.length, 1, '"new" is a single text block');
|
|
75
|
+
const text = prompt[0].text;
|
|
76
|
+
assert.match(text, /User: first question/, '"new" carries the prior user turn (history)');
|
|
77
|
+
assert.match(text, /Assistant: an answer/, '"new" carries the assistant turn');
|
|
78
|
+
assert.match(text, /follow up/, '"new" carries the latest user turn');
|
|
79
|
+
assert.ok(!text.includes("SECRET-SYSTEM-PROMPT"), '"new" never leaks systemPrompt');
|
|
80
|
+
assert.ok(!text.includes("BASE64IMAGEDATA"), '"new" never embeds raw image data');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// 2) reuse / resume / load → latest user delta ONLY
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
for (const path of REUSE_PATHS) {
|
|
87
|
+
const ctx = multiTurn();
|
|
88
|
+
const prompt = buildAcpPrompt(ctx, path);
|
|
89
|
+
assert.deepEqual(prompt, latestUserDelta(ctx), `"${path}" is the delta builder`);
|
|
90
|
+
assert.equal(prompt.length, 1, `"${path}" is a single text block`);
|
|
91
|
+
const text = prompt[0].text;
|
|
92
|
+
// Only the real latest user turn — not prior history, not the hook message.
|
|
93
|
+
assert.match(text, /follow up/, `"${path}" carries the latest user turn`);
|
|
94
|
+
assert.ok(!text.includes("first question"), `"${path}" excludes prior history (no duplicate injection)`);
|
|
95
|
+
assert.ok(!text.includes("an answer"), `"${path}" excludes the assistant turn`);
|
|
96
|
+
assert.ok(
|
|
97
|
+
!text.includes("device=thinkpad"),
|
|
98
|
+
`"${path}" skips the SessionStart hook user-message (trailing-group FIRST user, not last)`,
|
|
99
|
+
);
|
|
100
|
+
// Image still leaves a marker, never raw data.
|
|
101
|
+
assert.match(text, /\[image omitted: image\/png\]/, `"${path}" keeps the image marker`);
|
|
102
|
+
assert.ok(!text.includes("BASE64IMAGEDATA"), `"${path}" never embeds raw image data`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// 3) delta differs from full transcript (the whole point)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
{
|
|
109
|
+
const ctx = multiTurn();
|
|
110
|
+
assert.notDeepEqual(
|
|
111
|
+
buildAcpPrompt(ctx, "reuse"),
|
|
112
|
+
buildAcpPrompt(ctx, "new"),
|
|
113
|
+
"reuse delta must NOT equal the full-transcript new prompt",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 4) no assistant yet → first user of the whole context is the delta
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
{
|
|
121
|
+
const ctx: Context = {
|
|
122
|
+
messages: [
|
|
123
|
+
{ role: "user", content: "only question", timestamp: 0 },
|
|
124
|
+
{ role: "user", content: "device=thinkpad time_kst=x", timestamp: 0 },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
const delta = latestUserDelta(ctx);
|
|
128
|
+
assert.equal(delta.length, 1, "delta exists when there is a user turn");
|
|
129
|
+
assert.equal(delta[0].text, "only question", "first user wins (hook message skipped) even with no assistant");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 5) trailing message is assistant (no user after it) → empty delta
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
{
|
|
136
|
+
const ctx: Context = {
|
|
137
|
+
messages: [
|
|
138
|
+
{ role: "user", content: "q", timestamp: 0 },
|
|
139
|
+
{
|
|
140
|
+
role: "assistant",
|
|
141
|
+
content: [{ type: "text", text: "a" }],
|
|
142
|
+
api: "x",
|
|
143
|
+
provider: "x",
|
|
144
|
+
model: "x",
|
|
145
|
+
usage: {
|
|
146
|
+
input: 0,
|
|
147
|
+
output: 0,
|
|
148
|
+
cacheRead: 0,
|
|
149
|
+
cacheWrite: 0,
|
|
150
|
+
totalTokens: 0,
|
|
151
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
152
|
+
},
|
|
153
|
+
stopReason: "stop",
|
|
154
|
+
timestamp: 0,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
assert.deepEqual(latestUserDelta(ctx), [], "no user after the last assistant → empty delta");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// 6) empty context → empty for every path
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
{
|
|
165
|
+
const empty: Context = { messages: [] };
|
|
166
|
+
assert.deepEqual(buildAcpPrompt(empty, "new"), [], "empty context → empty new prompt");
|
|
167
|
+
for (const path of REUSE_PATHS) {
|
|
168
|
+
assert.deepEqual(buildAcpPrompt(empty, path), [], `empty context → empty ${path} prompt`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// 7) unknown bootstrapPath → THROW (fail-loud, never silent delta-only)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// 핀4 safety: a bad path must crash, not quietly fall through to delta-only —
|
|
176
|
+
// that would lose history on a path that should carry the full transcript.
|
|
177
|
+
{
|
|
178
|
+
const ctx = multiTurn();
|
|
179
|
+
assert.throws(
|
|
180
|
+
() => buildAcpPrompt(ctx, "bogus" as AcpBootstrapPath),
|
|
181
|
+
/unknown bootstrapPath/,
|
|
182
|
+
"unknown bootstrapPath must crash, not silently delta-only (history loss is the fail-open hazard)",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(
|
|
187
|
+
"[check-acp-prompt-builder] ok — bootstrapPath scopes the prompt: new=full transcript (history carrier), " +
|
|
188
|
+
"reuse/resume/load=latest user delta (first user after last assistant, SessionStart hook skipped, image marker " +
|
|
189
|
+
"kept, no raw image data, prior history excluded), no-assistant/trailing-assistant/empty edges, " +
|
|
190
|
+
"unknown bootstrapPath → throw (fail-loud, never silent delta-only)",
|
|
191
|
+
);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Deterministic gate for the ACP provider registration surface (S0 loader/fence,
|
|
2
|
+
// updated S2c when the real backend replaced the fail-loud stub).
|
|
3
|
+
//
|
|
4
|
+
// Three layers:
|
|
5
|
+
// (1 lib) loads the REAL lib modules and asserts the curated Claude
|
|
6
|
+
// surface + no-auth sentinel shape;
|
|
7
|
+
// (2 entry) COMPILES the project to a temp dir (root tsc emit, so the `.js`
|
|
8
|
+
// imports resolve to real emitted `.js`), imports the compiled
|
|
9
|
+
// acp-provider.js, and drives its default export against a fake pi
|
|
10
|
+
// that captures registerProvider — real execution capture of the
|
|
11
|
+
// actual entry, idempotency included, and that streamSimple is the
|
|
12
|
+
// real streamShellAcp backend (by name, NOT invoked — that spawns);
|
|
13
|
+
// (3 source) an auxiliary source-shape lock on acp-provider.ts.
|
|
14
|
+
//
|
|
15
|
+
// Layer 2 is the GPT-reviewed resolution to a fence tension: acp-provider.ts
|
|
16
|
+
// imports its lib with `.js` suffixes (the root/jiti runtime convention), which
|
|
17
|
+
// plain `node --experimental-strip-types` cannot resolve to `.ts`. Rather than
|
|
18
|
+
// force the entry onto a `.ts` strip-types fence (S0 avoids that) or collapse the
|
|
19
|
+
// lib/acp split, the gate emits the real build artifact and imports THAT — so the
|
|
20
|
+
// `.js` imports and the real default export both execute. Temp output lives under
|
|
21
|
+
// .tmp-verify/ (forbidden from the npm tarball) and is removed after the run.
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from "node:assert";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import {
|
|
29
|
+
CURATED_ANCHOR_MODEL_ID,
|
|
30
|
+
curatedClaudeModels,
|
|
31
|
+
ENTWURF_ACP_NO_AUTH_SENTINEL,
|
|
32
|
+
PROVIDER_ID,
|
|
33
|
+
} from "../pi-extensions/lib/acp/models.ts";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Layer 1 — lib-level surface (real modules, real behavior)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
// Current pre-rename provider id. S1 must rename this load-bearing routing id to `entwurf` together with its gates.
|
|
40
|
+
assert.equal(PROVIDER_ID, "entwurf", "PROVIDER_ID must match the current pre-rename provider id");
|
|
41
|
+
|
|
42
|
+
// no-auth sentinel shape: lowercase + hyphen only, so pi does not read it as an
|
|
43
|
+
// ENV reference. An ALL-CAPS value would trip the legacy-env path.
|
|
44
|
+
assert.match(
|
|
45
|
+
ENTWURF_ACP_NO_AUTH_SENTINEL,
|
|
46
|
+
/^[a-z0-9-]+$/,
|
|
47
|
+
`no-auth sentinel must be lowercase+hyphen (got "${ENTWURF_ACP_NO_AUTH_SENTINEL}")`,
|
|
48
|
+
);
|
|
49
|
+
assert.equal(ENTWURF_ACP_NO_AUTH_SENTINEL, "entwurf-no-auth", "no-auth sentinel literal drifted");
|
|
50
|
+
|
|
51
|
+
// curated Claude anchor present + full row shape.
|
|
52
|
+
const models = curatedClaudeModels();
|
|
53
|
+
assert.ok(models.length >= 1, "curated Claude surface must register at least one model");
|
|
54
|
+
const ids = models.map((m) => m.id);
|
|
55
|
+
assert.ok(
|
|
56
|
+
ids.includes(CURATED_ANCHOR_MODEL_ID),
|
|
57
|
+
`curated Claude anchor ${CURATED_ANCHOR_MODEL_ID} missing from surface: ${ids.join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
const REQUIRED_MODEL_FIELDS = ["id", "name", "reasoning", "input", "cost", "contextWindow", "maxTokens"] as const;
|
|
60
|
+
for (const m of models) {
|
|
61
|
+
for (const field of REQUIRED_MODEL_FIELDS) {
|
|
62
|
+
assert.ok(field in m, `model ${m.id} missing required ProviderModelConfig field: ${field}`);
|
|
63
|
+
}
|
|
64
|
+
assert.ok(m.contextWindow > 0, `model ${m.id} contextWindow must be positive`);
|
|
65
|
+
assert.ok(m.maxTokens > 0, `model ${m.id} maxTokens must be positive`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// S2c: the provider path is open — streamSimple is the REAL ACP backend, no
|
|
69
|
+
// longer the S0 fail-loud stub. The wiring is verified through the compiled
|
|
70
|
+
// entry (Layer 2) WITHOUT invoking it: streamShellAcp returns its stream
|
|
71
|
+
// synchronously and spawns the backend on a microtask, so calling it in a
|
|
72
|
+
// deterministic gate would launch a real child. Live behavior is proved by
|
|
73
|
+
// smoke-acp-provider-live (LIVE-gated).
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Layer 2 — real entry capture (compiled acp-provider.js driven by a fake pi)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
interface CapturedCfg {
|
|
80
|
+
baseUrl?: string;
|
|
81
|
+
apiKey?: string;
|
|
82
|
+
api?: string;
|
|
83
|
+
models?: Array<{ id: string }>;
|
|
84
|
+
streamSimple?: (...args: unknown[]) => unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TMP_EMIT = ".tmp-verify/acp-entry-capture";
|
|
88
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
89
|
+
try {
|
|
90
|
+
// Root tsc emit (no input files → uses the root tsconfig's program), so the
|
|
91
|
+
// entry's `.js` imports resolve to real emitted siblings.
|
|
92
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
93
|
+
stdio: "pipe",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const entryUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/acp-provider.js")).href;
|
|
97
|
+
const mod = await import(entryUrl);
|
|
98
|
+
|
|
99
|
+
const calls: Array<{ id: string; cfg: CapturedCfg }> = [];
|
|
100
|
+
const fakePi = {
|
|
101
|
+
registerProvider(id: string, cfg: CapturedCfg) {
|
|
102
|
+
calls.push({ id, cfg });
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Drive the REAL default export twice — the second call must be a no-op.
|
|
107
|
+
mod.default(fakePi);
|
|
108
|
+
mod.default(fakePi);
|
|
109
|
+
|
|
110
|
+
assert.equal(
|
|
111
|
+
calls.length,
|
|
112
|
+
1,
|
|
113
|
+
`entry must register exactly once across two calls (idempotency) — got ${calls.length}`,
|
|
114
|
+
);
|
|
115
|
+
const cap = calls[0];
|
|
116
|
+
assert.equal(cap.id, PROVIDER_ID, `entry registered the wrong provider id: ${cap.id}`);
|
|
117
|
+
assert.equal(cap.cfg.apiKey, ENTWURF_ACP_NO_AUTH_SENTINEL, "entry apiKey is not the no-auth sentinel");
|
|
118
|
+
assert.equal(cap.cfg.api, "entwurf", "entry api field drifted");
|
|
119
|
+
const capIds = (cap.cfg.models ?? []).map((m) => m.id);
|
|
120
|
+
for (const want of ["claude-sonnet-4-6", CURATED_ANCHOR_MODEL_ID]) {
|
|
121
|
+
assert.ok(capIds.includes(want), `entry model surface missing ${want} (got: ${capIds.join(", ") || "none"})`);
|
|
122
|
+
}
|
|
123
|
+
assert.equal(typeof cap.cfg.streamSimple, "function", "entry streamSimple must be a function");
|
|
124
|
+
// Regression guard: the entry must wire the REAL backend (streamShellAcp),
|
|
125
|
+
// not the removed S0 stub. We check by name rather than invoking — invoking
|
|
126
|
+
// would spawn a real ACP child. The compiled fn keeps its source name.
|
|
127
|
+
assert.equal(
|
|
128
|
+
cap.cfg.streamSimple?.name,
|
|
129
|
+
"streamShellAcp",
|
|
130
|
+
`entry must wire the real streamShellAcp backend (got "${cap.cfg.streamSimple?.name}")`,
|
|
131
|
+
);
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Layer 3 — auxiliary source-shape lock (pi-extensions/acp-provider.ts)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const entrySrc = readFileSync("pi-extensions/acp-provider.ts", "utf8");
|
|
141
|
+
assert.ok(
|
|
142
|
+
!/apiKey:\s*["'`]/.test(entrySrc),
|
|
143
|
+
"entry must not assign apiKey a string literal — use the no-auth sentinel constant",
|
|
144
|
+
);
|
|
145
|
+
const registerCalls = entrySrc.match(/\.registerProvider\(/g) ?? [];
|
|
146
|
+
assert.equal(
|
|
147
|
+
registerCalls.length,
|
|
148
|
+
1,
|
|
149
|
+
`entry must call registerProvider exactly once in source (found ${registerCalls.length})`,
|
|
150
|
+
);
|
|
151
|
+
// The entry must wire the real backend and must not reference the removed stub.
|
|
152
|
+
assert.match(entrySrc, /streamSimple:\s*streamShellAcp\b/, "entry must wire streamSimple: streamShellAcp");
|
|
153
|
+
assert.ok(!/backend-stub/.test(entrySrc), "entry must not import the removed S0 backend-stub");
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
`[check-acp-provider-surface] ok — compiled entry registers ${PROVIDER_ID} once (idempotent) with the no-auth ` +
|
|
157
|
+
`sentinel + curated Claude surface (sonnet + ${CURATED_ANCHOR_MODEL_ID}) + real streamShellAcp backend (S2c); ` +
|
|
158
|
+
`lib surface verified live (${models.length} model(s))`,
|
|
159
|
+
);
|