@junghanacs/entwurf 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * check-resume-launch-identity — deterministic gate for `resume-launch-identity.ts`, the
3
- * record-authoritative leaf preserved when the visible-first cut deleted `spawn-bg` and every
4
- * caller it had.
3
+ * record-authoritative leaf of a visible same-id resume (its consumer is `entwurf-v2-visible-resume`).
5
4
  *
6
- * WHY A GATE FOR A CONSUMER-ZERO MODULE. Identity authority is a fail-closed risk class: this
7
- * leaf's whole job is deciding WHICH being a resume turn lands in. A preserved leaf whose only
8
- * guarantee is its own header comment is exactly the rot preservation was meant to prevent — it
9
- * would be re-adopted, months later, by a visible-resume composition that trusts prose. The cells
5
+ * WHY A GATE FOR AN IDENTITY LEAF. Identity authority is a fail-closed risk class: this leaf's whole
6
+ * job is deciding WHICH being a resume lands in. A leaf whose only guarantee is its own header
7
+ * comment would be trusted as prose by every composition that calls it. The cells
10
8
  * below are transplanted from the deleted `check-entwurf-v2-spawn-production` §9 (the plan
11
9
  * parameter became a bare garden id; nothing else about the identity contract changed), plus one
12
10
  * new cell for the #52 read the leaf's header claims and the deleted gate never isolated.
@@ -25,14 +23,18 @@
25
23
  * backend, no recorded transcript, recorded-but-missing file, no model, headerless file. The
26
24
  * missing-file cell is a regression pin: before the existence check that case fell through
27
25
  * `readSessionIdentity`'s ENOENT swallow and lied "no recorded model".
28
- * 4b. THE ONE EXPECTED REFUSAL — a backend with no same-id resume is not a defect at all: the
29
- * record is fine and the operator reached past a capability boundary. It alone carries a
30
- * TYPED reason (`target-not-pi`) so the visible-resume composition can match a FIELD instead
31
- * of parsing a sentence or re-reading the record. Everything else here stays a bare throw.
26
+ * 4b. THE ONE EXPECTED REFUSAL — a backend with no same-id resume (since #147 L2: anything but pi
27
+ * and claude-code) is not a defect at all: the record is fine and the operator reached past a
28
+ * capability boundary. It alone carries a TYPED reason (`target-backend-unsupported`) so the
29
+ * visible-resume composition can match a FIELD instead of parsing a sentence or re-reading the
30
+ * record. Everything else here stays a bare throw.
31
+ * 4e. CLAUDE CODE (#147 L2) — a claude-code record resolves to its own session id, the record's
32
+ * cwd and createdAt, reading no transcript; a session id outside the lowercase-UUID grammar and
33
+ * a recorded transcript that is not a file on disk are each refused with their own cause.
32
34
  * 4c. ABSOLUTE TRANSCRIPT — a relative recorded path would resolve against the WINDOW's own cwd
33
35
  * at launch, not against this process's, so it names a different file than the one checked
34
36
  * here. Refused before anything opens.
35
- * 5. SSOT — the module header names THIS gate, so the preserved leaf cannot advertise a
37
+ * 5. SSOT — the module header names THIS gate and its consumer, so the leaf cannot advertise a
36
38
  * certification that does not exist.
37
39
  *
38
40
  * No spawn, no socket, no timer: the launch this identity feeds is deliberately not in scope.
@@ -91,12 +93,49 @@ function main(): void {
91
93
  ok(label, msg.includes(needle));
92
94
  };
93
95
 
96
+ // ── 4e. CLAUDE CODE (#147 L2) ─────────────────────────────────────────────────
97
+ {
98
+ const native = "0b5e2a4c-1f3d-4e8a-9c7b-2d6f8e1a3c5d";
99
+ const transcript = writeTranscript("cc-session.jsonl", "vendor-owned bytes this leaf never parses\n");
100
+ const gidCc = mintRecord(native, transcript, "claude-code");
101
+ const raw = JSON.parse(fs.readFileSync(path.join(storeDir, `${gidCc}.meta.json`), "utf8")) as {
102
+ createdAt: string;
103
+ };
104
+ let cc: ReturnType<typeof resolveResumeLaunchIdentity> | null = null;
105
+ try {
106
+ cc = resolveResumeLaunchIdentity(gidCc);
107
+ } catch {
108
+ cc = null;
109
+ }
110
+ ok(
111
+ "4e: a claude-code record resolves to its own session id, the record's cwd and createdAt [QK:RESUME-ID-CC-RESOLVES]",
112
+ cc?.backend === "claude-code" &&
113
+ cc.nativeSessionId === native &&
114
+ cc.cwd === cwd &&
115
+ cc.createdAt === raw.createdAt,
116
+ );
117
+ rejects(
118
+ mintRecord("--dangerously-skip-permissions", transcript, "claude-code"),
119
+ "is not a Claude Code session id",
120
+ "4e: a recorded session id outside the UUID grammar is refused before it can become a claude flag [QK:RESUME-ID-CC-SESSION-GRAMMAR]",
121
+ );
122
+ rejects(
123
+ mintRecord("0b5e2a4c-1f3d-4e8a-9c7b-2d6f8e1a3c5e", path.join(world, "gone.jsonl"), "claude-code"),
124
+ "is not a file on disk",
125
+ "4e: a recorded transcript that is gone is refused — claude --resume would open nothing [QK:RESUME-ID-CC-TRANSCRIPT-PRESENT]",
126
+ );
127
+ }
128
+
94
129
  // ── 1. HAPPY ──────────────────────────────────────────────────────────────
95
130
  const nativeOk = "0199aaaa-1111-4222-8333-444455556666";
96
131
  const fileOk = writeTranscript("own.jsonl", sessionLine(nativeOk) + modelLine);
97
132
  const gidOk = mintRecord(nativeOk, fileOk);
98
133
  const launch = resolveResumeLaunchIdentity(gidOk);
99
- ok("1: record-backed resume resolves the recorded transcript", launch.sessionFile === fileOk);
134
+ assert.ok(launch.backend === "pi", "1: a pi record resolves as pi");
135
+ ok(
136
+ "1: record-backed resume resolves the recorded transcript",
137
+ launch.backend === "pi" && launch.sessionFile === fileOk,
138
+ );
100
139
  ok("1: resume cwd = transcript header authority (#9 cold-resume)", launch.cwd === cwd);
101
140
  ok(
102
141
  "1: resume model/provider = first model_change",
@@ -122,29 +161,29 @@ function main(): void {
122
161
  // ── 4. CAUSE FIDELITY ─────────────────────────────────────────────────────
123
162
  rejects("20260101T000000-facade", "not a garden citizen", "4: recordless gid → not a citizen");
124
163
 
125
- const gidClaude = mintRecord("claude-native-1", null, "claude-code");
164
+ const gidCodex = mintRecord("codex-thread-1", null, "codex");
126
165
  rejects(
127
- gidClaude,
128
- "is a claude-code citizen",
166
+ gidCodex,
167
+ "is a codex citizen",
129
168
  "4: out-of-domain citizen → refused as a capability-domain miss, not as citizen rank",
130
169
  );
131
170
  // This one refusal is EXPECTED, not a defect: the record is perfectly good and the operator
132
171
  // has reached past a capability boundary. So it is the only failure here that carries a
133
172
  // typed `reason` a caller can match on a FIELD — the visible-resume composition turns it
134
- // into `target-not-pi`, and if it degraded to a bare Error that caller would either parse
173
+ // into `target-backend-unsupported`, and if it degraded to a bare Error that caller would either parse
135
174
  // this sentence or read the record a second time to ask which backend it was.
136
175
  {
137
176
  let caught: Error | null = null;
138
177
  try {
139
- resolveResumeLaunchIdentity(gidClaude);
178
+ resolveResumeLaunchIdentity(gidCodex);
140
179
  } catch (err) {
141
180
  caught = err as Error;
142
181
  }
143
182
  ok(
144
183
  "4: the backend miss is a TYPED refusal carrying reason + backend, not a bare Error [QK:RESUME-ID-BACKEND-TYPED-REFUSAL]",
145
184
  caught instanceof ResumeBackendUnsupportedError &&
146
- caught.reason === "target-not-pi" &&
147
- caught.backend === "claude-code",
185
+ caught.reason === "target-backend-unsupported" &&
186
+ caught.backend === "codex",
148
187
  );
149
188
  }
150
189
 
@@ -195,7 +234,8 @@ function main(): void {
195
234
  retired.err.model === "claude-sonnet-5" &&
196
235
  routed.err instanceof AcpModelNotCuratedError &&
197
236
  routed.err.model === "claude-opus-5" &&
198
- current.identity?.provider === "entwurf" &&
237
+ current.identity?.backend === "pi" &&
238
+ current.identity.provider === "entwurf" &&
199
239
  current.identity.model === "claude-sonnet-5-5",
200
240
  );
201
241
  }
@@ -243,6 +283,7 @@ function main(): void {
243
283
  transcriptPath: fileOk,
244
284
  createdAt: "2026-03-01T12:00:00.000Z",
245
285
  recordUpdatedAt: "2026-03-01T12:30:00.000Z",
286
+ attachIncarnation: null,
246
287
  }),
247
288
  );
248
289
  // The happy-path id resolved a moment ago; the ONLY thing that changed is a second record
@@ -271,9 +312,7 @@ function main(): void {
271
312
  "5: the leaf's header names check-resume-launch-identity (no certification it does not have) [QK:RESUME-ID-GATE-SSOT]",
272
313
  src.includes("`check-resume-launch-identity`"),
273
314
  );
274
- // The preservation is deliberate and stated: a reader who finds an uncalled export must
275
- // be able to tell "kept on purpose, for the visible lane" from "forgotten".
276
- ok("5: the leaf states it is preserved with no consumer, on purpose", /no consumer in the shipped tree/.test(src));
315
+ ok("5: the leaf names its consumer", /`entwurf-v2-visible-resume` is its consumer/.test(src));
277
316
  }
278
317
 
279
318
  console.log(`\ncheck-resume-launch-identity: ${passed} checks passed`);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * meta-hook-bundle — the Claude Code hook bundle a gate stages, derived from the hook's OWN
3
+ * import graph rather than from a hand-kept file list (#147 L2 B1).
4
+ *
5
+ * The installed bundle is the entry plus exactly the `lib/` files `meta-bridge-install.sh`
6
+ * copies. A gate that hand-copies its own list can pass while the installer misses a file the
7
+ * hook now imports — the false green this module exists to remove. So a staged bundle carries
8
+ * the entry's import closure, and `installerCopiedLibFiles` reads what the installer actually
9
+ * copies, for the gate to compare the two.
10
+ *
11
+ * SCOPE, deliberately narrow: only static `from "./…"` / `from "../…"` specifiers in DOUBLE
12
+ * quotes — the one form the hook's closure (entry, meta-session, entwurf-v2-lock, session-id)
13
+ * uses today. A side-effect `import "./x"`, a dynamic `import("./x")`, a `require`, or a
14
+ * single-quoted specifier is NOT followed, so the comparison says nothing about them; this is
15
+ * not a general module resolver and must not grow into one. `meta-session`'s win32 addon is
16
+ * one such unfollowed load (`createRequire`, win32 only, outside the Linux/macOS bundle). The
17
+ * independent proof that the closure is complete is the gate RUNNING the staged compiled hook.
18
+ */
19
+
20
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync } from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ const IMPORT_RE = /\bfrom\s+"(\.{1,2}\/[^"]+)"/g;
24
+
25
+ /**
26
+ * The entry's relative-import closure, as paths relative to the entry's directory
27
+ * (`lib/meta-session.ts`, …). The entry itself is not included.
28
+ */
29
+ export function hookImportClosure(entryFile: string): string[] {
30
+ const root = path.dirname(entryFile);
31
+ const seen = new Set<string>();
32
+ const visit = (file: string): void => {
33
+ const source = readFileSync(file, "utf8");
34
+ for (const match of source.matchAll(IMPORT_RE)) {
35
+ const target = path.resolve(path.dirname(file), match[1] as string);
36
+ const rel = path.relative(root, target);
37
+ if (seen.has(rel)) continue;
38
+ seen.add(rel);
39
+ visit(target);
40
+ }
41
+ };
42
+ visit(entryFile);
43
+ return [...seen].sort();
44
+ }
45
+
46
+ /** The `lib/<name>` destinations `meta-bridge-install.sh` copies into the assembled plugin. */
47
+ export function installerCopiedLibFiles(installerText: string): string[] {
48
+ const out = new Set<string>();
49
+ for (const match of installerText.matchAll(/^cp\s.*"\$ASM\/\$PLUGIN\/lib\/([^"]+)"\s*$/gm)) {
50
+ out.add(match[1] as string);
51
+ }
52
+ return [...out].sort();
53
+ }
54
+
55
+ /**
56
+ * Stage entry + its import closure + the launcher under `root`, exactly as installed. `entryFile`
57
+ * is the source (`pi-extensions/meta-bridge-hook.ts`) or the compiled entry
58
+ * (`mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js`); the closure follows whichever.
59
+ */
60
+ export function stageMetaHookBundle(root: string, entryFile: string, launcher: string): string[] {
61
+ const closure = hookImportClosure(entryFile);
62
+ mkdirSync(path.join(root, "scripts"), { recursive: true });
63
+ copyFileSync(entryFile, path.join(root, path.basename(entryFile)));
64
+ for (const rel of closure) {
65
+ mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
66
+ copyFileSync(path.join(path.dirname(entryFile), rel), path.join(root, rel));
67
+ }
68
+ const staged = path.join(root, "scripts", "hook-launch.sh");
69
+ copyFileSync(launcher, staged);
70
+ chmodSync(staged, 0o755);
71
+ return closure;
72
+ }
@@ -6,7 +6,8 @@
6
6
  #
7
7
  # The floor is a full three-part version, not a major lane: the discriminator sits
8
8
  # in the PATCH position (2.1.138 drops `args`, 2.1.139 introduced the exec form,
9
- # 2.1.217 is the version this repo actually proved in a live session). A major-only
9
+ # 2.1.217 is the version this repo proved the exec form on in a live session, 2.1.223
10
+ # is where `--resume <id>` started finding a session outside the current project). A major-only
10
11
  # comparison like the Node floor's `-lt 24` cannot express it, so a real semver
11
12
  # compare has to exist — and it must exist exactly ONCE. Two hand-rolled compares in
12
13
  # two shell scripts is how "the installer blessed a version the doctor refuses"
@@ -16,15 +17,18 @@
16
17
  # `entwurf.claudeCodeFloor` is the SSOT and everything here reads it. There is one
17
18
  # number to move. `check-claude-floor-coherence` binds every other spelling of it.
18
19
  #
19
- # WHY THE FLOOR IS 2.1.217 AND NOT 2.1.139
20
+ # WHY THE FLOOR IS 2.1.306
20
21
  #
21
- # 2.1.139 is where the exec-form strings enter the binary, but no session was ever
22
- # run on it — the floor would have been an inference. 2.1.217 is the version whose
23
- # runtime behavior was actually observed end to end (#51 B2: `args` honored per
24
- # element, no shell on the launch path, `asyncRewake` exit 2 waking an idle session).
25
- # GLG's decision (2026-07-22): Claude Code auto-updates, so entwurf does not carry
26
- # back-compat for versions it never verified. Declare the proven floor, not the
27
- # earliest plausible one.
22
+ # 2.1.217 is the version whose exec-form runtime behavior was observed end to end
23
+ # (#51 B2: `args` honored per element, no shell on the launch path, `asyncRewake` exit 2
24
+ # waking an idle session); those observations stand. #147 added same-id visible resume
25
+ # of a Claude Code citizen, which runs `claude --resume <nativeSessionId>` from the
26
+ # record's LAST OBSERVED cwd. The vendor CLI reference (code.claude.com cli-reference,
27
+ # read 2026-10-21, unversioned page) says the session-id search covers every project on
28
+ # the machine only from v2.1.223; below it a session whose cwd moved may not be found.
29
+ # GLG's decision (2026-10-21): the floor follows what the direction needs, with no
30
+ # back-compat — 2.1.306, the version GLG relayed as running here. That number is a
31
+ # declared floor: no LIVE receipt has been taken at it yet.
28
32
  #
29
33
  # Requires: python3 (already a hard dependency of both callers).
30
34
 
@@ -53,8 +53,10 @@ esac
53
53
  # a second derivation is a second thing to forget when the layout moves.
54
54
  if [ "$LIB_EXT" = "js" ]; then
55
55
  SRC_MS="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
56
+ SRC_LOCK="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js"
56
57
  else
57
58
  SRC_MS="$REPO/pi-extensions/lib/meta-session.ts"
59
+ SRC_LOCK="$REPO/pi-extensions/lib/entwurf-v2-lock.ts"
58
60
  fi
59
61
  # shellcheck source=scripts/meta-bridge-hook-log.sh
60
62
  source "$REPO/scripts/meta-bridge-hook-log.sh"
@@ -1276,6 +1278,21 @@ fi
1276
1278
  if [ -f "$ASM_MS" ] && [ "$asm_h" != "$src_h" ]; then
1277
1279
  warn "assembled bundle differs from source ($asm_v vs $src_v) — stale .assembled; install re-assembles it"
1278
1280
  fi
1281
+ # The hook's record-arm lock (#147 L2 B1) is part of the same writer closure: a writer that
1282
+ # commits an arm binding without the lock module it imports cannot load at all, and a stale one
1283
+ # serializes against a different contract. Same authority as the writer: the INSTALLED bundle.
1284
+ src_lock_h="$(hash12 "$SRC_LOCK")"
1285
+ if [ -n "${INST_MS:-}" ]; then
1286
+ INST_LOCK="$(dirname "$INST_MS")/entwurf-v2-lock.$LIB_EXT"
1287
+ inst_lock_h="$(hash12 "$INST_LOCK")"
1288
+ if [ ! -f "$INST_LOCK" ]; then
1289
+ bad "deployed hook lock module MISSING ($INST_LOCK) — the hook imports it, so the installed writer cannot load. Run ./run.sh install-meta-bridge to redeploy."
1290
+ elif [ "$inst_lock_h" != "$src_lock_h" ]; then
1291
+ bad "deployed hook lock module STALE: installed=$inst_lock_h vs source=$src_lock_h ($INST_LOCK). Run ./run.sh install-meta-bridge to redeploy."
1292
+ else
1293
+ ok "deployed hook lock module matches source ($inst_lock_h)"
1294
+ fi
1295
+ fi
1279
1296
 
1280
1297
  # Identity-writer dependency: loadMetaCapabilityRegistry reads
1281
1298
  # entwurf-capabilities.json at runtime. In the bundle layout it must sit at the
@@ -58,11 +58,13 @@ case "$REPO" in
58
58
  HOOK_ENTRY="meta-bridge-hook.js"
59
59
  HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
60
60
  LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
61
+ LOCK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js"
61
62
  LIB_EXT="js" ;;
62
63
  *)
63
64
  HOOK_ENTRY="meta-bridge-hook.ts"
64
65
  HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
65
66
  LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
67
+ LOCK_SRC="$REPO/pi-extensions/lib/entwurf-v2-lock.ts"
66
68
  LIB_EXT="ts" ;;
67
69
  esac
68
70
 
@@ -187,9 +189,13 @@ cp -r "$SRC/$PLUGIN" "$ASM/$PLUGIN"
187
189
  # Installed → tsc-emitted JS (dist, node_modules-safe); dev clone → strip-types .ts.
188
190
  [ -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)."
189
191
  [ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
192
+ [ -f "$LOCK_SRC" ] || die "hook lock artifact missing: $LOCK_SRC (same build-bridge dist closure)."
190
193
  cp "$HOOK_SRC" "$ASM/$PLUGIN/$HOOK_ENTRY"
191
194
  mkdir -p "$ASM/$PLUGIN/lib"
192
195
  cp "$LIB_SRC" "$ASM/$PLUGIN/lib/meta-session.$LIB_EXT"
196
+ # The record-arm lock (#147 L2 B1): the hook imports the shared per-gid lock primitive, so it
197
+ # travels in the same closure. The doctor's writer-version parity hashes it beside the writer.
198
+ cp "$LOCK_SRC" "$ASM/$PLUGIN/lib/entwurf-v2-lock.$LIB_EXT"
193
199
  cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
194
200
  # v2 writer (3D-3+) reads the capability registry at runtime
195
201
  # (loadMetaCapabilityRegistry). It MUST travel at the plugin ROOT — meta-session's
@@ -15,7 +15,7 @@
15
15
  * {
16
16
  * "schemaVersion": 1, // of THIS projection, not of the records
17
17
  * "storeDir": "/abs/path",
18
- * "citizens": [ <full verbatim strict 9-field v3 records, sorted by gardenId> ],
18
+ * "citizens": [ <full verbatim strict v3 records, sorted by gardenId> ],
19
19
  * "defects": [ { "filename", "message" }, … sorted by filename ]
20
20
  * }
21
21
  *
@@ -19,9 +19,12 @@
19
19
  "claim": "BRIDGEBOOT-RESUME-CALL-TARGET-ONLY",
20
20
  "title": "a model knob is added to the resume schema, giving the caller a second way to decide what a resumed citizen is after the record already decided",
21
21
  "subject": "mcp/entwurf-bridge/src/index.ts",
22
- "find": ["\t\t\t.describe(\"Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers).\"),"],
22
+ "find": [
23
+ "\t\t\t.describe(\"Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers).\"),"
24
+ ],
23
25
  "replace": [
24
- "\t\t\t.describe(\"Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers).\"),\n\t\tmodel: z.string().optional().describe(\"Override the recorded model.\"),"
26
+ "\t\t\t.describe(\"Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers).\"),",
27
+ "\t\tmodel: z.string().optional().describe(\"Override the recorded model.\"),"
25
28
  ],
26
29
  "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
27
30
  "timeoutSeconds": 120,
@@ -0,0 +1,129 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "cc-retire-pidfd",
4
+ "mutants": [
5
+ {
6
+ "claim": "CCRETIRE-WRONG-KEY-NO-SIGNAL",
7
+ "title": "the start-key re-check after pidfd_open is gone, so a reused pid number pins and offers for termination a process the caller never checked",
8
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
9
+ "find": [" if observed != key:"],
10
+ "replace": [" if False:"],
11
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
12
+ "timeoutSeconds": 600,
13
+ "signature": "[QK:CCRETIRE-WRONG-KEY-NO-SIGNAL]",
14
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
15
+ },
16
+ {
17
+ "claim": "CCRETIRE-CANCEL-NO-SIGNAL",
18
+ "title": "an explicit `cancel` frame is read as `go`, so the caller's own refusal after re-checking its binding terminates the process anyway",
19
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
20
+ "find": [" finish(3, \"cancelled\")"],
21
+ "replace": [" line = \"go\""],
22
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
23
+ "timeoutSeconds": 600,
24
+ "signature": "[QK:CCRETIRE-CANCEL-NO-SIGNAL]",
25
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
26
+ },
27
+ {
28
+ "claim": "CCRETIRE-EOF-NO-SIGNAL",
29
+ "title": "a caller that died or closed the pipe without a word is read as `go`, so losing the caller authorises the signal",
30
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
31
+ "find": [" finish(3, \"cancelled-eof\")"],
32
+ "replace": [" kind, line = \"line\", \"go\""],
33
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
34
+ "timeoutSeconds": 600,
35
+ "signature": "[QK:CCRETIRE-EOF-NO-SIGNAL]",
36
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
37
+ },
38
+ {
39
+ "claim": "CCRETIRE-TRUNCATED-GO-NO-SIGNAL",
40
+ "title": "the bytes `go` followed by EOF with no newline are accepted as a complete frame, so a half-written control authorises the signal",
41
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
42
+ "find": ["return (\"eof\", None) if not buf else (\"truncated\", None)"],
43
+ "replace": ["return (\"eof\", None) if not buf else (\"line\", buf.decode(\"ascii\"))"],
44
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
45
+ "timeoutSeconds": 600,
46
+ "signature": "[QK:CCRETIRE-TRUNCATED-GO-NO-SIGNAL]",
47
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
48
+ },
49
+ {
50
+ "claim": "CCRETIRE-OVERSIZE-NO-SIGNAL",
51
+ "title": "the control frame bound is lifted, so an over-long frame is parsed as a line instead of refused as oversize-control",
52
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
53
+ "find": ["CONTROL_MAX = 16"],
54
+ "replace": ["CONTROL_MAX = 4096"],
55
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
56
+ "timeoutSeconds": 600,
57
+ "signature": "[QK:CCRETIRE-OVERSIZE-NO-SIGNAL]",
58
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
59
+ },
60
+ {
61
+ "claim": "CCRETIRE-PARTIAL-DEADLINE-NO-SIGNAL",
62
+ "title": "a partial frame still open at the go deadline is taken as the frame, so a caller that never finished its line authorises the signal",
63
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
64
+ "find": [" return (\"deadline\", None)"],
65
+ "replace": [" return (\"deadline\", None) if not buf else (\"line\", buf.decode(\"ascii\"))"],
66
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
67
+ "timeoutSeconds": 600,
68
+ "signature": "[QK:CCRETIRE-PARTIAL-DEADLINE-NO-SIGNAL]",
69
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
70
+ },
71
+ {
72
+ "claim": "CCRETIRE-GO-TERMINATES-SAME-PROCESS",
73
+ "title": "SIGKILL replaces SIGTERM, so the target gets no chance to run its own shutdown and the receiver sees a different death",
74
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
75
+ "find": ["signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0)"],
76
+ "replace": ["signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0)"],
77
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
78
+ "timeoutSeconds": 600,
79
+ "signature": "[QK:CCRETIRE-GO-TERMINATES-SAME-PROCESS]",
80
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
81
+ },
82
+ {
83
+ "claim": "CCRETIRE-TERM-IGNORED-NO-ESCALATION",
84
+ "title": "a target that outlives the exit bound is signalled a second time, the escalation the helper promises never to make",
85
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
86
+ "find": ["finish(4, \"exit-unobserved\", waitedS=EXIT_WAIT_S)"],
87
+ "replace": [
88
+ "signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0) or finish(4, \"exit-unobserved\", waitedS=EXIT_WAIT_S)"
89
+ ],
90
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
91
+ "timeoutSeconds": 600,
92
+ "signature": "[QK:CCRETIRE-TERM-IGNORED-NO-ESCALATION]",
93
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
94
+ },
95
+ {
96
+ "claim": "CCRETIRE-GONE-PID-REFUSED",
97
+ "title": "a pid with no process behind it is reported as `exited`, so a target that was already gone reads as a termination this helper performed",
98
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
99
+ "find": ["finish(2, \"refused\", reason=\"pid-not-found\")"],
100
+ "replace": ["finish(0, \"exited\")"],
101
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
102
+ "timeoutSeconds": 600,
103
+ "signature": "[QK:CCRETIRE-GONE-PID-REFUSED]",
104
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
105
+ },
106
+ {
107
+ "claim": "CCRETIRE-MALFORMED-REFUSED",
108
+ "title": "the Linux PID_MAX_LIMIT bound is dropped, so an out-of-range number reaches pidfd_open instead of being refused as malformed input",
109
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
110
+ "find": ["1 < int(raw_pid) <= PID_MAX"],
111
+ "replace": ["1 < int(raw_pid)"],
112
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
113
+ "timeoutSeconds": 600,
114
+ "signature": "[QK:CCRETIRE-MALFORMED-REFUSED]",
115
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
116
+ },
117
+ {
118
+ "claim": "CCRETIRE-NO-NUMERIC-KILL",
119
+ "title": "the pidfd signal is replaced by a numeric os.kill, reopening the check -> signal race the helper exists to close; no live cell can see it, only the static cell",
120
+ "subject": "pi-extensions/lib/cc-retire-pidfd.py",
121
+ "find": ["signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0)"],
122
+ "replace": ["os.kill(pid, signal.SIGTERM)"],
123
+ "gate": ["bash", "run.sh", "check-cc-retire-pidfd"],
124
+ "timeoutSeconds": 600,
125
+ "signature": "[QK:CCRETIRE-NO-NUMERIC-KILL]",
126
+ "signatureSource": "pi-extensions/lib/cc-retire-pidfd.test.ts"
127
+ }
128
+ ]
129
+ }