@junghanacs/entwurf 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +380 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +6 -0
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +396 -8
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-facts.ts +12 -6
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +16 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-release-gate-outcomes.ts +12 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# install-herdr-ci — put the MEASURED herdr binary on PATH for one CI job, and nothing else.
|
|
3
|
+
#
|
|
4
|
+
# This is not an installer for people. `entwurf setup` does not call it, the package does not
|
|
5
|
+
# ship it as a command, and it never writes outside the runner's temp dir: Hard Rule 17 says
|
|
6
|
+
# Entwurf supplies Entwurf's bytes, never a harness or a placement owner's. A CI job that wants
|
|
7
|
+
# the herdr rail proven fetches the exact asset this repository has measured, checks its digest
|
|
8
|
+
# BEFORE the file is ever made executable, and exposes only that one directory to later steps.
|
|
9
|
+
#
|
|
10
|
+
# What is deliberately NOT here, each for a reason:
|
|
11
|
+
# - no `curl … | sh`. A pipe to a shell has no version and no digest to check; whatever the
|
|
12
|
+
# upstream script does today it can do something else tomorrow, and the whole point of this
|
|
13
|
+
# file is that CI runs a byte we have named.
|
|
14
|
+
# - no package manager (brew/mise/nix). Each adds a layer whose contents we would then be
|
|
15
|
+
# asserting about without measuring.
|
|
16
|
+
# - no `latest`. A mutable URL turns a green run into a claim about a binary nobody chose.
|
|
17
|
+
# - no `gh attestation verify`. `[측정 2026-09-14, gh 2.97.0]` it cannot close herdr's
|
|
18
|
+
# predicate (`in-toto release/v0.2`) — default is 404, explicit is "no attestations found",
|
|
19
|
+
# while the REST API returns the attestation. A check we cannot run is not a check.
|
|
20
|
+
# - no global install, no ~/.local/bin, no sudo. The binary lives and dies with the job.
|
|
21
|
+
#
|
|
22
|
+
# Every version and digest comes from scripts/fixtures/herdr-supply.json. This file contains
|
|
23
|
+
# none of its own, on purpose: two copies of a digest is how a pin drifts.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
27
|
+
manifest="${repo_dir}/scripts/fixtures/herdr-supply.json"
|
|
28
|
+
|
|
29
|
+
fail() {
|
|
30
|
+
echo "[install-herdr-ci] FAIL — $1" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[ -f "$manifest" ] || fail "the supply manifest is missing: ${manifest}"
|
|
35
|
+
|
|
36
|
+
# The runner's temp dir in CI; a private mktemp when someone runs this by hand. Never a
|
|
37
|
+
# directory that outlives the job.
|
|
38
|
+
dest_dir="${RUNNER_TEMP:-}"
|
|
39
|
+
if [ -z "$dest_dir" ]; then
|
|
40
|
+
dest_dir="$(mktemp -d)"
|
|
41
|
+
fi
|
|
42
|
+
mkdir -p "$dest_dir"
|
|
43
|
+
|
|
44
|
+
read_manifest() {
|
|
45
|
+
node -e '
|
|
46
|
+
const fs = require("node:fs");
|
|
47
|
+
const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
48
|
+
const arch = process.argv[2];
|
|
49
|
+
const asset = m.assets[arch];
|
|
50
|
+
if (!asset) {
|
|
51
|
+
// Fail closed. A machine architecture we have never measured does not get a
|
|
52
|
+
// "closest" binary — there is no such thing.
|
|
53
|
+
process.stderr.write(`unsupported architecture ${arch}; measured: ${Object.keys(m.assets).join(", ")}\n`);
|
|
54
|
+
process.exit(3);
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write([m.version, m.releaseUrlPrefix, m.tag, asset.name, asset.sha256].join("\n"));
|
|
57
|
+
' "$manifest" "$1"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
arch="$(uname -m)"
|
|
61
|
+
if ! fields="$(read_manifest "$arch")"; then
|
|
62
|
+
fail "no measured herdr asset for $(uname -m) — the supply manifest owns that set"
|
|
63
|
+
fi
|
|
64
|
+
version="$(echo "$fields" | sed -n 1p)"
|
|
65
|
+
url_prefix="$(echo "$fields" | sed -n 2p)"
|
|
66
|
+
tag="$(echo "$fields" | sed -n 3p)"
|
|
67
|
+
asset_name="$(echo "$fields" | sed -n 4p)"
|
|
68
|
+
expected_sha="$(echo "$fields" | sed -n 5p)"
|
|
69
|
+
|
|
70
|
+
url="${url_prefix}/${tag}/${asset_name}"
|
|
71
|
+
target="${dest_dir}/herdr"
|
|
72
|
+
|
|
73
|
+
echo "[install-herdr-ci] fetching ${asset_name} (${tag}) for ${arch}"
|
|
74
|
+
curl -fsSL --retry 3 --retry-delay 2 -o "$target" "$url" || fail "download failed: ${url}"
|
|
75
|
+
|
|
76
|
+
# ORDER IS THE SAFETY ARGUMENT: the digest is checked while the file is still inert data.
|
|
77
|
+
# chmod comes after, so a byte we did not choose never becomes a thing this job can run.
|
|
78
|
+
actual_sha="$(sha256sum "$target" | cut -d' ' -f1)"
|
|
79
|
+
if [ "$actual_sha" != "$expected_sha" ]; then
|
|
80
|
+
rm -f "$target"
|
|
81
|
+
fail "digest mismatch for ${asset_name}: expected ${expected_sha}, got ${actual_sha}"
|
|
82
|
+
fi
|
|
83
|
+
chmod +x "$target"
|
|
84
|
+
|
|
85
|
+
reported="$("$target" --version 2>/dev/null || true)"
|
|
86
|
+
case "$reported" in
|
|
87
|
+
*"herdr ${version}"*) ;;
|
|
88
|
+
*) fail "the verified binary reports '${reported}', not 'herdr ${version}' — the manifest and the artifact disagree" ;;
|
|
89
|
+
esac
|
|
90
|
+
|
|
91
|
+
# Only this directory reaches later steps. Nothing is copied into a shared bin dir.
|
|
92
|
+
if [ -n "${GITHUB_PATH:-}" ]; then
|
|
93
|
+
echo "$dest_dir" >>"$GITHUB_PATH"
|
|
94
|
+
fi
|
|
95
|
+
echo "[install-herdr-ci] ok — ${reported} at ${target} (sha256 ${expected_sha})"
|
|
96
|
+
echo "$dest_dir"
|
|
@@ -55,11 +55,11 @@ export function buildCodexInstruction(input: CodexInstructionInput): string {
|
|
|
55
55
|
'NO cwd parameter and NO placement parameter, and task "After your automatic callback succeeds, answer ACK and ' +
|
|
56
56
|
'stop. Do not open another sibling."\n' +
|
|
57
57
|
"2. Stop and wait for the Pi callback delivered into this Codex thread. Accept only a callback whose body " +
|
|
58
|
-
"repeats the exact nonce from your LAUNCH receipt and whose sender envelope
|
|
58
|
+
"repeats the exact nonce from your LAUNCH receipt and whose sender envelope carries a garden id on its `session:` line.\n" +
|
|
59
59
|
`3. Only after that match, call public mcp__entwurf_bridge__entwurf_v2 once with target ${input.callerGid}, ` +
|
|
60
60
|
"intent fire-and-forget, wants_reply false, and message:\n" +
|
|
61
61
|
`${input.finalToken}\nPI_LAUNCH_NONCE=<exact launch nonce>\nPI_CALLBACK_NONCE=<same exact nonce>\n` +
|
|
62
|
-
"PI_CALLBACK_FROM=<
|
|
62
|
+
"PI_CALLBACK_FROM=<the garden id on the callback envelope's `session:` line — NOT the model name on its `from:` line>\nPI_SESSION_ID=<exact $session id from the LAUNCH receipt>\n" +
|
|
63
63
|
"PI_WINDOW_ID=<exact @window id from the LAUNCH receipt>\nDo not claim completion in prose."
|
|
64
64
|
);
|
|
65
65
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-digest — one deterministic digest of a whole directory tree, for gates that claim a tree is
|
|
3
|
+
* UNCHANGED.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Two #116 M3-b3 oracles compared a sorted list of relative paths (one of them with
|
|
6
|
+
* sizes) and called the result "byte-identical". A same-length content edit passes both — which is
|
|
7
|
+
* precisely the shape a refusal-must-not-mutate claim needs to catch, because a transaction that
|
|
8
|
+
* rewrites a file in place rarely changes its length. So the digest here reads the actual bytes.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IS IN IT: every entry's relative path, its node TYPE, and for a regular file its permission
|
|
11
|
+
* bits, its size and its whole content; for a symlink its target; for a directory its own entry
|
|
12
|
+
* count, so an emptied directory and a removed one are different facts. That makes the honest claim
|
|
13
|
+
* "content + mode + topology", which is what the callers now say.
|
|
14
|
+
*
|
|
15
|
+
* FRAMING IS LENGTH-PREFIXED. A digest built by concatenating names cannot tell `a/b` + `c` from
|
|
16
|
+
* `a` + `b/c`; every field is therefore written as `label:<byteLength>:<bytes>\0`, so no boundary
|
|
17
|
+
* between two fields can be spelled by the contents of one.
|
|
18
|
+
*
|
|
19
|
+
* MEMORY IS BOUNDED. A runtime tree here is hundreds of megabytes, so files are hashed in 64 KiB
|
|
20
|
+
* chunks through one reused buffer rather than read whole. No `sha256sum`, no shell: a gate that
|
|
21
|
+
* shells out to hash acquires a dependency the hosts it runs on do not owe it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash, type Hash } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
29
|
+
|
|
30
|
+
function field(hash: Hash, label: string, value: string | number): void {
|
|
31
|
+
const bytes = Buffer.from(String(value), "utf8");
|
|
32
|
+
hash.update(`${label}:${bytes.length}:`);
|
|
33
|
+
hash.update(bytes);
|
|
34
|
+
hash.update("\0");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashFileContent(hash: Hash, file: string): void {
|
|
38
|
+
const fd = fs.openSync(file, "r");
|
|
39
|
+
try {
|
|
40
|
+
const buf = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
41
|
+
let read = fs.readSync(fd, buf, 0, CHUNK_BYTES, null);
|
|
42
|
+
while (read > 0) {
|
|
43
|
+
hash.update(buf.subarray(0, read));
|
|
44
|
+
read = fs.readSync(fd, buf, 0, CHUNK_BYTES, null);
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
fs.closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
hash.update("\0");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The digest of `root`, or the literal `"absent"` when nothing is there.
|
|
54
|
+
*
|
|
55
|
+
* @returns `sha256-<hex>` over content + mode + topology, or `absent`.
|
|
56
|
+
*/
|
|
57
|
+
export function treeDigest(root: string): string {
|
|
58
|
+
const top = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
59
|
+
if (top === undefined) return "absent";
|
|
60
|
+
const hash = createHash("sha256");
|
|
61
|
+
if (!top.isDirectory()) {
|
|
62
|
+
field(hash, top.isSymbolicLink() ? "root-symlink" : "root-nondir", path.basename(root));
|
|
63
|
+
if (top.isSymbolicLink()) field(hash, "target", fs.readlinkSync(root));
|
|
64
|
+
else if (top.isFile()) hashFileContent(hash, root);
|
|
65
|
+
return `sha256-${hash.digest("hex")}`;
|
|
66
|
+
}
|
|
67
|
+
const walk = (dir: string): void => {
|
|
68
|
+
const entries = fs
|
|
69
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
70
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
71
|
+
field(hash, "dir", path.relative(root, dir) || ".");
|
|
72
|
+
field(hash, "entries", entries.length);
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const abs = path.join(dir, entry.name);
|
|
75
|
+
const rel = path.relative(root, abs);
|
|
76
|
+
if (entry.isSymbolicLink()) {
|
|
77
|
+
field(hash, "symlink", rel);
|
|
78
|
+
field(hash, "target", fs.readlinkSync(abs));
|
|
79
|
+
} else if (entry.isDirectory()) {
|
|
80
|
+
field(hash, "subdir", rel);
|
|
81
|
+
} else if (entry.isFile()) {
|
|
82
|
+
const stat = fs.statSync(abs);
|
|
83
|
+
field(hash, "file", rel);
|
|
84
|
+
field(hash, "mode", (stat.mode & 0o7777).toString(8));
|
|
85
|
+
field(hash, "size", stat.size);
|
|
86
|
+
hashFileContent(hash, abs);
|
|
87
|
+
} else {
|
|
88
|
+
field(hash, "other", rel);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) walk(path.join(dir, entry.name));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(root);
|
|
96
|
+
return `sha256-${hash.digest("hex")}`;
|
|
97
|
+
}
|
|
@@ -170,7 +170,13 @@ echo "[meta-bridge-install] platform=$(uname -s) node=$NODE_VER ($NODE_BIN) clau
|
|
|
170
170
|
# settings.json / ~/.claude.json. Re-runs preserve the first snapshot, so
|
|
171
171
|
# uninstall restores the true pre-entwurf state rather than the last install
|
|
172
172
|
# run's already-managed values.
|
|
173
|
-
|
|
173
|
+
# #116 M3-b2 — decide before writing. An aggregate activation has to be able to say "nothing will
|
|
174
|
+
# fail once I start" before it starts, and that promise is only real if this half can be checked
|
|
175
|
+
# without mutating.
|
|
176
|
+
python3 "$REPO/scripts/meta-bridge-state.py" preflight-install --repo "$REPO" --asm "$ASM" \
|
|
177
|
+
${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
|
|
178
|
+
python3 "$REPO/scripts/meta-bridge-state.py" prepare --repo "$REPO" --asm "$ASM" \
|
|
179
|
+
${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
|
|
174
180
|
|
|
175
181
|
# --- 1. assemble a self-contained, node-baked plugin ------------------------
|
|
176
182
|
rm -rf "$ASM"
|
|
@@ -251,6 +257,15 @@ claude mcp remove pi-tools-bridge -s user >/dev/null 2>&1 || true
|
|
|
251
257
|
# in node_modules/@junghanacs/entwurf) wires the STABLE `entwurf-bridge` bin shim; baking
|
|
252
258
|
# the pnpm store path here would go stale on any peer/version bump. A dev clone pins to
|
|
253
259
|
# this clone's start.sh. Both branches carry the same env desired_mcp() writes.
|
|
260
|
+
# #116 M3-b2 — the explicit plugin mode comes FIRST, and it is derived, not supplied. A Herdr
|
|
261
|
+
# plugin activates where nothing entwurf is on PATH, so the bare shim cannot resolve; the absolute
|
|
262
|
+
# bridge under the certified stable runtime can. `desired_mcp()` above is still the SSOT — this
|
|
263
|
+
# branch mirrors it, as the comment on the block already demanded.
|
|
264
|
+
if [ -n "${ENTWURF_PLUGIN_RUNTIME:-}" ]; then
|
|
265
|
+
claude mcp add -s user entwurf-bridge \
|
|
266
|
+
-e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
|
|
267
|
+
-- "$ENTWURF_PLUGIN_RUNTIME/node_modules/.bin/entwurf-bridge" >/dev/null
|
|
268
|
+
else
|
|
254
269
|
case "$REPO" in
|
|
255
270
|
*/node_modules/@junghanacs/entwurf)
|
|
256
271
|
claude mcp add -s user entwurf-bridge \
|
|
@@ -261,6 +276,7 @@ case "$REPO" in
|
|
|
261
276
|
-e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
|
|
262
277
|
-- bash "$REPO/mcp/entwurf-bridge/start.sh" >/dev/null ;;
|
|
263
278
|
esac
|
|
279
|
+
fi
|
|
264
280
|
# Capture, THEN match — and require BOTH the exit code and the content.
|
|
265
281
|
# `<cli> | grep -q` under `set -o pipefail` is a race, not a test: grep exits at the
|
|
266
282
|
# first match and closes the pipe, the still-writing CLI dies of SIGPIPE (141), and
|
|
@@ -283,7 +299,8 @@ echo "[meta-bridge-install] installed entwurf-bridge MCP (scope: user = global r
|
|
|
283
299
|
# Re-assert the repo-owned keyset through our stateful manager. The Claude CLI
|
|
284
300
|
# calls above are allowed to maintain their cache/registry files, but the
|
|
285
301
|
# operator-facing JSON keys are owned here so uninstall can be honest.
|
|
286
|
-
python3 "$REPO/scripts/meta-bridge-state.py" apply --repo "$REPO" --asm "$ASM"
|
|
302
|
+
python3 "$REPO/scripts/meta-bridge-state.py" apply --repo "$REPO" --asm "$ASM" \
|
|
303
|
+
${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
|
|
287
304
|
|
|
288
305
|
# --- evidence ---------------------------------------------------------------
|
|
289
306
|
echo "--- claude plugin list ---"
|
|
@@ -21,6 +21,9 @@ MARKETPLACE = "meta-bridge-local"
|
|
|
21
21
|
PLUGIN_REF = f"{PLUGIN}@{MARKETPLACE}"
|
|
22
22
|
STATE_VERSION = 1
|
|
23
23
|
OWNER = "entwurf meta-bridge"
|
|
24
|
+
# The only suffix an assembled marketplace path may end in — the inverse acts on that address, and
|
|
25
|
+
# the doctor already refuses anything else, so the preflight uses the SAME shape (not a looser one).
|
|
26
|
+
ASSEMBLED_SUFFIX = "/entwurf/meta-bridge/.assembled"
|
|
24
27
|
|
|
25
28
|
PERMISSION_ALLOW = [
|
|
26
29
|
"Bash",
|
|
@@ -304,6 +307,44 @@ def is_installed_package(repo: Path) -> bool:
|
|
|
304
307
|
return parts[-3:] == ("node_modules", "@junghanacs", "entwurf")
|
|
305
308
|
|
|
306
309
|
|
|
310
|
+
# #116 M3-b2 — the explicit plugin mode. A Herdr plugin activates on a host where nothing entwurf is
|
|
311
|
+
# on PATH, so the bare bin shim cannot resolve. In that mode the commands are DERIVED from the
|
|
312
|
+
# certified stable active runtime root: the caller passes the root, these helpers append the fixed
|
|
313
|
+
# suffixes, and nothing accepts an arbitrary command. The default installed branch below is
|
|
314
|
+
# untouched — mode is an explicit input, never a path heuristic, because a stable-runtime install
|
|
315
|
+
# lives at `.../active/node_modules/@junghanacs/entwurf` and already satisfies every layout test.
|
|
316
|
+
PLUGIN_RUNTIME: Path | None = None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def stable_active_root() -> Path:
|
|
320
|
+
"""The ONE runtime address, derived from this process's XDG/HOME exactly as the runtime owner
|
|
321
|
+
derives it (`scripts/herdr-runtime.mjs` resolveRuntimeLayout). A gate pins the two equal."""
|
|
322
|
+
data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
323
|
+
return Path(data_home) / "entwurf" / "herdr-plugin" / "runtime" / "active"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def set_plugin_runtime(root: Path | None) -> None:
|
|
327
|
+
global PLUGIN_RUNTIME
|
|
328
|
+
if root is None:
|
|
329
|
+
PLUGIN_RUNTIME = None
|
|
330
|
+
return
|
|
331
|
+
if not root.is_absolute():
|
|
332
|
+
die(f"--plugin-runtime must be an ABSOLUTE stable runtime root, got {root}")
|
|
333
|
+
normalised = Path(os.path.normpath(str(root)))
|
|
334
|
+
# Named, not chosen: the caller may say which root, and it is accepted only when it IS this
|
|
335
|
+
# host's stable one. Any absolute directory would let a mode flag point Claude's MCP command at
|
|
336
|
+
# an executable of the caller's choosing.
|
|
337
|
+
expected = stable_active_root()
|
|
338
|
+
if normalised != expected:
|
|
339
|
+
die(f"--plugin-runtime must be this host's stable runtime root {expected}, got {normalised}")
|
|
340
|
+
PLUGIN_RUNTIME = normalised
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def plugin_bin(name: str) -> str:
|
|
344
|
+
assert PLUGIN_RUNTIME is not None
|
|
345
|
+
return str(PLUGIN_RUNTIME / "node_modules" / ".bin" / name)
|
|
346
|
+
|
|
347
|
+
|
|
307
348
|
def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
308
349
|
env = {
|
|
309
350
|
# #50 C4: anonymous sends are refused by the BRIDGE DEFAULT now — the
|
|
@@ -313,6 +354,8 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
|
313
354
|
# session always has an authoritative garden-id sender.
|
|
314
355
|
"ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
|
|
315
356
|
}
|
|
357
|
+
if PLUGIN_RUNTIME is not None:
|
|
358
|
+
return {"type": "stdio", "command": plugin_bin("entwurf-bridge"), "args": [], "env": env}
|
|
316
359
|
if is_installed_package(repo):
|
|
317
360
|
# Installed package: wire the STABLE `entwurf-bridge` bin shim that npm/pnpm
|
|
318
361
|
# place on PATH. Baking repo/mcp/entwurf-bridge/start.sh would embed the pnpm
|
|
@@ -333,6 +376,8 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
|
333
376
|
|
|
334
377
|
|
|
335
378
|
def desired_statusline(repo: Path) -> dict[str, Any]:
|
|
379
|
+
if PLUGIN_RUNTIME is not None:
|
|
380
|
+
return {"type": "command", "command": plugin_bin("entwurf-statusline")}
|
|
336
381
|
if is_installed_package(repo):
|
|
337
382
|
# Installed package: mirror the MCP stable-bin pattern. The statusLine is
|
|
338
383
|
# executed at render time, so a bare bin shim lets npm/pnpm update the
|
|
@@ -342,20 +387,23 @@ def desired_statusline(repo: Path) -> dict[str, Any]:
|
|
|
342
387
|
return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
|
|
343
388
|
|
|
344
389
|
|
|
345
|
-
def
|
|
346
|
-
existing = load_state(required=False)
|
|
347
|
-
state = existing if existing is not None else init_state(repo, asm)
|
|
348
|
-
state["updatedAt"] = iso_now()
|
|
349
|
-
state["repo"] = str(repo.resolve())
|
|
350
|
-
state["assembledMarketplacePath"] = str(asm.resolve())
|
|
351
|
-
|
|
390
|
+
def read_live_documents() -> tuple[dict[str, Any], dict[str, Any]]:
|
|
352
391
|
settings = read_json(settings_path(), {})
|
|
353
392
|
root = read_json(claude_root_config_path(), {})
|
|
354
393
|
if not isinstance(settings, dict):
|
|
355
394
|
die(f"{settings_path()} root must be a JSON object")
|
|
356
395
|
if not isinstance(root, dict):
|
|
357
396
|
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
397
|
+
return settings, root
|
|
398
|
+
|
|
358
399
|
|
|
400
|
+
def plan_snapshots(state: dict[str, Any], repo: Path, asm: Path, settings: dict[str, Any], root: dict[str, Any]) -> None:
|
|
401
|
+
"""Every snapshot decision `prepare` makes, in one place.
|
|
402
|
+
|
|
403
|
+
#116 M3-b2 amendment. `preflight-install` runs this against DEEP COPIES so the forward promise
|
|
404
|
+
is the real plan and not a JSON-object smoke test: a preflight that only checked the documents
|
|
405
|
+
parse would go green and then let `prepare` die on an array-type mismatch it never looked at.
|
|
406
|
+
"""
|
|
359
407
|
snapshot_value(
|
|
360
408
|
state,
|
|
361
409
|
"settings",
|
|
@@ -397,6 +445,15 @@ def prepare(repo: Path, asm: Path) -> None:
|
|
|
397
445
|
legacy_absent_if_equal=desired_mcp(repo),
|
|
398
446
|
)
|
|
399
447
|
|
|
448
|
+
|
|
449
|
+
def prepare(repo: Path, asm: Path) -> None:
|
|
450
|
+
existing = load_state(required=False)
|
|
451
|
+
state = existing if existing is not None else init_state(repo, asm)
|
|
452
|
+
state["updatedAt"] = iso_now()
|
|
453
|
+
state["repo"] = str(repo.resolve())
|
|
454
|
+
state["assembledMarketplacePath"] = str(asm.resolve())
|
|
455
|
+
settings, root = read_live_documents()
|
|
456
|
+
plan_snapshots(state, repo, asm, settings, root)
|
|
400
457
|
write_json(state_path(), state, mode=0o600)
|
|
401
458
|
print(f"[meta-bridge-state] prepared {state_path()}")
|
|
402
459
|
|
|
@@ -446,10 +503,41 @@ def apply(repo: Path, asm: Path) -> None:
|
|
|
446
503
|
print("[meta-bridge-state] applied managed keyset (settings.json + user MCP)")
|
|
447
504
|
|
|
448
505
|
|
|
449
|
-
def
|
|
506
|
+
def certify_entry(obj: dict[str, Any], entry: dict[str, Any], where: str = "state entry") -> str:
|
|
507
|
+
"""Prove this entry COULD be restored, and write nothing.
|
|
508
|
+
|
|
509
|
+
#116 M3-b2 amendment. This is the read-only twin of `restore_entry`, and it exists as one
|
|
510
|
+
function rather than two because the first cut of the uninstall preflight re-implemented the
|
|
511
|
+
checks and knew only ONE of the three entry shapes: it demanded `original` on every entry, while
|
|
512
|
+
`snapshot_array_items` writes `{originalExisted, added}`. Every real Claude activation therefore
|
|
513
|
+
failed its own preflight. A preflight that re-derives the restore rules will drift from them
|
|
514
|
+
again the next time a kind is added — so `restore_entry` calls THIS, and so does the preflight.
|
|
515
|
+
"""
|
|
450
516
|
path = entry.get("path")
|
|
451
517
|
if not isinstance(path, list) or not all(isinstance(p, str) for p in path):
|
|
452
|
-
die("bad state entry path")
|
|
518
|
+
die(f"{where}: bad state entry path")
|
|
519
|
+
kind = entry.get("kind")
|
|
520
|
+
if kind in ("map-entry", "scalar"):
|
|
521
|
+
original = entry.get("original")
|
|
522
|
+
if not isinstance(original, dict) or "existed" not in original:
|
|
523
|
+
die(f"{where}: bad scalar/map original in state")
|
|
524
|
+
return kind
|
|
525
|
+
if kind == "array-items":
|
|
526
|
+
if "originalExisted" not in entry:
|
|
527
|
+
die(f"{where}: array-items entry records no originalExisted")
|
|
528
|
+
if not isinstance(entry.get("added", []), list):
|
|
529
|
+
die(f"{where}: bad array added list in state")
|
|
530
|
+
existed, value = get_nested(obj, path)
|
|
531
|
+
if existed and not isinstance(value, list):
|
|
532
|
+
die(f"{where}: {'.'.join(path)} exists but is not an array; restoration is not applicable")
|
|
533
|
+
return kind
|
|
534
|
+
die(f"{where}: unknown state entry kind {kind!r}")
|
|
535
|
+
raise AssertionError("unreachable")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
|
|
539
|
+
certify_entry(obj, entry)
|
|
540
|
+
path = entry.get("path")
|
|
453
541
|
kind = entry.get("kind")
|
|
454
542
|
if kind in ("map-entry", "scalar"):
|
|
455
543
|
original = entry.get("original")
|
|
@@ -525,8 +613,71 @@ def relinquish_retired_scalar(
|
|
|
525
613
|
|
|
526
614
|
|
|
527
615
|
def preflight_uninstall() -> None:
|
|
528
|
-
|
|
529
|
-
|
|
616
|
+
"""Prove the ENTIRE restoration plan is applicable, writing nothing.
|
|
617
|
+
|
|
618
|
+
#116 M3-b2. This used to be `load_state(required=True)` and a print — it proved the ledger
|
|
619
|
+
existed and nothing else. An aggregate deactivate that calls it and then claims "every inverse
|
|
620
|
+
was preflighted" would be asserting a check that had not run, and a teardown that starts
|
|
621
|
+
mutating on that promise is how a Claude failure strands a Pi half. Every entry is certified
|
|
622
|
+
through the SAME function `restore_entry` uses, so the two cannot drift.
|
|
623
|
+
"""
|
|
624
|
+
state = load_state(required=True)
|
|
625
|
+
assert state is not None
|
|
626
|
+
owner = state.get("owner")
|
|
627
|
+
if owner != OWNER:
|
|
628
|
+
die(f"{state_path()} is not ours: owner={owner!r} (expected {OWNER!r})")
|
|
629
|
+
settings, root = read_live_documents()
|
|
630
|
+
certified = 0
|
|
631
|
+
for path_key, doc in (("settings", settings), ("claudeRoot", root)):
|
|
632
|
+
entries = state.get("files", {}).get(path_key, {}).get("keys", {})
|
|
633
|
+
if not isinstance(entries, dict):
|
|
634
|
+
die(f"{state_path()} has no {path_key} key map")
|
|
635
|
+
for name, entry in entries.items():
|
|
636
|
+
if not isinstance(entry, dict):
|
|
637
|
+
die(f"{state_path()} {path_key} entry {name!r} is not a record")
|
|
638
|
+
certify_entry(doc, entry, f"{path_key} entry {name!r}")
|
|
639
|
+
certified += 1
|
|
640
|
+
certify_assembled_path(state.get("assembledMarketplacePath"))
|
|
641
|
+
print(
|
|
642
|
+
f"[meta-bridge-state] uninstall preflight ok ({state_path()}): owner={owner}, "
|
|
643
|
+
f"settings+claudeRoot parse, {certified} restore entries applicable, "
|
|
644
|
+
f"assembled={state.get('assembledMarketplacePath')}"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def certify_assembled_path(recorded: Any) -> None:
|
|
649
|
+
"""The recorded marketplace path is an address the inverse acts on, so its SHAPE is checked."""
|
|
650
|
+
if not isinstance(recorded, str) or not recorded:
|
|
651
|
+
die(f"{state_path()} records no assembledMarketplacePath")
|
|
652
|
+
if not os.path.isabs(recorded):
|
|
653
|
+
die(f"{state_path()} assembledMarketplacePath is not absolute: {recorded}")
|
|
654
|
+
if not recorded.endswith(ASSEMBLED_SUFFIX):
|
|
655
|
+
die(f"{state_path()} assembledMarketplacePath does not end in {ASSEMBLED_SUFFIX}: {recorded}")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def preflight_install(repo: Path, asm: Path) -> None:
|
|
659
|
+
"""The forward half of the same promise: run the REAL plan on copies, write nothing.
|
|
660
|
+
|
|
661
|
+
An aggregate activation has to be able to say "nothing will fail once I start" before it starts.
|
|
662
|
+
A preflight that only checked the documents parse would go green and then let `prepare` die on
|
|
663
|
+
an array-type mismatch it never looked at, so this walks `plan_snapshots` — the very function
|
|
664
|
+
`prepare` calls — against deep copies and certifies every entry it produces.
|
|
665
|
+
"""
|
|
666
|
+
settings, root = read_live_documents()
|
|
667
|
+
existing = load_state(required=False)
|
|
668
|
+
state = copy.deepcopy(existing) if existing is not None else init_state(repo, asm)
|
|
669
|
+
plan_snapshots(state, repo, asm, copy.deepcopy(settings), copy.deepcopy(root))
|
|
670
|
+
planned = 0
|
|
671
|
+
for path_key, doc in (("settings", settings), ("claudeRoot", root)):
|
|
672
|
+
for name, entry in state["files"][path_key]["keys"].items():
|
|
673
|
+
certify_entry(doc, entry, f"planned {path_key} entry {name!r}")
|
|
674
|
+
planned += 1
|
|
675
|
+
certify_assembled_path(str(asm.resolve()))
|
|
676
|
+
mode = "plugin-runtime" if PLUGIN_RUNTIME is not None else ("installed" if is_installed_package(repo) else "clone")
|
|
677
|
+
print(
|
|
678
|
+
f"[meta-bridge-state] install preflight ok (mode={mode}, {planned} planned entries certified, "
|
|
679
|
+
f"mcp={desired_mcp(repo)['command']}, statusline={desired_statusline(repo)['command']}, asm={asm})"
|
|
680
|
+
)
|
|
530
681
|
|
|
531
682
|
|
|
532
683
|
def uninstall() -> None:
|
|
@@ -667,6 +818,7 @@ def main() -> int:
|
|
|
667
818
|
choices=[
|
|
668
819
|
"prepare",
|
|
669
820
|
"apply",
|
|
821
|
+
"preflight-install",
|
|
670
822
|
"preflight-uninstall",
|
|
671
823
|
"uninstall",
|
|
672
824
|
"assembled-path",
|
|
@@ -678,6 +830,7 @@ def main() -> int:
|
|
|
678
830
|
)
|
|
679
831
|
parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
|
|
680
832
|
parser.add_argument("--asm", default=None, type=Path)
|
|
833
|
+
parser.add_argument("--plugin-runtime", default=None, type=Path)
|
|
681
834
|
args = parser.parse_args()
|
|
682
835
|
repo = args.repo.resolve()
|
|
683
836
|
# The live artifact always lives under the XDG data dir — dev clone and
|
|
@@ -688,10 +841,13 @@ def main() -> int:
|
|
|
688
841
|
default_asm = xdg_data / "entwurf" / "meta-bridge" / ".assembled"
|
|
689
842
|
asm = (args.asm or default_asm).resolve()
|
|
690
843
|
try:
|
|
844
|
+
set_plugin_runtime(args.plugin_runtime)
|
|
691
845
|
if args.command == "prepare":
|
|
692
846
|
prepare(repo, asm)
|
|
693
847
|
elif args.command == "apply":
|
|
694
848
|
apply(repo, asm)
|
|
849
|
+
elif args.command == "preflight-install":
|
|
850
|
+
preflight_install(repo, asm)
|
|
695
851
|
elif args.command == "preflight-uninstall":
|
|
696
852
|
preflight_uninstall()
|
|
697
853
|
elif args.command == "uninstall":
|
|
@@ -157,10 +157,8 @@
|
|
|
157
157
|
"claim": "CODEX-SEAT-TITLE-BECOMES-DELIVERY-TARGET",
|
|
158
158
|
"title": "the caller's own garden address is replaced by the thread id the PANE TITLE matched, so a forgeable title decides who the fresh sibling is told to call back to. The sibling's first message would then be delivered against an identity nobody vouched for — the exact Hard Rule 16 crossing this lane is built to make impossible: the title is a placement input and may never become an address",
|
|
159
159
|
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
160
|
-
"find": ["\t\t\tcallerGardenId
|
|
161
|
-
"replace": [
|
|
162
|
-
"\t\t\tcallerGardenId: params.callerNativeSessionId ?? params.callerGardenId,\n\t\t\tnonce,\n\t\t}),"
|
|
163
|
-
],
|
|
160
|
+
"find": ["\t\t\tcallerGardenId,\n\t\t\tnonce,\n\t\t}),"],
|
|
161
|
+
"replace": ["\t\t\tcallerGardenId: params.callerNativeSessionId ?? callerGardenId,\n\t\t\tnonce,\n\t\t}),"],
|
|
164
162
|
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
165
163
|
"timeoutSeconds": 120,
|
|
166
164
|
"signature": "[QK:CODEX-SEAT-TITLE-BECOMES-DELIVERY-TARGET]",
|
|
@@ -203,12 +201,12 @@
|
|
|
203
201
|
},
|
|
204
202
|
{
|
|
205
203
|
"claim": "CODEX-CALLER-PREFLIGHT-SURFACE-WIRED",
|
|
206
|
-
"title": "the
|
|
207
|
-
"subject": "
|
|
204
|
+
"title": "the shared dispatcher stops running the caller-side preflight, so a Codex caller with no `thread-id` in its terminal title gets the generic `unresolved` seat refusal instead of the capability repair — and the MCP surface, its only supplier, cannot tell the two apart",
|
|
205
|
+
"subject": "pi-extensions/lib/fresh-call-dispatch.ts",
|
|
208
206
|
"find": [
|
|
209
|
-
"\t\
|
|
207
|
+
"\t\ttargetMissing === null && request.callerNativeSessionId !== undefined && request.placement === undefined\n\t\t\t? codexCallerFreshPreflight(env)\n\t\t\t: null;"
|
|
210
208
|
],
|
|
211
|
-
"replace": ["\t\
|
|
209
|
+
"replace": ["\t\tnull;"],
|
|
212
210
|
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
213
211
|
"timeoutSeconds": 120,
|
|
214
212
|
"signature": "[QK:CODEX-CALLER-PREFLIGHT-SURFACE-WIRED]",
|