@junghanacs/entwurf 0.12.6 → 0.12.7

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.
Files changed (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -0,0 +1,105 @@
1
+ // ACP plugin — billing carrier (engraving) loader (S2d-1c).
2
+ //
3
+ // The engraving is the OPERATOR surface for the Claude `_meta.systemPrompt`
4
+ // carrier — short, personal additions an operator wants attached to every ACP
5
+ // session's system prompt. It is NOT the bridge-identity / AGENTS / pi-base
6
+ // narrative: that rich context rides the first-user-message augment (augment.ts)
7
+ // because the system-prompt carrier MUST stay tiny.
8
+ //
9
+ // Why a SMALL but NON-EMPTY default (the v1 memory-containment lever, restored):
10
+ // shipping a non-empty string here makes claude-agent-acp REPLACE its
11
+ // `claude_code` preset with this string (acp-agent.js: string-form
12
+ // `_meta.systemPrompt` → full preset replacement). That replacement strips the
13
+ // preset's auto-memory section, so the ACP model never learns it has a per-session
14
+ // memory store — the containment the operator baseline depends on. An EMPTY
15
+ // carrier keeps the preset and re-leaks auto-memory (the model writes memory/*.md
16
+ // via Write): that regression is exactly what a non-empty default fixes.
17
+ //
18
+ // Billing axis is SIZE, not SHAPE (NEXT §S2-scout 핀1, oracle A): Anthropic
19
+ // subscription billing (Claude Code OAuth, 정액제) reclassifies a call as metered
20
+ // "extra usage" — HTTP 400 for users with no metered balance — when the carrier
21
+ // materially GROWS past the SDK-default size (e.g. by injecting AGENTS.md or the
22
+ // pi base prompt). A tiny placeholder string is shape-deviant yet v1-production-
23
+ // safe, so the rule is keep the carrier SHORT, never "absent". Rich context still
24
+ // rides the first-user-message augment (augment.ts), never this carrier.
25
+ //
26
+ // Stability contract (NEXT oracle C / 핀1): the rendered output MUST be a pure
27
+ // function of (template content on disk, backend, mcpServerNames). No clock /
28
+ // random / env-time. `bridgeConfigSignature` folds this string into its
29
+ // `appendSystemPrompt` slot — if the rendered carrier drifted turn-to-turn, the
30
+ // signature would drift and entwurf would rebuild the ACP session every
31
+ // turn. The default-path source is cached once for exactly this reason, so a
32
+ // resident's carrier never drifts mid-session. The env-override path instead
33
+ // re-reads on EVERY call: editing that file mid-session INTENTIONALLY drifts the
34
+ // rendered carrier → bridgeConfigSignature changes → the live session is judged
35
+ // incompatible and the next turn opens a fresh ACP session with the new carrier.
36
+ // That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
37
+ // shipped default (which stays cached precisely so a resident never rebuilds).
38
+ import { readFileSync } from "node:fs";
39
+ import { dirname, join, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+ const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
43
+ let cached = null;
44
+ /** Point the loader at an alternate engraving file (A/B); bypasses the cache. */
45
+ function resolveEngravingPath() {
46
+ const envPath = process.env.ENTWURF_ACP_ENGRAVING_PATH?.trim();
47
+ return envPath ? resolve(envPath) : DEFAULT_ENGRAVING_PATH;
48
+ }
49
+ function loadSource(filePath) {
50
+ // Env-override path → always re-read (A/B experimentation). Default path →
51
+ // cache once so a mid-session operator edit cannot drift the carrier (and thus
52
+ // bridgeConfigSignature) between turns of a resident.
53
+ if (filePath !== DEFAULT_ENGRAVING_PATH) {
54
+ return readFileSync(filePath, "utf8");
55
+ }
56
+ if (!cached || cached.filePath !== filePath) {
57
+ cached = { filePath, content: readFileSync(filePath, "utf8") };
58
+ }
59
+ return cached.content;
60
+ }
61
+ function interpolate(template, params) {
62
+ // Sort so a caller-side ordering difference can never drift the rendered text
63
+ // (and therefore the config signature) — GPT c32a6c8 determinism guard.
64
+ const names = [...params.mcpServerNames].sort();
65
+ const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
66
+ return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
67
+ }
68
+ /**
69
+ * The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
70
+ * (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
71
+ * unreadable — that null is the operator opt-out. The SHIPPED default, by
72
+ * contrast, IS the auto-memory containment lever (its non-empty carrier replaces
73
+ * the claude_code preset, stripping the auto-memory advertisement) and MUST be
74
+ * present + non-empty: if the shipped default is missing/unpackaged/empty this
75
+ * THROWS (fail-loud, Detour C) rather than silently shipping with the carrier
76
+ * strip off. To opt the carrier out, point the env override at an empty file.
77
+ * Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
78
+ * entirely (passing "" as the `appendSystemPrompt` signature input) so
79
+ * subscription billing is never reclassified.
80
+ */
81
+ export function loadEngraving(params) {
82
+ const filePath = resolveEngravingPath();
83
+ const isShippedDefault = filePath === DEFAULT_ENGRAVING_PATH;
84
+ let source;
85
+ try {
86
+ source = loadSource(filePath);
87
+ }
88
+ catch (err) {
89
+ if (isShippedDefault) {
90
+ throw new Error(`entwurf: shipped engraving carrier unreadable at ${filePath} — it is the auto-memory ` +
91
+ `containment lever; refusing to proceed with containment silently degraded. (${err.message})`);
92
+ }
93
+ return null;
94
+ }
95
+ const rendered = interpolate(source, params).trim();
96
+ if (rendered.length === 0) {
97
+ if (isShippedDefault) {
98
+ throw new Error(`entwurf: shipped engraving carrier at ${filePath} is empty — it is the auto-memory ` +
99
+ `containment lever; refusing to proceed with the carrier strip silently off. ` +
100
+ `(opt out via an empty ENTWURF_ACP_ENGRAVING_PATH file instead)`);
101
+ }
102
+ return null;
103
+ }
104
+ return rendered;
105
+ }
@@ -0,0 +1,90 @@
1
+ // ACP plugin — curated Claude model surface (S0 loader/fence slice).
2
+ //
3
+ // entwurf is an ACP *plugin* on the v2 core, NOT a general-purpose
4
+ // Anthropic provider. It deliberately does not expose the full pi-ai model
5
+ // registry — the surface is curated to the Claude anchor the plugin commits to
6
+ // driving through a real ACP backend (the backend lands in S2; S0 only stands
7
+ // the provider/model surface up).
8
+ //
9
+ // Claude-first scope (NEXT §스코프): Codex is already a native garden citizen
10
+ // and Gemini / major tools use native, so the curated surface is Claude-only on
11
+ // this lane. A second governed backend would EXTEND this set — it does not change
12
+ // the pattern.
13
+ // pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
14
+ // `@earendil-works/pi-ai/compat` entrypoint (the global-API churn). We import it
15
+ // from `/compat` directly.
16
+ //
17
+ // Why `/compat` and NOT the 0.80 provider-factory subpath (the pi-ai
18
+ // providers/anthropic subpath): this file is loaded by pi's
19
+ // EXTENSION loader (pi-coding-agent `core/extensions/loader.ts`), whose jiti
20
+ // alias map resolves ONLY three pi-ai specifiers for extensions — the bare root,
21
+ // `/compat`, and `/oauth` — all to `ai/dist/compat.js`. A `providers/*` subpath
22
+ // is NOT in that map: jiti prefix-matches the bare `@earendil-works/pi-ai` alias
23
+ // and appends the remainder, yielding the unresolvable
24
+ // `…/dist/compat.js/providers/anthropic` (verified live: extension load crash,
25
+ // invisible to static typecheck which resolves against node_modules `exports`).
26
+ // So `/compat` is the SINGLE sanctioned extension entrypoint for the old global
27
+ // model-catalog API, and the SINGLE allowlisted exception in
28
+ // `run.sh check-pi-import-surface`. `getModels` here is compat's deprecated
29
+ // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
30
+ // the loader then exposes.
31
+ import { getModels } from "@earendil-works/pi-ai/compat";
32
+ /** Provider id — current pre-rename surface; S1 renames this load-bearing id to `entwurf`. */
33
+ export const PROVIDER_ID = "entwurf";
34
+ // #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
35
+ // provider defines custom models, but the ACP plugin consumes NO key: backend
36
+ // auth belongs to the operator's own Claude CLI child process (AGENTS
37
+ // §Operating boundaries — trust invariants). This lowercase+hyphen literal
38
+ // satisfies pi's auth-present check WITHOUT being read as an ENV reference. An
39
+ // ALL-CAPS value like "ANTHROPIC_API_KEY" would (a) trip pi's legacy-env
40
+ // deprecation and (b) falsely present the plugin as Anthropic-key dependent,
41
+ // failing preflight when the var is unset. Do NOT change to "$ANTHROPIC_API_KEY"
42
+ // — that silences the warning but keeps the wrong auth-boundary shape. The
43
+ // check-auth-boundary gate pins this.
44
+ export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
45
+ // The curated Claude ids. Adding one here is a commitment to verify it across
46
+ // both axes (protocol smoke + agent interview) — do not extend casually.
47
+ // Exported so the claude backend adapter (backend-adapter.ts) can answer
48
+ // `routeModel` without re-deriving the set from curatedClaudeModels().
49
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-4-8"];
50
+ /** The anchor model whose absence is a hard registry regression, not a soft skip. */
51
+ export const CURATED_ANCHOR_MODEL_ID = "claude-opus-4-8";
52
+ // Anthropic's registry reports 1M for both Sonnet 5 and Opus 4.8, and the
53
+ // entwurf surface now exposes the full 1M for BOTH. Sonnet 5's 1M window is the
54
+ // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
55
+ // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
56
+ // future registry value can't silently inflate the surface past what we verify.
57
+ const CLAUDE_CONTEXT_DEFAULT = 1_000_000;
58
+ // `getModels("anthropic")` reads the static builtin model catalog only — no env
59
+ // read, no credential access, no network — preserving the #26 auth-boundary
60
+ // invariant: the curated surface consumes no key.
61
+ const ANTHROPIC_MODELS_ALL = getModels("anthropic");
62
+ function requireRegistryModel(models, id) {
63
+ const model = models.find((m) => m.id === id);
64
+ // Crash, don't warn (AGENTS): a missing anchor is a genuine pi-ai metadata
65
+ // regression and must fail the curated surface up front rather than be
66
+ // papered over with a fabricated row.
67
+ if (!model)
68
+ throw new Error(`entwurf: required Claude model missing from pi-ai registry: ${id}`);
69
+ return model;
70
+ }
71
+ function claudeContextWindow(model) {
72
+ return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
73
+ }
74
+ /**
75
+ * The curated Claude model rows handed to `pi.registerProvider({ models })`.
76
+ * Fail-loud if the anchor is absent from the pi-ai registry.
77
+ */
78
+ export function curatedClaudeModels() {
79
+ const supported = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
80
+ requireRegistryModel(ANTHROPIC_MODELS_ALL, CURATED_ANCHOR_MODEL_ID);
81
+ return ANTHROPIC_MODELS_ALL.filter((m) => supported.has(m.id)).map((m) => ({
82
+ id: m.id,
83
+ name: m.name,
84
+ reasoning: m.reasoning,
85
+ input: m.input,
86
+ cost: m.cost,
87
+ contextWindow: claudeContextWindow(m),
88
+ maxTokens: m.maxTokens,
89
+ }));
90
+ }
@@ -0,0 +1,194 @@
1
+ // ACP plugin — Claude config overlay materializer (S2b).
2
+ //
3
+ // claude-agent-acp's SettingsManager loads the operator's `~/.claude/settings.json`
4
+ // DIRECTLY (CLAUDE_CONFIG_DIR is the only knob that redirects that read). So the
5
+ // operator's native `permissions.defaultMode` ("auto"), hooks, plugins, and
6
+ // per-cwd memory/projects state would otherwise leak into entwurf ACP
7
+ // sessions. The overlay redirects SettingsManager at a pi-owned directory whose
8
+ // `settings.json` WE author (minimal, `hooks:{}`), while keeping exactly the
9
+ // operator entries a backend needs (credentials, caches, built-in skills)
10
+ // reachable through a TIGHT symlink whitelist — nothing else.
11
+ //
12
+ // Scope (NEXT §스코프 / §S2-scout 핀3): Claude-only on this lane. Codex/Gemini
13
+ // overlays (CODEX_HOME / admin.toml) are 0.11.0 behavior-oracle territory and
14
+ // out of scope — the bridge backends are not in v2 yet.
15
+ //
16
+ // Two deliberate divergences from the literal 0.11.0 illustrative comment block
17
+ // (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
18
+ // as a symlink): `projects/` and `sessions/` are overlay-PRIVATE empty dirs, not
19
+ // symlinks, so the operator's real ~/.claude/{projects,sessions} is never read
20
+ // or written from an ACP session (this also closes the per-cwd MEMORY.md
21
+ // auto-load leak: the binary finds an empty tree and injects nothing).
22
+ //
23
+ // `hooks:{}` is load-bearing, not cosmetic: the Claude SDK distinguishes an
24
+ // ABSENT hooks key from a configured-but-empty map during organic compaction
25
+ // (the absent shape made a compacting turn emit a meta summary instead of
26
+ // answering — 0.11.0 LIVE probe). Keeping it `{}` inherits NO operator hook —
27
+ // which is exactly the "mailbox absence by design" the plugin commits to
28
+ // (no meta-bridge hook on this child's settings surface → no mailbox).
29
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { join } from "node:path";
32
+ /** Operator's real Claude config dir — the symlink-passthrough SOURCE. */
33
+ export const CLAUDE_REAL_CONFIG_DIR = join(homedir(), ".claude");
34
+ /** pi-owned overlay dir — the CLAUDE_CONFIG_DIR target for ACP child spawns. */
35
+ export const CLAUDE_CONFIG_OVERLAY_DIR = join(homedir(), ".pi", "agent", "claude-config-overlay");
36
+ /**
37
+ * Operator `~/.claude/` entries exposed to the ACP child via symlink. Anything
38
+ * NOT here is intentionally hidden: CLAUDE.md, hooks, agents, plugins,
39
+ * settings.local.json (personal env / PAT), sessions/projects data, command
40
+ * history, todos — none leak into the model context, hook surface, or env.
41
+ *
42
+ * Limited to: backend auth (`.credentials.json`); the binary's runtime caches +
43
+ * telemetry (cache, debug, session-env, stats-cache.json, statsig, telemetry);
44
+ * the bridge's own scratch surface (shell-snapshots); and built-in
45
+ * (non-operator-defined) skill content (skills). `plugins` is deliberately out —
46
+ * plugin enablement is operator-personal; the plugin set is injected per-session
47
+ * via `_meta.claudeCode.options.plugins`, not filesystem inheritance.
48
+ */
49
+ export const OVERLAY_PASSTHROUGH = new Set([
50
+ ".credentials.json",
51
+ "cache",
52
+ "debug",
53
+ "session-env",
54
+ "shell-snapshots",
55
+ "skills",
56
+ "stats-cache.json",
57
+ "statsig",
58
+ "telemetry",
59
+ ]);
60
+ /**
61
+ * Directories owned by the overlay itself (empty trees). The binary
62
+ * auto-creates and writes per-cwd state under these; an empty overlay-scoped
63
+ * tree keeps operator data at ~/.claude/{projects,sessions} unread/unwritten.
64
+ * Memory containment is NOT provided by this directory shape alone: if the
65
+ * backend preset advertises project memory, Claude can still write overlay-local
66
+ * projects/<cwd>/memory files. The engraving carrier's tiny non-empty preset
67
+ * replacement strips that advertisement; the empty tree is read-isolation and
68
+ * defense-in-depth, not the primary write-containment lever.
69
+ */
70
+ export const OVERLAY_EMPTY_DIRS = new Set(["projects", "sessions"]);
71
+ /**
72
+ * Entries the binary creates INSIDE whatever CLAUDE_CONFIG_DIR it is pointed at
73
+ * (feature cache, `.claude.json` backups). They have no operator-side
74
+ * counterpart, so the cleanup pass preserves real files/dirs here but tears down
75
+ * any STALE symlink (a migration artifact from earlier overlay code that linked
76
+ * every entry). `settings.json` is overlay-authored but listed for symmetry so
77
+ * the cleanup loop never nukes it.
78
+ */
79
+ export const OVERLAY_BINARY_OWNED = new Set([".claude.json", "backups", "settings.json"]);
80
+ /**
81
+ * Minimal overlay settings.json. Only fields with a reason to pin:
82
+ * - `permissions.defaultMode: "default"` neutralizes the operator's native
83
+ * "auto"; combined with the explicit `tools`/`permissionAllow` surface,
84
+ * "default" auto-passes every tool we expose without prompts.
85
+ * - `autoMemoryEnabled: false` — SDK opt-out for auto-memory (defense in
86
+ * depth; the tiny non-empty engraving/preset replacement is the primary
87
+ * write-containment lever for Claude ACP).
88
+ * - `hooks: {}` — configured-but-empty (NOT absent): inherits no operator
89
+ * hook (mailbox absence by design) while keeping the compaction turn honest.
90
+ */
91
+ export function overlaySettingsJson() {
92
+ return `${JSON.stringify({
93
+ permissions: { defaultMode: "default" },
94
+ autoMemoryEnabled: false,
95
+ hooks: {},
96
+ }, null, 2)}\n`;
97
+ }
98
+ /**
99
+ * The launch-env override an ACP child spawn must carry to redirect
100
+ * SettingsManager at the overlay. Pure — merge into the child's `env`.
101
+ */
102
+ export function claudeLaunchEnvDefaults(overlayDir = CLAUDE_CONFIG_OVERLAY_DIR) {
103
+ return { CLAUDE_CONFIG_DIR: overlayDir };
104
+ }
105
+ /**
106
+ * Materialize / refresh the Claude config overlay. Idempotent: keeps correct
107
+ * symlinks, replaces wrong ones, removes stale entries cleanly. Safe to call on
108
+ * every ACP session bootstrap.
109
+ */
110
+ export function ensureClaudeConfigOverlay(realDir = CLAUDE_REAL_CONFIG_DIR, overlayDir = CLAUDE_CONFIG_OVERLAY_DIR) {
111
+ mkdirSync(overlayDir, { recursive: true });
112
+ // settings.json — always (cheap unconditional rewrite keeps the override in
113
+ // place even if a prior process or operator edited it).
114
+ writeFileSync(join(overlayDir, "settings.json"), overlaySettingsJson(), "utf8");
115
+ // Empty dirs — overlay-owned; replace any prior symlink with a real dir.
116
+ for (const entry of OVERLAY_EMPTY_DIRS) {
117
+ const overlayPath = join(overlayDir, entry);
118
+ try {
119
+ const existing = lstatSync(overlayPath);
120
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
121
+ rmSync(overlayPath, { recursive: true, force: true });
122
+ mkdirSync(overlayPath, { recursive: true });
123
+ }
124
+ }
125
+ catch {
126
+ mkdirSync(overlayPath, { recursive: true });
127
+ }
128
+ }
129
+ // Symlink passthrough — only whitelisted entries that exist in realDir.
130
+ if (existsSync(realDir)) {
131
+ for (const entry of OVERLAY_PASSTHROUGH) {
132
+ const realPath = join(realDir, entry);
133
+ const overlayPath = join(overlayDir, entry);
134
+ if (!existsSync(realPath)) {
135
+ // Not present operator-side — remove any stale overlay copy.
136
+ try {
137
+ lstatSync(overlayPath);
138
+ rmSync(overlayPath, { recursive: true, force: true });
139
+ }
140
+ catch {
141
+ // Doesn't exist — fine.
142
+ }
143
+ continue;
144
+ }
145
+ try {
146
+ const existing = lstatSync(overlayPath);
147
+ if (existing.isSymbolicLink()) {
148
+ if (readlinkSync(overlayPath) === realPath)
149
+ continue;
150
+ unlinkSync(overlayPath);
151
+ }
152
+ else {
153
+ rmSync(overlayPath, { recursive: true, force: true });
154
+ }
155
+ }
156
+ catch {
157
+ // Doesn't exist — fall through to symlink.
158
+ }
159
+ try {
160
+ symlinkSync(realPath, overlayPath);
161
+ }
162
+ catch (error) {
163
+ console.error(`[entwurf:claude-overlay] symlink failed for ${entry}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+ }
167
+ // Stale cleanup — remove anything off the current allowlist. Binary-owned
168
+ // entries are preserved when real (binary authored them inside the overlay)
169
+ // but torn down when a stale symlink points at operator data.
170
+ for (const entry of readdirSync(overlayDir)) {
171
+ if (OVERLAY_PASSTHROUGH.has(entry))
172
+ continue;
173
+ if (OVERLAY_EMPTY_DIRS.has(entry))
174
+ continue;
175
+ const overlayPath = join(overlayDir, entry);
176
+ if (OVERLAY_BINARY_OWNED.has(entry)) {
177
+ try {
178
+ const stat = lstatSync(overlayPath);
179
+ if (stat.isSymbolicLink())
180
+ rmSync(overlayPath, { force: true });
181
+ }
182
+ catch {
183
+ // Doesn't exist — fine; binary creates it on first launch.
184
+ }
185
+ continue;
186
+ }
187
+ try {
188
+ rmSync(overlayPath, { recursive: true, force: true });
189
+ }
190
+ catch {
191
+ // Best-effort; a stuck stale entry is annoying but not fatal.
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,153 @@
1
+ // ACP plugin — Claude tool surface + exclude-tools truthfulness preflight (S2b).
2
+ //
3
+ // Two jobs:
4
+ // 1. The curated Claude tool surface the bridge declares to claude-agent-acp
5
+ // via `_meta.claudeCode.options` (tools / permission allow / disallowed),
6
+ // built by `buildClaudeSessionMeta`.
7
+ // 2. `assertExcludeToolsHonored` — a PREFLIGHT truthfulness check, NOT a wire
8
+ // read. There is no stable ACP surface that reports the backend's ACTUAL
9
+ // tool list (GPT S2b Q2), so the effective config WE supply is the SSOT:
10
+ // if pi has excluded a built-in the backend will still expose, the declared
11
+ // surface diverges from the actual one — we fail fast rather than lie.
12
+ //
13
+ // Claude-only scope (NEXT §스코프). The function keeps a `backend` field so the
14
+ // honest-divergence logic stays explicit and the matrix gate can exercise both
15
+ // the "claude narrows via tools" and "native always exposes" branches, but the
16
+ // only backend this lane supplies is claude.
17
+ //
18
+ // Carrier guard (NEXT §S2-scout 핀1): `buildClaudeSessionMeta` only attaches
19
+ // `_meta.systemPrompt` when a caller passes one. The shipped S2d path passes the
20
+ // tiny non-empty engraving carrier (v1 preset replacement / memory containment);
21
+ // an absent argument remains a true opt-out branch. Rich context is never carried
22
+ // here — it rides the S2d first-user-message augment.
23
+ /**
24
+ * pi baseline tools (Claude-capitalized) — mirrors what pi advertises as
25
+ * "Available tools:". Lowercase pi names map 1:1; keeping them aligned is the
26
+ * whole point of the truthfulness constraint.
27
+ */
28
+ export const DEFAULT_CLAUDE_TOOLS = ["Read", "Bash", "Edit", "Write"];
29
+ /** Permission allow list = the baseline surface + `mcp__*` (bridge MCP auto-allow). */
30
+ export const DEFAULT_CLAUDE_PERMISSION_ALLOW = [
31
+ "Read(*)",
32
+ "Bash(*)",
33
+ "Edit(*)",
34
+ "Write(*)",
35
+ "mcp__*",
36
+ ];
37
+ /**
38
+ * Deferred/extra tools the SDK advertises (via the ToolSearch system-reminder)
39
+ * that pi does NOT advertise in its fixed baseline — disallow them so the
40
+ * declared-vs-actual surfaces match. pi's own equivalents cover each
41
+ * (Cron→/schedule, Web→brave-search/summarize, Task+RemoteTrigger→entwurf, …).
42
+ * When the SDK adds a deferred tool, this list must follow.
43
+ */
44
+ export const DEFAULT_CLAUDE_DISALLOWED_TOOLS = [
45
+ "AskUserQuestion",
46
+ "CronCreate",
47
+ "CronDelete",
48
+ "CronList",
49
+ "EnterPlanMode",
50
+ "EnterWorktree",
51
+ "ExitPlanMode",
52
+ "ExitWorktree",
53
+ "Monitor",
54
+ "NotebookEdit",
55
+ "PushNotification",
56
+ "RemoteTrigger",
57
+ "TaskCreate",
58
+ "TaskGet",
59
+ "TaskList",
60
+ "TaskOutput",
61
+ "TaskStop",
62
+ "TaskUpdate",
63
+ "WebFetch",
64
+ "WebSearch",
65
+ ];
66
+ /**
67
+ * pi built-in tool names (lowercase) that map 1:1 onto a backend capability the
68
+ * ACP child ALWAYS provides. Extension tools (entwurf_v2, entwurf_peers, …) are
69
+ * pi-side and never reach the backend — excluding THEM is honest, so they are
70
+ * deliberately not listed here.
71
+ */
72
+ export const PI_BUILTIN_BACKED_TOOLS = ["read", "bash", "edit", "write"];
73
+ /**
74
+ * Fail-fast on a tool-surface lie. If pi has excluded a built-in the backend
75
+ * will still expose, the declared surface diverges from the actual one — reject
76
+ * up front instead of telling the model a tool is gone while the backend can
77
+ * still run it. Pure function (no IO).
78
+ *
79
+ * - Claude: backend builtins = `resolved.tools` ∩ the pi-backed builtin set
80
+ * (lowercased). Drop `Read` from `tools` and excluding `read` becomes honest.
81
+ * - Other backends: assumed to always expose the full builtin set natively.
82
+ */
83
+ export function assertExcludeToolsHonored(activeToolNames, resolved) {
84
+ const active = new Set(activeToolNames);
85
+ const backendBuiltins = resolved.backend === "claude"
86
+ ? resolved.tools.map((t) => t.toLowerCase()).filter((t) => PI_BUILTIN_BACKED_TOOLS.includes(t))
87
+ : [...PI_BUILTIN_BACKED_TOOLS];
88
+ const unhonored = backendBuiltins.filter((t) => !active.has(t));
89
+ if (unhonored.length > 0) {
90
+ const many = unhonored.length > 1;
91
+ throw new Error(`entwurf cannot honor --exclude-tools (${unhonored.join(", ")}) on the ${resolved.backend} backend: ` +
92
+ `the backend CLI still exposes ${many ? "these capabilities" : "this capability"} natively, so excluding ` +
93
+ `${many ? "them" : "it"} from pi's surface would make the declared tool set diverge from what the backend can ` +
94
+ `actually do. Restrict ${many ? "them" : "it"} via the backend's own tool config instead` +
95
+ (resolved.backend === "claude" ? " (provider settings 'tools' / 'disallowedTools')" : "") +
96
+ ". Extension tools (entwurf_v2, entwurf_peers) can be excluded freely — they are pi-side and never reach the backend.");
97
+ }
98
+ }
99
+ /**
100
+ * Build the `_meta` object handed to `newSession` for a Claude ACP session.
101
+ *
102
+ * `normalizedSystemPrompt` is OPTIONAL. The SHIPPED default supplies it (the
103
+ * non-empty v1 engraving): a string carrier makes claude-agent-acp REPLACE its
104
+ * `claude_code` preset, stripping auto-memory (see engraving.ts). When absent
105
+ * (operator opt-out — emptied engraving), the result carries NO `systemPrompt`
106
+ * key and claude-agent-acp keeps its default preset. Either way the carrier stays
107
+ * SHORT so it never grows past the SDK-default size (NEXT §S2-scout 핀1). Rich
108
+ * identity/context rides a first-user-message prepend, never this carrier.
109
+ */
110
+ export function buildClaudeSessionMeta(params, normalizedSystemPrompt) {
111
+ const claudeCodeOptions = {
112
+ ...(params.modelId ? { model: params.modelId } : {}),
113
+ tools: [...params.tools],
114
+ settingSources: [...params.settingSources],
115
+ settings: {
116
+ permissions: {
117
+ allow: [...params.permissionAllow],
118
+ },
119
+ // Auto-memory containment, defense-in-depth (Detour C). The overlay's
120
+ // settings.json also pins `autoMemoryEnabled:false`, but production runs the
121
+ // query in SDK filesystem-isolation mode (`settingSources: []`), so that
122
+ // on-disk copy is never loaded — claude-agent-acp forwards our options
123
+ // verbatim (acp-agent.js: `...userProvidedOptions`) and the SDK skips
124
+ // ~/.claude/settings.json. This INLINE settings layer is independent of
125
+ // `settingSources`, so it IS honored — the live seal. Backstop only: the
126
+ // primary write-containment lever is the non-empty engraving carrier
127
+ // replacing the claude_code preset (which strips the auto-memory
128
+ // advertisement the model would otherwise act on). "knows-but-can't" here vs
129
+ // the carrier's "doesn't-know" — keeping both means a future preset/SDK
130
+ // change cannot silently re-open memory through a channel the strip misses.
131
+ autoMemoryEnabled: false,
132
+ },
133
+ };
134
+ if (params.skillPlugins.length > 0) {
135
+ claudeCodeOptions.plugins = params.skillPlugins.map((path) => ({ type: "local", path }));
136
+ }
137
+ // Only emit when non-empty so `disallowedTools: []` opts fully out of the
138
+ // bridge's deferred-tool muting (the agent's own AskUserQuestion mute still
139
+ // applies — that is claude-agent-acp's call, not ours).
140
+ if (params.disallowedTools.length > 0) {
141
+ claudeCodeOptions.disallowedTools = [...params.disallowedTools];
142
+ }
143
+ if (params.strictMcpConfig) {
144
+ claudeCodeOptions.extraArgs = { "strict-mcp-config": null };
145
+ }
146
+ const meta = {
147
+ claudeCode: { options: claudeCodeOptions },
148
+ };
149
+ if (normalizedSystemPrompt) {
150
+ meta.systemPrompt = normalizedSystemPrompt;
151
+ }
152
+ return meta;
153
+ }
@@ -3,20 +3,34 @@
3
3
  * (SE-1/SE-2 slice 2c). "If I enqueue a conversational reply to this target's mailbox
4
4
  * right now, will a model actually see it — or will it rot as garbage?"
5
5
  *
6
- * Two layers, both pure (facts injected, no IO):
6
+ * Three predicates, all pure (facts injected, no IO) — but on TWO different delivery
7
+ * axes that must never be collapsed into one (보정①):
7
8
  *
8
9
  * - computeMetaReceiverActive(facts): the shared "is this receiver active?" atom —
9
10
  * recordBacked AND ownerAlive AND watchArmed. This is the SAME conjunction the
10
11
  * self-addressability predicate uses for its meta branch; both import it so the
11
12
  * "active receiver" definition has ONE source of truth (concept shared, API split).
13
+ * `watchArmed` is the MAILBOX-ONLY "idle-wake watch armed" signal (meta-session
14
+ * receiver marker) — it belongs to the mailbox axis and nothing else.
12
15
  *
13
16
  * - mailboxConversationalDeliverable(facts): the enqueue gate. A mailbox enqueue +
14
- * doorbell only delivers for a SELF-FETCH backend (Claude Code / Codex / agy): the
15
- * receiver drains its own inbox on wake. A DIRECT-INJECT backend (pi) has no
16
- * mailbox drain at all — enqueuing for it is the SE-1 false success ("✓ delivered"
17
- * into a void). So deliverable = wakeMode === "self-fetch" AND the receiver is
18
- * active. This is the guard that the v1 fallback, MCP v1, pi-native v1, and the v2
19
- * decider/send-fallback enqueue sites must all pass before writing a .msg (slice 2d).
17
+ * doorbell only delivers for a SELF-FETCH backend (Claude Code): the receiver
18
+ * drains its own inbox on wake. A DIRECT-INJECT backend (pi / codex / antigravity)
19
+ * has no mailbox drain at all — enqueuing for it is the SE-1 false success
20
+ * ("✓ delivered" into a void). So deliverable = wakeMode === "self-fetch" AND the
21
+ * receiver is active. This is the guard that the v1 fallback, MCP v1, pi-native v1,
22
+ * and the v2 decider/send-fallback enqueue sites must all pass before writing a
23
+ * .msg (slice 2d).
24
+ *
25
+ * - nativePushDeliverable(facts): the SEPARATE deliverability predicate for a
26
+ * NATIVE-PUSH backend (antigravity). A native-push citizen has no mailbox and no
27
+ * idle-wake watch — delivery is a direct injection into a LIVE app-server
28
+ * conversation the adapter probe located. So deliverable = recordBacked AND
29
+ * probeAlive. It MUST NOT reuse computeMetaReceiverActive: that atom folds in
30
+ * `watchArmed`, so composing it here would smuggle a mailbox liveness fact into a
31
+ * domain that has no mailbox (보정① — native-push replyable ≠ mailbox receiver).
32
+ * The two axes are pinned apart in code so a future replyable-sender path cannot
33
+ * quietly collapse them.
20
34
  *
21
35
  * The contract is "mailboxConversationalDeliverable", NOT a broad "deliverable": it is
22
36
  * specifically about a conversational reply that needs a live doorbell wake, NOT about
@@ -56,8 +70,10 @@ export function receiverMarkerMatchesIdentity(marker, identity) {
56
70
  }
57
71
  /**
58
72
  * The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
59
- * self-fetch AND the receiver is active. A direct-inject backend (pi) is refused
60
- * outright — it has no mailbox drain, so an enqueue would be a silent false success.
73
+ * self-fetch AND the receiver is active. A direct-inject backend (pi / codex /
74
+ * antigravity) is refused outright — it has no mailbox drain, so an enqueue would be a
75
+ * silent false success. A native-push backend's live delivery goes through
76
+ * nativePushDeliverable, not this gate.
61
77
  */
62
78
  export function mailboxConversationalDeliverable(facts) {
63
79
  if (facts.wakeMode !== "self-fetch") {
@@ -74,3 +90,20 @@ export function mailboxConversationalDeliverable(facts) {
74
90
  : `self-fetch receiver inactive — ${recv.reason}`,
75
91
  };
76
92
  }
93
+ /**
94
+ * The native-push deliverability predicate (봉인 6). deliverable ⟺ recordBacked ∧
95
+ * probeAlive. This DELIBERATELY does NOT reuse computeMetaReceiverActive — that atom
96
+ * requires `watchArmed`, the mailbox-only "idle-wake watch armed" signal, which is
97
+ * meaningless for a backend with no mailbox (보정①). Fail-closed: an undefined fact
98
+ * is treated as false, never optimistic; each failure names its own cause so a
99
+ * record-less target is never conflated with a probe that found no live conversation.
100
+ */
101
+ export function nativePushDeliverable(facts) {
102
+ if (facts.recordBacked !== true) {
103
+ return { deliverable: false, reason: "no backing meta-record" };
104
+ }
105
+ if (facts.probeAlive !== true) {
106
+ return { deliverable: false, reason: "adapter probe found no live native conversation" };
107
+ }
108
+ return { deliverable: true, reason: "record backed, native conversation probed alive" };
109
+ }