@junghanacs/entwurf 0.21.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 +3 -2
- package/BASELINE.md +3 -1
- package/CHANGELOG.md +658 -0
- package/DELIVERY.md +167 -29
- package/README.md +104 -14
- package/VERIFY.md +73 -11
- package/docs/external-mcp-host.md +16 -6
- package/docs/setup-clean-host.md +73 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- 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 +289 -57
- 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/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- 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 +171 -263
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -31
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
- 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/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- 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 +257 -304
- package/run.sh +522 -10
- package/scripts/check-codex-app-server-launch.ts +445 -0
- 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-entwurf-v2-production.ts +42 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +19 -5
- 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-launch-tmux.ts +331 -35
- 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-app-server-launch.sh +275 -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/codex-socket-path.ts +33 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +121 -0
- 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 +13 -5
- package/scripts/lib/codex-fresh-source-receipts.ts +29 -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-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +334 -0
- 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 +184 -25
- 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/mutants/v2-surface.json +22 -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-codex-config-state.sh +192 -3
- package/scripts/smoke-codex-fresh-live.ts +277 -37
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-setup-verdict.sh +13 -11
|
@@ -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"
|
|
@@ -39,7 +39,6 @@ export interface CodexInstructionInput {
|
|
|
39
39
|
finalToken: string;
|
|
40
40
|
callerGid: string;
|
|
41
41
|
piModel: string;
|
|
42
|
-
scratch: string;
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
/** The payload the initial Pi forwards verbatim. It names only Codex's own steps. */
|
|
@@ -47,14 +46,20 @@ export function buildCodexInstruction(input: CodexInstructionInput): string {
|
|
|
47
46
|
return (
|
|
48
47
|
`${input.waitToken}\nThis is the addressed instruction from the initial Pi caller. Execute once, with no shell, ` +
|
|
49
48
|
"terminal, screen inspection, or retry:\n" +
|
|
50
|
-
`1. Call public mcp__entwurf_bridge__entwurf_fresh_call once with backend pi, model ${input.piModel},
|
|
51
|
-
|
|
49
|
+
`1. Call public mcp__entwurf_bridge__entwurf_fresh_call once with backend pi, model ${input.piModel}, ` +
|
|
50
|
+
// NO placement AND NO cwd are both CLAIMS rather than conveniences, and they are two
|
|
51
|
+
// different ones. The seat must come from this Codex's own pane title (#95 lane B); the
|
|
52
|
+
// DIRECTORY must come from this Codex's own record (#95 lane C), which is a fact only
|
|
53
|
+
// because its thread now opens where its pane is rather than where the app-server sits.
|
|
54
|
+
// Omitting the parameter is the whole point — passing one would prove neither.
|
|
55
|
+
'NO cwd parameter and NO placement parameter, and task "After your automatic callback succeeds, answer ACK and ' +
|
|
56
|
+
'stop. Do not open another sibling."\n' +
|
|
52
57
|
"2. Stop and wait for the Pi callback delivered into this Codex thread. Accept only a callback whose body " +
|
|
53
|
-
"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" +
|
|
54
59
|
`3. Only after that match, call public mcp__entwurf_bridge__entwurf_v2 once with target ${input.callerGid}, ` +
|
|
55
60
|
"intent fire-and-forget, wants_reply false, and message:\n" +
|
|
56
61
|
`${input.finalToken}\nPI_LAUNCH_NONCE=<exact launch nonce>\nPI_CALLBACK_NONCE=<same exact nonce>\n` +
|
|
57
|
-
"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" +
|
|
58
63
|
"PI_WINDOW_ID=<exact @window id from the LAUNCH receipt>\nDo not claim completion in prose."
|
|
59
64
|
);
|
|
60
65
|
}
|
|
@@ -71,6 +76,9 @@ export interface InitialPiPhaseOneInput {
|
|
|
71
76
|
export function buildInitialPiPhaseOne(input: InitialPiPhaseOneInput): string {
|
|
72
77
|
return (
|
|
73
78
|
`${input.initialPiWaitToken}\nExecute this public-tool chain exactly once; do not use shell, terminal, screen text, or retries:\n` +
|
|
79
|
+
// NO placement on BOTH legs since #95 D1: an omitted seat is the caller's own session for
|
|
80
|
+
// every backend, so this Codex opens in S beside the Pi that called it — which is what
|
|
81
|
+
// puts its TUI somewhere the app-server's inherited `TMUX` does not point.
|
|
74
82
|
`1. Call entwurf_fresh_call with backend codex, model ${input.codexModel}, cwd ${input.scratch}, NO placement, and task ` +
|
|
75
83
|
`"After your required callback receipt, end the turn and wait passively for the addressed instruction containing ${input.codexWaitToken}. ` +
|
|
76
84
|
`Do not call shell, sleep, terminal, or any tool to wait."\n` +
|
|
@@ -16,6 +16,33 @@ export interface ExpectedSourceToolCall {
|
|
|
16
16
|
arguments: Record<string, unknown>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Compare source-call arguments by MEANING for the one key whose absence already means something,
|
|
21
|
+
* and byte-exactly for every other.
|
|
22
|
+
*
|
|
23
|
+
* `entwurf_v2`'s `wants_reply` is optional and absent IS false — the schema says so
|
|
24
|
+
* (`mcp/entwurf-bridge/src/index.ts:474`, `z.boolean().optional().describe("… (default false)")`)
|
|
25
|
+
* and the decider enforces it (`pi-extensions/lib/entwurf-v2-decider.ts:270`,
|
|
26
|
+
* `input.wantsReply ?? false`). So a model that reads "wants_reply false" in its instruction and
|
|
27
|
+
* simply omits the parameter has obeyed the instruction; nothing about the dispatch differs.
|
|
28
|
+
*
|
|
29
|
+
* `[측정 2026-09-16]` this cost a LIVE run at assertion 43: the initial Pi called `entwurf_v2`
|
|
30
|
+
* with byte-identical `target`, `intent` and `message` and no `wants_reply` key, and the
|
|
31
|
+
* `isDeepStrictEqual` oracle called it drift. The gate was reading a serialization shape where
|
|
32
|
+
* its claim is about the dispatch. Only THIS key is normalized — a `wants_reply: true` still
|
|
33
|
+
* mismatches an expected `false`, and `target`/`intent`/`message`/`placement`/`cwd` stay exact,
|
|
34
|
+
* because for those a difference is a difference.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeSourceArguments(args: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
if (!("wants_reply" in args)) return { ...args, wants_reply: false };
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Arguments agree once the schema default above is filled in. */
|
|
42
|
+
export function sourceArgumentsMatch(expected: Record<string, unknown>, actual: Record<string, unknown>): boolean {
|
|
43
|
+
return isDeepStrictEqual(normalizeSourceArguments(expected), normalizeSourceArguments(actual));
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
/** Select one source call in its backend/target scope, rejecting drift, errors, and duplicates. */
|
|
20
47
|
export function selectExactSourceToolReceipt(
|
|
21
48
|
receipts: readonly SourceToolReceipt[],
|
|
@@ -32,7 +59,7 @@ export function selectExactSourceToolReceipt(
|
|
|
32
59
|
if (matchingScope.length > 1) throw new Error(`${label}: duplicate source tool calls (${matchingScope.length})`);
|
|
33
60
|
const receipt = matchingScope[0] ?? null;
|
|
34
61
|
if (receipt === null) return null;
|
|
35
|
-
if (!
|
|
62
|
+
if (!sourceArgumentsMatch(expectedArguments, receipt.arguments))
|
|
36
63
|
throw new Error(
|
|
37
64
|
`${label}: source tool arguments differ\nexpected=${JSON.stringify(expectedArguments)}\nactual=${JSON.stringify(receipt.arguments)}`,
|
|
38
65
|
);
|
|
@@ -68,7 +95,7 @@ export function assertExactSourceToolCalls(
|
|
|
68
95
|
if (receipt.status !== "completed") throw new Error(`${label}: ${receipt.toolName} remained ${receipt.status}`);
|
|
69
96
|
const index = remaining.findIndex(
|
|
70
97
|
(candidate) =>
|
|
71
|
-
candidate.toolName === receipt.toolName &&
|
|
98
|
+
candidate.toolName === receipt.toolName && sourceArgumentsMatch(candidate.arguments, receipt.arguments),
|
|
72
99
|
);
|
|
73
100
|
if (index < 0)
|
|
74
101
|
throw new Error(`${label}: unexpected source call ${receipt.toolName} ${JSON.stringify(receipt.arguments)}`);
|
|
@@ -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":
|