@junghanacs/entwurf 0.12.10 → 0.13.1
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 +6 -3
- package/BASELINE.md +56 -163
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +117 -261
- package/README.md +74 -399
- package/VERIFY.md +59 -85
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +231 -0
- package/docs/external-mcp-host.md +132 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +123 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +21 -48
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +155 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- package/mcp/entwurf-bridge/src/index.ts +21 -48
- package/package.json +14 -9
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/entwurf-control.ts +20 -49
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +99 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +190 -14
- package/pi-extensions/lib/acp/backend.ts +310 -37
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +46 -2
- package/pi-extensions/lib/acp/event-mapper.ts +26 -16
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -5
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- package/prompts/engraving.md +13 -5
- package/run.sh +386 -119
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-sdk-surface.ts +60 -21
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-entwurf-v2-surface.ts +41 -0
- package/scripts/check-gate-qualification.ts +7 -2
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2482 -0
- package/scripts/check-release-gate-outcomes.ts +332 -0
- package/scripts/check-shell-quote.ts +4 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/probe-acp-turn.ts +228 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/probe-ordering.json +1054 -0
- package/scripts/mutants/release-gate.json +105 -0
- package/scripts/mutants/v2-surface.json +26 -15
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +386 -0
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +4 -3
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
- package/scripts/smoke-meta-install-state.sh +4 -0
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
// AssistantMessageEvent protocol (text/thinking blocks + tool/permission
|
|
5
5
|
// notices + usage), maintaining a running `partial: AssistantMessage`.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Single-dialect collapse (NEXT §스코프 + oracle F): the 0.11.0 event-mapper
|
|
8
8
|
// reconciled three backend dialects (Claude rawOutput=array / Codex
|
|
9
9
|
// CallToolResult / Gemini content[]) plus an entwurf_v2 sent-box custom promotion.
|
|
10
|
-
// This lane
|
|
11
|
-
//
|
|
10
|
+
// This lane collapsed that to ONE dialect (rawOutput=array) and dropped the
|
|
11
|
+
// entwurf/gemini/codex special-casing.
|
|
12
|
+
//
|
|
13
|
+
// This module is COMMON layer, not a claude module: the cortex landing reused THIS
|
|
14
|
+
// FILE with no adapter branch and no edit, and the CP2 LIVE turn ran through it.
|
|
15
|
+
// (That is a fact about this mapper only — the landing did touch other common files,
|
|
16
|
+
// e.g. backend.ts's overlay call site.) The collapse is therefore a standing bet,
|
|
17
|
+
// not a proof: a future backend whose session_notification dialect differs from
|
|
18
|
+
// rawOutput=array reopens this file rather than forking it per backend.
|
|
12
19
|
//
|
|
13
20
|
// CRITICAL boundary (GPT S2c Q3): an ACP `tool_call` / `tool_call_update` is
|
|
14
21
|
// rendered as an INFORMATIONAL TEXT NOTICE, never a structured pi `toolcall_*`
|
|
@@ -32,7 +39,6 @@ export interface AcpStreamIdentity {
|
|
|
32
39
|
type ObservedToolState = {
|
|
33
40
|
title: string;
|
|
34
41
|
status?: string;
|
|
35
|
-
notifiedRunning?: boolean;
|
|
36
42
|
};
|
|
37
43
|
|
|
38
44
|
export type AcpPiStreamState = {
|
|
@@ -74,7 +80,12 @@ export function createAcpStreamState(
|
|
|
74
80
|
provider: identity.provider,
|
|
75
81
|
model: identity.model,
|
|
76
82
|
usage: zeroUsage(),
|
|
77
|
-
|
|
83
|
+
// Seed, not a verdict. pi 0.83 added "pending" for exactly this: a partial
|
|
84
|
+
// streaming message has not observed a terminal reason yet, and every pi
|
|
85
|
+
// provider seeds it here and treats a stream that ENDS still-pending as an
|
|
86
|
+
// error rather than a successful stop. Seeding "stop" instead would
|
|
87
|
+
// pre-claim success for the whole time the turn is in flight.
|
|
88
|
+
stopReason: "pending",
|
|
78
89
|
timestamp: opts?.timestamp ?? Date.now(),
|
|
79
90
|
};
|
|
80
91
|
return {
|
|
@@ -214,22 +225,21 @@ function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknow
|
|
|
214
225
|
const title = titleForTool(update, previous?.title);
|
|
215
226
|
const status = typeof update?.status === "string" ? (update.status as string) : previous?.status;
|
|
216
227
|
const updateContent = Array.isArray(update?.content) ? (update.content as unknown[]) : undefined;
|
|
217
|
-
const meta = update?._meta as { terminal_output?: unknown } | undefined;
|
|
218
|
-
|
|
219
|
-
let notifiedRunning = previous?.notifiedRunning;
|
|
220
228
|
|
|
221
229
|
if (update.sessionUpdate === "tool_call") {
|
|
222
|
-
observedTools.set(toolCallId, { title, status
|
|
230
|
+
observedTools.set(toolCallId, { title, status });
|
|
223
231
|
pushNotice(state, `\n[tool:start] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
|
|
224
232
|
return;
|
|
225
233
|
}
|
|
226
234
|
|
|
227
|
-
// tool_call_update
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
235
|
+
// tool_call_update. There is deliberately no mid-flight `[tool:running]`
|
|
236
|
+
// notice: it would have to be driven by the adapter's `_meta.terminal_output`,
|
|
237
|
+
// and that meta is gated upstream on `clientCapabilities._meta.terminal_output
|
|
238
|
+
// === true` while backend.ts initializes with `clientCapabilities: {}`. So the
|
|
239
|
+
// branch could never fire for any backend we ship — it claimed a transcript
|
|
240
|
+
// line the operator was never going to see. Declaring the terminal capability
|
|
241
|
+
// is a separate axis, not a one-line re-enable: the adapter would then send
|
|
242
|
+
// terminal widgets/metas this mapper cannot render honestly into a transcript.
|
|
233
243
|
if (status && status !== previous?.status) {
|
|
234
244
|
const summary = firstTextContent(update?.rawOutput) ?? firstTextContent(updateContent);
|
|
235
245
|
const suffix = summary ? ` — ${sanitizeNoticeFragment(summary, NOTICE_SUMMARY_MAX)}` : "";
|
|
@@ -243,7 +253,7 @@ function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknow
|
|
|
243
253
|
}
|
|
244
254
|
}
|
|
245
255
|
|
|
246
|
-
observedTools.set(toolCallId, { title, status
|
|
256
|
+
observedTools.set(toolCallId, { title, status });
|
|
247
257
|
}
|
|
248
258
|
|
|
249
259
|
/** Push a permission-decision notice (informational text, not a tool call). */
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// ACP plugin — curated
|
|
1
|
+
// ACP plugin — curated model surface (S0 loader/fence slice; cortex rows added
|
|
2
|
+
// in 0.13).
|
|
2
3
|
//
|
|
3
4
|
// entwurf is an ACP *plugin* on the v2 core, NOT a general-purpose
|
|
4
5
|
// Anthropic provider. It deliberately does not expose the full pi-ai model
|
|
5
|
-
// registry — the surface is curated to the
|
|
6
|
-
//
|
|
7
|
-
// the provider/model surface up).
|
|
6
|
+
// registry — the surface is curated to the ids the plugin commits to driving
|
|
7
|
+
// through a real ACP backend.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Two curated sets live here, one per adapter on the rail
|
|
10
|
+
// (backend-adapter.ts): the unprefixed Claude ids (the reference backend) and
|
|
11
|
+
// the `cortex-` prefixed Snowflake Cortex Code ids (the second backend, landed
|
|
12
|
+
// 0.13.0 — docs/acp-backend-rail.md “Cortex Code audit”). Codex has native
|
|
13
|
+
// delivery-probe evidence but no managed citizen lane; neither it nor Gemini is an ACP backend here.
|
|
14
|
+
// A third governed backend EXTENDS this file the same way — it does not change
|
|
12
15
|
// the pattern.
|
|
13
16
|
|
|
14
17
|
// pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
|
|
@@ -81,6 +84,65 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
|
|
|
81
84
|
return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// ── Cortex (Snowflake Cortex Code) curated surface ──────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// entwurf's ACP rail is backend-extensible: Cortex is the first NON-claude
|
|
90
|
+
// backend to land through it (docs/acp-backend-rail.md, “Shipped adapters”). pi-ai carries no
|
|
91
|
+
// snowflake/cortex model source, so this surface is HAND-CURATED. Every id
|
|
92
|
+
// carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
|
|
93
|
+
// authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
|
|
94
|
+
// native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
|
|
95
|
+
// `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
96
|
+
// recover the native id, which is enforced per-turn via
|
|
97
|
+
// session/set_config_option — never a launch `-m` pin (CP0-M).
|
|
98
|
+
//
|
|
99
|
+
// The 4-row set is the GLG-decided curation (2026-07-29) — a subset of the 11
|
|
100
|
+
// native ids cortex v1.1.52 exposed live when authenticated. A curated id that
|
|
101
|
+
// the running cortex no longer serves fails LOUD at set-model, before the
|
|
102
|
+
// prompt (measured: `Unsupported model: …` names the live set). Adding an id
|
|
103
|
+
// here is the same verify-both-axes commitment as the Claude set — do not
|
|
104
|
+
// extend casually.
|
|
105
|
+
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
106
|
+
export const SUPPORTED_CORTEX_MODEL_IDS = [
|
|
107
|
+
"cortex-auto",
|
|
108
|
+
"cortex-claude-opus-5",
|
|
109
|
+
"cortex-claude-sonnet-5",
|
|
110
|
+
"cortex-openai-gpt-5.4",
|
|
111
|
+
] as const;
|
|
112
|
+
|
|
113
|
+
// Hand-set conservative context window. Cortex reports the LIVE window via its
|
|
114
|
+
// own ACP session config, not this curated metadata — this is a floor for the
|
|
115
|
+
// registry surface, deliberately not inflated to the Claude 1M.
|
|
116
|
+
const CORTEX_CONTEXT_DEFAULT = 200_000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The curated Cortex model rows handed to the single `entwurf` provider via
|
|
120
|
+
* `allCuratedModels()`. The two Claude rows ride their OWN registry base
|
|
121
|
+
* (opus-5 / sonnet-5); `auto` (default family Claude) and the GPT row (no pi-ai
|
|
122
|
+
* source) ride the sonnet-5 base as a metadata floor. id / name / contextWindow
|
|
123
|
+
* are overridden; the `cortex-` prefix keeps them from colliding with the
|
|
124
|
+
* Claude curated ids. Same row shape as `curatedClaudeModels()` (AcpModelRow).
|
|
125
|
+
*/
|
|
126
|
+
export function curatedCortexModels() {
|
|
127
|
+
const sonnetBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-sonnet-5");
|
|
128
|
+
const opusBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-5");
|
|
129
|
+
const row = (id: string, name: string, base: RegistryModel) => ({
|
|
130
|
+
id,
|
|
131
|
+
name,
|
|
132
|
+
reasoning: base.reasoning,
|
|
133
|
+
input: base.input,
|
|
134
|
+
cost: base.cost,
|
|
135
|
+
contextWindow: CORTEX_CONTEXT_DEFAULT,
|
|
136
|
+
maxTokens: base.maxTokens,
|
|
137
|
+
});
|
|
138
|
+
return [
|
|
139
|
+
row("cortex-auto", "Cortex · Auto", sonnetBase),
|
|
140
|
+
row("cortex-claude-opus-5", "Cortex · Claude Opus 5", opusBase),
|
|
141
|
+
row("cortex-claude-sonnet-5", "Cortex · Claude Sonnet 5", sonnetBase),
|
|
142
|
+
row("cortex-openai-gpt-5.4", "Cortex · OpenAI GPT-5.4", sonnetBase),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
/**
|
|
85
147
|
* The curated Claude model rows handed to `pi.registerProvider({ models })`.
|
|
86
148
|
* Fail-loud if the anchor is absent from the pi-ai registry.
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
// operator entries a backend needs (credentials, caches, built-in skills)
|
|
10
10
|
// reachable through a TIGHT symlink whitelist — nothing else.
|
|
11
11
|
//
|
|
12
|
-
// Scope (NEXT §스코프 / §S2-scout 핀3):
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// Scope (NEXT §스코프 / §S2-scout 핀3): the block above describes the CLAUDE
|
|
13
|
+
// overlay — `ensureClaudeConfigOverlay` and its symlink whitelist are written for
|
|
14
|
+
// claude-agent-acp's config surface alone. A second backend brings its OWN
|
|
15
|
+
// materializer, and cortex did: `ensureCortexDualHomeOverlay` (+ projectCortexMcpJson /
|
|
16
|
+
// sweepDeadCortexOverlays / cortexOverlayScopeId) lives further down this file with its
|
|
17
|
+
// own D-number rationale — an isolated HOME rather than a redirect knob, because cortex
|
|
18
|
+
// has no CLAUDE_CONFIG_DIR equivalent (ACP rail Cortex audit D2). The two share the module, never the
|
|
19
|
+
// strategy. Codex/Gemini overlays (CODEX_HOME / admin.toml) remain behavior-oracle
|
|
20
|
+
// territory and are out of scope here.
|
|
17
21
|
//
|
|
18
22
|
// Two deliberate divergences from the literal 0.11.0 illustrative comment block
|
|
19
23
|
// (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
// which is exactly the "mailbox absence by design" the plugin commits to
|
|
30
34
|
// (no meta-bridge hook on this child's settings surface → no mailbox).
|
|
31
35
|
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
32
37
|
import {
|
|
33
38
|
existsSync,
|
|
34
39
|
lstatSync,
|
|
@@ -43,6 +48,8 @@ import {
|
|
|
43
48
|
import { homedir } from "node:os";
|
|
44
49
|
import { join } from "node:path";
|
|
45
50
|
|
|
51
|
+
import type { AcpMcpServer } from "./config.js";
|
|
52
|
+
|
|
46
53
|
/** Operator's real Claude config dir — the symlink-passthrough SOURCE. */
|
|
47
54
|
export const CLAUDE_REAL_CONFIG_DIR = join(homedir(), ".claude");
|
|
48
55
|
|
|
@@ -220,3 +227,225 @@ export function ensureClaudeConfigOverlay(
|
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
229
|
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// Cortex dual-HOME overlay — the as-measured containment for `cortex acp serve`
|
|
233
|
+
// (CP0 audit 2026-07-29, Cortex Code v1.1.52; docs/acp-backend-rail.md “Cortex Code audit”).
|
|
234
|
+
// ============================================================================
|
|
235
|
+
//
|
|
236
|
+
// Why this is NOT the claude-shaped `SNOWFLAKE_HOME`-only overlay PR #40 shipped
|
|
237
|
+
// (each point is a measured defect, not a preference — Cortex audit D-numbers):
|
|
238
|
+
//
|
|
239
|
+
// - D2: cortex reads `CONFIG_DIRS = [".claude", ".cortex"]` at `homedir()` and
|
|
240
|
+
// `~/.claude/skills` — a SNOWFLAKE_HOME redirect cannot move that axis. An
|
|
241
|
+
// overlay session carrying only auth symlinks still advertised the
|
|
242
|
+
// operator's 42 `~/.claude/skills` entries. Cortex has no
|
|
243
|
+
// `CLAUDE_CONFIG_DIR`-equivalent redirect knob (string occurs 0 times in the
|
|
244
|
+
// binary), so the only containment that closes the leak is an ISOLATED HOME:
|
|
245
|
+
// measured global-scope skill count 0, hook trace 0, bundled/project surface
|
|
246
|
+
// intact. Scope note: `homedir()`-anchored *operator-global* state is what
|
|
247
|
+
// the isolation closes; explicit cwd PROJECT scope (`<cwd>/.claude/*`) is
|
|
248
|
+
// retained by contract — a sibling working in a repo sees that repo's
|
|
249
|
+
// declared project surface.
|
|
250
|
+
// - D3: `CORTEX_HOME` beats `SNOWFLAKE_HOME` in cortex's own resolver, so one
|
|
251
|
+
// ambient operator variable would silently bypass the whole overlay. The
|
|
252
|
+
// adapter REFUSES to spawn when `CORTEX_HOME` is present at all (empty
|
|
253
|
+
// string included) — same presence-refusal family as the ordering probe's
|
|
254
|
+
// `CLAUDE_CODE_EXECUTABLE` precondition (backend-adapter.ts).
|
|
255
|
+
// - D9: cortex ACP `newSession` reads only `cwd` and `_meta` — the wire
|
|
256
|
+
// `mcpServers` param the backend-invariant turn loop passes is IGNORED. The
|
|
257
|
+
// explicit `entwurfProvider.mcpServers` (envelope-enriched) are therefore
|
|
258
|
+
// PROJECTED into the overlay-private `$SNOWFLAKE_HOME/cortex/mcp.json` (the
|
|
259
|
+
// door `cortex mcp add` writes). Exact-author every spawn; an entry type the
|
|
260
|
+
// file cannot represent fails loud BEFORE spawn (no silent drop).
|
|
261
|
+
// - D10: an isolated HOME also cuts `~/.pi/agent` (garden store, sockets,
|
|
262
|
+
// spawn surface) off the bundled entwurf-bridge — tools reach the model but
|
|
263
|
+
// see an EMPTY garden. Dual-HOME closes it: the `entwurf-bridge` mcp.json
|
|
264
|
+
// entry ALONE gets `HOME=<real operator home>` restored; every other MCP
|
|
265
|
+
// child stays in the isolated home. The real home is captured by the parent
|
|
266
|
+
// as an absolute path BEFORE spawn — never re-derived inside the child.
|
|
267
|
+
// - D4: cortex self-updates on launch by default, and `acp serve` accepts no
|
|
268
|
+
// `--no-auto-update` (the global flag position boots a TUI with exit 0 —
|
|
269
|
+
// protocol corruption, not a server). The one remaining door is the overlay
|
|
270
|
+
// writing `"autoUpdate": false` into its own `cortex/settings.json`. That is
|
|
271
|
+
// a mid-turn self-replacement OFF switch, not a version pin.
|
|
272
|
+
// - D5/F: auth passthrough is the measured MINIMUM: `connections.toml`,
|
|
273
|
+
// `config.toml` (optional — absent on the measured host),
|
|
274
|
+
// `cortex/cache/credential_cache` (auth succeeded with exactly this set).
|
|
275
|
+
// The WHOLE `cortex/cache` leaks operator tool_outputs/tip history; operator
|
|
276
|
+
// `cortex/skills` and operator `cortex/mcp.json` are denied outright.
|
|
277
|
+
// Symlink-through only — entwurf never copies/parses/mediates the Snowflake
|
|
278
|
+
// credential (AGENTS §ACP Plugin Boundary, Hard Rule 9).
|
|
279
|
+
//
|
|
280
|
+
// The overlay is SESSION/CHILD-SCOPED, never a static shared dir: two residents
|
|
281
|
+
// with different envelopes/configs would race one mcp.json. Scope id =
|
|
282
|
+
// `<host pid>-<sha256(scopeKey) 12 hex>`; the scope dir is torn down and
|
|
283
|
+
// exact-rewritten on every spawn (the prior child for the key is already dead —
|
|
284
|
+
// backend.ts tears it down before a "new" decision spawns), which is also the
|
|
285
|
+
// memory containment: nothing cortex wrote into the isolated home survives into
|
|
286
|
+
// the next session. Scope dirs whose host pid is gone are swept opportunistically.
|
|
287
|
+
|
|
288
|
+
/** Root under which per-session cortex dual-HOME overlays are materialized. */
|
|
289
|
+
export const CORTEX_OVERLAYS_ROOT = join(homedir(), ".pi", "agent", "cortex-overlays");
|
|
290
|
+
|
|
291
|
+
/** The name of the ONE mcp.json entry whose child gets the real operator HOME
|
|
292
|
+
* restored (D10 dual-HOME). Everything else stays in the isolated home. */
|
|
293
|
+
export const CORTEX_DUAL_HOME_BRIDGE_SERVER = "entwurf-bridge";
|
|
294
|
+
|
|
295
|
+
export interface CortexOverlayParams {
|
|
296
|
+
/** Session/child scope discriminator (PI_SESSION_ID, else a cwd-derived key).
|
|
297
|
+
* Identity for overlay-dir separation only — never an address authority. */
|
|
298
|
+
scopeKey: string;
|
|
299
|
+
/** Normalized + envelope-enriched MCP servers to project into mcp.json. */
|
|
300
|
+
mcpServers: readonly AcpMcpServer[];
|
|
301
|
+
/** Absolute real operator HOME captured by the parent BEFORE spawn (D10). */
|
|
302
|
+
realHome: string;
|
|
303
|
+
/** Real snowflake home (auth source). Defaults to `<realHome>/.snowflake`. */
|
|
304
|
+
realSnowflakeHome?: string;
|
|
305
|
+
/** Overlay root override (tests). Defaults to CORTEX_OVERLAYS_ROOT. */
|
|
306
|
+
overlaysRoot?: string;
|
|
307
|
+
/** pid-liveness probe override (tests) for the dead-scope sweep. */
|
|
308
|
+
isPidAlive?: (pid: number) => boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface CortexOverlayResult {
|
|
312
|
+
/** Isolated HOME for the cortex child (spawn env HOME). */
|
|
313
|
+
home: string;
|
|
314
|
+
/** Isolated snowflake home (spawn env SNOWFLAKE_HOME) = `<home>/.snowflake`. */
|
|
315
|
+
snowflakeHome: string;
|
|
316
|
+
/** The scope dir owning both (for diagnostics/teardown). */
|
|
317
|
+
scopeDir: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Deterministic per-(host process, session key) overlay dir name. */
|
|
321
|
+
export function cortexOverlayScopeId(scopeKey: string, pid: number = process.pid): string {
|
|
322
|
+
const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 12);
|
|
323
|
+
return `${pid}-${digest}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Overlay-authored `$SNOWFLAKE_HOME/cortex/settings.json` — D4's one door. */
|
|
327
|
+
export function cortexOverlaySettingsJson(): string {
|
|
328
|
+
return `${JSON.stringify({ autoUpdate: false }, null, "\t")}\n`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Project the (envelope-enriched) explicit server list into cortex's
|
|
333
|
+
* `$SNOWFLAKE_HOME/cortex/mcp.json` shape (D9). Only stdio entries are measured
|
|
334
|
+
* through this door — an http/sse entry fails loud BEFORE spawn rather than
|
|
335
|
+
* being silently dropped. The `entwurf-bridge` entry alone carries
|
|
336
|
+
* `HOME=<realHome>` (D10); a bridge-declared HOME env is overridden, not merged.
|
|
337
|
+
*/
|
|
338
|
+
export function projectCortexMcpJson(servers: readonly AcpMcpServer[], realHome: string): string {
|
|
339
|
+
const out: Record<string, { type: "stdio"; command: string; args: string[]; env: Record<string, string> }> = {};
|
|
340
|
+
for (const server of servers) {
|
|
341
|
+
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`entwurf: cortex mcp.json projection cannot represent ${server.type} server ` +
|
|
344
|
+
`${JSON.stringify(server.name)} — only stdio entries are measured through ` +
|
|
345
|
+
`$SNOWFLAKE_HOME/cortex/mcp.json (CP0 D9). Remove it from entwurfProvider.mcpServers ` +
|
|
346
|
+
`for cortex models or front it with a stdio bridge.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const stdio = server as { name: string; command: string; args: string[]; env: { name: string; value: string }[] };
|
|
350
|
+
const env: Record<string, string> = {};
|
|
351
|
+
for (const kv of stdio.env) env[kv.name] = kv.value;
|
|
352
|
+
if (stdio.name === CORTEX_DUAL_HOME_BRIDGE_SERVER) env.HOME = realHome;
|
|
353
|
+
out[stdio.name] = { type: "stdio", command: stdio.command, args: [...stdio.args], env };
|
|
354
|
+
}
|
|
355
|
+
return `${JSON.stringify({ mcpServers: out }, null, "\t")}\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function cortexPidAlive(pid: number): boolean {
|
|
359
|
+
try {
|
|
360
|
+
process.kill(pid, 0);
|
|
361
|
+
return true;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// EPERM = alive but not ours; anything else (ESRCH) = gone.
|
|
364
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Opportunistic GC of overlay scope dirs whose HOST pid is gone (process
|
|
370
|
+
* resources only — the overlay holds no records/transcripts worth preserving;
|
|
371
|
+
* conversations state is contained BY DESIGN, Hard Rule 8 does not apply to it).
|
|
372
|
+
* Entries not matching the scope-id grammar are left alone.
|
|
373
|
+
*/
|
|
374
|
+
export function sweepDeadCortexOverlays(
|
|
375
|
+
overlaysRoot: string,
|
|
376
|
+
isPidAlive: (pid: number) => boolean = cortexPidAlive,
|
|
377
|
+
): void {
|
|
378
|
+
let entries: string[];
|
|
379
|
+
try {
|
|
380
|
+
entries = readdirSync(overlaysRoot);
|
|
381
|
+
} catch {
|
|
382
|
+
return; // root absent — nothing to sweep
|
|
383
|
+
}
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
const match = /^(\d+)-[0-9a-f]{12}$/.exec(entry);
|
|
386
|
+
if (!match) continue;
|
|
387
|
+
const pid = Number(match[1]);
|
|
388
|
+
if (pid === process.pid) continue;
|
|
389
|
+
if (isPidAlive(pid)) continue;
|
|
390
|
+
try {
|
|
391
|
+
rmSync(join(overlaysRoot, entry), { recursive: true, force: true });
|
|
392
|
+
} catch {
|
|
393
|
+
// Best-effort GC; a stuck dir is retried on the next spawn.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Symlink `realPath` at `overlayPath` when the operator actually has it.
|
|
399
|
+
* The scope dir is freshly rebuilt by the caller, so no stale-link repair. */
|
|
400
|
+
function cortexLinkIfExists(realPath: string, overlayPath: string): void {
|
|
401
|
+
if (!existsSync(realPath)) return;
|
|
402
|
+
try {
|
|
403
|
+
symlinkSync(realPath, overlayPath);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
console.error(
|
|
406
|
+
`[entwurf:cortex-overlay] symlink failed for ${overlayPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Materialize the session-scoped cortex dual-HOME overlay: tear down the scope
|
|
413
|
+
* dir and exact-rewrite it (auth symlinks + authored settings.json/mcp.json).
|
|
414
|
+
* Returns the isolated HOME/SNOWFLAKE_HOME the spawn env must carry.
|
|
415
|
+
*/
|
|
416
|
+
export function ensureCortexDualHomeOverlay(params: CortexOverlayParams): CortexOverlayResult {
|
|
417
|
+
if (!params.realHome || !params.realHome.startsWith("/")) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`entwurf: cortex dual-HOME overlay requires an absolute realHome captured by the parent (got ${JSON.stringify(params.realHome)})`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const overlaysRoot = params.overlaysRoot ?? CORTEX_OVERLAYS_ROOT;
|
|
423
|
+
const realSnowflake = params.realSnowflakeHome ?? join(params.realHome, ".snowflake");
|
|
424
|
+
sweepDeadCortexOverlays(overlaysRoot, params.isPidAlive ?? cortexPidAlive);
|
|
425
|
+
|
|
426
|
+
const scopeDir = join(overlaysRoot, cortexOverlayScopeId(params.scopeKey));
|
|
427
|
+
// Exact rewrite (never merge): the prior child for this scope key is already
|
|
428
|
+
// torn down (backend.ts closes it before a "new" decision spawns), so
|
|
429
|
+
// everything it wrote — conversations, logs, $HOME dotfiles — is discarded
|
|
430
|
+
// here. rmSync does not follow symlinks, so the real auth files are untouched.
|
|
431
|
+
rmSync(scopeDir, { recursive: true, force: true });
|
|
432
|
+
|
|
433
|
+
const home = join(scopeDir, "home");
|
|
434
|
+
const snowflakeHome = join(home, ".snowflake");
|
|
435
|
+
const cortexDir = join(snowflakeHome, "cortex");
|
|
436
|
+
mkdirSync(join(cortexDir, "cache"), { recursive: true });
|
|
437
|
+
|
|
438
|
+
// D5/F — measured-minimum auth passthrough (symlink-through only).
|
|
439
|
+
cortexLinkIfExists(join(realSnowflake, "connections.toml"), join(snowflakeHome, "connections.toml"));
|
|
440
|
+
cortexLinkIfExists(join(realSnowflake, "config.toml"), join(snowflakeHome, "config.toml"));
|
|
441
|
+
cortexLinkIfExists(
|
|
442
|
+
join(realSnowflake, "cortex", "cache", "credential_cache"),
|
|
443
|
+
join(cortexDir, "cache", "credential_cache"),
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// D4 — runtime self-replacement off; D9 — explicit-server projection.
|
|
447
|
+
writeFileSync(join(cortexDir, "settings.json"), cortexOverlaySettingsJson(), "utf8");
|
|
448
|
+
writeFileSync(join(cortexDir, "mcp.json"), projectCortexMcpJson(params.mcpServers, params.realHome), "utf8");
|
|
449
|
+
|
|
450
|
+
return { home, snowflakeHome, scopeDir };
|
|
451
|
+
}
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
// if pi has excluded a built-in the backend will still expose, the declared
|
|
11
11
|
// surface diverges from the actual one — we fail fast rather than lie.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// The module's TWO jobs have different backend scopes — do not collapse them:
|
|
14
|
+
// - `buildClaudeSessionMeta` is claude-only by CONSTRUCTION, with exactly one
|
|
15
|
+
// caller (`claudeAdapter.buildSessionMeta`). Cortex exposes no
|
|
16
|
+
// `_meta.systemPrompt` and no tools/permission declaration surface (native
|
|
17
|
+
// tools + its own overlay mcp.json projection), so its adapter returns
|
|
18
|
+
// `undefined` and that function is never reached for a cortex turn.
|
|
19
|
+
// - `assertExcludeToolsHonored` is BACKEND-INVARIANT: backend.ts runs it for
|
|
20
|
+
// every adapter before any spawn, passing `backend: adapter.backend` and the
|
|
21
|
+
// resolved `config.tools`. A cortex turn DOES enter this module through it.
|
|
22
|
+
// That split is why the `backend` field exists: the honest-divergence logic stays
|
|
23
|
+
// explicit and the matrix gate can exercise both the "claude narrows via tools"
|
|
24
|
+
// and "native always exposes" branches.
|
|
17
25
|
//
|
|
18
26
|
// Carrier guard (NEXT §S2-scout 핀1): `buildClaudeSessionMeta` only attaches
|
|
19
27
|
// `_meta.systemPrompt` when a caller passes one. The shipped S2d path passes the
|
package/prompts/engraving.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
<!-- ═══════════════════════════════════════════════════════════════════════
|
|
2
2
|
SAMPLE — operator engraving carrier (entwurf)
|
|
3
3
|
|
|
4
|
-
What this is: the
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
What this is: the engraving file is loaded VERBATIM into the backend's
|
|
5
|
+
identity slot (Claude `_meta.systemPrompt`). It is the one place an
|
|
6
|
+
operator stamps a short identity onto every entwurf ACP turn —
|
|
7
|
+
replacing Claude's `claude_code` preset (which also strips the preset's
|
|
8
|
+
auto-memory advertisement, the memory-containment lever).
|
|
9
|
+
|
|
10
|
+
VERBATIM means the WHOLE file, this comment included — the loader does
|
|
11
|
+
not strip markdown comments. Copy this sample and delete everything you
|
|
12
|
+
do not want on the wire; do not point the runtime at it as-is.
|
|
9
13
|
|
|
10
14
|
Where it loads from:
|
|
11
15
|
• Runtime default = pi-extensions/lib/acp/prompts/engraving.md
|
|
@@ -22,6 +26,10 @@
|
|
|
22
26
|
tool catalogs here — a large Claude carrier can route OAuth
|
|
23
27
|
sessions to metered "extra usage" billing.
|
|
24
28
|
• An empty or missing file = opt-out (no engraving). That is fine.
|
|
29
|
+
• Do NOT open the file with a blank line to separate yourself from
|
|
30
|
+
Claude's fixed SDK sentence. Your leading/trailing whitespace is
|
|
31
|
+
trimmed (it would otherwise drift the reuse signature) and the
|
|
32
|
+
loader opens the carrier with that blank line itself.
|
|
25
33
|
═══════════════════════════════════════════════════════════════════════ -->
|
|
26
34
|
|
|
27
35
|
# Engraving Here
|