@junghanacs/entwurf 0.20.1 → 0.22.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 (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,653 @@
1
+ """codex_toml_io — shared TOML reader/writer for the two Codex config atoms.
2
+
3
+ `$CODEX_HOME/config.toml` is a SINGLE shared surface: the operator hand-edits
4
+ it AND the vendor writes it back at runtime (answering the hook-trust prompt
5
+ appends `[hooks.state."..."]` to the very same file — measured in
6
+ scripts/raw-codex-measure/README.md M1). The Copilot atoms could own a whole
7
+ JSON document; a Codex atom cannot. This module is therefore the
8
+ `scripts/omp-config-xdev.py` shape — a line editor that owns exactly the lines
9
+ it touches — hardened with three layers that file never needed:
10
+
11
+ 1. tomllib (stdlib, vendor-grade) is the parse-or-refuse oracle: a file that
12
+ does not parse is refused before any edit, and every CANDIDATE text is
13
+ re-parsed before it may reach the disk. A line edit that would break the
14
+ document can therefore never ship.
15
+ 2. Every write proves its own blast radius: with the one path the caller
16
+ owns removed from both sides, the parsed before/after documents must
17
+ compare equal (modulo empty tables, which carry no content) or the write
18
+ is refused as an internal error. The ownership policy is a check, not a
19
+ promise.
20
+ 3. `atomic_write` is a single fail-closed primitive (mkdir, write-to-temp,
21
+ rename) that both atoms call for BOTH files they own. Each atom writes
22
+ its install-state receipt — which already carries the first preimage —
23
+ BEFORE it ever touches the operator's config, so a crash or an I/O
24
+ failure at any of the three points leaves the operator's bytes exactly
25
+ as they were and a receipt that is either absent or already correct; a
26
+ retry resumes from it instead of re-deriving a preimage from
27
+ already-mutated bytes. `ENTWURF_TEST_CODEX_TOML_FAULT` injects a
28
+ deterministic failure at each of the three points for exactly this
29
+ property (see `_inject_test_fault`).
30
+
31
+ There is deliberately NO whole-file postimage hash here: the vendor writes
32
+ this file between our install and our uninstall, so byte-pinning would refuse
33
+ honest uninstalls. Spans are located FRESH from the current bytes at uninstall
34
+ time; the atoms record the span texts they produced and the preimages they
35
+ superseded.
36
+
37
+ Importers prepend their own directory to sys.path so the import holds however
38
+ they are invoked (same pattern as pi_settings_io).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import datetime
44
+ import json
45
+ import os
46
+ import re
47
+ import sys
48
+ import tempfile
49
+ import tomllib
50
+
51
+ _BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
52
+
53
+
54
+ def die(code: int, message: str) -> None:
55
+ sys.stderr.write(message.rstrip("\n") + "\n")
56
+ raise SystemExit(code)
57
+
58
+
59
+ def now() -> str:
60
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
61
+
62
+
63
+ def read_text(path: str, label: str) -> str | None:
64
+ """The file's bytes, or None when absent. Anything else is refused."""
65
+ try:
66
+ with open(path, "r", encoding="utf-8") as handle:
67
+ return handle.read()
68
+ except FileNotFoundError:
69
+ return None
70
+ except OSError as error:
71
+ die(4, f"codex-toml: cannot read {label} {path}: {error}")
72
+ except UnicodeDecodeError as error:
73
+ die(4, f"codex-toml: {label} {path} is not valid UTF-8: {error}")
74
+
75
+
76
+ def parse_toml(text: str, label: str) -> dict:
77
+ try:
78
+ value = tomllib.loads(text)
79
+ except tomllib.TOMLDecodeError as error:
80
+ die(4, f"codex-toml: {label} is not valid TOML: {error}")
81
+ return value
82
+
83
+
84
+ _TEST_FAULT_ENV = "ENTWURF_TEST_CODEX_TOML_FAULT"
85
+
86
+
87
+ def _inject_test_fault(stage: str, prefix: str) -> None:
88
+ """Deterministic failure injection for the smoke suite only. Set
89
+ `ENTWURF_TEST_CODEX_TOML_FAULT` to `<stage>` or `<stage>:<prefix>`, where
90
+ `stage` is one of `mkdir` / `write` / `replace` — the three points
91
+ `atomic_write` can fail at, in order — to raise there before a single
92
+ byte of the target path changes. `prefix` narrows the fault to one
93
+ writer's temp-file prefix (`.codex-state-` for a receipt,
94
+ `.codex-mcp-`/`.codex-sl-` for a config); omitted, it matches every
95
+ writer. Never read outside a test that sets the variable.
96
+ """
97
+ spec = os.environ.get(_TEST_FAULT_ENV)
98
+ if not spec:
99
+ return
100
+ want_stage, _, want_prefix = spec.partition(":")
101
+ if want_stage != stage or (want_prefix and want_prefix != prefix):
102
+ return
103
+ raise OSError(f"codex-toml: injected {stage} fault ({prefix})")
104
+
105
+
106
+ def atomic_write(path: str, text: str, prefix: str = ".codex-toml-") -> None:
107
+ directory = os.path.dirname(os.path.abspath(path))
108
+ _inject_test_fault("mkdir", prefix)
109
+ os.makedirs(directory, exist_ok=True)
110
+ _inject_test_fault("write", prefix)
111
+ fd, temporary = tempfile.mkstemp(dir=directory, prefix=prefix, suffix=".tmp")
112
+ try:
113
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
114
+ handle.write(text)
115
+ _inject_test_fault("replace", prefix)
116
+ os.replace(temporary, path)
117
+ except BaseException:
118
+ try:
119
+ os.unlink(temporary)
120
+ except FileNotFoundError:
121
+ pass
122
+ raise
123
+
124
+
125
+ def atomic_write_state(path: str, state: dict) -> None:
126
+ atomic_write(path, json.dumps(state, indent=2) + "\n", prefix=".codex-state-")
127
+
128
+
129
+ # --------------------------------------------------------------------------
130
+ # Char-level scanning: which lines are headers, where a value ends.
131
+ #
132
+ # The scanner only has to be RIGHT about the files tomllib already accepted;
133
+ # every span it reports is re-parsed in isolation and compared against the
134
+ # whole-document value (`span_value` below). A mis-track fails closed there,
135
+ # never silently.
136
+ # --------------------------------------------------------------------------
137
+
138
+
139
+ class _ScanState:
140
+ """Bracket/string context carried across lines."""
141
+
142
+ __slots__ = ("depth", "basic_ml", "literal_ml")
143
+
144
+ def __init__(self) -> None:
145
+ self.depth = 0 # net open [ or { outside strings (multi-line arrays)
146
+ self.basic_ml = False # inside a """ multi-line basic string
147
+ self.literal_ml = False # inside a ''' multi-line literal string
148
+
149
+ def busy(self) -> bool:
150
+ return self.depth > 0 or self.basic_ml or self.literal_ml
151
+
152
+
153
+ def _scan_text(text: str, state: _ScanState, start: int = 0) -> int:
154
+ """Advance over `text` from `start`, mutating `state`; return the resume
155
+ index (len(text) when the construct is still open). Newlines are ordinary
156
+ characters to the caller's context: inside multi-line strings and arrays
157
+ they are content; elsewhere the caller checks `state.busy()` at EOL.
158
+ Comments (# outside any string) end the rest of the text.
159
+ """
160
+ i = start
161
+ n = len(text)
162
+ in_basic = False # inside a single-line basic string
163
+ in_literal = False # inside a single-line literal string
164
+ escaped = False
165
+ while i < n:
166
+ ch = text[i]
167
+ if state.basic_ml:
168
+ if ch == '"':
169
+ run = 1
170
+ while i + run < n and text[i + run] == '"':
171
+ run += 1
172
+ if run >= 3:
173
+ # first three close the string; up to two extra quotes are
174
+ # content per the TOML spec and cannot open anything.
175
+ state.basic_ml = False
176
+ i += 3
177
+ continue
178
+ i += run # 1-2 quotes: content
179
+ continue
180
+ i += 1
181
+ continue
182
+ if state.literal_ml:
183
+ if ch == "'":
184
+ run = 1
185
+ while i + run < n and text[i + run] == "'":
186
+ run += 1
187
+ if run >= 3:
188
+ state.literal_ml = False
189
+ i += 3
190
+ continue
191
+ i += run
192
+ continue
193
+ i += 1
194
+ continue
195
+ if in_basic:
196
+ if escaped:
197
+ escaped = False
198
+ elif ch == "\\":
199
+ escaped = True
200
+ elif ch == '"':
201
+ in_basic = False
202
+ i += 1
203
+ continue
204
+ if in_literal:
205
+ if ch == "'":
206
+ in_literal = False
207
+ i += 1
208
+ continue
209
+ # outside every string
210
+ if ch == '"':
211
+ run = 1
212
+ while i + run < n and text[i + run] == '"':
213
+ run += 1
214
+ if run >= 3:
215
+ state.basic_ml = True
216
+ i += 3
217
+ elif run == 2:
218
+ i += 2 # empty string
219
+ else:
220
+ in_basic = True
221
+ i += 1
222
+ continue
223
+ if ch == "'":
224
+ run = 1
225
+ while i + run < n and text[i + run] == "'":
226
+ run += 1
227
+ if run >= 3:
228
+ state.literal_ml = True
229
+ i += 3
230
+ elif run == 2:
231
+ i += 2 # empty string
232
+ else:
233
+ in_literal = True
234
+ i += 1
235
+ continue
236
+ if ch == "#":
237
+ return n # comment: rest of the text is inert
238
+ if ch in "[{":
239
+ state.depth += 1
240
+ elif ch in "]}":
241
+ if state.depth > 0:
242
+ state.depth -= 1
243
+ i += 1
244
+ return i
245
+
246
+
247
+ def _unquote(segment: str) -> str | None:
248
+ """One dotted-path segment back to its key string, or None if malformed."""
249
+ if len(segment) >= 2 and segment[0] == '"' and segment[-1] == '"':
250
+ try:
251
+ return json.loads(segment)
252
+ except ValueError:
253
+ return None
254
+ if len(segment) >= 2 and segment[0] == "'" and segment[-1] == "'":
255
+ return segment[1:-1]
256
+ if _BARE_KEY.fullmatch(segment):
257
+ return segment
258
+ return None
259
+
260
+
261
+ def _parse_header(stripped: str) -> tuple[tuple[str, ...], bool] | None:
262
+ """The dotted path of a `[table]` / `[[array]]` header line, or None when
263
+ the line is not a parseable header (tomllib is the backstop oracle)."""
264
+ is_array = stripped.startswith("[[")
265
+ opener, closer = ("[[", "]]") if is_array else ("[", "]")
266
+ if not stripped.startswith(opener):
267
+ return None
268
+ end = stripped.find(closer)
269
+ if end < 0:
270
+ return None
271
+ body = stripped[len(opener):end]
272
+ parts: list[str] = []
273
+ current = []
274
+ in_basic = in_literal = escaped = False
275
+ for ch in body:
276
+ if in_basic:
277
+ if escaped:
278
+ escaped = False
279
+ elif ch == "\\":
280
+ escaped = True
281
+ elif ch == '"':
282
+ in_basic = False
283
+ current.append(ch)
284
+ continue
285
+ if in_literal:
286
+ if ch == "'":
287
+ in_literal = False
288
+ current.append(ch)
289
+ continue
290
+ if ch == '"':
291
+ in_basic = True
292
+ current.append(ch)
293
+ elif ch == "'":
294
+ in_literal = True
295
+ current.append(ch)
296
+ elif ch == ".":
297
+ parts.append("".join(current).strip())
298
+ current = []
299
+ else:
300
+ current.append(ch)
301
+ parts.append("".join(current).strip())
302
+ if any(not part for part in parts):
303
+ return None
304
+ keys = tuple(_unquote(part) for part in parts)
305
+ if any(key is None for key in keys):
306
+ return None
307
+ return keys, is_array # type: ignore[return-value]
308
+
309
+
310
+ class LineInfo:
311
+ __slots__ = ("header", "is_array")
312
+
313
+ def __init__(self, header: tuple[str, ...] | None, is_array: bool) -> None:
314
+ self.header = header
315
+ self.is_array = is_array
316
+
317
+
318
+ def scan_lines(lines: list[str]) -> list[LineInfo]:
319
+ """Classify every line: its table path when it is a header line at value
320
+ depth zero, else None. Multi-line strings and arrays suppress headers."""
321
+ infos: list[LineInfo] = []
322
+ state = _ScanState()
323
+ for line in lines:
324
+ header: tuple[str, ...] | None = None
325
+ is_array = False
326
+ if not state.busy():
327
+ stripped = line.lstrip()
328
+ if stripped.startswith("["):
329
+ parsed = _parse_header(stripped)
330
+ if parsed is not None:
331
+ header, is_array = parsed
332
+ infos.append(LineInfo(header, is_array))
333
+ _scan_text(line, state)
334
+ return infos
335
+
336
+
337
+ def find_table_family(lines: list[str], infos: list[LineInfo], path: tuple[str, ...]) -> tuple[int, int] | None:
338
+ """(start, end_exclusive) of the `[path]` header line and every sub-table
339
+ under it, up to the next header that is not a descendant, or EOF.
340
+
341
+ The BLANK LINES that sit between this family and whatever follows it are NOT
342
+ part of it. They are the separator two neighbours share, and the neighbour is
343
+ frequently not ours: measured on this host, the Codex vendor appended its own
344
+ `[hooks.state]` after our managed MCP block, and re-splicing the family over a
345
+ span that swallowed the separator deleted one of the vendor's bytes on a plain
346
+ idempotent reinstall — semantically nothing, but a byte outside our atom that
347
+ we had promised never to touch.
348
+
349
+ Only whitespace-only lines are given back. A COMMENT above the next header is
350
+ left inside the family exactly as before: a comment is somebody's words, and
351
+ deciding whose they are is a different question from where a table ends. This
352
+ boundary change must not quietly hand ownership of one to the other."""
353
+ start = None
354
+ for idx, info in enumerate(infos):
355
+ if info.header == path and not info.is_array:
356
+ start = idx
357
+ break
358
+ if start is None:
359
+ return None
360
+ end = len(lines)
361
+ for idx in range(start + 1, len(lines)):
362
+ info = infos[idx]
363
+ if info.header is None:
364
+ continue
365
+ other = info.header
366
+ if len(other) > len(path) and other[: len(path)] == path:
367
+ continue # a descendant sub-table stays in the family
368
+ end = idx
369
+ break
370
+ while end > start + 1 and lines[end - 1].strip() == "":
371
+ end -= 1
372
+ return start, end
373
+
374
+
375
+ def _parse_key_prefix(line: str) -> tuple[str, int] | None:
376
+ """(key, index_of_equals) when `line` begins a `key = value` entry, else
377
+ None. Handles bare and quoted keys; dotted keys are returned verbatim so
378
+ callers can refuse them explicitly."""
379
+ stripped = line.lstrip()
380
+ if not stripped:
381
+ return None
382
+ if stripped[0] in "\"'":
383
+ quote = stripped[0]
384
+ close = stripped.find(quote, 1)
385
+ if close < 0:
386
+ return None
387
+ key = _unquote(stripped[: close + 1])
388
+ if key is None:
389
+ return None
390
+ rest = stripped[close + 1 :]
391
+ else:
392
+ match = _BARE_KEY.match(stripped)
393
+ if not match:
394
+ return None
395
+ key = match.group(0)
396
+ rest = stripped[match.end():]
397
+ if rest.startswith("."):
398
+ return key + "." + "…dotted", -1 # caller refuses; never a plain key
399
+ rest = rest.lstrip()
400
+ if not rest.startswith("="):
401
+ return None
402
+ return key, line.index("=", line.index(stripped))
403
+
404
+
405
+ def find_direct_key(lines: list[str], infos: list[LineInfo], family_start: int, family_end: int, key: str) -> tuple[int, int] | None:
406
+ """(key_line, value_end_inclusive) of the direct `key = value` entry of
407
+ the table whose header is family_start, or None. The value span covers
408
+ multi-line arrays/strings."""
409
+ for idx in range(family_start + 1, family_end):
410
+ if infos[idx].header is not None:
411
+ break # direct members end at the first sub-table header
412
+ parsed = _parse_key_prefix(lines[idx])
413
+ if parsed is None:
414
+ continue
415
+ found_key, eq = parsed
416
+ if found_key != key:
417
+ continue
418
+ value_end = _value_end(lines, idx, eq + 1)
419
+ return idx, value_end
420
+ return None
421
+
422
+
423
+ def _value_end(lines: list[str], key_line: int, start_pos: int) -> int:
424
+ """The last line index of the value that starts at start_pos on
425
+ lines[key_line]. Single-line values return key_line."""
426
+ state = _ScanState()
427
+ line = lines[key_line]
428
+ resume = _scan_text(line, state, start_pos)
429
+ if not state.busy():
430
+ return key_line
431
+ idx = key_line
432
+ while state.busy() and idx + 1 < len(lines):
433
+ idx += 1
434
+ _scan_text(lines[idx], state)
435
+ return idx
436
+
437
+
438
+ def span_text(lines: list[str], start: int, end_exclusive: int) -> str:
439
+ return "\n".join(lines[start:end_exclusive])
440
+
441
+
442
+ def splice(lines: list[str], start: int, end_exclusive: int, replacement: list[str]) -> list[str]:
443
+ return lines[:start] + list(replacement) + lines[end_exclusive:]
444
+
445
+
446
+ def append_block(lines: list[str], block_lines: list[str]) -> list[str]:
447
+ """Append a table block at EOF, terminating an unterminated last line."""
448
+ body = list(lines)
449
+ if body and body[-1] == "":
450
+ body = body[:-1]
451
+ return body + list(block_lines)
452
+
453
+
454
+ def render_file(lines: list[str]) -> str:
455
+ """Text for the whole file: exactly one trailing newline, never two — a
456
+ split tail element of "" already IS the terminator."""
457
+ if lines and lines[-1] == "":
458
+ return "\n".join(lines)
459
+ return "\n".join(lines) + "\n"
460
+
461
+
462
+ def span_value(span: str, label: str):
463
+ """Re-parse an isolated value/key span and return what TOML says it is —
464
+ the self-check that keeps the line scanner honest. A family table span
465
+ parses to {"mcp_servers": {"entwurf-bridge": {...}}}; pass the inner path
466
+ to dig out the compared value."""
467
+ try:
468
+ return tomllib.loads(span)
469
+ except tomllib.TOMLDecodeError as error:
470
+ die(7, f"codex-toml: internal scanner drift: {label} span does not re-parse: {error}")
471
+
472
+
473
+ def deep_get(data, path: tuple[str, ...]):
474
+ current = data
475
+ for part in path:
476
+ if not isinstance(current, dict) or part not in current:
477
+ return None
478
+ current = current[part]
479
+ return current
480
+
481
+
482
+ def _drop_empty(value):
483
+ if isinstance(value, dict):
484
+ kept = {k: _drop_empty(v) for k, v in value.items()}
485
+ return {k: v for k, v in kept.items() if v != {}}
486
+ return value
487
+
488
+
489
+ def without_path(data: dict, path: tuple[str, ...]) -> dict:
490
+ """A comparable copy of `data` with `path` removed; empty tables left by
491
+ the removal (or empty anywhere — they carry no content) are dropped so an
492
+ appended parent table never reads as collateral damage."""
493
+ if not path:
494
+ return _drop_empty(data)
495
+
496
+ def strip(value, remaining):
497
+ if not isinstance(value, dict):
498
+ return value
499
+ out = {}
500
+ head, rest = remaining[0], remaining[1:]
501
+ for key, child in value.items():
502
+ if key == head:
503
+ if not rest:
504
+ continue
505
+ stripped = strip(child, rest)
506
+ if stripped != {}:
507
+ out[key] = stripped
508
+ continue
509
+ out[key] = child
510
+ return out
511
+
512
+ return _drop_empty(strip(data, path))
513
+
514
+
515
+ def assert_blast_radius(original_text: str | None, candidate_text: str, owned_path: tuple[str, ...], label: str) -> tuple[dict, dict]:
516
+ """The write's own proof: candidate parses, and with `owned_path` removed
517
+ both documents compare equal. Returns (parsed_before, parsed_after)."""
518
+ before = parse_toml(original_text if original_text is not None else "", label)
519
+ after = parse_toml(candidate_text, f"candidate {label}")
520
+ if without_path(before, owned_path) != without_path(after, owned_path):
521
+ die(7, f"codex-toml: internal refusal: editing {label} would change TOML outside {'.'.join(owned_path)}")
522
+ return before, after
523
+
524
+
525
+ def array_append_point(span: str, search_from: int = 0) -> tuple[int, str] | None:
526
+ """Where a new item goes at the END of the first array in `span`, as
527
+ `(insert_index, tail)` — or None when this span holds no array this writer
528
+ can own.
529
+
530
+ `tail` says what the last meaningful byte inside the array was, because
531
+ that decides the snippet an atom appends:
532
+
533
+ ``empty`` the array has no items — insert ``"x"``
534
+ ``item`` it ends with an item — insert ``, "x"``
535
+ ``comma`` it ends with a trailing comma (the multi-line style)
536
+ — insert `` "x",`` AFTER that comma
537
+
538
+ The naive locator — append before ``span.rfind("]")`` — is wrong on three
539
+ real operator shapes: a trailing comment (``items = ["a"] # keep ] this``),
540
+ a ``]`` inside a string item, and the multi-line array whose last item
541
+ already carries a comma (appending ``, "x"`` there emits ``,,``). So this
542
+ walks the span with the SAME string/comment rules `_scan_text` applies, but
543
+ reports POSITIONS, which `_scan_text` cannot: it is line-oriented (its
544
+ single-line string and escape flags are per call, so it may not be driven
545
+ character by character) and it returns only a resume index. Hence one
546
+ self-contained walk here rather than a wrapper.
547
+
548
+ Returning None is a SHAPE answer, never an error: a span with no
549
+ line-shaped array is a thing this writer does not own, and the caller
550
+ refuses it by name instead of splicing into someone's comment.
551
+ """
552
+ i = search_from
553
+ n = len(span)
554
+ depth = 0
555
+ open_at = -1
556
+ last_meaningful = -1 # index AFTER the last non-space byte inside the array
557
+ basic_ml = literal_ml = in_basic = in_literal = escaped = False
558
+
559
+ def mark(end: int) -> None:
560
+ nonlocal last_meaningful
561
+ if depth >= 1:
562
+ last_meaningful = end
563
+
564
+ while i < n:
565
+ ch = span[i]
566
+ if basic_ml or literal_ml:
567
+ quote = '"' if basic_ml else "'"
568
+ if ch == quote:
569
+ run = 1
570
+ while i + run < n and span[i + run] == quote:
571
+ run += 1
572
+ if run >= 3:
573
+ basic_ml = literal_ml = False
574
+ i += 3
575
+ mark(i)
576
+ continue
577
+ i += run
578
+ mark(i)
579
+ continue
580
+ i += 1
581
+ mark(i)
582
+ continue
583
+ if in_basic:
584
+ if escaped:
585
+ escaped = False
586
+ elif ch == "\\":
587
+ escaped = True
588
+ elif ch == '"':
589
+ in_basic = False
590
+ i += 1
591
+ mark(i)
592
+ continue
593
+ if in_literal:
594
+ if ch == "'":
595
+ in_literal = False
596
+ i += 1
597
+ mark(i)
598
+ continue
599
+ if ch in "\"'":
600
+ run = 1
601
+ while i + run < n and span[i + run] == ch:
602
+ run += 1
603
+ if run >= 3:
604
+ basic_ml, literal_ml = ch == '"', ch == "'"
605
+ i += 3
606
+ elif run == 2:
607
+ i += 2 # an empty string opens nothing
608
+ else:
609
+ in_basic, in_literal = ch == '"', ch == "'"
610
+ i += 1
611
+ mark(i)
612
+ continue
613
+ if ch == "#":
614
+ # A comment runs to end of line and is NOT meaningful content.
615
+ # Inside an OPEN array that is legal TOML and the array continues
616
+ # on the next line; outside one there is nothing left to close.
617
+ newline = span.find("\n", i)
618
+ if depth == 0 or newline < 0:
619
+ return None
620
+ i = newline + 1
621
+ continue
622
+ if ch in "[{":
623
+ depth += 1
624
+ if depth == 1 and ch == "[":
625
+ open_at = i
626
+ mark(i + 1)
627
+ i += 1
628
+ continue
629
+ if ch in "]}":
630
+ depth -= 1
631
+ if depth < 0:
632
+ return None
633
+ if depth == 0 and ch == "]" and open_at >= 0:
634
+ if last_meaningful <= open_at + 1:
635
+ return open_at + 1, "empty"
636
+ return last_meaningful, "comma" if span[last_meaningful - 1] == "," else "item"
637
+ mark(i + 1)
638
+ i += 1
639
+ continue
640
+ i += 1
641
+ if not ch.isspace():
642
+ mark(i)
643
+ return None
644
+
645
+
646
+ def toml_string(value: str) -> str:
647
+ # JSON double-quoted strings are valid TOML basic strings for every value
648
+ # json can emit (\", \\, \n, \uXXXX — all legal TOML escapes).
649
+ return json.dumps(value, ensure_ascii=True)
650
+
651
+
652
+ def render_string_array(items) -> str:
653
+ return "[" + ", ".join(toml_string(item) for item in items) + "]"