@junghanacs/entwurf 0.19.0 → 0.20.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 (59) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +23 -5
  3. package/CHANGELOG.md +236 -0
  4. package/DELIVERY.md +59 -6
  5. package/README.md +40 -9
  6. package/VERIFY.md +9 -1
  7. package/docs/acp-backend-rail.md +9 -1
  8. package/docs/external-mcp-host.md +6 -3
  9. package/docs/setup-clean-host.md +93 -7
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +3 -3
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +6 -2
  12. package/package.json +2 -2
  13. package/pi-extensions/acp-provider.ts +1 -1
  14. package/pi-extensions/lib/acp/models.ts +3 -3
  15. package/pi-extensions/lib/acp/overlay.ts +6 -2
  16. package/pi-extensions/lib/meta-session.ts +25 -0
  17. package/run.sh +71 -26
  18. package/scripts/agy-bridge.sh +1 -1
  19. package/scripts/agy-imprint.sh +10 -7
  20. package/scripts/check-acp-carrier-augment.ts +6 -5
  21. package/scripts/check-acp-cortex.ts +41 -0
  22. package/scripts/check-acp-provider-surface.ts +9 -6
  23. package/scripts/check-copilot-birth-hook.ts +3 -1
  24. package/scripts/check-copilot-receive-arm.ts +169 -21
  25. package/scripts/check-copilot-statusline.ts +3 -1
  26. package/scripts/check-gate-qualification.ts +10 -9
  27. package/scripts/check-hook-launch-topology.ts +6 -5
  28. package/scripts/check-install-surface.ts +2 -1
  29. package/scripts/check-meta-facts.ts +6 -4
  30. package/scripts/check-meta-hook-session-switch.ts +4 -3
  31. package/scripts/check-meta-identity-consumers.ts +8 -7
  32. package/scripts/check-meta-receiver-marker.ts +2 -1
  33. package/scripts/check-omp-birth-hook.ts +3 -2
  34. package/scripts/check-probe-bridge-command.ts +3 -1
  35. package/scripts/check-setup-qualification.sh +21 -0
  36. package/scripts/copilot-bridge-doctor.sh +15 -5
  37. package/scripts/copilot-bridge-install.sh +6 -2
  38. package/scripts/copilot-launch.sh +16 -2
  39. package/scripts/copilot-receive-bridge.sh +80 -13
  40. package/scripts/lib/reclaim-on-exit.ts +86 -0
  41. package/scripts/meta-bridge-doctor.sh +56 -15
  42. package/scripts/meta-bridge-install.sh +13 -7
  43. package/scripts/meta-bridge-uninstall.sh +6 -4
  44. package/scripts/mutants/acp-cortex.json +26 -2
  45. package/scripts/mutants/copilot-receive.json +26 -0
  46. package/scripts/mutants/omp-birth.json +23 -6
  47. package/scripts/mutants/setup-verdict.json +13 -0
  48. package/scripts/omp-bridge-doctor.sh +110 -41
  49. package/scripts/omp-bridge-install.sh +6 -2
  50. package/scripts/omp-receive-install.sh +7 -2
  51. package/scripts/raw-async-delivery/README.md +10 -1
  52. package/scripts/raw-codex-measure/README.md +689 -0
  53. package/scripts/raw-codex-measure/source-audit.md +243 -0
  54. package/scripts/raw-macos-measure/README.md +148 -0
  55. package/scripts/raw-macos-measure/probe.sh +389 -0
  56. package/scripts/smoke-meta-async-drift.sh +7 -5
  57. package/scripts/smoke-meta-install-state.sh +70 -19
  58. package/scripts/smoke-omp-bridge-state.sh +38 -0
  59. package/scripts/smoke-setup-verdict.sh +100 -0
@@ -0,0 +1,243 @@
1
+ Author: claude-opus-5 (entwurf fresh sibling, 2026-09-08) — source-layer receipts for #95 step 1.
2
+ These rows were written by the same reader who took the measurements in `README.md`, so they were
3
+ NOT self-certifying. They have since been independently audited: **terra (openai-codex), against
4
+ commit `87ac7ad`, 2026-09-08 — 20/20 quotations CONFIRMED, 0 CORRECTED, 0 UNVERIFIABLE**, with the
5
+ tag identity re-derived independently (`rust-v0.153.4^{}` =
6
+ `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`). The three defects that audit raised were prose
7
+ precision in `README.md`, not wrong vendor facts; they are folded in there and marked. Rows added
8
+ to this file BY that amendment are marked `[post-audit]` and carry no second reader.
9
+
10
+ Vendor: `~/repos/3rd/codex` at tag `rust-v0.153.4` (`3d2ee51`). Paths are relative to
11
+ `codex-rs/`. Code is the oracle.
12
+
13
+ # Item ledger
14
+
15
+ ## H. Hooks — vocabulary, firing, declaration, execution
16
+
17
+ ### H1. Event set is 12, not 0.136's 9
18
+ `hooks/src/lib.rs:23-36` — `HOOK_EVENT_NAMES: [&str; 12]`.
19
+ `hooks/src/lib.rs:43-52` — `HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 9]` (matcher-meaningful subset).
20
+ `config/src/hook_config.rs:36-61` — the same 12 as TOML keys, PascalCase via `serde(rename)`.
21
+ Added since the archived 0.136 note: `PermissionRequest`, `SessionEnd`, `Interrupt`.
22
+
23
+ ### H2. SessionStart source is a 4-valued matcher
24
+ `hooks/src/events/session_start.rs:25-38` — `SessionStartSource::{Startup,Resume,Clear,Compact}`
25
+ with `as_str()` → `startup|resume|clear|compact`.
26
+ `hooks/src/events/session_start.rs:69-74` — `matcher_input()` returns that string for
27
+ `SessionStart`, and `agent_type` for `SubagentStart`.
28
+ `core/src/session/session.rs:1600-1606` — the mapping from `InitialHistory`:
29
+ `Resumed→Resume`, `New|Forked→Startup`, `Cleared→Clear`.
30
+ `core/src/session/mod.rs:3812` — compaction queues `Compact`.
31
+
32
+ ### H3. Firing is turn-time, not construction-time — the decisive chain
33
+ 1. `core/src/session/session.rs:1623` — construction only calls
34
+ `state.queue_pending_session_start_source(session_start_source)`.
35
+ 2. `core/src/state/session.rs:55` — the queue is a `VecDeque<SessionStartSource>`;
36
+ `:348-352` push, `:355-358` pop.
37
+ 3. `core/src/hook_runtime.rs:124-128` — `run_pending_session_start_hooks` DRAINS that queue
38
+ (`while let Some(..) = sess.take_pending_session_start_source().await`).
39
+ 4. `core/src/session/turn.rs:264` and `:504` — the only two production call sites, both inside
40
+ turn execution. `grep -rn 'run_pending_session_start_hooks' core/src` returns no other
41
+ non-test caller.
42
+ Nothing in the construction path invokes the hook engine. `[host]` receipt in README M1.
43
+
44
+ ### H4. Handler kinds and TOML shape
45
+ `config/src/hook_config.rs:12-17` — `HooksFile { description?, hooks: HookEventsToml }`
46
+ (the `hooks.json` shape).
47
+ `config/src/hook_config.rs:19-25` — `HooksToml { #[serde(flatten)] events, state }`
48
+ (the `[hooks]` in-config shape, carrying `[hooks.state]`).
49
+ `config/src/hook_config.rs:27-33` — `HookStateToml { enabled?, trusted_hash? }`.
50
+ `config/src/hook_config.rs:153-159` — `MatcherGroup { matcher?, hooks: Vec<HookHandlerConfig> }`.
51
+ `config/src/hook_config.rs:161-200` — `#[serde(tag="type")] HookHandlerConfig`:
52
+ - `command { command, commandWindows?, timeout?, async, statusMessage?, additionalContextLimit? }`
53
+ - `mcp_tool { server, tool, input, timeout?, statusMessage? }`
54
+ - `prompt {}`, `agent {}`
55
+ The `mcp_tool` kind is executed through `HookMcpExecutor` (`hooks/src/mcp.rs:8-24`), whose
56
+ result text is "interpreted using ordinary command-hook output semantics".
57
+
58
+ ### H5. Command execution is shell-form, with an env REPLAY
59
+ `hooks/src/engine/command_runner.rs:391-411` — `build_command`: when the configured shell
60
+ program is empty, `default_shell_command(environment)`, then the command line as ONE arg.
61
+ `hooks/src/engine/command_runner.rs:428-453` — non-Windows: program from `SHELL` (fallback
62
+ `/bin/sh`), argument `-lc`. A LOGIN shell: the operator's profile runs first.
63
+ `hooks/src/engine/command_runner.rs:419-423` — `command.env_clear()` then
64
+ `command.envs(environment)` then the per-handler `env` then `scrub_non_inheritable_env_vars`.
65
+ "Replay the session snapshot instead of inheriting the live process environment" is the
66
+ in-source comment; the snapshot is the HOST process's, which is what makes M5's mode split
67
+ visible in the hook's own env.
68
+ There is no exec-form (`argv[]`) handler kind anywhere in `HookHandlerConfig`.
69
+
70
+ ### H6. Discovery sources and the trust/managed policy
71
+ `hooks/src/engine/discovery.rs:146-166` — two sources merged per config layer:
72
+ `load_hooks_json(layer.hooks_config_folder())` and the layer's own `hooks` TOML key
73
+ (`:387` reads `layer.config.get("hooks")`).
74
+ `hooks/src/engine/discovery.rs:339-343` — the JSON path is `<config folder>/hooks.json`.
75
+ `config/src/state.rs:239-243` — `hooks_config_folder()` = override else `config_folder()`.
76
+ `hooks/src/engine/discovery.rs:81-88` — `HookDiscoveryPolicy { allow_managed_hooks_only,
77
+ bypass_hook_trust }`; `:105-114` reads `allow_managed_hooks_only` from layer requirements.
78
+ `hooks/src/engine/discovery.rs:767` — the comment binding config-TOML and hooks.json hooks to
79
+ one trust identity.
80
+ `config/src/hook_config.rs:218-250` — `ManagedHooksRequirementsToml { managed_dir,
81
+ windows_managed_dir, #[serde(flatten)] hooks }` + `managed_dir_for_current_platform()`.
82
+
83
+ `[post-audit]` **The coordinates that actually carry the trust claim** (terra's correction; the
84
+ four lines above are the policy struct and the JSON path, which do not):
85
+ `hooks/src/engine/discovery.rs:794-811` — `hook_trust_status`: `is_builtin → Trusted`,
86
+ `is_managed → Managed`, otherwise compare `trusted_hash` against the current hash
87
+ (`Some(eq) → Trusted`, `Some(_) → Modified`, `None → Untrusted`).
88
+ `hooks/src/engine/discovery.rs:813-815` — `hook_enabled(is_managed, is_builtin, state)` is
89
+ `is_builtin || is_managed || state.enabled != Some(false)`.
90
+ `hooks/src/engine/discovery.rs:817-821` — `hook_trusted_hash` reads state ONLY when `!is_managed`.
91
+ `hooks/src/engine/discovery.rs:676-679` — the per-handler site where those three are combined.
92
+ `hooks/src/engine/discovery.rs:208-240` — `append_managed_requirement_handlers` enters with
93
+ `is_managed: true`.
94
+ `core/src/config/mod.rs:3253-3260` — the CLI bypass path and its operator warning.
95
+
96
+ `[post-audit]` **Which layers are managed**: `hooks/src/engine/discovery.rs:823-839` —
97
+ `System` (= `/etc/codex/config.toml`, `config/src/loader/mod.rs:66`), `Mdm`,
98
+ `EnterpriseManaged`, and both `LegacyManagedConfigToml*` are `is_managed: true`. `User`,
99
+ `Project`, `PackagedDefaults` and `SessionFlags` are not. `config/src/loader/mod.rs:746-748`
100
+ puts the requirements file at `/etc/codex/requirements.toml`;
101
+ `config/src/loader/layer_io.rs:22` puts managed config at `/etc/codex/managed_config.toml`.
102
+ `config/src/state.rs:43-62` — `LoaderOverrides` can redirect all of those, but it is internal
103
+ (no CLI flag reaches it outside `cli/src/debug_sandbox.rs`).
104
+ **Exercised on 2026-09-08 — see `README.md` §M-A.** A hook in the managed layer ran with no
105
+ trust prompt; `--dangerously-bypass-hook-trust` also ran the hook but killed auto-attach.
106
+
107
+ ### H7. Stop is a turn boundary, not a wake
108
+ `hooks/src/events/stop.rs:97-104` — `StopOutcome { should_stop, stop_reason, should_block,
109
+ block_reason, continuation_fragments }`.
110
+ `hooks/src/schema.rs:455-464` — `StopCommandOutputWire { universal, decision?, reason? }`
111
+ with the in-source note that Claude requires `reason` when `decision == block`.
112
+ Same class as Copilot's `agentStop`; `docs/adding-a-harness.md` step 7(c) forbids using it as
113
+ an idle-wake substitute.
114
+
115
+ ### H8. No filesystem-wake vocabulary
116
+ `grep -rn 'watchPaths|FileChanged|asyncRewake' --include=*.rs codex-rs/` returns exactly one
117
+ hit: `external-agent-migration/src/hooks_cla.rs:158-161`, inside a CLAUDE-HOOKS IMPORTER, in a
118
+ `continue` branch that SKIPS any imported hook object carrying `asyncRewake`, `shell` or
119
+ `once`. Codex reads Claude's doorbell vocabulary only to refuse it.
120
+
121
+ ## E. Environment
122
+
123
+ ### E1. Codex's non-inheritable set is five names, none of them ours
124
+ `protocol/src/shell_environment.rs:14-21` — `NON_INHERITABLE_ENV_VARS` =
125
+ `CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN`, `NODE_REPL_AUTH_TOKEN`, `OPENAI_FEDERATION_RULE_ID`,
126
+ `OPENAI_IDENTITY_TOKEN_FILE`, `OPENAI_WORKLOAD_IDENTITY_CONTEXT`.
127
+ `:33-50` — `scrub_non_inheritable_env_vars` removes those and only those.
128
+
129
+ ### E2. Default shell-env policy inherits everything
130
+ `protocol/src/config_types.rs:213-214` — `ShellEnvironmentPolicyInherit::All` is `#[default]`.
131
+ `protocol/src/config_types.rs:264-265` — the `Default` impl sets
132
+ `inherit: All, ignore_default_excludes: true`, so the `*KEY*`/`*SECRET*`/`*TOKEN*` filters at
133
+ `protocol/src/shell_environment.rs:124-131` are OFF by default.
134
+ Consequence: `PI_SESSION_ID` / `PI_AGENT_ID` pass through untouched. `[host]` confirmed in
135
+ README M5 (embedded mode).
136
+
137
+ ### E3. CODEX_THREAD_ID is a shell-tool carrier, not a hook carrier
138
+ `protocol/src/shell_environment.rs:7` — the constant.
139
+ `protocol/src/shell_environment.rs:149-152` — inserted by `populate_env` only when a
140
+ `thread_id` argument is supplied, i.e. on the shell-environment construction path.
141
+ `[host]` a hook's `env | grep ^CODEX_` showed `CODEX_HOME`, `CODEX_MANAGED_BY_PNPM`,
142
+ `CODEX_MANAGED_PACKAGE_ROOT` and nothing else.
143
+
144
+ ### E4. No entwurf/pi name collision
145
+ `grep -rn '"PI_SESSION_ID"|"PI_AGENT_ID"|PI_CODING_AGENT_DIR|ENTWURF' --include=*.rs codex-rs/`
146
+ → 0 hits. Codex has no pi lineage, so #87's fork-collision class (step 1(6)) is inapplicable.
147
+
148
+ ## T. MCP tool-name dialect
149
+
150
+ ### T1. Prefix + delimiter constants
151
+ `core/src/tools/handlers/mcp.rs:46` — `LEGACY_MCP_TOOL_NAME_PREFIX = "mcp__"`.
152
+ `core/src/tools/handlers/mcp.rs:47` and `codex-mcp/src/tools.rs:225` — `MCP_TOOL_NAME_DELIMITER
153
+ = "__"`. (`codex-mcp/src/tools.rs:22` carries the prefix constant for the same crate.)
154
+
155
+ ### T2. Namespace is the server name, prefixed unless a feature disables it
156
+ `codex-mcp/src/rmcp_client.rs:821` — `callable_namespace: server_name.to_string()`.
157
+ `codex-mcp/src/tools.rs:139-142` — `callable_namespace_with_prefix(sanitize(...), prefix &&
158
+ !non_prefixed_servers.contains(server))`.
159
+ `codex-mcp/src/tools.rs:228-234` — the prefixing function itself.
160
+ `core/src/config/mod.rs:1793-1796` — `prefix_mcp_tool_names()` is
161
+ `!features.enabled(NonPrefixedMcpToolNames) || non_prefixed_mcp_tool_servers.is_some()`.
162
+ So prefixing is the DEFAULT and a feature flag is required to lose it.
163
+
164
+ ### T3. The sanitizer keeps digits and case
165
+ `codex-mcp/src/mcp/mod.rs:544-559` — every char not in `[A-Za-z0-9_]` becomes `_`; empty →
166
+ `"_"`. Contrast omp, whose `[a-z_]` charset deletes the `2` in `entwurf_v2`.
167
+ Derivation for this repo's server: `entwurf-bridge` → `entwurf_bridge`, `entwurf_v2` → itself,
168
+ giving `mcp__entwurf_bridge__entwurf_v2`. `[host]` the live tool list in README M7 matches this
169
+ derivation on all seven bridge tools.
170
+
171
+ ### T4. Collision hardening exists and can mutate a name
172
+ `codex-mcp/src/tools.rs:152-193` — a namespace or tool name shared by two distinct raw
173
+ identities gets a 12-hex sha1 suffix (`callable_name_hash_suffix`, `:241-245`).
174
+ `codex-mcp/src/tools.rs:226` — `MAX_TOOL_NAME_LENGTH = 128`, with truncation-plus-hash at
175
+ `:263-285`. So the derived spelling is stable only while no second server collides on
176
+ `entwurf_bridge`. Not exercised.
177
+
178
+ ### T5. Hook-facing tool names differ from model-facing ones
179
+ `core/src/tools/handlers/mcp.rs:95-97` — `hook_tool_name()` = `ensure_mcp_prefix(join_tool_name(
180
+ canonical))`, i.e. what `PreToolUse`/`PostToolUse` matchers compare against.
181
+ `core/src/tools/handlers/mcp.rs:100-109` — `join_tool_name` trims a trailing `_` from the
182
+ namespace and a leading `_` from the name before joining.
183
+ The Copilot lesson generalises: measure the string each argv/config position actually wants.
184
+
185
+ ## S. §3.5 scope, statusline, app-server
186
+
187
+ ### S1. Subagents raise a different event
188
+ `core/src/hook_runtime.rs:129-147` — the target selection. A `SubAgentSource::ThreadSpawn` with
189
+ source `Startup` becomes `StartHookTarget::SubagentStart{turn_id, agent_id, agent_type}`; any
190
+ other `SessionSource::SubAgent(_)` returns `false` WITHOUT dispatching (`:146`); everything else
191
+ is `StartHookTarget::SessionStart`.
192
+ `hooks/src/events/session_start.rs:52-74` — `event_name()` maps those to
193
+ `HookEventName::SubagentStart` / `::SessionStart`.
194
+ `hooks/src/events/session_start.rs:156-167` — the `SubagentStartCommandInput` carries
195
+ `session_id` (parent), `turn_id`, `agent_id`, `agent_type`.
196
+ `hooks/src/events/session_start.rs:271-274` — `continue:false` is honoured for `SessionStart`
197
+ ONLY; `SubagentStart` is context-injection-only.
198
+ `core/src/tools/handlers/multi_agents_spec.rs:85`, `:124` — the model-facing spawner is
199
+ `spawn_agent` (siblings: `send_input`, `wait_agent`, `list_agents`, `close_agent`, …).
200
+ `[host]` one `spawn_agent` call produced exactly one `SubagentStart` and zero additional
201
+ `SessionStart` (README M8).
202
+
203
+ ### S2. Statusline is a closed enum with no command segment
204
+ `tui/src/bottom_pane/status_line_setup.rs:56-155` — `StatusLineItem`, ~28 variants, all
205
+ built-in facts. Nothing accepts operator text or a command.
206
+ `tui/src/chatwidget.rs:502` — `DEFAULT_STATUS_LINE_ITEMS = ["model-with-reasoning","current-dir"]`.
207
+ `tui/src/slash_command.rs:59`, `:118` — `/statusline` "configure which items appear in the
208
+ status line".
209
+ `tui/src/bottom_pane/status_line_setup.rs:137-139` — `SessionId` with
210
+ `strum(to_string="thread-id", serialize="session-id")`.
211
+ `tui/src/chatwidget/status_surfaces.rs:776-784` — `ThreadTitle` renders `thread_name`, else the
212
+ thread id.
213
+ `app-server-protocol/src/protocol/common.rs:566` — `ThreadSetName => "thread/name/set"`;
214
+ params `{thread_id, name}` (`app-server-protocol/src/protocol/v2/thread.rs:753-756`, camelCase
215
+ on the wire).
216
+ `[post-audit]` **The auto-titler is guarded on the thread being unnamed**:
217
+ `tui/src/app/thread_routing.rs:1841` — `let automatic_title_user_message = if
218
+ self.chat_widget.thread_name().is_none() && …`. Only inside that branch does the TUI call
219
+ `thread_set_name` with a provisional title (`:1877-1888`) and then generate a model title
220
+ (`tui/src/app/event_dispatch.rs:2579-2592`, which additionally re-checks the current name).
221
+ `app-server/src/request_processors/thread_processor.rs:639-658` — the explicit setter carries
222
+ no such guard.
223
+ **Exercised on 2026-09-08 — see `README.md` §M-B.** An explicitly set name survives turns in
224
+ both orderings.
225
+
226
+ ### S3. App-server method surface at 0.153.4
227
+ `app-server-protocol/src/protocol/` — the measured route survives: `turn/start`, `turn/steer`,
228
+ `turn/interrupt`, `thread/inject_items`, `thread/status/changed`, `thread/resume`,
229
+ `thread/loaded/list`, `thread/list`.
230
+ New family since the archived 0.136 note: `thread/queue/{add,start,list,update,reorder,delete,
231
+ changed}`. Not exercised; a candidate to compare against `turn/start` in a later step, not a
232
+ claim about it now.
233
+
234
+ # Open at the source layer
235
+
236
+ - Rows added under `[post-audit]` have no second reader yet.
237
+ - T4: name-collision hardening read, never triggered.
238
+ - S3: the queue family read, never called.
239
+ - Gotcha 2 of the archived probe (`-c` disables auto-attach) is now half-measured:
240
+ `tui/src/lib.rs:919-930` — `can_reuse_implicit_local_daemon` requires empty
241
+ `cli_kv_overrides`, default loader overrides, `!strict_config` and no non-replayable launch
242
+ overrides. `[host]` `--dangerously-bypass-hook-trust` was measured to defeat it (README
243
+ §M-A2); plain `-c` was not separately re-run at 0.153.4.
@@ -0,0 +1,148 @@
1
+ # raw-macos-measure — Darwin host measurement for the macOS parity lane (#78)
2
+
3
+ Lane: `#78` macOS row, opened by the first green `macos-install-surface` CI run
4
+ ([`34303884286`](https://github.com/junghan0611/entwurf/actions/runs/34303884286) @ `70eda03`,
5
+ 2026-09-09, `macos-latest`). That run certifies the **Entwurf-only install path** and nothing
6
+ else — it is CERTIFIED (CI), which in this repo is deliberately weaker than a physical-host
7
+ doctor green. This ledger is where the *rest* of the axis gets measured before any code claims it.
8
+
9
+ This file is a MEASUREMENT-ONLY ledger, on the `raw-codex-measure` precedent. Nothing here is
10
+ written by an entwurf installer, no meta-record is minted, and no operator config is edited.
11
+
12
+ **Evidence-state vocabulary:** **[host-linux]** = measured on the Linux oracle (control run —
13
+ tells us what the probe reports where we already know the answer); **[host-darwin]** = measured on
14
+ the borrowed Mac; **[source]** = read at `file:line`; **[hypothesis]** = inference not yet
15
+ confirmed by a run.
16
+
17
+ ## Why a probe script and not a prose ledger
18
+
19
+ The other `raw-*-measure` lanes were written on the host that owns the vendor. This one is not:
20
+ the Darwin host is **borrowed**, so its measurement window is short and probably single-shot. The
21
+ executable half (`probe.sh`) exists so that window costs one command instead of a live debugging
22
+ session, and so a mistake in the probe is found on Linux rather than on the Mac.
23
+
24
+ ```sh
25
+ ./scripts/raw-macos-measure/probe.sh # ~1.2s, writes nothing outside its own mktemp dir
26
+ ```
27
+
28
+ It is `/bin/sh`, not bash, deliberately: macOS ships bash 3.2 and *what a shell may assume here*
29
+ is itself one of the measurements (cell M2). Every cell prints a verdict token — `PRESENT` /
30
+ `ABSENT` / `OK` / `FAIL` / `DIFFERS` / `UNKNOWN` — so no reader has to interpret raw tool output.
31
+
32
+ ## Cells
33
+
34
+ | Cell | Question it answers | Consumer of the answer |
35
+ |---|---|---|
36
+ | **M1** | Which Darwin, which arch, which machine | BASELINE host record |
37
+ | **M2** | bash version at `/bin/bash` and via `env bash`; are bash-4 features (`declare -A`, `mapfile`) available | the two gates that use bash 4 (`check-install-container.sh`, `check-meta-doctor-oracle.sh`) |
38
+ | **M3** | node / npm / pnpm / python3 / git / tmux presence and versions, **and whether `python3` is the CommandLineTools stub** | `docs/setup-clean-host.md` requirements table; a stub python3 is a total failure of the install+doctor surface, not a degraded cell |
39
+ | **M4** | GNU-vs-BSD tool matrix, measured **behaviourally** (presence is not compatibility) | the P1 substitution surface — `sha256sum` (10 files), `stat -c` (19), `xargs -0` (13), `sort -z` (12), `realpath` (11), `readlink -f` (5), `grep -P` (2), `date -d` (2), `timeout(1)` (5 files) |
40
+ | **M5** | **The identity rail.** Which start-key source exists, at what resolution, and whether a sub-second one is reachable | `processStartKey()` `pi-extensions/lib/meta-session.ts:1554` — the defense that stops a stale marker granting the wrong garden identity |
41
+ | **M6** | Can a reader see another process's argv/environment — measured as a **contrast**, not a single reading | the bridge/receiver discovery paths that read `/proc/<pid>/{environ,cmdline}` (`scripts/omp-bridge-doctor.sh:258-267`, `scripts/copilot-receive-bridge.sh:264-289`) |
42
+ | **M7** | How reachable is a same-second pid reuse (`kern.maxproc`, PID_MAX) | the severity grade of the M5 finding |
43
+ | **M8** | Case-sensitivity, HOME/XDG shape, symlink support, `~/.local/bin` on PATH | installers and the `~/.local/bin/entwurf` symlink ownership |
44
+ | **M9** | Does a **rename-over** raise an `fs.watch` event on the watched path | the mailbox doorbell — the receiver arms a watch, the sender delivers by staging then renaming into place |
45
+
46
+ ### Why M6 is a contrast and not a reading
47
+
48
+ A first draft of this probe asked `ps -E` about a `/bin/sleep` child. That would have produced a
49
+ **false negative on Darwin**: Apple-signed, SIP-protected binaries refuse environment reads *by
50
+ construction*, so `REJECTED` there says nothing about our actual case — an unrestricted
51
+ Homebrew/nvm `node` (the entwurf MCP child) and the Copilot CLI. The probe therefore starts a
52
+ `node` child carrying `ENTWURF_PROBE_MARKER=hello-entwurf` and asks whether that exact string is
53
+ visible, **and** keeps the `/bin/sleep` control beside it. `VISIBLE` on the node row next to
54
+ `REJECTED` on the sleep row is the evidence; either row alone is not.
55
+
56
+ `[measured by the audit lane, 2026-09-09]` The Darwin conditions under which `ps -E` can read a
57
+ foreign environment are: same process, SIP disabled, a development kernel, target not restricted,
58
+ or a dedicated entitlement. Only "target not restricted" is satisfiable on a normal host, which is
59
+ exactly why the target choice decides the answer.
60
+
61
+ ## M5 — the finding that already exists, measured on Linux
62
+
63
+ The identity core is dual-scheme **[source, `meta-session.ts:1554-1576`]**: `/proc/<pid>/stat`
64
+ field 22 → `linux:<ticks>`, else `ps -o lstart=` → `ps:<lstart>`, else `""` (fail-closed).
65
+ `startKeyScheme()` **[source, `:1630-1633`]** accepts both. **Darwin has no `/proc`, so it always
66
+ takes the `ps:` branch.**
67
+
68
+ **[host-linux, oracle, 2026-09-09 12:32]** The two schemes do not have the same resolution, and
69
+ the gap is measurable without a Mac:
70
+
71
+ ```
72
+ /proc present? YES
73
+ ps -o lstart= self: Wed Sep 9 12:32:26 2026 <- 1-second granular
74
+ linux ticks 65958142 <- 10ms granular (100 ticks/s)
75
+ child A (556824): Wed Sep 9 12:32:27 2026
76
+ child B (556825): Wed Sep 9 12:32:27 2026
77
+ RESOLUTION 1-SECOND (two back-to-back starts share one key string)
78
+ widened key A Wed Sep 9 12:32:27 2026 sleep 4
79
+ widened key B Wed Sep 9 12:32:27 2026 sleep 4
80
+ WIDENING INSUFFICIENT ALONE (identical argv collides even widened)
81
+ ```
82
+
83
+ Two consequences, stated at the size of the evidence:
84
+
85
+ 1. **The `ps:` scheme's pid-reuse window is 1 second, not 10ms.** The key is compared per-pid, so
86
+ two *different* pids sharing an `lstart` string is harmless. The reachable failure is the
87
+ specific sequence the source comment names **[source, `:1538-1544`]**: a process exits, its pid
88
+ is reused **within the same second**, and the new process's `lstart` matches the recorded key —
89
+ so a stale marker validates against the wrong process and grants it the wrong garden identity.
90
+ 2. **Widening the key with argv does not close it.** Both children ran identical argv on purpose,
91
+ because that is the worst case and the worst case is what a fail-closed rail must survive — and
92
+ a harness restarting under the same command line is the *normal* shape, not an exotic one.
93
+
94
+ Whether that window is reachable in practice is an M7 question (pid space vs spawn rate), and the
95
+ grade of the finding — blocker or observation — follows from it. `probe.sh` also tests whether a
96
+ microsecond source is reachable on Darwin (`libproc` `proc_pidinfo(PROC_PIDTBSDINFO)` →
97
+ `pbi_start_tvsec`/`pbi_start_tvusec` through python3 ctypes); on Linux that cell degrades with a
98
+ named reason rather than a silent skip:
99
+
100
+ ```
101
+ libproc UNAVAILABLE (/usr/lib/libproc.dylib: cannot open shared object file: ...)
102
+ ```
103
+
104
+ ## If the Mac is single-shot: what this one command already buys
105
+
106
+ The portability audit (`.agent-reports/macos-portability-audit-20260909.md` §8) enumerates a
107
+ nine-item acceptance checklist. **Items 1–6 need no entwurf install and no fence change**, so they
108
+ can all be taken on first contact — and running `probe.sh` once covers every one of them:
109
+
110
+ | Checklist item | Closes | Covered by |
111
+ |---|---|---|
112
+ | 1 · run the probe | the host facts | the whole run |
113
+ | 2 · `ps -Eww` on a marker-carrying `node` child | **P2-2 · P2-3 · P2-4** | **M6**, with the SIP-protected `/bin/sleep` control beside it |
114
+ | 3 · `ps -o lstart=` and the two-child cell | **P2-5 · P0-8** | **M5** |
115
+ | 4 · `proc_pidinfo` microsecond cell | **P2-5 Plan B** | **M5** |
116
+ | 5 · expected-failure battery (`readlink -f`, `shasum`, `grep -P`, `timeout`) | **P1-1 · P1-3 · P1-4** | **M4** |
117
+ | 6 · `python3 -V` without the CommandLineTools dialog | **P3-3** | **M3** |
118
+ | 7–9 · installs, `doctor-meta-bridge`, receive round trip | **P3-1 · P3-2 · P2-1 · P3-4 · P3-5** | NOT the probe — needs the 0.20.0 code landed and a real logged-in harness |
119
+
120
+ So the ordering for a scarce window is: **run the probe, keep the output, leave.** Items 7–9 can
121
+ be attempted with whatever time is left, but items 1–6 are the ones that convert open design
122
+ questions into facts, and none of them can be answered from Linux.
123
+
124
+ A cell that comes back the *opposite* of what we expect is worth as much as one that confirms —
125
+ item 5 in particular is a battery of EXPECTED FAILURES, and a surprise success there changes a
126
+ substitution's justification rather than validating it.
127
+
128
+ ## Control run — Linux oracle
129
+
130
+ **[host-linux, oracle, 2026-09-09]** The probe was run on Linux before being handed to a Darwin
131
+ host, to prove the probe itself is not the thing under test. Selected verdicts: `stat -c %s` OK ·
132
+ `readlink -f` OK · all three sha256 producers agree on the known digest of `abc` · `sort -z` /
133
+ `xargs -0` / `xargs -0r` / `grep -P` / `date -d` all OK · `ps -E` **REJECTED** even on Linux (so
134
+ the environment-visibility question in M6 is not Darwin-specific) · case-sensitive volume · total
135
+ runtime 1.2s.
136
+
137
+ That control matters for one reason: every `OK` above is a **GNU** answer. The same probe on
138
+ Darwin is expected to turn several of them into `FAIL`, and the list of which ones is exactly the
139
+ P1 substitution work.
140
+
141
+ ## Darwin cells — NOT YET MEASURED
142
+
143
+ Nothing below the line has been run on a Mac. GLG is borrowing a company host; when it lands, run
144
+ `probe.sh` once and paste the whole output here under a `[host-darwin]` heading with the date,
145
+ the Darwin version, and who ran it.
146
+
147
+ Do not fill these in from documentation, from a CI runner image spec, or from another project's
148
+ notes. This lane exists because `#78` says *claim only what physical evidence proves*.