@junghanacs/entwurf 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -0,0 +1,609 @@
1
+ /**
2
+ * codex-declaration — what entwurf owns inside `$CODEX_HOME/hooks.json`, and nothing more.
3
+ *
4
+ * WHY THIS LEAF EXISTS (#117). The birth unit used to own hooks.json as ONE COMPLETE FILE:
5
+ * install refused a file it had not written, the state recorded a WHOLE-FILE sha256, and the
6
+ * doctor/preflight compared that digest to the live bytes. That was true only while entwurf was
7
+ * the only thing declaring a Codex hook. It is not: Herdr's official Codex integration appends
8
+ * its own `SessionStart` group through `ensure_command_hook`, and the vendor keeps running BOTH
9
+ * — `[source]` codex-rs/hooks/src/engine/discovery.rs:664-665 hashes a NORMALIZED
10
+ * event/matcher/group/handler and keys trust by `<path>:<event>:<group_idx>:<handler_idx>`, so
11
+ * trust is declaration-scoped and always was. Only entwurf's certification was file-scoped.
12
+ *
13
+ * WHAT OWNERSHIP MEANS HERE INSTEAD: exactly one `SessionStart` matcher group, holding exactly
14
+ * one handler whose `command` is our quoted absolute launcher path. Everything else in that file
15
+ * is FOREIGN — reported, never certified, never rewritten, never absorbed.
16
+ *
17
+ * WHY A NORMALIZED DIGEST AND NOT BYTES. `[측정 2026-09-17 oracle]` after Herdr appended its
18
+ * group, our own handler came back with its keys in `command,timeout,type` order while the
19
+ * installer writes `type,command,timeout` — Herdr re-serialized the whole document through
20
+ * serde. So neither the file's bytes NOR our group's raw bytes survive a neighbour's install.
21
+ * The digest below is taken over a canonical form (recursively sorted keys, no whitespace), so
22
+ * it is blind to key order, indentation, `description` and every neighbouring group, and
23
+ * sensitive to exactly what the trust identity is made of: the launcher path and the timeout.
24
+ *
25
+ * WHY A SPAN SPLICE AND NOT A RE-SERIALIZE. Adding or removing our group by parsing the file and
26
+ * writing `JSON.stringify` back would rewrite every foreign byte on the way past. This module
27
+ * locates our group's exact text span and edits only that, so a neighbour's declaration comes
28
+ * out of an install or an uninstall byte-for-byte identical. Every splice is verified by
29
+ * re-parsing the result and deep-comparing it to the value the caller expected; a splice that
30
+ * does not land exactly there is a REFUSAL, never a written file.
31
+ *
32
+ * This file is `.js` for the same reason `session-id.js` is: it is imported from the tsc-emit
33
+ * path (`codex-fresh-preflight.ts`), from `node --experimental-strip-types` gates, and from the
34
+ * three installer shells through `node -e 'import(...)'`. One definition, every consumer.
35
+ *
36
+ * Keep dependency-free except `node:crypto`.
37
+ */
38
+ import { createHash } from "node:crypto";
39
+ /** The vendor event name as it appears in hooks.json. */
40
+ export const CODEX_BIRTH_EVENT = "SessionStart";
41
+ /** The same event as it appears inside a `[hooks.state]` trust key — the vendor lower-snakes it. */
42
+ export const CODEX_BIRTH_TRUST_EVENT = "session_start";
43
+ /** Part of the identity the operator approves once; not a tunable. */
44
+ export const CODEX_BIRTH_TIMEOUT = 30;
45
+ /** A `description` this value prefixes is entwurf prose, and the only top-level member we own. */
46
+ export const CODEX_BIRTH_DESCRIPTION_PREFIX = "entwurf codex-birth ";
47
+ /**
48
+ * The command string the vendor sees. Codex runs a handler command as a SHELL STRING (there is
49
+ * no argv form), so the absolute launcher path is single-quoted — and THAT string, quotes
50
+ * included, is what the trust receipt is keyed to and what selects our declaration below.
51
+ *
52
+ * @param {string} launcher absolute path to the published launcher
53
+ * @returns {string}
54
+ */
55
+ export function entwurfDeclarationCommand(launcher) {
56
+ return `'${launcher}'`;
57
+ }
58
+ /**
59
+ * The declaration this unit publishes, as a value. Three omissions carry meaning and are
60
+ * asserted as omissions everywhere downstream: no `matcher` (so all four SessionStart sources
61
+ * fire; `""` is NOT the same), no `async` (birth must be synchronous — the MCP child of the same
62
+ * turn resolves its identity from the record this hook writes), and no `state` (that key is the
63
+ * vendor's, in config.toml, and this unit never touches that file).
64
+ *
65
+ * @param {string} launcher
66
+ * @returns {{hooks: Array<{type: string, command: string, timeout: number}>}}
67
+ */
68
+ export function entwurfDeclarationGroup(launcher) {
69
+ return { hooks: [{ type: "command", command: entwurfDeclarationCommand(launcher), timeout: CODEX_BIRTH_TIMEOUT }] };
70
+ }
71
+ /**
72
+ * Canonical JSON: object keys sorted recursively, no insignificant whitespace. The ONLY reason
73
+ * this exists is so a digest of a declaration survives a neighbour re-serializing the document.
74
+ *
75
+ * @param {unknown} value
76
+ * @returns {string}
77
+ */
78
+ export function canonicalJson(value) {
79
+ if (value === null || typeof value !== "object")
80
+ return JSON.stringify(value) ?? "null";
81
+ if (Array.isArray(value))
82
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
83
+ const keys = Object.keys(value).sort();
84
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
85
+ }
86
+ /**
87
+ * The digest that IS the certification. Taken over the event name together with the group, so a
88
+ * declaration moved to a different event is a different identity even when the group is equal.
89
+ *
90
+ * @param {unknown} group
91
+ * @param {string} [event]
92
+ * @returns {string} sha256 hex
93
+ */
94
+ export function declarationDigest(group, event = CODEX_BIRTH_EVENT) {
95
+ return createHash("sha256").update(canonicalJson({ event, group })).digest("hex");
96
+ }
97
+ /**
98
+ * The vendor's own trust key for a declaration at a given position.
99
+ * `[source]` discovery.rs:664-665 — `<declaration path>:<event>:<group_idx>:<handler_idx>`.
100
+ *
101
+ * @param {string} hooksFile
102
+ * @param {number} groupIndex
103
+ * @param {number} handlerIndex
104
+ * @returns {string}
105
+ */
106
+ export function trustReceiptKey(hooksFile, groupIndex, handlerIndex) {
107
+ return `${hooksFile}:${CODEX_BIRTH_TRUST_EVENT}:${groupIndex}:${handlerIndex}`;
108
+ }
109
+ /** @typedef {{index: number, commands: string[]}} ForeignGroup */
110
+ /**
111
+ * Locate entwurf's declaration inside a parsed hooks document, and describe its neighbours.
112
+ *
113
+ * SELECTION IS BY THE LAUNCHER COMMAND, not by index and not by position. That string is the
114
+ * trust identity, so it is the only thing that can name our declaration in a file whose shape
115
+ * somebody else is also allowed to change. Shape is judged AFTER selection on purpose: a handler
116
+ * that carries our command and an extra `async` key is OUR declaration, drifted — a named red —
117
+ * rather than somebody else's group we failed to recognise.
118
+ *
119
+ * `foreign` is filled on EVERY outcome, including the failures, because the doctor reports
120
+ * neighbours whether or not our own bytes are intact.
121
+ *
122
+ * @param {unknown} parsed the parsed hooks.json document
123
+ * @param {string} launcher absolute launcher path
124
+ * @returns {{ok: true, groupIndex: number, handlerIndex: number, group: unknown, digest: string, foreign: ForeignGroup[]}
125
+ * | {ok: false, code: string, detail: string, groupIndex: number|null, foreign: ForeignGroup[]}}
126
+ */
127
+ export function selectEntwurfDeclaration(parsed, launcher) {
128
+ const no = (code, detail, foreign = [], groupIndex = null) => ({ ok: false, code, detail, groupIndex, foreign });
129
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
130
+ return no("hooks-unreadable", "the hooks document is not a JSON object");
131
+ }
132
+ const events = parsed.hooks;
133
+ if (events === null || typeof events !== "object" || Array.isArray(events)) {
134
+ return no("hooks-unreadable", "the document carries no `hooks` object");
135
+ }
136
+ const groups = events[CODEX_BIRTH_EVENT];
137
+ if (groups === undefined)
138
+ return no("declaration-absent", `the document declares no ${CODEX_BIRTH_EVENT} group`);
139
+ if (!Array.isArray(groups))
140
+ return no("hooks-unreadable", `hooks.${CODEX_BIRTH_EVENT} is not an array`);
141
+ const want = entwurfDeclarationCommand(launcher);
142
+ /** @type {Array<{groupIndex: number, handlerIndex: number}>} */
143
+ const mine = [];
144
+ /** @type {ForeignGroup[]} */
145
+ const foreign = [];
146
+ for (let g = 0; g < groups.length; g += 1) {
147
+ const group = groups[g];
148
+ const handlers = group !== null && typeof group === "object" && !Array.isArray(group) ? group.hooks : undefined;
149
+ const list = Array.isArray(handlers) ? handlers : [];
150
+ let claimed = false;
151
+ for (let h = 0; h < list.length; h += 1) {
152
+ const handler = list[h];
153
+ if (handler !== null && typeof handler === "object" && !Array.isArray(handler) && handler.command === want) {
154
+ mine.push({ groupIndex: g, handlerIndex: h });
155
+ claimed = true;
156
+ }
157
+ }
158
+ if (!claimed) {
159
+ foreign.push({
160
+ index: g,
161
+ commands: list.map((handler) => handler !== null &&
162
+ typeof handler === "object" &&
163
+ !Array.isArray(handler) &&
164
+ typeof handler.command === "string"
165
+ ? handler.command
166
+ : "(no command string)"),
167
+ });
168
+ }
169
+ }
170
+ if (mine.length === 0) {
171
+ return no("declaration-absent", `no ${CODEX_BIRTH_EVENT} handler commands ${want}`, foreign);
172
+ }
173
+ if (mine.length > 1) {
174
+ const where = mine.map((m) => `group ${m.groupIndex} handler ${m.handlerIndex}`).join(", ");
175
+ return no("declaration-duplicated", `${mine.length} handlers command ${want} (${where}) — the vendor would run this birth hook more than once per session and only one position can carry the trust receipt`, foreign);
176
+ }
177
+ const { groupIndex, handlerIndex } = mine[0];
178
+ const group = groups[groupIndex];
179
+ const shape = declarationShapeProblem(group, want);
180
+ if (shape !== null)
181
+ return no("declaration-shape-drifted", shape, foreign, groupIndex);
182
+ return { ok: true, groupIndex, handlerIndex, group, digest: declarationDigest(group), foreign };
183
+ }
184
+ /**
185
+ * The GRAMMAR half of the certification, kept apart from the digest half because they fail for
186
+ * different reasons: this one says which keys may exist at all (an `async` or a `matcher` the
187
+ * installer never writes), while the digest says whether the VALUES are the ones recorded.
188
+ *
189
+ * @param {unknown} group
190
+ * @param {string} wantCommand
191
+ * @returns {string|null} a named problem, or null when the shape is exactly ours
192
+ */
193
+ function declarationShapeProblem(group, wantCommand) {
194
+ if (group === null || typeof group !== "object" || Array.isArray(group))
195
+ return "the matcher group is not an object";
196
+ const keys = Object.keys(group).sort();
197
+ if (keys.join(",") !== "hooks") {
198
+ return `the matcher group must carry no key other than \`hooks\` (found ${keys.join(", ") || "none"}) — a \`matcher\` narrows which SessionStart sources fire and changes the identity the operator trusted`;
199
+ }
200
+ const handlers = group.hooks;
201
+ if (!Array.isArray(handlers) || handlers.length !== 1) {
202
+ return `the matcher group must hold exactly one handler (found ${Array.isArray(handlers) ? handlers.length : "a non-array"}) — a second handler in our own group is not a neighbour, it is our declaration edited`;
203
+ }
204
+ const handler = handlers[0];
205
+ if (handler === null || typeof handler !== "object" || Array.isArray(handler))
206
+ return "the handler is not an object";
207
+ const handlerKeys = Object.keys(handler).sort();
208
+ if (handlerKeys.join(",") !== "command,timeout,type") {
209
+ return `the handler must carry exactly type+command+timeout (found ${handlerKeys.join(", ") || "none"}) — every extra key, \`async\` above all, changes the trust identity`;
210
+ }
211
+ if (handler.type !== "command")
212
+ return `the handler type must be \`command\`, not ${JSON.stringify(handler.type)}`;
213
+ if (handler.command !== wantCommand)
214
+ return `the handler command is ${JSON.stringify(handler.command)}`;
215
+ if (handler.timeout !== CODEX_BIRTH_TIMEOUT) {
216
+ return `the handler timeout must be ${CODEX_BIRTH_TIMEOUT}, not ${JSON.stringify(handler.timeout)} — it is part of the identity the operator approved`;
217
+ }
218
+ return null;
219
+ }
220
+ /* ─────────────────────── span-aware reading, for byte-preserving edits ─────────────────────── */
221
+ /**
222
+ * A JSON reader that keeps every node's text span. Deliberately NOT a tolerant parser: it is the
223
+ * same grammar `JSON.parse` accepts, and every caller below cross-checks its value against
224
+ * `JSON.parse` before any file is written, so a disagreement is a refusal rather than an edit.
225
+ *
226
+ * @typedef {{value: unknown, start: number, end: number, elements?: SpanNode[], members?: Array<{key: string, start: number, end: number, value: SpanNode}>}} SpanNode
227
+ */
228
+ /**
229
+ * @param {string} text
230
+ * @returns {SpanNode}
231
+ */
232
+ export function parseWithSpans(text) {
233
+ let at = 0;
234
+ const fail = (why) => {
235
+ throw new Error(`hooks.json is not readable at offset ${at}: ${why}`);
236
+ };
237
+ const ws = () => {
238
+ while (at < text.length && (text[at] === " " || text[at] === "\t" || text[at] === "\n" || text[at] === "\r"))
239
+ at += 1;
240
+ };
241
+ const lit = (word, value) => {
242
+ if (text.slice(at, at + word.length) !== word)
243
+ fail(`expected ${word}`);
244
+ const start = at;
245
+ at += word.length;
246
+ return { value, start, end: at };
247
+ };
248
+ const str = () => {
249
+ const start = at;
250
+ if (text[at] !== '"')
251
+ fail("expected a string");
252
+ at += 1;
253
+ while (at < text.length) {
254
+ const ch = text[at];
255
+ if (ch === "\\") {
256
+ at += 2;
257
+ continue;
258
+ }
259
+ if (ch === '"') {
260
+ at += 1;
261
+ return { value: JSON.parse(text.slice(start, at)), start, end: at };
262
+ }
263
+ at += 1;
264
+ }
265
+ return fail("unterminated string");
266
+ };
267
+ const num = () => {
268
+ const start = at;
269
+ if (text[at] === "-")
270
+ at += 1;
271
+ while (at < text.length && /[0-9eE+.-]/.test(text[at]))
272
+ at += 1;
273
+ const raw = text.slice(start, at);
274
+ const value = Number(raw);
275
+ if (raw.length === 0 || !Number.isFinite(value))
276
+ fail("expected a number");
277
+ return { value, start, end: at };
278
+ };
279
+ /** @returns {SpanNode} */
280
+ const node = () => {
281
+ ws();
282
+ const ch = text[at];
283
+ if (ch === "{") {
284
+ const start = at;
285
+ at += 1;
286
+ /** @type {Array<{key: string, start: number, end: number, value: SpanNode}>} */
287
+ const members = [];
288
+ /** @type {Record<string, unknown>} */
289
+ const value = {};
290
+ ws();
291
+ if (text[at] === "}") {
292
+ at += 1;
293
+ return { value, start, end: at, members };
294
+ }
295
+ for (;;) {
296
+ ws();
297
+ const memberStart = at;
298
+ const key = str();
299
+ ws();
300
+ if (text[at] !== ":")
301
+ fail("expected ':'");
302
+ at += 1;
303
+ const child = node();
304
+ members.push({ key: String(key.value), start: memberStart, end: child.end, value: child });
305
+ value[String(key.value)] = child.value;
306
+ ws();
307
+ if (text[at] === ",") {
308
+ at += 1;
309
+ continue;
310
+ }
311
+ if (text[at] === "}") {
312
+ at += 1;
313
+ return { value, start, end: at, members };
314
+ }
315
+ return fail("expected ',' or '}'");
316
+ }
317
+ }
318
+ if (ch === "[") {
319
+ const start = at;
320
+ at += 1;
321
+ /** @type {SpanNode[]} */
322
+ const elements = [];
323
+ ws();
324
+ if (text[at] === "]") {
325
+ at += 1;
326
+ return { value: [], start, end: at, elements };
327
+ }
328
+ for (;;) {
329
+ const child = node();
330
+ elements.push(child);
331
+ ws();
332
+ if (text[at] === ",") {
333
+ at += 1;
334
+ continue;
335
+ }
336
+ if (text[at] === "]") {
337
+ at += 1;
338
+ return { value: elements.map((e) => e.value), start, end: at, elements };
339
+ }
340
+ return fail("expected ',' or ']'");
341
+ }
342
+ }
343
+ if (ch === '"')
344
+ return str();
345
+ if (ch === "t")
346
+ return lit("true", true);
347
+ if (ch === "f")
348
+ return lit("false", false);
349
+ if (ch === "n")
350
+ return lit("null", null);
351
+ return num();
352
+ };
353
+ const root = node();
354
+ ws();
355
+ if (at !== text.length)
356
+ fail("trailing content after the document");
357
+ return root;
358
+ }
359
+ /**
360
+ * The span of `hooks.SessionStart` and of each group inside it.
361
+ *
362
+ * @param {string} text
363
+ * @returns {{root: SpanNode, array: SpanNode}}
364
+ */
365
+ export function sessionStartSpans(text) {
366
+ const root = parseWithSpans(text);
367
+ const hooks = root.members?.find((m) => m.key === "hooks");
368
+ if (!hooks)
369
+ throw new Error("the document carries no `hooks` object");
370
+ const event = hooks.value.members?.find((m) => m.key === CODEX_BIRTH_EVENT);
371
+ if (!event)
372
+ throw new Error(`the document carries no hooks.${CODEX_BIRTH_EVENT} array`);
373
+ if (!Array.isArray(event.value.value))
374
+ throw new Error(`hooks.${CODEX_BIRTH_EVENT} is not an array`);
375
+ return { root, array: event.value };
376
+ }
377
+ /** The whitespace a given offset is indented BY — non-empty only when the offset opens its own
378
+ * line, which is what makes it the right indent to repeat for a sibling element. */
379
+ function indentAt(text, offset) {
380
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
381
+ const line = text.slice(lineStart, offset);
382
+ return /^[ \t]*$/.test(line) ? line : "";
383
+ }
384
+ /** The whitespace opening the LINE a given offset sits on, whatever else is on it — used when an
385
+ * insertion has no sibling to line up with and must derive its indent from its container. */
386
+ function lineIndentAt(text, offset) {
387
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
388
+ return /^[ \t]*/.exec(text.slice(lineStart, offset))?.[0] ?? "";
389
+ }
390
+ /**
391
+ * Remove ONE element of the SessionStart array, editing nothing else in the file.
392
+ *
393
+ * The separator is removed with the element it belongs to: a middle or last element takes the
394
+ * comma BEFORE it (and the whitespace back to its predecessor), the first takes the comma after
395
+ * it, and a sole element leaves an empty array. Every neighbour's bytes are copied through
396
+ * untouched — that is the whole point of this function existing instead of a re-serialize.
397
+ *
398
+ * @param {string} text
399
+ * @param {number} index
400
+ * @returns {string}
401
+ */
402
+ export function removeSessionStartGroup(text, index) {
403
+ const { array } = sessionStartSpans(text);
404
+ const elements = array.elements ?? [];
405
+ if (index < 0 || index >= elements.length)
406
+ throw new Error(`no SessionStart group at index ${index}`);
407
+ let cutStart;
408
+ let cutEnd;
409
+ if (elements.length === 1) {
410
+ cutStart = array.start + 1;
411
+ cutEnd = array.end - 1;
412
+ }
413
+ else if (index === 0) {
414
+ cutStart = elements[0].start;
415
+ cutEnd = elements[1].start;
416
+ }
417
+ else {
418
+ cutStart = elements[index - 1].end;
419
+ cutEnd = elements[index].end;
420
+ }
421
+ return text.slice(0, cutStart) + text.slice(cutEnd);
422
+ }
423
+ /**
424
+ * Give a document that has a `hooks` object but no `SessionStart` array an EMPTY one, editing
425
+ * nothing else. Without this, appending our group into a file whose owner declared only other
426
+ * events would have to re-serialize the document — the exact foreign-byte rewrite this module
427
+ * exists to avoid. A document with no `hooks` object at all is somebody else's grammar and is
428
+ * refused by the caller, not repaired here.
429
+ *
430
+ * @param {string} text
431
+ * @returns {string}
432
+ */
433
+ export function ensureSessionStartArray(text) {
434
+ const root = parseWithSpans(text);
435
+ const hooks = root.members?.find((m) => m.key === "hooks");
436
+ if (!hooks)
437
+ throw new Error("the document carries no `hooks` object");
438
+ if (hooks.value.members?.some((m) => m.key === CODEX_BIRTH_EVENT))
439
+ return text;
440
+ const members = hooks.value.members ?? [];
441
+ const fresh = `${JSON.stringify(CODEX_BIRTH_EVENT)}: []`;
442
+ if (members.length === 0) {
443
+ const base = lineIndentAt(text, hooks.value.start);
444
+ return `${text.slice(0, hooks.value.start + 1)}\n${base} ${fresh}\n${base}${text.slice(hooks.value.end - 1)}`;
445
+ }
446
+ const last = members[members.length - 1];
447
+ const indent = indentAt(text, last.start);
448
+ return `${text.slice(0, last.end)},\n${indent}${fresh}${text.slice(last.end)}`;
449
+ }
450
+ /**
451
+ * Append our group to the SessionStart array, editing nothing else in the file.
452
+ *
453
+ * APPEND, never insert: the vendor keys trust by index, so taking a position a neighbour already
454
+ * holds would renumber THEIR receipt and cost the operator an approval they already gave.
455
+ *
456
+ * @param {string} text
457
+ * @param {unknown} group
458
+ * @returns {string}
459
+ */
460
+ export function appendSessionStartGroup(text, group) {
461
+ const { array } = sessionStartSpans(text);
462
+ const elements = array.elements ?? [];
463
+ const body = JSON.stringify(group, null, 2);
464
+ if (elements.length === 0) {
465
+ const base = lineIndentAt(text, array.start);
466
+ const laid = body
467
+ .split("\n")
468
+ .map((line) => `${base} ${line}`)
469
+ .join("\n");
470
+ return `${text.slice(0, array.start + 1)}\n${laid}\n${base}${text.slice(array.end - 1)}`;
471
+ }
472
+ const last = elements[elements.length - 1];
473
+ const indent = indentAt(text, last.start);
474
+ const laid = body
475
+ .split("\n")
476
+ .map((line, i) => (i === 0 ? line : indent + line))
477
+ .join("\n");
478
+ return `${text.slice(0, last.end)},\n${indent}${laid}${text.slice(last.end)}`;
479
+ }
480
+ /**
481
+ * Remove a top-level `description` whose value is entwurf's own prose. Ours to remove, and only
482
+ * ours: a description we did not author is a foreign byte and is left exactly where it is.
483
+ *
484
+ * @param {string} text
485
+ * @returns {string}
486
+ */
487
+ export function removeEntwurfDescription(text) {
488
+ const root = parseWithSpans(text);
489
+ const members = root.members ?? [];
490
+ const index = members.findIndex((m) => m.key === "description" &&
491
+ typeof m.value.value === "string" &&
492
+ m.value.value.startsWith(CODEX_BIRTH_DESCRIPTION_PREFIX));
493
+ if (index === -1)
494
+ return text;
495
+ let cutStart;
496
+ let cutEnd;
497
+ if (members.length === 1) {
498
+ cutStart = root.start + 1;
499
+ cutEnd = root.end - 1;
500
+ }
501
+ else if (index === 0) {
502
+ cutStart = members[0].start;
503
+ cutEnd = members[1].start;
504
+ }
505
+ else {
506
+ cutStart = members[index - 1].end;
507
+ cutEnd = members[index].end;
508
+ }
509
+ return text.slice(0, cutStart) + text.slice(cutEnd);
510
+ }
511
+ /**
512
+ * THE POST-CONDITION EVERY SPLICE IS GATED ON. The span reader above is the only new way this
513
+ * unit can damage a file it does not own, so no splice is ever returned to a writer on the
514
+ * strength of the reader alone: the result is re-parsed with `JSON.parse` and deep-compared to
515
+ * the value the caller says it intended. A splice that lands anywhere else throws, and every
516
+ * caller turns that into a zero-write refusal.
517
+ *
518
+ * @param {string} spliced
519
+ * @param {unknown} expected
520
+ * @returns {string} the same text, once it is proven to mean exactly `expected`
521
+ */
522
+ export function certifySplice(spliced, expected) {
523
+ let reparsed;
524
+ try {
525
+ reparsed = JSON.parse(spliced);
526
+ }
527
+ catch (err) {
528
+ throw new Error(`the edited hooks document does not parse (${err instanceof Error ? err.message : String(err)}); nothing written`);
529
+ }
530
+ if (canonicalJson(reparsed) !== canonicalJson(expected)) {
531
+ throw new Error("the edited hooks document is not the value this edit intended — the span edit landed somewhere else; nothing written");
532
+ }
533
+ return spliced;
534
+ }
535
+ /**
536
+ * Is this shared file one we may write through, and is it provably ours to certify?
537
+ *
538
+ * WHY IT LIVES HERE (sol B3, 2026-09-18). Four surfaces decide about the SAME file — the
539
+ * installer, the inverse, the doctor and the fresh-call preflight — and they were deciding
540
+ * differently. Preflight required a plausible owner and refused group/world-writable
541
+ * (`codex-fresh-preflight.ts`); the two shells checked only symlink-and-regular, and the doctor
542
+ * checked the file's CONTENT without ever asking who owned it. So a hooks.json owned by another
543
+ * uid, or writable by a group, could be installed into and reported GREEN while every Codex fresh
544
+ * call refused it as `codex-birth-unit-missing` — install and doctor saying yes about the same
545
+ * bytes launch said no about, which is the split this closes.
546
+ *
547
+ * TWO DIFFERENT THINGS, and only one of them is ours. We do NOT chmod a file we share — a
548
+ * neighbour's mode is a neighbour's business, and the installer carries it over untouched. But
549
+ * WRITING INTO a file anyone else can rewrite is a different question: whatever we certify there,
550
+ * someone else can change afterwards, so our receipt would describe bytes we cannot bind. That is
551
+ * why an unsafe file is a zero-write refusal rather than a mode we normalize.
552
+ *
553
+ * The classifier is PURE — it judges a stat record, not a path — so the same rules can be proven
554
+ * against fixture records with no filesystem, and so this leaf keeps its "dependency-free except
555
+ * node:crypto" promise. `statOwnedPath` takes the `fs` module from its caller for the same reason.
556
+ *
557
+ * @param {{ exists: boolean, isSymbolicLink: boolean, isFile: boolean, isDirectory: boolean, uid: number, mode: number }} stat
558
+ * @param {number} expectedUid
559
+ * @param {{ kind?: "file" | "directory" }} [options]
560
+ * @returns {"ok"|"missing"|"symlink"|"not-regular"|"foreign-uid"|"writable-by-others"}
561
+ */
562
+ export function classifyOwnedPath(stat, expectedUid, options = {}) {
563
+ const kind = options.kind ?? "file";
564
+ if (!stat.exists)
565
+ return "missing";
566
+ // SYMLINK FIRST, and it is not folded into `not-regular`: the two repairs differ. A link's
567
+ // target could be any file on the host, so the answer is never "fix the mode" — it is "this
568
+ // path is not the file we think we are looking at".
569
+ if (stat.isSymbolicLink)
570
+ return "symlink";
571
+ if (kind === "directory" ? !stat.isDirectory : !stat.isFile)
572
+ return "not-regular";
573
+ if (stat.uid !== expectedUid)
574
+ return "foreign-uid";
575
+ if ((stat.mode & 0o022) !== 0)
576
+ return "writable-by-others";
577
+ return "ok";
578
+ }
579
+ /**
580
+ * Read one path into the record `classifyOwnedPath` judges. `lstat`, never `stat`: following the
581
+ * link would answer about its target and hide the one verdict whose repair is different.
582
+ *
583
+ * @param {{ lstatSync: (p: string) => { isFile: () => boolean, isDirectory: () => boolean, isSymbolicLink: () => boolean, uid: number, mode: number } }} fs
584
+ * @param {string} file
585
+ */
586
+ export function statOwnedPath(fs, file) {
587
+ try {
588
+ const st = fs.lstatSync(file);
589
+ return {
590
+ exists: true,
591
+ isSymbolicLink: st.isSymbolicLink(),
592
+ isFile: st.isFile(),
593
+ isDirectory: st.isDirectory(),
594
+ uid: st.uid,
595
+ mode: st.mode,
596
+ };
597
+ }
598
+ catch {
599
+ return { exists: false, isSymbolicLink: false, isFile: false, isDirectory: false, uid: -1, mode: 0 };
600
+ }
601
+ }
602
+ /** What each verdict means to whoever has to repair it. One wording, so the installer, the
603
+ * inverse and the doctor cannot describe the same file in three different ways. */
604
+ export const OWNED_PATH_REFUSAL = {
605
+ symlink: "is a SYMLINK — this unit publishes through no link, and a link's target could be any file on the host",
606
+ "not-regular": "is not a regular file",
607
+ "foreign-uid": "is owned by another user, so nothing here can bind what it will say next",
608
+ "writable-by-others": "is group/world-writable, so anything certified in it can be rewritten by someone else afterwards",
609
+ };