@junghanacs/entwurf 0.23.0 → 0.24.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 -1
- package/BASELINE.md +2 -0
- package/CHANGELOG.md +307 -0
- package/FAQ.md +47 -0
- package/README.md +5 -3
- package/VERIFY.md +43 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +97 -18
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -1
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/package.json +15 -13
- package/pi-extensions/entwurf-control.ts +56 -0
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +20 -10
- package/pi-extensions/lib/acp/context.ts +30 -10
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/session-store.ts +2 -2
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/callback-env.ts +78 -0
- package/pi-extensions/lib/compaction-send-guard.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +56 -22
- package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
- package/pi-extensions/lib/herdr-placement.ts +13 -4
- package/pi-extensions/lib/mux-fresh-call.ts +23 -8
- package/pi-extensions/meta-bridge-omp.ts +35 -48
- package/run.sh +327 -246
- package/scripts/check-acp-backend-preflight.ts +117 -9
- package/scripts/check-acp-carrier-augment.ts +6 -4
- package/scripts/check-acp-event-mapper.ts +17 -6
- package/scripts/check-acp-prompt-builder.ts +17 -9
- package/scripts/check-acp-prompt-lifecycle.ts +23 -6
- package/scripts/check-acp-session-reuse.ts +64 -51
- package/scripts/check-acp-session-store.ts +31 -8
- package/scripts/check-acp-stop-reason.ts +20 -2
- package/scripts/check-acp-stream-hooks.ts +22 -5
- package/scripts/check-acp-usage-accounting.ts +42 -18
- package/scripts/check-entwurf-bridge-boot.ts +16 -1
- package/scripts/check-fresh-call-dispatch.ts +4 -5
- package/scripts/check-gate-qualification.ts +45 -5
- package/scripts/check-herdr-activation.ts +73 -0
- package/scripts/check-herdr-fresh-call.ts +22 -6
- package/scripts/check-herdr-sandbox.ts +14 -2
- package/scripts/check-install-container.sh +1 -1
- package/scripts/check-pi-launch.ts +361 -0
- package/scripts/check-release-gate-outcomes.ts +7 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/mutation-qualify.ts +1 -1
- package/scripts/mutants/acp-backend-preflight.json +20 -0
- package/scripts/mutants/bridge-boot-resume.json +26 -0
- package/scripts/mutants/herdr-activation.json +16 -0
- package/scripts/mutants/herdr-fresh-call.json +11 -0
- package/scripts/mutants/herdr-plugin-build.json +1 -1
- package/scripts/mutants/mux-fresh-call.json +21 -8
- package/scripts/mutants/omp-fresh.json +5 -9
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +23 -5
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
- package/scripts/smoke-herdr-raw-install-live.sh +877 -0
- package/scripts/smoke-omp-fresh-live.ts +4 -2
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-provider-state.sh +2 -2
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -391
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-herdr-raw-install-live — the FIRST USER PATH for the Herdr plugin (#118 홉 1, H1-4, H1-5).
|
|
3
|
+
#
|
|
4
|
+
# WHAT THIS IS THE ONLY EVIDENCE FOR. Every other herdr gate in this repository proves the
|
|
5
|
+
# plugin against a source we control: `check-herdr-plugin-build` drives a herdr-stub, and
|
|
6
|
+
# `smoke-herdr-plugin-build-live` redirects the product remote to a local bare clone with
|
|
7
|
+
# git `insteadOf` and asserts `identity.kind === "herdr-checkout"`. Both are honest about
|
|
8
|
+
# what they are, and neither has ever watched the plugin acquire its runtime from npm on a
|
|
9
|
+
# machine that has never seen this repository. VERIFY.md:61 says a source switch is a
|
|
10
|
+
# re-proof, and the production lock has said `npm` since dd84ac0 with that re-proof's
|
|
11
|
+
# acquisition axis still empty. Cells [1]–[5] close that one axis.
|
|
12
|
+
#
|
|
13
|
+
# THEN THE PATH KEEPS GOING, because an install is not a use. Cells [6]–[9] stay in the SAME
|
|
14
|
+
# container and ask what a user asks next: the runtime those bytes became is run (compiled
|
|
15
|
+
# entry, three executable bins, a real `check-bridge` printing its verb set); the pi that
|
|
16
|
+
# this plugin WIRED is started with no `-e` and no model turn, to see whether it becomes a
|
|
17
|
+
# garden citizen with a record and a control socket; `entwurf peer-facts` and the plugin's
|
|
18
|
+
# own status fan are asked about that citizen with neither side stubbed; and the shipped
|
|
19
|
+
# teardown deletes the runtime it is executing from and then comes back. Those are
|
|
20
|
+
# VERIFY.md:102's installed-runtime and deactivation rows, re-proved against the npm source
|
|
21
|
+
# rather than against a fixture.
|
|
22
|
+
#
|
|
23
|
+
# AT A REF WHOSE LOCK NAMES `herdr-checkout`, the acquisition axis reports a NAMED SKIP and
|
|
24
|
+
# the rest of the journey still runs. That lock is the candidate carrier a cut legitimately
|
|
25
|
+
# sits on (0.23.0 did, until dd84ac0 pinned npm), so a red there would say the install is
|
|
26
|
+
# broken when what is true is that this ref does not lock npm. What IS asserted at both lock
|
|
27
|
+
# kinds is that the runtime came from the source the ref locks.
|
|
28
|
+
#
|
|
29
|
+
# WHAT IT STILL DOES NOT CLOSE, kept explicit so a green run cannot be read as more than it
|
|
30
|
+
# is: swap / torn-swap recovery (owned by `smoke-herdr-plugin-build-live` cells 3–4 on the
|
|
31
|
+
# checkout carrier, and unmeasured on the npm one), the package-consumer proof, and the
|
|
32
|
+
# status fan drawing a citizen as a ROW — that needs `placement.kind === "herdr-pane"`, so
|
|
33
|
+
# the pi session must live in a herdr pane a session reference joins, and a headless
|
|
34
|
+
# container has no panes. Those stay where #118's table puts them.
|
|
35
|
+
#
|
|
36
|
+
# SCAFFOLDING VS PRODUCT FACE (Hard Rule 17). pi, herdr and Claude Code are installed in the
|
|
37
|
+
# IMAGE BUILD, by this harness, for this one container. Entwurf's own setup and package
|
|
38
|
+
# install none of them, and nothing here changes that: the product face under test begins at
|
|
39
|
+
# `herdr integration install pi` and ends at the plugin's own activation. herdr itself is
|
|
40
|
+
# fetched by `scripts/install-herdr-ci.sh` against `scripts/fixtures/herdr-supply.json` — the
|
|
41
|
+
# same digest-before-chmod path CI uses, and the only place a herdr version or digest lives.
|
|
42
|
+
#
|
|
43
|
+
# NO HOST BLEED. No socket, config, cache or repo is mounted. The runner arrives on stdin, so
|
|
44
|
+
# the container depends on no file from this checkout. There is no `insteadOf`: the remote is
|
|
45
|
+
# the public one a user would type.
|
|
46
|
+
#
|
|
47
|
+
# THE FIRST MEASUREMENT IS THE POINT. Herdr's plugin registration talks to a running herdr
|
|
48
|
+
# server and falls back to `persist_plugin_offline` only when the send fails as a CONNECTION
|
|
49
|
+
# error (herdr src/cli/plugin.rs:914-981,1016-1021 @ c77af189 — 0.9.0 source, 0.9.1 unmeasured).
|
|
50
|
+
# A container has no server. Whether that fallback carries 0.9.1 is exactly what this smoke
|
|
51
|
+
# asks first, and a "no" is an INVESTIGATION RESULT for #118, never a completion. `--yes` is
|
|
52
|
+
# passed always: without it a non-interactive stdin is exit 2 by design (`:189`), and
|
|
53
|
+
# mistaking that for a missing server is the misdiagnosis this comment exists to prevent.
|
|
54
|
+
#
|
|
55
|
+
# Without LIVE=1 it SKIPs by name. Docker absent is a SKIP that ENTWURF_REQUIRE_DOCKER=1 turns
|
|
56
|
+
# red, the same shape check-install-container uses. Neither is a pass.
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
|
|
59
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
60
|
+
while [ -L "$SOURCE" ]; do
|
|
61
|
+
DIR="$(cd -P -- "$(dirname -- "$SOURCE")" && pwd)"
|
|
62
|
+
TARGET="$(readlink "$SOURCE")"
|
|
63
|
+
case "$TARGET" in
|
|
64
|
+
/*) SOURCE="$TARGET" ;;
|
|
65
|
+
*) SOURCE="$DIR/$TARGET" ;;
|
|
66
|
+
esac
|
|
67
|
+
done
|
|
68
|
+
HERE="$(cd -P -- "$(dirname -- "$SOURCE")" && pwd)"
|
|
69
|
+
REPO="$(cd -P -- "$HERE/.." && pwd)"
|
|
70
|
+
|
|
71
|
+
case "$REPO" in
|
|
72
|
+
*/node_modules/*)
|
|
73
|
+
echo "entwurf: 'smoke-herdr-raw-install-live' is a dev-clone-only surface — the installed package ships no container harness." >&2
|
|
74
|
+
exit 1
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
|
|
78
|
+
# `--ref` defaults to `main`, not to a pinned SHA: receipt (a) is defined as what a user gets
|
|
79
|
+
# TODAY from the public remote, and a SHA frozen in this file would quietly stop being that.
|
|
80
|
+
# herdr resolves the ref and this gate prints the resolved commit, so the receipt is still exact.
|
|
81
|
+
#
|
|
82
|
+
# It takes a REMOTE REF — a branch or a tag — and NOT an arbitrary commit. Measured on herdr
|
|
83
|
+
# 0.9.1 (2026-09-19): `--ref 11ec0c3` fails as
|
|
84
|
+
# git failed with status exit status: 128: fatal: couldn't find remote ref 11ec0c3
|
|
85
|
+
# because the checkout fetches the ref by name. So a candidate is addressed by its BRANCH and
|
|
86
|
+
# pinned by the resolved_commit this gate prints, which is the honest pairing anyway: the
|
|
87
|
+
# branch says what was asked for, the commit says what arrived.
|
|
88
|
+
REF="main"
|
|
89
|
+
while [ $# -gt 0 ]; do
|
|
90
|
+
case "$1" in
|
|
91
|
+
--ref) REF="${2:?--ref needs a value}"; shift 2 ;;
|
|
92
|
+
*) echo "[smoke-herdr-raw-install-live] unknown option: $1" >&2; exit 2 ;;
|
|
93
|
+
esac
|
|
94
|
+
done
|
|
95
|
+
|
|
96
|
+
if [ "${LIVE:-0}" != 1 ]; then
|
|
97
|
+
echo "[smoke-herdr-raw-install-live] SKIP — needs LIVE=1 (container build + npm registry + GitHub network)."
|
|
98
|
+
echo "[smoke-herdr-raw-install-live] (this is a SKIP, not a pass: the first-user-path axis was not measured)"
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
|
|
103
|
+
if [ "${ENTWURF_REQUIRE_DOCKER:-0}" = 1 ]; then
|
|
104
|
+
echo "[smoke-herdr-raw-install-live] FAIL — ENTWURF_REQUIRE_DOCKER=1 but no usable Docker daemon." >&2
|
|
105
|
+
exit 1
|
|
106
|
+
fi
|
|
107
|
+
echo "[smoke-herdr-raw-install-live] SKIP — no usable Docker daemon on this host."
|
|
108
|
+
exit 0
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
PI_SPEC="$(node -p "require('$REPO/package.json').devDependencies['@earendil-works/pi-coding-agent']")"
|
|
112
|
+
# Pinned, not floating: a receipt that cannot say WHICH Claude Code it ran is not
|
|
113
|
+
# reproducible. This is the version measured in the container on 2026-09-19. Claude Code
|
|
114
|
+
# is scaffolding here (Rule 17) — the product face starts at `herdr integration install`.
|
|
115
|
+
CLAUDE_SPEC="2.1.278"
|
|
116
|
+
FLOOR_SPEC="$(node -p "require('$REPO/package.json').engines.node")"
|
|
117
|
+
FLOOR_MAJOR="${FLOOR_SPEC#>=}"; FLOOR_MAJOR="${FLOOR_MAJOR%%.*}"
|
|
118
|
+
HERDR_VERSION="$(node -p "require('$REPO/scripts/fixtures/herdr-supply.json').version")"
|
|
119
|
+
BASE_IMAGE="node:${FLOOR_MAJOR}-bookworm"
|
|
120
|
+
# Tagged by everything the image content depends on, so a changed pin rebuilds instead of
|
|
121
|
+
# silently reusing a stale layer cache.
|
|
122
|
+
IMAGE="entwurf-herdr-raw-install:node${FLOOR_MAJOR}-pi${PI_SPEC}-herdr${HERDR_VERSION}-claude${CLAUDE_SPEC}"
|
|
123
|
+
|
|
124
|
+
echo "[smoke-herdr-raw-install-live] ref=$REF base=$BASE_IMAGE pi=$PI_SPEC herdr=$HERDR_VERSION claude=$CLAUDE_SPEC"
|
|
125
|
+
|
|
126
|
+
CTX="$(mktemp -d -t entwurf-herdr-raw.XXXXXX)"
|
|
127
|
+
trap 'rm -rf "$CTX"' EXIT
|
|
128
|
+
mkdir -p "$CTX/scripts/fixtures"
|
|
129
|
+
# The supply manifest travels WITH its installer: install-herdr-ci.sh resolves the manifest
|
|
130
|
+
# relative to its own parent, and duplicating a digest here is exactly how a pin drifts.
|
|
131
|
+
cp "$REPO/scripts/install-herdr-ci.sh" "$CTX/scripts/install-herdr-ci.sh"
|
|
132
|
+
cp "$REPO/scripts/fixtures/herdr-supply.json" "$CTX/scripts/fixtures/herdr-supply.json"
|
|
133
|
+
|
|
134
|
+
cat > "$CTX/Dockerfile" <<DOCKERFILE_EOF
|
|
135
|
+
FROM ${BASE_IMAGE}
|
|
136
|
+
# git is what herdr shells out to for the remote checkout; curl is what the pinned-digest
|
|
137
|
+
# herdr installer uses. Both are scaffolding, and both are named so the image has no
|
|
138
|
+
# unexplained content.
|
|
139
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates \\
|
|
140
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
141
|
+
RUN npm install -g @earendil-works/pi-coding-agent@${PI_SPEC} @anthropic-ai/claude-code@${CLAUDE_SPEC}
|
|
142
|
+
COPY scripts /scaffold/scripts
|
|
143
|
+
RUN d="\$(bash /scaffold/scripts/install-herdr-ci.sh | tail -1)" \\
|
|
144
|
+
&& install -m 0755 "\$d/herdr" /usr/local/bin/herdr \\
|
|
145
|
+
&& rm -rf "\$d" \\
|
|
146
|
+
&& herdr --version
|
|
147
|
+
USER node
|
|
148
|
+
DOCKERFILE_EOF
|
|
149
|
+
|
|
150
|
+
echo "[smoke-herdr-raw-install-live] building scaffolding image $IMAGE"
|
|
151
|
+
docker build -q -t "$IMAGE" "$CTX" >/dev/null || {
|
|
152
|
+
echo "[smoke-herdr-raw-install-live] FAIL — scaffolding image build failed" >&2; exit 1; }
|
|
153
|
+
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$IMAGE")"
|
|
154
|
+
BASE_DIGEST="$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}<none>{{end}}' "$BASE_IMAGE" 2>/dev/null || echo '<none>')"
|
|
155
|
+
echo "[smoke-herdr-raw-install-live] image id=$IMAGE_ID base=$BASE_IMAGE repoDigest=$BASE_DIGEST"
|
|
156
|
+
echo "[smoke-herdr-raw-install-live] scaffolded: pi@$PI_SPEC herdr@$HERDR_VERSION claude-code@$CLAUDE_SPEC"
|
|
157
|
+
|
|
158
|
+
set +e
|
|
159
|
+
docker run --rm -i \
|
|
160
|
+
--workdir /tmp \
|
|
161
|
+
-e HOME=/home/node \
|
|
162
|
+
-e "REQUESTED_REF=$REF" \
|
|
163
|
+
"$IMAGE" \
|
|
164
|
+
bash -c 'bash -s' <<'CONTAINER_RUNNER_EOF'
|
|
165
|
+
set -uo pipefail
|
|
166
|
+
|
|
167
|
+
fail=0
|
|
168
|
+
ok() { echo " ok $*"; }
|
|
169
|
+
bad() { echo " FAIL $*"; fail=1; }
|
|
170
|
+
|
|
171
|
+
PLUGIN_ID="junghan0611.entwurf"
|
|
172
|
+
REMOTE_SPEC="junghan0611/entwurf/plugins/herdr"
|
|
173
|
+
DATA="$HOME/.local/share"
|
|
174
|
+
STATE="$HOME/.local/state"
|
|
175
|
+
JOURNAL="$DATA/entwurf/herdr-plugin/journal.json"
|
|
176
|
+
ACTIVE="$DATA/entwurf/herdr-plugin/runtime/active"
|
|
177
|
+
LEDGER="$STATE/entwurf/herdr-plugin/activation.json"
|
|
178
|
+
REGISTRY="$HOME/.config/herdr/plugins.json"
|
|
179
|
+
|
|
180
|
+
echo "[container] uid=$(id -u) user=$(id -un) HOME=$HOME"
|
|
181
|
+
echo "[container] $(herdr --version 2>&1 | head -1) | pi $(pi --version 2>&1 | head -1) | claude $(claude --version 2>&1 | head -1)"
|
|
182
|
+
|
|
183
|
+
# ── environment facts: nothing of the host's is reachable ────────────────────
|
|
184
|
+
[ ! -e /home/junghan ] && ok "no host home in the container" || bad "a host home path exists in the container"
|
|
185
|
+
[ -z "$(ls -A "$HOME" 2>/dev/null)" ] && ok "HOME starts empty" || echo " note HOME is not empty at start: $(ls -A "$HOME" | tr '\n' ' ')"
|
|
186
|
+
if git config --get-regexp 'url\..*\.insteadof' >/dev/null 2>&1; then bad "an insteadOf rewrite is configured — this must be the public remote"; else ok "no git insteadOf rewrite (public remote, as a user would type it)"; fi
|
|
187
|
+
|
|
188
|
+
# ── 0. harness first-run state ───────────────────────────────────────────────
|
|
189
|
+
# NOT a shortcut, and not a mkdir. `herdr integration install <agent>` refuses an agent that
|
|
190
|
+
# has never run: it creates `~/.pi/agent/extensions` only when `~/.pi/agent` already exists,
|
|
191
|
+
# and refuses Claude outright unless `~/.claude` is a directory (herdr
|
|
192
|
+
# src/integration/targets.rs:66-78,123-130 @ c77af189). A user reaching this point has started
|
|
193
|
+
# both harnesses at least once; a fresh container has not. So the REAL binaries are driven
|
|
194
|
+
# once, with the cheapest invocation measured to produce that state — no model turn, no login,
|
|
195
|
+
# no network account.
|
|
196
|
+
# `pi --help` → creates ~/.pi/agent (`pi --version` does NOT — measured here)
|
|
197
|
+
# `claude mcp list` → creates ~/.claude and ~/.claude.json
|
|
198
|
+
# (`claude --version` / `--help` do NOT — measured here)
|
|
199
|
+
# This is also a FINDING about the first user path, not only about this harness: the install
|
|
200
|
+
# order a README can promise starts at "run each harness once".
|
|
201
|
+
echo; echo "[0] harness first-run state (real binaries, no model turn)"
|
|
202
|
+
pi --help >/dev/null 2>&1 || true
|
|
203
|
+
claude mcp list >/dev/null 2>&1 || true
|
|
204
|
+
[ -d "$HOME/.pi/agent" ] && ok "pi first-run state exists (~/.pi/agent)" || bad "pi never created ~/.pi/agent — herdr will refuse to integrate it"
|
|
205
|
+
[ -d "$HOME/.claude" ] && ok "claude first-run state exists (~/.claude)" || bad "claude never created ~/.claude — herdr will refuse to integrate it"
|
|
206
|
+
|
|
207
|
+
# ── 1. integrate pi ──────────────────────────────────────────────────────────
|
|
208
|
+
echo; echo "[1] herdr integration install pi"
|
|
209
|
+
herdr integration install pi; rc=$?
|
|
210
|
+
if [ "$rc" -eq 0 ]; then
|
|
211
|
+
ok "integration install pi exit 0"
|
|
212
|
+
else
|
|
213
|
+
bad "integration install pi exit $rc — every later assertion would be a SECOND explanation for this one red"
|
|
214
|
+
echo; echo "smoke-herdr-raw-install-live: FAIL (harness integration, before the product face)"
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
# The pre-plugin baseline for [9]. Captured HERE and not later: everything after this line
|
|
218
|
+
# is the plugin's own writing, so this is the last moment the file still means what the
|
|
219
|
+
# host meant. Absence is a baseline too — `<absent>` below is a file the plugin created,
|
|
220
|
+
# and a teardown must not leave one behind carrying our entry.
|
|
221
|
+
cp "$HOME/.pi/agent/settings.json" /tmp/pi-settings-pre-plugin.json 2>/dev/null || rm -f /tmp/pi-settings-pre-plugin.json
|
|
222
|
+
|
|
223
|
+
# ── 2. THE FIRST MEASUREMENT: plugin install with no herdr server ────────────
|
|
224
|
+
echo; echo "[2] herdr plugin install $REMOTE_SPEC --ref $REQUESTED_REF --yes"
|
|
225
|
+
herdr plugin install "$REMOTE_SPEC" --ref "$REQUESTED_REF" --yes 2>&1 | sed 's/^/ /'
|
|
226
|
+
rc="${PIPESTATUS[0]}"
|
|
227
|
+
echo " → exit $rc"
|
|
228
|
+
if [ "$rc" -ne 0 ]; then
|
|
229
|
+
bad "plugin install exited $rc — see the lines above; this is #118's investigation output, not a completion"
|
|
230
|
+
echo; echo "smoke-herdr-raw-install-live: FAIL (first user path did not complete)"
|
|
231
|
+
exit 1
|
|
232
|
+
fi
|
|
233
|
+
ok "plugin install exit 0 with no herdr server running (the offline-persist path carries 0.9.x)"
|
|
234
|
+
|
|
235
|
+
# ── 3. what the install actually left behind ─────────────────────────────────
|
|
236
|
+
echo; echo "[3] acquisition identity and ledger"
|
|
237
|
+
node -e '
|
|
238
|
+
const fs = require("node:fs");
|
|
239
|
+
const path = require("node:path");
|
|
240
|
+
const [registry, journal, active, ledger, pluginId, requestedRef] = process.argv.slice(1);
|
|
241
|
+
let bad = 0;
|
|
242
|
+
const ok = (m) => console.log(" ok " + m);
|
|
243
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
244
|
+
const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
245
|
+
|
|
246
|
+
const reg = read(registry);
|
|
247
|
+
const entry = (Array.isArray(reg) ? reg : reg.plugins || []).find((e) => e.plugin_id === pluginId);
|
|
248
|
+
if (!entry) { no(`herdr registry has no ${pluginId} entry`); process.exit(1); }
|
|
249
|
+
ok(`herdr registry carries ${pluginId}`);
|
|
250
|
+
const src = entry.source || {};
|
|
251
|
+
console.log(` REF requested=${src.requested_ref ?? requestedRef} resolved_commit=${src.resolved_commit ?? "<none>"}`);
|
|
252
|
+
if (src.resolved_commit) ok("the registry records a resolved commit"); else no("no resolved commit recorded");
|
|
253
|
+
|
|
254
|
+
// The lock the INSTALLED checkout committed — read from herdr managed path, so this assertion
|
|
255
|
+
// follows whatever --ref was asked for instead of trusting the host working tree.
|
|
256
|
+
const managed = src.managed_path || entry.managed_path;
|
|
257
|
+
if (!managed) { no("no managed checkout path recorded"); process.exit(1); }
|
|
258
|
+
const lock = read(path.join(managed, "plugins", "herdr", "runtime-lock.json"));
|
|
259
|
+
// THE PACKAGE NAME COMES FROM THE CHECKOUT, NOT THE LOCK (A8). A `herdr-checkout` lock
|
|
260
|
+
// carries exactly `repository, schemaVersion, source` — npm keys on it are refused by name
|
|
261
|
+
// (herdr-runtime.mjs:317-328) — so `lock.name` is undefined at any ref locking the candidate
|
|
262
|
+
// carrier, and `path.join(active, "node_modules", undefined)` THROWS ERR_INVALID_ARG_TYPE
|
|
263
|
+
// rather than failing an assertion. A receipt that answers a source question with a node
|
|
264
|
+
// stack is the exact failure this smoke exists to refuse. `readCheckoutPackageSpec`
|
|
265
|
+
// (herdr-runtime.mjs:258-272) reads the same manifest for both lock kinds, and for an npm
|
|
266
|
+
// lock it is the very pair `certifyLockCoherence` compares against, so nothing is weakened.
|
|
267
|
+
const checkout = read(path.join(managed, "package.json"));
|
|
268
|
+
const PACKAGE = checkout.name;
|
|
269
|
+
console.log(` LOCK source=${lock.source}${lock.source === "npm" ? ` ${lock.name}@${lock.version}` : ` ${lock.repository}`}`);
|
|
270
|
+
console.log(` CHECKOUT ${checkout.name}@${checkout.version}`);
|
|
271
|
+
if (lock.source === "npm") console.log(` LOCK integrity=${lock.integrity}`);
|
|
272
|
+
|
|
273
|
+
if (!fs.existsSync(journal)) {
|
|
274
|
+
no(`no runtime journal at ${journal}`);
|
|
275
|
+
console.log(" note build.mjs exits 0 WITHOUT writing when the activation set A is empty, so this");
|
|
276
|
+
console.log(" note is what an install with no integrated harness also looks like. Check step [1].");
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
const j = read(journal);
|
|
280
|
+
// Measured key, not a guess. The journal top level is exactly
|
|
281
|
+
// schemaVersion, phase, runtimeRoot, artifactIdentity, previousRuntime.
|
|
282
|
+
const id = j.artifactIdentity;
|
|
283
|
+
if (!id) { no(`no runtime identity in ${journal}: keys ${Object.keys(j)}`); process.exit(1); }
|
|
284
|
+
// The identity is printed in the shape its OWN kind has: an npm identity carries
|
|
285
|
+
// name/version/integrity/digest, a herdr-checkout one carries repository/commit. Printing
|
|
286
|
+
// the npm fields unconditionally spelled `undefined@undefined` for a perfectly well-formed
|
|
287
|
+
// checkout identity, which is a receipt describing a state the host is not in.
|
|
288
|
+
if (id.kind === "npm") {
|
|
289
|
+
console.log(` RUNTIME kind=${id.kind} ${id.name}@${id.version}`);
|
|
290
|
+
console.log(` RUNTIME expectedIntegrity=${id.expectedIntegrity}`);
|
|
291
|
+
console.log(` RUNTIME observedDigest=${id.observedDigest}`);
|
|
292
|
+
} else {
|
|
293
|
+
console.log(` RUNTIME kind=${id.kind} ${id.repository}@${id.commit}`);
|
|
294
|
+
}
|
|
295
|
+
// ASKED AT BOTH LOCK KINDS, because what a ref LOCKED and what the host RECEIVED is the one
|
|
296
|
+
// question a receipt must answer even when the npm axis is not the subject.
|
|
297
|
+
id.kind === lock.source
|
|
298
|
+
? ok(`the runtime was acquired from the source this ref locks (identity.kind === ${id.kind})`)
|
|
299
|
+
: no(`this ref locks ${lock.source} but the runtime identity.kind is ${id.kind}`);
|
|
300
|
+
|
|
301
|
+
if (lock.source === "npm") {
|
|
302
|
+
ok("the committed lock at this ref names npm as the production source");
|
|
303
|
+
id.name === lock.name && id.version === lock.version ? ok(`runtime name@version matches the committed lock (${id.name}@${id.version})`) : no(`runtime ${id.name}@${id.version} != lock ${lock.name}@${lock.version}`);
|
|
304
|
+
id.expectedIntegrity === lock.integrity ? ok("expected integrity is the committed sha512") : no("expected integrity differs from the committed lock");
|
|
305
|
+
// observedDigest is a sha256 CONTENT ADDRESS of the tarball that arrived, not a second copy of
|
|
306
|
+
// the sha512 integrity — the two are different hashes of the same bytes. The sha512 comparison is
|
|
307
|
+
// not repeated here because it cannot be: npmAcquire hashes the fetched tarball and THROWS
|
|
308
|
+
// runtime-artifact-integrity-mismatch before installing anything (herdr-runtime.mjs:924-930), so a
|
|
309
|
+
// journal existing at all is that comparison having passed. What is left to check is that a real
|
|
310
|
+
// digest of real bytes was recorded.
|
|
311
|
+
/^sha256-[0-9a-f]{64}$/.test(id.observedDigest ?? "")
|
|
312
|
+
? ok("a sha256 content address of the FETCHED bytes is recorded (and the sha512 gate upstream let the install proceed)")
|
|
313
|
+
: no(`observedDigest is not a sha256 content address: ${JSON.stringify(id.observedDigest)}`);
|
|
314
|
+
} else {
|
|
315
|
+
// A NAMED SKIP, not a pass and not a red. During a candidate window the committed lock
|
|
316
|
+
// names `herdr-checkout` ON PURPOSE (0.23.0 cut this way before dd84ac0 pinned it), and a
|
|
317
|
+
// red there would say the install is broken when what is true is that this ref does not
|
|
318
|
+
// lock npm. The rest of this smoke still runs; only the acquisition axis stands down.
|
|
319
|
+
console.log(` skip this ref locks ${lock.source}; the npm acquisition axis is not measured at this commit`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The active dir is an npm PREFIX, so the package lands under node_modules/<name>
|
|
323
|
+
// (herdr-runtime.mjs:805-806), not at its root.
|
|
324
|
+
const installedRoot = path.join(active, "node_modules", PACKAGE);
|
|
325
|
+
if (!fs.existsSync(path.join(installedRoot, "package.json"))) {
|
|
326
|
+
no(`no installed runtime at ${installedRoot}`);
|
|
327
|
+
} else {
|
|
328
|
+
const pkg = read(path.join(installedRoot, "package.json"));
|
|
329
|
+
pkg.name === checkout.name && pkg.version === checkout.version
|
|
330
|
+
? ok(`the active runtime on disk is ${pkg.name}@${pkg.version}, the pair this checkout declares`)
|
|
331
|
+
: no(`active runtime is ${pkg.name}@${pkg.version}, not the checkout pair ${checkout.name}@${checkout.version}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!fs.existsSync(ledger)) { no(`no activation ledger at ${ledger}`); process.exit(1); }
|
|
335
|
+
const led = read(ledger);
|
|
336
|
+
const backends = led.activatedBackends;
|
|
337
|
+
JSON.stringify(backends) === JSON.stringify(["pi"]) ? ok(`ledger activatedBackends === ["pi"]`) : no(`ledger activatedBackends === ${JSON.stringify(backends)}`);
|
|
338
|
+
process.exit(bad);
|
|
339
|
+
' "$REGISTRY" "$JOURNAL" "$ACTIVE" "$LEDGER" "$PLUGIN_ID" "$REQUESTED_REF" || fail=1
|
|
340
|
+
|
|
341
|
+
# The pi wiring must point INTO the active runtime, never at a checkout herdr will delete.
|
|
342
|
+
#
|
|
343
|
+
# NAMED, NOT DISCOVERED (A7-1). This used to be `grep -rl entwurf ~/.pi/agent | head -1`
|
|
344
|
+
# followed by a substring grep for the active path: the file was whichever readdir happened
|
|
345
|
+
# to yield first, the JSON was never parsed, and any file merely CONTAINING the active path
|
|
346
|
+
# anywhere satisfied it — a wiring entry of the wrong shape, or under the wrong key, would
|
|
347
|
+
# have read as green. `register-pi-package.py` writes exactly one file at a path it derives
|
|
348
|
+
# rather than searches, so this asks that file by name and compares the entry it wrote.
|
|
349
|
+
PI_FILE="$HOME/.pi/agent/settings.json"
|
|
350
|
+
if [ -f "$PI_FILE" ]; then
|
|
351
|
+
cp "$PI_FILE" /tmp/pi-settings-phase-a.json
|
|
352
|
+
node -e '
|
|
353
|
+
const fs = require("node:fs");
|
|
354
|
+
const path = require("node:path");
|
|
355
|
+
const [settingsPath, active, registry, pluginId] = process.argv.slice(1);
|
|
356
|
+
let bad = 0;
|
|
357
|
+
const ok = (m) => console.log(" ok " + m);
|
|
358
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
359
|
+
const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
360
|
+
|
|
361
|
+
const reg = read(registry);
|
|
362
|
+
const entry = (Array.isArray(reg) ? reg : reg.plugins || []).find((e) => e.plugin_id === pluginId);
|
|
363
|
+
const managed = entry?.source?.managed_path || entry?.managed_path;
|
|
364
|
+
// The checkout manifest, for the reason spelled out in [3] (A8): a herdr-checkout lock has
|
|
365
|
+
// no `name`, and joining undefined throws instead of failing.
|
|
366
|
+
const checkout = read(path.join(managed, "package.json"));
|
|
367
|
+
// register() writes the CANONICAL ABSOLUTE path of the package root and nothing else
|
|
368
|
+
// (register-pi-package.py:244-282). The stable `active` dir is a real directory that a
|
|
369
|
+
// generation swap renames into place, never a symlink (herdr-runtime.mjs:15,744,1039), so
|
|
370
|
+
// the resolved string is this join and a generation path here would be the bug: it would
|
|
371
|
+
// strand the wiring the next time the runtime is swapped.
|
|
372
|
+
const expected = path.join(active, "node_modules", checkout.name);
|
|
373
|
+
|
|
374
|
+
const settings = read(settingsPath);
|
|
375
|
+
const packages = settings.packages;
|
|
376
|
+
console.log(` PI-WIRING ${path.basename(settingsPath)} packages=${JSON.stringify(packages)}`);
|
|
377
|
+
if (!Array.isArray(packages)) { no(`packages is ${JSON.stringify(packages)}, not an array`); process.exit(1); }
|
|
378
|
+
packages.filter((e) => e === expected).length === 1
|
|
379
|
+
? ok(`pi packages[] carries exactly one entry equal to the active runtime root (${expected})`)
|
|
380
|
+
: no(`no single packages[] entry equals ${expected}`);
|
|
381
|
+
// The substring test did catch one real thing — a SECOND, differently shaped entwurf
|
|
382
|
+
// registration — so that is kept as its own assertion rather than lost to the tightening.
|
|
383
|
+
packages.filter((e) => typeof e === "string" && e.includes("entwurf")).length === 1
|
|
384
|
+
? ok("no second entwurf-shaped packages[] entry survives beside it")
|
|
385
|
+
: no(`entwurf-shaped packages[] entries: ${JSON.stringify(packages.filter((e) => typeof e === "string" && e.includes("entwurf")))}`);
|
|
386
|
+
process.exit(bad);
|
|
387
|
+
' "$PI_FILE" "$ACTIVE" "$REGISTRY" "$PLUGIN_ID" || fail=1
|
|
388
|
+
else
|
|
389
|
+
bad "no pi settings at ${PI_FILE#$HOME/} — register-pi-package.py writes exactly that path, so its absence is the wiring never happening"
|
|
390
|
+
PI_FILE=""
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
# ── 4. add Claude, reinstall, and watch the ledger widen ─────────────────────
|
|
394
|
+
echo; echo "[4] herdr integration install claude, then reinstall (refresh)"
|
|
395
|
+
herdr integration install claude; rc=$?
|
|
396
|
+
[ "$rc" -eq 0 ] && ok "integration install claude exit 0" || bad "integration install claude exit $rc"
|
|
397
|
+
herdr plugin install "$REMOTE_SPEC" --ref "$REQUESTED_REF" --yes 2>&1 | sed 's/^/ /'
|
|
398
|
+
rc="${PIPESTATUS[0]}"
|
|
399
|
+
echo " → exit $rc"
|
|
400
|
+
[ "$rc" -eq 0 ] && ok "reinstall (refresh) exit 0" || bad "reinstall exited $rc"
|
|
401
|
+
|
|
402
|
+
echo; echo "[5] the {pi} → {pi, claude-code} transition"
|
|
403
|
+
node -e '
|
|
404
|
+
const fs = require("node:fs");
|
|
405
|
+
const path = require("node:path");
|
|
406
|
+
const [ledger, claudeUserConfig, active] = process.argv.slice(1);
|
|
407
|
+
let bad = 0;
|
|
408
|
+
const ok = (m) => console.log(" ok " + m);
|
|
409
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
410
|
+
if (!fs.existsSync(ledger)) { no(`no activation ledger at ${ledger}`); process.exit(1); }
|
|
411
|
+
if (!fs.existsSync(claudeUserConfig)) { no(`no Claude user config at ${claudeUserConfig}`); process.exit(1); }
|
|
412
|
+
const led = JSON.parse(fs.readFileSync(ledger, "utf8"));
|
|
413
|
+
const backends = led.activatedBackends;
|
|
414
|
+
JSON.stringify(backends) === JSON.stringify(["pi", "claude-code"])
|
|
415
|
+
? ok(`ledger activatedBackends === ["pi","claude-code"]`)
|
|
416
|
+
: no(`ledger activatedBackends === ${JSON.stringify(backends)}`);
|
|
417
|
+
const cfg = JSON.parse(fs.readFileSync(claudeUserConfig, "utf8"));
|
|
418
|
+
const servers = cfg.mcpServers || {};
|
|
419
|
+
// THE LITERAL KEY, not a substring (A7-2). `meta-bridge-state.py:495` sets exactly
|
|
420
|
+
// mcpServers.entwurf-bridge, so `k.includes("entwurf")` was strictly weaker than the name
|
|
421
|
+
// the writer uses: a drifted or renamed key would have satisfied it while Claude looked for
|
|
422
|
+
// the one that is no longer there. The substring is still asked, as a SEPARATE question —
|
|
423
|
+
// is there a second entwurf-shaped server beside ours — which is the thing it did catch.
|
|
424
|
+
const OWNER_KEY = "entwurf-bridge";
|
|
425
|
+
const keys = Object.keys(servers);
|
|
426
|
+
console.log(` CLAUDE-MCP keys=${JSON.stringify(keys)} command=${JSON.stringify(servers[OWNER_KEY]?.command)}`);
|
|
427
|
+
keys.filter((k) => k === OWNER_KEY).length === 1
|
|
428
|
+
? ok(`exactly one Claude MCP entry under the owner key the writer uses (${OWNER_KEY})`)
|
|
429
|
+
: no(`no single mcpServers.${OWNER_KEY} — keys are ${JSON.stringify(keys)}`);
|
|
430
|
+
keys.filter((k) => k !== OWNER_KEY && k.includes("entwurf")).length === 0
|
|
431
|
+
? ok("no second entwurf-shaped MCP server beside it")
|
|
432
|
+
: no(`extra entwurf-shaped MCP keys: ${JSON.stringify(keys.filter((k) => k !== OWNER_KEY && k.includes("entwurf")))}`);
|
|
433
|
+
// And it points into the ACTIVE runtime bin dir — `plugin_bin` (meta-bridge-state.py:343-345)
|
|
434
|
+
// writes that absolute path, and a bare name here would be a different contract silently.
|
|
435
|
+
servers[OWNER_KEY]?.command === path.join(active, "node_modules", ".bin", OWNER_KEY)
|
|
436
|
+
? ok("the MCP command is the bin inside the active runtime, by absolute path")
|
|
437
|
+
: no(`MCP command is ${JSON.stringify(servers[OWNER_KEY]?.command)}, not ${path.join(active, "node_modules", ".bin", OWNER_KEY)}`);
|
|
438
|
+
process.exit(bad);
|
|
439
|
+
' "$LEDGER" "$HOME/.claude.json" "$ACTIVE" || fail=1
|
|
440
|
+
|
|
441
|
+
if [ -n "$PI_FILE" ]; then
|
|
442
|
+
if diff -q /tmp/pi-settings-phase-a.json "$PI_FILE" >/dev/null 2>&1; then
|
|
443
|
+
ok "pi wiring is byte-identical across the reinstall"
|
|
444
|
+
else
|
|
445
|
+
bad "pi wiring changed across the reinstall:"; diff /tmp/pi-settings-phase-a.json "$PI_FILE" | sed 's/^/ /'
|
|
446
|
+
fi
|
|
447
|
+
fi
|
|
448
|
+
|
|
449
|
+
# ── 6. the INSTALLED runtime, MEASURED instead of implied (#118 H1-5) ────────
|
|
450
|
+
# A green [2] already implies this: `certifyInstalledTree` refuses an install whose tree
|
|
451
|
+
# lacks the compiled entry, the three bins, or a `check-bridge` that exits 0
|
|
452
|
+
# (herdr-runtime.mjs:824-843). Implication is not observation, and VERIFY.md:102 lists
|
|
453
|
+
# "the installed runtime (name@version, compiled entry, three executable bins, a real
|
|
454
|
+
# check-bridge)" as its own re-proof row against THIS source. A row closed by inference
|
|
455
|
+
# cannot print the verb set it claims, so these lines run the npm-acquired bytes and print
|
|
456
|
+
# what they answered. The bin set is REQUIRED_BINS (herdr-runtime.mjs:165) — the package
|
|
457
|
+
# declares six, and the three named there are the ones an activation certifies.
|
|
458
|
+
echo; echo "[6] the installed runtime: compiled entry, three bins, a real check-bridge"
|
|
459
|
+
node -e '
|
|
460
|
+
const fs = require("node:fs");
|
|
461
|
+
const path = require("node:path");
|
|
462
|
+
const { spawnSync } = require("node:child_process");
|
|
463
|
+
const [active, registry, pluginId] = process.argv.slice(1);
|
|
464
|
+
let bad = 0;
|
|
465
|
+
const ok = (m) => console.log(" ok " + m);
|
|
466
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
467
|
+
const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
468
|
+
|
|
469
|
+
const reg = read(registry);
|
|
470
|
+
const entry = (Array.isArray(reg) ? reg : reg.plugins || []).find((e) => e.plugin_id === pluginId);
|
|
471
|
+
const managed = entry?.source?.managed_path || entry?.managed_path;
|
|
472
|
+
// Checkout manifest, not the lock (A8): a herdr-checkout lock carries no `name`.
|
|
473
|
+
const checkout = read(path.join(managed, "package.json"));
|
|
474
|
+
const root = path.join(active, "node_modules", checkout.name);
|
|
475
|
+
|
|
476
|
+
// The exact constant the bootstrap uses, spelled out so a drift in either place is visible
|
|
477
|
+
// here rather than absorbed: mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js.
|
|
478
|
+
const COMPILED_ENTRY = path.join("mcp", "entwurf-bridge", "dist", "mcp", "entwurf-bridge", "src", "index.js");
|
|
479
|
+
const entryPath = path.join(root, COMPILED_ENTRY);
|
|
480
|
+
if (fs.existsSync(entryPath)) ok(`compiled bridge entry present (${COMPILED_ENTRY})`);
|
|
481
|
+
else no(`no compiled bridge entry at ${entryPath} — the npm artifact shipped source without its dist`);
|
|
482
|
+
|
|
483
|
+
// Executable, not merely present. A bin entry npm did not chmod is the failure mode
|
|
484
|
+
// `postinstall-chmod` exists for, and `fs.existsSync` would call it green.
|
|
485
|
+
const binDir = path.join(active, "node_modules", ".bin");
|
|
486
|
+
for (const name of ["entwurf", "entwurf-bridge", "entwurf-statusline"]) {
|
|
487
|
+
const bin = path.join(binDir, name);
|
|
488
|
+
try { fs.accessSync(bin, fs.constants.X_OK); ok(`bin is executable: ${name}`); }
|
|
489
|
+
catch (err) { no(`bin not executable: ${bin} (${err.code})`); }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// The REAL subcommand, from the installed bin, under node_modules — which is the branch of
|
|
493
|
+
// start.sh that runs the prebuilt dist. Its own oracle is an EXACT set (run.sh:5187), so a
|
|
494
|
+
// zero exit here is the artifact listing all seven garden verbs and no eighth.
|
|
495
|
+
const run = spawnSync(path.join(binDir, "entwurf"), ["check-bridge"], { encoding: "utf8" });
|
|
496
|
+
const said = `${run.stdout ?? ""}${run.stderr ?? ""}`;
|
|
497
|
+
// [a-z0-9_] and not [a-z_]: the first draft of this line printed `entwurf_v` for entwurf_v2
|
|
498
|
+
// and the cell still went green, because the EXIT CODE is the oracle and the verb list is
|
|
499
|
+
// the receipt. A receipt that misspells what it witnessed is the failure this cell exists
|
|
500
|
+
// to prevent one directory over, so it is spelled correctly here.
|
|
501
|
+
const verbs = (said.match(/entwurf_[a-z0-9_]+/g) ?? []).filter((v, i, a) => a.indexOf(v) === i).sort();
|
|
502
|
+
// A SECOND, INDEPENDENT COPY OF THE SET, ON PURPOSE (A5 D1). The exit code alone used to be
|
|
503
|
+
// the oracle here, which left the printed list as decoration a parser bug could quietly
|
|
504
|
+
// corrupt — and did, see the regex note just above. The package under test carries the
|
|
505
|
+
// expectation of run.sh:5187 in its own bytes, so reading the answer from there would let
|
|
506
|
+
// the artifact grade itself. The literal below is a copy that must AGREE with the shipped
|
|
507
|
+
// one; the two disagreeing is the signal, and a verb genuinely added upstream reddens this
|
|
508
|
+
// smoke until somebody says so here too.
|
|
509
|
+
const EXPECTED_VERBS = [
|
|
510
|
+
"entwurf_fresh_call",
|
|
511
|
+
"entwurf_inbox_read",
|
|
512
|
+
"entwurf_peers",
|
|
513
|
+
"entwurf_register_native",
|
|
514
|
+
"entwurf_resume_call",
|
|
515
|
+
"entwurf_self",
|
|
516
|
+
"entwurf_v2",
|
|
517
|
+
];
|
|
518
|
+
console.log(` VERBS ${verbs.join(",") || "<none>"}`);
|
|
519
|
+
if (run.status === 0) ok(`entwurf check-bridge exit 0 from the installed bin (${verbs.length} verbs listed)`);
|
|
520
|
+
else no(`entwurf check-bridge exit ${run.status}: ${said.trim().slice(0, 400)}`);
|
|
521
|
+
JSON.stringify(verbs) === JSON.stringify(EXPECTED_VERBS)
|
|
522
|
+
? ok(`the receipt names EXACTLY the seven garden verbs (${EXPECTED_VERBS.length}), parsed from what the installed bin answered`)
|
|
523
|
+
: no(`verb set mismatch — want ${JSON.stringify(EXPECTED_VERBS)} got ${JSON.stringify(verbs)}`);
|
|
524
|
+
process.exit(bad);
|
|
525
|
+
' "$ACTIVE" "$REGISTRY" "$PLUGIN_ID" || fail=1
|
|
526
|
+
|
|
527
|
+
# ── 7. THE USE PATH: the wired runtime makes a citizen, with no model turn ───
|
|
528
|
+
# What [1]–[5] proved is that bytes landed. #118 H1-4 asks the next question, which is the
|
|
529
|
+
# one a user actually has: does the pi that this plugin wired come up as a GARDEN CITIZEN?
|
|
530
|
+
#
|
|
531
|
+
# NO `-e` AND NO `--no-extensions`. smoke-resident-garden-guard loads this checkout's
|
|
532
|
+
# extension explicitly, because its subject is the checkout. The subject HERE is the
|
|
533
|
+
# wiring — the user-scope packages[] entry that `herdr plugin install` wrote — so the
|
|
534
|
+
# extension has to arrive the way it arrives for a user, or the cell proves nothing about
|
|
535
|
+
# the install. A bare `pi` is the whole point.
|
|
536
|
+
#
|
|
537
|
+
# ZERO TOKENS, and no provider argument either. `--mode rpc` + one `get_state` is the
|
|
538
|
+
# 0-token shape smoke-resident-garden-guard's BIRTH cell uses; the record and the socket
|
|
539
|
+
# exist only WHILE pi is alive, which is why the snapshot is taken from inside the driver
|
|
540
|
+
# between ordered commands rather than after the process ends. A provider/model pair is NOT
|
|
541
|
+
# passed: measured here 2026-09-19, a bogus one (`--provider zzz`) exits 1 with
|
|
542
|
+
# `Unknown provider` BEFORE session_start, and omitting the pair lets pi resolve the
|
|
543
|
+
# default the extension itself registers — so this cell needs no vendor account, no auth
|
|
544
|
+
# file, and no model name that could rot.
|
|
545
|
+
echo; echo "[7] pi --entwurf-control on the plugin's wiring: record + control socket, 0 tokens"
|
|
546
|
+
cat > /tmp/use-path-drive.mjs <<'DRIVER_EOF'
|
|
547
|
+
import { spawn } from "node:child_process";
|
|
548
|
+
import fs from "node:fs";
|
|
549
|
+
import path from "node:path";
|
|
550
|
+
import { spawnSync } from "node:child_process";
|
|
551
|
+
|
|
552
|
+
const HOME = process.env.HOME;
|
|
553
|
+
const SOCKET_DIR = path.join(HOME, ".pi", "entwurf-control");
|
|
554
|
+
const META_DIR = path.join(HOME, ".pi", "agent", "meta-sessions");
|
|
555
|
+
const ENTWURF_BIN = process.argv[2];
|
|
556
|
+
// The ENTRY POINT is an argument, because there are two of them and they are different
|
|
557
|
+
// claims. `pi --entwurf-control` proves the WIRING; `entwurf pi` proves the LAUNCHER a user
|
|
558
|
+
// received from npm. One program, so neither claim drifts away from the other.
|
|
559
|
+
const LABEL = process.argv[3];
|
|
560
|
+
const [COMMAND, ...COMMAND_ARGS] = process.argv.slice(4);
|
|
561
|
+
|
|
562
|
+
let bad = 0;
|
|
563
|
+
const ok = (m) => console.log(" ok " + m);
|
|
564
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
565
|
+
const ls = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
|
|
566
|
+
|
|
567
|
+
console.log(` ENTRY ${LABEL}: ${COMMAND} ${COMMAND_ARGS.join(" ")}`);
|
|
568
|
+
const child = spawn(COMMAND, COMMAND_ARGS, { stdio: ["pipe", "pipe", "pipe"] });
|
|
569
|
+
let stderr = "";
|
|
570
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
571
|
+
|
|
572
|
+
let buf = "";
|
|
573
|
+
let settled = false;
|
|
574
|
+
const extensionErrors = [];
|
|
575
|
+
let agentStartSeen = false;
|
|
576
|
+
|
|
577
|
+
const done = (code) => {
|
|
578
|
+
if (settled) return;
|
|
579
|
+
settled = true;
|
|
580
|
+
try { child.stdin.end(); } catch { /* best-effort */ }
|
|
581
|
+
setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* best-effort */ } process.exit(code); }, 300);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const observe = (sessionId) => {
|
|
585
|
+
const sockets = ls(SOCKET_DIR).filter((f) => f.endsWith(".sock"));
|
|
586
|
+
const recordFiles = ls(META_DIR).filter((f) => f.endsWith(".meta.json"));
|
|
587
|
+
const records = recordFiles.map((f) => {
|
|
588
|
+
try { return JSON.parse(fs.readFileSync(path.join(META_DIR, f), "utf8")); } catch { return null; }
|
|
589
|
+
}).filter((r) => r !== null);
|
|
590
|
+
const mine = records.filter((r) => r.nativeSessionId === sessionId);
|
|
591
|
+
console.log(` PI nativeSessionId=${sessionId}`);
|
|
592
|
+
console.log(` STORE records=${records.length} sockets=${JSON.stringify(sockets)}`);
|
|
593
|
+
|
|
594
|
+
if (mine.length === 1) ok("exactly one V3 record claims this pi session");
|
|
595
|
+
else { no(`${mine.length} records claim ${sessionId} — the wired extension did not birth exactly one citizen`); return null; }
|
|
596
|
+
|
|
597
|
+
const rec = mine[0];
|
|
598
|
+
console.log(` RECORD gardenId=${rec.gardenId} backend=${rec.backend} schemaVersion=${rec.schemaVersion} model=${rec.model} transcriptPath=${JSON.stringify(rec.transcriptPath)}`);
|
|
599
|
+
rec.schemaVersion === 3 && rec.backend === "pi"
|
|
600
|
+
? ok("the record is V3 with backend:\"pi\"")
|
|
601
|
+
: no(`record is schemaVersion=${rec.schemaVersion} backend=${rec.backend}`);
|
|
602
|
+
// The address is the record's. A gardenId equal to pi's own id would mean the id
|
|
603
|
+
// authority moved back into the harness — the split #50 C2 exists to hold.
|
|
604
|
+
typeof rec.gardenId === "string" && /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(rec.gardenId) && rec.gardenId !== sessionId
|
|
605
|
+
? ok(`the record minted the address, not pi (${rec.gardenId})`)
|
|
606
|
+
: no(`gardenId ${JSON.stringify(rec.gardenId)} is not a record-minted address distinct from pi's id`);
|
|
607
|
+
// A 0-token birth writes no session file, so a resume target here would be a phantom.
|
|
608
|
+
rec.transcriptPath === null ? ok("0-token birth carries transcriptPath=null (no phantom resume target)") : no(`transcriptPath=${JSON.stringify(rec.transcriptPath)} for a session with no turn`);
|
|
609
|
+
|
|
610
|
+
sockets.includes(`${rec.gardenId}.sock`)
|
|
611
|
+
? ok(`a control socket is open, keyed on the record gardenId (${rec.gardenId}.sock)`)
|
|
612
|
+
: no(`no ${rec.gardenId}.sock among ${JSON.stringify(sockets)} — the citizen has an address but no rail`);
|
|
613
|
+
sockets.some((s) => s === `${sessionId}.sock` || /^[0-9a-f]{8}-[0-9a-f]{4}-/.test(s))
|
|
614
|
+
? no(`a socket carries pi's own session id: ${JSON.stringify(sockets)}`)
|
|
615
|
+
: ok("no socket carries pi's session id");
|
|
616
|
+
return rec;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
/** peer-facts from the INSTALLED bin, while pi is still alive. */
|
|
620
|
+
const askPeers = (rec) => {
|
|
621
|
+
const run = spawnSync(ENTWURF_BIN, ["peer-facts"], { encoding: "utf8" });
|
|
622
|
+
if (run.status !== 0) { no(`installed \`entwurf peer-facts\` exit ${run.status}: ${(run.stderr ?? "").trim().slice(0, 300)}`); return; }
|
|
623
|
+
let payload;
|
|
624
|
+
try { payload = JSON.parse(run.stdout); } catch (err) { no(`peer-facts output is not JSON: ${err.message}`); return; }
|
|
625
|
+
const peers = Array.isArray(payload?.peers) ? payload.peers : null;
|
|
626
|
+
if (peers === null) { no("peer-facts payload has no `peers` array"); return; }
|
|
627
|
+
const row = peers.find((p) => p.gardenId === rec.gardenId);
|
|
628
|
+
console.log(` PEERS total=${peers.length} mine=${row ? JSON.stringify(row) : "<absent>"}`);
|
|
629
|
+
if (!row) { no(`peer-facts does not report ${rec.gardenId} — the citizen exists on disk and not in the listing`); return; }
|
|
630
|
+
ok(`the installed \`entwurf peer-facts\` reports this citizen (backend=${row.backend})`);
|
|
631
|
+
// Liveness is a FACT here, not a dispatch decision: the process is up and the socket is
|
|
632
|
+
// bound, so anything but `alive` means the probe cannot see a rail that exists.
|
|
633
|
+
row.liveness === "alive" ? ok("peer-facts reports liveness=alive while the session is up") : no(`peer-facts reports liveness=${row.liveness} for a running session`);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
child.stdout.on("data", (d) => {
|
|
637
|
+
buf += d.toString();
|
|
638
|
+
let i;
|
|
639
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
640
|
+
const line = buf.slice(0, i);
|
|
641
|
+
buf = buf.slice(i + 1);
|
|
642
|
+
if (!line.trim()) continue;
|
|
643
|
+
let evt;
|
|
644
|
+
try { evt = JSON.parse(line); } catch { continue; }
|
|
645
|
+
if (evt.type === "agent_start") agentStartSeen = true;
|
|
646
|
+
if (evt.type === "extension_error") extensionErrors.push({ path: evt.extensionPath, error: evt.error });
|
|
647
|
+
if (evt.type !== "response" || evt.command !== "get_state") continue;
|
|
648
|
+
const sessionId = evt.data?.sessionId ?? null;
|
|
649
|
+
if (typeof sessionId !== "string") { no(`get_state returned no sessionId: ${line.slice(0, 300)}`); done(bad || 1); return; }
|
|
650
|
+
extensionErrors.length === 0
|
|
651
|
+
? ok("the wired extension loaded with no extension_error")
|
|
652
|
+
: no(`extension errors: ${JSON.stringify(extensionErrors).slice(0, 400)}`);
|
|
653
|
+
const rec = observe(sessionId);
|
|
654
|
+
if (rec) askPeers(rec);
|
|
655
|
+
agentStartSeen ? no("a model turn started — this cell is supposed to cost zero tokens") : ok("no model turn ran (zero tokens)");
|
|
656
|
+
done(bad);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
child.on("error", (err) => { no(`pi failed to spawn: ${err.message}`); done(1); });
|
|
662
|
+
child.on("exit", (code) => {
|
|
663
|
+
if (settled) return;
|
|
664
|
+
no(`${LABEL}: the session exited ${code} before answering get_state — stderr: ${stderr.trim().slice(0, 600)}`);
|
|
665
|
+
done(1);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
setTimeout(() => { child.stdin.write(`${JSON.stringify({ type: "get_state", id: "g1" })}\n`); }, 500);
|
|
669
|
+
setTimeout(() => { if (!settled) { no("pi did not answer get_state within 60s"); done(1); } }, 60_000);
|
|
670
|
+
DRIVER_EOF
|
|
671
|
+
node /tmp/use-path-drive.mjs "$ACTIVE/node_modules/.bin/entwurf" "the plugin wiring" pi --entwurf-control --mode rpc || fail=1
|
|
672
|
+
|
|
673
|
+
# ── 8. the status fan, driven by the REAL binaries this install produced ─────
|
|
674
|
+
# `check-herdr-plugin` already drives `lib/status.mjs` against stub `entwurf` and `herdr`
|
|
675
|
+
# executables, and its oracle is the call LOG: exactly one `entwurf peer-facts` and one
|
|
676
|
+
# `herdr agent list`, in that order. Here the same entry runs with neither side stubbed —
|
|
677
|
+
# `ENTWURF_BIN` is the npm-installed bin from [6], `HERDR_BIN_PATH` is the herdr the image
|
|
678
|
+
# scaffolded — so the question is whether that contract survives contact with the real two.
|
|
679
|
+
#
|
|
680
|
+
# TWO STATES, because the honest answer differs between them and both are a user's.
|
|
681
|
+
# (i) NO SERVER — the state everything above ran in. `herdr agent list` answers
|
|
682
|
+
# {"error":{"code":"server_not_running"}} on exit 1 (measured, 0.9.1), so the fan must
|
|
683
|
+
# name `herdr-agent-list-failed` and go RED. A failed read rendered as an empty table
|
|
684
|
+
# is the one lie this surface could tell, and this is where it would tell it.
|
|
685
|
+
# (ii) HEADLESS SERVER — `herdr server` is scaffolding for the fan's SECOND read only; the
|
|
686
|
+
# offline-persist measurement that cell [2] owns already happened without it, and the
|
|
687
|
+
# server is stopped again before [9] so the reinstall there takes the same serverless
|
|
688
|
+
# path. With a server and no panes the agent list is a real empty result, so the fan
|
|
689
|
+
# exits 0 and the citizen from [7] is COUNTED rather than drawn: nobody observed a
|
|
690
|
+
# placement for it, which is exactly the `unobserved` word the renderer reserves for
|
|
691
|
+
# "nobody could look", as distinct from `none`.
|
|
692
|
+
#
|
|
693
|
+
# WHAT THIS CELL CANNOT CLOSE, stated rather than rounded: a citizen rendered as a ROW
|
|
694
|
+
# needs `placement.kind === "herdr-pane"`, which requires the pi session to be living in a
|
|
695
|
+
# herdr pane that herdr's own session reference joins. A headless container has no panes,
|
|
696
|
+
# so the row is out of reach here and stays with the host-side surface
|
|
697
|
+
# (`check-herdr-placement` for the join, GLG's raw PC for the picture).
|
|
698
|
+
echo; echo "[8] the status fan on real binaries — with no herdr server, then with one"
|
|
699
|
+
FAN="$(node -e '
|
|
700
|
+
const fs=require("node:fs");const path=require("node:path");
|
|
701
|
+
const [registry,pluginId]=process.argv.slice(1);
|
|
702
|
+
const reg=JSON.parse(fs.readFileSync(registry,"utf8"));
|
|
703
|
+
const e=(Array.isArray(reg)?reg:reg.plugins||[]).find((x)=>x.plugin_id===pluginId);
|
|
704
|
+
process.stdout.write(path.join(e?.source?.managed_path||e?.managed_path,"plugins","herdr","lib","status.mjs"));
|
|
705
|
+
' "$REGISTRY" "$PLUGIN_ID")"
|
|
706
|
+
if [ -f "$FAN" ]; then ok "the status fan ships in the managed checkout (${FAN#$HOME/})"; else bad "no status fan at $FAN"; fi
|
|
707
|
+
|
|
708
|
+
run_fan() {
|
|
709
|
+
env ENTWURF_BIN="$ACTIVE/node_modules/.bin/entwurf" HERDR_BIN_PATH="$(command -v herdr)" \
|
|
710
|
+
node "$FAN" </dev/null 2>&1
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fan_no_server="$(run_fan)"; fan_no_server_rc=$?
|
|
714
|
+
echo "$fan_no_server" | sed 's/^/ /'
|
|
715
|
+
echo " → exit $fan_no_server_rc"
|
|
716
|
+
if [ "$fan_no_server_rc" -ne 0 ] && printf '%s' "$fan_no_server" | grep -q '^herdr-agent-list-failed'; then
|
|
717
|
+
ok "with no herdr server the fan names herdr-agent-list-failed and goes red (not an empty table)"
|
|
718
|
+
else
|
|
719
|
+
bad "with no herdr server the fan answered rc=$fan_no_server_rc without naming herdr-agent-list-failed"
|
|
720
|
+
fi
|
|
721
|
+
|
|
722
|
+
(herdr server >/tmp/herdr-server.log 2>&1 &)
|
|
723
|
+
server_up=0
|
|
724
|
+
for _ in $(seq 1 40); do
|
|
725
|
+
if herdr agent list >/dev/null 2>&1; then server_up=1; break; fi
|
|
726
|
+
sleep 0.5
|
|
727
|
+
done
|
|
728
|
+
[ "$server_up" -eq 1 ] && ok "a headless herdr server answered 'agent list' (scaffolding for the fan's second read)" || bad "the headless herdr server never answered agent list: $(head -3 /tmp/herdr-server.log)"
|
|
729
|
+
|
|
730
|
+
fan_with_server="$(run_fan)"; fan_with_server_rc=$?
|
|
731
|
+
echo "$fan_with_server" | sed 's/^/ /'
|
|
732
|
+
echo " → exit $fan_with_server_rc"
|
|
733
|
+
if [ "$fan_with_server_rc" -eq 0 ]; then ok "with a herdr server the fan completes both reads and exits 0"; else bad "the fan exited $fan_with_server_rc with a live herdr server"; fi
|
|
734
|
+
# The WHOLE sentence, count included (A5 O1). The bare word `unobserved` also appears in
|
|
735
|
+
# `renderPlacement`'s fallback and could be carried by output that never accounted for
|
|
736
|
+
# anybody. status.mjs:205-213 writes `<n> citizen(s) not shown: ... (unobserved).`, and the
|
|
737
|
+
# store holds exactly the one citizen [7] created, so the count is a fact this cell may name.
|
|
738
|
+
if printf '%s' "$fan_with_server" | grep -qx '1 citizen(s) not shown: nobody could observe placement for them (unobserved)\.'; then
|
|
739
|
+
ok "the ONE citizen with no observed placement is COUNTED by name (1 citizen(s) not shown ... unobserved), not silently dropped"
|
|
740
|
+
else
|
|
741
|
+
bad "the fan drew no '1 citizen(s) not shown ... (unobserved)' accounting for the citizen born in [7]"
|
|
742
|
+
fi
|
|
743
|
+
herdr server stop >/dev/null 2>&1 || true
|
|
744
|
+
|
|
745
|
+
# ── 8a. the SHIPPED LAUNCHER, from the bin the user actually received ───────
|
|
746
|
+
# [7] proves the wiring by running `pi` off PATH. That is not the same claim as the one #118
|
|
747
|
+
# hop 2 makes, and astra named the gap: a user who installed this plugin does not have this
|
|
748
|
+
# checkout, so the only launcher that means anything to them is the one inside the npm
|
|
749
|
+
# artifact the lock names. Measured on the published tarball before this cell existed, the
|
|
750
|
+
# `pi)` case IS in `run.sh` at 0.23.1 — so the claim is now testable, and this cell is what
|
|
751
|
+
# tests it: the same 0-token birth, driven through `<active>/node_modules/.bin/entwurf pi`.
|
|
752
|
+
#
|
|
753
|
+
# The proof that it EXEC'd pi is the birth itself. A launcher that silently did nothing, or
|
|
754
|
+
# resolved to something else, cannot produce a V3 record and a gardenId-keyed socket.
|
|
755
|
+
#
|
|
756
|
+
# IT SITS AFTER [8] ON PURPOSE: the fan cell asserts the unobserved count as an exact
|
|
757
|
+
# literal, and a second citizen born before it would change that number. Order here is the
|
|
758
|
+
# cheaper of the two ways to keep that oracle exact.
|
|
759
|
+
echo; echo "[8a] entwurf pi — the launcher inside the npm artifact, same 0-token birth"
|
|
760
|
+
ENTWURF_INSTALLED_BIN="$ACTIVE/node_modules/.bin/entwurf"
|
|
761
|
+
if [ -x "$ENTWURF_INSTALLED_BIN" ]; then
|
|
762
|
+
node /tmp/use-path-drive.mjs "$ENTWURF_INSTALLED_BIN" "the shipped launcher" "$ENTWURF_INSTALLED_BIN" pi --mode rpc || fail=1
|
|
763
|
+
else
|
|
764
|
+
bad "no installed entwurf bin at $ENTWURF_INSTALLED_BIN — the launcher claim cannot be measured"
|
|
765
|
+
fi
|
|
766
|
+
|
|
767
|
+
# ── 9. deactivate: the roundtrip, on the npm source (#118 H1-5) ──────────────
|
|
768
|
+
# `check-herdr-activation` cell 16 proves this against fixtures: [QK:HAC-DEACTIVATE-ROUNDTRIP]
|
|
769
|
+
# asserts the pi settings return to their ORIGINAL MEANING (JSON equality, not bytes — the
|
|
770
|
+
# writer may reindent), zero install-state survives, and the runtime and ledger are retired.
|
|
771
|
+
# VERIFY.md:102 lists deactivation as a row that must be re-run against the production
|
|
772
|
+
# source, and #118's table still carries it as `deactivate 미결`. This runs the verb that
|
|
773
|
+
# actually ships — `entwurf herdr-plugin-deactivate` from the npm-installed bin, deleting
|
|
774
|
+
# the runtime it is itself executing from — and then reinstalls, because a teardown nobody
|
|
775
|
+
# can come back from is not a roundtrip.
|
|
776
|
+
echo; echo "[9] entwurf herdr-plugin-deactivate, then reinstall"
|
|
777
|
+
"$ACTIVE/node_modules/.bin/entwurf" herdr-plugin-deactivate 2>&1 | sed 's/^/ /'
|
|
778
|
+
rc="${PIPESTATUS[0]}"
|
|
779
|
+
echo " → exit $rc"
|
|
780
|
+
[ "$rc" -eq 0 ] && ok "herdr-plugin-deactivate exit 0 from the installed bin (it deleted its own runtime)" || bad "herdr-plugin-deactivate exit $rc"
|
|
781
|
+
|
|
782
|
+
node -e '
|
|
783
|
+
const fs = require("node:fs");
|
|
784
|
+
const path = require("node:path");
|
|
785
|
+
const [ledger, runtimeRoot, piSettings, piBaseline, claudeUserConfig, dataRoot] = process.argv.slice(1);
|
|
786
|
+
let bad = 0;
|
|
787
|
+
const ok = (m) => console.log(" ok " + m);
|
|
788
|
+
const no = (m) => { console.log(" FAIL " + m); bad = 1; };
|
|
789
|
+
const readOrNull = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } };
|
|
790
|
+
|
|
791
|
+
fs.existsSync(ledger) ? no(`the activation ledger survived at ${ledger}`) : ok("the activation ledger is retired");
|
|
792
|
+
fs.existsSync(runtimeRoot) ? no(`the runtime root survived at ${runtimeRoot}`) : ok("the stable runtime root is gone");
|
|
793
|
+
|
|
794
|
+
// The same oracle shape as [QK:HAC-DEACTIVATE-ROUNDTRIP]: JSON equality against what the
|
|
795
|
+
// host carried BEFORE the plugin touched it. `<absent>` is a real baseline too — a file the
|
|
796
|
+
// plugin created must not be left behind carrying our entry.
|
|
797
|
+
const before = readOrNull(piBaseline);
|
|
798
|
+
const after = readOrNull(piSettings);
|
|
799
|
+
console.log(` PI-SETTINGS before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
|
|
800
|
+
if (before === null) {
|
|
801
|
+
// NOT "absent, or anything without the word entwurf in it" (A5 D2). remove() is a FILTER,
|
|
802
|
+
// not a file deletion: it rewrites data[packages] without our entries and writes the file
|
|
803
|
+
// back, preserving every other key (register-pi-package.py:22,307-323). Deleting a
|
|
804
|
+
// settings file it never created would be over-reach, so an emptied packages[] is the
|
|
805
|
+
// CONTRACTED residue of a file the plugin itself created — and this branch runs only when
|
|
806
|
+
// there was no baseline, which is exactly that case. The oracle is that shell and nothing
|
|
807
|
+
// else: a surviving extra key, or somebody else packages left in the array, is a teardown
|
|
808
|
+
// that left something behind, and the old substring test called both of those clean.
|
|
809
|
+
const EMPTY_SHELL = JSON.stringify({ packages: [] });
|
|
810
|
+
JSON.stringify(after) === EMPTY_SHELL
|
|
811
|
+
? ok(`pi settings are the contracted empty shell ${EMPTY_SHELL} — the plugin created the file, remove() filtered it, nothing else survives`)
|
|
812
|
+
: no(`the plugin-created pi settings are ${JSON.stringify(after)}, not the contracted empty shell ${EMPTY_SHELL}`);
|
|
813
|
+
} else {
|
|
814
|
+
JSON.stringify(after) === JSON.stringify(before)
|
|
815
|
+
? ok("pi settings are JSON-equal to their pre-plugin meaning")
|
|
816
|
+
: no("pi settings did not return to their pre-plugin meaning");
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const claude = readOrNull(claudeUserConfig) ?? {};
|
|
820
|
+
// Same literal as [5] (A7-2): the teardown has to retire the key the writer wrote, and a
|
|
821
|
+
// substring test would have called a RENAMED survivor gone. Both questions are asked.
|
|
822
|
+
const OWNER_KEY = "entwurf-bridge";
|
|
823
|
+
const claudeKeys = Object.keys(claude.mcpServers ?? {});
|
|
824
|
+
console.log(` CLAUDE-MCP after teardown keys=${JSON.stringify(claudeKeys)}`);
|
|
825
|
+
claudeKeys.includes(OWNER_KEY) ? no(`mcpServers.${OWNER_KEY} survived the teardown`) : ok(`the Claude MCP owner entry ${OWNER_KEY} is gone`);
|
|
826
|
+
claudeKeys.filter((k) => k.includes("entwurf")).length === 0
|
|
827
|
+
? ok("no entwurf-shaped MCP server survives under any other name either")
|
|
828
|
+
: no(`entwurf-shaped MCP keys survived: ${JSON.stringify(claudeKeys.filter((k) => k.includes("entwurf")))}`);
|
|
829
|
+
|
|
830
|
+
// FILES, not directories: an emptied pi-package/ is a reclaimed install-state, and calling
|
|
831
|
+
// the surviving directory name dirty would fail a clean inverse.
|
|
832
|
+
const leftover = [];
|
|
833
|
+
const walk = (dir) => {
|
|
834
|
+
if (!fs.existsSync(dir)) return;
|
|
835
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
836
|
+
const abs = path.join(dir, e.name);
|
|
837
|
+
if (e.isDirectory()) walk(abs); else leftover.push(path.relative(dataRoot, abs));
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
walk(dataRoot);
|
|
841
|
+
leftover.length === 0 ? ok("zero install-state files survive under the entwurf data root") : no(`install-state survived: ${JSON.stringify(leftover)}`);
|
|
842
|
+
process.exit(bad);
|
|
843
|
+
' "$LEDGER" "$DATA/entwurf/herdr-plugin/runtime" "$HOME/.pi/agent/settings.json" /tmp/pi-settings-pre-plugin.json "$HOME/.claude.json" "$DATA/entwurf" || fail=1
|
|
844
|
+
|
|
845
|
+
# The return leg. Same serverless path as [2] and [4] — the headless server from [8] was
|
|
846
|
+
# stopped — so a green here is the plugin reinstalling onto a host it had fully left.
|
|
847
|
+
herdr plugin install "$REMOTE_SPEC" --ref "$REQUESTED_REF" --yes 2>&1 | sed 's/^/ /'
|
|
848
|
+
rc="${PIPESTATUS[0]}"
|
|
849
|
+
echo " → exit $rc"
|
|
850
|
+
[ "$rc" -eq 0 ] && ok "reinstall after a full teardown exit 0" || bad "reinstall after teardown exited $rc"
|
|
851
|
+
node -e '
|
|
852
|
+
const fs = require("node:fs");
|
|
853
|
+
const [ledger] = process.argv.slice(1);
|
|
854
|
+
if (!fs.existsSync(ledger)) { console.log(` FAIL no activation ledger after the reinstall (${ledger})`); process.exit(1); }
|
|
855
|
+
const led = JSON.parse(fs.readFileSync(ledger, "utf8"));
|
|
856
|
+
const b = led.activatedBackends;
|
|
857
|
+
if (JSON.stringify(b) === JSON.stringify(["pi", "claude-code"])) { console.log(` ok the reinstall restored activatedBackends === ["pi","claude-code"]`); process.exit(0); }
|
|
858
|
+
console.log(` FAIL ledger activatedBackends === ${JSON.stringify(b)} after the reinstall`);
|
|
859
|
+
process.exit(1);
|
|
860
|
+
' "$LEDGER" || fail=1
|
|
861
|
+
|
|
862
|
+
echo
|
|
863
|
+
if [ "$fail" -eq 0 ]; then
|
|
864
|
+
echo "smoke-herdr-raw-install-live: PASS (first user path green on the public remote + npm runtime)"
|
|
865
|
+
else
|
|
866
|
+
echo "smoke-herdr-raw-install-live: FAIL (see above)"
|
|
867
|
+
fi
|
|
868
|
+
exit "$fail"
|
|
869
|
+
CONTAINER_RUNNER_EOF
|
|
870
|
+
rc=$?
|
|
871
|
+
set -e
|
|
872
|
+
|
|
873
|
+
if [ "$rc" -ne 0 ]; then
|
|
874
|
+
echo "[smoke-herdr-raw-install-live] FAIL — container cell exited $rc (ref=$REF image=$IMAGE_ID)" >&2
|
|
875
|
+
exit 1
|
|
876
|
+
fi
|
|
877
|
+
echo "[smoke-herdr-raw-install-live] ok — ref=$REF image=$IMAGE_ID base=$BASE_DIGEST"
|