@junghanacs/entwurf 0.12.4 → 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,20 @@ 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
+
7
21
  ## 0.12.4 — 2026-07-01
8
22
 
9
23
  ### Fixed
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` /
@@ -187,6 +188,13 @@ raw `.ts` helpers under `node_modules`. If the doctor reports
187
188
  `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, reinstall a current package before
188
189
  trusting the floor result.
189
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
+
190
198
  For manual configuration, [`pi/settings.reference.json`](./pi/settings.reference.json)
191
199
  shows the pi adapter settings shape, and the external-host examples below show
192
200
  plain MCP registrations.
@@ -4,11 +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.4 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. The 0.12.4 floor fix is explicitly
9
+ > the ACP provider / control-socket runtime. The 0.12.5 floor fix is explicitly
10
10
  > covered here: installed packages under `node_modules` must not run raw `.ts`
11
- > doctor helpers through Node strip-types.
11
+ > bridge, doctor, or plugin-hook helpers through Node strip-types.
12
12
 
13
13
  `entwurf` is a garden-citizen dispatch substrate and meta-bridge. It does not
14
14
  provide, copy, or mediate backend credentials — it lets the official backend CLI
@@ -31,7 +31,7 @@ ssh cleanhost 'uname -a; whoami; which git node npm pi claude 2>/dev/null'
31
31
  |---|---|---|
32
32
  | Node | **24** recommended; `>=22.6.0` minimum | `engines.node` (Node strip-types / ESM runtime) |
33
33
  | npm | bundled with Node 24 | public package install path |
34
- | 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 |
35
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 |
36
36
 
37
37
  ## Stage 0 — Node 24 via nvm
@@ -64,6 +64,7 @@ npm install -g @junghanacs/entwurf
64
64
 
65
65
  which entwurf
66
66
  which entwurf-bridge
67
+ which entwurf-statusline
67
68
  entwurf --help | head -5
68
69
  ```
69
70
 
@@ -77,8 +78,11 @@ Prove the installed MCP server answers `tools/list` from inside `node_modules`.
77
78
  This is the first `node_modules` strip-types regression fixed in 0.12.0: Node
78
79
  refuses `--experimental-strip-types` for `.ts` under `node_modules`, so the
79
80
  installed package must boot the prebuilt JS under `mcp/entwurf-bridge/dist/`.
80
- The 0.12.4 doctor fix below applies the same installed-vs-dev split to
81
- `doctor-meta-bridge` helper checks.
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.
82
86
 
83
87
  ```bash
84
88
  node --input-type=module <<'JS'
@@ -146,17 +150,29 @@ entwurf doctor-meta-bridge
146
150
  ```
147
151
 
148
152
  On an installed package (`.../node_modules/@junghanacs/entwurf`), the doctor must
149
- not try to strip-types-run raw `.ts` helpers. In the output, check for these two
150
- 0.12.4 floor-regression signals:
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:
151
155
 
152
156
  ```text
157
+ ok cached SessionStart hook executes cleanly in an isolated temp agent dir
153
158
  ok full store scan: no corrupt records, duplicate nativeSessionId, body/filename drift, or backend↔wakeMode contradiction
154
159
  ok check-entwurf-v2-surface: shipped surface source present; exhaustive source-shape gate is a repo/release invariant (not run under node_modules)
155
160
  ```
156
161
 
157
- If either section reports `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, the host
158
- is still running a pre-0.12.4 package or a broken tarball. Reinstall the current
159
- package and re-run `entwurf install-meta-bridge && entwurf doctor-meta-bridge`.
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.
160
176
 
161
177
  A plain external MCP host can call tools but is non-replyable. A garden-native
162
178
  meta-session has a garden id, a mailbox, and a trusted sender marker; it can call
@@ -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();
@@ -46,5 +46,22 @@
46
46
  // emits here, so it adds ONE leaf JS (dist/scripts/meta-bridge-store-doctor.js)
47
47
  // and ZERO new deps. It is NOT in index.ts's boot closure, so
48
48
  // check-entwurf-bridge-pi-free (which walks the index eager closure) is unaffected.
49
- "include": ["./src/index.ts", "../../protocol.js", "../../scripts/meta-bridge-store-doctor.ts"]
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
+ ]
50
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.4",
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
@@ -1735,6 +1735,12 @@ check_pack() {
1735
1735
  # doctor.sh runs this prebuilt JS when installed under node_modules (strip-types
1736
1736
  # refuses the .ts there), same boundary start.sh crosses. build-bridge emits it.
1737
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"
1738
1744
  "scripts/postinstall-chmod.cjs"
1739
1745
  "pi/entwurf-capabilities.json"
1740
1746
  "pi/entwurf-targets.json"
@@ -1887,6 +1893,10 @@ check_pack_install() {
1887
1893
  # 0.12.4 — prebuilt node_modules-safe store-scan artifact for the doctor
1888
1894
  # (see check-pack). The installed-scan smoke below runs exactly this file.
1889
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"
1890
1900
  "scripts/postinstall-chmod.cjs"
1891
1901
  "pi/entwurf-capabilities.json"
1892
1902
  "pi/entwurf-targets.json"
@@ -2043,8 +2053,8 @@ check_pack_install() {
2043
2053
  fail "[check-pack-install] npm-managed layout missing executable run.sh (postinstall-chmod did not run?) at $npm_pkg"
2044
2054
  return 1
2045
2055
  fi
2046
- if [ ! -x "$npmroot/node_modules/.bin/entwurf" ] || [ ! -x "$npmroot/node_modules/.bin/entwurf-bridge" ]; then
2047
- 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)"
2048
2058
  ls -l "$npmroot/node_modules/.bin" 2>/dev/null | sed 's/^/ /' >&2 || true
2049
2059
  return 1
2050
2060
  fi
@@ -2065,6 +2075,121 @@ check_pack_install() {
2065
2075
  fi
2066
2076
  echo "[check-pack-install] npm-managed install regression pass (hoisted-dep run.sh install wrote settings)"
2067
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
+
2068
2193
  # 0.12.1 C — installed bridge BOOT regression. This is the test whose absence
2069
2194
  # let the 0.12.0 install bug ship: the README's bridge launcher was
2070
2195
  # `node --experimental-strip-types src/index.ts`, which Node REFUSES under
@@ -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
@@ -241,10 +275,17 @@ registry_for_ms() { # $1=bundle meta-session.ts → sibling plugin-root registry
241
275
  dirname "$(dirname "$1")"
242
276
  }
243
277
 
244
- 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
245
286
  SRC_REG="$REPO/pi/entwurf-capabilities.json"
246
- ASM_MS="$REPO/pi/meta-bridge/.assembled/$PLUGIN/lib/meta-session.ts"
247
- 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)"
248
289
 
249
290
  ASM_ROOT="$(registry_for_ms "$ASM_MS")"
250
291
  INST_ROOT="$(registry_for_ms "${INST_MS:-}")"
@@ -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"