@junghanacs/entwurf 0.24.0 → 0.25.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.
- package/CHANGELOG.md +564 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +24 -1
- package/README.md +2 -2
- package/VERIFY.md +20 -2
- package/demo/README.md +5 -2
- package/docs/acp-backend-rail.md +49 -2
- package/docs/setup-clean-host.md +4 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
- package/mcp/entwurf-bridge/dist/protocol.js +0 -7
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
- package/mcp/entwurf-bridge/src/index.ts +5 -4
- package/package.json +10 -9
- package/pi-extensions/entwurf-control.ts +27 -154
- package/pi-extensions/lib/compaction-send-guard.ts +5 -3
- package/pi-extensions/lib/control-send-receipt.ts +108 -0
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
- package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
- package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
- package/pi-extensions/lib/herdr-placement.ts +11 -0
- package/protocol.js +0 -8
- package/run.sh +202 -33
- package/scripts/check-entwurf-v2-surface.ts +289 -10
- package/scripts/check-gate-qualification.ts +2354 -11
- package/scripts/check-herdr-fresh-call.ts +10 -2
- package/scripts/check-herdr-placement.ts +51 -0
- package/scripts/check-herdr-plugin-build.ts +11 -4
- package/scripts/check-herdr-plugin.ts +745 -38
- package/scripts/check-mux-launcher-fence.ts +37 -0
- package/scripts/check-pi-launch.ts +7 -4
- package/scripts/check-release-gate-outcomes.ts +165 -0
- package/scripts/doctor-pi-provider.ts +44 -4
- package/scripts/herdr-rails.mjs +75 -0
- package/scripts/lib/qualification-acceptance.ts +146 -0
- package/scripts/lib/qualification-artifact.ts +359 -0
- package/scripts/lib/qualification-collection.ts +384 -0
- package/scripts/lib/qualification-gh.ts +231 -0
- package/scripts/lib/qualification-ledger.ts +669 -0
- package/scripts/lib/qualification-oracle-core.ts +279 -0
- package/scripts/lib/qualification-receipt.ts +561 -0
- package/scripts/lib/qualification-release.ts +271 -0
- package/scripts/meta-bridge-state.py +18 -1
- package/scripts/mutants/bridge-command-boot.json +38 -0
- package/scripts/mutants/control-send-receipt.json +235 -0
- package/scripts/mutants/herdr-fresh-call.json +4 -4
- package/scripts/mutants/herdr-plugin-build.json +7 -11
- package/scripts/mutants/herdr-plugin.json +177 -9
- package/scripts/mutants/meta-retire.json +22 -0
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-floor.json +17 -0
- package/scripts/mutants/qualification-ledger.json +489 -0
- package/scripts/mutants/release-gate.json +48 -0
- package/scripts/mutants/retired-sent-surface.json +32 -0
- package/scripts/mutants/v2-surface.json +7 -7
- package/scripts/qualification-oracle.ts +124 -0
- package/scripts/smoke-codex-fresh-live.ts +94 -13
- package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
- package/scripts/smoke-herdr-raw-install-live.sh +66 -4
- package/scripts/smoke-meta-install-state.sh +107 -1
- package/scripts/smoke-mux-lifecycle-live.ts +9 -2
- package/scripts/smoke-pi-provider-state.sh +106 -0
- package/scripts/smoke-setup-verdict.sh +18 -2
package/README.md
CHANGED
|
@@ -254,7 +254,7 @@ cd ~/repos/gh/entwurf
|
|
|
254
254
|
|
|
255
255
|
The full source setup requires Node 24, pnpm, and Python 3 on PATH. Harnesses are
|
|
256
256
|
optional-by-presence: `setup` runs the frozen dependency install, then composes what the operator
|
|
257
|
-
already installed — a compatible `pi` (`>=0.
|
|
257
|
+
already installed — a compatible `pi` (`>=0.87.1 <0.88`), Claude Code, agy, and the Copilot CLI
|
|
258
258
|
each get their wiring completed when detected, an absent harness is an explicit zero-state SKIP,
|
|
259
259
|
and a detected harness that cannot be completed (including a below-floor `pi`) is a named FAIL
|
|
260
260
|
that makes setup exit nonzero. `setup` never installs a harness binary or touches a credential
|
|
@@ -288,7 +288,7 @@ This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending
|
|
|
288
288
|
physical host (control sockets, mux, ACP plugin hosting).
|
|
289
289
|
|
|
290
290
|
To use the `entwurf` provider inside pi, install a compatible pi binary
|
|
291
|
-
separately (`@earendil-works/pi-coding-agent >=0.
|
|
291
|
+
separately (`@earendil-works/pi-coding-agent >=0.87.1 <0.88`). Then point pi at
|
|
292
292
|
the npm-installed package or development clone:
|
|
293
293
|
|
|
294
294
|
```bash
|
package/VERIFY.md
CHANGED
|
@@ -86,7 +86,7 @@ that moved out of `scripts/` and into a file beside its subject would otherwise
|
|
|
86
86
|
### The canonical floor — two entry points
|
|
87
87
|
|
|
88
88
|
- **Deterministic floor — two tiers (#70):** `pnpm check` is the everyday CORE — toolchain (lint + typecheck), the vitest lanes, and the pure-unit / behavioral-contract / source-topology gates plus the cheap static coherence checks. It prints its own total wall time; acceptance is ≤60s on the reference host `oracle` (an operator measurement, never a hard wall-clock gate on arbitrary hosts). `pnpm run check:full` is the FULL deterministic floor — the core plus the hermetic-integration and package/install tiers — and is what the frozen-candidate protocol, push CI, release-gate, and `prepublishOnly` run. Exact membership is the named `check:*` group scripts in `package.json` (the executable SSOT — this document records meaning and principles, not the command list); a gate changes tier by semantic-class decision, never by getting faster or slower. The FULL tier carries the qualification HEAD (`check-gate-manifests`, through `check:hermetic`); the everyday core does not, and neither tier carries the separately scheduled mutant-executing body (`check-gate-qualification`).
|
|
89
|
-
- **Discriminating power of that floor:** `./run.sh check-gate-qualification` (scheduled, not ambient: standalone when a lane changes a gate/mutant/matrix, in the CI `check` job on a branch push that touched the qualification surface, and as a release-gate MUST step — no longer inside the default check chains; a semver TAG push triggers no CI at all, and the exact-SHA evidence a release quotes is whichever run at that commit CARRIES the body — the branch run when the push touched the qualification surface, else a dispatch run; a push that touched none of the qualification surface skips the body, and `workflow_dispatch -f qualify=true` plus a weekly `schedule` run it unconditionally — the exact-SHA release oracle refuses a SHA whose body step did not conclude success) re-plants committed defect mutants (`scripts/mutants/*.json`, one per closed defect class) in an isolated snapshot repo and requires each to turn its gate red **bounded and at its claimed `[QK:<claim>]` signature** — a wrong-reason red fails, a baseline-red control voids the whole group, and the runner is negative-controlled on every run (zero-match/multi-match/survived/wrong-reason/hang/control-red/impurity). This measures whether the deterministic gates still *block* what they claim to block; it is **not a new evidence level** (L0–L5 are untouched) and never substitutes for LIVE evidence. Per-cut records cite claim IDs + killed mutant IDs — "N checks passed" alone is not evidence. `check-agy-permission-matrix` complements it with the enumerated permission contract space (literal cells + stated exclusion rules, oracle independent of the SUT). The gate's HEAD is separable and IS in the floor: `check-gate-manifests` runs the runner self-test, validates the committed manifest set against the origin index, and asserts the declared lane inventory, while executing zero mutants and never snapshotting the repo — three of the five reds qualification has ever produced in CI died in that head, in under five seconds.
|
|
89
|
+
- **Discriminating power of that floor:** `./run.sh check-gate-qualification` (scheduled, not ambient: standalone when a lane changes a gate/mutant/matrix, in the CI `check` job on a branch push that touched the qualification surface, and as a release-gate MUST step — no longer inside the default check chains; a semver TAG push triggers no CI at all, and the exact-SHA evidence a release quotes is whichever run at that commit CARRIES the body — the branch run when the push touched the qualification surface, else a dispatch run; a push that touched none of the qualification surface skips the body, and `workflow_dispatch -f qualify=true` plus a weekly `schedule` run it unconditionally — the exact-SHA release oracle refuses a SHA whose body step did not conclude success) re-plants committed defect mutants (`scripts/mutants/*.json`, one per closed defect class) in an isolated snapshot repo and requires each to turn its gate red **bounded and at its claimed `[QK:<claim>]` signature** — a wrong-reason red fails, a baseline-red control voids the whole group, and the runner is negative-controlled on every run (zero-match/multi-match/survived/wrong-reason/hang/control-red/impurity). This measures whether the deterministic gates still *block* what they claim to block; it is **not a new evidence level** (L0–L5 are untouched) and never substitutes for LIVE evidence. Per-cut records cite claim IDs + killed mutant IDs — "N checks passed" alone is not evidence. `check-agy-permission-matrix` complements it with the enumerated permission contract space (literal cells + stated exclusion rules, oracle independent of the SUT). The gate's HEAD is separable and IS in the floor: `check-gate-manifests` runs the runner self-test, validates the committed manifest set against the origin index, and asserts the declared lane inventory, while executing zero COMMITTED mutants (the self-test runs only its synthetic fixture mutants, in tmp fixture repos) and never snapshotting the repo — three of the five reds qualification has ever produced in CI died in that head, in under five seconds.
|
|
90
90
|
- **Live floor:** `LIVE=1 ./run.sh release-gate <scratch-project-dir> --cut` — `pnpm run check:full` + the v2-native live gates + the ACP plugin acceptance floor. It reports a **two-tier summary**:
|
|
91
91
|
- **MUST tier** (release-blocking — owns the exit code; "green" applies only here): `pnpm run check:full`, `check-gate-qualification`, `smoke-entwurf-v2-matrix-live`, `check-bridge`, `doctor-pi-provider` (#81 — `check-bridge` proves the launcher this checkout SHIPS; this proves the invocation the operator's pi provider actually EXECS, by booting it and requiring the entwurf verb set back, because `command -v` answers yes for a command that exits 127; wired as a step on 2026-08-19 after a relocated pnpm cmd-shim cost a cut sixteen LIVE steps before the same fact surfaced at `smoke-acp-bundled-mcp-live`), the resident-garden-guard zero-token half (record birth / record-keyed socket / attach-on-reopen), the `smoke-acp-*-live` ACP plugin smokes (socket-citizen / raw-turn / overlay / provider / session-reuse / carrier-augment / memory-containment / rgg / mcp / skill / bundled-mcp / v2-send), the axis wired in on 2026-07-31 that the aggregate had simply never listed (`smoke-claude-native-resume-live`), `smoke-entwurf-chain-live` — the cross-harness delivery chain (native Claude Code → pi GPT → pi ACP Sonnet → mailbox terminus) proving sender identity and replyability at every hop plus a real read receipt at the end — the integrated `smoke-mux-lifecycle-live` (its own note below), and the two OMP steps 0.16.0 wired: `smoke-omp-receive-live` (the addressed roundtrip into a live omp citizen; it reads the capability registry and decides its own outcome rather than passing by default) and `smoke-omp-fresh-live` (the clause 7 receipt the release stop now requires). (`smoke-session-id-name` is gone — #50 C3: its `--session-id`/`--name` substrate has no entwurf consumer anymore.)
|
|
92
92
|
- **BEHAVIOR tier** (advisory, non-blocking): the resident-garden-guard positive (a model-in-loop `entwurf_self` turn). A BEHAVIOR FAIL is surfaced with its artifact path but **never blocks the cut**. The lane holds what the model *chooses*, never what our wiring fails to deliver — a gate that TELLS the model which tool to call stays MUST, because its failure is ours — measured 2026-07-24, when the tool turned out to be absent from the session schema in both observed failures (the bundled-MCP readiness gap recorded in `scripts/smoke-acp-v2-send-live.ts`).
|
|
@@ -132,6 +132,24 @@ that moved out of `scripts/` and into a file beside its subject would otherwise
|
|
|
132
132
|
> `LIVE=1 ENTWURF_CODEX_APP_SERVER_PID=<existing-app-server-pid>
|
|
133
133
|
> ENTWURF_CODEX_FRESH_MODEL=<codex-model>
|
|
134
134
|
> ENTWURF_CODEX_FRESH_PI_MODEL=<pi-model> ./run.sh smoke-codex-fresh-live`.
|
|
135
|
+
>
|
|
136
|
+
> Those three values are the step's operator-supplied prerequisite, and
|
|
137
|
+
> **`release-gate --cut` inherits that triple** — it is exported onto the aggregate invocation (the
|
|
138
|
+
> release skill's P5 carries it), never inferred by the gate, so the no-inference rule above holds
|
|
139
|
+
> unchanged inside the aggregate. **An ABSENT value is a protocol SKIP naming what was missing; a PRESENT but wrong one
|
|
140
|
+
> stays a FAIL.** That split is the honest reading of the step-outcome protocol — a step that never
|
|
141
|
+
> ran and a step that ran and broke are different facts — and it is what makes this MUST reproducible
|
|
142
|
+
> at every cut instead of inviting a standing exception: before 2026-09-23 all three were assertions, so their
|
|
143
|
+
> absence threw, and because the aggregate supplied none of them this step went red inside every
|
|
144
|
+
> cut on every host (0.25.0's candidate receipt, `MUST PASS=23 FAIL=1`, is that shape). A SKIP is
|
|
145
|
+
> **not** a softening: `--cut` refuses it as `cut: BLOCKED (MUST SKIP)`, so a cut without this
|
|
146
|
+
> receipt is still impossible — the operator supplies the triple and re-runs. A host with no Codex
|
|
147
|
+
> app-server therefore cannot take a cut, which is the intended cost of #87's release stop
|
|
148
|
+
> (「A visible-fresh receipt outside the MUST tier is no longer the general rule」above) rather than
|
|
149
|
+
> an accident. The installed-config preflights are deliberately NOT in that split and remain FAIL
|
|
150
|
+
> on absence — a missing Codex birth, user MCP/status/terminal-title config, runtime, bridge, tmux
|
|
151
|
+
> seat, or an unanswered launch directory is host state `setup` and the Codex doctors own, not a
|
|
152
|
+
> per-invocation input.
|
|
135
153
|
> **Both of THIS cell's live-spend defaults are `gpt-5.6-luna` from 2026-09-17**
|
|
136
154
|
> (`openai-codex/gpt-5.6-luna` on the pi side) — the Codex leg came down off `sol`. It is a tier
|
|
137
155
|
> choice and not a gate change; the receipt this cell is accepted on must be the one that actually
|
|
@@ -269,7 +287,7 @@ The goal is not merely "invoke Claude Code." We want:
|
|
|
269
287
|
**One install command to remember: `./run.sh setup <project>`.** It is idempotent — re-run the exact same command whenever anything looks wrong. There is no second install surface to juggle: from a clone `setup` runs the whole floor in order.
|
|
270
288
|
|
|
271
289
|
1. `pnpm install` — installs the pinned development dependencies and builds the bridge (source-checkout bootstrap only; an installed package never runs npm/pnpm inside `node_modules`)
|
|
272
|
-
2. pi wiring → `<project>/.pi/settings.json` + user-scope registration — only when a `pi` inside the supported range (`>=0.
|
|
290
|
+
2. pi wiring → `<project>/.pi/settings.json` + user-scope registration — only when a `pi` inside the supported range (`>=0.87.1 <0.88`) is on PATH; absent pi is an explicit zero-state SKIP, a below-floor pi is a detected FAIL. The user-scope entry is owner-recorded (#86 C2): another root's live-or-missing ownership makes this step a zero-write refusal (setup: pi FAIL) that names `takeover-user-scope`; the install-states bind the exact managed settings path (a drifted/symlinked/corrupt target is a zero-write refusal) and the inverse removes only the recorded owner's exact entry; `doctor-pi-package` reports the verdict
|
|
273
291
|
3. Claude meta-bridge global plugin — only when `claude` is on PATH; otherwise skipped cleanly
|
|
274
292
|
4. source stable-bin exposure — including certified `entwurf` → this checkout's `run.sh`, the managed runtime Copilot fresh resolves; helper units are attempted independently and a foreign helper is a named FAIL
|
|
275
293
|
5. agy bridge + exact permission + statusline + `PreInvocation` hook — only when `agy` is on PATH; each adapter is idempotent and independently doctorable
|
package/demo/README.md
CHANGED
|
@@ -46,7 +46,10 @@ a background driver types the scene prompts into the sender pane.
|
|
|
46
46
|
1. **Spawn** a sonnet sibling (memory write — "tempered indigo").
|
|
47
47
|
2. **Resume** that same sibling (memory recall — identity preservation,
|
|
48
48
|
the #9 regression guard).
|
|
49
|
-
3. **Cross-session greeting** via `entwurf_send
|
|
49
|
+
3. **Cross-session greeting** via `entwurf_send`. *(Historical: `entwurf_send` was retired in the
|
|
50
|
+
0.12 cutover, and the "sent box" half of that pairing was never actually reachable — the
|
|
51
|
+
sender-side renderer had zero producers and was removed in #120 P3. What the recording shows on
|
|
52
|
+
the receive side is real; the send side is a box the operator could not have seen.)*
|
|
50
53
|
|
|
51
54
|
Run:
|
|
52
55
|
|
|
@@ -139,7 +142,7 @@ SCENE_DELAY=30 FINAL_PAUSE=10 bash demo.sh
|
|
|
139
142
|
|
|
140
143
|
## Prerequisites
|
|
141
144
|
|
|
142
|
-
- `pi` on PATH (current floor 0.
|
|
145
|
+
- `pi` on PATH (current floor 0.87.1)
|
|
143
146
|
- `entwurf` provider configured + auth ready for the selected sender/peer models
|
|
144
147
|
- `asciinema` installed
|
|
145
148
|
- `agg` installed (optional — only for GIF conversion)
|
package/docs/acp-backend-rail.md
CHANGED
|
@@ -92,8 +92,8 @@ undifferentiated "supported" column is what let a Claude PASS read as if it also
|
|
|
92
92
|
|
|
93
93
|
| Surface | Declaration | Class | What a green actually says |
|
|
94
94
|
|---|---|---|---|
|
|
95
|
-
| Entwurf package | `0.
|
|
96
|
-
| pi runtime | devDep exact `0.
|
|
95
|
+
| Entwurf package | `0.25.0` | shipped baseline | the package contract these rows belong to |
|
|
96
|
+
| pi runtime | devDep exact `0.87.1`, peer `>=0.87.1 <0.88` | **exact** oracle + **closed range** | built and certified against 0.87.1; hosts inside the range are accepted, and the ceiling moves only on measurement |
|
|
97
97
|
| ACP wire SDK | `@agentclientprotocol/sdk 1.4.0` | **exact** | the shared wire oracle both adapters speak |
|
|
98
98
|
| Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.79.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
|
|
99
99
|
| Claude Agent SDK | `0.3.274` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
|
|
@@ -476,6 +476,53 @@ already emitted a `usage_update` at `compact_boundary` and 0.75.1 still does
|
|
|
476
476
|
|
|
477
477
|
Receipt, limits and the `completed`-branch gap: `scripts/raw-acp-compaction-measure/README.md`.
|
|
478
478
|
|
|
479
|
+
## 11-9. A steer reaches an ACP receiver one child turn late (#120 P5, measurement only)
|
|
480
|
+
|
|
481
|
+
`entwurf_v2 mode:"steer"` injects into the receiver's pi steering queue; it is not an ACP concept.
|
|
482
|
+
On a NATIVE pi receiver that queue is drained seconds later, and on an ACP receiver it is drained
|
|
483
|
+
one whole child turn later. The difference is not a different drain point — it is the same one.
|
|
484
|
+
|
|
485
|
+
`[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` `dist/agent-loop.js:141` awaits `streamAssistantResponse`
|
|
486
|
+
once per turn, and the steering drain is `:186`, after the `turn_end` emit at `:185`. For this rail
|
|
487
|
+
that one await is `pi-extensions/lib/acp/backend.ts:815` —
|
|
488
|
+
`await Promise.race([session.connection.prompt(promptArgs), lifecycle])` — so it spans the entire
|
|
489
|
+
`session/prompt` round trip. The child's own tool calls and subagent turns are invisible to pi, so
|
|
490
|
+
pi cannot reach `:186` until the child turn ends. Same code path as native, one long await.
|
|
491
|
+
|
|
492
|
+
That is a property, not a silent failure: the sender's receipt already says `queued-steer` and
|
|
493
|
+
`DELIVERY.md` already states that both queues are volatile and unordered against each other, so
|
|
494
|
+
nothing here is promised and then quietly not delivered. `[QK:PI-QUEUE-NO-MIDFLIGHT-MERGE]`
|
|
495
|
+
(`test/pi-queue.oracle.test.ts`) proves the mechanism on the installed vendor: a message queued
|
|
496
|
+
while a turn is in flight never joins that turn and arrives as a later one. The delay's SIZE is
|
|
497
|
+
that turn's duration, which the same cell fixes by construction.
|
|
498
|
+
|
|
499
|
+
**Forwarding is not adopted here.** `[측정 2026-09-22]` standard ACP has no steer method at all —
|
|
500
|
+
enumerating the installed `@agentclientprotocol/sdk`'s `AGENT_METHODS` and `CLIENT_METHODS` yields
|
|
501
|
+
zero entries containing `steer`. What exists is a PRIVATE vendor extension,
|
|
502
|
+
`_session/steering` in `@agentclientprotocol/claude-agent-acp@0.79.0`
|
|
503
|
+
(`dist/acp-agent.js:163`, handler at `:7792`), client→agent, advertised through
|
|
504
|
+
`InitializeResponse._meta.steering.supported`. Our client sends `initialize`, `session/new`,
|
|
505
|
+
`session/prompt`, `session/set_config_option` and `session/cancel` (`acp-client.ts:138-149`) and
|
|
506
|
+
has no steering path at all.
|
|
507
|
+
|
|
508
|
+
Adopting it would mean owning five things this driver does not own today, each a new contract
|
|
509
|
+
rather than a wiring change:
|
|
510
|
+
|
|
511
|
+
1. reading the `_meta.steering.supported` capability, and deciding what a backend without it is;
|
|
512
|
+
2. a typed schema and version fence for a request that is outside the SDK's method set, so a
|
|
513
|
+
private extension changing shape is a named refusal rather than a silent no-op;
|
|
514
|
+
3. a backend-specific concurrent-request seam from the control extension to a live ACP connection —
|
|
515
|
+
today a turn is one in-flight `prompt`, and this adds a second request during it;
|
|
516
|
+
4. an exactly-once / ordering policy that picks the outer pi queue or the vendor injection but never
|
|
517
|
+
both, since those are two independent orderings that currently do not meet;
|
|
518
|
+
5. a receipt mapping for the extension's outcomes — `injected`, `promptRequired`, `startedNewTurn` —
|
|
519
|
+
plus the idle race, cancel and release meanings behind them. (`idleBehavior:"promptRequired"`
|
|
520
|
+
lets a host refuse the detached `startedNewTurn` path, but that opt-in is itself a new contract.)
|
|
521
|
+
|
|
522
|
+
The adapter keeps its own turn-settlement bookkeeping internally, and our event mapper ignores
|
|
523
|
+
update kinds it does not handle (`event-mapper.ts:377-378`), so nothing above is a claim that
|
|
524
|
+
Entwurf must replicate vendor internals. The reason to stay put is the five contracts, not a ledger.
|
|
525
|
+
|
|
479
526
|
## Open work
|
|
480
527
|
|
|
481
528
|
- causal MCP-readiness diagnosis and, only with proof, a backend-invariant fence;
|
package/docs/setup-clean-host.md
CHANGED
|
@@ -22,7 +22,7 @@ evidence split, and its three states must not collapse into "macOS is supported"
|
|
|
22
22
|
| npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
|
|
23
23
|
| Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot/Codex wiring writers |
|
|
24
24
|
| entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
|
|
25
|
-
| pi | optional-by-presence, `>=0.
|
|
25
|
+
| pi | optional-by-presence, `>=0.87.1 <0.88` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
|
|
26
26
|
| Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
|
|
27
27
|
| GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
|
|
28
28
|
| OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
|
|
@@ -156,7 +156,7 @@ on Darwin; that is wiring, not a rail receipt. If `pi` is on PATH,
|
|
|
156
156
|
Install the exact release floor, then wire the project:
|
|
157
157
|
|
|
158
158
|
```bash
|
|
159
|
-
npm install -g @earendil-works/pi-coding-agent@0.
|
|
159
|
+
npm install -g @earendil-works/pi-coding-agent@0.87.1
|
|
160
160
|
pi --version
|
|
161
161
|
|
|
162
162
|
cd ~/entwurf-smoke
|
|
@@ -164,7 +164,7 @@ entwurf install .
|
|
|
164
164
|
pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
The supported range is `>=0.
|
|
167
|
+
The supported range is `>=0.87.1 <0.88`. It is a hard minimum: installing this
|
|
168
168
|
release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
|
|
169
169
|
minor. A host using only the external MCP bridge can skip pi until it needs a
|
|
170
170
|
control socket; no delivery rail launches a pi process.
|
|
@@ -307,7 +307,7 @@ as a note rather than a fault.
|
|
|
307
307
|
|
|
308
308
|
**entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
|
|
309
309
|
presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
|
|
310
|
-
gate, and no exact pin — unlike Node (`engines.node`), pi (`>=0.
|
|
310
|
+
gate, and no exact pin — unlike Node (`engines.node`), pi (`>=0.87.1 <0.88`) and Claude Code
|
|
311
311
|
(`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
|
|
312
312
|
decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
|
|
313
313
|
earned one because an older binary validates the exec manifest, drops `args` at runtime, and
|
|
@@ -354,7 +354,9 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
|
|
|
354
354
|
"alone does not say which rail that citizen answers on. Give target + intent; the decider picks transport " +
|
|
355
355
|
"from liveness (live socket citizen → control-socket send; deliverable self-fetch citizen → meta-bridge " +
|
|
356
356
|
"mailbox; probe-alive native-push citizen → direct injection into its conversation) and reports ONE " +
|
|
357
|
-
"outcome
|
|
357
|
+
"outcome, named per rail: a control receiver's acceptance boundary (`sent` / `queued-steer` / " +
|
|
358
|
+
"`queued-follow-up` / `accepted-unknown-boundary` — those four only), a mailbox ENQUEUE receipt, a " +
|
|
359
|
+
"native-push INJECTION, a rejection, or lock-dirty. EXISTING targets only; discover with " +
|
|
358
360
|
"entwurf_peers. A peer entwurf_peers shows as liveness=alive → fire-and-forget. A " +
|
|
359
361
|
"citizen with NO socket liveness (liveness=unsupported) is ALSO fire-and-forget — unsupported means only " +
|
|
360
362
|
'"no control-socket probe." The decider resolves the actual rail at dispatch time: a self-fetch ' +
|
|
@@ -389,9 +391,8 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
|
|
|
389
391
|
mode: z
|
|
390
392
|
.enum(["steer", "follow_up"])
|
|
391
393
|
.optional()
|
|
392
|
-
.describe("Injection style for a CONTROL-SOCKET send only: steer (
|
|
393
|
-
"
|
|
394
|
-
"effect on those rails."),
|
|
394
|
+
.describe("Injection style for a CONTROL-SOCKET send only: steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. " +
|
|
395
|
+
"The mailbox and native-push plans carry no mode, so it has no effect on those rails."),
|
|
395
396
|
wants_reply: z.boolean().optional().describe("Human-conversation reply hint (default false)"),
|
|
396
397
|
}, async ({ target, intent, message, mode, wants_reply }, extra) => {
|
|
397
398
|
try {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control-send-receipt — the ONE place the control-socket's acceptance boundary is named,
|
|
3
|
+
* on both ends of the wire (#120 P2).
|
|
4
|
+
*
|
|
5
|
+
* WHY A BOUNDARY AXIS AT ALL, AND WHY IT IS NOT THE ROUTE AXIS.
|
|
6
|
+
* `SendFinalOutcome` (`entwurf-v2-send.ts`) answers WHICH LEG delivered — first socket,
|
|
7
|
+
* re-resolved socket, mailbox — and it is what the release reducer keys on. It is an INTERNAL
|
|
8
|
+
* route/release algebra and it is NOT the sender-visible delivery boundary: `sent` and
|
|
9
|
+
* `fallback-sent` say a receiver accepted the bytes, and nothing whatever about what the receiver
|
|
10
|
+
* then did with them. Answering "what happened to my message" with a route word is how
|
|
11
|
+
* `control-socket → sent` came to mean four different things, one of which was
|
|
12
|
+
* "queued behind ten steers and dropped when the operator hit ESC".
|
|
13
|
+
*
|
|
14
|
+
* `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` the receiver's two queues are independent and each is
|
|
15
|
+
* `one-at-a-time` (`dist/agent.js:60-71,96-97,137-138`); steering is drained after every turn
|
|
16
|
+
* (`dist/agent-loop.js:85,186`) while follow-ups are drained ONLY when the inner loop has ended
|
|
17
|
+
* (`:191-197`). There is no global FIFO across them. So `steer` and `follow_up` are not two
|
|
18
|
+
* spellings of one queue — they are two different promises about when, and a receipt that cannot
|
|
19
|
+
* tell them apart cannot be used to reason about either.
|
|
20
|
+
*
|
|
21
|
+
* WHAT EACH BOUNDARY CLAIMS, EXACTLY:
|
|
22
|
+
* sent the receiver was idle and the message was accepted as a DIRECT
|
|
23
|
+
* turn trigger. It is an accepted REQUEST, not proof that the model
|
|
24
|
+
* saw it and not proof the turn completed.
|
|
25
|
+
* queued-steer accepted into the receiver's in-process steering queue. Volatile
|
|
26
|
+
* process memory: no durability, no ordering against the other
|
|
27
|
+
* queue, and an abort clears it.
|
|
28
|
+
* queued-follow-up accepted into the receiver's in-process follow-up queue. Same
|
|
29
|
+
* volatility, and it waits for the inner loop to end — a steer that
|
|
30
|
+
* arrives later still goes first.
|
|
31
|
+
* accepted-unknown-boundary the receiver ACCEPTED (its RPC answered success) but did not name
|
|
32
|
+
* a boundary this build understands. Version skew, not failure: the
|
|
33
|
+
* message is gone from our hands either way, so this must never be
|
|
34
|
+
* rendered as a failure to retry — and never as `sent`, which would
|
|
35
|
+
* put back the exact false claim this axis exists to remove.
|
|
36
|
+
*
|
|
37
|
+
* NO LEGACY MAPPER LIVES HERE. The previous wire carried `delivered:true` plus a `deliveredAs`
|
|
38
|
+
* producer nothing consumed; both are gone rather than translated. A new receiver answers an old
|
|
39
|
+
* sender fine (it keys on `response.success` and ignores `data`), and an old receiver answers a
|
|
40
|
+
* new sender as `accepted-unknown-boundary` — which is the honest name for it. A dual route would
|
|
41
|
+
* be a second address for one fact.
|
|
42
|
+
*/
|
|
43
|
+
/** The closed boundary set, in the order a reader should learn them. */
|
|
44
|
+
export const ACCEPTANCE_BOUNDARIES = ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"];
|
|
45
|
+
/**
|
|
46
|
+
* RECEIVER SIDE. The whole classification, from the two facts the receiver already has at the
|
|
47
|
+
* moment it accepts: was it idle, and which queue did the caller ask for. Pure — it reads no
|
|
48
|
+
* clock, no socket and no session, so the same inputs always name the same boundary.
|
|
49
|
+
*
|
|
50
|
+
* `idle` wins over `mode`: an idle receiver takes the message as a direct trigger and neither
|
|
51
|
+
* queue is involved, which is why `mode` is meaningless there (`entwurf-control.ts` passes no
|
|
52
|
+
* `deliverAs` on that branch).
|
|
53
|
+
*/
|
|
54
|
+
export function sendReceiptBoundary(input) {
|
|
55
|
+
if (input.idle)
|
|
56
|
+
return "sent";
|
|
57
|
+
return input.mode === "follow_up" ? "queued-follow-up" : "queued-steer";
|
|
58
|
+
}
|
|
59
|
+
/** Is this an acceptance a receiver could have observed about itself? */
|
|
60
|
+
export function isObservedBoundary(value) {
|
|
61
|
+
return value === "sent" || value === "queued-steer" || value === "queued-follow-up";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* SENDER SIDE. Read the receiver's typed boundary off an RPC `data` payload.
|
|
65
|
+
*
|
|
66
|
+
* Anything this build does not recognise — a missing field, an older receiver's shape, a value
|
|
67
|
+
* outside the set — is `accepted-unknown-boundary` and NOT an error: by the time `data` exists the
|
|
68
|
+
* RPC has already answered success, so the message has been accepted. Inventing a failure here
|
|
69
|
+
* would invite a retry that double-delivers, and answering `sent` would claim a direct injection
|
|
70
|
+
* nobody reported.
|
|
71
|
+
*/
|
|
72
|
+
export function parseAcceptanceBoundary(data) {
|
|
73
|
+
if (typeof data !== "object" || data === null)
|
|
74
|
+
return "accepted-unknown-boundary";
|
|
75
|
+
const value = data.boundary;
|
|
76
|
+
return isObservedBoundary(value) ? value : "accepted-unknown-boundary";
|
|
77
|
+
}
|
|
78
|
+
/** How much of an unrecognised answer may be repeated back. A receiver's payload is untrusted
|
|
79
|
+
* input from another process; a full object on a status line is an injection surface and an
|
|
80
|
+
* unbounded one at that. */
|
|
81
|
+
const UNKNOWN_DIAGNOSTIC_MAX = 80;
|
|
82
|
+
/**
|
|
83
|
+
* A BOUNDED, single-line description of an answer we could not classify — for the operator who has
|
|
84
|
+
* to decide whether this is version skew or a broken peer. Returns `undefined` when there is
|
|
85
|
+
* nothing worth saying, so a caller renders the bare boundary rather than an empty parenthesis.
|
|
86
|
+
*
|
|
87
|
+
* It describes the SHAPE and never trusts the content: the raw value is stringified, stripped of
|
|
88
|
+
* newlines and truncated. Never the whole object.
|
|
89
|
+
*/
|
|
90
|
+
export function describeUnknownBoundary(data) {
|
|
91
|
+
if (typeof data !== "object" || data === null)
|
|
92
|
+
return data === undefined ? undefined : `data: ${typeof data}`;
|
|
93
|
+
const value = data.boundary;
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
return "no boundary field";
|
|
96
|
+
const raw = typeof value === "string" ? value : typeof value;
|
|
97
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
98
|
+
if (flat === "")
|
|
99
|
+
return "empty boundary";
|
|
100
|
+
return flat.length > UNKNOWN_DIAGNOSTIC_MAX ? `${flat.slice(0, UNKNOWN_DIAGNOSTIC_MAX)}…` : flat;
|
|
101
|
+
}
|
|
@@ -228,8 +228,12 @@ export const ENTWURF_V2_TRANSPORTS = ["control-socket", "meta-mailbox", "native-
|
|
|
228
228
|
// and a future visible-resume verb would speak them again.
|
|
229
229
|
export const ENTWURF_V2_ACTIONS = ["send"];
|
|
230
230
|
export const ENTWURF_V2_OWNERSHIPS = ["ack-only"];
|
|
231
|
-
// Delivery mode of the message to the target
|
|
232
|
-
//
|
|
231
|
+
// Delivery mode of the message to the target: WHICH QUEUE a busy receiver is asked for.
|
|
232
|
+
// `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither is an interrupt — `dist/agent-loop.js:186`
|
|
233
|
+
// drains steering after each turn and `:191-197` drains follow-ups only once the inner loop has
|
|
234
|
+
// ended, and the two queues (`dist/agent.js:96-97,137-138`) have no FIFO between them, so a later
|
|
235
|
+
// steer overtakes every earlier follow_up. Both are volatile process memory and an abort drops
|
|
236
|
+
// them. A SEPARATE axis from
|
|
233
237
|
// both the intent/ownership axis (F1) and the liveness-routing axis. The removed
|
|
234
238
|
// v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
|
|
235
239
|
// inherited vocabulary, not a second live delivery verb.
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
|
|
29
29
|
* claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
|
|
30
30
|
*/
|
|
31
|
+
import { describeUnknownBoundary, parseAcceptanceBoundary } from "./control-send-receipt.js";
|
|
31
32
|
import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
|
|
32
33
|
import { mailboxConversationalDeliverable, resolveMailboxReceiverFacts, } from "./entwurf-deliverability.js";
|
|
33
34
|
import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
|
|
@@ -190,7 +191,22 @@ export function makeProductionEntwurfV2Deps(opts) {
|
|
|
190
191
|
wants_reply: plan.wantsReply,
|
|
191
192
|
sender: opts.senderProvider(),
|
|
192
193
|
});
|
|
193
|
-
|
|
194
|
+
// #120 P2 THE WIRE SEAM. `response.data` is another process's payload, so it is
|
|
195
|
+
// classified here by one total function and never carried raw any further: a shape we
|
|
196
|
+
// do not recognise becomes `accepted-unknown-boundary`, an ACCEPTANCE we could not
|
|
197
|
+
// name rather than a failure — the RPC already answered success, so the message is out
|
|
198
|
+
// of our hands and inventing a failure here would invite a double delivery. Only a
|
|
199
|
+
// SUCCESSFUL answer gets a boundary; a refusal carries its error instead.
|
|
200
|
+
const boundary = response.success ? parseAcceptanceBoundary(response.data) : undefined;
|
|
201
|
+
if (boundary === "accepted-unknown-boundary") {
|
|
202
|
+
// The operator has to be able to tell version skew from a broken peer, and the
|
|
203
|
+
// receipt itself may not carry another process's payload onto a status line.
|
|
204
|
+
// Hard Rule 15: diagnostics go to stderr, bounded and single-line.
|
|
205
|
+
const shape = describeUnknownBoundary(response.data);
|
|
206
|
+
process.stderr.write(`[entwurf_v2] ${plan.targetGardenId} accepted the message but named no boundary this build knows` +
|
|
207
|
+
`${shape === undefined ? "" : ` (${shape})`}\n`);
|
|
208
|
+
}
|
|
209
|
+
return { success: response.success, error: response.error, boundary };
|
|
194
210
|
},
|
|
195
211
|
classifyConnect: io.classifyConnect,
|
|
196
212
|
releaseLock: release,
|
|
@@ -55,6 +55,7 @@ export async function executeDispatch(decision, deps) {
|
|
|
55
55
|
outcome: r.outcome,
|
|
56
56
|
rejectReason: r.rejectReason,
|
|
57
57
|
messagePath: r.messagePath,
|
|
58
|
+
boundary: r.boundary,
|
|
58
59
|
},
|
|
59
60
|
};
|
|
60
61
|
}
|
|
@@ -67,6 +68,9 @@ export async function executeDispatch(decision, deps) {
|
|
|
67
68
|
transport,
|
|
68
69
|
error: errorMessage(err),
|
|
69
70
|
finalizedOutcome: err.finalizedOutcome,
|
|
71
|
+
finalizedBoundary: err.finalizedBoundary,
|
|
72
|
+
finalizedMessagePath: err.finalizedMessagePath,
|
|
73
|
+
finalizedRejectReason: err.finalizedRejectReason,
|
|
70
74
|
releaseFailed: true,
|
|
71
75
|
retrySafe: false,
|
|
72
76
|
};
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
* `releaseLock` throw never MASKS a send failure — the original error wins (5b).
|
|
33
33
|
*/
|
|
34
34
|
import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
|
|
35
|
+
/**
|
|
36
|
+
* A successful SOCKET answer always carries a boundary: the wire seam classifies every answer with
|
|
37
|
+
* one total function, and an answer it cannot place is `accepted-unknown-boundary` rather than
|
|
38
|
+
* nothing. So `undefined` here is not a host state, it is a bypassed seam — a dep wired straight
|
|
39
|
+
* past the classifier. Fail loud. A silent default would make the one shape that must never be
|
|
40
|
+
* mistaken for a direct trigger — delivered, boundary unknown to us — look exactly like one.
|
|
41
|
+
*/
|
|
42
|
+
function requireBoundary(result) {
|
|
43
|
+
if (result.boundary === undefined) {
|
|
44
|
+
throw new Error("entwurf-v2-send: a successful control-socket RPC reported no acceptance boundary — the wire seam that classifies one was bypassed (contract violation).");
|
|
45
|
+
}
|
|
46
|
+
return result.boundary;
|
|
47
|
+
}
|
|
35
48
|
/**
|
|
36
49
|
* Execute a control-socket send to a final outcome and release the held lock exactly
|
|
37
50
|
* once. `lock` MUST be the in-domain claim the decider handed over —
|
|
@@ -58,7 +71,12 @@ export async function executeControlSocketSend(plan, lock, deps) {
|
|
|
58
71
|
drive = { outcome: "failed", error: err };
|
|
59
72
|
}
|
|
60
73
|
finalizeRelease(policy, deps, held, drive);
|
|
61
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
outcome: drive.outcome,
|
|
76
|
+
rejectReason: drive.rejectReason,
|
|
77
|
+
messagePath: drive.messagePath,
|
|
78
|
+
boundary: drive.boundary,
|
|
79
|
+
};
|
|
62
80
|
}
|
|
63
81
|
/** Drive the 1차 send and route a connect failure through the F3 split. */
|
|
64
82
|
async function driveSend(plan, lock, deps) {
|
|
@@ -78,7 +96,7 @@ async function driveSend(plan, lock, deps) {
|
|
|
78
96
|
}
|
|
79
97
|
// A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
|
|
80
98
|
// its receiver error verbatim when supplied; do not make a reason up when absent.
|
|
81
|
-
return result.success ? { outcome: "sent" } : inBandRejected(result);
|
|
99
|
+
return result.success ? { outcome: "sent", boundary: requireBoundary(result) } : inBandRejected(result);
|
|
82
100
|
}
|
|
83
101
|
/**
|
|
84
102
|
* The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
|
|
@@ -116,7 +134,7 @@ async function driveDeadFallback(plan, lock, deps) {
|
|
|
116
134
|
// connect failure finalizes as failed (no further fallback).
|
|
117
135
|
try {
|
|
118
136
|
const r = await deps.sendOverSocket(rePlan);
|
|
119
|
-
return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
|
|
137
|
+
return r.success ? { outcome: "fallback-sent", boundary: requireBoundary(r) } : inBandRejected(r);
|
|
120
138
|
}
|
|
121
139
|
catch (err) {
|
|
122
140
|
return { outcome: "failed", error: err };
|
|
@@ -152,12 +170,35 @@ async function driveDeadFallback(plan, lock, deps) {
|
|
|
152
170
|
*/
|
|
153
171
|
export class SendDeliveredReleaseFailedError extends Error {
|
|
154
172
|
finalizedOutcome;
|
|
173
|
+
/** #120 P2: the ACCEPTANCE the send actually reached, carried through the failure so the
|
|
174
|
+
* dirty-lock line can say what happened instead of a bare route word. Absent on a `rejected`
|
|
175
|
+
* finalization (nothing was accepted) and on the mailbox fallback leg (which carries a file
|
|
176
|
+
* instead) — those two are exactly why this is optional rather than defaulted. */
|
|
177
|
+
finalizedBoundary;
|
|
178
|
+
/** #98 R receipt, same reason: a mailbox fallback that then failed to release still wrote a
|
|
179
|
+
* `.msg`, and the operator has to be told WHICH one before touching the lock. */
|
|
180
|
+
finalizedMessagePath;
|
|
181
|
+
/** The receiver's own refusal text on a `rejected` finalization. Without it the dirty-lock line
|
|
182
|
+
* is the ONE place a refusal loses its reason — and it is the place an operator reads while
|
|
183
|
+
* deciding by hand whether the message still needs sending. */
|
|
184
|
+
finalizedRejectReason;
|
|
155
185
|
releaseError;
|
|
156
|
-
constructor(finalizedOutcome, releaseError) {
|
|
186
|
+
constructor(finalizedOutcome, releaseError, accepted = {}) {
|
|
157
187
|
const detail = releaseError instanceof Error ? releaseError.message : String(releaseError);
|
|
158
|
-
|
|
188
|
+
// NOT "delivered". The word was wrong on two of the three shapes that reach here — a
|
|
189
|
+
// `rejected` finalization delivered nothing at all, and a `queued-*` one is sitting in
|
|
190
|
+
// volatile memory — and DELIVERY.md refuses a bare `delivered` for exactly that reason.
|
|
191
|
+
// What is true of all three is that the send REACHED A TERMINAL RESULT and must not be
|
|
192
|
+
// repeated.
|
|
193
|
+
const what = finalizedOutcome === "rejected"
|
|
194
|
+
? `rejected${accepted.rejectReason ? ` (${accepted.rejectReason})` : ""}`
|
|
195
|
+
: (accepted.boundary ?? "mailbox-enqueued");
|
|
196
|
+
super(`entwurf-v2-send: the send finalized as \`${what}\` and releaseLock then failed (lock dirty, do NOT re-send): ${detail}`);
|
|
159
197
|
this.name = "SendDeliveredReleaseFailedError";
|
|
160
198
|
this.finalizedOutcome = finalizedOutcome;
|
|
199
|
+
this.finalizedBoundary = accepted.boundary;
|
|
200
|
+
this.finalizedMessagePath = accepted.messagePath;
|
|
201
|
+
this.finalizedRejectReason = accepted.rejectReason;
|
|
161
202
|
this.releaseError = releaseError;
|
|
162
203
|
}
|
|
163
204
|
}
|
|
@@ -183,7 +224,13 @@ function finalizeRelease(policy, deps, lock, drive) {
|
|
|
183
224
|
throw original;
|
|
184
225
|
// N1: the delivery/refusal already happened — surface it as a structured,
|
|
185
226
|
// retry-unsafe error rather than a bare release throw.
|
|
186
|
-
|
|
227
|
+
// The acceptance truth rides along: without it the dirty-lock line is the ONE place
|
|
228
|
+
// left that still answers "what happened to my message" with a route word.
|
|
229
|
+
throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr, {
|
|
230
|
+
boundary: drive.boundary,
|
|
231
|
+
messagePath: drive.messagePath,
|
|
232
|
+
rejectReason: drive.rejectReason,
|
|
233
|
+
});
|
|
187
234
|
}
|
|
188
235
|
}
|
|
189
236
|
if (drive.outcome === "failed")
|
|
@@ -124,9 +124,34 @@ export function renderEntwurfV2Result(result) {
|
|
|
124
124
|
// socket-to-socket retry (no file) and on every non-mailbox outcome, so the
|
|
125
125
|
// line degrades to the bare outcome rather than printing "undefined".
|
|
126
126
|
const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
|
|
127
|
+
if (delivered) {
|
|
128
|
+
// #120 P2 — THE BOUNDARY IS THE RECEIPT, and the route is an annotation.
|
|
129
|
+
// `sent` / `fallback-sent` name which LEG carried the bytes. An operator
|
|
130
|
+
// cannot act on that: for 78 minutes this line said `sent` about a message
|
|
131
|
+
// lying in a queue. So the visible word is what the RECEIVER observed —
|
|
132
|
+
// `sent` only for an idle direct trigger, `queued-steer` /
|
|
133
|
+
// `queued-follow-up` for the two volatile in-process queues, and
|
|
134
|
+
// `accepted-unknown-boundary` when a peer answered success in a shape this
|
|
135
|
+
// build cannot classify. That last one is never spelled with the word
|
|
136
|
+
// "sent": it was accepted, and saying "sent (unknown)" would put back the
|
|
137
|
+
// exact false claim this axis exists to remove.
|
|
138
|
+
//
|
|
139
|
+
// A re-resolved MAILBOX leg has no turn-injection boundary at all — it
|
|
140
|
+
// wrote a durable file — so it says `mailbox-enqueued` rather than a bare
|
|
141
|
+
// route word. The route survives as a separate annotation either way,
|
|
142
|
+
// because "your first socket was dead" is a real thing to have been told.
|
|
143
|
+
const route = o.outcome === "fallback-sent" ? " (via a re-resolved route)" : "";
|
|
144
|
+
// A re-resolved leg with no boundary IS the mailbox leg — that rail reports a
|
|
145
|
+
// durable file instead. A PRIMARY leg with no boundary is structurally
|
|
146
|
+
// impossible (the send hand throws on it), so if one ever arrives here it is
|
|
147
|
+
// named as the acceptance we could not classify rather than dressed as a
|
|
148
|
+
// mailbox file that was never written.
|
|
149
|
+
const boundary = o.boundary ?? (o.outcome === "fallback-sent" ? "mailbox-enqueued" : "accepted-unknown-boundary");
|
|
150
|
+
return { text: `entwurf_v2 control-socket → ${boundary}${route}${enqueued}`, isError: false };
|
|
151
|
+
}
|
|
127
152
|
return {
|
|
128
153
|
text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
|
|
129
|
-
isError:
|
|
154
|
+
isError: true,
|
|
130
155
|
};
|
|
131
156
|
}
|
|
132
157
|
if (o.transport === "native-push") {
|
|
@@ -150,9 +175,19 @@ export function renderEntwurfV2Result(result) {
|
|
|
150
175
|
}
|
|
151
176
|
case "execution-failed": {
|
|
152
177
|
if (result.releaseFailed && result.finalizedOutcome) {
|
|
153
|
-
// N1: the
|
|
178
|
+
// N1: the send reached a terminal result and `releaseLock` then threw.
|
|
179
|
+
//
|
|
180
|
+
// #120 P2 — THIS LINE USED TO SAY `DELIVERED (<route>)` ABOUT ALL THREE SHAPES THAT
|
|
181
|
+
// REACH IT, and it was wrong about two of them: a `rejected` finalization delivered
|
|
182
|
+
// nothing at all, and a `queued-*` one is sitting in volatile receiver memory. So
|
|
183
|
+
// the same acceptance vocabulary the ordinary receipt uses is named here too — a
|
|
184
|
+
// dirty lock is a reason to be MORE precise about what happened, not less, because
|
|
185
|
+
// the operator is about to decide by hand whether anything needs re-sending.
|
|
186
|
+
const what = result.finalizedOutcome === "rejected"
|
|
187
|
+
? `rejected${result.finalizedRejectReason ? ` (${result.finalizedRejectReason})` : ""} — the receiver refused it; nothing was accepted`
|
|
188
|
+
: `${result.finalizedBoundary ?? "mailbox-enqueued"}${result.finalizedMessagePath ? ` (enqueued ${basename(result.finalizedMessagePath)})` : ""}`;
|
|
154
189
|
return {
|
|
155
|
-
text: `entwurf_v2 ${result.transport}
|
|
190
|
+
text: `entwurf_v2 ${result.transport} finalized as ${what}, and the lock is DIRTY ` +
|
|
156
191
|
`(release failed) — do NOT retry, a re-send would double-deliver. Clear the lock by hand.` +
|
|
157
192
|
`\n error: ${result.error}`,
|
|
158
193
|
isError: true,
|
|
@@ -43,13 +43,23 @@
|
|
|
43
43
|
import { createHash } from "node:crypto";
|
|
44
44
|
import { statSync } from "node:fs";
|
|
45
45
|
import path from "node:path";
|
|
46
|
+
import { HERDR_FRESH_CALL_BACKENDS } from "../../scripts/herdr-rails.mjs";
|
|
46
47
|
import { callbackEnvAssignments } from "./callback-env.js";
|
|
47
48
|
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallFraming, normalizeFreshCallInputs, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The pilot set, closed. `[#116 decision]` herdr's own `--kind` enum is much larger, and that is
|
|
49
51
|
* NOT evidence of support: every other backend is a pre-mutation named reject here, and there is
|
|
50
52
|
* no fallback to the tmux rail — a caller inside herdr who asks for codex gets a refusal, not a
|
|
51
|
-
* window somewhere else.
|
|
52
|
-
|
|
53
|
+
* window somewhere else.
|
|
54
|
+
*
|
|
55
|
+
* The VALUE now lives in `scripts/herdr-rails.mjs`, the one leaf the status pane and the install
|
|
56
|
+
* narration also read (#116 A7), so a fourth backend cannot appear in prose on one surface while
|
|
57
|
+
* this rail still refuses it. The re-export keeps this module's public name unchanged. The TYPE
|
|
58
|
+
* still has to be a tuple: a `.mjs` leaf infers `readonly string[]` without the JSDoc cast there,
|
|
59
|
+
* and `HerdrFreshCallBackend` would silently widen to `string` while every runtime check still
|
|
60
|
+
* passed. A gate pins that.
|
|
61
|
+
*/
|
|
62
|
+
export { HERDR_FRESH_CALL_BACKENDS };
|
|
53
63
|
/** Our backend name → the `--kind` token herdr accepts. `[측정 2026-09-14]` both round-tripped with
|
|
54
64
|
* the vendor executable echoed back in `argv`. This is a REQUESTED kind: `[file:line @ c77af189]`
|
|
55
65
|
* `src/app/agents.rs:197-199` turns it into a bare executable name and writes it into the pane's
|
|
@@ -79,6 +79,17 @@ const OFFICIAL_REPORTS = {
|
|
|
79
79
|
* only diff in that file's `.jsonl` lines is a lambda parameter rename on the READER
|
|
80
80
|
* side. A source read alone would not have settled it, so the real file is the receipt.
|
|
81
81
|
*
|
|
82
|
+
* `[측정 2026-09-22, pi 0.87.0]` re-measured, and the shape of the evidence CHANGED for the better.
|
|
83
|
+
* Source: the three lines that BUILD a session name are byte-unchanged from 0.86.0
|
|
84
|
+
* (`packages/coding-agent/src/core/session-manager.ts` `${fileTimestamp}_${sessionId}.jsonl` at
|
|
85
|
+
* :1080/:1666/:1842, moved from :991/:1527/:1703 by the +148/−9 `SessionManager`-canonical work
|
|
86
|
+
* 0.87.0 declares as breaking). File: `scripts/check-herdr-placement.ts` now makes the installed
|
|
87
|
+
* `SessionManager` write a real session file into a temp dir on EVERY run — `create()` plus one
|
|
88
|
+
* `appendMessage`, zero model, zero network, zero child process — and asserts this parser reads
|
|
89
|
+
* that basename back to the id the file's own header declares. So the vendor floor below is no
|
|
90
|
+
* longer a receipt somebody took once; it is re-taken by the gate. The 0.86.0 hand-measured file
|
|
91
|
+
* stays a 0.86.0 fact in the bump ledger rather than being re-dated.
|
|
92
|
+
*
|
|
82
93
|
* WHY THIS IS PINNED HERE AND NOT INFERRED. The uuid is the join key and it lives in
|
|
83
94
|
* a VENDOR filename, so this rule depends on pi's naming and would break silently if
|
|
84
95
|
* pi changed it. **Vendor floor: pi 0.86.0, measured 2026-09-20** — whether that layout
|
|
@@ -20,10 +20,3 @@
|
|
|
20
20
|
* AGENTS.md section from its own pi-context augment.
|
|
21
21
|
*/
|
|
22
22
|
export const ENTWURF_PROJECT_CONTEXT_OPEN_TAG = "<project-context";
|
|
23
|
-
/**
|
|
24
|
-
* Sender-side UI echo custom message type for successful entwurf_v2 send calls.
|
|
25
|
-
* The bridge emits it, the control renderer draws it, and the provider-level
|
|
26
|
-
* context filter removes it before LLM conversion. Keep as one SSOT: a typo
|
|
27
|
-
* here otherwise becomes a silent UI/context split.
|
|
28
|
-
*/
|
|
29
|
-
export const ENTWURF_SENT_MESSAGE_TYPE = "entwurf-sent";
|