Bridge seam: harness-agnostic FO-event egress (DRC-3798)#445
Conversation
…rn (DRC-3798)
The DRC-3799 audit of the Bridge seam found it is two flows in opposite
directions: ingress (captain intent -> FO, the bridge-inbox drain) was
already harness-agnostic on the portable mod-hook loop, while egress (FO
liveness/activity -> Bridge: events.jsonl, the session->entity marker, the
heartbeat session id) was Claude-Code-coupled with no adapter seam.
This routes the egress through Spacedock's existing per-host adapter pattern
(the same PRESENT/ABSENT idiom fo-dispatch-core.md uses), keeping the schema
Spacedock-owned and the producer per-host:
- docs/dev/bridge-egress-contract.md (new): the harness-neutral schema for all
four egress surfaces (events.jsonl, fo.$SLUG.json heartbeat, fo-feed.jsonl,
the session->entity marker) + the per-host producer bindings. Claude PRESENT;
Codex/Pi ABSENT/TODO with the exact open work named. Records the decision
that the deterministic RUNNING badge is Claude-only for now, with graceful
degradation on other hosts (heartbeat still attaches; git + fo-feed still
drive fleet-history; only the live FO-vs-ensign badge is withheld).
- A "## Bridge egress" binding section in each of the claude/codex/pi
first-officer runtime adapters; the claude ensign badge paragraph and
shared-core step 7b now point at it.
- The bridge-inbox heartbeat session id moves off the hardcoded
${CLAUDE_CODE_SESSION_ID:-} onto a neutral-first token with a built-in
per-host fallback: ${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}.
The launcher cannot export SD_SESSION_ID (the harness mints the session id
inside the session) and a per-tick FO export is fragile, so the fallback
keeps Claude/Codex populated with no regression while SD_SESSION_ID stays the
neutral override the contract documents.
- bridge_session_link_test.go reframed as TestClaudeAdapterConformsToEgressContract:
the harness-neutral contract is the unit under test (parse-based assertions on
the events.jsonl line shape + nesting + the session-marker shape), so a future
Codex/Pi producer reuses the same assertions with its own input builder.
Doc/contract + test only; no producer behavior change. contractlint, build,
go vet, and the reframed contract test pass. (Pre-existing, unrelated:
TestSurveyCodexPresenceThroughSync.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@infuseai.io>
…o the DRC-3798 egress branch Keeps #445 stacked on the refreshed base so its diff stays just the egress-abstraction changes. Clean merge; contractlint + the egress contract test pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jared Scott <jared.scott@infuseai.io>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Signed-off-by: Jared Scott <jared.scott@variable.team>
There was a problem hiding this comment.
Pull request overview
This PR extends the Bridge seam to be harness-agnostic on egress by routing FO/ensign lifecycle activity through a shared, host-neutral internal/bridgeegress emitter and binding host-specific wrappers (Claude hooks, Codex hooks, Pi extension) to a single hidden CLI surface (spacedock bridge egress emit --host <host>). It also tightens the Bridge ↔ FO conversation-loop contract (intent ids, frozen target_set, and best-effort _bridge/fo-replies.jsonl acknowledgements) with doc updates and contractlint tests.
Changes:
- Add a host-neutral egress normalizer/writer for
_bridge/events.jsonland first-write-wins_bridge/sessions/*markers, including Pi lifecycle-name normalization. - Add a hidden, silent CLI command (
bridge egress emit) and update Claude/Codex/Pi host bindings to call it. - Update Bridge inbox/egress contract docs and add tests locking the reply-loop semantics and host packaging expectations.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/integration/testdata/codex/bridge-egress-minimal-session-start.json | Adds a minimal Codex hook payload fixture for egress tests. |
| skills/integration/codex_bridge_egress_hook_test.go | Verifies Codex plugin manifest/hooks wiring and wrapper silence/argv contract. |
| skills/integration/bridge_session_link_test.go | Reworks Claude adapter conformance test to assert harness-neutral egress output shapes. |
| skills/first-officer/references/pi-first-officer-runtime.md | Documents Pi Bridge-egress support boundaries (events packaged; markers unclaimed). |
| skills/first-officer/references/first-officer-shared-core.md | Updates fleet-mode + startup guidance to reflect target_set and egress capability split. |
| skills/first-officer/references/codex-first-officer-runtime.md | Documents Codex Bridge-egress bindings and session-id fallback semantics. |
| skills/first-officer/references/claude-first-officer-runtime.md | Documents Claude Bridge-egress bindings (events + deterministic marker path). |
| skills/ensign/references/claude-ensign-runtime.md | Clarifies ensign-side running-badge behavior via Claude egress hooks/markers. |
| scripts/spacedock-bridge-events.sh | Simplifies Claude wrapper to delegate to shared CLI emitter. |
| scripts/codex-bridge-events.sh | Adds Codex wrapper delegating to the shared CLI emitter (silent, observe-only). |
| internal/contractlint/fo_feed_and_eager_drain_test.go | Adds contract locks for inbox id/target_set and _bridge/fo-replies.jsonl semantics. |
| internal/cli/pi.go | Tightens Pi package gating to require the Spacedock Pi extension in addition to skills. |
| internal/cli/pi_frontdoor_test.go | Updates Pi tests for new extension gate and dev-override behavior. |
| internal/cli/pi_egress_test.go | Adds tests for Pi extension wiring + package manifest advertising + runtime extension gate. |
| internal/cli/cli.go | Adds hidden spacedock bridge egress emit --host <host> command wiring to bridgeegress. |
| internal/cli/bridge_egress_test.go | Tests the hidden bridge egress CLI for silence, output, and malformed-payload no-op. |
| internal/bridgeegress/egress.go | Introduces host-neutral egress normalizer/writer (events + markers + truncation). |
| internal/bridgeegress/egress_test.go | Adds unit tests for event schema, Claude marker behavior, truncation, and Pi normalization. |
| hooks/codex-hooks.json | Adds Codex non-async command hooks invoking the Codex wrapper via PLUGIN_ROOT. |
| docs/dev/bridge-egress-contract.md | Adds/updates the harness-neutral egress contract (events, heartbeat, feed, replies, markers). |
| docs/dev/_mods/bridge-inbox.md | Updates inbox schema/routing (id, target_set) and defines _bridge/fo-replies.jsonl rules. |
| .pi/extensions/spacedock.ts | Extends Pi extension to forward lifecycle events into the shared CLI emitter. |
| .codex-plugin/plugin.json | Points Codex plugin manifest at the Codex-specific hooks file. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Signed-off-by: Jared Scott <jared.scott@variable.team>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Review follow-ups on the harness-agnostic FO-event work: - wake: reclaim a stale .wake-lock.codex left by a crashed/killed wake (O_EXCL alone permanently wedged durable delivery on any crash). - wake: validate session ids read from _bridge/ state before they become a codex argv positional (argument-injection hardening). - wake/egress: raise the bufio scan limit above 64KB so a large record cannot hard-fail the inbox scan or silently disable the event-log trim. - codex: drop the orphaned scripts/codex-bridge-events.sh wrapper and its test; codex-hooks.json inlines the emitter call and a test forbids ever wiring the wrapper in, so it was superseded dead code. - docs: fix the Claude events.jsonl schema (was missing timestamp/host/ actor_id), the "Last-write" mislabel on the first-write-wins marker, and the codex/pi marker path (<actor_id>.json, not <session_id>.json). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: PR #445Reviewed Full antagonistic pass (correctness, security data-flow, cross-reference, error-handling, tests, diff, docs-vs-code) over the egress/ingress/alert Go code, the host wiring (Claude/Codex/Pi hooks + scripts), and the contract/skill docs. Reviewed against base Issues (fixed in
|
gcko
left a comment
There was a problem hiding this comment.
Claude Code Review complete: 5 issues found and fixed in eb1fba3 (durable-wake stale-lock recovery, egress/inbox scan limit, session-id argv hardening, orphaned codex wrapper removed, egress-doc schema/label corrections). Residual notes (contract-pinned doc wording, Pi lifecycle fan-out, test gaps) are non-blocking — see the review comment. No failing Actions exist on this base.
Make the Bridge->FO ingress path robust on Claude, matching the Codex work already in this PR. Two changes address the reported pain that "the communication system goes through the AI" and that a parked Claude FO never sees Bridge's commands until manually poked. Packaged drain (host-neutral): new `spacedock bridge inbox drain|ack|commit|check` verbs move cursor math, per-line target routing, the liveness heartbeat, and reply/ack JSONL serialization out of FO prose and into the binary (internal/bridgeingress/drain.go, reusing the wake package's routing/replyKey helpers). The FO now calls deterministic commands and keeps only judgment (interpreting a tell, resolving a gate), so the split-shell cursor corruption and non-compact-JSONL contract breaks are eliminated. The heartbeat now carries `host` for Bridge wake routing. Claude durable wake (in-session, not external resume): a synchronous Stop hook (scripts/spacedock-bridge-inbox-check.sh -> `bridge inbox check`) blocks the stop with a drain instruction when intent is queued for this session's workflow, so a parked FO drains before stopping. External `claude -p --resume` of a live session is unsafe (Claude transcripts have no write locking), so there is deliberately no `ingress wake --host claude`. Also: `bridge-inbox` mod rewritten to call the verbs instead of hand-written shell; Claude FO runtime gains a `## Runtime implementation` block documenting the drain/ack/commit/wake bindings and the `status --next` (look-ahead) vs current-`status` state-machine semantics, so the FO no longer reads source to act. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed a follow-up commit extending this PR to make the Claude ingress path robust (companion Bridge PR: spacedock-dev/bridge#49). Packaged inbox drain (host-neutral) — new hidden verbs Claude durable wake (in-session) — a synchronous Docs —
|
Adversarial self-review — commit
|
- Check: hoist the per-slug inbox cursor read out of the per-record loop (was re-reading .inbox-cursor.<slug> once per inbox line per slug). - drain: preserve the exact on-disk `ts` string through drain→ack (via the previously-dead RawTS field) instead of reformatting to UTC, so wake's ts-based replyKey fallback for an id-less record cannot mismatch. - test: lock verbatim ts round-trip (non-UTC offset). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed both self-review findings in 0d96a40:
Full suite green except the pre-existing, unrelated |
Egress resolved its _bridge dir from the emitting session's cwd (filepath.Join(cwd, "_bridge")). Off-root sessions and ensign subagents therefore scattered stray _bridge/ dirs in workflow subdirs and worktrees, and their events never reached Bridge, which reads exactly one _bridge/ at the root the FO launched in. Resolve the nearest enclosing git root instead (first ancestor with a .git entry, file or dir) and stop there. A linked worktree is NOT resolved back to its main checkout: an FO and its Bridge commonly run from a worktree and read that worktree's own _bridge, not the main checkout's. Non-repo cwd falls back to the input, so this stays observe-only. Ingress (inbox drain/check) already anchors at the FO's operating root; this brings egress into line so events, session markers, and inbox share one dir. Signed-off-by: Jared Scott <jared.scott@variable.team>
Ack() now fails loudly when a reply carries neither an id nor a ts (no strong correlator), instead of appending an orphan the Bridge reader would silently drop. The common id-carrying path is unaffected. Drain() auto-appends an interim "acting" status ack for each freshly drained addressed record (matching id/line/ts/kind), so the command-ack lifecycle advances received->acting mechanically in the binary. The Ack write path is factored into a shared appendReply helper.
Net-new FO-to-Bridge initiation channel (contract items 1 and 4): - internal/bridgeinitiate: AppendInitiation writes _bridge/fo-initiate.jsonl, mirroring bridgealert. id/kind/headline required (loud errors, no random id fallback), request_id defaults to id for gate-review, anchors via filepath.Abs(root). truncateInitiate caps the file but never evicts the latest record of a still-open gate-review id. - internal/cli: bridge initiate branch + parseBridgeInitiate flag parser. - present-gate SKILL.md: host-neutral emit step + channel boundary (a gate goes to fo-initiate ONLY, never fo-feed/fo-replies). - bridge-egress-contract.md: document the fo-initiate stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jared Scott <jared.scott@variable.team>
Address review findings on the FO->Bridge initiate path: - skills/present-gate/SKILL.md: the documented `bridge initiate` gate-emit command passed only --request-id and claimed --id defaults from it. This is backwards: AppendInitiation hard-requires --id and defaults request_id from id. Following the doc verbatim returned "id is required" and emitted no gate. Fix the command to pass --id, and add --host/--session-id so gate cards carry host attribution. - internal/bridgeinitiate/initiate.go: truncateInitiate protected every gate-review whose on-disk status was "open" forever, but the writer ALWAYS writes "open" (resolution is overlaid by the reader from inbox decision intents), so no gate was ever evictable and the file grew unbounded in historical gate-reviews. Make truncation resolution-aware: a gate whose request_id has a captain decision intent in the sibling inbox.jsonl is resolved and evictable past the cap; undecided open gates stay protected. Best-effort inbox read falls back to protecting all open gates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jared Scott <jared.scott@variable.team>
What & why
Makes the Bridge ↔ Spacedock seam harness-agnostic and closes the full captain-intent conversation loop in both directions — captain→FO and FO→captain. Implements the DRC-3799 harness-agnosticism audit of the Bridge seam (PR #435) plus the follow-up contract, drain, wake, initiation, and alert work needed for a live Bridge conversation across Claude, Codex, and Pi.
The seam spans the shared
_bridge/directory in three directions:_bridge/inbox.jsonl; each FO drains it through the packagedspacedock bridge inbox drain|ack|commit|checkverbs (cursor math, routing, heartbeat, and ack serialization live in the binary, not FO prose), driven by a portable per-host hook. Parked sessions are woken durably per host — Claude via a synchronous Stop-hook that blocks the stop and drains in-session; Codex via an opt-inspacedock bridge ingress wake --host codexresume._bridge/events.jsonland first-write-wins session→entity markers under_bridge/sessions/._bridge/fo-replies.jsonl, FO-authored status/reco/gate-review cards in_bridge/fo-initiate.jsonl, and non-blocking permission interrupts in_bridge/fo-alerts.jsonl.The event schema stays Spacedock-owned and harness-neutral; concrete producers are bound per host. Claude, Codex, and Pi ship packaged event producers via the shared
spacedock bridge egress emit --host <host>command; deterministic session→entity marker parity remains Claude-proven only.> Stacked on #435 (
bridge-seam-inbox-events) — target files only exist on that branch; base retargets tomainwhen #435 merges. Review only the diff here. Companion Bridge implementation: spacedock-dev/bridge#48.What's in this PR
Harness-neutral egress (
internal/bridgeegress/) — normalizes host payloads into a stable_bridge/events.jsonlschema and first-write-wins_bridge/sessions/*.jsonmarkers; normalizes Pi-native lifecycle names to Bridge canonical events. Resolves_bridge/at the nearest enclosing git root (not raw cwd), so off-root sessions, ensign subagents, and worktrees write to the one dir Bridge actually reads instead of scattering stray dirs. Observe-only: malformed input or write failures degrade to no-op.Shared egress command + per-host producers — hidden
spacedock bridge egress emit --host <host>(internal/cli/cli.go). Claude's hook (scripts/spacedock-bridge-events.sh) becomes a thin wrapper; Codex gets its own non-async hooks (scripts/codex-bridge-events.sh,hooks/codex-hooks.json,.codex-plugin/plugin.json) that call the emitter viaSPACEDOCK_BIN/PATHwithout reusing Claude's async file, env vars, or plugin-cache wrapper; Pi forwards lifecycle events via.pi/extensions/spacedock.ts.Packaged inbox drain + durable wake (
internal/bridgeingress/) — deterministicspacedock bridge inbox drain|ack|commit|checkverbs move cursor math,target_setrouting, heartbeat, and ack serialization out of FO prose into the binary. Ack requires a validin_reply_tocorrelator (loud guard — a mismatched ack writes nothing) and drain auto-acts the deliveries it fulfills. Parked FOs are woken durably per host: Claude via a synchronous Stop-hook (scripts/spacedock-bridge-inbox-check.sh, registered inhooks/hooks.json) that blocks the stop and drains in-session; Codex viaspacedock bridge ingress wake --host codex, which resumes parked sessions for inbox records not yet delivered (by cursor or FO reply/ack). A wake is only an attempt; delivery is confirmed by the FO-owned drain + ack, so it stays safe against the gate-vs-inbox race.FO-initiated Bridge channel (
internal/bridgeinitiate/) —spacedock bridge initiate --kind status|reco|gate-reviewlets an FO push its own feed lines and decidable gates to_bridge/fo-initiate.jsonl, so a remote captain can decide a gate from the command-center UI. Append-only and best-effort; anchors_bridge/at the git root like egress. Gate-reviews fold on a stable--id= f(entity, stage) so re-emitting on each drain tick collapses to one card instead of stacking duplicates, and a still-open gate is retained past the truncation tail. Thepresent-gateskill (skills/present-gate/SKILL.md) now instructs every host's FO to emit the same gate it renders in-session to Bridge — host-neutral, living in the skill rather than a Claude-only hook — with an explicit channel boundary: a gate goes tofo-initiateONLY, neverfo-feed(ambient narration, no Approve/Reject affordance) orfo-replies(requires anin_reply_toparent).FO permission alerts (
internal/bridgealert/) —spacedock bridge alert permissionappends non-blocking captain interrupts to_bridge/fo-alerts.jsonl.Front-door
--plugin-dirsplit (internal/cli/frontdoor.go) — before-----plugin-dirdirs are kept separate from host passthrough: Claude receives them at launch, Codex installs them via a local marketplace symlink (Codex has no launch-time--plugin-dir).--no-install+--plugin-diris rejected for Codex.Pi extension gating (
internal/cli/pi.go) — Pi package/runtime checks now require the Spacedock Pi extension and the ensign skill, so a skills-only package is not treated as extension-capable.Contract + skill docs —
docs/dev/bridge-egress-contract.mdanddocs/dev/_mods/bridge-inbox.mdpin the harness-neutral producer contract,target_setrouting, per-workflow cursors,_bridge/fo-replies.jsonlreply/ack semantics,_bridge/fo-feed.jsonlambient git narration, and at-least-once (not exactly-once) delivery; acontractlinttest asserts the FO mod writes fo-feed narration on dispatch/advance/complete and eager-drains. First-officer runtime references (skills/*/references/*-first-officer-runtime.md) document per-host producer support vs. Claude-only marker parity.Validation
go build ./...,go vet ./...,go test ./...(all green except a pre-existing, environment-dependentTestSurveyCodexPresenceThroughSyncthat fails identically on the base branch and is unrelated to this PR).