@junghanacs/entwurf 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # Three paths, all the operator's own:
5
5
  #
6
- # $CODEX_HOME/hooks.json ONE complete file, ours or absent
6
+ # $CODEX_HOME/hooks.json ONE SessionStart group inside a SHARED file
7
7
  # ${XDG_DATA_HOME:-~/.local/share}/entwurf/codex-birth/helper/
8
8
  # launcher + the TS payload closure
9
9
  # .../entwurf/codex-birth/install-state.json the digest inventory that licenses removal
@@ -23,6 +23,16 @@
23
23
  # give; forging it in a file we already own would be the one shortcut that turns a security
24
24
  # prompt into a silent install.
25
25
  #
26
+ # WHAT THIS UNIT OWNS IN hooks.json, AND WHAT IT DOES NOT (#117). Until 0.22.0 this unit owned
27
+ # that file WHOLE: it refused any file it had not written and recorded a whole-file sha256. That
28
+ # was true only while entwurf was the only thing declaring a Codex hook. Herdr's official Codex
29
+ # integration appends its own `SessionStart` group and the vendor keeps running both, because
30
+ # `[source]` discovery.rs:664-665 keys trust by `<path>:<event>:<group_idx>:<handler_idx>` — trust
31
+ # is DECLARATION-scoped and always was. So this unit now owns exactly one group holding exactly
32
+ # one handler whose command is our quoted launcher path. Every other group is FOREIGN: reported,
33
+ # never certified, never rewritten. Adding and removing our group is a TEXT SPLICE
34
+ # (pi-extensions/lib/codex-declaration.js), so a neighbour's bytes come through untouched.
35
+ #
26
36
  # THE TRUST IDENTITY IS THE LAUNCHER PATH. `[source]` discovery.rs:775-792 hashes a
27
37
  # normalized identity of (event name + matcher group + the single handler) — so `type`, the
28
38
  # quoted absolute `command`, `timeout` and the absent `matcher` ARE the operator's approval,
@@ -79,6 +89,9 @@ case "$NODE_BIN" in /*) : ;; *) die "resolved node path is not absolute: $NODE_B
79
89
  NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
80
90
  [ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (the payload is stripped, not compiled); $NODE_BIN reports major '$NODE_MAJOR'."
81
91
 
92
+ DECLARATION_LIB="$REPO/pi-extensions/lib/codex-declaration.js"
93
+ [ -f "$DECLARATION_LIB" ] || die "the declaration leaf is missing: $DECLARATION_LIB (reinstall the package, or check out the repo completely)."
94
+
82
95
  PAYLOAD_SRC="$REPO/pi-extensions/$PAYLOAD_NAME"
83
96
  LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
84
97
  CODEX_CLIENT_SRC="$REPO/pi-extensions/lib/native-push/codex-ws-client.ts"
@@ -108,6 +121,38 @@ safe_own_dir() { # $1 = dir, $2 = label
108
121
  [ $((8#$mode & 0022)) -eq 0 ] || die "$2 ($1) is group/world-writable (mode $mode), so its contents are not provably ours. Nothing written."
109
122
  }
110
123
 
124
+ # The SHARED file's own ownership, judged by the SHARED predicate (sol B3, 2026-09-18).
125
+ # `classifyOwnedPath` lives in codex-declaration.js because four surfaces decide about this one
126
+ # file — this installer, the inverse, the doctor and the fresh-call preflight — and they used to
127
+ # decide differently: the two shells asked only symlink-and-regular, preflight asked owner and mode
128
+ # too, and the doctor asked neither. An install could therefore succeed and a doctor go green on
129
+ # bytes every Codex launch then refused as `codex-birth-unit-missing`.
130
+ #
131
+ # We still do NOT chmod what we share: a neighbour's mode is theirs, and the APPEND below carries
132
+ # it over untouched. Writing into a file anyone else may rewrite is the different question, and the
133
+ # answer to that one is a refusal, because a receipt cannot bind bytes somebody else controls.
134
+ owned_path_verdict() { # $1 = path, $2 = file|directory
135
+ "$NODE_BIN" -e '
136
+ import(process.argv[1]).then((m) => {
137
+ const fs = require("node:fs");
138
+ process.stdout.write(m.classifyOwnedPath(m.statOwnedPath(fs, process.argv[2]), process.getuid(), { kind: process.argv[3] }));
139
+ }).catch((err) => { process.stderr.write(String(err && err.message ? err.message : err)); process.exit(1); });
140
+ ' "$DECLARATION_LIB" "$1" "${2:-file}"
141
+ }
142
+
143
+ require_writable_share() { # $1 = path, $2 = label — `missing` is fine; we are about to create it
144
+ local verdict
145
+ verdict="$(owned_path_verdict "$1" file)" || die "$2 ($1) could not be judged: $verdict. Nothing written."
146
+ case "$verdict" in
147
+ ok|missing) : ;;
148
+ symlink) die "$2 ($1) is a SYMLINK. This unit owns that path as ONE regular file and never publishes through a link. Refusing; nothing written." ;;
149
+ not-regular) die "$2 ($1) exists and is not a regular file. Refusing; nothing written." ;;
150
+ foreign-uid) die "$2 ($1) is owned by another user, so nothing this unit certifies in it can bind what it will say next. Refusing; nothing written." ;;
151
+ writable-by-others) die "$2 ($1) is group/world-writable, so anything certified in it can be rewritten by someone else afterwards. Its mode is not ours to change either. Refusing; nothing written." ;;
152
+ *) die "$2 ($1) returned an unknown ownership verdict '$verdict'. Refusing; nothing written." ;;
153
+ esac
154
+ }
155
+
111
156
  # ── ownership preflight — READ-ONLY, and every refusal is zero-write ─────────
112
157
  # Three different facts, three different refusals, none of them silent:
113
158
  # a foreign hooks.json (no state behind it), a hand-edited one (state disagrees), and a
@@ -120,7 +165,7 @@ safe_own_dir() { # $1 = dir, $2 = label
120
165
  [ -e "$UNIT_ROOT" ] && safe_own_dir "$UNIT_ROOT" "the unit root (it holds the ownership state)"
121
166
 
122
167
  STATE_PRESENT=0
123
- RECORDED_HOOKS_SHA=""
168
+ RECORDED_SCHEMA=""
124
169
  if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
125
170
  [ -L "$STATE_FILE" ] && die "$STATE_FILE is a symlink — refusing to take replacement authority from a link. Nothing written."
126
171
  [ -f "$STATE_FILE" ] || die "$STATE_FILE is not a regular file. Nothing written."
@@ -131,14 +176,19 @@ if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
131
176
  STATE_READ="$("$NODE_BIN" -e '
132
177
  const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
133
178
  const path = require("node:path");
134
- if (s.schema !== "codex-birth-install-state/v1") throw new Error("foreign state schema " + JSON.stringify(s.schema));
179
+ // v1 is READ HERE AND NOWHERE ELSE, and only to be SUPERSEDED. It recorded a whole-file
180
+ // hooksSha256 this installer no longer consults: whether hooks.json may be edited is now
181
+ // decided by reading the live document and selecting our own declaration, not by a receipt
182
+ // from a generation that claimed the whole file. What v1 still holds that this run needs is
183
+ // the CLOSURE inventory, whose shape is identical in both schemas. Every other reader —
184
+ // uninstall, doctor, preflight — refuses v1 by name and sends the operator here.
185
+ if (s.schema !== "codex-birth-install-state/v2" && s.schema !== "codex-birth-install-state/v1") throw new Error("foreign state schema " + JSON.stringify(s.schema));
135
186
  // A state this unit never wrote — or one left in a status it does not define — is not a
136
187
  // receipt, however well its digests happen to match. `publishing` is the one interrupted
137
188
  // generation this installer knows how to finish.
138
189
  if (s.status !== "installed" && s.status !== "publishing") throw new Error("unknown state status " + JSON.stringify(s.status));
139
190
  if (s.hooksFile !== process.argv[2]) throw new Error("state is bound to " + s.hooksFile + ", not " + process.argv[2]);
140
191
  if (s.helperDir !== process.argv[3]) throw new Error("state is bound to helper dir " + s.helperDir);
141
- if (!/^[0-9a-f]{64}$/.test(s.hooksSha256)) throw new Error("hooksSha256 is not a digest");
142
192
  // The inventory is the removal/replacement authority, so it must be EXACTLY the closure
143
193
  // this unit publishes: a state naming fewer members would leave an unlisted file in the
144
194
  // helper directory that nothing can reclaim, and one naming more would license deleting
@@ -164,7 +214,7 @@ if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
164
214
  (duplicate.length ? "; duplicated " + duplicate.join(", ") : ""),
165
215
  );
166
216
  }
167
- const lines = ["HOOKSSHA\t" + s.hooksSha256];
217
+ const lines = ["SCHEMA\t" + s.schema];
168
218
  for (const f of s.helperFiles) {
169
219
  if (typeof f?.path !== "string" || f.path.length === 0 || f.path.startsWith("/") || f.path.split("/").includes("..")) {
170
220
  throw new Error("unsafe helper path " + JSON.stringify(f?.path));
@@ -175,7 +225,7 @@ if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
175
225
  process.stdout.write(lines.join("\n"));
176
226
  ' "$STATE_FILE" "$HOOKS_FILE" "$HELPER_DIR" 2>&1)" || die "the ownership state is malformed or bound elsewhere, so replacement authority is UNKNOWN: $STATE_READ
177
227
  Move $STATE_FILE aside deliberately and re-run. Nothing written."
178
- RECORDED_HOOKS_SHA="$(printf '%s\n' "$STATE_READ" | sed -n 's/^HOOKSSHA\t//p')"
228
+ RECORDED_SCHEMA="$(printf '%s\n' "$STATE_READ" | sed -n 's/^SCHEMA\t//p')"
179
229
  STATE_PRESENT=1
180
230
 
181
231
  # The closure is licensed by the SAME rule as the declaration, and for the same reason.
@@ -214,25 +264,9 @@ $STATE_READ
214
264
  EOF
215
265
  fi
216
266
 
217
- if [ -L "$HOOKS_FILE" ]; then
218
- die "$HOOKS_FILE is a SYMLINK. This unit owns that path as ONE regular file and never publishes through a link. Refusing; nothing written."
219
- fi
220
- if [ -e "$HOOKS_FILE" ]; then
221
- [ -f "$HOOKS_FILE" ] || die "$HOOKS_FILE exists and is not a regular file. Refusing; nothing written."
222
- LIVE_HOOKS_SHA="$(sha_of "$HOOKS_FILE")"
223
- if [ "$STATE_PRESENT" = "0" ]; then
224
- die "$HOOKS_FILE already exists and this host has NO entwurf ownership state ($STATE_FILE).
225
- This unit owns hooks.json as ONE COMPLETE FILE, so adopting it would silently delete hook
226
- declarations somebody else wrote. Refusing; nothing written.
227
- Move it aside deliberately and re-run — or declare your own hooks in config.toml, which the
228
- vendor merges with this file (both sources load per layer)."
229
- fi
230
- if [ "$LIVE_HOOKS_SHA" != "$RECORDED_HOOKS_SHA" ]; then
231
- die "$HOOKS_FILE was edited after install (live $LIVE_HOOKS_SHA, recorded $RECORDED_HOOKS_SHA).
232
- Republishing would throw away an edit this unit cannot read back. Refusing; nothing written.
233
- If the edit was yours, keep it and stop using this unit; otherwise run the inverse first."
234
- fi
235
- note "adopting the hooks.json this unit published (sha256 $LIVE_HOOKS_SHA) — it will be republished."
267
+ require_writable_share "$HOOKS_FILE" "the declaration file"
268
+ if [ "$STATE_PRESENT" = "1" ] && [ "$RECORDED_SCHEMA" = "codex-birth-install-state/v1" ]; then
269
+ note "superseding a v1 ownership receipt (it claimed the WHOLE hooks.json) with v2 (it certifies entwurf's own declaration) — no foreign byte is read as ours, and none is rewritten."
236
270
  fi
237
271
 
238
272
  if [ -e "$HELPER_DIR" ] || [ -L "$HELPER_DIR" ]; then
@@ -301,59 +335,113 @@ REGISTRY_SHA="$(sha_of "$STAGE/entwurf-capabilities.json")"
301
335
  # identity from the record this hook writes.
302
336
  # state not a hooks.json key at all; `[hooks.state]` is the vendor's, in config.toml, and
303
337
  # this unit neither reads nor writes that file.
304
- cat > "$HOOKS_TMP" <<HOOKSJSON
305
- {
306
- "description": "entwurf codex-birth $UNIT_VERSION mints one garden meta-record per top-level Codex thread on its first turn. Managed by scripts/codex-birth-install.sh; inverse: scripts/codex-birth-uninstall.sh. Do not edit: the exact bytes are recorded in $STATE_FILE. The vendor's trust decision for this declaration lives in config.toml and is the operator's alone.",
307
- "hooks": {
308
- "SessionStart": [
309
- {
310
- "hooks": [
311
- {
312
- "type": "command",
313
- "command": "'$LAUNCHER'",
314
- "timeout": 30
315
- }
316
- ]
338
+ #
339
+ # FOUR OUTCOMES, AND ONLY TWO OF THEM WRITE (#117). The declaration leaf decides, because it is
340
+ # the same leaf the doctor and the fresh preflight judge with:
341
+ #
342
+ # PRESENT+CERTIFIED our group is there, shaped right, and its normalized digest is the one
343
+ # this unit publishes -> hooks.json is NOT TOUCHED AT ALL. Not rewritten,
344
+ # not re-serialized, mtime unchanged. This is the ordinary re-run on a host
345
+ # where a neighbour has since re-serialized the document.
346
+ # ABSENT our group is not there -> it is APPENDED by text splice. Every foreign
347
+ # group comes through byte-for-byte, and appending (rather than inserting)
348
+ # leaves a neighbour's index — and therefore their trust receipt — alone.
349
+ # CREATE no file at all -> this unit writes the whole document, description and all.
350
+ # A description is OURS only on a file we created; into a shared file this
351
+ # unit adds no prose, because that member belongs to whoever made it.
352
+ # DRIFTED/DUPLICATED our group is there and edited, or there twice -> zero-write REFUSAL by
353
+ # name. Republishing would throw away an edit nobody can read back, and
354
+ # duplication is a birth hook the vendor would run twice.
355
+ #
356
+ # Every splice is re-parsed and deep-compared to the value it intended before it reaches a file
357
+ # (`certifySplice`), so a span edit that lands anywhere else is a refusal, not a written file.
358
+ HOOKS_DESCRIPTION="entwurf codex-birth $UNIT_VERSION — mints one garden meta-record per top-level Codex thread on its first turn. Managed by scripts/codex-birth-install.sh; inverse: scripts/codex-birth-uninstall.sh. This unit owns ONLY this SessionStart declaration; any other group in this file is foreign and is never read as ours. The vendor's trust decision for this declaration lives in config.toml and is the operator's alone."
359
+ DECISION="$("$NODE_BIN" -e '
360
+ const fs = require("node:fs");
361
+ const [, lib, hooksFile, launcher, description, outTmp] = process.argv;
362
+ import(lib).then((m) => {
363
+ const group = m.entwurfDeclarationGroup(launcher);
364
+ const digest = m.declarationDigest(group);
365
+ const emit = (verdict, detail) => process.stdout.write([verdict, digest, detail ?? ""].join("\t"));
366
+ if (!fs.existsSync(hooksFile)) {
367
+ fs.writeFileSync(outTmp, JSON.stringify({ description, hooks: { SessionStart: [group] } }, null, 2) + "\n");
368
+ return emit("CREATE", "");
369
+ }
370
+ const text = fs.readFileSync(hooksFile, "utf8");
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(text);
374
+ } catch (err) {
375
+ throw new Error(hooksFile + " is not readable JSON (" + err.message + "). This unit edits only its own declaration inside that file and will not rewrite a document it cannot read.");
376
+ }
377
+ const sel = m.selectEntwurfDeclaration(parsed, launcher);
378
+ if (sel.ok) {
379
+ if (sel.digest !== digest) {
380
+ throw new Error(
381
+ "entwurf\u2019s own declaration in " + hooksFile + " was EDITED after install (group " + sel.groupIndex +
382
+ ": live " + sel.digest + ", this unit publishes " + digest + "). Republishing would throw away an edit this unit cannot read back. If the edit was yours, keep it and stop using this unit; otherwise run the inverse first.",
383
+ );
317
384
  }
318
- ]
319
- }
320
- }
321
- HOOKSJSON
385
+ return emit("UNCHANGED", String(sel.foreign.length));
386
+ }
387
+ if (sel.code !== "declaration-absent") throw new Error(sel.code + ": " + sel.detail);
388
+ const want = JSON.parse(text);
389
+ if (want.hooks === null || typeof want.hooks !== "object" || Array.isArray(want.hooks)) {
390
+ throw new Error(hooksFile + " carries no `hooks` object, so there is nowhere to declare a SessionStart group without rewriting somebody else\u2019s grammar.");
391
+ }
392
+ want.hooks.SessionStart = (Array.isArray(want.hooks.SessionStart) ? want.hooks.SessionStart : []).concat([group]);
393
+ fs.writeFileSync(outTmp, m.certifySplice(m.appendSessionStartGroup(m.ensureSessionStartArray(text), group), want));
394
+ return emit("APPEND", String(sel.foreign.length));
395
+ }).catch((err) => {
396
+ process.stderr.write(err && err.message ? err.message : String(err));
397
+ process.exit(1);
398
+ });
399
+ ' "$DECLARATION_LIB" "$HOOKS_FILE" "$LAUNCHER" "$HOOKS_DESCRIPTION" "$HOOKS_TMP" 2>&1)" || die "$DECISION
400
+ Refusing; nothing written."
401
+ HOOKS_ACTION="$(printf '%s' "$DECISION" | cut -f1)"
402
+ DECL_SHA="$(printf '%s' "$DECISION" | cut -f2)"
403
+ FOREIGN_COUNT="$(printf '%s' "$DECISION" | cut -f3)"
404
+ # Exactly 64 hex, not "starts hex": this value is written into the ownership receipt and every
405
+ # later reader compares the live declaration to it, so a truncated or warning-polluted capture
406
+ # would record a receipt nothing can ever match.
407
+ case "$DECL_SHA" in
408
+ ""|*[!0-9a-f]*) die "the declaration leaf returned no digest (got '$DECISION'). Nothing written." ;;
409
+ esac
410
+ [ "${#DECL_SHA}" -eq 64 ] || die "the declaration leaf returned a ${#DECL_SHA}-character digest, not 64 (got '$DECISION'). Nothing written."
411
+ # `mktemp` made this file, so it always exists; chmod it unconditionally rather than behind a
412
+ # test whose false branch would be the last command of a `set -e` line.
322
413
  chmod 0644 -- "$HOOKS_TMP"
323
414
 
324
- # Prove the bytes are the grammar we think they are BEFORE they are published: a broken
325
- # hooks.json is a hook nobody declared and nobody can see failing.
326
- "$NODE_BIN" -e '
327
- const fs = require("node:fs");
328
- const f = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
329
- if (Object.keys(f).sort().join(",") !== "description,hooks") throw new Error("hooks.json top level must be exactly description+hooks");
330
- const groups = f.hooks?.SessionStart;
331
- if (Object.keys(f.hooks).join(",") !== "SessionStart") throw new Error("this unit declares SessionStart and nothing else");
332
- if (!Array.isArray(groups) || groups.length !== 1) throw new Error("hooks.SessionStart must be a single matcher group");
333
- if ("matcher" in groups[0]) throw new Error("the matcher group must carry no matcher key");
334
- if (Object.keys(groups[0]).join(",") !== "hooks") throw new Error("the matcher group must carry no key other than hooks");
335
- const hs = groups[0].hooks;
336
- if (!Array.isArray(hs) || hs.length !== 1) throw new Error("the matcher group must hold exactly one handler");
337
- if (Object.keys(hs[0]).sort().join(",") !== "command,timeout,type") throw new Error("the handler must carry exactly type+command+timeout — every extra key changes the trust identity");
338
- if (hs[0].type !== "command") throw new Error("the handler type must be command");
339
- if (hs[0].timeout !== 30) throw new Error("the handler timeout must be 30 it is part of the hash the operator trusts");
340
- if (hs[0].command !== "'"'"'" + process.argv[2] + "'"'"'") throw new Error("the command must be the single-quoted fixed launcher path, got " + hs[0].command);
341
- ' "$HOOKS_TMP" "$LAUNCHER" || die "the staged hooks.json is not the exact declaration this unit publishes. Nothing written."
342
- HOOKS_SHA="$(sha_of "$HOOKS_TMP")"
343
-
415
+ # Prove the staged bytes carry EXACTLY our declaration before they are published: a broken or
416
+ # absorbed hooks.json is a hook nobody declared and nobody can see failing. The same leaf reads
417
+ # it back, so what is asserted here is what the doctor and the preflight will assert later.
418
+ if [ "$HOOKS_ACTION" != "UNCHANGED" ]; then
419
+ "$NODE_BIN" -e '
420
+ const fs = require("node:fs");
421
+ const [, lib, staged, launcher, wantDigest] = process.argv;
422
+ import(lib).then((m) => {
423
+ const sel = m.selectEntwurfDeclaration(JSON.parse(fs.readFileSync(staged, "utf8")), launcher);
424
+ if (!sel.ok) throw new Error("the staged document does not carry our declaration: " + sel.code + " " + sel.detail);
425
+ if (sel.digest !== wantDigest) throw new Error("the staged declaration digest is " + sel.digest + ", not " + wantDigest);
426
+ }).catch((err) => {
427
+ process.stderr.write(err && err.message ? err.message : String(err));
428
+ process.exit(1);
429
+ });
430
+ ' "$DECLARATION_LIB" "$HOOKS_TMP" "$LAUNCHER" "$DECL_SHA" || die "the staged hooks.json is not the exact declaration this unit publishes. Nothing written."
431
+ fi
344
432
  write_state() { # $1 = status
345
433
  # Created here only when absent — the safe umask floors it and the explicit mode removes any
346
434
  # doubt. An EXISTING root was judged above and is left exactly as it was found.
347
435
  if [ ! -e "$UNIT_ROOT" ]; then mkdir -p -- "$UNIT_ROOT" && chmod 0755 -- "$UNIT_ROOT"; fi
348
436
  cat > "$STATE_TMP" <<STATE
349
437
  {
350
- "schema": "codex-birth-install-state/v1",
438
+ "schema": "codex-birth-install-state/v2",
351
439
  "status": "$1",
352
440
  "unitVersion": "$UNIT_VERSION",
353
441
  "writtenAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
354
442
  "nodeBin": "$NODE_BIN",
355
443
  "hooksFile": "$HOOKS_FILE",
356
- "hooksSha256": "$HOOKS_SHA",
444
+ "declaration": { "event": "SessionStart", "command": "'$LAUNCHER'", "sha256": "$DECL_SHA" },
357
445
  "hooksExistedBefore": $HOOKS_EXISTED,
358
446
  "helperDir": "$HELPER_DIR",
359
447
  "helperFiles": [
@@ -396,18 +484,36 @@ chmod 0755 -- "$LAUNCHER"
396
484
  chmod 0644 -- "$HELPER_DIR/$PAYLOAD_NAME" "$HELPER_DIR/lib/meta-session.ts" "$HELPER_DIR/lib/native-push/codex-ws-client.ts" "$HELPER_DIR/lib/session-id.js" "$HELPER_DIR/entwurf-capabilities.json"
397
485
 
398
486
  if [ ! -e "$CODEX_HOME" ]; then mkdir -p -- "$CODEX_HOME" && chmod 0755 -- "$CODEX_HOME"; fi
399
- # Strongest idempotence is an untouched file: identical bytes are not rewritten, so a
400
- # re-run leaves mtime and any vendor state keyed off it — alone.
401
- if [ -f "$HOOKS_FILE" ] && [ "$(sha_of "$HOOKS_FILE")" = "$HOOKS_SHA" ]; then
402
- note "hooks.json is already the exact declaration — not rewritten"
403
- else
404
- cp -- "$HOOKS_TMP" "$HOOKS_FILE.tmp" && mv -f -- "$HOOKS_FILE.tmp" "$HOOKS_FILE"
405
- fi
487
+ # Strongest idempotence is an UNTOUCHED file, and on a shared hooks.json that is also the only
488
+ # honest one: re-serializing a document to reproduce bytes we already agree with would rewrite
489
+ # every neighbouring declaration for no reason at all.
490
+ case "$HOOKS_ACTION" in
491
+ UNCHANGED)
492
+ note "hooks.json already carries entwurf's declaration (normalized sha256 $DECL_SHA) — NOT REWRITTEN, not one byte"
493
+ ;;
494
+ APPEND)
495
+ # The file was somebody else's before this line and is shared after it, so its MODE is
496
+ # carried over from what we found rather than reset to the one a fresh publish would use.
497
+ chmod "$(stat -c %a -- "$HOOKS_FILE")" -- "$HOOKS_TMP"
498
+ cp -- "$HOOKS_TMP" "$HOOKS_FILE.tmp" && mv -f -- "$HOOKS_FILE.tmp" "$HOOKS_FILE"
499
+ note "appended entwurf's declaration to the existing hooks.json by text splice — every foreign group came through byte-for-byte"
500
+ ;;
501
+ CREATE)
502
+ cp -- "$HOOKS_TMP" "$HOOKS_FILE.tmp" && mv -f -- "$HOOKS_FILE.tmp" "$HOOKS_FILE"
503
+ ;;
504
+ *)
505
+ die "unknown declaration decision '$HOOKS_ACTION'. Nothing more written."
506
+ ;;
507
+ esac
406
508
 
407
509
  write_state installed
408
510
 
409
511
  note "installed"
410
- note " declaration : $HOOKS_FILE (sha256 $HOOKS_SHA)"
512
+ note " declaration : $HOOKS_FILE (normalized sha256 $DECL_SHA)"
513
+ case "$FOREIGN_COUNT" in
514
+ ""|0) note " neighbours : none — entwurf is the only SessionStart declaration in that file" ;;
515
+ *) note " neighbours : $FOREIGN_COUNT foreign SessionStart group(s) in that file, neither certified nor touched" ;;
516
+ esac
411
517
  note " launcher : $LAUNCHER"
412
518
  note " state : $STATE_FILE"
413
519
  note "The vendor will ask the operator to trust this declaration ONCE, in the Codex TUI."
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env bash
2
2
  # codex-birth-uninstall — the inverse of codex-birth-install.sh.
3
3
  #
4
- # The ownership state is the ONLY input. Every removal is licensed per-byte by a digest it
5
- # recorded, so this inverse can say three different things about every path it published and
6
- # never guesses at a fourth:
4
+ # WHAT IT REMOVES FROM hooks.json, AND WHAT IT LEAVES (#117). Not the file: ONE `SessionStart`
5
+ # group, the one whose handler commands our launcher, taken out by TEXT SPLICE so every
6
+ # neighbouring declaration comes out byte-for-byte identical. The file itself is removed only
7
+ # when our group was the only group in it and no other event is declared — the case where
8
+ # removing our declaration leaves a document with nothing in it but our own prose. A top-level
9
+ # `description` is removed only when it is entwurf's own; anybody else's is a foreign byte.
10
+ #
11
+ # The ownership state is the ONLY input for the CLOSURE. Every removal there is licensed
12
+ # per-byte by a digest it recorded, so this inverse can say three different things about every
13
+ # path it published and never guesses at a fourth:
7
14
  #
8
15
  # * already absent -> done, not drift.
9
16
  # * bytes match the state -> removed.
@@ -11,6 +18,10 @@
11
18
  # edited it, and deleting an edit nobody can read back is worse
12
19
  # than leaving a file behind.
13
20
  #
21
+ # The DECLARATION is licensed the same way but by its NORMALIZED digest rather than by file
22
+ # bytes, because a neighbour re-serializing the document (measured: Herdr does) changes every
23
+ # byte of that file and nothing about what entwurf declared.
24
+ #
14
25
  # It never reads or writes config.toml, so the vendor's `[hooks.state]` trust record survives
15
26
  # this inverse untouched. Removing the declaration does not un-trust it; re-publishing the
16
27
  # SAME declaration later reuses the operator's original approval, because the trust identity
@@ -42,6 +53,21 @@ command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required (removal is l
42
53
  NODE_BIN="$(command -v node)" || die "node is not on PATH — it parses the ownership state."
43
54
  sha_of() { sha256sum -- "$1" | cut -d' ' -f1; }
44
55
 
56
+ REPO="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
57
+ DECLARATION_LIB="$REPO/pi-extensions/lib/codex-declaration.js"
58
+
59
+ # One shared ownership predicate for the file this unit SHARES — see the installer's copy of this
60
+ # comment for why four surfaces had to stop deciding it three different ways.
61
+ owned_path_verdict() { # $1 = path, $2 = file|directory
62
+ "$NODE_BIN" -e '
63
+ import(process.argv[1]).then((m) => {
64
+ const fs = require("node:fs");
65
+ process.stdout.write(m.classifyOwnedPath(m.statOwnedPath(fs, process.argv[2]), process.getuid(), { kind: process.argv[3] }));
66
+ }).catch((err) => { process.stderr.write(String(err && err.message ? err.message : err)); process.exit(1); });
67
+ ' "$DECLARATION_LIB" "$1" "${2:-file}"
68
+ }
69
+ [ -f "$DECLARATION_LIB" ] || die "the declaration leaf is missing: $DECLARATION_LIB — without it this inverse cannot tell entwurf's declaration from a neighbour's. Nothing removed."
70
+
45
71
  # Judged BEFORE anything is removed: a state sitting in a directory somebody else can write
46
72
  # is a removal licence somebody else can forge.
47
73
  safe_parent() { # $1 = dir, $2 = label
@@ -72,11 +98,21 @@ PLAN="$("$NODE_BIN" -e '
72
98
  const path = require("node:path");
73
99
  const s = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
74
100
  const hex = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
75
- if (s.schema !== "codex-birth-install-state/v1") throw new Error("foreign state schema " + JSON.stringify(s.schema));
101
+ if (s.schema !== "codex-birth-install-state/v2") {
102
+ throw new Error(
103
+ "the ownership state is " + JSON.stringify(s.schema) + ". This inverse reads codex-birth-install-state/v2, " +
104
+ "which certifies entwurf\u2019s own declaration; v1 claimed the WHOLE hooks.json and removing on its word " +
105
+ "would delete a neighbour\u2019s. Run `entwurf install-codex-birth` once to supersede the receipt, then this inverse.",
106
+ );
107
+ }
76
108
  if (s.status !== "installed" && s.status !== "publishing") throw new Error("unknown status " + JSON.stringify(s.status));
77
109
  if (typeof s.hooksFile !== "string" || !s.hooksFile.startsWith("/")) throw new Error("hooksFile is not an absolute path");
78
110
  if (typeof s.helperDir !== "string" || !s.helperDir.startsWith("/")) throw new Error("helperDir is not an absolute path");
79
- if (!hex(s.hooksSha256)) throw new Error("hooksSha256 is not a sha256 hex digest");
111
+ const decl = s.declaration;
112
+ if (decl === null || typeof decl !== "object" || Array.isArray(decl)) throw new Error("the state records no declaration receipt");
113
+ if (decl.event !== "SessionStart") throw new Error("the recorded declaration event is " + JSON.stringify(decl.event));
114
+ if (typeof decl.command !== "string" || decl.command.length === 0) throw new Error("the recorded declaration command is not a string");
115
+ if (!hex(decl.sha256)) throw new Error("the recorded declaration digest is not a sha256 hex digest");
80
116
  if (!Array.isArray(s.helperFiles) || s.helperFiles.length === 0) throw new Error("helperFiles is empty");
81
117
  // Exactly the closure this unit publishes. Removing on a SHORT inventory would leave a file
82
118
  // behind and then delete the directory that held it; a LONG one would license deleting
@@ -101,7 +137,17 @@ PLAN="$("$NODE_BIN" -e '
101
137
  (duplicate.length ? "; duplicated " + duplicate.join(", ") : ""),
102
138
  );
103
139
  }
104
- const lines = [["STATUS", s.status, s.unitVersion ?? ""].join("\t"), ["HOOKS", s.hooksSha256, s.hooksFile].join("\t")];
140
+ // WHO CREATED THE FILE is a removal authority, and the receipt has always recorded it — the
141
+ // inverse simply never read it (sol B2, 2026-09-18). Anything other than an explicit `false`
142
+ // reads as "it was already there", which is the safe direction: an old receipt that predates
143
+ // this field licenses a splice, never a delete.
144
+ const createdByUs = s.hooksExistedBefore === false;
145
+ const lines = [
146
+ ["STATUS", s.status, s.unitVersion ?? ""].join("\t"),
147
+ ["DECLCMD", "-", decl.command].join("\t"),
148
+ ["HOOKSCREATED", "-", createdByUs ? "yes" : "no"].join("\t"),
149
+ ["DECLARATION", decl.sha256, s.hooksFile].join("\t"),
150
+ ];
105
151
  const seen = new Set();
106
152
  for (const f of s.helperFiles) {
107
153
  if (typeof f?.path !== "string" || f.path.length === 0 || f.path.startsWith("/") || f.path.split("/").includes("..")) {
@@ -118,6 +164,111 @@ PLAN="$("$NODE_BIN" -e '
118
164
  Inspect it and remove the unit deliberately. Nothing removed."
119
165
 
120
166
  DRIFTED=0
167
+
168
+ # The declaration is not a file this inverse deletes — it is ONE GROUP inside a file it shares.
169
+ # Four verdicts, and only two of them touch a byte: already absent (done, not drift), drifted
170
+ # (named, left exactly as found), sole-owner (the file was ours whole, so the file goes), and
171
+ # shared (our group is spliced out and every neighbouring group comes through byte-for-byte).
172
+ REMOVE_DECLARATION() { # $1 = recorded normalized digest, $2 = hooks.json path
173
+ local want="$1" target="$2" verdict kind detail tmp owned
174
+ # THE SAME PREDICATE THE INSTALLER AND THE DOCTOR USE (sol B3, 2026-09-18). This inverse used to
175
+ # ask only symlink-and-regular, so it would happily rewrite a hooks.json owned by another user or
176
+ # writable by a group — a file whose next state nothing here can bind. Every non-`ok` verdict is
177
+ # DRIFT: named, counted, and zero-write.
178
+ owned="$(owned_path_verdict "$target" file)" || {
179
+ printf '[codex-birth-uninstall] DRIFT: %s could not be judged for ownership (%s) — leaving it exactly as found.\n' "$target" "$owned" >&2
180
+ DRIFTED=$((DRIFTED + 1)); return
181
+ }
182
+ if [ "$owned" = "missing" ]; then
183
+ note "already absent: $target"; return
184
+ fi
185
+ if [ "$owned" != "ok" ]; then
186
+ printf '[codex-birth-uninstall] DRIFT: the declaration file %s (%s) — refusing to edit it.\n' "$owned" "$target" >&2
187
+ DRIFTED=$((DRIFTED + 1)); return
188
+ fi
189
+ tmp="$(mktemp)"
190
+ verdict="$("$NODE_BIN" -e '
191
+ const fs = require("node:fs");
192
+ const [, lib, hooksFile, launcherCommand, wantDigest, outTmp, createdByUs] = process.argv;
193
+ import(lib).then((m) => {
194
+ const text = fs.readFileSync(hooksFile, "utf8");
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(text);
198
+ } catch (err) {
199
+ return process.stdout.write("DRIFT\t" + hooksFile + " is not readable JSON (" + err.message + ") — this inverse edits only its own group and will not rewrite a document it cannot read");
200
+ }
201
+ // The launcher the RECEIPT names, not one rebuilt from paths: the receipt is the authority.
202
+ const launcher = launcherCommand.replace(/^\x27|\x27$/g, "");
203
+ const sel = m.selectEntwurfDeclaration(parsed, launcher);
204
+ if (!sel.ok) {
205
+ if (sel.code === "declaration-absent") return process.stdout.write("ABSENT\t");
206
+ return process.stdout.write("DRIFT\t" + sel.code + ": " + sel.detail);
207
+ }
208
+ if (sel.digest !== wantDigest) {
209
+ return process.stdout.write(
210
+ "DRIFT\tentwurf\u2019s declaration was edited after install (group " + sel.groupIndex + ": live " + sel.digest + ", recorded " + wantDigest + ")",
211
+ );
212
+ }
213
+ // FILE — deleting the whole file — is the ONE verdict that can destroy a byte nobody here
214
+ // wrote, so it takes three proofs and not one (sol B2, 2026-09-18). "Our group is the last
215
+ // declaration" was the only one it used to take, and that is true of a file we merely
216
+ // APPENDED to: `{"description":"foreign","hooks":{"SessionStart":[]}}` has no foreign GROUP,
217
+ // so after our install the old shape classified it FILE and deleted top-level bytes
218
+ // belonging to a neighbour.
219
+ // 1. the receipt says this unit created the file (hooksExistedBefore === false),
220
+ // 2. nothing but our own two top-level keys is in it, and the description is OURS,
221
+ // 3. our group is the only declaration, under the only event.
222
+ // Anything else SPLICES: our group comes out, every other byte stays.
223
+ const events = Object.keys(parsed.hooks);
224
+ const topLevel = Object.keys(parsed).sort().join(",");
225
+ const ourDescription =
226
+ typeof parsed.description === "string" && parsed.description.startsWith(m.CODEX_BIRTH_DESCRIPTION_PREFIX);
227
+ const soleDeclaration = sel.foreign.length === 0 && events.length === 1 && events[0] === m.CODEX_BIRTH_EVENT;
228
+ if (createdByUs === "yes" && soleDeclaration && topLevel === "description,hooks" && ourDescription) {
229
+ return process.stdout.write("FILE\t");
230
+ }
231
+ const want = JSON.parse(text);
232
+ want.hooks[m.CODEX_BIRTH_EVENT].splice(sel.groupIndex, 1);
233
+ const description = want.description;
234
+ let spliced = m.certifySplice(m.removeSessionStartGroup(text, sel.groupIndex), want);
235
+ if (typeof description === "string" && description.startsWith(m.CODEX_BIRTH_DESCRIPTION_PREFIX)) {
236
+ delete want.description;
237
+ spliced = m.certifySplice(m.removeEntwurfDescription(spliced), want);
238
+ }
239
+ fs.writeFileSync(outTmp, spliced);
240
+ process.stdout.write("SPLICE\t" + String(sel.foreign.length));
241
+ }).catch((err) => {
242
+ process.stderr.write(err && err.message ? err.message : String(err));
243
+ process.exit(1);
244
+ });
245
+ ' "$DECLARATION_LIB" "$target" "$DECLARATION_COMMAND" "$want" "$tmp" "$HOOKS_CREATED_BY_US" 2>&1)" || {
246
+ rm -f -- "$tmp"
247
+ printf '[codex-birth-uninstall] DRIFT: the declaration could not be judged (%s) — leaving %s exactly as found.\n' "$verdict" "$target" >&2
248
+ DRIFTED=$((DRIFTED + 1)); return
249
+ }
250
+ kind="$(printf '%s' "$verdict" | cut -f1)"
251
+ detail="$(printf '%s' "$verdict" | cut -f2-)"
252
+ case "$kind" in
253
+ ABSENT) rm -f -- "$tmp"; note "already absent: entwurf declares nothing in $target" ;;
254
+ DRIFT)
255
+ rm -f -- "$tmp"
256
+ printf '[codex-birth-uninstall] DRIFT: %s — LEFT IN PLACE (%s).\n' "$detail" "$target" >&2
257
+ DRIFTED=$((DRIFTED + 1))
258
+ ;;
259
+ FILE) rm -f -- "$tmp" "$target"; note "removed $target (entwurf was its only declaration)" ;;
260
+ SPLICE)
261
+ # The file survives this inverse, so its MODE is one more thing that is not ours to
262
+ # change: it is carried over from what we found rather than reset to what we publish.
263
+ chmod "$(stat -c %a -- "$target")" -- "$tmp"
264
+ cp -- "$tmp" "$target.tmp" && mv -f -- "$target.tmp" "$target"
265
+ rm -f -- "$tmp"
266
+ note "removed entwurf's declaration from $target by text splice — $detail foreign group(s) preserved byte-for-byte"
267
+ ;;
268
+ *) rm -f -- "$tmp"; die "the declaration reader returned '$verdict'. Nothing else removed." ;;
269
+ esac
270
+ }
271
+
121
272
  REMOVE_ONE() { # $1 = recorded digest, $2 = absolute path, $3 = label
122
273
  local want="$1" target="$2" label="$3" live
123
274
  if [ -L "$target" ]; then
@@ -141,10 +292,14 @@ REMOVE_ONE() { # $1 = recorded digest, $2 = absolute path, $3 = label
141
292
  }
142
293
 
143
294
  HELPER_DIR=""
295
+ DECLARATION_COMMAND=""
296
+ HOOKS_CREATED_BY_US="no"
144
297
  while IFS="$(printf '\t')" read -r kind digest target; do
145
298
  case "$kind" in
146
299
  STATUS) note "state: status=$digest unitVersion=$target" ;;
147
- HOOKS) REMOVE_ONE "$digest" "$target" "the declaration" ;;
300
+ DECLCMD) DECLARATION_COMMAND="$target" ;;
301
+ HOOKSCREATED) HOOKS_CREATED_BY_US="$target" ;;
302
+ DECLARATION) REMOVE_DECLARATION "$digest" "$target" ;;
148
303
  HELPER) REMOVE_ONE "$digest" "$target" "helper member" ;;
149
304
  HELPERDIR) HELPER_DIR="$target" ;;
150
305
  esac
@@ -0,0 +1,29 @@
1
+ {
2
+ "$comment": "SSOT for the herdr binary this repository has MEASURED against. Nothing else may carry a version or a digest: the CI installer, the sandbox gate's boundary check and the launch-rail document all read or cite this file. Re-measuring a new herdr means running check-herdr-sandbox against it FIRST, then editing this file, then repairing the measured sentences in docs/herdr-launch-rail.md in the same commit — miss one and the gate fails on version mismatch, which is the point.",
3
+ "version": "0.9.1",
4
+ "tag": "v0.9.1",
5
+ "tagCommit": "065ef9d6a531c49fb8bee7e818ef837065b21ee9",
6
+ "socketProtocol": 22,
7
+ "measuredOn": "2026-09-17",
8
+ "measuredArch": "aarch64",
9
+ "releaseUrlPrefix": "https://github.com/herdrdev/herdr/releases/download",
10
+ "integrity": {
11
+ "kind": "github-immutable-release-attestation",
12
+ "predicateType": "https://in-toto.io/attestation/release/v0.2",
13
+ "note": "GitHub signs that these digests are this tag's assets. It is NOT a SLSA build provenance authored by herdr and NOT a reproducible-build claim. Corroborate with `gh api repos/herdrdev/herdr/attestations/sha256:<digest>`; `gh attestation verify` 2.97.0 cannot close this predicate and must not be depended on."
14
+ },
15
+ "assets": {
16
+ "x86_64": {
17
+ "name": "herdr-linux-x86_64",
18
+ "sha256": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7",
19
+ "executed": false,
20
+ "executedNote": "pending the first CI run on an x86_64 runner — the digest is pinned, but no cell of this rail has ever run on this binary"
21
+ },
22
+ "aarch64": {
23
+ "name": "herdr-linux-aarch64",
24
+ "sha256": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e",
25
+ "executed": true,
26
+ "executedNote": "the #116 rail was re-measured on this exact binary on 2026-09-17: `check-herdr-sandbox` green, `herdr status client` reports protocol 22 (unchanged from 0.9.0), and `integration status` adds exactly one row — `letta (experimental)` — with every other row byte-identical to 0.9.0. The operator host still runs the 0.9.0 asset at that date; the binary measured here was fetched by digest into a temp dir"
27
+ }
28
+ }
29
+ }