@junghanacs/entwurf 0.12.3 → 0.12.5

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/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.12.5 — 2026-07-01
8
+
9
+ ### Fixed
10
+
11
+ - **The plugin hook now runs as compiled JS when installed — the strip-types class is closed for good.** The SessionStart/UserPromptSubmit hook ran as a raw `meta-bridge-hook.ts`. When the marketplace source sits below `node_modules` (a global npm/pnpm install), Claude executes that `.ts` directly and Node refuses strip-types there (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so every session failed. The hook was the last `.ts`-at-runtime surface; `build-bridge` now emits `dist/pi-extensions/meta-bridge-hook.js` and the installer copies that compiled closure (dev clones still run the `.ts` for transparent editing), mirroring the same installed-vs-dev split `start.sh` (0.12.1) and the store-doctor (0.12.4) already use. `check-pack-install` now runs the installed compiled hook **from under `node_modules` with plain `node`** and proves a raw `.ts` at the same location is refused — the exact fence that broke real hosts is a green regression. The doctor's writer-version parity compares `meta-session.<js|ts>` by install mode so a compiled bundle no longer false-STALEs against the `.ts` source.
12
+ - **Installed meta-bridge paths are now stable across package upgrades.** The installed statusline uses a new `entwurf-statusline` bin shim (matching the existing `entwurf-bridge` MCP shim), and installed meta-bridge marketplace assemblies move out of versioned pnpm store paths into a version-stable operator data directory. Package upgrades still require `entwurf install-meta-bridge` to refresh the Claude plugin bundle/cache, but old-store dead links no longer strand `statusLine` or marketplace source paths. The doctor now also executes the cached SessionStart hook once in an isolated temp agent dir, catching stale plugin-cache / hook-syntax failures before the next real Claude Code session.
13
+
14
+ ### Verification
15
+
16
+ - Oracle and local review both reproduced the installed hook fence: compiled `meta-bridge-hook.js` runs from under `node_modules` with plain `node`, while the raw `.ts` at the same location fails specifically with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
17
+ - `./run.sh check-pack` and `./run.sh check-pack-install` passed on 2026-07-01 with the hook-JS tarball requirements, baked `hooks.json` `.js` assertion, and raw-`.ts` fence reproduction.
18
+ - `pnpm check` passed on 2026-07-01 after the hook-JS + stable installed-path changes.
19
+ - `./run.sh install-meta-bridge && ./run.sh doctor-meta-bridge` passed from the development clone after re-materializing the gitignored `.assembled` bundle.
20
+
21
+ ## 0.12.4 — 2026-07-01
22
+
23
+ ### Fixed
24
+
25
+ - **Installed `doctor-meta-bridge` no longer false-fails on node_modules strip-types.** The doctor previously ran `scripts/meta-bridge-store-doctor.ts` and `scripts/check-entwurf-v2-surface.ts` through Node strip-types even from an npm/pnpm-installed package. Node refuses type stripping under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so real floor hosts reported false store-scan / v2-surface failures while development clones stayed green. The doctor now branches by package location: installed packages run the prebuilt `mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js` with plain `node` and defer the repo-only v2 source-shape gate after confirming the shipped source and runtime wiring; development clones keep the raw `.ts` gates.
26
+ - **The store-doctor helper now ships as a node_modules-safe dist artifact.** The bridge build emits `dist/scripts/meta-bridge-store-doctor.js` alongside the MCP bridge closure, reusing the already-shipped `meta-session.js` dependency without adding a new runtime package lane. `check-pack` / `check-pack-install` require the artifact in the tarball and prove it scans a real fixture store under `node_modules` with plain `node`.
27
+ - **Clean-host install docs call out the 0.12.4 doctor floor check.** The walkthrough now tells operators exactly which doctor lines prove the installed-vs-dev split and flags any `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` as a pre-0.12.4 or broken-tarball signal.
28
+
29
+ ### Verification
30
+
31
+ - `pnpm check` passed on 2026-07-01 after the doctor/install-floor fix.
32
+ - `./run.sh check-pack-install` passed with the new installed store-doctor scan and doctor-dispatch lock assertions.
33
+ - Release-gate prep evidence: `LIVE=1 ./run.sh release-gate /tmp/psa-release-gate-0.12.4.PliLsd` passed on 2026-07-01; log `/tmp/pi-tmux-entwurf-release-gate-0124.log`; summary `MUST: PASS=17 FAIL=0 SKIP=0`, advisory `BEHAVIOR: PASS=0 FAIL=1` (`smoke-resident-garden-guard` post-`/gnew` autonomous `entwurf_self` identity turn; non-blocking).
34
+ - `hejdev6` real floor host reproduced the pre-fix strip-types failure from an installed package and passed after installing the patched tarball: compiled store-doctor scanned `1 record(s)` with plain `node`, and installed `doctor-meta-bridge` reported store-scan ok plus v2-surface deferred.
35
+
7
36
  ## 0.12.3 — 2026-07-01
8
37
 
9
38
  ### Changed
package/README.md CHANGED
@@ -80,10 +80,11 @@ A few words that look unusual for a coding tool.
80
80
  adapter that hosts the ACP plugin and live control-socket surface, but the base
81
81
  install is **not** `pi install npm:...` anymore.
82
82
 
83
- The package exposes two bins:
83
+ The package exposes three bins:
84
84
 
85
85
  - `entwurf` → `run.sh` (installer, checks, meta-bridge doctor/install)
86
86
  - `entwurf-bridge` → the MCP stdio launcher (`mcp/entwurf-bridge/start.sh`)
87
+ - `entwurf-statusline` → the Claude Code statusline renderer (`scripts/meta-bridge-statusline.sh`); a stable bin shim so a package upgrade tracks the current version without rewriting Claude settings
87
88
 
88
89
  The bridge does not provide Claude credentials, tokens, or subscription access,
89
90
  and does not bypass any backend auth. Whatever the operator's local `claude` /
@@ -181,6 +182,19 @@ entwurf install-meta-bridge
181
182
  entwurf doctor-meta-bridge
182
183
  ```
183
184
 
185
+ On npm/pnpm-installed packages, `doctor-meta-bridge` must use prebuilt JS for its
186
+ store scan and defer repo-only source-shape gates; Node refuses strip-types for
187
+ raw `.ts` helpers under `node_modules`. If the doctor reports
188
+ `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, reinstall a current package before
189
+ trusting the floor result.
190
+
191
+ After upgrading a globally installed package, run `entwurf install-meta-bridge`
192
+ again before trusting native Claude Code sessions. The installed statusline and
193
+ MCP entries use stable bin shims, and the marketplace source uses a version-stable
194
+ operator data dir, but the Claude plugin bundle/cache still has to be
195
+ re-materialized for the new package writer. Existing Claude Code sessions should
196
+ be restarted after reinstall.
197
+
184
198
  For manual configuration, [`pi/settings.reference.json`](./pi/settings.reference.json)
185
199
  shows the pi adapter settings shape, and the external-host examples below show
186
200
  plain MCP registrations.
@@ -4,9 +4,11 @@ End-to-end install of **entwurf** on a host with only `git` available — no
4
4
  node, no npm package, no pi binary, no dotfiles. The point is to validate the
5
5
  public install surface as an outside user would experience it.
6
6
 
7
- > **Scope.** This is the entwurf 0.12.1 install recipe. The base package install
7
+ > **Scope.** This is the entwurf 0.12.5 install recipe. The base package install
8
8
  > is **neutral npm**, not `pi install npm:...`. Pi is an optional adapter lane for
9
- > the ACP provider / control-socket runtime.
9
+ > the ACP provider / control-socket runtime. The 0.12.5 floor fix is explicitly
10
+ > covered here: installed packages under `node_modules` must not run raw `.ts`
11
+ > bridge, doctor, or plugin-hook helpers through Node strip-types.
10
12
 
11
13
  `entwurf` is a garden-citizen dispatch substrate and meta-bridge. It does not
12
14
  provide, copy, or mediate backend credentials — it lets the official backend CLI
@@ -29,7 +31,7 @@ ssh cleanhost 'uname -a; whoami; which git node npm pi claude 2>/dev/null'
29
31
  |---|---|---|
30
32
  | Node | **24** recommended; `>=22.6.0` minimum | `engines.node` (Node strip-types / ESM runtime) |
31
33
  | npm | bundled with Node 24 | public package install path |
32
- | entwurf | `@junghanacs/entwurf` | neutral npm package; exposes `entwurf` and `entwurf-bridge` bins |
34
+ | entwurf | `@junghanacs/entwurf` | neutral npm package; exposes `entwurf`, `entwurf-bridge`, and `entwurf-statusline` bins |
33
35
  | pi binary | **optional**, `@earendil-works/pi-coding-agent >=0.80.3 <0.81` | needed only for the pi adapter / ACP provider / spawn-bg resume lane |
34
36
 
35
37
  ## Stage 0 — Node 24 via nvm
@@ -62,6 +64,7 @@ npm install -g @junghanacs/entwurf
62
64
 
63
65
  which entwurf
64
66
  which entwurf-bridge
67
+ which entwurf-statusline
65
68
  entwurf --help | head -5
66
69
  ```
67
70
 
@@ -72,9 +75,14 @@ MCP bridge can boot in Claude Code / Codex / Antigravity without pi present.
72
75
  ## Stage 2 — auth-free bridge boot
73
76
 
74
77
  Prove the installed MCP server answers `tools/list` from inside `node_modules`.
75
- This is the regression that 0.12.0 missed: Node refuses `--experimental-strip-types`
76
- for `.ts` under `node_modules`, so the installed package must boot the prebuilt
77
- JS under `mcp/entwurf-bridge/dist/`.
78
+ This is the first `node_modules` strip-types regression fixed in 0.12.0: Node
79
+ refuses `--experimental-strip-types` for `.ts` under `node_modules`, so the
80
+ installed package must boot the prebuilt JS under `mcp/entwurf-bridge/dist/`.
81
+ The same installed-vs-dev split then closes the other two `.ts`-at-runtime
82
+ surfaces on that same fence: the `doctor-meta-bridge` store-scan helper (0.12.4)
83
+ and the plugin `SessionStart`/`UserPromptSubmit` hook (0.12.5, runs the compiled
84
+ `dist/pi-extensions/meta-bridge-hook.js` when installed). Installed packages run
85
+ tsc-emitted JS on every one of these; dev clones keep the `.ts` source.
78
86
 
79
87
  ```bash
80
88
  node --input-type=module <<'JS'
@@ -141,6 +149,31 @@ entwurf install-meta-bridge
141
149
  entwurf doctor-meta-bridge
142
150
  ```
143
151
 
152
+ On an installed package (`.../node_modules/@junghanacs/entwurf`), the doctor must
153
+ not try to strip-types-run raw `.ts` helpers or hooks. In the output, check for
154
+ these 0.12.5 floor-regression signals:
155
+
156
+ ```text
157
+ ok cached SessionStart hook executes cleanly in an isolated temp agent dir
158
+ ok full store scan: no corrupt records, duplicate nativeSessionId, body/filename drift, or backend↔wakeMode contradiction
159
+ ok check-entwurf-v2-surface: shipped surface source present; exhaustive source-shape gate is a repo/release invariant (not run under node_modules)
160
+ ```
161
+
162
+ If any of those sections reports `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`,
163
+ the host is still running a pre-0.12.5 package or a broken tarball. Reinstall the
164
+ current package and re-run `entwurf install-meta-bridge && entwurf doctor-meta-bridge`.
165
+
166
+ Upgrade invariant: every global npm/pnpm package upgrade must be followed by
167
+ `entwurf install-meta-bridge` from that same installed binary and then
168
+ `entwurf doctor-meta-bridge`. Installed statusline/MCP entries use stable bin
169
+ shims and the marketplace source lives in a version-stable operator data dir, but
170
+ package managers still do not re-materialize Claude's plugin bundle/cache. If a
171
+ dev checkout's `./run.sh doctor-meta-bridge` expects repo-owned paths while the
172
+ global install intentionally owns the meta-bridge (or the reverse), that is an
173
+ ownership mismatch — run the doctor from the surface that intentionally owns the
174
+ install, or reinstall from the other surface. Restart already-open Claude Code
175
+ sessions after changing the meta-bridge install.
176
+
144
177
  A plain external MCP host can call tools but is non-replyable. A garden-native
145
178
  meta-session has a garden id, a mailbox, and a trusted sender marker; it can call
146
179
  `entwurf_self`, receive mailbox wakeups, and be replied to by garden id.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * meta-bridge-hook — the Claude Code native-session entry shell (1.0.0 step 4).
3
+ *
4
+ * THE load-bearing hook. Shipped inside a plugin bundle whose `hooks/hooks.json`
5
+ * wires it to `SessionStart` (and `CwdChanged` / `UserPromptSubmit`). Because a
6
+ * plugin's hooks load at STARTUP (a bare skill's load only on invocation, after
7
+ * SessionStart has passed), this fires on every native Claude Code open and makes
8
+ * that session a garden citizen WITHOUT any pi JSONL of its own:
9
+ *
10
+ * stdin {session_id, transcript_path, cwd} (Claude hook envelope)
11
+ * -> upsertMetaSession(claude-code) idempotent create/attach the record
12
+ * -> gardenId the session's garden address
13
+ * -> arm watchPath <mailbox>/<gardenId>/inbox.signal idle-wake doorbell
14
+ * -> stdout hookSpecificOutput.watchPaths (SessionStart / CwdChanged only)
15
+ *
16
+ * The record is keyed/looked-up by `native_session_id` (THE authority,
17
+ * scanByNativeId); the idle-wake mailbox is keyed by GARDEN id — that is the
18
+ * whole point of the meta-bridge over the raw per-session prototype: one garden
19
+ * address a sender can target, decoupled from the backend's native id grammar.
20
+ *
21
+ * FAILURE POLICY (decided, see NEXT.md step 4): the runtime hook is
22
+ * BEST-EFFORT + LOG. It never screams into the user's terminal and never blocks
23
+ * startup — on any error it appends a line to `<pi-agent-dir>/meta-bridge-hook.log`
24
+ * and emits an empty `{}` (no arm). The fail-LOUD surface is the doctor
25
+ * (step 5), which reads that log + the meta-record dir to catch a silent miss.
26
+ *
27
+ * watchPaths can be emitted only from SessionStart / CwdChanged / FileChanged, so
28
+ * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
+ * re-arm the idle watch — the record's address is restored, the wake is not.
30
+ *
31
+ * Run (dev clone): `<node> --experimental-strip-types <plugin-root>/meta-bridge-hook.ts`.
32
+ * Run (installed): `<node> <plugin-root>/meta-bridge-hook.js` — the tsc-emitted
33
+ * closure (build-bridge → dist), because Node REFUSES strip-types on a `.ts` below
34
+ * node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). The installer picks
35
+ * the artifact by layout (0.12.5), mirroring start.sh / store-doctor.
36
+ * Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); `rewriteRelativeImportExtensions`
37
+ * rewrites that to `./lib/meta-session.js` in the emitted closure. The installer
38
+ * copies the lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
39
+ */
40
+ import * as fs from "node:fs";
41
+ import * as path from "node:path";
42
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
43
+ function logLine(level, message) {
44
+ try {
45
+ // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
46
+ const file = path.join(path.dirname(defaultMetaSessionsDir()), "meta-bridge-hook.log");
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`);
49
+ }
50
+ catch {
51
+ /* logging is best-effort; a broken log must not break the session */
52
+ }
53
+ }
54
+ /** Emit a hook response on stdout and exit 0. `{}` means "did nothing, do not block startup". */
55
+ function emit(payload) {
56
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
57
+ process.exit(0);
58
+ }
59
+ /**
60
+ * Map the hook event to a receiver-marker arm provenance. ONLY the genuinely
61
+ * arm-capable events map; any other (a future/unknown hook event) returns null so we
62
+ * never mint an "active receiver" presence we cannot back — fail-closed, not an
63
+ * optimistic session-start. UserPromptSubmit never reaches here (it early-returns
64
+ * before the arm block).
65
+ */
66
+ function armProvenanceFor(eventName) {
67
+ if (eventName === "SessionStart")
68
+ return "session-start";
69
+ if (eventName === "CwdChanged")
70
+ return "cwd-changed";
71
+ if (eventName === "FileChanged")
72
+ return "file-changed";
73
+ return null;
74
+ }
75
+ function main() {
76
+ let raw;
77
+ try {
78
+ raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin (the Claude hook envelope)
79
+ }
80
+ catch (err) {
81
+ logLine("ERROR", `stdin read failed: ${err instanceof Error ? err.message : String(err)}`);
82
+ emit({});
83
+ }
84
+ let env;
85
+ try {
86
+ const parsed = JSON.parse(raw);
87
+ if (typeof parsed !== "object" || parsed === null)
88
+ throw new Error("envelope is not an object");
89
+ env = parsed;
90
+ }
91
+ catch (err) {
92
+ logLine("ERROR", `envelope parse failed: ${err instanceof Error ? err.message : String(err)}`);
93
+ emit({});
94
+ }
95
+ const sessionId = typeof env.session_id === "string" ? env.session_id : "";
96
+ const transcriptPath = typeof env.transcript_path === "string" ? env.transcript_path : "";
97
+ // cwd: prefer the envelope's, fall back to the process cwd (the hook runs in the session's cwd).
98
+ const cwd = typeof env.cwd === "string" && env.cwd.length > 0 ? env.cwd : process.cwd();
99
+ const modelEnvelope = env.model;
100
+ const model = typeof modelEnvelope === "object" &&
101
+ modelEnvelope !== null &&
102
+ typeof modelEnvelope.id === "string"
103
+ ? modelEnvelope.id
104
+ : typeof env.model_id === "string"
105
+ ? env.model_id
106
+ : undefined;
107
+ const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
108
+ if (!sessionId || !transcriptPath) {
109
+ // A degraded envelope: cannot mint an honest reference record. Log + no-op
110
+ // rather than write a half-record or guess a transcript path. LEVEL depends
111
+ // on the event: a degraded SessionStart / CwdChanged means the session FAILED
112
+ // to become (or refresh) a garden citizen — that is the silent registration
113
+ // miss the doctor must catch (blocker #2), so ERROR. UserPromptSubmit only
114
+ // ever does a best-effort record backfill, so a degraded one is just WARN.
115
+ const degradedLevel = eventName === "UserPromptSubmit" ? "WARN" : "ERROR";
116
+ logLine(degradedLevel, `degraded envelope (event=${eventName}, session_id=${sessionId ? "set" : "MISSING"}, transcript_path=${transcriptPath ? "set" : "MISSING"})`);
117
+ emit({});
118
+ }
119
+ let gardenId;
120
+ try {
121
+ const result = upsertMetaSession({
122
+ input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
123
+ onSkip: (filename, e) => logLine("WARN", `scan skipped ${filename}: ${e.message}`),
124
+ });
125
+ gardenId = result.record.gardenId;
126
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
127
+ }
128
+ catch (err) {
129
+ // Best-effort: a broken record store must surface via the doctor, not by
130
+ // breaking the user's session open. Log and continue with no arm. This is
131
+ // the silent-registration-miss (blocker #2): the session opened fine but is
132
+ // NOT a garden citizen — the doctor catches it via this ERROR line.
133
+ logLine("ERROR", `upsert failed (event=${eventName}, native=${sessionId}): ${err instanceof Error ? err.message : String(err)}`);
134
+ emit({});
135
+ }
136
+ // Sender marker, keyed by the shared Claude Code parent pid: the user-scope
137
+ // MCP child (same parent) reads it at entwurf_v2 send time to promote this
138
+ // session from anonymous external-mcp to a REPLYABLE meta-session sender —
139
+ // process ancestry, not cwd inference (same repo + multiple sessions would be
140
+ // ambiguous). Best-effort: a failed marker only costs reply-addressability
141
+ // (WARN), it does not break the session or the receiver path.
142
+ //
143
+ // SE-1/SE-2 (dual-owner fix): write ONLY for the direct parent (process.ppid =
144
+ // the Claude CLI that ran this hook, verified the native tree is direct — the
145
+ // plugin host is not in between). The old code ALSO wrote a marker for the
146
+ // grandparent. That grandparent is the login shell (e.g. bash under ghostty/i3),
147
+ // which OUTLIVES the Claude session: when Claude exits, the grandparent marker's
148
+ // ownerStartKey still matches a live pid, so it passes readMetaSenderMarker's
149
+ // reuse guard and the dead session keeps looking like a live, replyable receiver
150
+ // — a false-positive "active receiver" leak. The owner must be the watchPaths
151
+ // subscriber (the Claude CLI), nothing higher. If a stray topology means the MCP
152
+ // child's shared ancestor is not process.ppid, that resolves to "no marker"
153
+ // (fail-closed, honest) rather than a wrong-but-live grandparent identity.
154
+ const ownerPid = process.ppid;
155
+ if (typeof ownerPid === "number" && ownerPid > 0) {
156
+ try {
157
+ writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
158
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
159
+ }
160
+ catch (err) {
161
+ logLine("WARN", `sender marker write failed (event=${eventName}, pid=${ownerPid}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
162
+ }
163
+ }
164
+ // watchPaths is emittable only from SessionStart / CwdChanged / FileChanged.
165
+ // UserPromptSubmit reaches here only to backfill the record above; it must NOT
166
+ // claim to arm a watch it cannot (decided).
167
+ if (eventName === "UserPromptSubmit") {
168
+ emit({});
169
+ }
170
+ try {
171
+ const mailbox = path.join(defaultMetaMailboxDir(), gardenId);
172
+ fs.mkdirSync(mailbox, { recursive: true });
173
+ const signal = path.join(mailbox, "inbox.signal");
174
+ if (!fs.existsSync(signal))
175
+ fs.writeFileSync(signal, "", { mode: 0o600 });
176
+ logLine("INFO", `armed watch ${signal}`);
177
+ // Receiver presence marker (SE-2): written on the arm-capable hook path that
178
+ // emits watchPaths, keyed by garden id with the watch owner pid (= the Claude
179
+ // CLI, process.ppid — same single owner as the sender marker, never the
180
+ // grandparent). It records that a LIVE owner reached the watch-arm emit; it is
181
+ // not proof the host ack'd the watch registration. This is what lets a sender
182
+ // tell a live receiver from a terminated one whose record still lingers.
183
+ // Best-effort: a failed/skipped marker only costs deliverability detection
184
+ // (WARN), it does not break the arm. An unknown event maps to null provenance →
185
+ // no marker (fail-closed: never claim an active receiver we cannot back).
186
+ const ownerPid = process.ppid;
187
+ const armProvenance = armProvenanceFor(eventName);
188
+ if (armProvenance === null) {
189
+ logLine("WARN", `receiver marker skipped — non-arm event ${eventName} (garden=${gardenId})`);
190
+ }
191
+ else if (typeof ownerPid === "number" && ownerPid > 0) {
192
+ try {
193
+ writeMetaReceiverMarker({
194
+ gardenId,
195
+ backend: "claude-code",
196
+ nativeSessionId: sessionId,
197
+ ownerPid,
198
+ armProvenance,
199
+ });
200
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
201
+ }
202
+ catch (err) {
203
+ logLine("WARN", `receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
204
+ }
205
+ }
206
+ emit({
207
+ hookSpecificOutput: {
208
+ hookEventName: eventName,
209
+ watchPaths: [signal],
210
+ },
211
+ });
212
+ }
213
+ catch (err) {
214
+ logLine("ERROR", `arm failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
215
+ emit({}); // record landed; only the arm failed — the doctor will flag the missing watch.
216
+ }
217
+ }
218
+ main();
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * meta-bridge-store-doctor — fail-loud full scan for meta-session records.
4
+ *
5
+ * The store is the authority for native→garden lookup. Doctor must therefore
6
+ * refuse corrupt records, duplicate nativeSessionId authorities, and body ↔
7
+ * filename drift instead of auto-pruning or silently picking one.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { parseMetaIdentity } from "../pi-extensions/lib/meta-session.js";
12
+ const dir = process.argv[2];
13
+ if (!dir) {
14
+ console.error("usage: node --experimental-strip-types scripts/meta-bridge-store-doctor.ts <meta-sessions-dir>");
15
+ process.exit(2);
16
+ }
17
+ const failures = [];
18
+ const nativeToFiles = new Map();
19
+ let scanned = 0;
20
+ if (!fs.existsSync(dir)) {
21
+ console.log(`meta-store scan ok: ${dir} does not exist yet (0 records)`);
22
+ process.exit(0);
23
+ }
24
+ for (const filename of fs.readdirSync(dir).sort()) {
25
+ if (!filename.endsWith(".meta.json"))
26
+ continue;
27
+ scanned += 1;
28
+ const file = path.join(dir, filename);
29
+ try {
30
+ // dual-read (3D-4 commit1): parseMetaIdentity reads v1 AND v2 and normalizes,
31
+ // so doctor's gardenId/nativeSessionId checks survive the v2 cut.
32
+ const id = parseMetaIdentity(fs.readFileSync(file, "utf8"));
33
+ const expectedFilename = `${id.gardenId}.meta.json`;
34
+ if (filename !== expectedFilename) {
35
+ failures.push(`${filename}: body/filename drift — body gardenId=${id.gardenId}, expected filename ${expectedFilename}`);
36
+ }
37
+ const files = nativeToFiles.get(id.nativeSessionId) ?? [];
38
+ files.push(filename);
39
+ nativeToFiles.set(id.nativeSessionId, files);
40
+ }
41
+ catch (err) {
42
+ failures.push(`${filename}: ${err instanceof Error ? err.message : String(err)}`);
43
+ }
44
+ }
45
+ for (const [nativeSessionId, files] of nativeToFiles.entries()) {
46
+ if (files.length > 1) {
47
+ failures.push(`duplicate nativeSessionId ${JSON.stringify(nativeSessionId)} in ${files.join(", ")} — authority ambiguity; prune manually`);
48
+ }
49
+ }
50
+ if (failures.length > 0) {
51
+ for (const failure of failures)
52
+ console.error(`FAIL: ${failure}`);
53
+ process.exit(1);
54
+ }
55
+ console.log(`meta-store scan ok: ${scanned} record(s) scanned, no corrupt/duplicate/drift records`);
@@ -33,10 +33,35 @@
33
33
  // Clear the root `exclude` (it filters out every `.ts`-extension lib file the
34
34
  // bridge graph needs). Only node_modules must stay excluded.
35
35
  "exclude": ["../../node_modules"],
36
- // Just the bridge entry (+ the dependency-free protocol.js it reaches via an
37
- // explicit `.js` import). tsc auto-follows the import graph STATIC imports
38
- // AND the lazy `await import("./entwurf-preflight.ts")` and emits exactly the
39
- // boot closure, nothing more. This tracks graph drift automatically and keeps
40
- // the pi-lane-only acp/* (unreached at boot) out of the tarball.
41
- "include": ["./src/index.ts", "../../protocol.js"]
36
+ // The bridge boot entry (+ the dependency-free protocol.js it reaches via an
37
+ // explicit `.js` import) PLUS the doctor's store-scan helper. tsc auto-follows
38
+ // the import graphSTATIC imports AND the lazy
39
+ // `await import("./entwurf-preflight.ts")` and emits exactly that closure,
40
+ // nothing more (the pi-lane-only acp/* stays out of the tarball).
41
+ //
42
+ // WHY store-doctor rides THIS build: meta-bridge-doctor.sh's full store scan
43
+ // must run from an INSTALLED package (under node_modules, where strip-types is
44
+ // refused) — the same node_modules boundary start.sh crosses. Its only repo
45
+ // import is pi-extensions/lib/meta-session.ts, which the bridge graph ALREADY
46
+ // emits here, so it adds ONE leaf JS (dist/scripts/meta-bridge-store-doctor.js)
47
+ // and ZERO new deps. It is NOT in index.ts's boot closure, so
48
+ // check-entwurf-bridge-pi-free (which walks the index eager closure) is unaffected.
49
+ //
50
+ // WHY meta-bridge-hook rides THIS build (0.12.5): the SessionStart/UserPromptSubmit
51
+ // plugin hook ran as raw `.ts` (`node ${CLAUDE_PLUGIN_ROOT}/meta-bridge-hook.ts`).
52
+ // Under an installed marketplace source below node_modules that is
53
+ // ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — the SAME strip-types fence
54
+ // start.sh (0.12.1) and the store-doctor (0.12.4) already cross by shipping JS.
55
+ // The hook was the last `.ts`-at-runtime surface; emitting it here closes that
56
+ // class for good (installer copies the dist JS when installed, keeps `.ts` for a
57
+ // dev clone). Its only repo import is meta-session.ts, ALREADY emitted for the
58
+ // store-doctor, so this adds ONE leaf JS (dist/pi-extensions/meta-bridge-hook.js)
59
+ // and ZERO new deps. It is a plugin hook, never in index.ts's boot closure, so
60
+ // check-entwurf-bridge-pi-free is unaffected.
61
+ "include": [
62
+ "./src/index.ts",
63
+ "../../protocol.js",
64
+ "../../scripts/meta-bridge-store-doctor.ts",
65
+ "../../pi-extensions/meta-bridge-hook.ts"
66
+ ]
42
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -132,7 +132,8 @@
132
132
  },
133
133
  "bin": {
134
134
  "entwurf": "./run.sh",
135
- "entwurf-bridge": "./mcp/entwurf-bridge/start.sh"
135
+ "entwurf-bridge": "./mcp/entwurf-bridge/start.sh",
136
+ "entwurf-statusline": "./scripts/meta-bridge-statusline.sh"
136
137
  },
137
138
  "peerDependenciesMeta": {
138
139
  "@earendil-works/pi-ai": {
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/meta-bridge-hook.ts"
9
+ "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/__HOOK_ENTRY__"
10
10
  }
11
11
  ]
12
12
  }
@@ -17,7 +17,7 @@
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
20
- "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/meta-bridge-hook.ts"
20
+ "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/__HOOK_ENTRY__"
21
21
  }
22
22
  ]
23
23
  }
@@ -28,7 +28,7 @@
28
28
  "hooks": [
29
29
  {
30
30
  "type": "command",
31
- "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/meta-bridge-hook.ts"
31
+ "command": "__NODE_BIN__ ${CLAUDE_PLUGIN_ROOT}/__HOOK_ENTRY__"
32
32
  }
33
33
  ]
34
34
  }
@@ -28,9 +28,14 @@
28
28
  * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
29
  * re-arm the idle watch — the record's address is restored, the wake is not.
30
30
  *
31
- * Run: `<node> --experimental-strip-types <plugin-root>/meta-bridge-hook.ts`.
32
- * Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); the installer copies
33
- * that lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
31
+ * Run (dev clone): `<node> --experimental-strip-types <plugin-root>/meta-bridge-hook.ts`.
32
+ * Run (installed): `<node> <plugin-root>/meta-bridge-hook.js` the tsc-emitted
33
+ * closure (build-bridge dist), because Node REFUSES strip-types on a `.ts` below
34
+ * node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). The installer picks
35
+ * the artifact by layout (0.12.5), mirroring start.sh / store-doctor.
36
+ * Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); `rewriteRelativeImportExtensions`
37
+ * rewrites that to `./lib/meta-session.js` in the emitted closure. The installer
38
+ * copies the lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
34
39
  */
35
40
 
36
41
  import * as fs from "node:fs";
package/run.sh CHANGED
@@ -1731,6 +1731,16 @@ check_pack() {
1731
1731
  # this dist JS when present (the .ts source can't strip-types under
1732
1732
  # node_modules). prepack runs on `npm pack --dry-run`, so it is in this gate.
1733
1733
  "mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js"
1734
+ # 0.12.4 — the doctor's node_modules-safe store-scan artifact. meta-bridge-
1735
+ # doctor.sh runs this prebuilt JS when installed under node_modules (strip-types
1736
+ # refuses the .ts there), same boundary start.sh crosses. build-bridge emits it.
1737
+ "mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js"
1738
+ # 0.12.5 — the node_modules-safe plugin hook + its lib. install-meta-bridge copies
1739
+ # these compiled JS into the assembled plugin when installed (raw .ts can't
1740
+ # strip-types under node_modules). meta-session.js is shared with the store-doctor
1741
+ # above; listed here too so the hook axis fails loud if the emit graph drops it.
1742
+ "mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
1743
+ "mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
1734
1744
  "scripts/postinstall-chmod.cjs"
1735
1745
  "pi/entwurf-capabilities.json"
1736
1746
  "pi/entwurf-targets.json"
@@ -1880,6 +1890,13 @@ check_pack_install() {
1880
1890
  "mcp/entwurf-bridge/src/index.ts"
1881
1891
  # 0.12.1 C — prepack-built node_modules-safe boot artifact (see check-pack).
1882
1892
  "mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js"
1893
+ # 0.12.4 — prebuilt node_modules-safe store-scan artifact for the doctor
1894
+ # (see check-pack). The installed-scan smoke below runs exactly this file.
1895
+ "mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js"
1896
+ # 0.12.5 — node_modules-safe plugin hook + lib (see check-pack). The installed
1897
+ # hook regression below runs exactly this compiled JS from under node_modules.
1898
+ "mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
1899
+ "mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
1883
1900
  "scripts/postinstall-chmod.cjs"
1884
1901
  "pi/entwurf-capabilities.json"
1885
1902
  "pi/entwurf-targets.json"
@@ -2036,8 +2053,8 @@ check_pack_install() {
2036
2053
  fail "[check-pack-install] npm-managed layout missing executable run.sh (postinstall-chmod did not run?) at $npm_pkg"
2037
2054
  return 1
2038
2055
  fi
2039
- if [ ! -x "$npmroot/node_modules/.bin/entwurf" ] || [ ! -x "$npmroot/node_modules/.bin/entwurf-bridge" ]; then
2040
- fail "[check-pack-install] npm-managed neutral install missing package bins (entwurf / entwurf-bridge)"
2056
+ if [ ! -x "$npmroot/node_modules/.bin/entwurf" ] || [ ! -x "$npmroot/node_modules/.bin/entwurf-bridge" ] || [ ! -x "$npmroot/node_modules/.bin/entwurf-statusline" ]; then
2057
+ fail "[check-pack-install] npm-managed neutral install missing package bins (entwurf / entwurf-bridge / entwurf-statusline)"
2041
2058
  ls -l "$npmroot/node_modules/.bin" 2>/dev/null | sed 's/^/ /' >&2 || true
2042
2059
  return 1
2043
2060
  fi
@@ -2058,6 +2075,121 @@ check_pack_install() {
2058
2075
  fi
2059
2076
  echo "[check-pack-install] npm-managed install regression pass (hoisted-dep run.sh install wrote settings)"
2060
2077
 
2078
+ # Installed meta-bridge ownership regression (0.12.5): package upgrades must not
2079
+ # bake versioned pnpm-store paths into Claude settings. MCP already uses the
2080
+ # stable `entwurf-bridge` bin; statusLine must now use `entwurf-statusline`, and
2081
+ # the plugin marketplace source must be the version-stable operator data dir
2082
+ # rather than <node_modules>/pi/meta-bridge/.assembled. Use a fake claude CLI so
2083
+ # this stays deterministic/offline while running the REAL installed
2084
+ # install-meta-bridge path and meta-bridge-state apply.
2085
+ local fake_claude_dir="$npm_tmp/fake-claude-bin" fake_claude_log="$npm_tmp/fake-claude.log"
2086
+ mkdir -p "$fake_claude_dir"
2087
+ cat > "$fake_claude_dir/claude" <<'SH'
2088
+ #!/usr/bin/env bash
2089
+ printf '%s\n' "$*" >> "${FAKE_CLAUDE_LOG:?}"
2090
+ case "$1${2:+ $2}" in
2091
+ "--version") echo "2.1.197 (Claude Code)" ;;
2092
+ "plugin validate") : ;;
2093
+ "plugin uninstall") : ;;
2094
+ "plugin marketplace") : ;;
2095
+ "plugin install") : ;;
2096
+ "plugin list") printf '%s\n' "entwurf-meta-receive@meta-bridge-local" " Status: enabled" ;;
2097
+ "mcp remove") : ;;
2098
+ "mcp add") : ;;
2099
+ "mcp get") printf '%s\n' "Scope: User config" "Status: ✔ Connected" ;;
2100
+ *) : ;;
2101
+ esac
2102
+ exit 0
2103
+ SH
2104
+ chmod +x "$fake_claude_dir/claude"
2105
+ local mb_home="$npm_tmp/meta-home" mb_cfg="$npm_tmp/meta-claude" mb_log
2106
+ mkdir -p "$mb_home" "$mb_cfg"
2107
+ mb_log=$(HOME="$mb_home" XDG_DATA_HOME="$mb_home/.local/share" CLAUDE_CONFIG_DIR="$mb_cfg" FAKE_CLAUDE_LOG="$fake_claude_log" PATH="$fake_claude_dir:$npmroot/node_modules/.bin:$PATH" "$npm_pkg/run.sh" install-meta-bridge 2>&1) || {
2108
+ fail "[check-pack-install] installed install-meta-bridge failed under fake claude:"
2109
+ echo "$mb_log" | tail -20 | sed 's/^/ /' >&2
2110
+ return 1
2111
+ }
2112
+ local stable_asm="$mb_home/.local/share/entwurf/meta-bridge/.assembled"
2113
+ local installed_hook="$stable_asm/entwurf-meta-receive/meta-bridge-hook.js"
2114
+ if [ ! -f "$installed_hook" ]; then
2115
+ fail "[check-pack-install] installed meta-bridge did not assemble the compiled hook JS into the stable operator data dir: $installed_hook"
2116
+ return 1
2117
+ fi
2118
+ if [ -f "$stable_asm/entwurf-meta-receive/meta-bridge-hook.ts" ]; then
2119
+ fail "[check-pack-install] installed meta-bridge shipped a raw .ts hook — installed packages must run compiled JS (strip-types is refused under node_modules)"
2120
+ return 1
2121
+ fi
2122
+ # The artifact existing is not enough (review BLOCKER 1): Claude runs the hooks.json
2123
+ # COMMAND, so assert the baked command actually targets the compiled .js with both
2124
+ # placeholders resolved. A bake that mis-targeted .ts (or left a placeholder) would
2125
+ # still pass the direct-JS smoke below, then fail live.
2126
+ local installed_hooks_json="$stable_asm/entwurf-meta-receive/hooks/hooks.json"
2127
+ if ! grep -q 'meta-bridge-hook\.js' "$installed_hooks_json"; then
2128
+ fail "[check-pack-install] installed hooks.json does not point at the compiled meta-bridge-hook.js: $installed_hooks_json"
2129
+ return 1
2130
+ fi
2131
+ if grep -qE 'meta-bridge-hook\.ts|__HOOK_ENTRY__|__NODE_BIN__' "$installed_hooks_json"; then
2132
+ fail "[check-pack-install] installed hooks.json references a raw .ts entry or an unbaked placeholder (__HOOK_ENTRY__/__NODE_BIN__): $installed_hooks_json"
2133
+ return 1
2134
+ fi
2135
+ if [ -e "$npm_pkg/pi/meta-bridge/.assembled/entwurf-meta-receive" ]; then
2136
+ fail "[check-pack-install] installed meta-bridge still assembled inside the versioned package store: $npm_pkg/pi/meta-bridge/.assembled"
2137
+ return 1
2138
+ fi
2139
+ # 0.12.5 strip-types-fence regression (GPT safety pin). The compiled hook must run
2140
+ # FROM UNDER node_modules with plain node — the exact fence that broke oracle's
2141
+ # 0.12.4 raw-.ts hook (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). L2 relocates
2142
+ # the runtime marketplace source to XDG, but this hardens the ARTIFACT itself so
2143
+ # it is safe even if a future cache/marketplace path lands under node_modules.
2144
+ local nm_probe="$npmroot/node_modules/@junghanacs/entwurf/pi/meta-bridge/.assembled-packprobe/entwurf-meta-receive"
2145
+ mkdir -p "$nm_probe/lib"
2146
+ cp "$installed_hook" "$nm_probe/meta-bridge-hook.js"
2147
+ cp "$stable_asm/entwurf-meta-receive/lib/meta-session.js" "$nm_probe/lib/meta-session.js"
2148
+ cp "$stable_asm/entwurf-meta-receive/lib/session-id.js" "$nm_probe/lib/session-id.js"
2149
+ cp "$stable_asm/entwurf-meta-receive/entwurf-capabilities.json" "$nm_probe/entwurf-capabilities.json"
2150
+ local probe_env='{"session_id":"pack-probe","transcript_path":"/tmp/x.jsonl","cwd":"/tmp","hook_event_name":"SessionStart","model":{"id":"probe"}}'
2151
+ local probe_out
2152
+ if probe_out="$(printf '%s' "$probe_env" | env PI_CODING_AGENT_DIR="$(mktemp -d)" CLAUDE_PLUGIN_ROOT="$nm_probe" node "$nm_probe/meta-bridge-hook.js" 2>&1)" && printf '%s' "$probe_out" | grep -q hookSpecificOutput; then
2153
+ : # compiled hook crosses the node_modules strip-types fence safely
2154
+ else
2155
+ fail "[check-pack-install] compiled hook failed to run under node_modules with plain node: $(printf '%s' "$probe_out" | tr '\n' ' ' | cut -c1-200)"
2156
+ rm -rf "$nm_probe"; return 1
2157
+ fi
2158
+ # FAIL-reproduction (review BLOCKER 2): a raw .ts at the SAME node_modules location
2159
+ # must be refused SPECIFICALLY by the strip-types fence — not by some unrelated
2160
+ # failure. A missing lib would also exit nonzero and hollow out the proof, so
2161
+ # capture stderr and require the exact ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
2162
+ cp "$npm_pkg/pi-extensions/meta-bridge-hook.ts" "$nm_probe/meta-bridge-hook.ts"
2163
+ local ts_out ts_rc
2164
+ ts_out="$(printf '%s' "$probe_env" | env PI_CODING_AGENT_DIR="$(mktemp -d)" CLAUDE_PLUGIN_ROOT="$nm_probe" node "$nm_probe/meta-bridge-hook.ts" 2>&1)" && ts_rc=0 || ts_rc=$?
2165
+ if [ "${ts_rc:-0}" -eq 0 ]; then
2166
+ fail "[check-pack-install] raw .ts hook UNEXPECTEDLY ran under node_modules — strip-types fence moved; the compiled-hook rationale must be revisited"
2167
+ rm -rf "$nm_probe"; return 1
2168
+ fi
2169
+ if ! printf '%s' "$ts_out" | grep -q 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'; then
2170
+ fail "[check-pack-install] raw .ts hook failed under node_modules but NOT via the strip-types fence (expected ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING); got: $(printf '%s' "$ts_out" | tr '\n' ' ' | cut -c1-200) — the regression no longer proves the compiled-hook rationale"
2171
+ rm -rf "$nm_probe"; return 1
2172
+ fi
2173
+ rm -rf "$nm_probe"
2174
+ echo "[check-pack-install] installed hook is node_modules-safe compiled JS (hooks.json points at .js; runs under node_modules with plain node; raw .ts refused there by the strip-types fence)"
2175
+ python3 - "$mb_cfg/settings.json" "$mb_home/.claude.json" "$stable_asm" <<'PY'
2176
+ import json, sys
2177
+ settings = json.load(open(sys.argv[1]))
2178
+ root = json.load(open(sys.argv[2]))
2179
+ stable_asm = sys.argv[3]
2180
+ market = settings.get("extraKnownMarketplaces", {}).get("meta-bridge-local", {})
2181
+ assert market == {"source": {"source": "directory", "path": stable_asm}}, market
2182
+ assert settings.get("statusLine") == {"type": "command", "command": "entwurf-statusline"}, settings.get("statusLine")
2183
+ mcp = root.get("mcpServers", {}).get("entwurf-bridge", {})
2184
+ assert mcp.get("command") == "entwurf-bridge" and mcp.get("args") == [], mcp
2185
+ PY
2186
+ if ! grep -q "$stable_asm" "$fake_claude_log"; then
2187
+ fail "[check-pack-install] fake claude did not receive the stable marketplace path during install-meta-bridge"
2188
+ sed 's/^/ /' "$fake_claude_log" >&2 || true
2189
+ return 1
2190
+ fi
2191
+ echo "[check-pack-install] installed meta-bridge ownership pass (stable statusline bin + stable marketplace dir + stable MCP bin)"
2192
+
2061
2193
  # 0.12.1 C — installed bridge BOOT regression. This is the test whose absence
2062
2194
  # let the 0.12.0 install bug ship: the README's bridge launcher was
2063
2195
  # `node --experimental-strip-types src/index.ts`, which Node REFUSES under
@@ -2127,6 +2259,52 @@ JS
2127
2259
  fi
2128
2260
  echo "[check-pack-install] installed bridge boot pass (dist boots under node_modules, pi-free: $boot_out)"
2129
2261
 
2262
+ # 0.12.4 — installed STORE-DOCTOR regression. meta-bridge-doctor.sh's full store
2263
+ # scan runs the prebuilt dist JS when it lives under node_modules (strip-types
2264
+ # refuses the .ts there — the same class as the bridge boot above). Before the dist
2265
+ # split the doctor ran the raw .ts and reported a FALSE "corrupt records" FAIL on
2266
+ # EVERY installed host. Prove the shipped dist JS scans a fixture store with PLAIN
2267
+ # node (a strip-types fallback would crash with the exact
2268
+ # ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING) AND that its rewritten import of the
2269
+ # emitted meta-session.js resolves — a real record exercises parseMetaIdentity.
2270
+ local installed_store_doctor="$npm_pkg/mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js"
2271
+ if [ ! -f "$installed_store_doctor" ]; then
2272
+ fail "[check-pack-install] installed store-doctor missing prebuilt dist (prepack did not emit it into the tarball?): $installed_store_doctor"
2273
+ return 1
2274
+ fi
2275
+ local sd_fixture="$npm_tmp/store-fixture"
2276
+ mkdir -p "$sd_fixture"
2277
+ # One valid v2 record: filename == "${gardenId}.meta.json" (no drift), unique
2278
+ # nativeSessionId (no dupe). gardenId must match YYYYMMDDTHHMMSS-[0-9a-f]{6}.
2279
+ printf '%s\n' '{"schemaVersion":2,"gardenId":"20990101T000000-abcdef","backend":"claude-code","nativeSessionId":"native-store-doctor-fixture","cwd":"/tmp/entwurf-fixture","model":null,"transcriptPath":null,"parentGardenId":null,"isEntwurf":false,"createdAt":"2099-01-01T00:00:00.000Z","recordUpdatedAt":"2099-01-01T00:00:00.000Z"}' \
2280
+ > "$sd_fixture/20990101T000000-abcdef.meta.json"
2281
+ local sd_out
2282
+ if ! sd_out=$(node "$installed_store_doctor" "$sd_fixture" 2>&1); then
2283
+ fail "[check-pack-install] installed store-doctor FAILED to scan under node_modules (strip-types-under-node_modules regression, or the emitted meta-session import broke):"
2284
+ echo "$sd_out" | tail -15 | sed 's/^/ /' >&2
2285
+ return 1
2286
+ fi
2287
+ if ! grep -q "1 record(s) scanned" <<<"$sd_out"; then
2288
+ fail "[check-pack-install] installed store-doctor scanned but did not report the fixture record (parseMetaIdentity path not exercised?): $sd_out"
2289
+ return 1
2290
+ fi
2291
+ echo "[check-pack-install] installed store-doctor scan pass (dist JS scans under node_modules with plain node: $sd_out)"
2292
+
2293
+ # 0.12.4 — installed DOCTOR DISPATCH lock. The artifact above proves the store-scan
2294
+ # target runs under node_modules; this proves the doctor SCRIPT actually routes to
2295
+ # it (store scan → dist JS) and defers the strip-types-only source-shape gate
2296
+ # (v2-surface) when installed, instead of running raw .ts and false-failing.
2297
+ local installed_doctor="$npm_pkg/scripts/meta-bridge-doctor.sh"
2298
+ if ! grep -q 'dist/scripts/meta-bridge-store-doctor.js' "$installed_doctor"; then
2299
+ fail "[check-pack-install] installed doctor does not dispatch the store scan to the dist JS under node_modules: $installed_doctor"
2300
+ return 1
2301
+ fi
2302
+ if ! grep -q 'shipped surface source present' "$installed_doctor"; then
2303
+ fail "[check-pack-install] installed doctor does not defer the v2-surface source-shape gate on installed hosts: $installed_doctor"
2304
+ return 1
2305
+ fi
2306
+ echo "[check-pack-install] installed doctor dispatch lock pass (store-scan → dist JS, v2-surface deferred)"
2307
+
2130
2308
  ok "[check-pack-install] publish install smoke pass"
2131
2309
  return 0
2132
2310
  }
@@ -17,6 +17,19 @@ MKT_NAME="meta-bridge-local"
17
17
  PLUGIN="entwurf-meta-receive"
18
18
  CLAUDE_CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
19
19
  REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
20
+ # LIB_EXT tracks the hook artifact mode (0.12.5): installed packages ship the
21
+ # tsc-emitted `.js` closure (node_modules-safe), dev clones run the `.ts` source.
22
+ # The writer-version parity below hashes meta-session.<LIB_EXT> so an installed
23
+ # `.js` bundle is compared against the SAME-pipeline dist `.js`, never against the
24
+ # `.ts` source (which would hash-mismatch and false-STALE).
25
+ case "$REPO" in
26
+ */node_modules/@junghanacs/entwurf)
27
+ ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
28
+ LIB_EXT="js" ;;
29
+ *)
30
+ ASM="$REPO/pi/meta-bridge/.assembled"
31
+ LIB_EXT="ts" ;;
32
+ esac
20
33
  # shellcheck source=scripts/meta-bridge-hook-log.sh
21
34
  source "$REPO/scripts/meta-bridge-hook-log.sh"
22
35
 
@@ -65,7 +78,7 @@ if command -v python3 >/dev/null; then
65
78
  # which key drifted — silent instead of fail-loud. As an `if` condition the
66
79
  # substitution's nonzero status is consumed (set -e suspended), so we always
67
80
  # reach the detail. stderr (drift detail) is captured via `2>&1 >/dev/null`.
68
- if CHECK_ERR="$(python3 "$REPO/scripts/meta-bridge-state.py" check --repo "$REPO" --asm "$REPO/pi/meta-bridge/.assembled" 2>&1 >/dev/null)"; then
81
+ if CHECK_ERR="$(python3 "$REPO/scripts/meta-bridge-state.py" check --repo "$REPO" --asm "$ASM" 2>&1 >/dev/null)"; then
69
82
  ok "state file present and managed settings/MCP keyset survives intact"
70
83
  else
71
84
  bad "managed settings/MCP keyset missing or overwritten — another consumer may have clobbered a pi-owned key. Re-run ./run.sh install-meta-bridge. Drift detail:"
@@ -93,8 +106,23 @@ if [ -n "$CACHE_HOOKS" ]; then
93
106
  # line 158) so BAKED="" routes to that bad(...): an installed hooks.json exists but
94
107
  # the doctor cannot read its load-bearing hook command, so the store-churn guard is
95
108
  # blind — a verify-impossible state must fail loud, not pass as a soft warn (A3b).
96
- BAKED="$({ grep -oE '"command": "[^ ]+ \$\{CLAUDE_PLUGIN_ROOT\}/meta-bridge-hook.ts"' "$CACHE_HOOKS" || true; } | head -1 | sed -E 's/.*"command": "([^ ]+) .*/\1/')"
97
- if [ -n "$BAKED" ] && [ -x "$BAKED" ]; then ok "baked node exists + executable: $BAKED"
109
+ # Match either hook artifact (0.12.5): installed ships meta-bridge-hook.js, dev
110
+ # clones meta-bridge-hook.ts. Capture the whole command so we run the SAME entry
111
+ # file the live hook runs (never a hardcoded extension that drifts from install).
112
+ HOOK_CMD="$({ grep -oE '"command": "[^ ]+ \$\{CLAUDE_PLUGIN_ROOT\}/meta-bridge-hook\.(ts|js)"' "$CACHE_HOOKS" || true; } | head -1)"
113
+ BAKED="$(printf '%s' "$HOOK_CMD" | sed -E 's/.*"command": "([^ ]+) .*/\1/')"
114
+ HOOK_FILE="$(printf '%s' "$HOOK_CMD" | sed -E 's#.*/([^/"]+)"$#\1#')"
115
+ if [ -n "$BAKED" ] && [ -x "$BAKED" ]; then
116
+ ok "baked node exists + executable: $BAKED"
117
+ CACHE_ROOT="$(cd "$(dirname "$CACHE_HOOKS")/.." && pwd)"
118
+ TMP_AGENT="$(mktemp -d 2>/dev/null || mktemp -d -t entwurf-doctor-hook)"
119
+ HOOK_ENV='{"session_id":"doctor-synthetic-native","transcript_path":"/tmp/entwurf-doctor-synthetic-transcript.jsonl","cwd":"/tmp","hook_event_name":"SessionStart","model":{"id":"doctor-model"}}'
120
+ if HOOK_OUT="$(printf '%s' "$HOOK_ENV" | env PI_CODING_AGENT_DIR="$TMP_AGENT" CLAUDE_PLUGIN_ROOT="$CACHE_ROOT" "$BAKED" "$CACHE_ROOT/$HOOK_FILE" 2>&1)" && printf '%s' "$HOOK_OUT" | grep -q 'hookSpecificOutput'; then
121
+ ok "cached SessionStart hook executes cleanly in an isolated temp agent dir"
122
+ else
123
+ bad "cached SessionStart hook failed to execute — stale plugin cache / unsupported TS syntax / broken bundle. Re-run install-meta-bridge from the intended surface. Detail: $(printf '%s' "$HOOK_OUT" | tr '\n' ' ' | cut -c1-300)"
124
+ fi
125
+ rm -rf "$TMP_AGENT" 2>/dev/null || true
98
126
  elif [ -n "$BAKED" ]; then bad "baked node path is DEAD (nix GC / version bump?): $BAKED — re-run ./run.sh install-meta-bridge"
99
127
  else bad "could not parse baked node path from $CACHE_HOOKS — hook command format drift; doctor cannot verify the NixOS store-churn guard. Re-run ./run.sh install-meta-bridge or update the doctor parser."; fi
100
128
  else
@@ -114,9 +142,15 @@ except Exception:
114
142
  print('')
115
143
  PY
116
144
  )"
117
- EXPECTED_STATUSLINE="$REPO/scripts/meta-bridge-statusline.sh"
118
- if [ "$STATUSLINE_CMD" = "$EXPECTED_STATUSLINE" ]; then ok "statusLine.command is repo-owned: $STATUSLINE_CMD"; else bad "statusLine.command drifted (got '$STATUSLINE_CMD', expected '$EXPECTED_STATUSLINE')"; fi
119
- if [ -x "$EXPECTED_STATUSLINE" ]; then ok "statusline executable"; else bad "statusline script not executable: $EXPECTED_STATUSLINE"; fi
145
+ EXPECTED_STATUSLINE="$(python3 "$REPO/scripts/meta-bridge-state.py" desired-statusline --repo "$REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("command", ""))')"
146
+ if [ "$STATUSLINE_CMD" = "$EXPECTED_STATUSLINE" ]; then ok "statusLine.command matches expected (dev: checkout path; installed: entwurf-statusline bin shim): $STATUSLINE_CMD"; else bad "statusLine.command drifted (got '$STATUSLINE_CMD', expected '$EXPECTED_STATUSLINE')"; fi
147
+ if [[ "$EXPECTED_STATUSLINE" == */* ]]; then
148
+ if [ -x "$EXPECTED_STATUSLINE" ]; then ok "statusline executable"; else bad "statusline script not executable: $EXPECTED_STATUSLINE"; fi
149
+ elif command -v "$EXPECTED_STATUSLINE" >/dev/null 2>&1; then
150
+ ok "statusline bin resolves on PATH: $(command -v "$EXPECTED_STATUSLINE")"
151
+ else
152
+ bad "statusline bin not on PATH: $EXPECTED_STATUSLINE"
153
+ fi
120
154
  SAMPLE_STATUSLINE_OUT="$(printf '%s' '{"session_id":"doctor-no-record","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":1,"current_usage":{"input_tokens":1}}}' | "$EXPECTED_STATUSLINE" 2>/dev/null || true)"
121
155
  if [ "$(printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | wc -l | tr -d ' ')" = "2" ] && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '🪛' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q ' cc | s'; then ok "statusline synthetic execution emits two rows (work context + identity)"; else bad "statusline synthetic execution failed or omitted two-row work/identity marker"; fi
122
156
  else
@@ -147,10 +181,36 @@ echo "[meta-record store]"
147
181
  mkdir -p "$META_SESSIONS" 2>/dev/null || true
148
182
  if [ -d "$META_SESSIONS" ] && [ -w "$META_SESSIONS" ]; then ok "writable: $META_SESSIONS"; else bad "meta-sessions dir not writable: $META_SESSIONS"; fi
149
183
  if command -v node >/dev/null; then
150
- if node --experimental-strip-types "$REPO/scripts/meta-bridge-store-doctor.ts" "$META_SESSIONS" >/dev/null 2>&1; then
151
- ok "full store scan: no corrupt records, duplicate nativeSessionId, body/filename drift, or backend↔wakeMode contradiction"
152
- else
153
- bad "full store scan failed corrupt/duplicate/drift meta-record(s) present. Inspect with: node --experimental-strip-types $REPO/scripts/meta-bridge-store-doctor.ts $META_SESSIONS"
184
+ # Full store scan — mode by LOCATION (mirror mcp/entwurf-bridge/start.sh). Node
185
+ # REFUSES --experimental-strip-types for a .ts under node_modules
186
+ # (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so an INSTALLED package must scan
187
+ # via the prebuilt dist JS (build-bridge emits it; files:["mcp/"] ships it); a DEV
188
+ # CLONE lives outside node_modules and runs the .ts source. Before this split the
189
+ # doctor always ran the .ts and reported a FALSE "corrupt records" FAIL on every
190
+ # installed host (the 0.12.0 strip-types-under-node_modules class the bridge boot
191
+ # already fixed, but this doctor helper still ran raw .ts).
192
+ case "$REPO" in
193
+ */node_modules/*)
194
+ store_doctor="$REPO/mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js"
195
+ if [ -f "$store_doctor" ]; then
196
+ store_scan=(node "$store_doctor" "$META_SESSIONS")
197
+ else
198
+ bad "installed under node_modules but prebuilt store-doctor missing: $store_doctor — the tarball ships it via prepack; reinstall @junghanacs/entwurf"
199
+ store_scan=()
200
+ fi
201
+ ;;
202
+ *)
203
+ store_scan=(node --experimental-strip-types "$REPO/scripts/meta-bridge-store-doctor.ts" "$META_SESSIONS")
204
+ ;;
205
+ esac
206
+ # Guard the expansion: bash 3.2 (macOS) + set -u errors on "${arr[@]}" for a
207
+ # zero-length array, so only run/print when the command was actually built.
208
+ if [ "${#store_scan[@]}" -gt 0 ]; then
209
+ if "${store_scan[@]}" >/dev/null 2>&1; then
210
+ ok "full store scan: no corrupt records, duplicate nativeSessionId, body/filename drift, or backend↔wakeMode contradiction"
211
+ else
212
+ bad "full store scan failed — corrupt/duplicate/drift meta-record(s) present. Inspect with: ${store_scan[*]}"
213
+ fi
154
214
  fi
155
215
  else
156
216
  bad "cannot scan meta-record store without node"
@@ -215,10 +275,17 @@ registry_for_ms() { # $1=bundle meta-session.ts → sibling plugin-root registry
215
275
  dirname "$(dirname "$1")"
216
276
  }
217
277
 
218
- SRC_MS="$REPO/pi-extensions/lib/meta-session.ts"
278
+ # Source authority for parity depends on the mode: an installed bundle carries the
279
+ # dist `.js`, so compare it against the dist `.js` (same tsc pipeline → hash-equal);
280
+ # a dev clone carries the `.ts`, so compare against the `.ts` source.
281
+ if [ "$LIB_EXT" = "js" ]; then
282
+ SRC_MS="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
283
+ else
284
+ SRC_MS="$REPO/pi-extensions/lib/meta-session.ts"
285
+ fi
219
286
  SRC_REG="$REPO/pi/entwurf-capabilities.json"
220
- ASM_MS="$REPO/pi/meta-bridge/.assembled/$PLUGIN/lib/meta-session.ts"
221
- INST_MS="$(ls "$CLAUDE_CFG"/plugins/cache/"$MKT_NAME"/"$PLUGIN"/*/lib/meta-session.ts 2>/dev/null | head -1 || true)"
287
+ ASM_MS="$ASM/$PLUGIN/lib/meta-session.$LIB_EXT"
288
+ INST_MS="$(ls "$CLAUDE_CFG"/plugins/cache/"$MKT_NAME"/"$PLUGIN"/*/lib/meta-session."$LIB_EXT" 2>/dev/null | head -1 || true)"
222
289
 
223
290
  ASM_ROOT="$(registry_for_ms "$ASM_MS")"
224
291
  INST_ROOT="$(registry_for_ms "${INST_MS:-}")"
@@ -297,11 +364,30 @@ if [ -f "$V2_MCP_SRC" ] && grep -q 'server.tool(' "$V2_MCP_SRC" && grep -q '"ent
297
364
  else
298
365
  bad "MCP entwurf_v2 verb not found in entwurf-bridge"
299
366
  fi
300
- if [ -f "$V2_SURFACE_SRC" ] && (cd "$REPO" && node --experimental-strip-types scripts/check-entwurf-v2-surface.ts >/dev/null 2>&1); then
301
- ok "check-entwurf-v2-surface gate passes (surface adapter + pi-native/MCP wiring)"
302
- else
303
- bad "check-entwurf-v2-surface gate FAILED or surface adapter missingrun: ./run.sh check-entwurf-v2-surface"
304
- fi
367
+ # check-entwurf-v2-surface is a SOURCE-SHAPE gate: it strip-types-runs a .ts gate
368
+ # (which imports typebox, a dev dep) to assert the surface source shape. Under
369
+ # node_modules that cannot run (strip-types refused + dev deps absent), AND its
370
+ # subject the shipped surface source shapeis a repo/release invariant already
371
+ # enforced by the release gate. So on an INSTALLED host, confirm the surface source
372
+ # SHIPPED and defer the exhaustive gate; the live runtime wiring (flag/tool/MCP verb)
373
+ # is already proven by the grep checks just above. Running the full .ts gate here
374
+ # produced a FALSE "gate FAILED" on every installed host.
375
+ case "$REPO" in
376
+ */node_modules/*)
377
+ if [ -f "$V2_SURFACE_SRC" ]; then
378
+ ok "check-entwurf-v2-surface: shipped surface source present; exhaustive source-shape gate is a repo/release invariant (not run under node_modules)"
379
+ else
380
+ bad "entwurf-v2-surface.ts missing from the installed package — the v2 surface source did not ship"
381
+ fi
382
+ ;;
383
+ *)
384
+ if [ -f "$V2_SURFACE_SRC" ] && (cd "$REPO" && node --experimental-strip-types scripts/check-entwurf-v2-surface.ts >/dev/null 2>&1); then
385
+ ok "check-entwurf-v2-surface gate passes (surface adapter + pi-native/MCP wiring)"
386
+ else
387
+ bad "check-entwurf-v2-surface gate FAILED or surface adapter missing — run: ./run.sh check-entwurf-v2-surface"
388
+ fi
389
+ ;;
390
+ esac
305
391
  # prefixRoots operator policy (5d-4b): the shared env SSOT both v2 surfaces read. Display
306
392
  # ONLY — the parser SSOT lives in entwurf-v2-surface.ts (proven by check-entwurf-v2-surface);
307
393
  # the doctor does NOT re-implement parsing. Unset ⇒ no prefix promotion (the safe default).
@@ -13,8 +13,9 @@
13
13
  # entwurf_inbox_read tool comes from USER-scope entwurf-bridge MCP wiring
14
14
  # (`claude mcp add -s user ...`). Project-scoped .mcp.json is deliberately
15
15
  # not enough: a /tmp native session would wake without a receipt tool.
16
- # 2. marketplace add <repo-stable .assembled> (NOT /tmp ephemeral source
17
- # would break `claude plugin marketplace update`).
16
+ # 2. marketplace add <stable .assembled> (dev: repo-local; installed package:
17
+ # version-stable XDG data dir; NOT /tmp — ephemeral source would break
18
+ # `claude plugin marketplace update`).
18
19
  # 3. install entwurf-meta-receive@meta-bridge-local --scope user (= global:
19
20
  # every native session auto-loads it; no manual --plugin-dir).
20
21
  # 4. install/update USER-scope entwurf-bridge MCP, so every native session has
@@ -30,7 +31,31 @@ REPO="$(cd "$HERE/.." && pwd)"
30
31
  MKT_NAME="meta-bridge-local"
31
32
  PLUGIN="entwurf-meta-receive"
32
33
  SRC="$REPO/pi/meta-bridge"
33
- ASM="$SRC/.assembled"
34
+ # Dev clone: keep the marketplace source inside the checkout for transparent
35
+ # inspection. Installed package: assemble into a version-stable operator path;
36
+ # Claude settings store this directory path and package-manager upgrades do not
37
+ # rewrite it, so a pnpm-store path would go stale on version/peer churn.
38
+ #
39
+ # The SAME installed-vs-dev split also picks the hook artifact (0.12.5): an
40
+ # installed marketplace source lives below node_modules, where Node REFUSES
41
+ # `--experimental-strip-types` on `.ts` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
42
+ # so installed packages run the tsc-emitted `meta-bridge-hook.js` closure (mirrors
43
+ # start.sh/store-doctor). Dev clones live outside node_modules and run the `.ts`
44
+ # source directly for transparent editing. HOOK_ENTRY is baked into hooks.json.
45
+ case "$REPO" in
46
+ */node_modules/@junghanacs/entwurf)
47
+ ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
48
+ HOOK_ENTRY="meta-bridge-hook.js"
49
+ HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
50
+ LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
51
+ LIB_EXT="js" ;;
52
+ *)
53
+ ASM="$SRC/.assembled"
54
+ HOOK_ENTRY="meta-bridge-hook.ts"
55
+ HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
56
+ LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
57
+ LIB_EXT="ts" ;;
58
+ esac
34
59
 
35
60
  die() { echo "meta-bridge-install: $*" >&2; exit 1; }
36
61
 
@@ -65,9 +90,12 @@ mkdir -p "$ASM"
65
90
  cp -r "$SRC/.claude-plugin" "$ASM/.claude-plugin"
66
91
  cp -r "$SRC/$PLUGIN" "$ASM/$PLUGIN"
67
92
  # entry shell + its lib travel WITH the plugin so the install copy is self-contained.
68
- cp "$REPO/pi-extensions/meta-bridge-hook.ts" "$ASM/$PLUGIN/meta-bridge-hook.ts"
93
+ # Installed → tsc-emitted JS (dist, node_modules-safe); dev clone → strip-types .ts.
94
+ [ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (installed package ships it via prepack build-bridge → dist; reinstall @junghanacs/entwurf, or run 'pnpm run build-bridge' in a dev clone)."
95
+ [ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
96
+ cp "$HOOK_SRC" "$ASM/$PLUGIN/$HOOK_ENTRY"
69
97
  mkdir -p "$ASM/$PLUGIN/lib"
70
- cp "$REPO/pi-extensions/lib/meta-session.ts" "$ASM/$PLUGIN/lib/meta-session.ts"
98
+ cp "$LIB_SRC" "$ASM/$PLUGIN/lib/meta-session.$LIB_EXT"
71
99
  cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
72
100
  # v2 writer (3D-3+) reads the capability registry at runtime
73
101
  # (loadMetaCapabilityRegistry). It MUST travel at the plugin ROOT — meta-session's
@@ -76,23 +104,29 @@ cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
76
104
  # Without this, a v2 writer throws on every mint/parse. doctor-meta-bridge asserts it.
77
105
  cp "$REPO/pi/entwurf-capabilities.json" "$ASM/$PLUGIN/entwurf-capabilities.json"
78
106
  chmod +x "$ASM/$PLUGIN/scripts/doorbell.sh"
79
- # Bake the node abspath into hooks.json — the ONLY templated surface. mailbox /
80
- # meta-record dirs resolve at runtime inside entry.ts (<pi-agent-dir>, fixed ~/).
107
+ # Bake the node abspath AND the hook entry filename into hooks.json — the two
108
+ # templated surfaces (0.12.5: HOOK_ENTRY is meta-bridge-hook.js when installed,
109
+ # .ts in a dev clone). mailbox / meta-record dirs resolve at runtime inside the
110
+ # hook itself (<pi-agent-dir>, fixed ~/).
81
111
  # The plugin owns ONLY the wake/record hooks; the receiver-side entwurf_inbox_read
82
112
  # tool is NOT the plugin's job. It comes from USER-scope entwurf-bridge MCP
83
113
  # wiring (`claude mcp add -s user ...`), never a plugin .mcp.json duplicate.
84
114
  HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
85
- HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" python3 - <<'PY'
115
+ HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
86
116
  from pathlib import Path
87
117
  import os
88
118
  hooks = Path(os.environ["HOOKS_PATH"])
89
119
  node = os.environ["NODE_PATH_TO_BAKE"]
120
+ hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
90
121
  text = hooks.read_text(encoding="utf-8")
91
- if "__NODE_BIN__" not in text:
92
- raise SystemExit(f"node-path bake failed before replacement (placeholder absent in {hooks})")
93
- hooks.write_text(text.replace("__NODE_BIN__", node), encoding="utf-8")
122
+ for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
123
+ if placeholder not in text:
124
+ raise SystemExit(f"hooks bake failed before replacement ({placeholder} absent in {hooks})")
125
+ hooks.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
94
126
  PY
95
- grep -q "__NODE_BIN__" "$HOOKS" && die "node-path bake failed (placeholder still present in $HOOKS)."
127
+ for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
128
+ grep -q "$placeholder" "$HOOKS" && die "hooks bake failed ($placeholder still present in $HOOKS)."
129
+ done
96
130
  echo "[meta-bridge-install] assembled $ASM (node baked, entry+lib bundled; MCP wiring is NOT plugin-owned)"
97
131
 
98
132
  # --- validate the manifests before touching user config ---------------------
@@ -306,6 +306,12 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
306
306
 
307
307
 
308
308
  def desired_statusline(repo: Path) -> dict[str, Any]:
309
+ if is_installed_package(repo):
310
+ # Installed package: mirror the MCP stable-bin pattern. The statusLine is
311
+ # executed at render time, so a bare bin shim lets npm/pnpm update the
312
+ # target on package upgrade without rewriting Claude settings. Dev clones
313
+ # stay pinned to their checkout below.
314
+ return {"type": "command", "command": "entwurf-statusline"}
309
315
  return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
310
316
 
311
317
 
@@ -543,7 +549,16 @@ def main() -> int:
543
549
  parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
544
550
  parser.add_argument(
545
551
  "command",
546
- choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys", "desired-mcp"],
552
+ choices=[
553
+ "prepare",
554
+ "apply",
555
+ "preflight-uninstall",
556
+ "uninstall",
557
+ "check",
558
+ "managed-keys",
559
+ "desired-mcp",
560
+ "desired-statusline",
561
+ ],
547
562
  )
548
563
  parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
549
564
  parser.add_argument("--asm", default=None, type=Path)
@@ -568,6 +583,10 @@ def main() -> int:
568
583
  # write for --repo. Lets a deterministic guard assert the installed-vs-clone
569
584
  # dual-mode without spinning up a real `claude` CLI. --repo need not exist.
570
585
  print(json.dumps(desired_mcp(repo), indent=2))
586
+ elif args.command == "desired-statusline":
587
+ # Same guard surface for the statusLine dual-mode: dev clones pin the
588
+ # checkout path; installed packages use the stable bin shim.
589
+ print(json.dumps(desired_statusline(repo), indent=2))
571
590
  except StateError as exc:
572
591
  print(f"meta-bridge-state: {exc}", file=sys.stderr)
573
592
  return 1
@@ -36,4 +36,17 @@ else
36
36
  fi
37
37
 
38
38
  python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
39
+
40
+ # Remove the assembled marketplace source too — an honest inverse must not orphan
41
+ # it. Installed packages assemble into a version-stable operator data dir
42
+ # (~/.local/share/entwurf/meta-bridge/.assembled); leaving that tree behind is the
43
+ # only meta-bridge footprint uninstall would otherwise leak. Dev clones drop their
44
+ # gitignored in-checkout .assembled. Mirror install-meta-bridge's ASM resolution.
45
+ case "$REPO" in
46
+ */node_modules/@junghanacs/entwurf)
47
+ rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge"
48
+ rmdir "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf" 2>/dev/null || true ;;
49
+ *)
50
+ rm -rf "$REPO/pi/meta-bridge/.assembled" ;;
51
+ esac
39
52
  echo "[meta-bridge-uninstall] DONE"
@@ -71,6 +71,12 @@ JSON
71
71
 
72
72
  py() { python3 "$STATE" "$@" --repo "$REPO" --asm "$ASM"; }
73
73
 
74
+ DEV_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
75
+ if [ "$DEV_STATUSLINE" = "$REPO/scripts/meta-bridge-statusline.sh" ]; then ok "dev statusLine pins the checkout script"; else bad "dev statusLine command drifted: $DEV_STATUSLINE"; fi
76
+ FAKE_INSTALLED_REPO="$TMP/npmroot/node_modules/@junghanacs/entwurf"
77
+ INSTALLED_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$FAKE_INSTALLED_REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
78
+ if [ "$INSTALLED_STATUSLINE" = "entwurf-statusline" ]; then ok "installed statusLine uses the stable bin shim"; else bad "installed statusLine command drifted: $INSTALLED_STATUSLINE"; fi
79
+
74
80
  py prepare >/dev/null
75
81
  STATE_FILE="$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
76
82
  [ -f "$STATE_FILE" ] && ok "prepare writes install-state before any merge" || bad "state file missing after prepare"