@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
@@ -25,7 +25,7 @@
25
25
  "gate": ["bash", "run.sh", "check-entwurf-v2-send"],
26
26
  "timeoutSeconds": 60,
27
27
  "signature": "[QK:V2SEND-INBAND-REJECT-REASON]",
28
- "signatureSource": "scripts/check-entwurf-v2-send.ts"
28
+ "signatureSource": "pi-extensions/lib/entwurf-v2-send.test.ts"
29
29
  },
30
30
  {
31
31
  "claim": "V2PROD-INBAND-ERROR-WIRED",
@@ -206,7 +206,7 @@
206
206
  "gate": ["bash", "run.sh", "check-entwurf-v2-native-push"],
207
207
  "timeoutSeconds": 120,
208
208
  "signature": "[QK:NATIVE-PUSH-RETRY-BYTE-IDENTITY]",
209
- "signatureSource": "scripts/check-entwurf-v2-native-push.ts"
209
+ "signatureSource": "pi-extensions/lib/entwurf-v2-native-push.test.ts"
210
210
  }
211
211
  ]
212
212
  }
@@ -11,7 +11,7 @@
11
11
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
12
12
  "timeoutSeconds": 60,
13
13
  "signature": "[QK:V2RESUME-LIVE-REFUSED]",
14
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
14
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
15
15
  },
16
16
  {
17
17
  "claim": "V2RESUME-INDETERMINATE-NO-START",
@@ -22,7 +22,7 @@
22
22
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
23
23
  "timeoutSeconds": 60,
24
24
  "signature": "[QK:V2RESUME-INDETERMINATE-NO-START]",
25
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
25
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
26
26
  },
27
27
  {
28
28
  "claim": "V2RESUME-CONFLICT-NO-START",
@@ -33,7 +33,7 @@
33
33
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
34
34
  "timeoutSeconds": 60,
35
35
  "signature": "[QK:V2RESUME-CONFLICT-NO-START]",
36
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
36
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
37
37
  },
38
38
  {
39
39
  "claim": "V2RESUME-LOCKED-NO-RESOLVE",
@@ -44,7 +44,7 @@
44
44
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
45
45
  "timeoutSeconds": 60,
46
46
  "signature": "[QK:V2RESUME-LOCKED-NO-RESOLVE]",
47
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
47
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
48
48
  },
49
49
  {
50
50
  "claim": "V2RESUME-NO-TRANSCRIPT-FAILS-LOUD",
@@ -67,7 +67,7 @@
67
67
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
68
68
  "timeoutSeconds": 60,
69
69
  "signature": "[QK:V2RESUME-NO-TRANSCRIPT-FAILS-LOUD]",
70
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
70
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
71
71
  },
72
72
  {
73
73
  "claim": "V2RESUME-OBSERVE-IS-BOUNDED-WAIT",
@@ -78,7 +78,7 @@
78
78
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
79
79
  "timeoutSeconds": 60,
80
80
  "signature": "[QK:V2RESUME-OBSERVE-IS-BOUNDED-WAIT]",
81
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
81
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
82
82
  },
83
83
  {
84
84
  "claim": "V2RESUME-UNOBSERVED-IS-REAL",
@@ -89,7 +89,7 @@
89
89
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
90
90
  "timeoutSeconds": 60,
91
91
  "signature": "[QK:V2RESUME-UNOBSERVED-IS-REAL]",
92
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
92
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
93
93
  },
94
94
  {
95
95
  "claim": "V2RESUME-RELEASE-FAILURE-LOUD",
@@ -100,7 +100,7 @@
100
100
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
101
101
  "timeoutSeconds": 60,
102
102
  "signature": "[QK:V2RESUME-RELEASE-FAILURE-LOUD]",
103
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
103
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
104
104
  },
105
105
  {
106
106
  "claim": "V2RESUME-ARGV-FROM-RECORD",
@@ -111,7 +111,7 @@
111
111
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
112
112
  "timeoutSeconds": 60,
113
113
  "signature": "[QK:V2RESUME-ARGV-FROM-RECORD]",
114
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
114
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
115
115
  },
116
116
  {
117
117
  "claim": "V2RESUME-RECEIPTS-SEPARATE",
@@ -124,7 +124,7 @@
124
124
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
125
125
  "timeoutSeconds": 60,
126
126
  "signature": "[QK:V2RESUME-RECEIPTS-SEPARATE]",
127
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
127
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
128
128
  },
129
129
  {
130
130
  "claim": "V2RESUME-RENDER-SEPARATES",
@@ -137,7 +137,7 @@
137
137
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
138
138
  "timeoutSeconds": 60,
139
139
  "signature": "[QK:V2RESUME-RENDER-SEPARATES]",
140
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
140
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
141
141
  },
142
142
  {
143
143
  "claim": "V2RESUME-IDENTITY-BEFORE-WINDOW",
@@ -148,7 +148,7 @@
148
148
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
149
149
  "timeoutSeconds": 60,
150
150
  "signature": "[QK:V2RESUME-IDENTITY-BEFORE-WINDOW]",
151
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
151
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
152
152
  },
153
153
  {
154
154
  "claim": "V2RESUME-TIMEOUT-RELEASES",
@@ -161,7 +161,7 @@
161
161
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
162
162
  "timeoutSeconds": 60,
163
163
  "signature": "[QK:V2RESUME-TIMEOUT-RELEASES]",
164
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
164
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
165
165
  },
166
166
  {
167
167
  "claim": "V2RESUME-LOCK-BEFORE-LIVENESS",
@@ -172,7 +172,7 @@
172
172
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
173
173
  "timeoutSeconds": 60,
174
174
  "signature": "[QK:V2RESUME-LOCK-BEFORE-LIVENESS]",
175
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
175
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
176
176
  },
177
177
  {
178
178
  "claim": "V2RESUME-NOT-PI-REFUSED",
@@ -183,7 +183,7 @@
183
183
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
184
184
  "timeoutSeconds": 60,
185
185
  "signature": "[QK:V2RESUME-NOT-PI-REFUSED]",
186
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
186
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
187
187
  },
188
188
  {
189
189
  "claim": "V2RESUME-NOT-PI-BY-FIELD",
@@ -198,7 +198,7 @@
198
198
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
199
199
  "timeoutSeconds": 60,
200
200
  "signature": "[QK:V2RESUME-NOT-PI-BY-FIELD]",
201
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
201
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
202
202
  },
203
203
  {
204
204
  "claim": "V2RESUME-SHARED-ID-GRAMMAR",
@@ -209,7 +209,7 @@
209
209
  "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
210
210
  "timeoutSeconds": 60,
211
211
  "signature": "[QK:V2RESUME-SHARED-ID-GRAMMAR]",
212
- "signatureSource": "scripts/check-entwurf-v2-visible-resume.ts"
212
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
213
213
  }
214
214
  ]
215
215
  }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * peer-facts — the read-only JSON projection of the OBSERVED peer listing (#116 M2-a).
4
+ *
5
+ * `meta-facts` (#65) emits what the meta-record STORE owns. This emits what the fact
6
+ * PROVIDER owns: the same `listEntwurfFacts` assembly `entwurf_peers` renders, serialized
7
+ * instead of drawn. It exists for the same reason #65 exists — so a consumer stops
8
+ * carrying a decaying copy of a join it does not own.
9
+ *
10
+ * THE COPY THIS REFUSES. The placement axis joins a herdr pane to a citizen on
11
+ * `nativeSessionId`, and on the pi side that key is recovered from a session FILENAME by a
12
+ * strict conversion measured against one vendor floor (`herdr-placement.ts`
13
+ * piNativeSessionIdFromPath). A consumer that re-implements that join forks a vendor floor
14
+ * into a file no gate covers and no document owns (`docs/mux-launch-rail.md` §7). With this
15
+ * verb the consumer's whole join becomes ONE opaque pane-id string equality, which is a
16
+ * thing a shell script may correctly do.
17
+ *
18
+ * ONE PROVIDER, ONE RENDERER, NO SECOND JOIN. The payload is `renderEntwurfPeers().payload`
19
+ * — the same provider (`listEntwurfFacts`) through the same renderer the MCP `entwurf_peers`
20
+ * surface uses, so the SHAPE is that surface's payload shape and each row is a `PeerFact`
21
+ * whose keyset `check-entwurf-peers-surface` pins to `{peers, diagnostics}`. Nothing here
22
+ * recomputes, filters, re-sorts or re-words a provider fact, and nothing here re-implements
23
+ * the join.
24
+ *
25
+ * It is NOT the same bytes as an `entwurf_peers` call, and the difference is deliberate, not
26
+ * a drift: that surface is a human listing, so it passes `observationLimit` and returns only
27
+ * rendered TEXT over the wire. This is a machine projection, so its observation is
28
+ * UNBOUNDED — a rationed row would say `unobserved`, which a machine consumer cannot tell
29
+ * apart from "nobody could look". Older rows can therefore read `exists`/`active` here where
30
+ * the human surface says `unobserved`. Same facts, measured for more rows.
31
+ *
32
+ * `placement` STAYS STRUCTURED. The human surface prints `herdr <pane>` so a reader never
33
+ * mistakes a pane id for one of ours; a machine consumer needs the tagged union itself
34
+ * (`{kind:"herdr-pane",paneId}` / `unobserved` / `none` / `ambiguous`), so `renderPlacement`
35
+ * is NOT applied here. A pane is an ephemeral view (Hard Rule 16), never an address.
36
+ *
37
+ * WHAT IS DELIBERATELY ABSENT. No herdr `agent_status`, no `interactive_ready`, no screen
38
+ * text: those are herdr's own display verdicts, they are not delivery evidence
39
+ * (`docs/herdr-launch-rail.md` §9), and keeping them out of every entwurf payload is what
40
+ * makes "observed activity" unable to drift into delivery liveness. A consumer that wants
41
+ * to show them reads herdr itself and labels them as herdr's report.
42
+ *
43
+ * ONE PLACEMENT READ. `readPlacementIndex` is left undefined, so the provider performs its
44
+ * own single read per listing — the anti-watcher shape `entwurf-fact-provider.ts` documents.
45
+ * This verb never loops, retries, or waits for a pane whose session reference has not landed.
46
+ *
47
+ * NO SOCKET COORDINATE LEAVES THIS VERB. `ENTWURF_DIR` selects the socket world that is
48
+ * PROBED, and that is all it does: the control dir is never emitted. #50 C4 retired the
49
+ * legacy `sessions` projection "with the `controlDir` it exposed" because the record is the
50
+ * sole address axis and socket paths are dispatch-internal transport
51
+ * (`entwurf-peers-render.ts` header). Re-publishing that path under a new verb would undo
52
+ * that retirement, and no renderer needs it.
53
+ *
54
+ * stdout (deterministic, 2-space indent, trailing newline):
55
+ * {
56
+ * "schemaVersion": 1, // of THIS projection
57
+ * "storeDir": "/abs", // the meta-record store that was read
58
+ * "peers": [ <PeerFact, verbatim, provider order> ],
59
+ * "diagnostics": [ <EntwurfDiagnostic, verbatim> ]
60
+ * }
61
+ *
62
+ * EXIT CONTRACT — identical in shape to meta-facts, for the same reason:
63
+ * 0 — the store was readable; the JSON above is on stdout (diagnostics in-band).
64
+ * A store that does not exist is a readable EMPTY store (ENOENT only).
65
+ * 2 — usage error (bad argv).
66
+ * 3 — the store could not be READ. No JSON is emitted: an unreadable host must
67
+ * never look like an empty one.
68
+ * A provider wiring invariant (duplicate identity / unprobed in-domain citizen) is left to
69
+ * THROW. It is not an unreadable store and must not be dressed as one (Hard Rule 15).
70
+ */
71
+
72
+ import os from "node:os";
73
+ import { defaultControlSocketDir } from "../pi-extensions/lib/control-socket-path.js";
74
+ import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
75
+ import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
76
+ import {
77
+ type ActiveStoreEntry,
78
+ defaultMetaSessionsDir,
79
+ makeStoreRecordReader,
80
+ readActiveStoreEntries,
81
+ } from "../pi-extensions/lib/meta-session.ts";
82
+
83
+ const arg = process.argv[2];
84
+ // A dash argv is a flag this command does not have, not a store directory — treating it as
85
+ // a path would answer `--help` with "empty store, exit 0", a silent wrong fact.
86
+ if (process.argv.length > 3 || (arg !== undefined && arg.startsWith("-"))) {
87
+ console.error("usage: entwurf peer-facts [meta-sessions-dir]");
88
+ process.exit(2);
89
+ }
90
+
91
+ const storeDir = arg ?? defaultMetaSessionsDir();
92
+ // The SAME override the bridge honours (`mcp/entwurf-bridge/src/index.ts` ENTWURF_DIR), so
93
+ // this verb and `entwurf_peers` probe one socket world. The pi side has no such override;
94
+ // the path grammar is the shared leaf either way. This value is an INPUT to the probe and
95
+ // never an output — see NO SOCKET COORDINATE LEAVES THIS VERB above.
96
+ const controlDir = process.env.ENTWURF_DIR ?? defaultControlSocketDir(os.homedir());
97
+
98
+ let metaEntries: ActiveStoreEntry[];
99
+ try {
100
+ // Entries WITH their kind, never bare names: the listing must be able to refuse a
101
+ // symlinked record without following it (Hard Rule 7). ENOENT is the empty store.
102
+ metaEntries = readActiveStoreEntries(storeDir);
103
+ } catch (err) {
104
+ console.error(`FAIL: ${err instanceof Error ? err.message : String(err)}`);
105
+ process.exit(3); // see the EXIT CONTRACT above: 3 = unreadable store, never an empty one
106
+ }
107
+
108
+ const result = await listEntwurfFacts({
109
+ metaEntries,
110
+ readRecord: makeStoreRecordReader(storeDir),
111
+ socket: { dir: controlDir },
112
+ // No `observationLimit`: a budget would emit `unobserved` for rows nobody chose to
113
+ // skip, and in a machine payload that is indistinguishable from "no herdr here". The
114
+ // human surface may ration its own rows; a projection may not ration its facts.
115
+ });
116
+
117
+ const { payload } = renderEntwurfPeers(result);
118
+ const projection = { schemaVersion: 1, storeDir, ...payload };
119
+
120
+ process.stdout.write(`${JSON.stringify(projection, null, 2)}\n`);
@@ -120,6 +120,14 @@ from pathlib import Path
120
120
  # the first one was closed (#53 B). sys.path[0] already holds this directory when the
121
121
  # script is run by path, which is how run.sh and every gate invoke it; the explicit
122
122
  # insert keeps the import true under any other invocation form.
123
+ # The sibling import below would otherwise leave `scripts/__pycache__/pi_settings_io.*.pyc`
124
+ # behind — a write into the checkout this script was merely READ from. It is git-ignored, so
125
+ # `git status --porcelain` never shows it, and on a host where the cache already exists nothing
126
+ # changes; but in the gate-qualification snapshot the file is NEW, and the tree manifest that
127
+ # certifies "the runner never wrote the tree" reads bytes, not porcelain. Measured on the #116
128
+ # M3-b3 candidate: that one path was the whole of `IMPURE: treeClean=false porcelainClean=true`.
129
+ # Same guard, same reason as codex-mcp-config.py / codex-statusline-config.py.
130
+ sys.dont_write_bytecode = True
123
131
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
124
132
  from pi_settings_io import classify_installer_root, detect_indent, dumps # noqa: E402
125
133
 
@@ -76,6 +76,14 @@ import sys
76
76
  # `install` — semantically a no-op, byte-wise a RED `pnpm check`. sys.path[0] already
77
77
  # holds this directory when the script is run by path (how run.sh and the gates invoke
78
78
  # it); the explicit insert keeps the import true under any other invocation form.
79
+ # The sibling import below would otherwise leave `scripts/__pycache__/pi_settings_io.*.pyc`
80
+ # behind — a write into the checkout this script was merely READ from. It is git-ignored, so
81
+ # `git status --porcelain` never shows it, and on a host where the cache already exists nothing
82
+ # changes; but in the gate-qualification snapshot the file is NEW, and the tree manifest that
83
+ # certifies "the runner never wrote the tree" reads bytes, not porcelain. Measured on the #116
84
+ # M3-b3 candidate: that one path was the whole of `IMPURE: treeClean=false porcelainClean=true`.
85
+ # Same guard, same reason as codex-mcp-config.py / codex-statusline-config.py.
86
+ sys.dont_write_bytecode = True
79
87
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
80
88
  from pi_settings_io import classify_installer_root, detect_indent, dumps, unchanged # noqa: E402
81
89
 
@@ -170,11 +178,49 @@ def _now() -> str:
170
178
  return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
171
179
 
172
180
 
173
- def _classify(existing_cmd, repo_dir: str) -> str:
181
+ # #116 M3-b2 — the ONE alternative managed command, and it is DERIVED, never supplied.
182
+ # A Herdr plugin activates on a host where nothing entwurf is on PATH, so the bare bin cannot
183
+ # resolve. The plugin mode therefore names the bridge by its absolute path under the certified
184
+ # stable active runtime — and that is the whole freedom it gets: the caller passes the runtime ROOT,
185
+ # this function appends the fixed suffix, and any other string is refused. There is deliberately no
186
+ # flag or environment variable that accepts an arbitrary command or path: that would be a generic
187
+ # "write whatever you like into the operator's pi settings" authority wearing a plugin's clothes.
188
+ PLUGIN_BIN_SUFFIX = os.path.join("node_modules", ".bin", SERVER_KEY)
189
+
190
+
191
+ def stable_active_root() -> str:
192
+ """The ONE runtime address, derived from this process's XDG/HOME exactly as the runtime owner
193
+ derives it (`scripts/herdr-runtime.mjs` resolveRuntimeLayout). A gate pins the two
194
+ implementations equal; nothing here reads a caller-supplied root as authority."""
195
+ data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
196
+ return os.path.join(data_home, "entwurf", "herdr-plugin", "runtime", "active")
197
+
198
+
199
+ def plugin_command_for(runtime_root: str) -> str:
200
+ # The caller may NAME the root, but it is only accepted when it IS this host's stable root. An
201
+ # absolute path was never the contract — "the stable runtime" was — and a mode that takes any
202
+ # absolute directory is a way to point every pi session at an executable of the caller's choice.
203
+ if not isinstance(runtime_root, str) or not os.path.isabs(runtime_root):
204
+ _die(2, f"register-pi-provider: --plugin-runtime must be an ABSOLUTE stable runtime root, got {runtime_root!r}")
205
+ if runtime_root != os.path.normpath(runtime_root) or runtime_root.endswith(os.sep):
206
+ _die(2, f"register-pi-provider: --plugin-runtime must be a normalised path, got {runtime_root!r}")
207
+ expected = stable_active_root()
208
+ if runtime_root != expected:
209
+ _die(2, f"register-pi-provider: --plugin-runtime must be this host's stable runtime root {expected!r}, "
210
+ f"got {runtime_root!r} — an arbitrary absolute directory is not a plugin runtime.")
211
+ return os.path.join(runtime_root, PLUGIN_BIN_SUFFIX)
212
+
213
+
214
+ def _classify(existing_cmd, repo_dir: str, managed_command: str = BARE_COMMAND) -> str:
174
215
  if existing_cmd is None:
175
216
  return "absent"
176
- if existing_cmd == BARE_COMMAND:
217
+ if existing_cmd == managed_command:
177
218
  return "managed-current"
219
+ # The OTHER shape we own is adoptable, so bare↔plugin is a normalisation rather than a
220
+ # takeover. In DEFAULT mode this is exactly the historical set (the bare bin is
221
+ # managed-current above and never reaches here), so default bytes and verdicts are unchanged.
222
+ if managed_command != BARE_COMMAND and existing_cmd == BARE_COMMAND:
223
+ return "managed-legacy"
178
224
  if isinstance(existing_cmd, str) and (
179
225
  existing_cmd == f"{repo_dir}/mcp/{SERVER_KEY}/start.sh"
180
226
  or existing_cmd.endswith(f"/entwurf/mcp/{SERVER_KEY}/start.sh")
@@ -210,7 +256,8 @@ def _load_state_file(state_path: str) -> dict | None:
210
256
 
211
257
 
212
258
  def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str, takeover: bool = False,
213
- preflight: bool = False) -> None:
259
+ preflight: bool = False, plugin_runtime: str | None = None) -> None:
260
+ managed_command = plugin_command_for(plugin_runtime) if plugin_runtime else BARE_COMMAND
214
261
  if os.path.islink(settings_path):
215
262
  target = os.readlink(settings_path)
216
263
  _die(3, f"register-pi-provider: refusing to adopt {settings_path} — it is a symlink to {target} "
@@ -254,7 +301,7 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str,
254
301
  _prune_legacy(servers, repo_dir) # independent of entwurf-bridge ownership
255
302
  existing = servers.get(SERVER_KEY)
256
303
  existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
257
- ownership = _classify(existing_cmd, repo_dir)
304
+ ownership = _classify(existing_cmd, repo_dir, managed_command)
258
305
 
259
306
  if preflight:
260
307
  # Read-only half of the atomic user-scope operation: the ownership decision
@@ -282,8 +329,8 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str,
282
329
  sys.stdout.write(f"install: no change — {settings_path} left untouched (bytes and mtime stable)\n")
283
330
  return
284
331
 
285
- # absent / managed-current / managed-legacy → normalize to the bare stable bin.
286
- newval = {"command": BARE_COMMAND}
332
+ # absent / managed-current / managed-legacy → normalize to the managed command for this mode.
333
+ newval = {"command": managed_command}
287
334
  # preserve non-empty custom args if the operator set them; else default [].
288
335
  if isinstance(existing, dict) and existing.get("args") not in (None, []):
289
336
  newval["args"] = existing["args"]
@@ -292,7 +339,8 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str,
292
339
  servers[SERVER_KEY] = newval
293
340
  wrote = _persist(settings_path, before, data, raw)
294
341
  sys.stdout.write(
295
- f"install: {ownership} → entwurfProvider.mcpServers.{SERVER_KEY} = {BARE_COMMAND} (bare stable bin)\n"
342
+ f"install: {ownership} → entwurfProvider.mcpServers.{SERVER_KEY} = {managed_command} "
343
+ f"({'stable plugin runtime' if plugin_runtime else 'bare stable bin'})\n"
296
344
  )
297
345
  # The desired value AND the legacy prune both already held: nothing to say to the
298
346
  # file. Reported so an operator (and the gate) can tell "already correct" from
@@ -317,7 +365,7 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str,
317
365
  "managedSettingsPath": os.path.abspath(settings_path),
318
366
  "scope": "user",
319
367
  "key": f"entwurfProvider.mcpServers.{SERVER_KEY}",
320
- "command": BARE_COMMAND,
368
+ "command": managed_command,
321
369
  "ownership": ownership, # absent | managed-current | managed-legacy
322
370
  "installerRoot": repo_dir, # #86 C2: the root whose inverse may remove this key
323
371
  "preimage": existing, # raw prior value (audit only; NOT restored)
@@ -387,8 +435,23 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str, o
387
435
  before = _parse_settings(managed, raw) # dies 4 on corrupt — preflight and writer alike
388
436
  data = _parse_settings(managed, raw)
389
437
  provider, servers = _provider_servers(data, create=False)
438
+ # #116 M3-b2 — DRIFT IS NOT OURS TO DELETE. The user-scope inverse is admitted by the
439
+ # ownership record, not by the command string, so without this it would remove whatever now
440
+ # sits at our key. The state records the exact command we wrote (bare bin, or the absolute
441
+ # bridge under a stable plugin runtime); if the live value is PRESENT and different, someone
442
+ # replaced our key after we installed it and the honest move is to refuse by name rather
443
+ # than delete their override. An ABSENT key stays an idempotent no-op.
444
+ recorded_cmd = state.get("command")
445
+ live = servers.get(SERVER_KEY) if isinstance(servers, dict) else None
446
+ live_cmd = live.get("command") if isinstance(live, dict) else live
447
+ if isinstance(recorded_cmd, str) and live_cmd is not None and live_cmd != recorded_cmd:
448
+ _die(6, f"register-pi-provider: the user-scope {SERVER_KEY} command drifted since install — "
449
+ f"we wrote {recorded_cmd!r}, {managed} now holds {live_cmd!r}. That is somebody's override, "
450
+ "not our key; zero settings bytes written and the ownership state is left intact.")
390
451
  if preflight:
391
- sys.stdout.write("preflight: remove ok (owner verified; managed target bound and parseable)\n")
452
+ sys.stdout.write(
453
+ f"preflight: remove ok (owner verified; managed target bound and parseable; command={recorded_cmd!r})\n"
454
+ )
392
455
  return
393
456
  if os.path.exists(managed):
394
457
  # honest inverse: absent/managed-* → remove OUR key (a legacy repo path is NOT
@@ -442,6 +505,7 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str, o
442
505
 
443
506
 
444
507
  def _parse(argv: list):
508
+ plugin_runtime = None
445
509
  # positional: settings_path repo_dir ; flags: --scope <s> [--state <p>] [--takeover] [--orphan-cleanup] [--preflight]
446
510
  pos, scope, state_path = [], None, ""
447
511
  takeover, orphan, preflight = False, False, False
@@ -460,17 +524,24 @@ def _parse(argv: list):
460
524
  orphan = True
461
525
  elif a == "--preflight":
462
526
  preflight = True
527
+ elif a == "--plugin-runtime":
528
+ i += 1
529
+ # A flag with no value must NOT fall through to bare mode: the caller asked for the
530
+ # plugin runtime and would otherwise be told, silently, that it got it.
531
+ if i >= len(argv) or not argv[i]:
532
+ _die(5, "register-pi-provider.py: --plugin-runtime requires a value")
533
+ plugin_runtime = argv[i]
463
534
  else:
464
535
  pos.append(a)
465
536
  i += 1
466
- return pos, scope, state_path, takeover, orphan, preflight
537
+ return pos, scope, state_path, takeover, orphan, preflight, plugin_runtime
467
538
 
468
539
 
469
540
  def main(argv: list) -> None:
470
541
  if len(argv) < 2:
471
542
  _die(5, "usage: register-pi-provider.py <install|remove> <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
472
543
  sub = argv[1]
473
- pos, scope, state_path, takeover, orphan, preflight = _parse(argv[2:])
544
+ pos, scope, state_path, takeover, orphan, preflight, plugin_runtime = _parse(argv[2:])
474
545
  if sub not in ("install", "remove"):
475
546
  _die(5, f"register-pi-provider.py: unknown subcommand {sub!r}")
476
547
  if len(pos) != 2:
@@ -486,8 +557,13 @@ def main(argv: list) -> None:
486
557
  _die(5, "register-pi-provider.py: --takeover is an install action")
487
558
  if orphan and sub != "remove":
488
559
  _die(5, "register-pi-provider.py: --orphan-cleanup is a remove action")
560
+ # The plugin mode is an INSTALL-side USER-scope decision only. `remove` needs no mode: it
561
+ # recognises every shape we own through the same classifier, so an inverse never has to be told
562
+ # which one it is undoing — and a mode flag on remove would be a way to aim a deletion.
563
+ if plugin_runtime is not None and (sub != "install" or scope != "user"):
564
+ _die(5, "register-pi-provider.py: --plugin-runtime is a user-scope install action")
489
565
  if sub == "install":
490
- cmd_install(settings_path, repo_dir, scope, state_path, takeover, preflight)
566
+ cmd_install(settings_path, repo_dir, scope, state_path, takeover, preflight, plugin_runtime)
491
567
  else:
492
568
  cmd_remove(settings_path, repo_dir, scope, state_path, orphan, preflight)
493
569
 
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.76.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.79.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).