@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.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- 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
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
* mailbox drain at all — enqueuing for it is the SE-1 false success
|
|
17
|
-
* into a void). So deliverable = wakeMode === "self-fetch" AND the
|
|
18
|
-
* active. This is the guard that the v1 fallback, MCP v1, pi-native v1,
|
|
19
|
-
* decider/send-fallback enqueue sites must all pass before writing a
|
|
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
|
|
60
|
-
* outright — it has no mailbox drain, so an enqueue would be a
|
|
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
|
+
}
|