@junghanacs/entwurf 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -0,0 +1,849 @@
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
+
557
+ let bad = 0;
558
+ const ok = (m) => console.log(" ok " + m);
559
+ const no = (m) => { console.log(" FAIL " + m); bad = 1; };
560
+ const ls = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
561
+
562
+ const child = spawn("pi", ["--entwurf-control", "--mode", "rpc"], { stdio: ["pipe", "pipe", "pipe"] });
563
+ let stderr = "";
564
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
565
+
566
+ let buf = "";
567
+ let settled = false;
568
+ const extensionErrors = [];
569
+ let agentStartSeen = false;
570
+
571
+ const done = (code) => {
572
+ if (settled) return;
573
+ settled = true;
574
+ try { child.stdin.end(); } catch { /* best-effort */ }
575
+ setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* best-effort */ } process.exit(code); }, 300);
576
+ };
577
+
578
+ const observe = (sessionId) => {
579
+ const sockets = ls(SOCKET_DIR).filter((f) => f.endsWith(".sock"));
580
+ const recordFiles = ls(META_DIR).filter((f) => f.endsWith(".meta.json"));
581
+ const records = recordFiles.map((f) => {
582
+ try { return JSON.parse(fs.readFileSync(path.join(META_DIR, f), "utf8")); } catch { return null; }
583
+ }).filter((r) => r !== null);
584
+ const mine = records.filter((r) => r.nativeSessionId === sessionId);
585
+ console.log(` PI nativeSessionId=${sessionId}`);
586
+ console.log(` STORE records=${records.length} sockets=${JSON.stringify(sockets)}`);
587
+
588
+ if (mine.length === 1) ok("exactly one V3 record claims this pi session");
589
+ else { no(`${mine.length} records claim ${sessionId} — the wired extension did not birth exactly one citizen`); return null; }
590
+
591
+ const rec = mine[0];
592
+ console.log(` RECORD gardenId=${rec.gardenId} backend=${rec.backend} schemaVersion=${rec.schemaVersion} model=${rec.model} transcriptPath=${JSON.stringify(rec.transcriptPath)}`);
593
+ rec.schemaVersion === 3 && rec.backend === "pi"
594
+ ? ok("the record is V3 with backend:\"pi\"")
595
+ : no(`record is schemaVersion=${rec.schemaVersion} backend=${rec.backend}`);
596
+ // The address is the record's. A gardenId equal to pi's own id would mean the id
597
+ // authority moved back into the harness — the split #50 C2 exists to hold.
598
+ typeof rec.gardenId === "string" && /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(rec.gardenId) && rec.gardenId !== sessionId
599
+ ? ok(`the record minted the address, not pi (${rec.gardenId})`)
600
+ : no(`gardenId ${JSON.stringify(rec.gardenId)} is not a record-minted address distinct from pi's id`);
601
+ // A 0-token birth writes no session file, so a resume target here would be a phantom.
602
+ 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`);
603
+
604
+ sockets.includes(`${rec.gardenId}.sock`)
605
+ ? ok(`a control socket is open, keyed on the record gardenId (${rec.gardenId}.sock)`)
606
+ : no(`no ${rec.gardenId}.sock among ${JSON.stringify(sockets)} — the citizen has an address but no rail`);
607
+ sockets.some((s) => s === `${sessionId}.sock` || /^[0-9a-f]{8}-[0-9a-f]{4}-/.test(s))
608
+ ? no(`a socket carries pi's own session id: ${JSON.stringify(sockets)}`)
609
+ : ok("no socket carries pi's session id");
610
+ return rec;
611
+ };
612
+
613
+ /** peer-facts from the INSTALLED bin, while pi is still alive. */
614
+ const askPeers = (rec) => {
615
+ const run = spawnSync(ENTWURF_BIN, ["peer-facts"], { encoding: "utf8" });
616
+ if (run.status !== 0) { no(`installed \`entwurf peer-facts\` exit ${run.status}: ${(run.stderr ?? "").trim().slice(0, 300)}`); return; }
617
+ let payload;
618
+ try { payload = JSON.parse(run.stdout); } catch (err) { no(`peer-facts output is not JSON: ${err.message}`); return; }
619
+ const peers = Array.isArray(payload?.peers) ? payload.peers : null;
620
+ if (peers === null) { no("peer-facts payload has no `peers` array"); return; }
621
+ const row = peers.find((p) => p.gardenId === rec.gardenId);
622
+ console.log(` PEERS total=${peers.length} mine=${row ? JSON.stringify(row) : "<absent>"}`);
623
+ if (!row) { no(`peer-facts does not report ${rec.gardenId} — the citizen exists on disk and not in the listing`); return; }
624
+ ok(`the installed \`entwurf peer-facts\` reports this citizen (backend=${row.backend})`);
625
+ // Liveness is a FACT here, not a dispatch decision: the process is up and the socket is
626
+ // bound, so anything but `alive` means the probe cannot see a rail that exists.
627
+ 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`);
628
+ };
629
+
630
+ child.stdout.on("data", (d) => {
631
+ buf += d.toString();
632
+ let i;
633
+ while ((i = buf.indexOf("\n")) >= 0) {
634
+ const line = buf.slice(0, i);
635
+ buf = buf.slice(i + 1);
636
+ if (!line.trim()) continue;
637
+ let evt;
638
+ try { evt = JSON.parse(line); } catch { continue; }
639
+ if (evt.type === "agent_start") agentStartSeen = true;
640
+ if (evt.type === "extension_error") extensionErrors.push({ path: evt.extensionPath, error: evt.error });
641
+ if (evt.type !== "response" || evt.command !== "get_state") continue;
642
+ const sessionId = evt.data?.sessionId ?? null;
643
+ if (typeof sessionId !== "string") { no(`get_state returned no sessionId: ${line.slice(0, 300)}`); done(bad || 1); return; }
644
+ extensionErrors.length === 0
645
+ ? ok("the wired extension loaded with no extension_error")
646
+ : no(`extension errors: ${JSON.stringify(extensionErrors).slice(0, 400)}`);
647
+ const rec = observe(sessionId);
648
+ if (rec) askPeers(rec);
649
+ agentStartSeen ? no("a model turn started — this cell is supposed to cost zero tokens") : ok("no model turn ran (zero tokens)");
650
+ done(bad);
651
+ return;
652
+ }
653
+ });
654
+
655
+ child.on("error", (err) => { no(`pi failed to spawn: ${err.message}`); done(1); });
656
+ child.on("exit", (code) => {
657
+ if (settled) return;
658
+ no(`pi exited ${code} before answering get_state — stderr: ${stderr.trim().slice(0, 600)}`);
659
+ done(1);
660
+ });
661
+
662
+ setTimeout(() => { child.stdin.write(`${JSON.stringify({ type: "get_state", id: "g1" })}\n`); }, 500);
663
+ setTimeout(() => { if (!settled) { no("pi did not answer get_state within 60s"); done(1); } }, 60_000);
664
+ DRIVER_EOF
665
+ node /tmp/use-path-drive.mjs "$ACTIVE/node_modules/.bin/entwurf" || fail=1
666
+
667
+ # ── 8. the status fan, driven by the REAL binaries this install produced ─────
668
+ # `check-herdr-plugin` already drives `lib/status.mjs` against stub `entwurf` and `herdr`
669
+ # executables, and its oracle is the call LOG: exactly one `entwurf peer-facts` and one
670
+ # `herdr agent list`, in that order. Here the same entry runs with neither side stubbed —
671
+ # `ENTWURF_BIN` is the npm-installed bin from [6], `HERDR_BIN_PATH` is the herdr the image
672
+ # scaffolded — so the question is whether that contract survives contact with the real two.
673
+ #
674
+ # TWO STATES, because the honest answer differs between them and both are a user's.
675
+ # (i) NO SERVER — the state everything above ran in. `herdr agent list` answers
676
+ # {"error":{"code":"server_not_running"}} on exit 1 (measured, 0.9.1), so the fan must
677
+ # name `herdr-agent-list-failed` and go RED. A failed read rendered as an empty table
678
+ # is the one lie this surface could tell, and this is where it would tell it.
679
+ # (ii) HEADLESS SERVER — `herdr server` is scaffolding for the fan's SECOND read only; the
680
+ # offline-persist measurement that cell [2] owns already happened without it, and the
681
+ # server is stopped again before [9] so the reinstall there takes the same serverless
682
+ # path. With a server and no panes the agent list is a real empty result, so the fan
683
+ # exits 0 and the citizen from [7] is COUNTED rather than drawn: nobody observed a
684
+ # placement for it, which is exactly the `unobserved` word the renderer reserves for
685
+ # "nobody could look", as distinct from `none`.
686
+ #
687
+ # WHAT THIS CELL CANNOT CLOSE, stated rather than rounded: a citizen rendered as a ROW
688
+ # needs `placement.kind === "herdr-pane"`, which requires the pi session to be living in a
689
+ # herdr pane that herdr's own session reference joins. A headless container has no panes,
690
+ # so the row is out of reach here and stays with the host-side surface
691
+ # (`check-herdr-placement` for the join, GLG's raw PC for the picture).
692
+ echo; echo "[8] the status fan on real binaries — with no herdr server, then with one"
693
+ FAN="$(node -e '
694
+ const fs=require("node:fs");const path=require("node:path");
695
+ const [registry,pluginId]=process.argv.slice(1);
696
+ const reg=JSON.parse(fs.readFileSync(registry,"utf8"));
697
+ const e=(Array.isArray(reg)?reg:reg.plugins||[]).find((x)=>x.plugin_id===pluginId);
698
+ process.stdout.write(path.join(e?.source?.managed_path||e?.managed_path,"plugins","herdr","lib","status.mjs"));
699
+ ' "$REGISTRY" "$PLUGIN_ID")"
700
+ if [ -f "$FAN" ]; then ok "the status fan ships in the managed checkout (${FAN#$HOME/})"; else bad "no status fan at $FAN"; fi
701
+
702
+ run_fan() {
703
+ env ENTWURF_BIN="$ACTIVE/node_modules/.bin/entwurf" HERDR_BIN_PATH="$(command -v herdr)" \
704
+ node "$FAN" </dev/null 2>&1
705
+ }
706
+
707
+ fan_no_server="$(run_fan)"; fan_no_server_rc=$?
708
+ echo "$fan_no_server" | sed 's/^/ /'
709
+ echo " → exit $fan_no_server_rc"
710
+ if [ "$fan_no_server_rc" -ne 0 ] && printf '%s' "$fan_no_server" | grep -q '^herdr-agent-list-failed'; then
711
+ ok "with no herdr server the fan names herdr-agent-list-failed and goes red (not an empty table)"
712
+ else
713
+ bad "with no herdr server the fan answered rc=$fan_no_server_rc without naming herdr-agent-list-failed"
714
+ fi
715
+
716
+ (herdr server >/tmp/herdr-server.log 2>&1 &)
717
+ server_up=0
718
+ for _ in $(seq 1 40); do
719
+ if herdr agent list >/dev/null 2>&1; then server_up=1; break; fi
720
+ sleep 0.5
721
+ done
722
+ [ "$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)"
723
+
724
+ fan_with_server="$(run_fan)"; fan_with_server_rc=$?
725
+ echo "$fan_with_server" | sed 's/^/ /'
726
+ echo " → exit $fan_with_server_rc"
727
+ 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
728
+ # The WHOLE sentence, count included (A5 O1). The bare word `unobserved` also appears in
729
+ # `renderPlacement`'s fallback and could be carried by output that never accounted for
730
+ # anybody. status.mjs:205-213 writes `<n> citizen(s) not shown: ... (unobserved).`, and the
731
+ # store holds exactly the one citizen [7] created, so the count is a fact this cell may name.
732
+ if printf '%s' "$fan_with_server" | grep -qx '1 citizen(s) not shown: nobody could observe placement for them (unobserved)\.'; then
733
+ ok "the ONE citizen with no observed placement is COUNTED by name (1 citizen(s) not shown ... unobserved), not silently dropped"
734
+ else
735
+ bad "the fan drew no '1 citizen(s) not shown ... (unobserved)' accounting for the citizen born in [7]"
736
+ fi
737
+ herdr server stop >/dev/null 2>&1 || true
738
+
739
+ # ── 9. deactivate: the roundtrip, on the npm source (#118 H1-5) ──────────────
740
+ # `check-herdr-activation` cell 16 proves this against fixtures: [QK:HAC-DEACTIVATE-ROUNDTRIP]
741
+ # asserts the pi settings return to their ORIGINAL MEANING (JSON equality, not bytes — the
742
+ # writer may reindent), zero install-state survives, and the runtime and ledger are retired.
743
+ # VERIFY.md:102 lists deactivation as a row that must be re-run against the production
744
+ # source, and #118's table still carries it as `deactivate 미결`. This runs the verb that
745
+ # actually ships — `entwurf herdr-plugin-deactivate` from the npm-installed bin, deleting
746
+ # the runtime it is itself executing from — and then reinstalls, because a teardown nobody
747
+ # can come back from is not a roundtrip.
748
+ echo; echo "[9] entwurf herdr-plugin-deactivate, then reinstall"
749
+ "$ACTIVE/node_modules/.bin/entwurf" herdr-plugin-deactivate 2>&1 | sed 's/^/ /'
750
+ rc="${PIPESTATUS[0]}"
751
+ echo " → exit $rc"
752
+ [ "$rc" -eq 0 ] && ok "herdr-plugin-deactivate exit 0 from the installed bin (it deleted its own runtime)" || bad "herdr-plugin-deactivate exit $rc"
753
+
754
+ node -e '
755
+ const fs = require("node:fs");
756
+ const path = require("node:path");
757
+ const [ledger, runtimeRoot, piSettings, piBaseline, claudeUserConfig, dataRoot] = process.argv.slice(1);
758
+ let bad = 0;
759
+ const ok = (m) => console.log(" ok " + m);
760
+ const no = (m) => { console.log(" FAIL " + m); bad = 1; };
761
+ const readOrNull = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } };
762
+
763
+ fs.existsSync(ledger) ? no(`the activation ledger survived at ${ledger}`) : ok("the activation ledger is retired");
764
+ fs.existsSync(runtimeRoot) ? no(`the runtime root survived at ${runtimeRoot}`) : ok("the stable runtime root is gone");
765
+
766
+ // The same oracle shape as [QK:HAC-DEACTIVATE-ROUNDTRIP]: JSON equality against what the
767
+ // host carried BEFORE the plugin touched it. `<absent>` is a real baseline too — a file the
768
+ // plugin created must not be left behind carrying our entry.
769
+ const before = readOrNull(piBaseline);
770
+ const after = readOrNull(piSettings);
771
+ console.log(` PI-SETTINGS before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
772
+ if (before === null) {
773
+ // NOT "absent, or anything without the word entwurf in it" (A5 D2). remove() is a FILTER,
774
+ // not a file deletion: it rewrites data[packages] without our entries and writes the file
775
+ // back, preserving every other key (register-pi-package.py:22,307-323). Deleting a
776
+ // settings file it never created would be over-reach, so an emptied packages[] is the
777
+ // CONTRACTED residue of a file the plugin itself created — and this branch runs only when
778
+ // there was no baseline, which is exactly that case. The oracle is that shell and nothing
779
+ // else: a surviving extra key, or somebody else packages left in the array, is a teardown
780
+ // that left something behind, and the old substring test called both of those clean.
781
+ const EMPTY_SHELL = JSON.stringify({ packages: [] });
782
+ JSON.stringify(after) === EMPTY_SHELL
783
+ ? ok(`pi settings are the contracted empty shell ${EMPTY_SHELL} — the plugin created the file, remove() filtered it, nothing else survives`)
784
+ : no(`the plugin-created pi settings are ${JSON.stringify(after)}, not the contracted empty shell ${EMPTY_SHELL}`);
785
+ } else {
786
+ JSON.stringify(after) === JSON.stringify(before)
787
+ ? ok("pi settings are JSON-equal to their pre-plugin meaning")
788
+ : no("pi settings did not return to their pre-plugin meaning");
789
+ }
790
+
791
+ const claude = readOrNull(claudeUserConfig) ?? {};
792
+ // Same literal as [5] (A7-2): the teardown has to retire the key the writer wrote, and a
793
+ // substring test would have called a RENAMED survivor gone. Both questions are asked.
794
+ const OWNER_KEY = "entwurf-bridge";
795
+ const claudeKeys = Object.keys(claude.mcpServers ?? {});
796
+ console.log(` CLAUDE-MCP after teardown keys=${JSON.stringify(claudeKeys)}`);
797
+ claudeKeys.includes(OWNER_KEY) ? no(`mcpServers.${OWNER_KEY} survived the teardown`) : ok(`the Claude MCP owner entry ${OWNER_KEY} is gone`);
798
+ claudeKeys.filter((k) => k.includes("entwurf")).length === 0
799
+ ? ok("no entwurf-shaped MCP server survives under any other name either")
800
+ : no(`entwurf-shaped MCP keys survived: ${JSON.stringify(claudeKeys.filter((k) => k.includes("entwurf")))}`);
801
+
802
+ // FILES, not directories: an emptied pi-package/ is a reclaimed install-state, and calling
803
+ // the surviving directory name dirty would fail a clean inverse.
804
+ const leftover = [];
805
+ const walk = (dir) => {
806
+ if (!fs.existsSync(dir)) return;
807
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
808
+ const abs = path.join(dir, e.name);
809
+ if (e.isDirectory()) walk(abs); else leftover.push(path.relative(dataRoot, abs));
810
+ }
811
+ };
812
+ walk(dataRoot);
813
+ leftover.length === 0 ? ok("zero install-state files survive under the entwurf data root") : no(`install-state survived: ${JSON.stringify(leftover)}`);
814
+ process.exit(bad);
815
+ ' "$LEDGER" "$DATA/entwurf/herdr-plugin/runtime" "$HOME/.pi/agent/settings.json" /tmp/pi-settings-pre-plugin.json "$HOME/.claude.json" "$DATA/entwurf" || fail=1
816
+
817
+ # The return leg. Same serverless path as [2] and [4] — the headless server from [8] was
818
+ # stopped — so a green here is the plugin reinstalling onto a host it had fully left.
819
+ herdr plugin install "$REMOTE_SPEC" --ref "$REQUESTED_REF" --yes 2>&1 | sed 's/^/ /'
820
+ rc="${PIPESTATUS[0]}"
821
+ echo " → exit $rc"
822
+ [ "$rc" -eq 0 ] && ok "reinstall after a full teardown exit 0" || bad "reinstall after teardown exited $rc"
823
+ node -e '
824
+ const fs = require("node:fs");
825
+ const [ledger] = process.argv.slice(1);
826
+ if (!fs.existsSync(ledger)) { console.log(` FAIL no activation ledger after the reinstall (${ledger})`); process.exit(1); }
827
+ const led = JSON.parse(fs.readFileSync(ledger, "utf8"));
828
+ const b = led.activatedBackends;
829
+ if (JSON.stringify(b) === JSON.stringify(["pi", "claude-code"])) { console.log(` ok the reinstall restored activatedBackends === ["pi","claude-code"]`); process.exit(0); }
830
+ console.log(` FAIL ledger activatedBackends === ${JSON.stringify(b)} after the reinstall`);
831
+ process.exit(1);
832
+ ' "$LEDGER" || fail=1
833
+
834
+ echo
835
+ if [ "$fail" -eq 0 ]; then
836
+ echo "smoke-herdr-raw-install-live: PASS (first user path green on the public remote + npm runtime)"
837
+ else
838
+ echo "smoke-herdr-raw-install-live: FAIL (see above)"
839
+ fi
840
+ exit "$fail"
841
+ CONTAINER_RUNNER_EOF
842
+ rc=$?
843
+ set -e
844
+
845
+ if [ "$rc" -ne 0 ]; then
846
+ echo "[smoke-herdr-raw-install-live] FAIL — container cell exited $rc (ref=$REF image=$IMAGE_ID)" >&2
847
+ exit 1
848
+ fi
849
+ echo "[smoke-herdr-raw-install-live] ok — ref=$REF image=$IMAGE_ID base=$BASE_DIGEST"