From 88fe1e8cc301d780f0ba3dda325116be379ef43e Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Tue, 30 Jun 2026 18:56:32 +0800 Subject: [PATCH 01/21] Bridge seam: route FO-event egress through the per-host adapter pattern (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) Signed-off-by: Jared Scott --- docs/dev/_mods/bridge-inbox.md | 4 +- docs/dev/bridge-egress-contract.md | 81 +++++++ scripts/spacedock-bridge-events.sh | 5 + .../references/claude-ensign-runtime.md | 2 +- .../claude-first-officer-runtime.md | 7 + .../references/codex-first-officer-runtime.md | 7 + .../references/first-officer-shared-core.md | 2 +- .../references/pi-first-officer-runtime.md | 7 + .../integration/bridge_session_link_test.go | 211 ++++++++++++------ 9 files changed, 255 insertions(+), 71 deletions(-) create mode 100644 docs/dev/bridge-egress-contract.md diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 85f14f337..447a9b23d 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -52,9 +52,11 @@ Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it tre ``` mkdir -p _bridge printf '{"session_id":"%s","ts":"%s","state":"idle"}\n' \ - "${CLAUDE_CODE_SESSION_ID:-}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json + "${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json ``` +`SD_SESSION_ID` is the host-neutral session-id token owned by your runtime adapter's **Bridge egress** binding; the snippet reads it first and falls back to the host's own session var (`$CLAUDE_CODE_SESSION_ID` on Claude, `$CODEX_THREAD_ID` on Codex) so the heartbeat never silently blanks, with no per-tick `export` needed. An empty value (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. + `state` is `idle`: this mod runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. Writing the heartbeat is observe-only; never let it block the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) ### Drain diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md new file mode 100644 index 000000000..b13b61cbd --- /dev/null +++ b/docs/dev/bridge-egress-contract.md @@ -0,0 +1,81 @@ +# Bridge egress contract (FO liveness → Bridge) + +The Bridge seam is two flows over the shared `_bridge/` dir (resolved from the repo root where the FO launched). They run in opposite directions and are documented apart: + +- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. +- **Egress** — FO liveness/activity → Bridge — is *this* contract: the four `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. + +**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat and feed are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. Today only the Claude binding is real; Codex and Pi are ABSENT/TODO (no established event producer on those hosts). + +All four files are gitignored session runtime (`.gitignore` carries `_bridge/`), append-only or last-write where noted, and strictly observe-only: a telemetry side-channel must never block, fail, or alter the FO. + +## Session-id binding `«session-id»` + +Two of the surfaces stamp the FO/ensign session id. The neutral shell token is **`SD_SESSION_ID`**, owned by the host adapter's `## Bridge egress` binding. The bridge-inbox heartbeat reads it neutral-first with a built-in per-host fallback, so it never silently blanks and needs no per-tick `export`: + +``` +"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}" +``` + +- → **Claude:** resolves to `$CLAUDE_CODE_SESSION_ID` (set by Claude Code in the session). · **Codex:** resolves to `$CODEX_THREAD_ID` when the host exposes it to the FO shell. · **Pi:** ABSENT → empty. + +`SD_SESSION_ID` is honored by the **heartbeat** producer only. The Claude event producer stamps `events.jsonl` and the session marker from its hook payload's `.session_id`, *not* from `SD_SESSION_ID` — so on Claude leave `SD_SESSION_ID` unset (the fallback already yields `$CLAUDE_CODE_SESSION_ID`, the same id the payload carries). Setting `SD_SESSION_ID` to a *different* value would desync the heartbeat from the event stream and break the join the contract exists to enable; an explicit override is only safe when it equals the host's own session id. + +An empty value is honest, not a bug: the heartbeat is still a valid liveness tick (Bridge reads freshness from `ts`); only the join between this heartbeat and the event stream is unavailable. + +## `_bridge/events.jsonl` — FO/ensign activity stream + +One JSON object per line, appended on each lifecycle event. Liveness, not content: no tool inputs/outputs, no prompt text. + +``` +{"ts":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","detail":{"tool":"","source":""}} +``` + +- `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge tells FO-vs-ensign activity apart. +- Best-effort size cap: the producer trims to the most recent lines past a bound (it is liveness, not a ledger; a few lost lines to a concurrent trim are acceptable). +- → **Claude:** PRESENT — the plugin hook `scripts/spacedock-bridge-events.sh`, registered for the six events above in `hooks/hooks.json` (all async). It normalizes the Claude Code hook payload to the line above so Bridge never couples to Claude's internal transcript format. · **Codex:** ABSENT/TODO — no established Codex app-server event producer. · **Pi:** ABSENT/TODO — no established Pi event API producer. + +## `_bridge/fo.$SLUG.json` — per-workflow liveness heartbeat + +Last-write (one object, not a stream), one file per workflow slug. Bridge treats the workflow as live only when `ts` is fresh (within 30 minutes) and not in the future. + +``` +{"session_id":"<«session-id»>","ts":"","state":"idle"} +``` + +- `state` is `idle`: the producer runs at startup/idle boundaries (between dispatches), so it cannot honestly claim `working` — the finer working/idle signal lives in `events.jsonl`. +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod writes this each startup/idle tick (it rides the portable hook loop). The only per-host part is `«session-id»` — see the binding above. The heartbeat still attaches the FO on every host. + +## `_bridge/fo-feed.jsonl` — fleet-history narration + +One JSON object per line, appended when the FO dispatches, advances, or completes an entity. Drives Bridge's fleet-history rail for a local-only workflow (entities gitignored, so the `dispatch:`/`advance:` git narration is empty). + +``` +{"ts":"","verb":"","entity":"","workflow":"<$SLUG>","stage":"","text":""} +``` + +- `verb`: `dispatch` (sent an ensign to a stage), `advance` (moved an entity to its next stage), `complete` (entity reached terminal). +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod's feed step appends it. No per-host binding — it is FO-loop narration, not a host event. + +## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) + +Last-write (first-write-wins per session), one file per live working session. Maps a session id to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. + +``` +{"session_id":"<«session-id»>","entity":"","workflow":""} +``` + +- The `entity`/`workflow` pair is derived from the ensign's first Read of its entity file under `docs/spacedock//.md` (flat or `/index.md`); the path carries both the workflow (so Bridge's join is collision-free across workflows reusing a ticket id) and the slug. First-write-wins records the ensign's own entity, read before any duplicate-check sibling read. Archived (`_archive/`) entities are never marked. +- → **Claude:** PRESENT — derived in `scripts/spacedock-bridge-events.sh` from the `PostToolUse` hook (which fires on every tool call). · **Codex:** ABSENT/TODO. · **Pi:** ABSENT/TODO. + +## Decision: deterministic RUNNING badge is Claude-only (for now) + +The session→entity marker is the **only** deterministic source for the live FO-vs-ensign RUNNING badge, and it is **Claude-only for now**. It relies on a hook that fires on *every* tool call (Claude Code `PostToolUse`) to record the marker on the ensign's first entity Read. The prose-driven alternative — having the ensign run a first-action shell to write its own marker — is unreliable: the ensign skips that step roughly three times out of four. Deriving it in the hook is what makes the badge dependable, and that hook is a Claude plugin mechanism with no Codex/Pi equivalent yet. + +**On non-Claude hosts Bridge degrades gracefully, it does not break:** + +- The heartbeat (`fo.$SLUG.json`) is host-neutral, so the FO still shows **attached**. +- Git `dispatch:`/`advance:` narration and `fo-feed.jsonl` (both host-neutral) still drive the **fleet-history** rail. +- Only the live per-ship **RUNNING badge** (FO-vs-ensign, this very moment) is absent — Bridge simply does not render it, rather than showing a wrong one. + +When a Codex app-server event surface or a Pi event API is established, bind `events.jsonl` and the session marker in that host's adapter (`## Bridge egress`) against this same schema; the badge lights up with no Bridge change. diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index e3bf83b23..bad497c9b 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -6,6 +6,11 @@ # in hooks/hooks.json (all async). It writes a stable, Spacedock-owned event contract so # Bridge does not have to couple to Claude Code's internal transcript JSONL format. # +# This script is the CLAUDE producer-binding for the egress surface; the harness-neutral +# schema for events.jsonl + the _bridge/sessions/.json marker (and where Codex/Pi are +# ABSENT/TODO) is docs/dev/bridge-egress-contract.md. Keep the emitted line shapes in sync +# with that contract. +# # Events land in «session-cwd»/_bridge/events.jsonl — the same _bridge/ dir the bridge-inbox # mod drains and that Bridge resolves from the repo root. agent_id/agent_type are empty for # the main FO session and set for ensign subagents, so Bridge can tell FO vs ensign activity. diff --git a/skills/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 0c08c1f8e..36f73fefe 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,7 +6,7 @@ How the shared ensign core executes on Claude Code. The ensign is dispatched by the first officer via the Agent tool. The dispatch prompt is authoritative for all assignment fields: entity, stage, stage definition, workflow location, and checklist. -Bridge's "running" badge needs no action from you: the Spacedock event hook records your session→entity link **deterministically** when you Read your entity file (it derives the entity + workflow from the path), so Bridge can show the ship you are driving as running. Just do your work — reading the entity file is part of it. +Bridge's "running" badge needs no action from you: the Spacedock plugin hooks (`hooks/hooks.json` → `scripts/spacedock-bridge-events.sh`) record your session→entity link **deterministically** on your first Read of your entity file (the hook derives the entity + workflow from the path and writes `_bridge/sessions/.json`), so Bridge shows the ship you are driving as running. Just do your work — reading the entity file is part of it. (This egress producer is bound on Claude only; the FO adapter's Bridge-egress binding states the per-host coverage.) ## Clarification diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index fdbbf7f5f..8fcc1c55d 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -33,3 +33,10 @@ See `## Probe and Ideation Discipline` in the shared core — its Grep-over-Read ## Filing New Entities To file a seed task, do NOT use the Write tool to hand-assemble frontmatter after a `status --next-id` preview — that two-step flow can land a stale id when the `--next-id` candidate drifts between preview and write. Use `spacedock new [--folder] [--id-seed S --id-actor A]` via Bash from the project root (`new` auto-discovers the lone workflow, else pass `--workflow-dir {workflow_dir}` — see `spacedock new --help`), piping a complete entity stub on stdin (frontmatter with `id` omitted or blank, followed by the brief description body): it mints the id, stamps it into the frontmatter, and atomically writes the stamped entity as flat `.md` in one call (see `## FO Write Scope` in the shared core for the full contract). `--next-id` is a candidate-preview surface only. `new` writes but does not commit; for split-root state checkouts the FO still does the path-scoped commit + push after `new` (per the shared core's State Management rule). + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod and needs no adapter binding.) + +- **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The script normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi: ABSENT — see those adapters.) +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude, so `fo.$SLUG.json` carries the same id the event stream and session markers use and Bridge can join liveness to activity. No per-tick `export` is needed; leave `SD_SESSION_ID` unset on Claude (the event producer stamps `events.jsonl`/markers from the hook payload's session id, so an override that differs would desync the heartbeat from the event stream). diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index dccf65bcd..0504f7364 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -50,3 +50,10 @@ Feedback rejection is the load-bearing exception to casual fresh dispatch. When ## Captain Interaction The captain is the user of the Codex session. Communicate gate results, clarifications, and status directly in the conversation. + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex this producer is not yet established. + +- **FO event emission** — ABSENT/TODO on Codex: there is no established producer (the Codex app-server lifecycle event API for emitting the events.jsonl contract line is unconfirmed). Consequence: Bridge gets no `events.jsonl` and no `_bridge/sessions/` markers on a Codex host, so it shows neither live tool activity nor FO-vs-ensign attribution; liveness falls back to the bridge-inbox heartbeat (`_bridge/fo.$SLUG.json`) the FO writes each tick. Binding a Codex producer that emits the contract line is the open work. +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 028e7aff5..75b31b9d6 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -27,7 +27,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg - **STATE_BACKEND** — `split-root` or `single-root`, the resolved entity dir, and whether it is present. 6. `«state.ensure-ready»()` — converge the split-root checkout to linked-and-integrated before any dispatch (the halt-gate + the pull-on-boot). A single-root workflow is a no-op. 7. `«state.sweep-merged»()` — advance every merged-PR entity to terminal at boot, before the greet. The common boot (no merged PR) reads zero mod files. -7b. **Bridge liveness heartbeat (before-greet).** When the MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its `## Hook: startup` BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat (present-time UTC) and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots, not only after the first dispatch. This is boot liveness, NOT event-loop work: a greet-and-stop boot (interactive step 8, which never enters the event loop) STILL runs it, unlike the deferred comm-officer spawn. The heartbeat write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` makes the drain a no-op. In fleet mode this runs once per member, keyed by each member's `$SLUG`/`{workflow_dir}` (see `## Fleet Mode`). Skip when no `startup` bridge-inbox hook is registered — the common boot reads zero mod files. +7b. **Bridge liveness heartbeat (before-greet).** When the MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its `## Hook: startup` BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat (present-time UTC) and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots, not only after the first dispatch. This is boot liveness, NOT event-loop work: a greet-and-stop boot (interactive step 8, which never enters the event loop) STILL runs it, unlike the deferred comm-officer spawn. The heartbeat write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` makes the drain a no-op. In fleet mode this runs once per member, keyed by each member's `$SLUG`/`{workflow_dir}` (see `## Fleet Mode`). Skip when no `startup` bridge-inbox hook is registered — the common boot reads zero mod files. The heartbeat's `session_id` — and the richer `_bridge/events.jsonl` stream / session→entity markers it joins to — is a per-host **Bridge egress** capability defined in the active runtime adapter: PRESENT on Claude (the plugin hooks emit the event stream; the heartbeat resolves `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude), ABSENT on Codex/Pi (liveness rests on this heartbeat alone). The heartbeat falls back across hosts automatically, so no manual step is needed (full surface: `docs/dev/bridge-egress-contract.md`). 8. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary (boot JSON + README frontmatter) as today, including `gh`-absent UNKNOWN PR status. - **Interactive:** present the summary (and any ready `gate: true` gate as captain-facing text), then STOP for input; do NOT auto-dispatch. The expensive deferrals stay past the greet, reached on the captain's first direction. - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index 7f4b3dfd1..797626043 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -20,3 +20,10 @@ The build artifact carries the entity slug/name, entity path, workflow directory Live Pi tests should run with an isolated Pi config directory and an isolated session directory. The harness may copy the operator's existing Pi auth file into the isolated config directory so OAuth/subscription credentials are reused without sharing global sessions, packages, or settings. The durable proof for Pi support is not transcript phrasing. A valid live proof dispatches a Pi ensign against a temp split-root workflow and verifies process exit, state checkout file changes, git log, and stage report content. + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi this producer is not yet established. + +- **FO event emission** — ABSENT/TODO on Pi: there is no established producer (a Pi event API for emitting the events.jsonl contract line is unconfirmed). Consequence: Bridge gets no `events.jsonl` and no `_bridge/sessions/` markers on a Pi host, so it shows neither live tool activity nor FO-vs-ensign attribution; liveness falls back to the bridge-inbox heartbeat (`_bridge/fo.$SLUG.json`) the FO writes each tick. Binding a Pi producer that emits the contract line is the open work. +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`; on Pi this stays empty (no stable per-session id is exposed), so the heartbeat carries an empty session id — still a valid liveness tick (Bridge reads freshness from `ts`). Bind it once Pi exposes a stable per-session id. diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index c3529f0e1..c97b479a0 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -1,10 +1,11 @@ -// ABOUTME: Bridge session-marker smoke — the event hook deterministically records -// ABOUTME: a dispatched ensign's session→entity(+workflow) link from its entity-file -// ABOUTME: Read, so Bridge's "running" badge no longer depends on the ensign running -// ABOUTME: a first-action shell (which it skipped ~3/4 of the time). DRC running-badge. +// ABOUTME: Bridge egress-contract conformance — the Claude adapter (hooks.json + +// ABOUTME: spacedock-bridge-events.sh) must turn its host-shaped hook payload into the +// ABOUTME: harness-neutral egress contract: a canonical events.jsonl liveness line plus a +// ABOUTME: deterministic session→entity(+workflow) marker. DRC harness-agnostic FO events. package integration import ( + "encoding/json" "os" "os/exec" "path/filepath" @@ -12,108 +13,182 @@ import ( "testing" ) -// runEventHook feeds one hook payload (the JSON Claude Code pipes to the hook) to -// scripts/spacedock-bridge-events.sh and returns once it exits. cwd anchors the -// _bridge/ dir the hook writes to. -func runEventHook(t *testing.T, payload string) { +// runClaudeAdapter feeds ONE Claude-Code-shaped hook payload — the Claude adapter's input — +// to scripts/spacedock-bridge-events.sh (the Claude binding of the egress producer) and +// returns once it exits. cwd anchors the _bridge/ dir the adapter writes to. +func runClaudeAdapter(t *testing.T, payload string) { t.Helper() hook := filepath.Join("..", "..", "scripts", "spacedock-bridge-events.sh") if _, err := os.Stat(hook); err != nil { - t.Fatalf("hook script not found at %s: %v", hook, err) + t.Fatalf("Claude adapter script not found at %s: %v", hook, err) } cmd := exec.Command("bash", hook) cmd.Stdin = strings.NewReader(payload) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("event hook failed: %v\n%s", err, out) + t.Fatalf("Claude adapter failed: %v\n%s", err, out) } } -func readPayload(cwd, sid, agentType, filePath string) string { +// claudeAdapterRead builds the Claude-Code-shaped PostToolUse/Read payload that Claude Code +// pipes to the hook. This CC-specific JSON is the Claude adapter's INPUT only — it is NOT the +// contract. The contract is what the adapter emits (asserted below); a future Codex or Pi +// producer would consume its own host payload but must emit the same two output shapes. +func claudeAdapterRead(cwd, sid, agentType, filePath string) string { return `{"cwd":"` + cwd + `","session_id":"` + sid + `","agent_type":"` + agentType + `","hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"` + filePath + `"}}` } -// TestEventHookWritesSessionMarker locks the deterministic producer: an ensign's -// Read of its entity file records {session_id, entity, workflow} — carrying the -// workflow so Bridge's join is collision-free — and it is first-write-wins (a later -// sibling Read does not overwrite), while a non-ensign (FO) Read writes nothing. -func TestEventHookWritesSessionMarker(t *testing.T) { - root := t.TempDir() - ent := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "drc-3467.md") +// egressLine is the Spacedock-owned, harness-neutral events.jsonl contract line every host +// adapter must produce. Pointer fields distinguish "key absent" (nil) from "present but empty", +// and the nested detail struct enforces detail.{tool,source} nesting — a producer that flattens +// tool/source to the top level fails to populate detail and is rejected. +type egressLine struct { + TS string `json:"ts"` + Event string `json:"event"` + SessionID string `json:"session_id"` + AgentID *string `json:"agent_id"` + AgentType *string `json:"agent_type"` + Detail *struct { + Tool *string `json:"tool"` + Source *string `json:"source"` + } `json:"detail"` +} + +// assertEgressContractLine parses the LAST line of «root»/_bridge/events.jsonl and asserts it +// conforms to the harness-neutral egress contract — valid JSON, all canonical keys present with +// detail.{tool,source} genuinely NESTED, and the load-bearing values mapped correctly (event, +// session_id, agent_type — the field Bridge uses to tell FO from ensign — and a non-empty ts, +// which Bridge reads for freshness). Parsing (not substring matching) is what makes this a real +// cross-host guardrail: a future Codex/Pi producer emitting the same shape passes; one that +// drops a key, mis-nests detail, or mis-maps a value fails. +func assertEgressContractLine(t *testing.T, root, wantEvent, wantSession, wantAgentType, wantTool string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + t.Fatalf("egress contract line not written: %v", err) + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + raw := lines[len(lines)-1] + var line egressLine + if err := json.Unmarshal([]byte(raw), &line); err != nil { + t.Fatalf("egress line is not valid JSON: %v\n%s", err, raw) + } + if line.AgentID == nil || line.AgentType == nil { + t.Fatalf("egress line missing agent_id/agent_type keys\ngot: %s", raw) + } + if line.Detail == nil || line.Detail.Tool == nil || line.Detail.Source == nil { + t.Fatalf("egress line must nest detail.{tool,source} (not flatten them to the top level)\ngot: %s", raw) + } + if line.TS == "" { + t.Errorf("egress line has empty ts (Bridge reads freshness from ts)\ngot: %s", raw) + } + if line.Event != wantEvent { + t.Errorf("event = %q, want %q\ngot: %s", line.Event, wantEvent, raw) + } + if line.SessionID != wantSession { + t.Errorf("session_id = %q, want %q\ngot: %s", line.SessionID, wantSession, raw) + } + if *line.AgentType != wantAgentType { + t.Errorf("agent_type = %q, want %q (FO-vs-ensign attribution)\ngot: %s", *line.AgentType, wantAgentType, raw) + } + if *line.Detail.Tool != wantTool { + t.Errorf("detail.tool = %q, want %q\ngot: %s", *line.Detail.Tool, wantTool, raw) + } +} - // 1. Ensign reads its own entity file → marker written with entity + workflow. - runEventHook(t, readPayload(root, "ses-1", "spacedock:ensign", ent)) - marker := filepath.Join(root, "_bridge", "sessions", "ses-1.json") - data, err := os.ReadFile(marker) +// assertSessionMarker parses «root»/_bridge/sessions/.json and asserts the marker contract +// {session_id,entity,workflow}. Parse-based (not substring) so a marker that records the state +// dir as the workflow, or mis-maps a field, fails. Reusable by a future Codex/Pi producer test. +func assertSessionMarker(t *testing.T, root, sid, wantEntity, wantWorkflow string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", sid+".json")) if err != nil { - t.Fatalf("marker not written: %v", err) + t.Fatalf("marker for %s not written: %v", sid, err) + } + var m struct { + SessionID string `json:"session_id"` + Entity string `json:"entity"` + Workflow string `json:"workflow"` } - got := string(data) - for _, want := range []string{`"session_id":"ses-1"`, `"entity":"drc-3467"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(got, want) { - t.Errorf("marker missing %s\ngot: %s", want, got) - } + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("marker for %s is not valid JSON: %v\n%s", sid, err, data) } + if m.SessionID != sid { + t.Errorf("marker session_id = %q, want %q", m.SessionID, sid) + } + if m.Entity != wantEntity { + t.Errorf("marker entity = %q, want %q", m.Entity, wantEntity) + } + if m.Workflow != wantWorkflow { + t.Errorf("marker workflow = %q, want %q", m.Workflow, wantWorkflow) + } +} + +// markerExists reports whether a session marker file was written for sid. +func markerExists(root, sid string) bool { + _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", sid+".json")) + return err == nil +} + +// TestClaudeAdapterConformsToEgressContract is the Claude adapter's conformance test against +// the harness-neutral egress contract. The unit under test is the CONTRACT, not the raw Claude +// Code payload: each step feeds the Claude adapter its host-shaped input and asserts the +// adapter's OUTPUT — (a) a canonical events.jsonl liveness line and (b) the session→entity +// marker {session_id,entity,workflow} — matches the Spacedock-owned shape. A future Codex or Pi +// producer is the same test with a different input builder reusing assertEgressContractLine / +// assertSessionMarker, and must satisfy these same OUTPUT assertions; that is what +// "harness-agnostic egress" means. +func TestClaudeAdapterConformsToEgressContract(t *testing.T) { + root := t.TempDir() + ent := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "drc-3467.md") + + // 1. Ensign reads its own entity file. Assert BOTH contract outputs: + // (a) the canonical egress liveness line, and (b) the session→entity marker. + runClaudeAdapter(t, claudeAdapterRead(root, "ses-1", "spacedock:ensign", ent)) + assertEgressContractLine(t, root, "PostToolUse", "ses-1", "spacedock:ensign", "Read") + assertSessionMarker(t, root, "ses-1", "drc-3467", "linear-drc-review") // 2. First-write-wins: a later sibling Read in the same session must NOT overwrite. sibling := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "drc-9999.md") - runEventHook(t, readPayload(root, "ses-1", "spacedock:ensign", sibling)) - data2, _ := os.ReadFile(marker) - if !strings.Contains(string(data2), `"entity":"drc-3467"`) { - t.Errorf("sibling Read overwrote the marker (lost first-write-wins):\n%s", data2) - } + runClaudeAdapter(t, claudeAdapterRead(root, "ses-1", "spacedock:ensign", sibling)) + assertSessionMarker(t, root, "ses-1", "drc-3467", "linear-drc-review") - // 3. A non-ensign (FO) Read of an entity file writes no marker. - runEventHook(t, readPayload(root, "fo-sess", "spacedock:first-officer", ent)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "fo-sess.json")); err == nil { + // 3. A non-ensign (FO) Read of an entity file writes no marker. The egress liveness line + // is still emitted (the FO is live) and carries the FO's agent_type, but no + // session→entity link is recorded. + runClaudeAdapter(t, claudeAdapterRead(root, "fo-sess", "spacedock:first-officer", ent)) + assertEgressContractLine(t, root, "PostToolUse", "fo-sess", "spacedock:first-officer", "Read") + if markerExists(root, "fo-sess") { t.Errorf("FO Read should not produce a session marker") } - // 3b. RELATIVE entity path — the FO passes a repo-relative {entity_file_path}, so - // the ensign's scoped Read carries "docs/spacedock//.md" (no leading - // slash). The hook must still record it (regression: the absolute-only pattern - // missed every live ensign). - runEventHook(t, readPayload(root, "ses-rel", "spacedock:ensign", + // 3b. RELATIVE entity path — the FO passes a repo-relative {entity_file_path}, so the + // ensign's scoped Read carries "docs/spacedock//.md" (no leading slash). The + // adapter must still record it (regression: the absolute-only pattern missed every live + // ensign). + runClaudeAdapter(t, claudeAdapterRead(root, "ses-rel", "spacedock:ensign", "docs/spacedock/linear-drc-review/drc-7000.md")) - relData, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", "ses-rel.json")) - if err != nil { - t.Fatalf("relative-path Read produced no marker (the live-ensign regression): %v", err) - } - for _, want := range []string{`"entity":"drc-7000"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(string(relData), want) { - t.Errorf("relative-path marker missing %s\ngot: %s", want, relData) - } - } + assertSessionMarker(t, root, "ses-rel", "drc-7000", "linear-drc-review") // 4. _archive entity Reads are skipped. arch := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "_archive", "drc-1.md") - runEventHook(t, readPayload(root, "ses-arch", "spacedock:ensign", arch)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-arch.json")); err == nil { + runClaudeAdapter(t, claudeAdapterRead(root, "ses-arch", "spacedock:ensign", arch)) + if markerExists(root, "ses-arch") { t.Errorf("_archive Read should not produce a session marker") } // 5. SPLIT-ROOT entity path: the entity now lives at /.spacedock-state/.md, // so the workflow must be derived from the segment after docs/spacedock/ — NOT the - // entity's parent dir (which would wrongly be ".spacedock-state"). + // entity's parent dir (which would wrongly be ".spacedock-state"). assertSessionMarker's + // exact workflow match ("linear-drc-review") rejects the ".spacedock-state" mis-derivation. sr := filepath.Join(root, "docs", "spacedock", "linear-drc-review", ".spacedock-state", "drc-8000.md") - runEventHook(t, readPayload(root, "ses-sr", "spacedock:ensign", sr)) - srData, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", "ses-sr.json")) - if err != nil { - t.Fatalf("split-root Read produced no marker: %v", err) - } - for _, want := range []string{`"entity":"drc-8000"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(string(srData), want) { - t.Errorf("split-root marker missing %s (workflow must be the dir above .spacedock-state)\ngot: %s", want, srData) - } - } - if strings.Contains(string(srData), `.spacedock-state`) { - t.Errorf("split-root marker wrongly recorded the state dir as the workflow: %s", srData) - } + runClaudeAdapter(t, claudeAdapterRead(root, "ses-sr", "spacedock:ensign", sr)) + assertSessionMarker(t, root, "ses-sr", "drc-8000", "linear-drc-review") // 6. SPLIT-ROOT _archive: an archived entity in the state checkout is still skipped. srArch := filepath.Join(root, "docs", "spacedock", "linear-drc-review", ".spacedock-state", "_archive", "drc-9.md") - runEventHook(t, readPayload(root, "ses-srarch", "spacedock:ensign", srArch)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-srarch.json")); err == nil { + runClaudeAdapter(t, claudeAdapterRead(root, "ses-srarch", "spacedock:ensign", srArch)) + if markerExists(root, "ses-srarch") { t.Errorf("split-root _archive Read should not produce a session marker") } } From 1f83c0814c3ae75dbfb45fcd3ccfd87193fb1c7b Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 1 Jul 2026 18:14:22 +0800 Subject: [PATCH 02/21] Add harness-neutral bridge egress producers --- .codex-plugin/plugin.json | 1 + .pi/extensions/spacedock.ts | 111 ++++- docs/dev/bridge-egress-contract.md | 19 +- hooks/codex-hooks.json | 64 +++ internal/bridgeegress/egress.go | 459 ++++++++++++++++++ internal/bridgeegress/egress_test.go | 256 ++++++++++ internal/cli/bridge_egress_test.go | 77 +++ internal/cli/cli.go | 34 ++ internal/cli/pi.go | 46 +- internal/cli/pi_egress_test.go | 118 +++++ internal/cli/pi_frontdoor_test.go | 49 +- scripts/codex-bridge-events.sh | 19 + scripts/spacedock-bridge-events.sh | 119 +---- .../claude-first-officer-runtime.md | 2 +- .../references/codex-first-officer-runtime.md | 4 +- .../references/pi-first-officer-runtime.md | 4 +- .../integration/bridge_session_link_test.go | 32 ++ .../codex_bridge_egress_hook_test.go | 155 ++++++ .../bridge-egress-minimal-session-start.json | 9 + 19 files changed, 1434 insertions(+), 144 deletions(-) create mode 100644 hooks/codex-hooks.json create mode 100644 internal/bridgeegress/egress.go create mode 100644 internal/bridgeegress/egress_test.go create mode 100644 internal/cli/bridge_egress_test.go create mode 100644 internal/cli/pi_egress_test.go create mode 100755 scripts/codex-bridge-events.sh create mode 100644 skills/integration/codex_bridge_egress_hook_test.go create mode 100644 skills/integration/testdata/codex/bridge-egress-minimal-session-start.json diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 61db79049..7bad71756 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,6 +16,7 @@ "automation" ], "skills": "./skills/", + "hooks": "./hooks/codex-hooks.json", "requires-contract": ">=2,<3", "interface": { "displayName": "Spacedock", diff --git a/.pi/extensions/spacedock.ts b/.pi/extensions/spacedock.ts index 5cb679654..bf1ca9150 100644 --- a/.pi/extensions/spacedock.ts +++ b/.pi/extensions/spacedock.ts @@ -1,4 +1,4 @@ -// Spacedock pi extension — parent-session skill discovery. +// Spacedock pi extension — parent-session skill discovery and Bridge egress. // // Once `spacedock install --host pi` (or the dev `pi install ./local/path`) // registers the Spacedock package in ~/.pi/agent/settings.json `packages`, the @@ -12,10 +12,12 @@ // the package-root scan reading `package.json` `pi.skills` — no cwd dependency. import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; import * as path from "node:path"; export default function registerSpacedockExtension(pi: { on(event: "resources_discover", handler: (event: { type: "resources_discover"; cwd: string; reason: string }) => { skillPaths?: string[] } | void): void; + on(event: string, handler: (event: Record, ctx?: Record) => void | Promise): void; }): void { pi.on("resources_discover", () => { const extDir = path.dirname(fileURLToPath(import.meta.url)); @@ -24,4 +26,111 @@ export default function registerSpacedockExtension(pi: { const skillsDir = path.join(repoRoot, "skills"); return { skillPaths: [skillsDir] }; }); + + const lifecycleEvents = [ + "session_start", + "session_shutdown", + "agent_start", + "agent_end", + "turn_start", + "turn_end", + "tool_execution_start", + "tool_execution_end", + "tool_call", + "tool_result", + ]; + for (const eventName of lifecycleEvents) { + pi.on(eventName, (event, ctx) => { + void emitBridgeEgress(eventName, event, ctx).catch(() => {}); + }); + } +} + +async function emitBridgeEgress(eventName: string, event: Record, ctx?: Record): Promise { + const cwd = stringValue(event.cwd) || contextCwd(ctx) || process.cwd(); + const sessionFile = contextSessionFile(ctx); + const payload = { + event: eventName, + cwd, + session_file: sessionFile, + session_id: sessionIdFromSessionFile(sessionFile), + agent_id: "", + agent_type: "", + detail: eventDetail(eventName, event), + }; + + await invokeSpacedockEgress(payload, cwd); +} + +function invokeSpacedockEgress(payload: Record, cwd: string): Promise { + const bin = process.env.SPACEDOCK_BIN || "spacedock"; + return new Promise((resolve) => { + const child = spawn(bin, ["bridge", "egress", "emit", "--host", "pi"], { + cwd, + stdio: ["pipe", "ignore", "ignore"], + }); + child.on("error", () => resolve()); + child.on("close", () => resolve()); + child.stdin.on("error", () => resolve()); + try { + child.stdin.end(JSON.stringify(payload)); + } catch { + resolve(); + } + }); +} + +function eventDetail(eventName: string, event: Record): Record { + const detail: Record = { source: "pi" }; + const tool = stringValue(event.toolName); + if (tool) detail.tool = tool; + if (eventName === "session_start" || eventName === "session_shutdown") { + const reason = stringValue(event.reason); + if (reason) detail.reason = reason; + } + const toolCallId = stringValue(event.toolCallId); + if (toolCallId) detail.tool_call_id = toolCallId; + return detail; +} + +function contextCwd(ctx?: Record): string { + const opts = getSystemPromptOptions(ctx); + if (!opts) return ""; + return stringValue(opts.cwd); +} + +function contextSessionFile(ctx?: Record): string { + const manager = objectValue(ctx?.sessionManager); + const getSessionFile = manager?.getSessionFile; + if (typeof getSessionFile !== "function") return ""; + try { + return stringValue(getSessionFile.call(manager)); + } catch { + return ""; + } +} + +function getSystemPromptOptions(ctx?: Record): Record | undefined { + const fn = ctx?.getSystemPromptOptions; + if (typeof fn !== "function") return undefined; + try { + return objectValue(fn.call(ctx)); + } catch { + return undefined; + } +} + +function sessionIdFromSessionFile(sessionFile: string): string { + if (!sessionFile) return ""; + const base = path.basename(sessionFile); + const ext = path.extname(base); + return ext ? base.slice(0, -ext.length) : base; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" ? value as Record : undefined; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; } diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index b13b61cbd..3c4b84b7d 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -5,7 +5,7 @@ The Bridge seam is two flows over the shared `_bridge/` dir (resolved from the r - **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. - **Egress** — FO liveness/activity → Bridge — is *this* contract: the four `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. -**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat and feed are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. Today only the Claude binding is real; Codex and Pi are ABSENT/TODO (no established event producer on those hosts). +**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat and feed are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. The current implementation uses the shared `spacedock bridge egress emit --host ` command for host normalization. Claude, Codex, and Pi have packaged event producers; deterministic session→entity marker parity is proven only for Claude so far. All four files are gitignored session runtime (`.gitignore` carries `_bridge/`), append-only or last-write where noted, and strictly observe-only: a telemetry side-channel must never block, fail, or alter the FO. @@ -28,12 +28,14 @@ An empty value is honest, not a bug: the heartbeat is still a valid liveness tic One JSON object per line, appended on each lifecycle event. Liveness, not content: no tool inputs/outputs, no prompt text. ``` -{"ts":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","detail":{"tool":"","source":""}} +{"timestamp":"","ts":"","host":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","actor_id":"","detail":{"tool":"","source":""}} ``` - `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge tells FO-vs-ensign activity apart. - Best-effort size cap: the producer trims to the most recent lines past a bound (it is liveness, not a ledger; a few lost lines to a concurrent trim are acceptable). -- → **Claude:** PRESENT — the plugin hook `scripts/spacedock-bridge-events.sh`, registered for the six events above in `hooks/hooks.json` (all async). It normalizes the Claude Code hook payload to the line above so Bridge never couples to Claude's internal transcript format. · **Codex:** ABSENT/TODO — no established Codex app-server event producer. · **Pi:** ABSENT/TODO — no established Pi event API producer. +- → **Claude:** PRESENT — `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` for Claude lifecycle events (all async). The wrapper delegates to the shared `spacedock bridge egress emit --host claude` command so Bridge never couples to Claude's internal transcript format. +- → **Codex:** PACKAGED/FIXTURE-COVERED — `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks invoke `scripts/codex-bridge-events.sh`; the wrapper delegates to `spacedock bridge egress emit --host codex`. This proves packaging and minimal lifecycle payload handling, not live marker parity. +- → **Pi:** PACKAGED/SOURCE-COVERED — `package.json` advertises `.pi/extensions/spacedock.ts`, and the Pi extension forwards lifecycle event payloads to `spacedock bridge egress emit --host pi`. Local tests cover registration/source wiring and package discovery, not a live Pi run. ## `_bridge/fo.$SLUG.json` — per-workflow liveness heartbeat @@ -66,16 +68,17 @@ Last-write (first-write-wins per session), one file per live working session. Ma ``` - The `entity`/`workflow` pair is derived from the ensign's first Read of its entity file under `docs/spacedock//.md` (flat or `/index.md`); the path carries both the workflow (so Bridge's join is collision-free across workflows reusing a ticket id) and the slug. First-write-wins records the ensign's own entity, read before any duplicate-check sibling read. Archived (`_archive/`) entities are never marked. -- → **Claude:** PRESENT — derived in `scripts/spacedock-bridge-events.sh` from the `PostToolUse` hook (which fires on every tool call). · **Codex:** ABSENT/TODO. · **Pi:** ABSENT/TODO. +- → **Claude:** PRESENT — derived by the shared emitter from the Claude `PostToolUse`/`Read` hook payload (which fires on every tool call). +- → **Codex/Pi:** NOT CLAIMED — the shared emitter can write a marker from an explicit normalized `entity_path`, but the packaged Codex/Pi producers do not yet have durable live proof that they can supply the child actor and entity path reliably. Until that proof exists, Bridge must treat their event streams as activity-only and omit deterministic per-ship RUNNING markers. -## Decision: deterministic RUNNING badge is Claude-only (for now) +## Decision: deterministic RUNNING badge is Claude-proven only (for now) -The session→entity marker is the **only** deterministic source for the live FO-vs-ensign RUNNING badge, and it is **Claude-only for now**. It relies on a hook that fires on *every* tool call (Claude Code `PostToolUse`) to record the marker on the ensign's first entity Read. The prose-driven alternative — having the ensign run a first-action shell to write its own marker — is unreliable: the ensign skips that step roughly three times out of four. Deriving it in the hook is what makes the badge dependable, and that hook is a Claude plugin mechanism with no Codex/Pi equivalent yet. +The session→entity marker is the **only** deterministic source for the live FO-vs-ensign RUNNING badge, and the live derivation is **Claude-proven only for now**. It relies on a hook that fires on *every* tool call (Claude Code `PostToolUse`) to record the marker on the ensign's first entity Read. The prose-driven alternative — having the ensign run a first-action shell to write its own marker — is unreliable: the ensign skips that step roughly three times out of four. Deriving it in the hook is what makes the badge dependable. Codex and Pi now have event producers, but their marker path stays unclaimed until their adapters prove durable child identity plus entity path evidence. **On non-Claude hosts Bridge degrades gracefully, it does not break:** - The heartbeat (`fo.$SLUG.json`) is host-neutral, so the FO still shows **attached**. - Git `dispatch:`/`advance:` narration and `fo-feed.jsonl` (both host-neutral) still drive the **fleet-history** rail. -- Only the live per-ship **RUNNING badge** (FO-vs-ensign, this very moment) is absent — Bridge simply does not render it, rather than showing a wrong one. +- Only the live per-ship **RUNNING badge** (FO-vs-ensign, this very moment) is absent unless the host has written a session marker — Bridge simply does not render it, rather than showing a wrong one. -When a Codex app-server event surface or a Pi event API is established, bind `events.jsonl` and the session marker in that host's adapter (`## Bridge egress`) against this same schema; the badge lights up with no Bridge change. +When Codex or Pi can prove durable child identity plus entity path evidence, bind the session marker in that host's adapter (`## Bridge egress`) against this same schema; the badge lights up with no Bridge change. diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json new file mode 100644 index 000000000..4d75e9540 --- /dev/null +++ b/hooks/codex-hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + } + ] + } + ] + } +} diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go new file mode 100644 index 000000000..8eb505209 --- /dev/null +++ b/internal/bridgeegress/egress.go @@ -0,0 +1,459 @@ +// ABOUTME: Host-neutral Bridge egress writer for events.jsonl and session markers. +// ABOUTME: Observe-only: malformed payloads and filesystem failures degrade to no-op. +package bridgeegress + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/spacedock-dev/spacedock/internal/status" +) + +const ( + defaultMaxLines = 2000 + defaultKeepLines = 1000 +) + +var safeIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// Options controls one observe-only egress emission. +type Options struct { + Host string + CWD string + Now func() time.Time + MaxLines int + KeepLines int +} + +// Detail is the intentionally small, non-sensitive event detail block. +type Detail struct { + Tool string `json:"tool"` + Source string `json:"source"` +} + +// Event is one normalized Bridge activity line. +type Event struct { + Timestamp string `json:"timestamp"` + TS string `json:"ts"` + Host string `json:"host"` + Event string `json:"event"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AgentType string `json:"agent_type"` + ActorID string `json:"actor_id"` + Detail Detail `json:"detail"` +} + +// Marker maps a host actor to the workflow entity it is driving. +type Marker struct { + Host string `json:"host"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + ActorID string `json:"actor_id"` + Entity string `json:"entity"` + Workflow string `json:"workflow"` +} + +type payload struct { + CWD string `json:"cwd"` + Host string `json:"host"` + Event string `json:"event"` + HookEventName string `json:"hook_event_name"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AgentType string `json:"agent_type"` + ActorID string `json:"actor_id"` + ToolName string `json:"tool_name"` + Source string `json:"source"` + FilePath string `json:"file_path"` + EntityPath string `json:"entity_path"` + Timestamp string `json:"timestamp"` + TS string `json:"ts"` + Detail struct { + Tool string `json:"tool"` + Source string `json:"source"` + FilePath string `json:"file_path"` + } `json:"detail"` + ToolInput struct { + FilePath string `json:"file_path"` + } `json:"tool_input"` +} + +// EmitFromReader reads one JSON payload and writes normalized egress files. It +// never returns an operational error: Bridge egress is telemetry and must not +// break the host session. +func EmitFromReader(r io.Reader, opts Options) { + data, err := io.ReadAll(r) + if err != nil { + return + } + Emit(data, opts) +} + +// Emit writes one normalized event and, when the payload names an entity file +// for a child actor, a first-write-wins session marker. +func Emit(data []byte, opts Options) { + var p payload + if err := json.Unmarshal(data, &p); err != nil { + return + } + + host := firstNonEmpty(opts.Host, p.Host) + eventName := firstNonEmpty(p.Event, p.HookEventName) + cwd := firstNonEmpty(p.CWD, opts.CWD) + if host == "" || eventName == "" || cwd == "" { + return + } + cwdAbs, err := filepath.Abs(cwd) + if err != nil { + return + } + + actorID := actorIDFor(host, p.SessionID, p.AgentID, p.ActorID) + ts := timestampFor(p, opts) + line := Event{ + Timestamp: ts, + TS: ts, + Host: host, + Event: eventName, + SessionID: p.SessionID, + AgentID: p.AgentID, + AgentType: p.AgentType, + ActorID: actorID, + Detail: Detail{ + Tool: firstNonEmpty(p.Detail.Tool, p.ToolName), + Source: firstNonEmpty(p.Detail.Source, p.Source), + }, + } + + bridgeDir := filepath.Join(cwdAbs, "_bridge") + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + return + } + eventsPath := filepath.Join(bridgeDir, "events.jsonl") + appendJSONLine(eventsPath, line) + truncateEvents(eventsPath, opts) + + if actorID == "" || !isChildActor(p) { + return + } + entityPath, ok := markerEntityPath(host, p) + if !ok { + return + } + workflow, entity, ok := DeriveEntity(cwdAbs, entityPath) + if !ok { + return + } + writeMarker(filepath.Join(bridgeDir, "sessions", actorID+".json"), Marker{ + Host: host, + SessionID: p.SessionID, + AgentID: p.AgentID, + ActorID: actorID, + Entity: entity, + Workflow: workflow, + }) +} + +// DeriveEntity maps a read entity file path to (workflow, entity). It supports +// legacy docs/spacedock layouts and workflow-local split-root state checkouts. +func DeriveEntity(cwd, entityPath string) (string, string, bool) { + if strings.TrimSpace(entityPath) == "" { + return "", "", false + } + + cleanInput := filepath.Clean(entityPath) + if hasArchiveSegment(cleanInput) { + return "", "", false + } + + abs := cleanInput + if !filepath.IsAbs(abs) { + if cwd == "" { + return "", "", false + } + abs = filepath.Join(cwd, cleanInput) + } + abs, err := filepath.Abs(abs) + if err != nil { + return "", "", false + } + if cwd != "" { + if _, ok := pathRelInside(cwd, abs); !ok { + return "", "", false + } + } + if workflow, entity, ok := deriveLegacyDocsSpacedock(abs); ok { + return workflow, entity, true + } + if workflow, entity, ok := deriveFromReadmeState(abs); ok { + return workflow, entity, true + } + return deriveDotStateSegment(abs) +} + +func appendJSONLine(path string, value any) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() + data, err := json.Marshal(value) + if err != nil { + return + } + _, _ = f.Write(append(data, '\n')) +} + +func writeMarker(path string, marker Marker) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644) + if err != nil { + return + } + defer f.Close() + data, err := json.Marshal(marker) + if err != nil { + return + } + _, _ = f.Write(append(data, '\n')) +} + +func truncateEvents(path string, opts Options) { + maxLines := opts.MaxLines + if maxLines <= 0 { + maxLines = defaultMaxLines + } + keepLines := opts.KeepLines + if keepLines <= 0 { + keepLines = defaultKeepLines + } + if keepLines > maxLines { + keepLines = maxLines + } + + f, err := os.Open(path) + if err != nil { + return + } + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + if len(lines) <= maxLines || scanner.Err() != nil { + return + } + if keepLines > len(lines) { + keepLines = len(lines) + } + kept := strings.Join(lines[len(lines)-keepLines:], "\n") + "\n" + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp.*") + if err != nil { + return + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(kept); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + } +} + +func timestampFor(p payload, opts Options) string { + if p.Timestamp != "" { + return p.Timestamp + } + if p.TS != "" { + return p.TS + } + now := time.Now + if opts.Now != nil { + now = opts.Now + } + return now().UTC().Format(time.RFC3339) +} + +func actorIDFor(host, sessionID, agentID, explicit string) string { + if explicit != "" { + if safeID(explicit) { + return explicit + } + return "" + } + if host == "claude" { + if safeID(sessionID) { + return sessionID + } + return "" + } + if safeID(sessionID) && safeID(agentID) { + return sessionID + "." + agentID + } + if safeID(sessionID) { + return sessionID + } + if safeID(agentID) { + return agentID + } + return "" +} + +func isChildActor(p payload) bool { + if p.AgentID != "" { + return true + } + return strings.Contains(strings.ToLower(p.AgentType), "ensign") +} + +func markerEntityPath(host string, p payload) (string, bool) { + if p.EntityPath != "" { + return p.EntityPath, true + } + if host == "claude" && p.HookEventName == "PostToolUse" && p.ToolName == "Read" { + path := firstNonEmpty(p.ToolInput.FilePath, p.FilePath, p.Detail.FilePath) + return path, path != "" + } + return "", false +} + +func deriveLegacyDocsSpacedock(path string) (string, string, bool) { + segments := pathSegments(path) + for i := 0; i+2 < len(segments); i++ { + if segments[i] != "docs" || segments[i+1] != "spacedock" { + continue + } + workflow := segments[i+2] + rel := segments[i+3:] + if len(rel) > 0 && rel[0] == ".spacedock-state" { + rel = rel[1:] + } + if entity, ok := entitySlugFromRel(rel); ok && safeID(workflow) { + return workflow, entity, true + } + } + return "", "", false +} + +func deriveFromReadmeState(absPath string) (string, string, bool) { + dir := filepath.Dir(absPath) + for { + readme := filepath.Join(dir, "README.md") + if isRegularFile(readme) { + mode, relPath, err := status.ClassifyState(status.ParseFrontmatter(readme)["state"]) + if err == nil && mode == status.StateSplitRoot { + stateRoot := filepath.Join(dir, relPath) + if rel, ok := pathRelInside(stateRoot, absPath); ok { + if entity, ok := entitySlugFromRel(pathSegments(rel)); ok { + workflow := filepath.Base(dir) + if safeID(workflow) { + return workflow, entity, true + } + } + } + } + } + parent := filepath.Dir(dir) + if parent == dir { + return "", "", false + } + dir = parent + } +} + +func deriveDotStateSegment(absPath string) (string, string, bool) { + segments := pathSegments(absPath) + for i := 1; i < len(segments); i++ { + if segments[i] != ".spacedock-state" { + continue + } + workflow := segments[i-1] + rel := segments[i+1:] + if entity, ok := entitySlugFromRel(rel); ok && safeID(workflow) { + return workflow, entity, true + } + } + return "", "", false +} + +func entitySlugFromRel(rel []string) (string, bool) { + if len(rel) == 1 && strings.HasSuffix(rel[0], ".md") && rel[0] != "README.md" { + slug := strings.TrimSuffix(rel[0], ".md") + return slug, safeID(slug) + } + if len(rel) == 2 && rel[1] == "index.md" { + return rel[0], safeID(rel[0]) + } + return "", false +} + +func pathRelInside(root, target string) (string, bool) { + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", false + } + targetAbs, err := filepath.Abs(target) + if err != nil { + return "", false + } + rel, err := filepath.Rel(rootAbs, targetAbs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return rel, true +} + +func hasArchiveSegment(path string) bool { + for _, seg := range pathSegments(path) { + if seg == "_archive" { + return true + } + } + return false +} + +func pathSegments(path string) []string { + clean := filepath.ToSlash(filepath.Clean(path)) + raw := strings.Split(clean, "/") + segments := raw[:0] + for _, seg := range raw { + if seg != "" && seg != "." { + segments = append(segments, seg) + } + } + return segments +} + +func safeID(s string) bool { + return safeIDPattern.MatchString(s) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func isRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/internal/bridgeegress/egress_test.go b/internal/bridgeegress/egress_test.go new file mode 100644 index 000000000..1b339b37c --- /dev/null +++ b/internal/bridgeegress/egress_test.go @@ -0,0 +1,256 @@ +package bridgeegress + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestEmitWritesEventSchemaAndClaudeMarker(t *testing.T) { + root := t.TempDir() + entityPath := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "drc-3467", "index.md") + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "hook_event_name":"PostToolUse", + "session_id":"ses-1", + "agent_type":"spacedock:ensign", + "tool_name":"Read", + "source":"hook", + "tool_input":{"file_path":`+quote(entityPath)+`} + }`), fixedOptions("claude")) + + var event Event + readLastEvent(t, root, &event) + if event.Timestamp != "2026-07-01T01:02:03Z" { + t.Fatalf("timestamp = %q", event.Timestamp) + } + if event.TS != event.Timestamp { + t.Fatalf("ts = %q, want timestamp %q", event.TS, event.Timestamp) + } + if event.Host != "claude" || event.Event != "PostToolUse" || event.SessionID != "ses-1" { + t.Fatalf("event identity mismatch: %+v", event) + } + if event.ActorID != "ses-1" { + t.Fatalf("actor_id = %q, want Claude session id", event.ActorID) + } + if event.AgentType != "spacedock:ensign" || event.Detail.Tool != "Read" || event.Detail.Source != "hook" { + t.Fatalf("event detail mismatch: %+v", event) + } + + var marker Marker + readMarker(t, root, "ses-1", &marker) + if marker.Host != "claude" || marker.SessionID != "ses-1" || marker.ActorID != "ses-1" { + t.Fatalf("marker identity mismatch: %+v", marker) + } + if marker.Workflow != "linear-drc-review" || marker.Entity != "drc-3467" { + t.Fatalf("marker target mismatch: %+v", marker) + } +} + +func TestEmitMalformedOrIncompletePayloadNoops(t *testing.T) { + root := t.TempDir() + for _, input := range [][]byte{ + []byte(`{`), + []byte(`{"cwd":` + quote(root) + `}`), + []byte(`{"event":"SessionStart"}`), + } { + Emit(input, fixedOptions("claude")) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "events.jsonl")); !os.IsNotExist(err) { + t.Fatalf("events.jsonl exists after malformed/incomplete payload: %v", err) + } +} + +func TestEmitInvalidActorIDStillWritesEventButNoMarker(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "event":"PostToolUse", + "session_id":"bad/id", + "agent_type":"spacedock:ensign", + "tool_name":"Read", + "tool_input":{"file_path":"docs/spacedock/wf/task.md"} + }`), fixedOptions("claude")) + + var event Event + readLastEvent(t, root, &event) + if event.ActorID != "" { + t.Fatalf("unsafe actor_id = %q, want empty", event.ActorID) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions")); !os.IsNotExist(err) { + t.Fatalf("sessions dir exists for unsafe actor id: %v", err) + } +} + +func TestEmitDoesNotCreateMarkerFromIncidentalFilePath(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "hook_event_name":"PostToolUse", + "session_id":"ses-edit", + "agent_type":"spacedock:ensign", + "tool_name":"Edit", + "tool_input":{"file_path":"docs/spacedock/wf/task.md"} + }`), fixedOptions("claude")) + + var event Event + readLastEvent(t, root, &event) + if event.Event != "PostToolUse" || event.Detail.Tool != "Edit" { + t.Fatalf("event mismatch: %+v", event) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-edit.json")); !os.IsNotExist(err) { + t.Fatalf("Edit file_path should not create a marker: %v", err) + } +} + +func TestEmitMarkerFirstWriteWins(t *testing.T) { + root := t.TempDir() + first := `{"cwd":` + quote(root) + `,"hook_event_name":"PostToolUse","session_id":"ses-1","agent_type":"spacedock:ensign","tool_name":"Read","tool_input":{"file_path":"docs/spacedock/wf/first.md"}}` + second := `{"cwd":` + quote(root) + `,"hook_event_name":"PostToolUse","session_id":"ses-1","agent_type":"spacedock:ensign","tool_name":"Read","tool_input":{"file_path":"docs/spacedock/wf/second.md"}}` + + Emit([]byte(first), fixedOptions("claude")) + Emit([]byte(second), fixedOptions("claude")) + + var marker Marker + readMarker(t, root, "ses-1", &marker) + if marker.Entity != "first" { + t.Fatalf("marker overwritten: %+v", marker) + } +} + +func TestEmitSkipsArchiveMarkers(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "event":"PostToolUse", + "session_id":"ses-arch", + "agent_type":"spacedock:ensign", + "tool_input":{"file_path":"docs/spacedock/wf/_archive/old.md"} + }`), fixedOptions("claude")) + if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-arch.json")); !os.IsNotExist(err) { + t.Fatalf("archive marker exists: %v", err) + } +} + +func TestDeriveEntitySupportsWorkflowLocalSplitRootAndFolderForm(t *testing.T) { + root := t.TempDir() + workflowDir := filepath.Join(root, "docs", "dev") + if err := os.MkdirAll(filepath.Join(workflowDir, ".spacedock-state", "wire-egress"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "README.md"), []byte("---\nstate: .spacedock-state\n---\n# Dev\n"), 0o644); err != nil { + t.Fatal(err) + } + entityPath := filepath.Join(workflowDir, ".spacedock-state", "wire-egress", "index.md") + + workflow, entity, ok := DeriveEntity(root, entityPath) + if !ok { + t.Fatalf("DeriveEntity did not recognize split-root folder path") + } + if workflow != "dev" || entity != "wire-egress" { + t.Fatalf("DeriveEntity = (%q,%q), want (dev,wire-egress)", workflow, entity) + } +} + +func TestDeriveEntitySupportsDotStateWithoutReadme(t *testing.T) { + workflow, entity, ok := DeriveEntity("/repo", "/repo/docs/dev/.spacedock-state/flat-task.md") + if !ok || workflow != "dev" || entity != "flat-task" { + t.Fatalf("DeriveEntity fallback = (%q,%q,%v), want (dev,flat-task,true)", workflow, entity, ok) + } +} + +func TestDeriveEntityRejectsAbsolutePathOutsideCWD(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "docs", "spacedock", "wf", "task.md") + workflow, entity, ok := DeriveEntity(root, outside) + if ok { + t.Fatalf("DeriveEntity accepted outside path as (%q,%q)", workflow, entity) + } +} + +func TestEmitExplicitEntityPathCombinesSessionAndAgent(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "event":"SubagentStart", + "session_id":"parent", + "agent_id":"agent-7", + "agent_type":"spacedock:ensign", + "entity_path":"docs/spacedock/dev/task.md" + }`), fixedOptions("codex")) + + var marker Marker + readMarker(t, root, "parent.agent-7", &marker) + if marker.ActorID != "parent.agent-7" || marker.SessionID != "parent" || marker.AgentID != "agent-7" { + t.Fatalf("explicit entity_path marker identity mismatch: %+v", marker) + } +} + +func TestEmitAppendsAndTruncatesEvents(t *testing.T) { + root := t.TempDir() + opts := fixedOptions("claude") + opts.MaxLines = 3 + opts.KeepLines = 2 + for _, event := range []string{"one", "two", "three", "four"} { + Emit([]byte(`{"cwd":`+quote(root)+`,"event":`+quote(event)+`,"session_id":"ses"}`), opts) + } + + data, err := os.ReadFile(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("kept %d lines, want 2:\n%s", len(lines), data) + } + var first, second Event + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatal(err) + } + if first.Event != "three" || second.Event != "four" { + t.Fatalf("kept events = %q,%q; want three,four", first.Event, second.Event) + } +} + +func fixedOptions(host string) Options { + return Options{ + Host: host, + Now: func() time.Time { + return time.Date(2026, 7, 1, 1, 2, 3, 0, time.UTC) + }, + } +} + +func readLastEvent(t *testing.T, root string, out *Event) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if err := json.Unmarshal([]byte(lines[len(lines)-1]), out); err != nil { + t.Fatalf("unmarshal event: %v\n%s", err, lines[len(lines)-1]) + } +} + +func readMarker(t *testing.T, root, actorID string, out *Marker) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", actorID+".json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatalf("unmarshal marker: %v\n%s", err, data) + } +} + +func quote(s string) string { + data, _ := json.Marshal(s) + return string(data) +} diff --git a/internal/cli/bridge_egress_test.go b/internal/cli/bridge_egress_test.go new file mode 100644 index 000000000..c0adb0857 --- /dev/null +++ b/internal/cli/bridge_egress_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBridgeEgressEmitHiddenCLISilentAndWrites(t *testing.T) { + root := t.TempDir() + payload := `{"event":"SessionStart","session_id":"ses-1","source":"startup"}` + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{"bridge", "egress", "emit", "--host", "claude"}, + nil, root, strings.NewReader(payload), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("bridge egress should be silent, stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + t.Fatal(err) + } + var event struct { + Host string `json:"host"` + Event string `json:"event"` + SessionID string `json:"session_id"` + ActorID string `json:"actor_id"` + Detail struct { + Source string `json:"source"` + } `json:"detail"` + } + if err := json.Unmarshal(bytes.TrimSpace(data), &event); err != nil { + t.Fatalf("event JSON: %v\n%s", err, data) + } + if event.Host != "claude" || event.Event != "SessionStart" || event.SessionID != "ses-1" || event.ActorID != "ses-1" || event.Detail.Source != "startup" { + t.Fatalf("event mismatch: %+v", event) + } +} + +func TestBridgeEgressEmitMalformedPayloadSilentNoop(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{"bridge", "egress", "emit", "--host", "claude"}, + nil, root, strings.NewReader(`{`), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("bridge egress should be silent, stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "events.jsonl")); !os.IsNotExist(err) { + t.Fatalf("events.jsonl exists after malformed payload: %v", err) + } +} + +func TestBridgeCommandStaysOutOfTopLevelHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if strings.Contains(stdout.String(), "bridge") { + t.Fatalf("top-level help exposes hidden bridge command:\n%s", stdout.String()) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cf1a83d86..181fb695b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" + "github.com/spacedock-dev/spacedock/internal/bridgeegress" "github.com/spacedock-dev/spacedock/internal/claudeteam" "github.com/spacedock-dev/spacedock/internal/contract" "github.com/spacedock-dev/spacedock/internal/dispatch" @@ -150,6 +151,7 @@ func newRootCommand(ctx context.Context, rawArgs []string, env []string, dir str newMergeCommand(ctx, env, dir, stdout, stderr), newCompletionCommand(stdout, stderr), newDispatchCommand(dispatchProbe, stdin, stdout, stderr), + newBridgeCommand(dir, stdin), ) return root } @@ -458,6 +460,38 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout } } +// newBridgeCommand is a hidden hook-facing surface. It is intentionally silent +// and no-op-safe because Bridge egress is observe-only telemetry. +func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { + return &cobra.Command{ + Use: "bridge egress emit --host ", + Hidden: true, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 || args[0] != "egress" || args[1] != "emit" { + return nil + } + bridgeegress.EmitFromReader(stdin, bridgeegress.Options{ + Host: parseBridgeHost(args[2:]), + CWD: dir, + }) + return nil + }, + } +} + +func parseBridgeHost(args []string) string { + for i := 0; i < len(args); i++ { + if args[i] == "--host" && i+1 < len(args) { + return args[i+1] + } + if strings.HasPrefix(args[i], "--host=") { + return strings.TrimPrefix(args[i], "--host=") + } + } + return "" +} + // wantsHelp reports whether the operator asked for command help. Commands with // DisableFlagParsing receive `-h`/`--help` as ordinary args, so each RunE checks // for it before doing work. Only a leading help token counts: a `--help` after diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 58f7b3409..e39f5234c 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -49,6 +49,7 @@ type piPackageStatus struct { registered bool ensignDiscoverable bool firstOfficerDiscoverable bool + extensionDiscoverable bool source string // the settings.json packages entry for spacedock packageRoot string // the resolved package root } @@ -251,13 +252,13 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR // empirically). Pass the extension + the checkout's skills explicitly so the // parent session loads the Spacedock first-officer/ensign skills via the // extension's resources_discover. This is the dev-override equivalent of what - // `pi install` registers for the installed path. The os.Stat guard makes the + // `pi install` registers for the installed path. The Stat guard makes the // addition graceful: if the extension is absent at the resolved path, the // flags are not added (no crash, falls back to the installed-path mechanism). if cfg.repoRoot != "" { spacedockExt := filepath.Join(cfg.repoRoot, ".pi", "extensions", "spacedock.ts") spacedockSkills := filepath.Join(cfg.repoRoot, "skills") - if _, err := os.Stat(spacedockExt); err == nil { + if err := ops.Stat(spacedockExt); err == nil { argv = append(argv, "--extension", spacedockExt, "--skill", spacedockSkills) } } @@ -555,26 +556,30 @@ func checkPiRuntime(ops piRuntimeOps, cfg piRuntimeConfig) piCheckResult { res.intercomPackageOK = ops.Stat(cfg.intercomPackageRoot) == nil res.intercomSkillOK = ops.Stat(filepath.Join(cfg.intercomPackageRoot, "skills", "pi-intercom", "SKILL.md")) == nil // The retired repo-path Stat checks (firstOfficerOK/ensignOK) are replaced by - // spacedockPackageOK: the package is registered AND ensign is discoverable via - // the package-root skill scan — the real discovery contract, not a filesystem - // coincidence at a cwd-derived path. + // spacedockPackageOK: the package is registered, its Pi extension is + // discoverable, and ensign is discoverable via the package-root skill scan — + // the real discovery contract, not a filesystem coincidence at a cwd-derived + // path. status := ops.SpacedockPackageStatus(cfg.agentDir, cfg.home) res.packageStatus = status - res.spacedockPackageOK = status.registered && status.ensignDiscoverable + res.spacedockPackageOK = status.registered && status.extensionDiscoverable && status.ensignDiscoverable // Dev override: --plugin-dir / SPACEDOCK_REPO_ROOT points at a local // Spacedock checkout. When the package is not registered in settings.json // (e.g. a fresh pi-home), the dev-override checkout satisfies the gate if - // it contains the ensign skill (skills/ensign/SKILL.md). This restores the - // documented dev-override launch path that the package-OK gate + // it contains both the Spacedock Pi extension and the ensign skill + // (skills/ensign/SKILL.md). This restores the documented dev-override launch path that the package-OK gate // inadvertently broke. When repoRoot is empty, spacedockPackageOK still // requires the registered package (the install-managed contract). + devOverrideExtensionOK := cfg.repoRoot != "" && ops.Stat(filepath.Join(cfg.repoRoot, ".pi", "extensions", "spacedock.ts")) == nil if !res.spacedockPackageOK && cfg.repoRoot != "" && + devOverrideExtensionOK && ops.Stat(filepath.Join(cfg.repoRoot, "skills", "ensign", "SKILL.md")) == nil { res.spacedockPackageOK = true res.packageStatus = piPackageStatus{ registered: true, ensignDiscoverable: true, firstOfficerDiscoverable: ops.Stat(filepath.Join(cfg.repoRoot, "skills", "first-officer", "SKILL.md")) == nil, + extensionDiscoverable: true, source: cfg.repoRoot + " (dev override)", packageRoot: cfg.repoRoot, } @@ -690,11 +695,19 @@ func piSpacedockPackageStatus(agentDir, home string) piPackageStatus { if root == "" { continue } - name, skillPaths := readPackagePiSkills(root) + name, extensions, skillPaths := readPackagePiManifest(root) if name != "spacedock" { continue } st := piPackageStatus{registered: true, source: src, packageRoot: root} + for _, ext := range extensions { + clean := cleanRelativePath(ext) + if clean == filepath.Join(".pi", "extensions", "spacedock.ts") { + if _, err := os.Stat(filepath.Join(root, clean)); err == nil { + st.extensionDiscoverable = true + } + } + } for _, sp := range skillPaths { dir := filepath.Join(root, sp) if piSkillFileExists(dir, "ensign") { @@ -723,21 +736,26 @@ func piPackageSourceFromEntry(raw json.RawMessage) string { return "" } -func readPackagePiSkills(root string) (name string, skills []string) { +func readPackagePiManifest(root string) (name string, extensions, skills []string) { data, err := os.ReadFile(filepath.Join(root, "package.json")) if err != nil { - return "", nil + return "", nil, nil } var pkg struct { Name string `json:"name"` Pi struct { - Skills []string `json:"skills"` + Extensions []string `json:"extensions"` + Skills []string `json:"skills"` } `json:"pi"` } if json.Unmarshal(data, &pkg) != nil { - return "", nil + return "", nil, nil } - return pkg.Name, pkg.Pi.Skills + return pkg.Name, pkg.Pi.Extensions, pkg.Pi.Skills +} + +func cleanRelativePath(p string) string { + return filepath.Clean(strings.TrimPrefix(p, "./")) } func piSkillFileExists(dir, skill string) bool { diff --git a/internal/cli/pi_egress_test.go b/internal/cli/pi_egress_test.go new file mode 100644 index 000000000..6567b642e --- /dev/null +++ b/internal/cli/pi_egress_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPiExtensionWiresBridgeEgressThroughSpacedockCLI(t *testing.T) { + srcPath := filepath.Join("..", "..", ".pi", "extensions", "spacedock.ts") + data, err := os.ReadFile(srcPath) + if err != nil { + t.Fatal(err) + } + src := string(data) + for _, want := range []string{ + "process.env.SPACEDOCK_BIN || \"spacedock\"", + "[\"bridge\", \"egress\", \"emit\", \"--host\", \"pi\"]", + "\"session_start\"", + "\"session_shutdown\"", + "\"agent_start\"", + "\"agent_end\"", + "\"tool_execution_start\"", + "\"tool_execution_end\"", + "\"tool_call\"", + "\"tool_result\"", + "stdio: [\"pipe\", \"ignore\", \"ignore\"]", + } { + if !strings.Contains(src, want) { + t.Fatalf("Pi extension missing %q:\n%s", want, src) + } + } + for _, notWant := range []string{ + "_bridge/events.jsonl", + "_bridge/sessions", + "session marker", + } { + if strings.Contains(src, notWant) { + t.Fatalf("Pi extension must not directly write %q or claim marker parity:\n%s", notWant, src) + } + } +} + +func TestPackageManifestAdvertisesPiExtension(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "package.json")) + if err != nil { + t.Fatal(err) + } + var pkg struct { + Pi struct { + Extensions []string `json:"extensions"` + Skills []string `json:"skills"` + } `json:"pi"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + t.Fatal(err) + } + if !containsString(pkg.Pi.Extensions, "./.pi/extensions/spacedock.ts") { + t.Fatalf("package.json pi.extensions must include ./.pi/extensions/spacedock.ts, got %v", pkg.Pi.Extensions) + } + if !containsString(pkg.Pi.Skills, "./skills") { + t.Fatalf("package.json pi.skills must include ./skills, got %v", pkg.Pi.Skills) + } +} + +func TestPiPackageStatusRequiresSpacedockExtension(t *testing.T) { + home := t.TempDir() + agentDir := filepath.Join(home, ".pi", "agent") + pkgRoot := filepath.Join(agentDir, "packages", "spacedock") + writeFileWithDirs(t, filepath.Join(agentDir, "settings.json"), `{"packages":["`+pkgRoot+`"]}`+"\n") + writeFileWithDirs(t, filepath.Join(pkgRoot, "package.json"), `{ + "name": "spacedock", + "pi": { + "extensions": ["./.pi/extensions/spacedock.ts"], + "skills": ["./skills"] + } + }`+"\n") + writePiSkillFixtures(t, pkgRoot) + writeFileWithDirs(t, filepath.Join(pkgRoot, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") + + status := piSpacedockPackageStatus(agentDir, home) + if !status.registered || !status.ensignDiscoverable || !status.firstOfficerDiscoverable { + t.Fatalf("status should find registered skills before extension gate, got %+v", status) + } + if !status.extensionDiscoverable { + t.Fatalf("status should find package extension, got %+v", status) + } + + if err := os.Remove(filepath.Join(pkgRoot, ".pi", "extensions", "spacedock.ts")); err != nil { + t.Fatal(err) + } + status = piSpacedockPackageStatus(agentDir, home) + if !status.registered || !status.ensignDiscoverable { + t.Fatalf("status should still detect package and skills without extension, got %+v", status) + } + if status.extensionDiscoverable { + t.Fatalf("extensionDiscoverable=true after removing extension: %+v", status) + } + check := checkPiRuntime(&fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(pkgRoot, t.TempDir()), + packageStatus: status, + }, piRuntimeConfigFromEnv([]string{"HOME=" + home}, "/non-repo-cwd", "")) + if check.spacedockPackageOK { + t.Fatalf("runtime gate must reject installed Spacedock package without Pi extension: %+v", check) + } +} + +func containsString(values []string, want string) bool { + for _, got := range values { + if got == want { + return true + } + } + return false +} diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 70eb29c96..4660c6b13 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -18,7 +18,7 @@ type fakePiRuntimeOps struct { statOK map[string]bool launched []string launchedEnv []string - launchCode int // host exit code Launch returns (default 0) + launchCode int // host exit code Launch returns (default 0) piInstalls []string // sources captured by PiInstall piInstallOut string piInstallErr error @@ -61,6 +61,7 @@ func healthyPiPackageStatus() piPackageStatus { registered: true, ensignDiscoverable: true, firstOfficerDiscoverable: true, + extensionDiscoverable: true, source: "git:github.com/spacedock-dev/spacedock", packageRoot: "/pkg-store/spacedock", } @@ -153,9 +154,11 @@ func TestRunPi_DevOverridePassesSpacedockExtensionAndSkills(t *testing.T) { writeFileWithDirs(t, filepath.Join(repo, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") pkg := t.TempDir() writePiSubagentsFixtures(t, pkg) + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) ops := &fakePiRuntimeOps{ lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + statOK: statOK, packageStatus: healthyPiPackageStatus(), } var stdout, stderr bytes.Buffer @@ -660,9 +663,10 @@ func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { // TestPiRuntimeDevOverrideSatisfiesPackageGate verifies the regression fix for // the --plugin-dir / SPACEDOCK_REPO_ROOT dev-override launch path: when the // Spacedock package is NOT registered in settings.json (fresh pi-home), a -// dev-override repoRoot that contains skills/ensign/SKILL.md satisfies the -// package gate so the launch path reaches the ensign. The inverse (no -// repoRoot, no package) still fails the gate — the install-managed contract. +// dev-override repoRoot that contains the Spacedock Pi extension and +// skills/ensign/SKILL.md satisfies the package gate so the launch path reaches +// the ensign. The inverse (no repoRoot, no package) still fails the gate — the +// install-managed contract. func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { repo := t.TempDir() writePiSkillFixtures(t, repo) @@ -677,7 +681,11 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { } check := checkPiRuntime(&fakePiRuntimeOps{ lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + statOK: func() map[string]bool { + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) + return statOK + }(), // No package registered in settings.json. packageStatus: piPackageStatus{}, }, cfg) @@ -697,11 +705,16 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { t.Run("runPi launches with dev override and no installed package", func(t *testing.T) { home := t.TempDir() + writeFileWithDirs(t, filepath.Join(repo, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") var stdout, stderr bytes.Buffer code := runPi(context.Background(), []string{"do work", "--plugin-dir", repo, "--", "--print"}, "/non-repo-cwd", piTestEnv(pkg, home), &fakePiRuntimeOps{ - lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + lookPath: piHealthyPathFixtures(), + statOK: func() map[string]bool { + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) + return statOK + }(), packageStatus: piPackageStatus{}, // not installed }, &stdout, &stderr) if code != 0 { @@ -751,6 +764,22 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { t.Fatalf("dev override without ensign skill must not satisfy spacedockPackageOK") } }) + + t.Run("dev override without Spacedock extension does not satisfy gate", func(t *testing.T) { + repoWithoutExtension := t.TempDir() + writePiSkillFixtures(t, repoWithoutExtension) + home := t.TempDir() + statOK := statOKForPiResources(repoWithoutExtension, pkg) + cfg := piRuntimeConfigFromEnv(append(piTestEnv(pkg, home), "SPACEDOCK_REPO_ROOT="+repoWithoutExtension), "/non-repo-cwd", "") + check := checkPiRuntime(&fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOK, + packageStatus: piPackageStatus{}, + }, cfg) + if check.spacedockPackageOK { + t.Fatalf("dev override without Spacedock extension must not satisfy spacedockPackageOK") + } + }) } func assertEqual(t *testing.T, got, want string) { @@ -792,6 +821,10 @@ func statOKForPiResources(repo, pkg string) map[string]bool { } } +func markSpacedockPiExtension(statOK map[string]bool, repo string) { + statOK[filepath.Join(repo, ".pi", "extensions", "spacedock.ts")] = true +} + func piHealthyPathFixtures() map[string]string { return map[string]string{ "pi": "/bin/pi", diff --git a/scripts/codex-bridge-events.sh b/scripts/codex-bridge-events.sh new file mode 100755 index 000000000..c89049cfe --- /dev/null +++ b/scripts/codex-bridge-events.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# ABOUTME: Codex plugin hook wrapper for Bridge egress; observe-only and silent. +# +# Codex loads hooks from hooks/codex-hooks.json, not hooks/hooks.json, because the +# shared Claude hook file uses async:true and Codex skips async command hooks. +# This wrapper intentionally delegates to the public Spacedock egress command so +# Codex packaging does not grow its own private event schema. +set -u + +if [ -n "${SPACEDOCK_BIN:-}" ] && [ -x "${SPACEDOCK_BIN:-}" ]; then + bin="${SPACEDOCK_BIN}" +elif command -v spacedock >/dev/null 2>&1; then + bin="spacedock" +else + exit 0 +fi + +"$bin" bridge egress emit --host codex >/dev/null 2>&1 || : +exit 0 diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index bad497c9b..999a84018 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -1,115 +1,18 @@ #!/usr/bin/env bash -# ABOUTME: Spacedock plugin hook — emit a normalized FO event line to _bridge/events.jsonl -# ABOUTME: for the external Bridge command-center UI to tail. Observe-only; never blocks the session. +# ABOUTME: Claude plugin hook wrapper for Bridge egress; observe-only and silent. # -# Registered for SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop -# in hooks/hooks.json (all async). It writes a stable, Spacedock-owned event contract so -# Bridge does not have to couple to Claude Code's internal transcript JSONL format. -# -# This script is the CLAUDE producer-binding for the egress surface; the harness-neutral -# schema for events.jsonl + the _bridge/sessions/.json marker (and where Codex/Pi are -# ABSENT/TODO) is docs/dev/bridge-egress-contract.md. Keep the emitted line shapes in sync -# with that contract. -# -# Events land in «session-cwd»/_bridge/events.jsonl — the same _bridge/ dir the bridge-inbox -# mod drains and that Bridge resolves from the repo root. agent_id/agent_type are empty for -# the main FO session and set for ensign subagents, so Bridge can tell FO vs ensign activity. -# -# Honesty + safety: this only observes. It must never alter the session, so it always exits 0 -# and degrades to a silent no-op when jq is unavailable, the payload lacks a cwd, or the write -# fails — a telemetry side-channel must not be able to break the FO. +# Registered by hooks/hooks.json for Claude lifecycle events. The normalized +# Bridge schema and marker logic live in the spacedock binary, so host wrappers do +# not grow private JSON contracts. set -u -# No jq → no-op. (Spacedock already assumes jq for its gh-driven hooks.) -command -v jq >/dev/null 2>&1 || exit 0 - -payload="$(cat 2>/dev/null)" || exit 0 -[ -n "$payload" ] || exit 0 - -cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" || exit 0 -[ -n "$cwd" ] || exit 0 - -dir="$cwd/_bridge" -mkdir -p "$dir" 2>/dev/null || exit 0 - -ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -# Normalize to a stable line. Only generic, non-sensitive fields are emitted (no tool inputs -# or outputs, no prompt text) — liveness, not content. -events="$dir/events.jsonl" -printf '%s' "$payload" | jq -c \ - --arg ts "$ts" \ - '{ - ts: $ts, - event: (.hook_event_name // "unknown"), - session_id: (.session_id // ""), - agent_id: (.agent_id // ""), - agent_type: (.agent_type // ""), - detail: { - tool: (.tool_name // ""), - source: (.source // "") - } - }' >> "$events" 2>/dev/null || exit 0 - -# ── Session→entity marker (DETERMINISTIC running-badge source) ─────────────────────── -# Bridge maps a live working session to its ship via «cwd»/_bridge/sessions/.json. -# Derive it HERE — from the hook, which fires on every tool call — instead of relying on the -# ensign to run a first-action shell (it skips that ~3/4 of the time). On an ENSIGN's first -# Read of its entity file (.../docs/spacedock//.md, flat or /index.md), -# record {session_id, entity, workflow}: the path carries BOTH the workflow (so Bridge's join -# is collision-free across workflows that reuse a ticket id) and the full slug. First-write- -# wins per session, so the ensign's OWN entity — read before any duplicate-check sibling reads -# — is what gets recorded. Observe-only and best-effort: every step degrades to a no-op. -m_sid="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)" -m_type="$(printf '%s' "$payload" | jq -r '.agent_type // empty' 2>/dev/null)" -m_evt="$(printf '%s' "$payload" | jq -r '.hook_event_name // empty' 2>/dev/null)" -m_tool="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null)" -case "$m_sid" in *[!A-Za-z0-9._-]*) m_sid="" ;; esac # unsafe id → skip (never escape _bridge/) -if [ -n "$m_sid" ] && [ "$m_type" = "spacedock:ensign" ] && [ "$m_evt" = "PostToolUse" ] && [ "$m_tool" = "Read" ]; then - marker="$dir/sessions/$m_sid.json" - if [ ! -f "$marker" ]; then # first-write-wins → the ensign's own entity (read first) - fp="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" - case "$fp" in - */docs/spacedock/*/*.md|docs/spacedock/*/*.md) # absolute OR repo-relative (the FO passes a relative {entity_file_path}) - # Workflow = the path segment right AFTER docs/spacedock/ — robust to a - # split-root entity nested under /.spacedock-state/.md (taking the - # entity's parent dir would wrongly yield ".spacedock-state"). - rest="${fp##*/docs/spacedock/}" # absolute / nested: strip through the last /docs/spacedock/ - rest="${rest#docs/spacedock/}" # repo-relative: strip the leading docs/spacedock/ - wf="${rest%%/*}" # first segment after docs/spacedock/ = the workflow dir - if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: ...//index.md - slug="$(basename "$(dirname "$fp")")" - else # flat entity: .../.md - slug="$(basename "$fp" .md)" - fi - case "$fp" in */_archive/*) wf="" ;; esac # never mark an archived (terminal) entity - case "$wf" in ""|_*|*[!A-Za-z0-9._-]*) wf="" ;; esac # skip _-dirs, unsafe - case "$slug" in ""|README|*[!A-Za-z0-9._-]*) slug="" ;; esac - if [ -n "$slug" ] && [ -n "$wf" ]; then - mkdir -p "$dir/sessions" 2>/dev/null && - printf '{"session_id":"%s","entity":"%s","workflow":"%s"}\n' "$m_sid" "$slug" "$wf" > "$marker" 2>/dev/null || : - fi - ;; - esac - fi -fi - -# Best-effort size cap: a liveness side-channel must not grow without bound (PostToolUse -# fires on every tool call). When the log passes max_lines, keep only the most recent -# keep_lines. Lock-free and best-effort by design — this is liveness, not a ledger, so -# losing a few lines to a concurrent append during the rare trim is acceptable, and every -# step degrades to a no-op ($$ keeps the temp unique across concurrent async hooks; any -# failure leaves the existing log untouched). Never block or break the FO. -max_lines=2000 -keep_lines=1000 -n="$(wc -l < "$events" 2>/dev/null || echo 0)" -if [ "${n:-0}" -gt "$max_lines" ] 2>/dev/null; then - tmp="$events.tmp.$$" - if tail -n "$keep_lines" "$events" > "$tmp" 2>/dev/null; then - mv -f "$tmp" "$events" 2>/dev/null || rm -f "$tmp" 2>/dev/null - else - rm -f "$tmp" 2>/dev/null - fi +if [ -n "${SPACEDOCK_BIN:-}" ] && [ -x "${SPACEDOCK_BIN:-}" ]; then + bin="${SPACEDOCK_BIN}" +elif command -v spacedock >/dev/null 2>&1; then + bin="spacedock" +else + exit 0 fi +"$bin" bridge egress emit --host claude >/dev/null 2>&1 || : exit 0 diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index 8fcc1c55d..25be825a1 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -38,5 +38,5 @@ To file a seed task, do NOT use the Write tool to hand-assemble frontmatter afte Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod and needs no adapter binding.) -- **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The script normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi: ABSENT — see those adapters.) +- **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The wrapper delegates to `spacedock bridge egress emit --host claude`, which normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi have event producers but do not yet claim deterministic marker parity — see those adapters.) - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude, so `fo.$SLUG.json` carries the same id the event stream and session markers use and Bridge can join liveness to activity. No per-tick `export` is needed; leave `SD_SESSION_ID` unset on Claude (the event producer stamps `events.jsonl`/markers from the hook payload's session id, so an override that differs would desync the heartbeat from the event stream). diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index 0504f7364..6501c17c3 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -53,7 +53,7 @@ The captain is the user of the Codex session. Communicate gate results, clarific ## Bridge egress (FO liveness/activity → Bridge) -Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex this producer is not yet established. +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex the event producer is packaged through Codex plugin hooks; deterministic session→entity markers are not yet claimed. -- **FO event emission** — ABSENT/TODO on Codex: there is no established producer (the Codex app-server lifecycle event API for emitting the events.jsonl contract line is unconfirmed). Consequence: Bridge gets no `events.jsonl` and no `_bridge/sessions/` markers on a Codex host, so it shows neither live tool activity nor FO-vs-ensign attribution; liveness falls back to the bridge-inbox heartbeat (`_bridge/fo.$SLUG.json`) the FO writes each tick. Binding a Codex producer that emits the contract line is the open work. +- **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks invoke `scripts/codex-bridge-events.sh`; the wrapper delegates to `spacedock bridge egress emit --host codex`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file or `CLAUDE_PLUGIN_ROOT`. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index 797626043..111302bad 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -23,7 +23,7 @@ The durable proof for Pi support is not transcript phrasing. A valid live proof ## Bridge egress (FO liveness/activity → Bridge) -Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi this producer is not yet established. +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi the event producer is packaged through the Spacedock Pi extension; deterministic session→entity markers are not yet claimed. -- **FO event emission** — ABSENT/TODO on Pi: there is no established producer (a Pi event API for emitting the events.jsonl contract line is unconfirmed). Consequence: Bridge gets no `events.jsonl` and no `_bridge/sessions/` markers on a Pi host, so it shows neither live tool activity nor FO-vs-ensign attribution; liveness falls back to the bridge-inbox heartbeat (`_bridge/fo.$SLUG.json`) the FO writes each tick. Binding a Pi producer that emits the contract line is the open work. +- **FO event emission** — PACKAGED/SOURCE-COVERED on Pi: `package.json` advertises `.pi/extensions/spacedock.ts`, and that extension forwards Pi lifecycle event payloads to `spacedock bridge egress emit --host pi`. The package-status gate requires the Spacedock Pi extension as well as the ensign skill, so a skills-only package is not treated as Bridge-egress-capable. This does not prove `_bridge/sessions/` marker parity: Pi marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`; on Pi this stays empty (no stable per-session id is exposed), so the heartbeat carries an empty session id — still a valid liveness tick (Bridge reads freshness from `ts`). Bind it once Pi exposes a stable per-session id. diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index c97b479a0..612e777b6 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -10,9 +10,16 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" ) +var ( + bridgeAdapterBin string + bridgeAdapterBinErr error + bridgeAdapterBinOnce sync.Once +) + // runClaudeAdapter feeds ONE Claude-Code-shaped hook payload — the Claude adapter's input — // to scripts/spacedock-bridge-events.sh (the Claude binding of the egress producer) and // returns once it exits. cwd anchors the _bridge/ dir the adapter writes to. @@ -24,11 +31,36 @@ func runClaudeAdapter(t *testing.T, payload string) { } cmd := exec.Command("bash", hook) cmd.Stdin = strings.NewReader(payload) + cmd.Env = append(os.Environ(), "SPACEDOCK_BIN="+bridgeAdapterBinary(t)) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("Claude adapter failed: %v\n%s", err, out) } } +func bridgeAdapterBinary(t *testing.T) string { + t.Helper() + bridgeAdapterBinOnce.Do(func() { + dir, err := os.MkdirTemp("", "spacedock-bridge-adapter-bin-*") + if err != nil { + bridgeAdapterBinErr = err + return + } + bridgeAdapterBin = filepath.Join(dir, "spacedock") + cmd := exec.Command("go", "build", "-o", bridgeAdapterBin, "./cmd/spacedock") + cmd.Dir = repoRoot(t) + if out, err := cmd.CombinedOutput(); err != nil { + bridgeAdapterBinErr = err + _ = os.RemoveAll(dir) + bridgeAdapterBin = "" + t.Logf("build output:\n%s", out) + } + }) + if bridgeAdapterBinErr != nil { + t.Fatalf("build spacedock bridge adapter binary: %v", bridgeAdapterBinErr) + } + return bridgeAdapterBin +} + // claudeAdapterRead builds the Claude-Code-shaped PostToolUse/Read payload that Claude Code // pipes to the hook. This CC-specific JSON is the Claude adapter's INPUT only — it is NOT the // contract. The contract is what the adapter emits (asserted below); a future Codex or Pi diff --git a/skills/integration/codex_bridge_egress_hook_test.go b/skills/integration/codex_bridge_egress_hook_test.go new file mode 100644 index 000000000..05f673e9a --- /dev/null +++ b/skills/integration/codex_bridge_egress_hook_test.go @@ -0,0 +1,155 @@ +// ABOUTME: Codex Bridge egress packaging tests — Codex must use its own non-async +// ABOUTME: hooks and a silent wrapper around the shared Spacedock egress command. +package integration + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCodexManifestPointsAtCodexBridgeHooks(t *testing.T) { + manifestPath := filepath.Join(repoRoot(t), ".codex-plugin", "plugin.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read codex manifest: %v", err) + } + + var manifest struct { + Hooks string `json:"hooks"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse codex manifest: %v", err) + } + if manifest.Hooks != "./hooks/codex-hooks.json" { + t.Fatalf("codex manifest hooks = %q, want ./hooks/codex-hooks.json", manifest.Hooks) + } + if manifest.Hooks == "./hooks/hooks.json" { + t.Fatalf("codex manifest must not reuse hooks/hooks.json; that file contains async Claude hooks") + } + + if _, err := os.Stat(filepath.Join(repoRoot(t), strings.TrimPrefix(manifest.Hooks, "./"))); err != nil { + t.Fatalf("codex manifest hooks target is not present: %v", err) + } +} + +func TestCodexBridgeHooksAreNonAsyncAndCallPluginRootWrapper(t *testing.T) { + path := filepath.Join(repoRoot(t), "hooks", "codex-hooks.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read codex hooks: %v", err) + } + + var cfg struct { + Hooks map[string][]struct { + Hooks []map[string]any `json:"hooks"` + } `json:"hooks"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse codex hooks: %v", err) + } + if strings.Contains(string(data), `"async"`) { + t.Fatalf("codex hooks must not contain any async field; Codex skips async command hooks:\n%s", data) + } + + for _, event := range []string{"SessionStart", "UserPromptSubmit", "PostToolUse", "SubagentStart", "SubagentStop", "Stop"} { + groups := cfg.Hooks[event] + if len(groups) == 0 { + t.Fatalf("codex hooks missing %s", event) + } + for _, group := range groups { + if len(group.Hooks) == 0 { + t.Fatalf("codex hook %s has no command handlers", event) + } + for _, handler := range group.Hooks { + if _, ok := handler["async"]; ok { + t.Fatalf("codex hook %s contains async field; Codex skips async command hooks", event) + } + if handler["type"] != "command" { + t.Fatalf("codex hook %s handler type = %v, want command", event, handler["type"]) + } + cmd, ok := handler["command"].(string) + if !ok || cmd == "" { + t.Fatalf("codex hook %s handler has no command: %#v", event, handler) + } + if strings.Contains(cmd, "CLAUDE_PLUGIN_ROOT") { + t.Fatalf("codex hook %s command must not depend on Claude env: %q", event, cmd) + } + for _, want := range []string{"PLUGIN_ROOT", "scripts/codex-bridge-events.sh"} { + if !strings.Contains(cmd, want) { + t.Fatalf("codex hook %s command %q missing %q", event, cmd, want) + } + } + } + } + } +} + +func TestCodexBridgeWrapperCallsSharedEgressEmitterSilently(t *testing.T) { + root := repoRoot(t) + wrapper := filepath.Join(root, "scripts", "codex-bridge-events.sh") + fi, err := os.Stat(wrapper) + if err != nil { + t.Fatalf("codex wrapper missing: %v", err) + } + if fi.Mode()&0o111 == 0 { + t.Fatalf("codex wrapper must be executable: mode %v", fi.Mode()) + } + + binDir := t.TempDir() + logPath := filepath.Join(t.TempDir(), "argv.log") + fake := filepath.Join(binDir, "spacedock") + if err := os.WriteFile(fake, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$SPACEDOCK_FAKE_ARGV_LOG\"\nprintf 'stdout leak\\n'\nprintf 'stderr leak\\n' >&2\nexit 42\n"), 0o755); err != nil { + t.Fatalf("write fake spacedock: %v", err) + } + + cmd := exec.Command("bash", wrapper) + cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"codex-parent-session","cwd":"/repo/spacedock"}`) + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "SPACEDOCK_BIN=", + "SPACEDOCK_FAKE_ARGV_LOG="+logPath, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex wrapper must remain observe-only even when emitter fails: %v\n%s", err, out) + } + if string(out) != "" { + t.Fatalf("codex wrapper must be silent; got %q", out) + } + + argv, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("fake spacedock was not invoked: %v", err) + } + if got, want := strings.TrimSpace(string(argv)), "bridge egress emit --host codex"; got != want { + t.Fatalf("spacedock argv = %q, want %q", got, want) + } +} + +func TestCodexBridgeEgressMinimalPayloadFixture(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "integration", "testdata", "codex", "bridge-egress-minimal-session-start.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read codex fixture: %v", err) + } + + var payload struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Source string `json:"source"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("parse codex fixture: %v", err) + } + if payload.SessionID == "" || payload.CWD == "" || payload.HookEventName != "SessionStart" || payload.Source != "startup" { + t.Fatalf("minimal Codex SessionStart fixture lost required fields: %+v", payload) + } + if strings.Contains(string(data), `"tool_name"`) || strings.Contains(string(data), `"Read"`) { + t.Fatalf("minimal Codex fixture must not imply Read/PostToolUse marker support before live proof:\n%s", data) + } +} diff --git a/skills/integration/testdata/codex/bridge-egress-minimal-session-start.json b/skills/integration/testdata/codex/bridge-egress-minimal-session-start.json new file mode 100644 index 000000000..4ef0df19e --- /dev/null +++ b/skills/integration/testdata/codex/bridge-egress-minimal-session-start.json @@ -0,0 +1,9 @@ +{ + "session_id": "codex-parent-session", + "transcript_path": null, + "cwd": "/repo/spacedock", + "hook_event_name": "SessionStart", + "model": "gpt-5", + "permission_mode": "bypassPermissions", + "source": "startup" +} From 67921878c274d16860d65ab9f6fc55b295a7faa2 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 1 Jul 2026 18:30:09 +0800 Subject: [PATCH 03/21] Normalize Pi bridge egress lifecycle events --- docs/dev/bridge-egress-contract.md | 3 +- internal/bridgeegress/egress.go | 33 ++++++++++++++++++- internal/bridgeegress/egress_test.go | 30 +++++++++++++++++ .../references/pi-first-officer-runtime.md | 2 +- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 3c4b84b7d..36059dba1 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -28,10 +28,11 @@ An empty value is honest, not a bug: the heartbeat is still a valid liveness tic One JSON object per line, appended on each lifecycle event. Liveness, not content: no tool inputs/outputs, no prompt text. ``` -{"timestamp":"","ts":"","host":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","actor_id":"","detail":{"tool":"","source":""}} +{"timestamp":"","ts":"","host":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","actor_id":"","detail":{"tool":"","source":""}} ``` - `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge tells FO-vs-ensign activity apart. +- `event` is normalized by `spacedock bridge egress emit` into Bridge's canonical grammar before it reaches `events.jsonl`: `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Notification`, `Stop`, `SubagentStart`, or `SubagentStop`. Host-native names must not leak into this file; for example Pi `session_shutdown`/`turn_end` normalize to `Stop`, `agent_end` normalizes to `SubagentStop`, and `tool_execution_end` normalizes to `PostToolUse`. - Best-effort size cap: the producer trims to the most recent lines past a bound (it is liveness, not a ledger; a few lost lines to a concurrent trim are acceptable). - → **Claude:** PRESENT — `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` for Claude lifecycle events (all async). The wrapper delegates to the shared `spacedock bridge egress emit --host claude` command so Bridge never couples to Claude's internal transcript format. - → **Codex:** PACKAGED/FIXTURE-COVERED — `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks invoke `scripts/codex-bridge-events.sh`; the wrapper delegates to `spacedock bridge egress emit --host codex`. This proves packaging and minimal lifecycle payload handling, not live marker parity. diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go index 8eb505209..b16269548 100644 --- a/internal/bridgeegress/egress.go +++ b/internal/bridgeegress/egress.go @@ -105,7 +105,7 @@ func Emit(data []byte, opts Options) { } host := firstNonEmpty(opts.Host, p.Host) - eventName := firstNonEmpty(p.Event, p.HookEventName) + eventName := canonicalEventName(host, firstNonEmpty(p.Event, p.HookEventName)) cwd := firstNonEmpty(p.CWD, opts.CWD) if host == "" || eventName == "" || cwd == "" { return @@ -290,6 +290,37 @@ func timestampFor(p payload, opts Options) string { return now().UTC().Format(time.RFC3339) } +func canonicalEventName(host, raw string) string { + if raw == "" { + return "" + } + switch strings.ToLower(host) { + case "pi": + return canonicalPiEventName(raw) + default: + return raw + } +} + +func canonicalPiEventName(raw string) string { + switch strings.ToLower(raw) { + case "session_start": + return "SessionStart" + case "session_shutdown", "turn_end": + return "Stop" + case "agent_start": + return "SubagentStart" + case "agent_end": + return "SubagentStop" + case "turn_start": + return "UserPromptSubmit" + case "tool_execution_start", "tool_execution_end", "tool_call", "tool_result": + return "PostToolUse" + default: + return raw + } +} + func actorIDFor(host, sessionID, agentID, explicit string) string { if explicit != "" { if safeID(explicit) { diff --git a/internal/bridgeegress/egress_test.go b/internal/bridgeegress/egress_test.go index 1b339b37c..1762f2797 100644 --- a/internal/bridgeegress/egress_test.go +++ b/internal/bridgeegress/egress_test.go @@ -189,6 +189,36 @@ func TestEmitExplicitEntityPathCombinesSessionAndAgent(t *testing.T) { } } +func TestEmitNormalizesPiLifecycleEvents(t *testing.T) { + root := t.TempDir() + cases := []struct { + raw string + want string + }{ + {raw: "session_start", want: "SessionStart"}, + {raw: "session_shutdown", want: "Stop"}, + {raw: "agent_start", want: "SubagentStart"}, + {raw: "agent_end", want: "SubagentStop"}, + {raw: "turn_start", want: "UserPromptSubmit"}, + {raw: "turn_end", want: "Stop"}, + {raw: "tool_execution_start", want: "PostToolUse"}, + {raw: "tool_execution_end", want: "PostToolUse"}, + {raw: "tool_call", want: "PostToolUse"}, + {raw: "tool_result", want: "PostToolUse"}, + {raw: "future_pi_event", want: "future_pi_event"}, + } + + for _, tc := range cases { + Emit([]byte(`{"cwd":`+quote(root)+`,"event":`+quote(tc.raw)+`,"session_id":"pi-ses"}`), fixedOptions("pi")) + + var event Event + readLastEvent(t, root, &event) + if event.Host != "pi" || event.Event != tc.want { + t.Fatalf("Pi event %q normalized to host=%q event=%q, want pi/%q", tc.raw, event.Host, event.Event, tc.want) + } + } +} + func TestEmitAppendsAndTruncatesEvents(t *testing.T) { root := t.TempDir() opts := fixedOptions("claude") diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index 111302bad..c05b44cf9 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -25,5 +25,5 @@ The durable proof for Pi support is not transcript phrasing. A valid live proof Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi the event producer is packaged through the Spacedock Pi extension; deterministic session→entity markers are not yet claimed. -- **FO event emission** — PACKAGED/SOURCE-COVERED on Pi: `package.json` advertises `.pi/extensions/spacedock.ts`, and that extension forwards Pi lifecycle event payloads to `spacedock bridge egress emit --host pi`. The package-status gate requires the Spacedock Pi extension as well as the ensign skill, so a skills-only package is not treated as Bridge-egress-capable. This does not prove `_bridge/sessions/` marker parity: Pi marker support requires durable live evidence for child identity plus entity path. +- **FO event emission** — PACKAGED/SOURCE-COVERED on Pi: `package.json` advertises `.pi/extensions/spacedock.ts`, and that extension forwards Pi lifecycle event payloads to `spacedock bridge egress emit --host pi`. The shared emitter normalizes Pi-native lifecycle names (`session_shutdown`, `agent_end`, `turn_end`, `tool_execution_end`, etc.) into Bridge's canonical event grammar before writing `_bridge/events.jsonl`. The package-status gate requires the Spacedock Pi extension as well as the ensign skill, so a skills-only package is not treated as Bridge-egress-capable. This does not prove `_bridge/sessions/` marker parity: Pi marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`; on Pi this stays empty (no stable per-session id is exposed), so the heartbeat carries an empty session id — still a valid liveness tick (Bridge reads freshness from `ts`). Bind it once Pi exposes a stable per-session id. From 4932c8960550b43b21f260771950194b7e69f6ed Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 1 Jul 2026 19:52:45 +0800 Subject: [PATCH 04/21] Document Bridge reply loop contract Signed-off-by: Jared Scott --- docs/dev/_mods/bridge-inbox.md | 53 ++++++++--- docs/dev/bridge-egress-contract.md | 26 ++++-- .../fo_feed_and_eager_drain_test.go | 88 +++++++++++++++++++ .../references/first-officer-shared-core.md | 4 +- 4 files changed, 151 insertions(+), 20 deletions(-) diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 447a9b23d..d92b23a4f 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -1,15 +1,15 @@ --- name: bridge-inbox -description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl), routing per-workflow, and write the FO liveness heartbeat each tick -version: 0.2.0 +description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl), route per-workflow, acknowledge in _bridge/fo-replies.jsonl, and write the FO liveness heartbeat each tick +version: 0.3.0 fo-realm: "FO realm — the FO maintains this file directly; it is FO process (the seam to the Bridge UI), not product built under the dev workflow." --- # Bridge Inbox -[Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, and writes a per-workflow liveness heartbeat Bridge reads. +[Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, writes a per-workflow liveness heartbeat Bridge reads, and appends best-effort explanatory replies/acks to `_bridge/fo-replies.jsonl`. -**Path alignment (load-bearing).** Bridge anchors the inbox, heartbeat, and feed on its `--repo-root` flag, falling back to `--fleet` only when `--repo-root` is unset. So intent reaches this FO **only when the captain launches Bridge with `--repo-root` pointing at this FO's cwd** (the repo root). Under a multi-workflow layout (`--fleet /docs/spacedock` with no `--repo-root`), Bridge would write to `/docs/spacedock/_bridge/` while the FO reads `/_bridge/` — and intent silently never arrives. The dir is the same `_bridge/` Bridge resolves *iff* the two roots agree. +**Path alignment (load-bearing).** Bridge anchors the inbox, heartbeat, feed, and reply stream on its `--repo-root` flag, falling back to `--fleet` only when `--repo-root` is unset. So intent reaches this FO **only when the captain launches Bridge with `--repo-root` pointing at this FO's cwd** (the repo root). Under a multi-workflow layout (`--fleet /docs/spacedock` with no `--repo-root`), Bridge would write to `/docs/spacedock/_bridge/` while the FO reads `/_bridge/` — and intent silently never arrives. The dir is the same `_bridge/` Bridge resolves *iff* the two roots agree. **This is a pull, not a push.** Delivery latency is one FO loop cadence: the captain's intent is read whenever the FO next idles or boots, never instantly. Bridge's UI says as much ("queued — read on the FO's next tick"); do not promise synchronous delivery. @@ -26,10 +26,16 @@ esac **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"ts": "", "kind": "tell" | "conn" | "decision", "text": "", "granted": , "target": "" | "all", "entity": "", "field": "", "value": ""} +{"id":"","ts":"","kind":"tell"|"conn"|"decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":""} ``` -`target` routes the intent. Act on a record only when `target == "$SLUG"` **or** `target == "all"`; a **missing/empty `target` means `all`** (backward-compatible with older Bridge records, which carried no target). A record targeted at another workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. +`id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set`, a frozen array of workflow slugs expected to drain and acknowledge this intent. For `target == "all"`, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. `target` remains for backward compatibility with older records. + +Routing is: + +- If `target_set` is present, act only when `"$SLUG"` is in `target_set`. Ignore `target` entirely for routing in that case, including `target == "all"`; the frozen `target_set` is authoritative. +- If `target_set` is absent, preserve old target behavior: act when `target == "$SLUG"`, `target == "all"`, or `target` is missing/empty. +- A record not addressed to this workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. **Consume by a per-workflow cursor, never by rewrite.** Bridge only appends to the one shared `inbox.jsonl`; this mod advances only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed). Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and re-firing with no new lines is a no-op. @@ -55,7 +61,7 @@ printf '{"session_id":"%s","ts":"%s","state":"idle"}\n' \ "${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json ``` -`SD_SESSION_ID` is the host-neutral session-id token owned by your runtime adapter's **Bridge egress** binding; the snippet reads it first and falls back to the host's own session var (`$CLAUDE_CODE_SESSION_ID` on Claude, `$CODEX_THREAD_ID` on Codex) so the heartbeat never silently blanks, with no per-tick `export` needed. An empty value (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. +`SD_SESSION_ID` is the host-neutral session-id token owned by your runtime adapter's **Bridge egress** binding; the snippet reads it first and falls back to the host's own session var (`$CLAUDE_CODE_SESSION_ID` on Claude, `$CODEX_THREAD_ID` on Codex) so the heartbeat never silently blanks, with no per-tick `export` needed. An empty value (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, `fo-replies.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. `state` is `idle`: this mod runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. Writing the heartbeat is observe-only; never let it block the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) @@ -71,20 +77,41 @@ Drain newly-queued captain intent addressed to this workflow, if any: fi CURSOR=$(cat _bridge/.inbox-cursor.$SLUG 2>/dev/null || echo 0) ``` -3. Snapshot the current line count and read exactly the new records (bounding the read so a concurrent Bridge append can't make the cursor skip a line): +3. Snapshot the current line count and read exactly the new records by physical line number (bounding the read so a concurrent Bridge append can't make the cursor skip a line): ``` NEW=$(wc -l < _bridge/inbox.jsonl | tr -d ' ') sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl ``` If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, parse `kind` / `text` / `granted` / `target` (and `entity` / `field` / `value` on a `decision` record). **Check the target first:** if `target` is present and is neither `"$SLUG"` nor `"all"`, this record is for another workflow's FO — skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise (target is `"$SLUG"`, `"all"`, or absent) act: - - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and acknowledge it to the captain. - - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). `granted: false` → take the conn back: stop at every gate for the captain's call again. - - **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. If `entity` does not resolve in this workflow (it belongs to another member's slug), skip it like a mismatched target. Acknowledge to the captain which entity you resolved and how. +4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` / `field` / `value` on a `decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: + - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and append a `reply` record with `status:"answered"`. + - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). Then append a `conn-ack` record with `status:"accepted"`. `granted: false` → take the conn back: stop at every gate for the captain's call again. Then append a `conn-ack` record with `status:"released"`. + - **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. Append a `decision-ack` record with `status:"applied"` when the field value is present and gate resolution finished or was already satisfied; append `status:"blocked"` when the intent is valid but execution could not finish; append `status:"rejected"` when the intent is invalid or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. 5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`. 6. Report to the captain: how many intents you drained (for this workflow) and what you did with each. -If a record is malformed (not valid JSON, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record. +If a record is malformed (not valid JSON, missing required fields for its `kind`, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record. Append a rejected ack only when the record is addressed to `"$SLUG"` and has enough valid metadata to produce a valid reply shape: an `id`, `ts`, and a known `kind` that determines `reply`/`conn-ack`/`decision-ack` plus `intent_kind`. Unknown-kind or unrouteable records cannot be represented by the reply schema; report the skip to the captain, advance the cursor, and do not invent an id or invalid ack kind. + +### Replies / acks + +Append one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected: + +``` +{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision","status":"answered"|"accepted"|"released"|"applied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","session_id":"optional","host":"optional"} +``` + +Rules: + +- `target` is the actual acknowledging workflow slug (`$SLUG`), never `"all"`. +- `in_reply_to_line` is the physical inbox line number you processed, not the count of addressed records. +- `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts`. +- `intent_kind` echoes the inbox `kind`. +- `text` is optional but, when present, must be a single line. +- Echo `granted`, `entity`, `field`, and `value` when present and relevant. +- `session_id` should be `"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}"` when non-empty; `host` is optional. +- Write one complete newline-terminated JSON object in one append operation, e.g. build the full JSON line with a structured encoder and append it with a single `>> _bridge/fo-replies.jsonl` write. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl`. +- Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed reply append must never block the FO from completing the drained intent or advancing the cursor after action. +- Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. Still prefer idempotent behavior. **Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (step 5 follows step 4), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acting and writing the cursor re-delivers that batch on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed above, which guards against re-applying the *entire* pre-versioning history.) diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 36059dba1..a5b9eaff2 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -1,13 +1,13 @@ -# Bridge egress contract (FO liveness → Bridge) +# Bridge egress contract (FO liveness/replies → Bridge) The Bridge seam is two flows over the shared `_bridge/` dir (resolved from the repo root where the FO launched). They run in opposite directions and are documented apart: -- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. -- **Egress** — FO liveness/activity → Bridge — is *this* contract: the four `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. +- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. Bridge writes every new intent with an opaque `id` and, on current records, a frozen `target_set` array of workflow slugs expected to drain and acknowledge the intent. For `target == "all"`, Bridge expands the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. When `target_set` is present it is authoritative, and the legacy `target` field is ignored for routing; `target` remains only for backward compatibility with records that predate `target_set`. +- **Egress** — FO liveness/activity/replies → Bridge — is *this* contract: the `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. -**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat and feed are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. The current implementation uses the shared `spacedock bridge egress emit --host ` command for host normalization. Claude, Codex, and Pi have packaged event producers; deterministic session→entity marker parity is proven only for Claude so far. +**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat, feed, and FO reply stream are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. The current implementation uses the shared `spacedock bridge egress emit --host ` command for host normalization. Claude, Codex, and Pi have packaged event producers; deterministic session→entity marker parity is proven only for Claude so far. -All four files are gitignored session runtime (`.gitignore` carries `_bridge/`), append-only or last-write where noted, and strictly observe-only: a telemetry side-channel must never block, fail, or alter the FO. +All files are gitignored session runtime (`.gitignore` carries `_bridge/`), append-only or last-write where noted, and strictly observe-only: a telemetry side-channel must never block, fail, or alter the FO. ## Session-id binding `«session-id»` @@ -60,6 +60,22 @@ One JSON object per line, appended when the FO dispatches, advances, or complete - `verb`: `dispatch` (sent an ensign to a stage), `advance` (moved an entity to its next stage), `complete` (entity reached terminal). - → **All hosts (host-neutral producer):** the `bridge-inbox` mod's feed step appends it. No per-host binding — it is FO-loop narration, not a host event. +## `_bridge/fo-replies.jsonl` — captain-intent acknowledgements + +One JSON object per line, appended after the FO has interpreted, accepted, or applied an inbox intent. This is the explanatory reply/ack stream for Bridge's conversation loop; the per-workflow inbox cursor remains the delivery/read source of truth. Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. The stream is best-effort explanatory content, not an exactly-once delivery ledger. + +``` +{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","session_id":"optional","host":"optional"} +``` + +- `target` is the actual acknowledging workflow slug, never `all`. +- `in_reply_to_line` is the physical line number in `_bridge/inbox.jsonl` that produced the ack. +- `kind`: `reply` for `tell`, `conn-ack` for `conn`, `decision-ack` for `decision`. +- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant; `released` when the FO gives the conn back; `applied` when a decision field value is present and gate resolution finished or was already satisfied; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. +- Echo `granted`, `entity`, `field`, and `value` when present and relevant to the intent. Keep `text` one line. +- Append-only and best-effort: write one complete newline-terminated JSON object in one append operation, and never rewrite `fo-replies.jsonl`. +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod appends replies/acks while draining `_bridge/inbox.jsonl`. + ## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) Last-write (first-write-wins per session), one file per live working session. Maps a session id to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go index a3d4e1f53..896a9b4a7 100644 --- a/internal/contractlint/fo_feed_and_eager_drain_test.go +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -55,3 +55,91 @@ func TestFOFeedNarration(t *testing.T) { } } } + +// TestBridgeConversationReplyContract locks the full Bridge conversation loop: +// Bridge writes stable intent ids and a frozen target_set, and the FO writes a +// post-action reply/ack line to _bridge/fo-replies.jsonl. +func TestBridgeConversationReplyContract(t *testing.T) { + modPath := filepath.Join(repoRoot(t), "docs", "dev", "_mods", "bridge-inbox.md") + modData, err := os.ReadFile(modPath) + if err != nil { + t.Fatalf("read bridge-inbox mod: %v", err) + } + mod := string(modData) + for _, r := range []string{ + `"id":""`, + `"target_set":["", "..."]`, + `If ` + "`target_set`" + ` is present, act only when ` + "`\"$SLUG\"`" + ` is in ` + "`target_set`", + `Ignore ` + "`target`" + ` entirely for routing in that case, including ` + "`target == \"all\"`", + `If ` + "`target_set`" + ` is absent, preserve old target behavior`, + `physical ` + "`LINE`" + ` number`, + `not merely after shell-reading the line`, + `_bridge/fo-replies.jsonl`, + `"kind":"reply"|"conn-ack"|"decision-ack"`, + `"in_reply_to_line":123`, + `"status":"answered"|"accepted"|"released"|"applied"|"rejected"|"blocked"`, + `target` + "` is the actual acknowledging workflow slug (`$SLUG`), never " + "`\"all\"`", + `Cursor remains the delivery/read source of truth`, + `Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind`, + `one complete newline-terminated JSON object in one append operation`, + `Do not rewrite, truncate, sort, or compact ` + "`fo-replies.jsonl`", + `Append a rejected ack only when the record is addressed to ` + "`\"$SLUG\"`" + ` and has enough valid metadata to produce a valid reply shape`, + `Unknown-kind or unrouteable records cannot be represented by the reply schema`, + } { + if !strings.Contains(mod, r) { + t.Errorf("bridge-inbox mod no longer pins the reply-loop contract: missing %q.", r) + } + } + + contractPath := filepath.Join(repoRoot(t), "docs", "dev", "bridge-egress-contract.md") + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatalf("read bridge egress contract: %v", err) + } + contract := string(contractData) + for _, r := range []string{ + `Bridge writes every new intent with an opaque ` + "`id`", + `frozen ` + "`target_set`" + ` array of workflow slugs`, + `When ` + "`target_set`" + ` is present it is authoritative`, + `## ` + "`_bridge/fo-replies.jsonl`" + ` — captain-intent acknowledgements`, + `"schema":1`, + `"target":""`, + `"intent_kind":""`, + `"status":""`, + `target` + "` is the actual acknowledging workflow slug, never " + "`all`", + `applied` + "` when a decision field value is present and gate resolution finished or was already satisfied", + `blocked` + "` when a valid intent could not finish", + `rejected` + "` when an intent is invalid or unresolvable", + `not an exactly-once delivery ledger`, + `Append-only and best-effort: write one complete newline-terminated JSON object in one append operation`, + } { + if !strings.Contains(contract, r) { + t.Errorf("bridge egress contract no longer pins the reply-loop contract: missing %q.", r) + } + } +} + +// TestFleetModeMentionsFrozenTargetSet guards the high-level FO routing guide: +// fleet-mode drains must obey Bridge's frozen target_set before falling back to +// legacy target behavior. +func TestFleetModeMentionsFrozenTargetSet(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read first-officer-shared-core: %v", err) + } + c := string(data) + for _, r := range []string{ + `frozen ` + "`target_set`", + `act only when the member's ` + "`$SLUG`" + ` is in ` + "`target_set`", + `_bridge/fo-replies.jsonl`, + `with ` + "`target`" + ` set to the actual member slug, never ` + "`all`", + `Legacy records without ` + "`target_set`" + ` keep old ` + "`target`" + ` behavior`, + `Claude, Codex, and Pi have packaged event producers`, + `deterministic session→entity marker parity remains Claude-proven only`, + } { + if !strings.Contains(c, r) { + t.Errorf("fleet-mode routing guide no longer pins target_set/ack behavior: missing %q.", r) + } + } +} diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 98a3efe3a..4aa21f87d 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -27,7 +27,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg - **STATE_BACKEND** — `split-root` or `single-root`, the resolved entity dir, and whether it is present. 6. `«state.ensure-ready»()` — converge the split-root checkout to linked-and-integrated before any dispatch (the halt-gate + the pull-on-boot). A single-root workflow is a no-op. 7. `«state.sweep-merged»()` — advance every merged-PR entity to terminal at boot, before the greet. The common boot (no merged PR) reads zero mod files. -7b. **Bridge liveness heartbeat (before-greet).** When the MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its `## Hook: startup` BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat (present-time UTC) and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots, not only after the first dispatch. This is boot liveness, NOT event-loop work: a greet-and-stop boot (interactive step 8, which never enters the event loop) STILL runs it, unlike the deferred comm-officer spawn. The heartbeat write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` makes the drain a no-op. In fleet mode this runs once per member, keyed by each member's `$SLUG`/`{workflow_dir}` (see `## Fleet Mode`). Skip when no `startup` bridge-inbox hook is registered — the common boot reads zero mod files. The heartbeat's `session_id` — and the richer `_bridge/events.jsonl` stream / session→entity markers it joins to — is a per-host **Bridge egress** capability defined in the active runtime adapter: PRESENT on Claude (the plugin hooks emit the event stream; the heartbeat resolves `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude), ABSENT on Codex/Pi (liveness rests on this heartbeat alone). The heartbeat falls back across hosts automatically, so no manual step is needed (full surface: `docs/dev/bridge-egress-contract.md`). +7b. **Bridge liveness heartbeat (before-greet).** When the MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its `## Hook: startup` BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat (present-time UTC) and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots, not only after the first dispatch. This is boot liveness, NOT event-loop work: a greet-and-stop boot (interactive step 8, which never enters the event loop) STILL runs it, unlike the deferred comm-officer spawn. The heartbeat write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` makes the drain a no-op. In fleet mode this runs once per member, keyed by each member's `$SLUG`/`{workflow_dir}` (see `## Fleet Mode`). Skip when no `startup` bridge-inbox hook is registered — the common boot reads zero mod files. The heartbeat's `session_id` — and the richer `_bridge/events.jsonl` stream / session→entity markers it joins to — is a per-host **Bridge egress** capability defined in the active runtime adapter: Claude, Codex, and Pi have packaged event producers, while deterministic session→entity marker parity remains Claude-proven only. The heartbeat resolves `SD_SESSION_ID` first and falls back across host session ids automatically, so liveness stays host-neutral and no manual step is needed (full surface: `docs/dev/bridge-egress-contract.md`). 8. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary (boot JSON + README frontmatter) as today, including `gh`-absent UNKNOWN PR status. - **Interactive:** present the summary (and any ready `gate: true` gate as captain-facing text), then STOP for input; do NOT auto-dispatch. The expensive deferrals stay past the greet, reached on the captain's first direction. - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. @@ -93,7 +93,7 @@ An opt-in mode for driving MULTIPLE commissioned workflows from ONE session — - **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. A member's per-workflow `## Hook: startup` mods fire once per member, keyed by that member's `$SLUG`: bridge-inbox's boot heartbeat + initial drain run at the before-greet Bridge liveness heartbeat step (Startup step 7b) for EACH member, so every member shows live in Bridge's roster from boot — even in a greet-and-stop launch, not only after the first dispatch. - **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 8). Headless: drive every member's dispatchables per step 8; "given the conn" resolves gates across the members the grant names. - **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. -- **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. A drained record's `target` selects scope: `all` (or absent) is drained by EVERY member's cursor (fleet-wide — so a fleet-wide `tell` is acknowledged once per member, a fleet-wide `conn` grant adopts the conn for each member's entities); a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. +- **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. Current Bridge records carry a frozen `target_set`: act only when the member's `$SLUG` is in `target_set`, and append that member's ack/reply to `_bridge/fo-replies.jsonl` with `target` set to the actual member slug, never `all`. Legacy records without `target_set` keep old `target` behavior: `all` (or absent/empty) is drained by EVERY member's cursor, while a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. - **Write scope and gates are unchanged.** Each member's entities, gates, `## Stage Report` review, and FO write scope are exactly as in single-workflow mode, scoped by the member's `{workflow_dir}`. ## Working Directory From dc2e884f409c05a4d81c1b167c41bd34bcd7eb74 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 1 Jul 2026 20:10:18 +0800 Subject: [PATCH 05/21] Clarify unknown broadcast target routing Signed-off-by: Jared Scott --- docs/dev/_mods/bridge-inbox.md | 2 +- docs/dev/bridge-egress-contract.md | 2 +- internal/contractlint/fo_feed_and_eager_drain_test.go | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index d92b23a4f..3ef0782a1 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -29,7 +29,7 @@ esac {"id":"","ts":"","kind":"tell"|"conn"|"decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":""} ``` -`id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set`, a frozen array of workflow slugs expected to drain and acknowledge this intent. For `target == "all"`, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. `target` remains for backward compatibility with older records. +`id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set` when Bridge can freeze a recipient set: for `target == "all"` with known fleet members, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. `target` remains for backward compatibility with older records and unknown-recipient broadcasts. Routing is: diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index a5b9eaff2..c5d1c58c2 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -2,7 +2,7 @@ The Bridge seam is two flows over the shared `_bridge/` dir (resolved from the repo root where the FO launched). They run in opposite directions and are documented apart: -- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. Bridge writes every new intent with an opaque `id` and, on current records, a frozen `target_set` array of workflow slugs expected to drain and acknowledge the intent. For `target == "all"`, Bridge expands the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. When `target_set` is present it is authoritative, and the legacy `target` field is ignored for routing; `target` remains only for backward compatibility with records that predate `target_set`. +- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. Bridge writes every new intent with an opaque `id` and writes a frozen `target_set` array when it can resolve the workflow slugs expected to drain and acknowledge the intent. For `target == "all"` with known fleet members, Bridge expands the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. When `target_set` is present it is authoritative, and the legacy `target` field is ignored for routing; `target` remains only for backward compatibility with records that predate `target_set` and unknown-recipient broadcasts. - **Egress** — FO liveness/activity/replies → Bridge — is *this* contract: the `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. **The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat, feed, and FO reply stream are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. The current implementation uses the shared `spacedock bridge egress emit --host ` command for host normalization. Claude, Codex, and Pi have packaged event producers; deterministic session→entity marker parity is proven only for Claude so far. diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go index 896a9b4a7..8b08ca9d2 100644 --- a/internal/contractlint/fo_feed_and_eager_drain_test.go +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -69,6 +69,7 @@ func TestBridgeConversationReplyContract(t *testing.T) { for _, r := range []string{ `"id":""`, `"target_set":["", "..."]`, + `If Bridge cannot resolve any current member slugs for a broadcast, it omits ` + "`target_set`", `If ` + "`target_set`" + ` is present, act only when ` + "`\"$SLUG\"`" + ` is in ` + "`target_set`", `Ignore ` + "`target`" + ` entirely for routing in that case, including ` + "`target == \"all\"`", `If ` + "`target_set`" + ` is absent, preserve old target behavior`, @@ -99,7 +100,8 @@ func TestBridgeConversationReplyContract(t *testing.T) { contract := string(contractData) for _, r := range []string{ `Bridge writes every new intent with an opaque ` + "`id`", - `frozen ` + "`target_set`" + ` array of workflow slugs`, + `writes a frozen ` + "`target_set`" + ` array when it can resolve the workflow slugs`, + `unknown-recipient broadcasts`, `When ` + "`target_set`" + ` is present it is authoritative`, `## ` + "`_bridge/fo-replies.jsonl`" + ` — captain-intent acknowledgements`, `"schema":1`, From 302157e56b2c0cb91a55dbad86ff82a92e8f5ae4 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 1 Jul 2026 20:34:08 +0800 Subject: [PATCH 06/21] Address Copilot review on bridge egress Signed-off-by: Jared Scott --- docs/dev/bridge-egress-contract.md | 6 ++-- internal/bridgeegress/egress.go | 6 +++- internal/bridgeegress/egress_test.go | 35 +++++++++++++++++++ .../integration/bridge_session_link_test.go | 34 ++++-------------- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index c5d1c58c2..4e5be94d0 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -76,12 +76,12 @@ One JSON object per line, appended after the FO has interpreted, accepted, or ap - Append-only and best-effort: write one complete newline-terminated JSON object in one append operation, and never rewrite `fo-replies.jsonl`. - → **All hosts (host-neutral producer):** the `bridge-inbox` mod appends replies/acks while draining `_bridge/inbox.jsonl`. -## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) +## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) -Last-write (first-write-wins per session), one file per live working session. Maps a session id to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. +Last-write (first-write-wins per host actor), one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. ``` -{"session_id":"<«session-id»>","entity":"","workflow":""} +{"host":"","session_id":"<«session-id»>","agent_id":"","actor_id":"","entity":"","workflow":""} ``` - The `entity`/`workflow` pair is derived from the ensign's first Read of its entity file under `docs/spacedock//.md` (flat or `/index.md`); the path carries both the workflow (so Bridge's join is collision-free across workflows reusing a ticket id) and the slug. First-write-wins records the ensign's own entity, read before any duplicate-check sibling read. Archived (`_archive/`) entities are never marked. diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go index b16269548..e3438d2fd 100644 --- a/internal/bridgeegress/egress.go +++ b/internal/bridgeegress/egress.go @@ -104,7 +104,7 @@ func Emit(data []byte, opts Options) { return } - host := firstNonEmpty(opts.Host, p.Host) + host := normalizeHost(firstNonEmpty(opts.Host, p.Host)) eventName := canonicalEventName(host, firstNonEmpty(p.Event, p.HookEventName)) cwd := firstNonEmpty(p.CWD, opts.CWD) if host == "" || eventName == "" || cwd == "" { @@ -302,6 +302,10 @@ func canonicalEventName(host, raw string) string { } } +func normalizeHost(host string) string { + return strings.ToLower(strings.TrimSpace(host)) +} + func canonicalPiEventName(raw string) string { switch strings.ToLower(raw) { case "session_start": diff --git a/internal/bridgeegress/egress_test.go b/internal/bridgeegress/egress_test.go index 1762f2797..ee2d4c300 100644 --- a/internal/bridgeegress/egress_test.go +++ b/internal/bridgeegress/egress_test.go @@ -50,6 +50,41 @@ func TestEmitWritesEventSchemaAndClaudeMarker(t *testing.T) { } } +func TestEmitNormalizesHostName(t *testing.T) { + root := t.TempDir() + entityPath := filepath.Join(root, "docs", "spacedock", "dev", "task.md") + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "hook_event_name":"PostToolUse", + "session_id":"ses-1", + "agent_type":"spacedock:ensign", + "tool_name":"Read", + "tool_input":{"file_path":`+quote(entityPath)+`} + }`), fixedOptions("Claude")) + + var event Event + readLastEvent(t, root, &event) + if event.Host != "claude" || event.ActorID != "ses-1" { + t.Fatalf("event identity = %+v, want normalized claude host and Claude actor id", event) + } + var marker Marker + readMarker(t, root, "ses-1", &marker) + if marker.Host != "claude" || marker.ActorID != "ses-1" { + t.Fatalf("marker identity = %+v, want normalized claude host and marker", marker) + } +} + +func TestEmitNormalizesPayloadHostName(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{"cwd":`+quote(root)+`,"host":"Pi","event":"turn_end","session_id":"pi-ses"}`), fixedOptions("")) + + var event Event + readLastEvent(t, root, &event) + if event.Host != "pi" || event.Event != "Stop" { + t.Fatalf("event = %+v, want normalized pi/Stop", event) + } +} + func TestEmitMalformedOrIncompletePayloadNoops(t *testing.T) { root := t.TempDir() for _, input := range [][]byte{ diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index 612e777b6..716bfc84e 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -10,16 +10,9 @@ import ( "os/exec" "path/filepath" "strings" - "sync" "testing" ) -var ( - bridgeAdapterBin string - bridgeAdapterBinErr error - bridgeAdapterBinOnce sync.Once -) - // runClaudeAdapter feeds ONE Claude-Code-shaped hook payload — the Claude adapter's input — // to scripts/spacedock-bridge-events.sh (the Claude binding of the egress producer) and // returns once it exits. cwd anchors the _bridge/ dir the adapter writes to. @@ -39,26 +32,13 @@ func runClaudeAdapter(t *testing.T, payload string) { func bridgeAdapterBinary(t *testing.T) string { t.Helper() - bridgeAdapterBinOnce.Do(func() { - dir, err := os.MkdirTemp("", "spacedock-bridge-adapter-bin-*") - if err != nil { - bridgeAdapterBinErr = err - return - } - bridgeAdapterBin = filepath.Join(dir, "spacedock") - cmd := exec.Command("go", "build", "-o", bridgeAdapterBin, "./cmd/spacedock") - cmd.Dir = repoRoot(t) - if out, err := cmd.CombinedOutput(); err != nil { - bridgeAdapterBinErr = err - _ = os.RemoveAll(dir) - bridgeAdapterBin = "" - t.Logf("build output:\n%s", out) - } - }) - if bridgeAdapterBinErr != nil { - t.Fatalf("build spacedock bridge adapter binary: %v", bridgeAdapterBinErr) - } - return bridgeAdapterBin + bin := filepath.Join(t.TempDir(), "spacedock") + cmd := exec.Command("go", "build", "-o", bin, "./cmd/spacedock") + cmd.Dir = repoRoot(t) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build spacedock bridge adapter binary: %v\n%s", err, out) + } + return bin } // claudeAdapterRead builds the Claude-Code-shaped PostToolUse/Read payload that Claude Code From 58c65f1584e6bcd2e4fb6dd4eb770671080da307 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 10:01:04 +0800 Subject: [PATCH 07/21] Keep Codex plugin-dir out of launch argv Signed-off-by: Jared Scott --- internal/cli/frontdoor.go | 74 ++++++++++++--------- internal/cli/frontdoor_parse_test.go | 49 ++++++++------ internal/cli/frontdoor_stray_prompt_test.go | 42 ++++++------ internal/cli/help.go | 9 +-- internal/cli/launch_parity_test.go | 7 +- internal/cli/plugin_dir_frontdoor_test.go | 23 +++++++ 6 files changed, 119 insertions(+), 85 deletions(-) diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index 19522966c..bee6ac4fd 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -310,7 +310,7 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look // A `--plugin-dir` launch loads the LOCAL plugin checkout, so the installed // plugin's contract verdict is irrelevant — it relaxes the gate exactly like // an explicit `--skip-contract-check`. - if !fd.skipCheck && !hasPluginDir(fd.passthrough) { + if !fd.skipCheck && !hasFrontDoorPluginDir(fd) { switch gateHost(ops, "claude", stderr) { case contract.Compatible: // proceed to launch @@ -357,6 +357,7 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look if !wrap && !resume && !passthroughHasFlag(fd.passthrough, "--permission-mode") { inner = append(inner, "--permission-mode", "auto") } + inner = append(inner, pluginDirArgs(fd.pluginDirs)...) inner = append(inner, fd.passthrough...) if !resume { inner = append(inner, launchPrompt(bootstrapPrompt, fd)) @@ -416,6 +417,10 @@ func launchPrompt(base string, fd frontDoorArgs) string { // hasPluginDir reports whether the host passthrough carries a `--plugin-dir` // flag (either `--plugin-dir P` or `--plugin-dir=P`). Its presence relaxes the // contract gate (the local checkout supersedes the installed plugin). +func hasFrontDoorPluginDir(fd frontDoorArgs) bool { + return len(fd.pluginDirs) > 0 || hasPluginDir(fd.passthrough) +} + func hasPluginDir(passthrough []string) bool { for _, a := range passthrough { if a == "--plugin-dir" || strings.HasPrefix(a, "--plugin-dir=") { @@ -425,6 +430,17 @@ func hasPluginDir(passthrough []string) bool { return false } +func pluginDirArgs(dirs []string) []string { + if len(dirs) == 0 { + return nil + } + args := make([]string, 0, len(dirs)*2) + for _, d := range dirs { + args = append(args, "--plugin-dir", d) + } + return args +} + // passthroughHasFlag reports whether the operator already supplied any of the // named host flags in the passthrough, in either `--flag value` or `--flag=value` // form. The unsandboxed launchers consult it before injecting their default @@ -495,7 +511,7 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP // (NoPluginFound) auto-installs the codex plugin and proceeds to launch so the // single command the user typed yields a working session — `--no-install` opts // out, preserving the refuse-and-instruct behavior. This mirrors runClaude. - if !fd.skipCheck && !hasPluginDir(fd.passthrough) { + if !fd.skipCheck && !hasFrontDoorPluginDir(fd) { switch gateHost(ops, "codex", stderr) { case contract.Compatible: // proceed to launch @@ -540,6 +556,9 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP if !wrap && !resume && !passthroughHasFlag(fd.passthrough, "--ask-for-approval", "-a") { inner = append(inner, "--ask-for-approval", "on-request") } + // Codex does not expose a launch-time --plugin-dir flag. Before-`--` + // plugin dirs are Spacedock dev overrides only; explicit after-`--` tokens + // still forward as operator-owned Codex argv. inner = append(inner, fd.passthrough...) if !resume { inner = append(inner, launchPrompt(codexBootstrapPrompt, fd)) @@ -578,9 +597,8 @@ func codexResume(passthrough []string) bool { // valueTakingHostFlags is the per-host set of host flags whose successor token is // the flag's value (space form), so that successor is NOT a stray positional. The // assembled argv is unchanged regardless of membership; the set only tunes the -// advisory's accuracy. The spacedock-injected `--plugin-dir ` prefix is NOT -// handled here — skipInjectedPrefix strips it structurally before any scan — so -// the prefix interaction stays in one place rather than threaded through this set. +// advisory's accuracy. Explicit after-`--` `--plugin-dir ` tokens are handled +// structurally by skipPluginDirPrefix rather than threaded through this set. var valueTakingHostFlags = map[string]map[string]bool{ "claude": { "-p": true, "--print": true, @@ -614,17 +632,11 @@ var leadingHostSubcommands = map[string]map[string]bool{ "codex": {"exec": true, "resume": true}, } -// skipInjectedPrefix returns the passthrough slice past the spacedock-injected -// leading `--plugin-dir ` pairs. parseFrontDoorArgs re-prepends each -// before-`--` `--plugin-dir` as a `--plugin-dir ` pair at the FRONT of -// fd.passthrough; that prefix is spacedock-owned, not operator after-`--` tokens, -// so the classifier's subcommand and value-flag checks must run against the real -// after-`--` tokens BEHIND it. `--plugin-dir` is the only flag parseFrontDoorArgs -// re-prepends (the safehouse knobs live in fd.safehouseFlags, the booleans are -// consumed), so it is the complete injected-prefix set. Skipping a leading -// `--plugin-dir ` pair is correct regardless of origin: the dir is the flag's -// value, never a stray prompt. -func skipInjectedPrefix(passthrough []string) []string { +// skipPluginDirPrefix returns the passthrough slice past leading explicit +// `--plugin-dir ` pairs. Those pairs are host flag/value tokens, so the +// classifier's subcommand and value-flag checks should run against the tokens +// behind them; the dir itself is the flag's value, never a stray prompt. +func skipPluginDirPrefix(passthrough []string) []string { for len(passthrough) >= 2 && passthrough[0] == "--plugin-dir" { passthrough = passthrough[2:] } @@ -639,11 +651,9 @@ func skipInjectedPrefix(passthrough []string) []string { // // It fires only when the operator gave no task before `--` (hasTask == false): a // task before `--` means the operator already placed their prompt, so a positional -// after `--` is a deliberate host positional. The classifier first skips the -// spacedock-injected leading `--plugin-dir ` prefix, then runs every check -// against the real after-`--` tokens — so the subcommand exemption, the value-flag -// scan, and any future per-token rule all see the operator's actual grammar -// regardless of the injected prefix. A token is a candidate when it is non-flag +// after `--` is a deliberate host positional. The classifier first skips leading +// explicit `--plugin-dir ` pairs, then runs every check against the remaining +// after-`--` tokens. A token is a candidate when it is non-flag // (does not start with `-`, and is not the bare `--` separator) AND the real tokens // do not lead with a known host subcommand whose arguments are legitimate. A // candidate is reported as stray only when we can be confident it is NOT a host @@ -660,7 +670,7 @@ func strayPromptAfterDash(fd frontDoorArgs, host string) (positional string, ok if fd.hasTask { return "", false } - tokens := skipInjectedPrefix(fd.passthrough) + tokens := skipPluginDirPrefix(fd.passthrough) subcommands := leadingHostSubcommands[host] if len(tokens) > 0 && subcommands[tokens[0]] { return "", false @@ -693,6 +703,8 @@ func strayPromptAfterDash(fd frontDoorArgs, host string) (positional string, ok type frontDoorArgs struct { // passthrough is the host-only argv (claude/codex flags), in operator order. passthrough []string + // pluginDirs are Spacedock-owned dev override checkouts parsed before `--`. + pluginDirs []string // task is the launch-prompt override (the bare text after the `--` fence); // hasTask distinguishes an explicit empty task from "no fence given". task string @@ -795,18 +807,14 @@ func parseFrontDoorArgs(args []string) (fd frontDoorArgs, err error) { fd.hasTask = true } - // --plugin-dir is the one host flag spacedock parses before `--`: pflag knows - // its arity (one value, repeatable), so the dirs are captured correctly in - // space/equals/repeated forms. Re-inject each as a `--plugin-dir ` pair at - // the FRONT of passthrough so it forwards to the host and hasPluginDir sees it, - // ahead of any after-`--` tokens. This keeps the spacedock prompt the always-last - // assembled token and hasPluginDir the single gate-relax reader (D4). + // --plugin-dir is the one host-adjacent flag Spacedock parses before `--`. + // pflag knows its arity (one value, repeatable), so the dirs are captured + // correctly in space/equals/repeated forms. Keep them separate from host + // passthrough: Claude accepts a launch-time --plugin-dir and receives them + // when assembling its argv; Codex does not, so forwarding them there breaks + // launch before the first officer starts. if dirs := *flags.pluginDir; len(dirs) > 0 { - front := make([]string, 0, len(dirs)*2+len(fd.passthrough)) - for _, d := range dirs { - front = append(front, "--plugin-dir", d) - } - fd.passthrough = append(front, fd.passthrough...) + fd.pluginDirs = append(fd.pluginDirs, dirs...) } return fd, nil } diff --git a/internal/cli/frontdoor_parse_test.go b/internal/cli/frontdoor_parse_test.go index 7617bc3b9..abefb0a33 100644 --- a/internal/cli/frontdoor_parse_test.go +++ b/internal/cli/frontdoor_parse_test.go @@ -18,6 +18,7 @@ func TestParseFrontDoorArgs(t *testing.T) { name string args []string passthrough []string + pluginDirs []string task string hasTask bool forceSafehouse bool @@ -78,39 +79,40 @@ func TestParseFrontDoorArgs(t *testing.T) { hasTask: true, }, { - name: "plugin-dir-before-dash-space-form", - args: []string{"--plugin-dir", "/p"}, - passthrough: []string{"--plugin-dir", "/p"}, + name: "plugin-dir-before-dash-space-form", + args: []string{"--plugin-dir", "/p"}, + pluginDirs: []string{"/p"}, }, { - name: "plugin-dir-before-dash-equals-form", - args: []string{"--plugin-dir=/p"}, - passthrough: []string{"--plugin-dir", "/p"}, + name: "plugin-dir-before-dash-equals-form", + args: []string{"--plugin-dir=/p"}, + pluginDirs: []string{"/p"}, }, { - name: "plugin-dir-before-dash-repeated", - args: []string{"--plugin-dir", "/a", "--plugin-dir=/b"}, - passthrough: []string{"--plugin-dir", "/a", "--plugin-dir", "/b"}, + name: "plugin-dir-before-dash-repeated", + args: []string{"--plugin-dir", "/a", "--plugin-dir=/b"}, + pluginDirs: []string{"/a", "/b"}, }, { - name: "plugin-dir-before-dash-then-task", - args: []string{"--plugin-dir", "/p", "review the PRs"}, - passthrough: []string{"--plugin-dir", "/p"}, - task: "review the PRs", - hasTask: true, + name: "plugin-dir-before-dash-then-task", + args: []string{"--plugin-dir", "/p", "review the PRs"}, + pluginDirs: []string{"/p"}, + task: "review the PRs", + hasTask: true, }, { - name: "plugin-dir-before-dash-with-skip-and-task", - args: []string{"--plugin-dir", "/p", "--skip-contract-check", "do it"}, - passthrough: []string{"--plugin-dir", "/p"}, - skipCheck: true, - task: "do it", - hasTask: true, + name: "plugin-dir-before-dash-with-skip-and-task", + args: []string{"--plugin-dir", "/p", "--skip-contract-check", "do it"}, + pluginDirs: []string{"/p"}, + skipCheck: true, + task: "do it", + hasTask: true, }, { - name: "plugin-dir-before-and-after-dash-both-forward", + name: "plugin-dir-before-and-after-dash-separate", args: []string{"--plugin-dir", "/before", "--", "--plugin-dir", "/after"}, - passthrough: []string{"--plugin-dir", "/before", "--plugin-dir", "/after"}, + pluginDirs: []string{"/before"}, + passthrough: []string{"--plugin-dir", "/after"}, }, } for _, tc := range cases { @@ -122,6 +124,9 @@ func TestParseFrontDoorArgs(t *testing.T) { if !equalArgv(fd.passthrough, tc.passthrough) { t.Errorf("passthrough = %v, want %v", fd.passthrough, tc.passthrough) } + if !equalArgv(fd.pluginDirs, tc.pluginDirs) { + t.Errorf("pluginDirs = %v, want %v", fd.pluginDirs, tc.pluginDirs) + } if fd.task != tc.task || fd.hasTask != tc.hasTask { t.Errorf("task = (%q,%v), want (%q,%v)", fd.task, fd.hasTask, tc.task, tc.hasTask) } diff --git a/internal/cli/frontdoor_stray_prompt_test.go b/internal/cli/frontdoor_stray_prompt_test.go index 0a464bd54..03d4797d3 100644 --- a/internal/cli/frontdoor_stray_prompt_test.go +++ b/internal/cli/frontdoor_stray_prompt_test.go @@ -44,10 +44,9 @@ func TestClaudeStrayPromptAfterDashWarns(t *testing.T) { // TestClaudeStrayPromptSession12Shape (AC-1, the captain's actual session-12 // case): `--plugin-dir "$(pwd)" -- --model gpt-x '@/tmp/handoff.md'`. The -// spacedock-injected `--plugin-dir ` prefix (re-prepended to fd.passthrough -// by parseFrontDoorArgs) must NOT shadow the operator's real stray prompt — the -// warning names `@/tmp/handoff.md`, never the injected dir, and the inner argv is -// unchanged. +// Spacedock-owned `--plugin-dir ` must NOT shadow the operator's real stray +// prompt — the warning names `@/tmp/handoff.md`, never the plugin dir, and the +// inner argv is unchanged. func TestClaudeStrayPromptSession12Shape(t *testing.T) { fake := &fakeHost{manifest: compatibleManifest(t)} var stdout, stderr bytes.Buffer @@ -65,7 +64,7 @@ func TestClaudeStrayPromptSession12Shape(t *testing.T) { t.Fatalf("warning does not name the operator's stray positional: %q", warn) } if strings.Contains(warn, "/co") { - t.Fatalf("warning names the spacedock-injected --plugin-dir value (shadows the real prompt): %q", warn) + t.Fatalf("warning names the Spacedock-owned --plugin-dir value (shadows the real prompt): %q", warn) } want := []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "--plugin-dir", "/co", "--model", "gpt-x", "@/tmp/handoff.md", wantBootstrapPrompt} if !equalArgv(fake.launchedArg, want) { @@ -153,29 +152,26 @@ func TestStrayPromptGuardNegatives(t *testing.T) { want: []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "--some-new-flag", "the-value", wantBootstrapPrompt}, }, { - // The spacedock-injected `--plugin-dir ` prefix lands the `exec` - // subcommand at index 2; the leading-subcommand exemption must see it - // THROUGH the prefix and stay silent. This case REDS if skipInjectedPrefix - // is removed (the bare index-0 check then names `exec` as stray), so it - // pins the structural skip as the load-bearing mechanism. - name: "codex --plugin-dir then exec subcommand behind injected prefix", + // Before-`--` plugin-dir is Spacedock-owned and must not reach Codex. + // The leading-subcommand exemption still sees `exec` and stays silent. + name: "codex --plugin-dir then exec subcommand", run: func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int { var stdout bytes.Buffer return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr) }, args: []string{"--plugin-dir", "/co", "--", "exec", "do the thing"}, - want: []string{"codex", "--ask-for-approval", "on-request", "--plugin-dir", "/co", "exec", "do the thing", wantCodexBootstrapPrompt}, + want: []string{"codex", "--ask-for-approval", "on-request", "exec", "do the thing", wantCodexBootstrapPrompt}, }, { - // Same structural skip for the codex `resume` subcommand behind the - // injected prefix — no stray warning, argv unchanged. - name: "codex --plugin-dir then resume subcommand behind injected prefix", + // Same for the codex `resume` subcommand — no stray warning, no + // launch-time plugin-dir in the Codex argv. + name: "codex --plugin-dir then resume subcommand", run: func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int { var stdout bytes.Buffer return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr) }, args: []string{"--plugin-dir", "/co", "--", "resume", "abc123"}, - want: []string{"codex", "--ask-for-approval", "on-request", "--plugin-dir", "/co", "resume", "abc123", wantCodexBootstrapPrompt}, + want: []string{"codex", "resume", "abc123"}, }, } for _, tc := range cases { @@ -240,25 +236,25 @@ func TestStrayPromptAfterDashClassifier(t *testing.T) { host: "claude", }, { - // skipInjectedPrefix strips the leading `--plugin-dir ` pair so the + // skipPluginDirPrefix strips the leading `--plugin-dir ` pair so the // dir is NOT named and the operator's real prompt after it IS. - name: "spacedock-injected --plugin-dir prefix does not shadow the real prompt", + name: "explicit --plugin-dir prefix does not shadow the real prompt", passthrough: []string{"--plugin-dir", "/co", "--model", "gpt-x", "@/tmp/handoff.md"}, host: "claude", wantPositional: "@/tmp/handoff.md", wantOK: true, }, { - // A leading subcommand BEHIND the injected `--plugin-dir ` prefix must + // A leading subcommand BEHIND the explicit `--plugin-dir ` prefix must // still be recognized as a subcommand (its args legitimate). This reds if - // skipInjectedPrefix is removed — the bare index-0 check then names `exec`. - name: "subcommand behind injected --plugin-dir prefix is not stray", + // skipPluginDirPrefix is removed — the bare index-0 check then names `exec`. + name: "subcommand behind explicit --plugin-dir prefix is not stray", passthrough: []string{"--plugin-dir", "/co", "exec", "do the thing"}, host: "codex", }, { - // Multiple injected `--plugin-dir ` pairs are all skipped. - name: "multiple injected --plugin-dir pairs all skipped before subcommand", + // Multiple explicit `--plugin-dir ` pairs are all skipped. + name: "multiple explicit --plugin-dir pairs all skipped before subcommand", passthrough: []string{"--plugin-dir", "/a", "--plugin-dir", "/b", "exec", "p"}, host: "codex", }, diff --git a/internal/cli/help.go b/internal/cli/help.go index 5b7c43ca2..7189f944d 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -57,8 +57,9 @@ Start `+hostTitle(host)+` as your Spacedock first officer. The optional task is launch prompt; everything after -- forwards verbatim to `+host+`. A --plugin-dir launch loads a local plugin checkout and relaxes the contract gate, -so it does not require a prior "spacedock install". --plugin-dir is accepted both -before -- (as a spacedock-parsed flag, repeatable) and after -- (forwarded verbatim). +so it does not require a prior "spacedock install". Before -- it is a +spacedock-parsed flag; Claude also receives it at launch, while Codex does not +because Codex has no launch-time --plugin-dir flag. Flags: `) @@ -66,13 +67,13 @@ Flags: fmt.Fprint(w, ` Forwarding: Tokens before -- are spacedock's (the task + the flags above). Tokens after -- - forward verbatim to `+host+`, e.g. `+host+` model/session flags and --plugin-dir. + forward verbatim to `+host+`, e.g. `+host+` model/session flags. Examples: spacedock `+host+` spacedock `+host+` "review the open PRs" spacedock `+host+` --plugin-dir ./checkout - spacedock `+host+` --safehouse-add-dirs ~/scratch -- --plugin-dir ./checkout + spacedock `+host+` --safehouse-add-dirs ~/scratch -- --model gpt-x `) }) } diff --git a/internal/cli/launch_parity_test.go b/internal/cli/launch_parity_test.go index b7c201dc7..53976ed5c 100644 --- a/internal/cli/launch_parity_test.go +++ b/internal/cli/launch_parity_test.go @@ -273,8 +273,9 @@ func TestCodexResumeSubcommandSuppressesPrompt(t *testing.T) { }) } -// LP-AC-3: --plugin-dir passes through (multiplicity, order) AND relaxes the -// gate (launches even on a failing manifest); without it the gate still fails. +// LP-AC-3: --plugin-dir relaxes the gate (launches even on a failing manifest). +// Claude supports launch-time --plugin-dir and receives the parsed values; Codex +// does not, so before-`--` values stay Spacedock-only there. func TestPluginDirRelaxesGate(t *testing.T) { t.Run("claude-relaxes-on-failing-manifest", func(t *testing.T) { fake := &fakeHost{manifest: tooOldBinaryManifest(t)} // gate would FAIL @@ -315,7 +316,7 @@ func TestPluginDirRelaxesGate(t *testing.T) { if code != 0 { t.Fatalf("exit = %d, want 0 (before-`--` --plugin-dir relaxes the gate); stderr=%q", code, stderr.String()) } - want := []string{"codex", "--ask-for-approval", "on-request", "--plugin-dir", "/a", wantCodexBootstrapPrompt} + want := []string{"codex", "--ask-for-approval", "on-request", wantCodexBootstrapPrompt} if !equalArgv(fake.launchedArg, want) { t.Fatalf("launch argv = %v, want %v", fake.launchedArg, want) } diff --git a/internal/cli/plugin_dir_frontdoor_test.go b/internal/cli/plugin_dir_frontdoor_test.go index c42852ec6..39a333363 100644 --- a/internal/cli/plugin_dir_frontdoor_test.go +++ b/internal/cli/plugin_dir_frontdoor_test.go @@ -77,6 +77,29 @@ func TestDevLanePluginDirReachesLaunchSeam(t *testing.T) { } } +// TestCodexDevLanePluginDirDoesNotReachHostArgv pins the live Codex contract: +// Codex has plugin marketplace/add commands, but no launch-time --plugin-dir +// flag. The before-`--` flag is still a Spacedock dev-lane override and still +// relaxes the gate, but it must not be forwarded into the Codex argv. +func TestCodexDevLanePluginDirDoesNotReachHostArgv(t *testing.T) { + repo := vendoredRepoRoot(t) + host := &resolveErrHost{} + var stdout, stderr bytes.Buffer + + code := runCodex(context.Background(), []string{"--plugin-dir", repo, "do the thing"}, t.TempDir(), host, lookFound, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit = %d, want 0 (--plugin-dir must relax the gate); stderr=%q", code, stderr.String()) + } + want := []string{ + "codex", "--ask-for-approval", "on-request", + wantCodexBootstrapPrompt + " do the thing", + } + if !equalArgv(host.launchedArg, want) { + t.Fatalf("launch argv = %v, want %v", host.launchedArg, want) + } +} + // TestDanglingValueTakingHostFlagStillSwallows pins the ACCURATE AC-3 property and // its honest limitation. The invariant the Option-2 grammar guarantees is narrow: // the spacedock prompt is ALWAYS the last assembled host-argv token and ALWAYS From 0682d239ebd6eccde5b448988c4bdede19592aa4 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 10:56:17 +0800 Subject: [PATCH 08/21] fix(codex): install dev plugin hooks for bridge egress Signed-off-by: Jared Scott --- docs/dev/bridge-egress-contract.md | 2 +- hooks/codex-hooks.json | 12 ++--- internal/cli/frontdoor.go | 51 +++++++++++++++++++ internal/cli/frontdoor_stray_prompt_test.go | 6 ++- internal/cli/launch_parity_test.go | 7 ++- internal/cli/plugin_dir_frontdoor_test.go | 24 +++++++-- internal/cli/state_init_test.go | 4 +- .../references/codex-first-officer-runtime.md | 2 +- .../codex_bridge_egress_hook_test.go | 11 ++-- 9 files changed, 98 insertions(+), 21 deletions(-) diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 4e5be94d0..1468ee388 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -35,7 +35,7 @@ One JSON object per line, appended on each lifecycle event. Liveness, not conten - `event` is normalized by `spacedock bridge egress emit` into Bridge's canonical grammar before it reaches `events.jsonl`: `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Notification`, `Stop`, `SubagentStart`, or `SubagentStop`. Host-native names must not leak into this file; for example Pi `session_shutdown`/`turn_end` normalize to `Stop`, `agent_end` normalizes to `SubagentStop`, and `tool_execution_end` normalizes to `PostToolUse`. - Best-effort size cap: the producer trims to the most recent lines past a bound (it is liveness, not a ledger; a few lost lines to a concurrent trim are acceptable). - → **Claude:** PRESENT — `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` for Claude lifecycle events (all async). The wrapper delegates to the shared `spacedock bridge egress emit --host claude` command so Bridge never couples to Claude's internal transcript format. -- → **Codex:** PACKAGED/FIXTURE-COVERED — `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks invoke `scripts/codex-bridge-events.sh`; the wrapper delegates to `spacedock bridge egress emit --host codex`. This proves packaging and minimal lifecycle payload handling, not live marker parity. +- → **Codex:** PACKAGED/FIXTURE-COVERED — `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. The hook does not depend on plugin-root environment variables, so installed cache layout and dev checkout layout are both valid. This proves packaging and minimal lifecycle payload handling, not live marker parity. - → **Pi:** PACKAGED/SOURCE-COVERED — `package.json` advertises `.pi/extensions/spacedock.ts`, and the Pi extension forwards lifecycle event payloads to `spacedock bridge egress emit --host pi`. Local tests cover registration/source wiring and package discovery, not a live Pi run. ## `_bridge/fo.$SLUG.json` — per-workflow liveness heartbeat diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index 4d75e9540..d40e44153 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } @@ -15,7 +15,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } @@ -25,7 +25,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } @@ -35,7 +35,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } @@ -45,7 +45,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } @@ -55,7 +55,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=${PLUGIN_ROOT:-}; [ -n \"$root\" ] || exit 0; \"$root/scripts/codex-bridge-events.sh\" >/dev/null 2>&1 || true'" + "command": "sh -c 'if [ -n \"${SPACEDOCK_BIN:-}\" ] && [ -x \"${SPACEDOCK_BIN:-}\" ]; then bin=\"$SPACEDOCK_BIN\"; elif command -v spacedock >/dev/null 2>&1; then bin=spacedock; else exit 0; fi; \"$bin\" bridge egress emit --host codex >/dev/null 2>&1 || true'" } ] } diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index bee6ac4fd..0e3294f92 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -507,6 +507,23 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP fmt.Fprintf(stderr, "spacedock codex: %v\n", err) return 1 } + if len(fd.pluginDirs) > 0 { + if fd.noInstall { + fmt.Fprintln(stderr, "spacedock codex: --no-install cannot be combined with --plugin-dir; Codex requires a marketplace install to load a dev plugin checkout") + return 1 + } + pluginDir := lastString(fd.pluginDirs) + source, err := codexLocalMarketplaceSource(pluginDir) + if err != nil { + fmt.Fprintf(stderr, "spacedock codex: prepare local Codex marketplace: %v\n", err) + return 1 + } + fmt.Fprintf(stderr, "Installing the %s plugin from %s…\n", "codex", pluginDir) + if _, err := ops.Install("codex", source, devBranch); err != nil { + fmt.Fprintf(stderr, "spacedock codex: dev plugin install failed: %v\n", err) + return 1 + } + } // The gate fails fast on a contract mismatch, but a missing plugin // (NoPluginFound) auto-installs the codex plugin and proceeds to launch so the // single command the user typed yields a working session — `--no-install` opts @@ -586,6 +603,40 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP return code } +func codexLocalMarketplaceSource(pluginDir string) (string, error) { + pluginDir, err := filepath.Abs(pluginDir) + if err != nil { + return "", fmt.Errorf("resolve plugin dir: %w", err) + } + if _, err := os.Stat(filepath.Join(pluginDir, manifestSubpath("codex"))); err != nil { + return "", fmt.Errorf("codex manifest not found under %s: %w", pluginDir, err) + } + + root := filepath.Join(codexHome(), "spacedock-local-marketplaces", channelMarketplace(devBranch)) + if err := os.MkdirAll(filepath.Join(root, ".claude-plugin"), 0o755); err != nil { + return "", fmt.Errorf("create marketplace manifest dir: %w", err) + } + link := filepath.Join(root, "spacedock") + if err := os.RemoveAll(link); err != nil { + return "", fmt.Errorf("replace local plugin link: %w", err) + } + if err := os.Symlink(pluginDir, link); err != nil { + return "", fmt.Errorf("link local plugin checkout: %w", err) + } + manifest := fmt.Sprintf(`{ + "name": "%s", + "owner": { "name": "Spacedock" }, + "plugins": [ + { "name": "spacedock", "source": "./spacedock", "description": "local Spacedock checkout", "category": "workflow" } + ] +} +`, channelMarketplace(devBranch)) + if err := os.WriteFile(filepath.Join(root, ".claude-plugin", "marketplace.json"), []byte(manifest), 0o644); err != nil { + return "", fmt.Errorf("write marketplace manifest: %w", err) + } + return root, nil +} + // codexResume reports whether the codex passthrough begins with the `resume` // subcommand (codex's resume is a leading subcommand, not a flag like claude's // `--resume`). A resume carries its own session intent, so the bootstrap prompt diff --git a/internal/cli/frontdoor_stray_prompt_test.go b/internal/cli/frontdoor_stray_prompt_test.go index 03d4797d3..622ae9a4b 100644 --- a/internal/cli/frontdoor_stray_prompt_test.go +++ b/internal/cli/frontdoor_stray_prompt_test.go @@ -106,6 +106,8 @@ func TestCodexStrayPromptAfterDashWarns(t *testing.T) { // negatives are the value of a value-taking flag (`-p `), the argument of // a known leading subcommand (`exec `), and the hasTask short-circuit. func TestStrayPromptGuardNegatives(t *testing.T) { + repo := vendoredRepoRoot(t) + t.Setenv("CODEX_HOME", t.TempDir()) cases := []struct { name string run func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int @@ -159,7 +161,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) { var stdout bytes.Buffer return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr) }, - args: []string{"--plugin-dir", "/co", "--", "exec", "do the thing"}, + args: []string{"--plugin-dir", repo, "--", "exec", "do the thing"}, want: []string{"codex", "--ask-for-approval", "on-request", "exec", "do the thing", wantCodexBootstrapPrompt}, }, { @@ -170,7 +172,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) { var stdout bytes.Buffer return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr) }, - args: []string{"--plugin-dir", "/co", "--", "resume", "abc123"}, + args: []string{"--plugin-dir", repo, "--", "resume", "abc123"}, want: []string{"codex", "resume", "abc123"}, }, } diff --git a/internal/cli/launch_parity_test.go b/internal/cli/launch_parity_test.go index 53976ed5c..433d1aadf 100644 --- a/internal/cli/launch_parity_test.go +++ b/internal/cli/launch_parity_test.go @@ -310,12 +310,17 @@ func TestPluginDirRelaxesGate(t *testing.T) { } }) t.Run("codex-before-dash-forwards-and-relaxes", func(t *testing.T) { + repo := vendoredRepoRoot(t) + t.Setenv("CODEX_HOME", t.TempDir()) fake := &fakeHost{manifest: tooOldBinaryManifest(t)} // gate would FAIL var stdout, stderr bytes.Buffer - code := runCodex(context.Background(), []string{"--plugin-dir", "/a"}, t.TempDir(), fake, lookFound, &stdout, &stderr) + code := runCodex(context.Background(), []string{"--plugin-dir", repo}, t.TempDir(), fake, lookFound, &stdout, &stderr) if code != 0 { t.Fatalf("exit = %d, want 0 (before-`--` --plugin-dir relaxes the gate); stderr=%q", code, stderr.String()) } + if len(fake.installCmds) != 3 || fake.installCmds[0] != "codex" { + t.Fatalf("install cmds = %v, want codex local marketplace install", fake.installCmds) + } want := []string{"codex", "--ask-for-approval", "on-request", wantCodexBootstrapPrompt} if !equalArgv(fake.launchedArg, want) { t.Fatalf("launch argv = %v, want %v", fake.launchedArg, want) diff --git a/internal/cli/plugin_dir_frontdoor_test.go b/internal/cli/plugin_dir_frontdoor_test.go index 39a333363..2c22df274 100644 --- a/internal/cli/plugin_dir_frontdoor_test.go +++ b/internal/cli/plugin_dir_frontdoor_test.go @@ -77,12 +77,15 @@ func TestDevLanePluginDirReachesLaunchSeam(t *testing.T) { } } -// TestCodexDevLanePluginDirDoesNotReachHostArgv pins the live Codex contract: -// Codex has plugin marketplace/add commands, but no launch-time --plugin-dir -// flag. The before-`--` flag is still a Spacedock dev-lane override and still -// relaxes the gate, but it must not be forwarded into the Codex argv. -func TestCodexDevLanePluginDirDoesNotReachHostArgv(t *testing.T) { +// TestCodexDevLanePluginDirInstallsLocalMarketplaceThenLaunches pins the live +// Codex contract: Codex has plugin marketplace/add commands, but no launch-time +// --plugin-dir flag. The before-`--` flag is a Spacedock dev-lane override: it +// installs the checkout through a local Codex marketplace, relaxes the gate, and +// still must not be forwarded into the Codex argv. +func TestCodexDevLanePluginDirInstallsLocalMarketplaceThenLaunches(t *testing.T) { repo := vendoredRepoRoot(t) + home := t.TempDir() + t.Setenv("CODEX_HOME", home) host := &resolveErrHost{} var stdout, stderr bytes.Buffer @@ -91,6 +94,17 @@ func TestCodexDevLanePluginDirDoesNotReachHostArgv(t *testing.T) { if code != 0 { t.Fatalf("exit = %d, want 0 (--plugin-dir must relax the gate); stderr=%q", code, stderr.String()) } + if len(host.installCmds) != 3 || host.installCmds[0] != "codex" || host.installCmds[2] != devBranch { + t.Fatalf("install cmds = %v, want codex %s", host.installCmds, devBranch) + } + source := host.installCmds[1] + wantSource := filepath.Join(home, "spacedock-local-marketplaces", channelMarketplace(devBranch)) + if source != wantSource { + t.Fatalf("install source = %q, want %q", source, wantSource) + } + if _, err := os.Stat(filepath.Join(source, ".claude-plugin", "marketplace.json")); err != nil { + t.Fatalf("local codex marketplace manifest missing: %v", err) + } want := []string{ "codex", "--ask-for-approval", "on-request", wantCodexBootstrapPrompt + " do the thing", diff --git a/internal/cli/state_init_test.go b/internal/cli/state_init_test.go index 3355654f6..468f4b16b 100644 --- a/internal/cli/state_init_test.go +++ b/internal/cli/state_init_test.go @@ -33,7 +33,7 @@ stages: // git runs a git command in dir, failing the test on a non-zero exit. func git(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", @@ -50,7 +50,7 @@ func git(t *testing.T, dir string, args ...string) string { // where a 2nd `git worktree add` is expected to FATAL. func gitOK(t *testing.T, dir string, args ...string) (string, bool) { t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index 6501c17c3..a687dd1ca 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -55,5 +55,5 @@ The captain is the user of the Codex session. Communicate gate results, clarific Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex the event producer is packaged through Codex plugin hooks; deterministic session→entity markers are not yet claimed. -- **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks invoke `scripts/codex-bridge-events.sh`; the wrapper delegates to `spacedock bridge egress emit --host codex`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file or `CLAUDE_PLUGIN_ROOT`. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. +- **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file, `CLAUDE_PLUGIN_ROOT`, or Codex plugin-root state. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). diff --git a/skills/integration/codex_bridge_egress_hook_test.go b/skills/integration/codex_bridge_egress_hook_test.go index 05f673e9a..b86f1bf7f 100644 --- a/skills/integration/codex_bridge_egress_hook_test.go +++ b/skills/integration/codex_bridge_egress_hook_test.go @@ -1,5 +1,5 @@ // ABOUTME: Codex Bridge egress packaging tests — Codex must use its own non-async -// ABOUTME: hooks and a silent wrapper around the shared Spacedock egress command. +// ABOUTME: hooks and call the shared Spacedock egress command without plugin-root state. package integration import ( @@ -36,7 +36,7 @@ func TestCodexManifestPointsAtCodexBridgeHooks(t *testing.T) { } } -func TestCodexBridgeHooksAreNonAsyncAndCallPluginRootWrapper(t *testing.T) { +func TestCodexBridgeHooksAreNonAsyncAndCallEgressDirectly(t *testing.T) { path := filepath.Join(repoRoot(t), "hooks", "codex-hooks.json") data, err := os.ReadFile(path) if err != nil { @@ -78,7 +78,12 @@ func TestCodexBridgeHooksAreNonAsyncAndCallPluginRootWrapper(t *testing.T) { if strings.Contains(cmd, "CLAUDE_PLUGIN_ROOT") { t.Fatalf("codex hook %s command must not depend on Claude env: %q", event, cmd) } - for _, want := range []string{"PLUGIN_ROOT", "scripts/codex-bridge-events.sh"} { + for _, forbidden := range []string{"PLUGIN_ROOT", "scripts/codex-bridge-events.sh"} { + if strings.Contains(cmd, forbidden) { + t.Fatalf("codex hook %s command must not depend on plugin checkout paths (%q): %q", event, forbidden, cmd) + } + } + for _, want := range []string{"SPACEDOCK_BIN", "bridge egress emit --host codex"} { if !strings.Contains(cmd, want) { t.Fatalf("codex hook %s command %q missing %q", event, cmd, want) } From 8a2948161ff1e69278708c02d9977954af398162 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 12:48:50 +0800 Subject: [PATCH 09/21] feat(bridge): wake Codex FO from inbox intents Signed-off-by: Jared Scott --- internal/bridgeingress/wake.go | 496 ++++++++++++++++++++++++++++ internal/bridgeingress/wake_test.go | 157 +++++++++ internal/cli/bridge_ingress_test.go | 35 ++ internal/cli/cli.go | 57 +++- 4 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 internal/bridgeingress/wake.go create mode 100644 internal/bridgeingress/wake_test.go create mode 100644 internal/cli/bridge_ingress_test.go diff --git a/internal/bridgeingress/wake.go b/internal/bridgeingress/wake.go new file mode 100644 index 000000000..db9a68890 --- /dev/null +++ b/internal/bridgeingress/wake.go @@ -0,0 +1,496 @@ +// ABOUTME: Bridge ingress wake-up for Codex first-officer sessions. +// ABOUTME: Reads the durable Bridge inbox and nudges live Codex sessions to drain it. +package bridgeingress + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const liveWindow = 30 * time.Minute + +var safeSlugPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// ResumeFunc resumes a host session with the supplied prompt. +type ResumeFunc func(ctx context.Context, sessionID, prompt string) error + +// Options controls one Bridge inbox wake pass. +type Options struct { + Host string + Root string + Members []string + CodexBin string + Now func() time.Time + Resume ResumeFunc +} + +// Result is the JSON shape printed by the hidden CLI for Bridge to display. +type Result struct { + Status string `json:"status"` + Lines []int `json:"lines,omitempty"` + Sessions int `json:"sessions,omitempty"` + Targets []string `json:"targets,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +type inboxRecord struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + Kind string `json:"kind"` + Target string `json:"target"` + TargetSet []string `json:"target_set"` + Line int `json:"-"` +} + +type heartbeat struct { + SessionID string `json:"session_id"` + TS time.Time `json:"ts"` + State string `json:"state"` +} + +type wakeEvent struct { + Timestamp string `json:"timestamp"` + TS string `json:"ts"` + Host string `json:"host"` + Event string `json:"event"` + Status string `json:"status"` + Line int `json:"line,omitempty"` + Lines []int `json:"lines,omitempty"` + IntentID string `json:"intent_id,omitempty"` + Targets []string `json:"targets,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +// Wake resumes live Codex FO sessions that are addressed by inbox records after +// the successful-wake cursor. It advances the cursor only after at least one +// resume succeeds; no-session records remain eligible for a future wake. +func Wake(ctx context.Context, opts Options) Result { + host := normalizeHost(opts.Host) + if host == "" { + host = "codex" + } + if host != "codex" { + return Result{Status: "failed", Error: "bridge ingress wake currently supports host codex only"} + } + root := opts.Root + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return Result{Status: "failed", Error: err.Error()} + } + now := time.Now().UTC + if opts.Now != nil { + now = func() time.Time { return opts.Now().UTC() } + } + + unlock, ok := acquireLock(absRoot) + if !ok { + return Result{Status: "locked", Message: "another bridge ingress wake is running"} + } + defer unlock() + + cursor := readCursor(absRoot) + records, lastLine, err := readPendingInbox(absRoot, cursor) + if err != nil { + return Result{Status: "failed", Error: err.Error()} + } + if len(records) == 0 { + if lastLine > cursor { + _ = writeCursor(absRoot, lastLine) + } + return Result{Status: "noop", Message: "no pending inbox records"} + } + + sessions := map[string]*sessionWake{} + targetsMissingSession := map[string]bool{} + for _, rec := range records { + targets := targetsFor(absRoot, rec, opts.Members) + if len(targets) == 0 { + continue + } + for _, target := range targets { + hb, ok := loadHeartbeat(absRoot, target, now()) + if !ok || hb.SessionID == "" { + targetsMissingSession[target] = true + continue + } + w := sessions[hb.SessionID] + if w == nil { + w = &sessionWake{SessionID: hb.SessionID, TargetSet: map[string]bool{}} + sessions[hb.SessionID] = w + } + w.TargetSet[target] = true + w.Lines = appendUniqueInt(w.Lines, rec.Line) + if rec.ID != "" { + w.IntentIDs = appendUniqueString(w.IntentIDs, rec.ID) + } + } + } + + if len(sessions) == 0 { + targets := keys(targetsMissingSession) + appendWakeEvent(absRoot, wakeEvent{ + Timestamp: now().Format(time.RFC3339), + TS: now().Format(time.RFC3339), + Host: host, + Event: "wake", + Status: "skipped-no-session", + Lines: recordLines(records), + Targets: targets, + Message: "no fresh FO heartbeat with a Codex session id", + }) + return Result{Status: "skipped-no-session", Lines: recordLines(records), Targets: targets, Message: "no fresh FO heartbeat with a Codex session id"} + } + + resume := opts.Resume + if resume == nil { + resume = func(ctx context.Context, sessionID, prompt string) error { + return execCodexResume(ctx, opts.CodexBin, sessionID, prompt) + } + } + + var successes int + var firstErr error + allTargets := map[string]bool{} + for _, w := range sortedSessionWakes(sessions) { + for target := range w.TargetSet { + allTargets[target] = true + } + prompt := wakePrompt(absRoot, w) + err := resume(ctx, w.SessionID, prompt) + status := "woke" + errText := "" + if err != nil { + status = "failed" + errText = err.Error() + if firstErr == nil { + firstErr = err + } + } else { + successes++ + } + appendWakeEvent(absRoot, wakeEvent{ + Timestamp: now().Format(time.RFC3339), + TS: now().Format(time.RFC3339), + Host: host, + Event: "wake", + Status: status, + Lines: append([]int(nil), w.Lines...), + Targets: w.Targets(), + SessionID: w.SessionID, + Error: errText, + }) + } + + if successes > 0 { + _ = writeCursor(absRoot, lastLine) + } + result := Result{ + Status: "woke", + Lines: recordLines(records), + Sessions: successes, + Targets: keys(allTargets), + Message: "resumed Codex FO session", + } + if firstErr != nil { + result.Status = "partial" + result.Error = firstErr.Error() + if successes == 0 { + result.Status = "failed" + result.Message = "" + } + } + return result +} + +type sessionWake struct { + SessionID string + Lines []int + TargetSet map[string]bool + IntentIDs []string +} + +func (w *sessionWake) Targets() []string { return keys(w.TargetSet) } + +func execCodexResume(ctx context.Context, bin, sessionID, prompt string) error { + if strings.TrimSpace(bin) == "" { + bin = "codex" + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + promptFile, err := os.CreateTemp("", "spacedock-bridge-wake-*.txt") + if err != nil { + return fmt.Errorf("codex exec resume prompt: %w", err) + } + promptPath := promptFile.Name() + defer func() { + _ = promptFile.Close() + _ = os.Remove(promptPath) + }() + if _, err := promptFile.WriteString(prompt); err != nil { + return fmt.Errorf("codex exec resume prompt: %w", err) + } + if _, err := promptFile.Seek(0, 0); err != nil { + return fmt.Errorf("codex exec resume prompt: %w", err) + } + + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("codex exec resume output: %w", err) + } + defer devNull.Close() + + cmd := exec.Command(bin, "exec", "resume", sessionID, "-") + cmd.Stdin = promptFile + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := cmd.Start(); err != nil { + return fmt.Errorf("codex exec resume: %w", err) + } + return nil +} + +func wakePrompt(root string, w *sessionWake) string { + return fmt.Sprintf(`Bridge queued captain intent for this Spacedock first-officer session. + +Repo root: %s +Inbox: %s +Pending physical inbox lines for this session: %s +Addressed workflow slugs: %s + +Run the Bridge inbox idle drain now for only those workflow slugs. Honor target_set routing, append per-slug FO replies or acknowledgements for valid captain intents, advance the matching _bridge/.inbox-cursor. files, then continue the normal first-officer event loop. +`, root, filepath.Join(root, "_bridge", "inbox.jsonl"), joinInts(w.Lines), strings.Join(w.Targets(), ",")) +} + +func acquireLock(root string) (func(), bool) { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return func() {}, false + } + path := filepath.Join(dir, ".wake-lock.codex") + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return func() {}, false + } + _, _ = fmt.Fprintf(f, "%d\n", os.Getpid()) + _ = f.Close() + return func() { _ = os.Remove(path) }, true +} + +func readCursor(root string) int { + data, err := os.ReadFile(filepath.Join(root, "_bridge", ".wake-cursor.codex")) + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(data))) + return n +} + +func writeCursor(root string, line int) error { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, ".wake-cursor.codex"), []byte(strconv.Itoa(line)+"\n"), 0o600) +} + +func readPendingInbox(root string, cursor int) ([]inboxRecord, int, error) { + f, err := os.Open(filepath.Join(root, "_bridge", "inbox.jsonl")) + if os.IsNotExist(err) { + return nil, cursor, nil + } + if err != nil { + return nil, cursor, err + } + defer f.Close() + + var out []inboxRecord + lineNo := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lineNo++ + if lineNo <= cursor || strings.TrimSpace(scanner.Text()) == "" { + continue + } + var rec inboxRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + rec.Line = lineNo + out = append(out, rec) + } + if err := scanner.Err(); err != nil { + return nil, lineNo, err + } + return out, lineNo, nil +} + +func targetsFor(root string, rec inboxRecord, members []string) []string { + if len(rec.TargetSet) > 0 { + return cleanSlugs(rec.TargetSet) + } + target := strings.TrimSpace(rec.Target) + if target == "" || target == "all" { + if len(members) > 0 { + return cleanSlugs(members) + } + return discoverHeartbeatSlugs(root) + } + return cleanSlugs([]string{target}) +} + +func discoverHeartbeatSlugs(root string) []string { + matches, _ := filepath.Glob(filepath.Join(root, "_bridge", "fo.*.json")) + var out []string + for _, path := range matches { + name := filepath.Base(path) + slug := strings.TrimSuffix(strings.TrimPrefix(name, "fo."), ".json") + out = append(out, slug) + } + return cleanSlugs(out) +} + +func cleanSlugs(in []string) []string { + seen := map[string]bool{} + var out []string + for _, slug := range in { + slug = strings.TrimSpace(slug) + if slug == "" || slug == "." || slug == ".." || !safeSlugPattern.MatchString(slug) || seen[slug] { + continue + } + seen[slug] = true + out = append(out, slug) + } + sort.Strings(out) + return out +} + +func loadHeartbeat(root, slug string, now time.Time) (heartbeat, bool) { + var hb heartbeat + if !safeSlugPattern.MatchString(slug) || slug == "." || slug == ".." { + return hb, false + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo."+slug+".json")) + if err != nil { + return hb, false + } + if err := json.Unmarshal(data, &hb); err != nil || hb.TS.IsZero() { + return hb, false + } + age := now.Sub(hb.TS) + return hb, age >= 0 && age <= liveWindow +} + +func appendWakeEvent(root string, event wakeEvent) { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + f, err := os.OpenFile(filepath.Join(dir, "wake-events.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() + data, err := json.Marshal(event) + if err != nil { + return + } + _, _ = f.Write(append(data, '\n')) +} + +func sortedSessionWakes(in map[string]*sessionWake) []*sessionWake { + var sessions []string + for session := range in { + sessions = append(sessions, session) + } + sort.Strings(sessions) + out := make([]*sessionWake, 0, len(sessions)) + for _, session := range sessions { + w := in[session] + sort.Ints(w.Lines) + w.IntentIDs = cleanStrings(w.IntentIDs) + out = append(out, w) + } + return out +} + +func recordLines(records []inboxRecord) []int { + lines := make([]int, 0, len(records)) + for _, rec := range records { + lines = appendUniqueInt(lines, rec.Line) + } + sort.Ints(lines) + return lines +} + +func appendUniqueInt(in []int, v int) []int { + for _, existing := range in { + if existing == v { + return in + } + } + return append(in, v) +} + +func appendUniqueString(in []string, v string) []string { + for _, existing := range in { + if existing == v { + return in + } + } + return append(in, v) +} + +func cleanStrings(in []string) []string { + seen := map[string]bool{} + var out []string + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + sort.Strings(out) + return out +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func joinInts(in []int) string { + parts := make([]string, 0, len(in)) + for _, n := range in { + parts = append(parts, strconv.Itoa(n)) + } + return strings.Join(parts, ",") +} + +func normalizeHost(host string) string { + return strings.ToLower(strings.TrimSpace(host)) +} diff --git a/internal/bridgeingress/wake_test.go b/internal/bridgeingress/wake_test.go new file mode 100644 index 000000000..dece83141 --- /dev/null +++ b/internal/bridgeingress/wake_test.go @@ -0,0 +1,157 @@ +package bridgeingress + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWakeResumesFreshHeartbeatSessionAndAdvancesCursor(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"all","target_set":["a","b"]}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + writeHeartbeat(t, root, "b", "session-a", now.Add(-time.Minute)) + + var gotSession, gotPrompt string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, sessionID, prompt string) error { + gotSession = sessionID + gotPrompt = prompt + return nil + }, + }) + + if res.Status != "woke" || res.Sessions != 1 { + t.Fatalf("result = %+v, want woke one session", res) + } + if gotSession != "session-a" { + t.Fatalf("session = %q, want session-a", gotSession) + } + for _, want := range []string{"Pending physical inbox lines for this session: 1", "Addressed workflow slugs: a,b", "_bridge/inbox.jsonl"} { + if !strings.Contains(gotPrompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, gotPrompt) + } + } + if cursor := readFile(t, filepath.Join(root, "_bridge", ".wake-cursor.codex")); cursor != "1\n" { + t.Fatalf("cursor = %q, want 1", cursor) + } + var ev wakeEvent + readLastJSON(t, filepath.Join(root, "_bridge", "wake-events.jsonl"), &ev) + if ev.Status != "woke" || ev.SessionID != "session-a" || len(ev.Targets) != 2 { + t.Fatalf("event = %+v, want woke session with targets", ev) + } +} + +func TestWakeNoSessionDoesNotAdvanceCursor(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"all","target_set":["a"]}`, + ) + + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(context.Context, string, string) error { + t.Fatal("resume should not run without a fresh heartbeat") + return nil + }, + }) + + if res.Status != "skipped-no-session" { + t.Fatalf("result = %+v, want skipped-no-session", res) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", ".wake-cursor.codex")); !os.IsNotExist(err) { + t.Fatalf("cursor should not exist after no-session wake: %v", err) + } +} + +func TestWakeSkipsAlreadyWokenLines(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:58:00Z","kind":"tell","target":"a"}`, + `{"id":"i2","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, + ) + if err := writeCursor(root, 1); err != nil { + t.Fatal(err) + } + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + + var prompt string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, _ string, p string) error { + prompt = p + return nil + }, + }) + + if res.Status != "woke" { + t.Fatalf("result = %+v, want woke", res) + } + if !strings.Contains(prompt, "Pending physical inbox lines for this session: 2") || strings.Contains(prompt, "1,2") { + t.Fatalf("prompt did not scope to pending line 2:\n%s", prompt) + } + if cursor := readFile(t, filepath.Join(root, "_bridge", ".wake-cursor.codex")); cursor != "2\n" { + t.Fatalf("cursor = %q, want 2", cursor) + } +} + +func writeInbox(t *testing.T, root string, lines ...string) { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(filepath.Join(dir, "inbox.jsonl"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeHeartbeat(t *testing.T, root, slug, sessionID string, ts time.Time) { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"session_id":"` + sessionID + `","ts":"` + ts.Format(time.RFC3339) + `","state":"idle"}` + if err := os.WriteFile(filepath.Join(dir, "fo."+slug+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func readLastJSON(t *testing.T, path string, out any) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if err := json.Unmarshal([]byte(lines[len(lines)-1]), out); err != nil { + t.Fatalf("decode %s: %v", path, err) + } +} diff --git a/internal/cli/bridge_ingress_test.go b/internal/cli/bridge_ingress_test.go new file mode 100644 index 000000000..2c6128ddf --- /dev/null +++ b/internal/cli/bridge_ingress_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestBridgeIngressWakeHiddenCLIPrintsJSONNoop(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{"bridge", "ingress", "wake", "--host", "codex", "--repo-root", root, "--members", "a,b"}, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var got struct { + Status string `json:"status"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if got.Status != "noop" { + t.Fatalf("status = %q, want noop", got.Status) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 181fb695b..3f5dfd0ea 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4,6 +4,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -14,6 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/spacedock-dev/spacedock/internal/bridgeegress" + "github.com/spacedock-dev/spacedock/internal/bridgeingress" "github.com/spacedock-dev/spacedock/internal/claudeteam" "github.com/spacedock-dev/spacedock/internal/contract" "github.com/spacedock-dev/spacedock/internal/dispatch" @@ -460,21 +462,32 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout } } -// newBridgeCommand is a hidden hook-facing surface. It is intentionally silent -// and no-op-safe because Bridge egress is observe-only telemetry. +// newBridgeCommand is a hidden Bridge-facing surface. Egress stays silent and +// no-op-safe because it is observe-only telemetry; ingress wake prints a compact +// JSON result that Bridge can surface without knowing Codex internals. func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { return &cobra.Command{ - Use: "bridge egress emit --host ", + Use: "bridge egress emit --host | ingress wake --host codex", Hidden: true, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 2 || args[0] != "egress" || args[1] != "emit" { + if len(args) >= 2 && args[0] == "egress" && args[1] == "emit" { + bridgeegress.EmitFromReader(stdin, bridgeegress.Options{ + Host: parseBridgeHost(args[2:]), + CWD: dir, + }) + return nil + } + if len(args) >= 2 && args[0] == "ingress" && args[1] == "wake" { + result := bridgeingress.Wake(cmd.Context(), bridgeingress.Options{ + Host: parseBridgeHost(args[2:]), + Root: parseBridgeStringFlag(args[2:], "--repo-root", dir), + Members: parseBridgeCSVFlag(args[2:], "--members"), + CodexBin: parseBridgeStringFlag(args[2:], "--codex-bin", ""), + }) + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) return nil } - bridgeegress.EmitFromReader(stdin, bridgeegress.Options{ - Host: parseBridgeHost(args[2:]), - CWD: dir, - }) return nil }, } @@ -492,6 +505,34 @@ func parseBridgeHost(args []string) string { return "" } +func parseBridgeStringFlag(args []string, name string, fallback string) string { + for i := 0; i < len(args); i++ { + if args[i] == name && i+1 < len(args) { + return args[i+1] + } + if strings.HasPrefix(args[i], name+"=") { + return strings.TrimPrefix(args[i], name+"=") + } + } + return fallback +} + +func parseBridgeCSVFlag(args []string, name string) []string { + raw := parseBridgeStringFlag(args, name, "") + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + // wantsHelp reports whether the operator asked for command help. Commands with // DisableFlagParsing receive `-h`/`--help` as ordinary args, so each RunE checks // for it before doing work. Only a leading help token counts: a `--help` after From e46903f4102f936cdcd11453e94727e82f1a7f3a Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 13:30:52 +0800 Subject: [PATCH 10/21] feat(bridge): emit FO permission alerts --- docs/dev/_mods/bridge-inbox.md | 9 +- docs/dev/bridge-egress-contract.md | 23 +++- internal/bridgealert/alert.go | 129 ++++++++++++++++++ internal/bridgealert/alert_test.go | 55 ++++++++ internal/cli/bridge_alert_test.go | 56 ++++++++ internal/cli/cli.go | 25 +++- .../fo_feed_and_eager_drain_test.go | 8 +- .../references/first-officer-shared-core.md | 10 ++ 8 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 internal/bridgealert/alert.go create mode 100644 internal/bridgealert/alert_test.go create mode 100644 internal/cli/bridge_alert_test.go diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 3ef0782a1..56fb02147 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -26,7 +26,7 @@ esac **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"id":"","ts":"","kind":"tell"|"conn"|"decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":""} +{"id":"","ts":"","kind":"tell"|"conn"|"decision"|"permission-decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":"","request_id":""} ``` `id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set` when Bridge can freeze a recipient set: for `target == "all"` with known fleet members, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. `target` remains for backward compatibility with older records and unknown-recipient broadcasts. @@ -83,10 +83,11 @@ Drain newly-queued captain intent addressed to this workflow, if any: sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl ``` If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` / `field` / `value` on a `decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: +4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` / `field` / `value` on a `decision` record; `request_id` on a `permission-decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and append a `reply` record with `status:"answered"`. - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). Then append a `conn-ack` record with `status:"accepted"`. `granted: false` → take the conn back: stop at every gate for the captain's call again. Then append a `conn-ack` record with `status:"released"`. - **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. Append a `decision-ack` record with `status:"applied"` when the field value is present and gate resolution finished or was already satisfied; append `status:"blocked"` when the intent is valid but execution could not finish; append `status:"rejected"` when the intent is invalid or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. + - **`kind == "permission-decision"`** — the captain resolved a top-level FO permission alert. Match `request_id` to the open `_bridge/fo-alerts.jsonl` record you emitted. `value: "deny"` means do not retry the blocked action; append a `permission-ack` with `status:"denied"` and report the block remains. `value: "approve-once"` means retry the exact blocked host action once using the runtime's escalation path. `value: "approve-rule"` means retry with the alert's proposed `prefix_rule` when one was present; otherwise treat it as `approve-once`. Append `permission-ack` with `status:"accepted"` before retrying; append `status:"blocked"` if the retry could not be started. This Bridge approval is FO intent, not a bypass of a host-native security prompt; if the host still presents a native approval dialog, honor it normally. 5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`. 6. Report to the captain: how many intents you drained (for this workflow) and what you did with each. @@ -97,7 +98,7 @@ If a record is malformed (not valid JSON, missing required fields for its `kind` Append one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected: ``` -{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision","status":"answered"|"accepted"|"released"|"applied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","session_id":"optional","host":"optional"} +{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision"|"permission-decision","status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","request_id":"...","session_id":"optional","host":"optional"} ``` Rules: @@ -107,7 +108,7 @@ Rules: - `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts`. - `intent_kind` echoes the inbox `kind`. - `text` is optional but, when present, must be a single line. -- Echo `granted`, `entity`, `field`, and `value` when present and relevant. +- Echo `granted`, `entity`, `field`, `value`, and `request_id` when present and relevant. - `session_id` should be `"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}"` when non-empty; `host` is optional. - Write one complete newline-terminated JSON object in one append operation, e.g. build the full JSON line with a structured encoder and append it with a single `>> _bridge/fo-replies.jsonl` write. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl`. - Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed reply append must never block the FO from completing the drained intent or advancing the cursor after action. diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 1468ee388..4e52ab460 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -65,17 +65,32 @@ One JSON object per line, appended when the FO dispatches, advances, or complete One JSON object per line, appended after the FO has interpreted, accepted, or applied an inbox intent. This is the explanatory reply/ack stream for Bridge's conversation loop; the per-workflow inbox cursor remains the delivery/read source of truth. Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. The stream is best-effort explanatory content, not an exactly-once delivery ledger. ``` -{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","session_id":"optional","host":"optional"} +{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","request_id":"...","session_id":"optional","host":"optional"} ``` - `target` is the actual acknowledging workflow slug, never `all`. - `in_reply_to_line` is the physical line number in `_bridge/inbox.jsonl` that produced the ack. -- `kind`: `reply` for `tell`, `conn-ack` for `conn`, `decision-ack` for `decision`. -- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant; `released` when the FO gives the conn back; `applied` when a decision field value is present and gate resolution finished or was already satisfied; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. -- Echo `granted`, `entity`, `field`, and `value` when present and relevant to the intent. Keep `text` one line. +- `kind`: `reply` for `tell`, `conn-ack` for `conn`, `decision-ack` for `decision`, `permission-ack` for `permission-decision`. +- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant or accepts a permission retry; `released` when the FO gives the conn back; `applied` when a decision field value is present and gate resolution finished or was already satisfied; `denied` when the captain rejects a permission request; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. +- Echo `granted`, `entity`, `field`, `value`, and `request_id` when present and relevant to the intent. Keep `text` one line. - Append-only and best-effort: write one complete newline-terminated JSON object in one append operation, and never rewrite `fo-replies.jsonl`. - → **All hosts (host-neutral producer):** the `bridge-inbox` mod appends replies/acks while draining `_bridge/inbox.jsonl`. +## `_bridge/fo-alerts.jsonl` — top-level FO alerts + +One JSON object per line, appended when the FO is blocked by a captain-owned host decision that should surface above ordinary chat replies. The first producer is the sandbox permission path: when a command cannot proceed because the host needs approval, the FO writes an open permission request before waiting for a decision. + +``` +{"schema":1,"id":"perm_","ts":"","kind":"permission-request","severity":"blocked","workflow":"","entity":"","host":"","session_id":"<«session-id»>","reason":"","command":"","prefix_rule":["git","-C"],"status":"open"} +``` + +- `id` is the stable join key for the captain's response. +- `workflow` and `entity` scope the alert for fleet UI routing; `entity` may be empty when the block happens before an entity is selected. +- `command` is a concise command summary, not a secret-bearing shell transcript. +- `prefix_rule` is optional and present only when the FO has a narrowly scoped reusable approval to propose. +- Bridge overlays a later inbox `permission-decision` record for the same `request_id` to show approved/denied state; the alert file itself remains append-only. +- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge alert permission ...`. The helper returns `{"id":"...","queued":true}` and appends one line to `_bridge/fo-alerts.jsonl`. + ## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) Last-write (first-write-wins per host actor), one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. diff --git a/internal/bridgealert/alert.go b/internal/bridgealert/alert.go new file mode 100644 index 000000000..9d63bffe7 --- /dev/null +++ b/internal/bridgealert/alert.go @@ -0,0 +1,129 @@ +// ABOUTME: FO-to-Bridge alert writer for top-level captain interrupts. +// ABOUTME: Append-only and best-effort; Bridge reads the resulting JSONL. +package bridgealert + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// PermissionAlert is the stable JSONL shape Bridge reads from +// _bridge/fo-alerts.jsonl. +type PermissionAlert struct { + Schema int `json:"schema"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Workflow string `json:"workflow,omitempty"` + Entity string `json:"entity,omitempty"` + Host string `json:"host,omitempty"` + SessionID string `json:"session_id,omitempty"` + Reason string `json:"reason"` + Command string `json:"command,omitempty"` + PrefixRule []string `json:"prefix_rule,omitempty"` + Status string `json:"status"` +} + +// PermissionOptions controls one permission-alert append. +type PermissionOptions struct { + Root string + Now func() time.Time + ID string + Workflow string + Entity string + Host string + SessionID string + Reason string + Command string + PrefixRule []string +} + +type Result struct { + ID string `json:"id"` + Queued bool `json:"queued"` +} + +// AppendPermission writes one open permission alert under root/_bridge. +func AppendPermission(opts PermissionOptions) (Result, error) { + root := opts.Root + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return Result{}, err + } + reason := strings.TrimSpace(opts.Reason) + if reason == "" { + return Result{}, fmt.Errorf("permission alert: reason is required") + } + id := strings.TrimSpace(opts.ID) + if id == "" { + id, err = newID() + if err != nil { + return Result{}, err + } + } + now := time.Now().UTC + if opts.Now != nil { + now = func() time.Time { return opts.Now().UTC() } + } + alert := PermissionAlert{ + Schema: 1, + ID: id, + TS: now().Format(time.RFC3339), + Kind: "permission-request", + Severity: "blocked", + Workflow: strings.TrimSpace(opts.Workflow), + Entity: strings.TrimSpace(opts.Entity), + Host: strings.TrimSpace(opts.Host), + SessionID: strings.TrimSpace(opts.SessionID), + Reason: reason, + Command: strings.TrimSpace(opts.Command), + PrefixRule: cleanedPrefixRule(opts.PrefixRule), + Status: "open", + } + dir := filepath.Join(absRoot, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return Result{}, err + } + f, err := os.OpenFile(filepath.Join(dir, "fo-alerts.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return Result{}, err + } + defer func() { _ = f.Close() }() + data, err := json.Marshal(alert) + if err != nil { + return Result{}, err + } + if _, err := f.Write(append(data, '\n')); err != nil { + return Result{}, err + } + return Result{ID: id, Queued: true}, nil +} + +func cleanedPrefixRule(in []string) []string { + out := make([]string, 0, len(in)) + for _, part := range in { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func newID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "perm_" + hex.EncodeToString(b[:]), nil +} diff --git a/internal/bridgealert/alert_test.go b/internal/bridgealert/alert_test.go new file mode 100644 index 000000000..0253c11df --- /dev/null +++ b/internal/bridgealert/alert_test.go @@ -0,0 +1,55 @@ +package bridgealert + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAppendPermissionWritesAlert(t *testing.T) { + root := t.TempDir() + got, err := AppendPermission(PermissionOptions{ + Root: root, + Now: func() time.Time { return time.Date(2026, 7, 2, 5, 0, 0, 0, time.UTC) }, + ID: "perm-1", + Workflow: "pr-review-queue", + Entity: "datarecce-recce-pr-1", + Host: "codex", + SessionID: "s1", + Reason: "sandbox blocked state gitdir", + Command: "git status", + PrefixRule: []string{"git", "-C"}, + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "perm-1" || !got.Queued { + t.Fatalf("result = %+v", got) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + var alert PermissionAlert + if err := json.Unmarshal(bytes.TrimSpace(data), &alert); err != nil { + t.Fatalf("alert JSON: %v\n%s", err, data) + } + if alert.Kind != "permission-request" || alert.Severity != "blocked" || alert.Status != "open" { + t.Fatalf("alert = %+v", alert) + } + if alert.TS != "2026-07-02T05:00:00Z" || alert.Workflow != "pr-review-queue" || alert.Command != "git status" { + t.Fatalf("alert fields = %+v", alert) + } + if len(alert.PrefixRule) != 2 || alert.PrefixRule[0] != "git" { + t.Fatalf("prefix rule = %+v", alert.PrefixRule) + } +} + +func TestAppendPermissionRequiresReason(t *testing.T) { + if _, err := AppendPermission(PermissionOptions{Root: t.TempDir()}); err == nil { + t.Fatal("expected missing reason to fail") + } +} diff --git a/internal/cli/bridge_alert_test.go b/internal/cli/bridge_alert_test.go new file mode 100644 index 000000000..cdf654267 --- /dev/null +++ b/internal/cli/bridge_alert_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBridgeAlertPermissionHiddenCLIWritesAlert(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{ + "bridge", "alert", "permission", + "--repo-root", root, + "--id", "perm-1", + "--host", "codex", + "--workflow", "pr-review-queue", + "--entity", "ship-1", + "--session-id", "s1", + "--reason", "sandbox blocked state gitdir", + "--command", "git status", + "--prefix-rule", "git,-C", + }, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var result struct { + ID string `json:"id"` + Queued bool `json:"queued"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if result.ID != "perm-1" || !result.Queued { + t.Fatalf("result = %+v", result) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"reason":"sandbox blocked state gitdir"`) || + !strings.Contains(string(data), `"prefix_rule":["git","-C"]`) { + t.Fatalf("alert file missing fields:\n%s", data) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3f5dfd0ea..7ec09d012 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" + "github.com/spacedock-dev/spacedock/internal/bridgealert" "github.com/spacedock-dev/spacedock/internal/bridgeegress" "github.com/spacedock-dev/spacedock/internal/bridgeingress" "github.com/spacedock-dev/spacedock/internal/claudeteam" @@ -463,11 +464,11 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout } // newBridgeCommand is a hidden Bridge-facing surface. Egress stays silent and -// no-op-safe because it is observe-only telemetry; ingress wake prints a compact -// JSON result that Bridge can surface without knowing Codex internals. +// no-op-safe because it is observe-only telemetry; ingress wake and alert writes +// print compact JSON results that Bridge can surface without knowing host internals. func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { return &cobra.Command{ - Use: "bridge egress emit --host | ingress wake --host codex", + Use: "bridge egress emit --host | ingress wake --host codex | alert permission", Hidden: true, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -488,6 +489,24 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) return nil } + if len(args) >= 2 && args[0] == "alert" && args[1] == "permission" { + result, err := bridgealert.AppendPermission(bridgealert.PermissionOptions{ + Root: parseBridgeStringFlag(args[2:], "--repo-root", dir), + ID: parseBridgeStringFlag(args[2:], "--id", ""), + Workflow: parseBridgeStringFlag(args[2:], "--workflow", ""), + Entity: parseBridgeStringFlag(args[2:], "--entity", ""), + Host: parseBridgeHost(args[2:]), + SessionID: parseBridgeStringFlag(args[2:], "--session-id", ""), + Reason: parseBridgeStringFlag(args[2:], "--reason", ""), + Command: parseBridgeStringFlag(args[2:], "--command", ""), + PrefixRule: parseBridgeCSVFlag(args[2:], "--prefix-rule"), + }) + if err != nil { + return err + } + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) + return nil + } return nil }, } diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go index 8b08ca9d2..b98efdb4c 100644 --- a/internal/contractlint/fo_feed_and_eager_drain_test.go +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -76,9 +76,9 @@ func TestBridgeConversationReplyContract(t *testing.T) { `physical ` + "`LINE`" + ` number`, `not merely after shell-reading the line`, `_bridge/fo-replies.jsonl`, - `"kind":"reply"|"conn-ack"|"decision-ack"`, + `"kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack"`, `"in_reply_to_line":123`, - `"status":"answered"|"accepted"|"released"|"applied"|"rejected"|"blocked"`, + `"status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked"`, `target` + "` is the actual acknowledging workflow slug (`$SLUG`), never " + "`\"all\"`", `Cursor remains the delivery/read source of truth`, `Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind`, @@ -106,8 +106,8 @@ func TestBridgeConversationReplyContract(t *testing.T) { `## ` + "`_bridge/fo-replies.jsonl`" + ` — captain-intent acknowledgements`, `"schema":1`, `"target":""`, - `"intent_kind":""`, - `"status":""`, + `"intent_kind":""`, + `"status":""`, `target` + "` is the actual acknowledging workflow slug, never " + "`all`", `applied` + "` when a decision field value is present and gate resolution finished or was already satisfied", `blocked` + "` when a valid intent could not finish", diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 4aa21f87d..a148ad6a6 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -225,6 +225,16 @@ Ask the human before dispatch when requirements are materially ambiguous, a desi Don't ask permission for a step the contract already allows (the reversible-work principle); keep dispatching other ready entities when one blocks. Report state once on idle or at a gate, not repeatedly while waiting. +## Permission Blocks and Bridge Alerts + +When a host sandbox or permission boundary blocks a workflow action that would otherwise be valid to attempt, surface it as a top-level Bridge alert before parking the loop. Use the hidden helper from the repo root: + +``` +spacedock bridge alert permission --host --workflow --entity --reason "" --command "" [--prefix-rule "git,-C"] +``` + +The helper appends `_bridge/fo-alerts.jsonl`, which Bridge renders as an Approve/Deny alert. A Bridge `permission-decision` inbox record with the returned `request_id` is the typed captain response: `deny` leaves the action blocked, `approve-once` retries the exact blocked action once through the runtime's escalation path, and `approve-rule` retries with the proposed reusable prefix rule when one was present. This is an FO intent signal, not a bypass of host-native security; if the host still presents its own approval prompt, honor that prompt normally. + ## Working Principles **Prefer a code gate over a prose-only rule.** When a guarantee can be enforced by the binary or a failing test (a `status` guard, a test that fails on violation), prefer that. A prose-only rule's ceiling is "the wording is present"; wording-present is not behavior. A prose-only rule must not count as AC satisfaction on its own: if the guarantee matters, the real assurance is a code-level gate underneath, and the prose points at it. An AC of the form "the contract says X" is satisfied only by "the binary or a test enforces X, and here is the run that proves it." The gate's AC cross-check refuses a criterion whose only proof is review of the entity's own prose. From 79c38c12e6bf5586d15b6006731d0200fb73cee0 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 14:22:45 +0800 Subject: [PATCH 11/21] fix(bridge): make permission alerts non-blocking --- docs/dev/bridge-egress-contract.md | 2 +- internal/bridgealert/alert.go | 79 ++++++++++++++----- internal/bridgealert/alert_test.go | 58 +++++++++++++- internal/cli/bridge_alert_test.go | 42 +++++++++- internal/cli/cli.go | 61 +++++++++++--- .../references/first-officer-shared-core.md | 2 +- 6 files changed, 206 insertions(+), 38 deletions(-) diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 4e52ab460..93cdb40e2 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -89,7 +89,7 @@ One JSON object per line, appended when the FO is blocked by a captain-owned hos - `command` is a concise command summary, not a secret-bearing shell transcript. - `prefix_rule` is optional and present only when the FO has a narrowly scoped reusable approval to propose. - Bridge overlays a later inbox `permission-decision` record for the same `request_id` to show approved/denied state; the alert file itself remains append-only. -- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge alert permission ...`. The helper returns `{"id":"...","queued":true}` and appends one line to `_bridge/fo-alerts.jsonl`. +- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge alert permission ...`. The helper returns `{"id":"...","request_id":"...","queued":true}` and appends one line to `_bridge/fo-alerts.jsonl`. If the helper cannot queue the alert, it still exits without blocking the FO command and returns `{"queued":false,"error":"..."}`. ## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) diff --git a/internal/bridgealert/alert.go b/internal/bridgealert/alert.go index 9d63bffe7..443e500e2 100644 --- a/internal/bridgealert/alert.go +++ b/internal/bridgealert/alert.go @@ -6,11 +6,16 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "fmt" "os" "path/filepath" "strings" "time" + "unicode" +) + +const ( + maxReasonLen = 240 + maxCommandLen = 500 ) // PermissionAlert is the stable JSONL shape Bridge reads from @@ -46,31 +51,35 @@ type PermissionOptions struct { } type Result struct { - ID string `json:"id"` - Queued bool `json:"queued"` + ID string `json:"id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Queued bool `json:"queued"` + Error string `json:"error,omitempty"` } // AppendPermission writes one open permission alert under root/_bridge. func AppendPermission(opts PermissionOptions) (Result, error) { + id := strings.TrimSpace(opts.ID) + if id == "" { + id = fallbackID() + if generated, err := newID(); err == nil { + id = generated + } + } + result := Result{ID: id, RequestID: id} root := opts.Root if root == "" { root = "." } absRoot, err := filepath.Abs(root) if err != nil { - return Result{}, err + return result.withError(err.Error()), nil } - reason := strings.TrimSpace(opts.Reason) + reason := oneLineSummary(opts.Reason, maxReasonLen) if reason == "" { - return Result{}, fmt.Errorf("permission alert: reason is required") - } - id := strings.TrimSpace(opts.ID) - if id == "" { - id, err = newID() - if err != nil { - return Result{}, err - } + return result.withError("permission alert: reason is required"), nil } + command := oneLineSummary(opts.Command, maxCommandLen) now := time.Now().UTC if opts.Now != nil { now = func() time.Time { return opts.Now().UTC() } @@ -86,27 +95,55 @@ func AppendPermission(opts PermissionOptions) (Result, error) { Host: strings.TrimSpace(opts.Host), SessionID: strings.TrimSpace(opts.SessionID), Reason: reason, - Command: strings.TrimSpace(opts.Command), + Command: command, PrefixRule: cleanedPrefixRule(opts.PrefixRule), Status: "open", } dir := filepath.Join(absRoot, "_bridge") if err := os.MkdirAll(dir, 0o755); err != nil { - return Result{}, err + return result.withError(err.Error()), nil } f, err := os.OpenFile(filepath.Join(dir, "fo-alerts.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { - return Result{}, err + return result.withError(err.Error()), nil } defer func() { _ = f.Close() }() data, err := json.Marshal(alert) if err != nil { - return Result{}, err + return result.withError(err.Error()), nil } if _, err := f.Write(append(data, '\n')); err != nil { - return Result{}, err + return result.withError(err.Error()), nil } - return Result{ID: id, Queued: true}, nil + result.Queued = true + return result, nil +} + +func (r Result) withError(msg string) Result { + r.Queued = false + r.Error = msg + return r +} + +func oneLineSummary(in string, maxLen int) string { + in = strings.TrimSpace(in) + var b strings.Builder + prevSpace := false + for _, r := range in { + if unicode.IsControl(r) || unicode.IsSpace(r) { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + if b.Len() >= maxLen { + break + } + } + return strings.TrimSpace(b.String()) } func cleanedPrefixRule(in []string) []string { @@ -127,3 +164,7 @@ func newID() (string, error) { } return "perm_" + hex.EncodeToString(b[:]), nil } + +func fallbackID() string { + return "perm_" + hex.EncodeToString([]byte(time.Now().UTC().Format("20060102150405.000000000"))) +} diff --git a/internal/bridgealert/alert_test.go b/internal/bridgealert/alert_test.go index 0253c11df..9fb489f7b 100644 --- a/internal/bridgealert/alert_test.go +++ b/internal/bridgealert/alert_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" ) @@ -29,6 +30,9 @@ func TestAppendPermissionWritesAlert(t *testing.T) { if got.ID != "perm-1" || !got.Queued { t.Fatalf("result = %+v", got) } + if got.RequestID != "perm-1" { + t.Fatalf("request id = %q, want alias", got.RequestID) + } data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) if err != nil { t.Fatal(err) @@ -48,8 +52,56 @@ func TestAppendPermissionWritesAlert(t *testing.T) { } } -func TestAppendPermissionRequiresReason(t *testing.T) { - if _, err := AppendPermission(PermissionOptions{Root: t.TempDir()}); err == nil { - t.Fatal("expected missing reason to fail") +func TestAppendPermissionMissingReasonIsNonBlocking(t *testing.T) { + got, err := AppendPermission(PermissionOptions{Root: t.TempDir(), ID: "perm-1"}) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendPermissionWriteFailureIsNonBlocking(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "_bridge"), []byte("not a dir"), 0o600); err != nil { + t.Fatal(err) + } + got, err := AppendPermission(PermissionOptions{Root: root, ID: "perm-1", Reason: "sandbox blocked"}) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendPermissionNormalizesOneLineSummaries(t *testing.T) { + root := t.TempDir() + got, err := AppendPermission(PermissionOptions{ + Root: root, + ID: "perm-1", + Reason: "sandbox\nblocked\tstate gitdir", + Command: "git status\n" + strings.Repeat("x", 800), + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v, want queued", got) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + var alert PermissionAlert + if err := json.Unmarshal(bytes.TrimSpace(data), &alert); err != nil { + t.Fatal(err) + } + if alert.Reason != "sandbox blocked state gitdir" { + t.Fatalf("reason = %q", alert.Reason) + } + if strings.ContainsAny(alert.Command, "\n\t") || len(alert.Command) > maxCommandLen { + t.Fatalf("command = %q len=%d", alert.Command, len(alert.Command)) } } diff --git a/internal/cli/bridge_alert_test.go b/internal/cli/bridge_alert_test.go index cdf654267..46547bac3 100644 --- a/internal/cli/bridge_alert_test.go +++ b/internal/cli/bridge_alert_test.go @@ -36,13 +36,14 @@ func TestBridgeAlertPermissionHiddenCLIWritesAlert(t *testing.T) { t.Fatalf("stderr = %q, want empty", stderr.String()) } var result struct { - ID string `json:"id"` - Queued bool `json:"queued"` + ID string `json:"id"` + RequestID string `json:"request_id"` + Queued bool `json:"queued"` } if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) } - if result.ID != "perm-1" || !result.Queued { + if result.ID != "perm-1" || result.RequestID != "perm-1" || !result.Queued { t.Fatalf("result = %+v", result) } data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) @@ -54,3 +55,38 @@ func TestBridgeAlertPermissionHiddenCLIWritesAlert(t *testing.T) { t.Fatalf("alert file missing fields:\n%s", data) } } + +func TestBridgeAlertPermissionHiddenCLIRejectsMissingFlagValueWithoutFailing(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{ + "bridge", "alert", "permission", + "--repo-root", root, + "--id", "perm-1", + "--reason", + "--command", "git status", + }, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var result struct { + Queued bool `json:"queued"` + Error string `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if result.Queued || !strings.Contains(result.Error, "missing value for --reason") { + t.Fatalf("result = %+v", result) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "fo-alerts.jsonl")); !os.IsNotExist(err) { + t.Fatalf("alert file err = %v, want not exist", err) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7ec09d012..bfe6fd925 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -490,17 +490,12 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { return nil } if len(args) >= 2 && args[0] == "alert" && args[1] == "permission" { - result, err := bridgealert.AppendPermission(bridgealert.PermissionOptions{ - Root: parseBridgeStringFlag(args[2:], "--repo-root", dir), - ID: parseBridgeStringFlag(args[2:], "--id", ""), - Workflow: parseBridgeStringFlag(args[2:], "--workflow", ""), - Entity: parseBridgeStringFlag(args[2:], "--entity", ""), - Host: parseBridgeHost(args[2:]), - SessionID: parseBridgeStringFlag(args[2:], "--session-id", ""), - Reason: parseBridgeStringFlag(args[2:], "--reason", ""), - Command: parseBridgeStringFlag(args[2:], "--command", ""), - PrefixRule: parseBridgeCSVFlag(args[2:], "--prefix-rule"), - }) + opts, parseErr := parseBridgeAlertPermission(args[2:], dir) + if parseErr != "" { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(bridgealert.Result{Queued: false, Error: parseErr}) + return nil + } + result, err := bridgealert.AppendPermission(opts) if err != nil { return err } @@ -512,6 +507,46 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { } } +func parseBridgeAlertPermission(args []string, fallbackRoot string) (bridgealert.PermissionOptions, string) { + opts := bridgealert.PermissionOptions{Root: fallbackRoot} + for i := 0; i < len(args); i++ { + key, value, inline := strings.Cut(args[i], "=") + if !strings.HasPrefix(key, "--") { + return opts, "unexpected positional argument: " + args[i] + } + if !inline { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return opts, "missing value for " + key + } + i++ + value = args[i] + } + switch key { + case "--repo-root": + opts.Root = value + case "--id": + opts.ID = value + case "--workflow": + opts.Workflow = value + case "--entity": + opts.Entity = value + case "--host": + opts.Host = value + case "--session-id": + opts.SessionID = value + case "--reason": + opts.Reason = value + case "--command": + opts.Command = value + case "--prefix-rule": + opts.PrefixRule = csvParts(value) + default: + return opts, "unknown flag: " + key + } + } + return opts, "" +} + func parseBridgeHost(args []string) string { for i := 0; i < len(args); i++ { if args[i] == "--host" && i+1 < len(args) { @@ -541,6 +576,10 @@ func parseBridgeCSVFlag(args []string, name string) []string { if raw == "" { return nil } + return csvParts(raw) +} + +func csvParts(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index a148ad6a6..229d5d5b8 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -233,7 +233,7 @@ When a host sandbox or permission boundary blocks a workflow action that would o spacedock bridge alert permission --host --workflow --entity --reason "" --command "" [--prefix-rule "git,-C"] ``` -The helper appends `_bridge/fo-alerts.jsonl`, which Bridge renders as an Approve/Deny alert. A Bridge `permission-decision` inbox record with the returned `request_id` is the typed captain response: `deny` leaves the action blocked, `approve-once` retries the exact blocked action once through the runtime's escalation path, and `approve-rule` retries with the proposed reusable prefix rule when one was present. This is an FO intent signal, not a bypass of host-native security; if the host still presents its own approval prompt, honor that prompt normally. +The helper appends `_bridge/fo-alerts.jsonl`, which Bridge renders as an Approve/Deny alert. The helper's returned `id` is the alert join key; Bridge echoes it back as `request_id` on the typed `permission-decision` inbox record. `deny` leaves the action blocked, `approve-once` retries the exact blocked action once through the runtime's escalation path, and `approve-rule` retries with the proposed reusable prefix rule when one was present. This is an FO intent signal, not a bypass of host-native security; if the host still presents its own approval prompt, honor that prompt normally. ## Working Principles From 1b16bbe3ed607c5b6ed95ac7e17d25f78bc8f4e3 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 19:20:29 +0800 Subject: [PATCH 12/21] fix(bridge): wake codex from durable delivery state Signed-off-by: Jared Scott --- docs/dev/_mods/bridge-inbox.md | 13 +- docs/dev/bridge-egress-contract.md | 6 +- internal/bridgeingress/wake.go | 245 ++++++++++++++---- internal/bridgeingress/wake_test.go | 221 +++++++++++++++- .../fo_feed_and_eager_drain_test.go | 2 +- .../gate_drain_before_present_test.go | 9 +- .../references/first-officer-shared-core.md | 2 +- 7 files changed, 428 insertions(+), 70 deletions(-) diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 56fb02147..ec82a87dd 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -26,7 +26,7 @@ esac **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"id":"","ts":"","kind":"tell"|"conn"|"decision"|"permission-decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":"","request_id":""} +{"id":"","ts":"","kind":"tell"|"conn"|"decision"|"permission-decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":"","verdict":"approve|reject|redo, plain decision only","directives":["plain decision route-back notes"],"request_id":""} ``` `id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set` when Bridge can freeze a recipient set: for `target == "all"` with known fleet members, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. `target` remains for backward compatibility with older records and unknown-recipient broadcasts. @@ -83,10 +83,13 @@ Drain newly-queued captain intent addressed to this workflow, if any: sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl ``` If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` / `field` / `value` on a `decision` record; `request_id` on a `permission-decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: +4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` plus either `field` / `value` or `verdict` / `directives` on a `decision` record; `request_id` on a `permission-decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and append a `reply` record with `status:"answered"`. - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). Then append a `conn-ack` record with `status:"accepted"`. `granted: false` → take the conn back: stop at every gate for the captain's call again. Then append a `conn-ack` record with `status:"released"`. - - **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. Append a `decision-ack` record with `status:"applied"` when the field value is present and gate resolution finished or was already satisfied; append `status:"blocked"` when the intent is valid but execution could not finish; append `status:"rejected"` when the intent is invalid or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. + - **`kind == "decision"`** — the captain resolved a gate from Bridge. This is a captain decision, not FO self-approval, and Bridge has not advanced the entity. Resolve `entity` (its slug) in THIS workflow, verify the entity is still at a compatible current gate, and apply the workflow's normal gate-resolution flow: + - Self-described decision shape: `field` plus `value`. Treat it as the captain's selected gate value: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then continue the current gate exactly as if the captain had decided it in chat. + - Plain decision shape: `verdict` plus optional `directives`. `approve` advances through the gate's own approve path and side effects. `reject` / `redo` route to the gate's `feedback-to` stage with the supplied directives. If the workflow has external actions (GitHub review, Linear update, labels, etc.), perform them before terminal state or final acknowledgement. + - Append a `decision-ack` record only after the decision is applied, blocked, or rejected. Use `status:"applied"` when gate resolution finished or was already satisfied; `status:"blocked"` when the intent is valid but execution could not finish; `status:"rejected"` when the intent is invalid, stale, or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. - **`kind == "permission-decision"`** — the captain resolved a top-level FO permission alert. Match `request_id` to the open `_bridge/fo-alerts.jsonl` record you emitted. `value: "deny"` means do not retry the blocked action; append a `permission-ack` with `status:"denied"` and report the block remains. `value: "approve-once"` means retry the exact blocked host action once using the runtime's escalation path. `value: "approve-rule"` means retry with the alert's proposed `prefix_rule` when one was present; otherwise treat it as `approve-once`. Append `permission-ack` with `status:"accepted"` before retrying; append `status:"blocked"` if the retry could not be started. This Bridge approval is FO intent, not a bypass of a host-native security prompt; if the host still presents a native approval dialog, honor it normally. 5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`. 6. Report to the captain: how many intents you drained (for this workflow) and what you did with each. @@ -98,7 +101,7 @@ If a record is malformed (not valid JSON, missing required fields for its `kind` Append one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected: ``` -{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision"|"permission-decision","status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","request_id":"...","session_id":"optional","host":"optional"} +{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision"|"permission-decision","status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","verdict":"...","request_id":"...","session_id":"optional","host":"optional"} ``` Rules: @@ -108,7 +111,7 @@ Rules: - `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts`. - `intent_kind` echoes the inbox `kind`. - `text` is optional but, when present, must be a single line. -- Echo `granted`, `entity`, `field`, `value`, and `request_id` when present and relevant. +- Echo `granted`, `entity`, `field`, `value`, `verdict`, and `request_id` when present and relevant. - `session_id` should be `"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}"` when non-empty; `host` is optional. - Write one complete newline-terminated JSON object in one append operation, e.g. build the full JSON line with a structured encoder and append it with a single `>> _bridge/fo-replies.jsonl` write. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl`. - Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed reply append must never block the FO from completing the drained intent or advancing the cursor after action. diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 93cdb40e2..82114b865 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -65,14 +65,14 @@ One JSON object per line, appended when the FO dispatches, advances, or complete One JSON object per line, appended after the FO has interpreted, accepted, or applied an inbox intent. This is the explanatory reply/ack stream for Bridge's conversation loop; the per-workflow inbox cursor remains the delivery/read source of truth. Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. The stream is best-effort explanatory content, not an exactly-once delivery ledger. ``` -{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","request_id":"...","session_id":"optional","host":"optional"} +{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","verdict":"...","request_id":"...","session_id":"optional","host":"optional"} ``` - `target` is the actual acknowledging workflow slug, never `all`. - `in_reply_to_line` is the physical line number in `_bridge/inbox.jsonl` that produced the ack. - `kind`: `reply` for `tell`, `conn-ack` for `conn`, `decision-ack` for `decision`, `permission-ack` for `permission-decision`. -- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant or accepts a permission retry; `released` when the FO gives the conn back; `applied` when a decision field value is present and gate resolution finished or was already satisfied; `denied` when the captain rejects a permission request; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. -- Echo `granted`, `entity`, `field`, `value`, and `request_id` when present and relevant to the intent. Keep `text` one line. +- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant or accepts a permission retry; `released` when the FO gives the conn back; `applied` when a decision is present and gate resolution finished or was already satisfied; `denied` when the captain rejects a permission request; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. +- Echo `granted`, `entity`, `field`, `value`, `verdict`, and `request_id` when present and relevant to the intent. Keep `text` one line. - Append-only and best-effort: write one complete newline-terminated JSON object in one append operation, and never rewrite `fo-replies.jsonl`. - → **All hosts (host-neutral producer):** the `bridge-inbox` mod appends replies/acks while draining `_bridge/inbox.jsonl`. diff --git a/internal/bridgeingress/wake.go b/internal/bridgeingress/wake.go index db9a68890..06f6fb657 100644 --- a/internal/bridgeingress/wake.go +++ b/internal/bridgeingress/wake.go @@ -53,12 +53,34 @@ type inboxRecord struct { Line int `json:"-"` } +type replyRecord struct { + Schema int `json:"schema"` + Kind string `json:"kind"` + Target string `json:"target"` + InReplyToID string `json:"in_reply_to_id"` + InReplyToLine int `json:"in_reply_to_line"` + InReplyToTS time.Time `json:"in_reply_to_ts"` + IntentKind string `json:"intent_kind"` + Status string `json:"status"` +} + type heartbeat struct { SessionID string `json:"session_id"` TS time.Time `json:"ts"` State string `json:"state"` } +type sessionMarker struct { + SessionID string `json:"session_id"` + Workflow string `json:"workflow"` +} + +type eventRecord struct { + Host string `json:"host"` + TS time.Time `json:"ts"` + SessionID string `json:"session_id"` +} + type wakeEvent struct { Timestamp string `json:"timestamp"` TS string `json:"ts"` @@ -74,9 +96,9 @@ type wakeEvent struct { Error string `json:"error,omitempty"` } -// Wake resumes live Codex FO sessions that are addressed by inbox records after -// the successful-wake cursor. It advances the cursor only after at least one -// resume succeeds; no-session records remain eligible for a future wake. +// Wake resumes Codex FO sessions for inbox records that are not yet delivered by +// the addressed workflow cursors or an FO reply/ack. Starting a resume process is +// only a wake attempt; delivery is confirmed later by cursor advancement or ack. func Wake(ctx context.Context, opts Options) Result { host := normalizeHost(opts.Host) if host == "" { @@ -104,35 +126,38 @@ func Wake(ctx context.Context, opts Options) Result { } defer unlock() - cursor := readCursor(absRoot) - records, lastLine, err := readPendingInbox(absRoot, cursor) + allRecords, err := readInbox(absRoot) if err != nil { return Result{Status: "failed", Error: err.Error()} } - if len(records) == 0 { - if lastLine > cursor { - _ = writeCursor(absRoot, lastLine) + replies := loadReplies(absRoot) + var records []inboxRecord + for _, rec := range allRecords { + if len(pendingTargetsFor(absRoot, rec, opts.Members, replies)) > 0 { + records = append(records, rec) } + } + if len(records) == 0 { return Result{Status: "noop", Message: "no pending inbox records"} } sessions := map[string]*sessionWake{} targetsMissingSession := map[string]bool{} for _, rec := range records { - targets := targetsFor(absRoot, rec, opts.Members) + targets := pendingTargetsFor(absRoot, rec, opts.Members, replies) if len(targets) == 0 { continue } for _, target := range targets { - hb, ok := loadHeartbeat(absRoot, target, now()) - if !ok || hb.SessionID == "" { + sessionID, ok := resumableSessionID(absRoot, target) + if !ok { targetsMissingSession[target] = true continue } - w := sessions[hb.SessionID] + w := sessions[sessionID] if w == nil { - w = &sessionWake{SessionID: hb.SessionID, TargetSet: map[string]bool{}} - sessions[hb.SessionID] = w + w = &sessionWake{SessionID: sessionID, TargetSet: map[string]bool{}} + sessions[sessionID] = w } w.TargetSet[target] = true w.Lines = appendUniqueInt(w.Lines, rec.Line) @@ -152,9 +177,9 @@ func Wake(ctx context.Context, opts Options) Result { Status: "skipped-no-session", Lines: recordLines(records), Targets: targets, - Message: "no fresh FO heartbeat with a Codex session id", + Message: "no resumable Codex session id", }) - return Result{Status: "skipped-no-session", Lines: recordLines(records), Targets: targets, Message: "no fresh FO heartbeat with a Codex session id"} + return Result{Status: "skipped-no-session", Lines: recordLines(records), Targets: targets, Message: "no resumable Codex session id"} } resume := opts.Resume @@ -197,9 +222,6 @@ func Wake(ctx context.Context, opts Options) Result { }) } - if successes > 0 { - _ = writeCursor(absRoot, lastLine) - } result := Result{ Status: "woke", Lines: recordLines(records), @@ -265,6 +287,9 @@ func execCodexResume(ctx context.Context, bin, sessionID, prompt string) error { if err := cmd.Start(); err != nil { return fmt.Errorf("codex exec resume: %w", err) } + go func() { + _ = cmd.Wait() + }() return nil } @@ -295,30 +320,13 @@ func acquireLock(root string) (func(), bool) { return func() { _ = os.Remove(path) }, true } -func readCursor(root string) int { - data, err := os.ReadFile(filepath.Join(root, "_bridge", ".wake-cursor.codex")) - if err != nil { - return 0 - } - n, _ := strconv.Atoi(strings.TrimSpace(string(data))) - return n -} - -func writeCursor(root string, line int) error { - dir := filepath.Join(root, "_bridge") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - return os.WriteFile(filepath.Join(dir, ".wake-cursor.codex"), []byte(strconv.Itoa(line)+"\n"), 0o600) -} - -func readPendingInbox(root string, cursor int) ([]inboxRecord, int, error) { +func readInbox(root string) ([]inboxRecord, error) { f, err := os.Open(filepath.Join(root, "_bridge", "inbox.jsonl")) if os.IsNotExist(err) { - return nil, cursor, nil + return nil, nil } if err != nil { - return nil, cursor, err + return nil, err } defer f.Close() @@ -327,7 +335,7 @@ func readPendingInbox(root string, cursor int) ([]inboxRecord, int, error) { scanner := bufio.NewScanner(f) for scanner.Scan() { lineNo++ - if lineNo <= cursor || strings.TrimSpace(scanner.Text()) == "" { + if strings.TrimSpace(scanner.Text()) == "" { continue } var rec inboxRecord @@ -338,9 +346,73 @@ func readPendingInbox(root string, cursor int) ([]inboxRecord, int, error) { out = append(out, rec) } if err := scanner.Err(); err != nil { - return nil, lineNo, err + return nil, err + } + return out, nil +} + +func pendingTargetsFor(root string, rec inboxRecord, members []string, replies map[string]bool) []string { + var pending []string + for _, target := range targetsFor(root, rec, members) { + if inboxCursor(root, target) >= rec.Line { + continue + } + if replies[replyKey(rec, target)] { + continue + } + pending = append(pending, target) + } + return pending +} + +func inboxCursor(root, slug string) int { + if !safeSlugPattern.MatchString(slug) || slug == "." || slug == ".." { + return 0 + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", ".inbox-cursor."+slug)) + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(data))) + if n < 0 { + return 0 + } + return n +} + +func loadReplies(root string) map[string]bool { + out := map[string]bool{} + f, err := os.Open(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + return out + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "" { + continue + } + var rec replyRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + if rec.Schema != 1 || rec.Target == "" || rec.InReplyToLine <= 0 || rec.IntentKind == "" { + continue + } + if !safeSlugPattern.MatchString(rec.Target) || rec.Target == "." || rec.Target == ".." { + continue + } + out[replyKey(inboxRecord{ID: rec.InReplyToID, TS: rec.InReplyToTS, Kind: rec.IntentKind, Line: rec.InReplyToLine}, rec.Target)] = true + } + return out +} + +func replyKey(rec inboxRecord, target string) string { + id := rec.ID + if id == "" { + id = rec.TS.Format(time.RFC3339Nano) } - return out, lineNo, nil + return strconv.Itoa(rec.Line) + "\x00" + id + "\x00" + rec.Kind + "\x00" + target } func targetsFor(root string, rec inboxRecord, members []string) []string { @@ -384,6 +456,19 @@ func cleanSlugs(in []string) []string { } func loadHeartbeat(root, slug string, now time.Time) (heartbeat, bool) { + var hb heartbeat + if !safeSlugPattern.MatchString(slug) || slug == "." || slug == ".." { + return hb, false + } + hb, ok := loadHeartbeatAnyAge(root, slug) + if !ok || hb.TS.IsZero() { + return hb, false + } + age := now.Sub(hb.TS) + return hb, age >= 0 && age <= liveWindow +} + +func loadHeartbeatAnyAge(root, slug string) (heartbeat, bool) { var hb heartbeat if !safeSlugPattern.MatchString(slug) || slug == "." || slug == ".." { return hb, false @@ -392,11 +477,81 @@ func loadHeartbeat(root, slug string, now time.Time) (heartbeat, bool) { if err != nil { return hb, false } - if err := json.Unmarshal(data, &hb); err != nil || hb.TS.IsZero() { + if err := json.Unmarshal(data, &hb); err != nil { return hb, false } - age := now.Sub(hb.TS) - return hb, age >= 0 && age <= liveWindow + return hb, true +} + +func resumableSessionID(root, slug string) (string, bool) { + if hb, ok := loadHeartbeatAnyAge(root, slug); ok && strings.TrimSpace(hb.SessionID) != "" { + return strings.TrimSpace(hb.SessionID), true + } + if sessionID, ok := sessionIDFromMarkers(root, slug); ok { + return sessionID, true + } + if sessionID, ok := latestCodexEventSession(root); ok { + return sessionID, true + } + return "", false +} + +func sessionIDFromMarkers(root, slug string) (string, bool) { + matches, _ := filepath.Glob(filepath.Join(root, "_bridge", "sessions", "*.json")) + sort.Strings(matches) + var bestPath string + var bestMod time.Time + var bestSession string + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var rec sessionMarker + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + if rec.Workflow != slug || strings.TrimSpace(rec.SessionID) == "" { + continue + } + info, err := os.Stat(path) + mod := time.Time{} + if err == nil { + mod = info.ModTime() + } + if bestSession == "" || mod.After(bestMod) || (mod.Equal(bestMod) && path > bestPath) { + bestPath = path + bestMod = mod + bestSession = strings.TrimSpace(rec.SessionID) + } + } + return bestSession, bestSession != "" +} + +func latestCodexEventSession(root string) (string, bool) { + f, err := os.Open(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + return "", false + } + defer f.Close() + var best eventRecord + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var rec eventRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + if normalizeHost(rec.Host) != "codex" || strings.TrimSpace(rec.SessionID) == "" { + continue + } + if best.SessionID == "" || rec.TS.After(best.TS) { + best = rec + } + } + if best.SessionID == "" { + return "", false + } + return strings.TrimSpace(best.SessionID), true } func appendWakeEvent(root string, event wakeEvent) { diff --git a/internal/bridgeingress/wake_test.go b/internal/bridgeingress/wake_test.go index dece83141..74735848a 100644 --- a/internal/bridgeingress/wake_test.go +++ b/internal/bridgeingress/wake_test.go @@ -10,7 +10,7 @@ import ( "time" ) -func TestWakeResumesFreshHeartbeatSessionAndAdvancesCursor(t *testing.T) { +func TestWakeResumesFreshHeartbeatSessionWithoutAdvancingWakeCursor(t *testing.T) { root := t.TempDir() now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) writeInbox(t, root, @@ -42,8 +42,8 @@ func TestWakeResumesFreshHeartbeatSessionAndAdvancesCursor(t *testing.T) { t.Fatalf("prompt missing %q:\n%s", want, gotPrompt) } } - if cursor := readFile(t, filepath.Join(root, "_bridge", ".wake-cursor.codex")); cursor != "1\n" { - t.Fatalf("cursor = %q, want 1", cursor) + if _, err := os.Stat(filepath.Join(root, "_bridge", ".wake-cursor.codex")); !os.IsNotExist(err) { + t.Fatalf("wake cursor should not be written after resume launch: %v", err) } var ev wakeEvent readLastJSON(t, filepath.Join(root, "_bridge", "wake-events.jsonl"), &ev) @@ -77,16 +77,14 @@ func TestWakeNoSessionDoesNotAdvanceCursor(t *testing.T) { } } -func TestWakeSkipsAlreadyWokenLines(t *testing.T) { +func TestWakeRetriesAlreadyStartedUndeliveredLines(t *testing.T) { root := t.TempDir() now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) writeInbox(t, root, `{"id":"i1","ts":"2026-07-02T11:58:00Z","kind":"tell","target":"a"}`, `{"id":"i2","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, ) - if err := writeCursor(root, 1); err != nil { - t.Fatal(err) - } + writeWakeCursor(t, root, "1") writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) var prompt string @@ -103,11 +101,166 @@ func TestWakeSkipsAlreadyWokenLines(t *testing.T) { if res.Status != "woke" { t.Fatalf("result = %+v, want woke", res) } - if !strings.Contains(prompt, "Pending physical inbox lines for this session: 2") || strings.Contains(prompt, "1,2") { - t.Fatalf("prompt did not scope to pending line 2:\n%s", prompt) + if !strings.Contains(prompt, "Pending physical inbox lines for this session: 1,2") { + t.Fatalf("prompt did not retry undelivered lines despite prior wake cursor:\n%s", prompt) + } +} + +func TestWakeSkipsDeliveredByInboxCursor(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:58:00Z","kind":"tell","target":"a"}`, + ) + writeInboxCursor(t, root, "a", "1") + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(context.Context, string, string) error { + t.Fatal("resume should not run for cursor-delivered line") + return nil + }, + }) + + if res.Status != "noop" { + t.Fatalf("result = %+v, want noop", res) + } +} + +func TestWakeSkipsDeliveredByReplyAck(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:58:00Z","kind":"decision","target":"a"}`, + ) + writeReplies(t, root, + `{"schema":1,"ts":"2026-07-02T12:00:00Z","kind":"decision-ack","target":"a","in_reply_to_id":"i1","in_reply_to_line":1,"in_reply_to_ts":"2026-07-02T11:58:00Z","intent_kind":"decision","status":"applied"}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(context.Context, string, string) error { + t.Fatal("resume should not run for ack-delivered line") + return nil + }, + }) + + if res.Status != "noop" { + t.Fatalf("result = %+v, want noop", res) + } +} + +func TestWakeOnlyTargetsUndeliveredMembers(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:58:00Z","kind":"tell","target":"all","target_set":["a","b"]}`, + ) + writeInboxCursor(t, root, "a", "1") + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + writeHeartbeat(t, root, "b", "session-b", now.Add(-time.Minute)) + + var gotSession, gotPrompt string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, sessionID, prompt string) error { + gotSession = sessionID + gotPrompt = prompt + return nil + }, + }) + + if res.Status != "woke" || res.Sessions != 1 || gotSession != "session-b" { + t.Fatalf("result=%+v session=%q, want only pending target b", res, gotSession) } - if cursor := readFile(t, filepath.Join(root, "_bridge", ".wake-cursor.codex")); cursor != "2\n" { - t.Fatalf("cursor = %q, want 2", cursor) + if strings.Contains(gotPrompt, "Addressed workflow slugs: a") || !strings.Contains(gotPrompt, "Addressed workflow slugs: b") { + t.Fatalf("prompt should name only pending target b:\n%s", gotPrompt) + } +} + +func TestWakeStaleHeartbeatWithSessionIsResumable(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-2*time.Hour)) + + var gotSession string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, sessionID, _ string) error { + gotSession = sessionID + return nil + }, + }) + + if res.Status != "woke" || gotSession != "session-a" { + t.Fatalf("result=%+v session=%q, want stale heartbeat resumable", res, gotSession) + } +} + +func TestWakeSessionMarkerWithoutHeartbeatIsResumable(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, + ) + writeSessionMarker(t, root, "session-a", "a") + + var gotSession string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, sessionID, _ string) error { + gotSession = sessionID + return nil + }, + }) + + if res.Status != "woke" || gotSession != "session-a" { + t.Fatalf("result=%+v session=%q, want session marker resumable", res, gotSession) + } +} + +func TestWakeMultipleTargetsCoalesceOneSession(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"all","target_set":["a","b"]}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + writeHeartbeat(t, root, "b", "session-a", now.Add(-time.Minute)) + + var calls int + var prompt string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, _ string, p string) error { + calls++ + prompt = p + return nil + }, + }) + + if res.Status != "woke" || res.Sessions != 1 || calls != 1 { + t.Fatalf("result=%+v calls=%d, want one resumed session", res, calls) + } + if !strings.Contains(prompt, "Addressed workflow slugs: a,b") { + t.Fatalf("prompt missing coalesced targets:\n%s", prompt) } } @@ -135,6 +288,52 @@ func writeHeartbeat(t *testing.T, root, slug, sessionID string, ts time.Time) { } } +func writeInboxCursor(t *testing.T, root, slug, content string) { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".inbox-cursor."+slug), []byte(content+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeWakeCursor(t *testing.T, root, content string) { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".wake-cursor.codex"), []byte(content+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeReplies(t *testing.T, root string, lines ...string) { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(filepath.Join(dir, "fo-replies.jsonl"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeSessionMarker(t *testing.T, root, sessionID, workflow string) { + t.Helper() + dir := filepath.Join(root, "_bridge", "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"session_id":"` + sessionID + `","workflow":"` + workflow + `"}` + if err := os.WriteFile(filepath.Join(dir, sessionID+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go index b98efdb4c..a3d8c76f2 100644 --- a/internal/contractlint/fo_feed_and_eager_drain_test.go +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -109,7 +109,7 @@ func TestBridgeConversationReplyContract(t *testing.T) { `"intent_kind":""`, `"status":""`, `target` + "` is the actual acknowledging workflow slug, never " + "`all`", - `applied` + "` when a decision field value is present and gate resolution finished or was already satisfied", + `applied` + "` when a decision is present and gate resolution finished or was already satisfied", `blocked` + "` when a valid intent could not finish", `rejected` + "` when an intent is invalid or unresolvable", `not an exactly-once delivery ledger`, diff --git a/internal/contractlint/gate_drain_before_present_test.go b/internal/contractlint/gate_drain_before_present_test.go index c9046cb47..be992e116 100644 --- a/internal/contractlint/gate_drain_before_present_test.go +++ b/internal/contractlint/gate_drain_before_present_test.go @@ -13,9 +13,9 @@ import ( // TestGateAssembleVerdictDrainsBeforePresenting locks the drain-before-present // fix: when the FO reaches a gate, it must first fire that entity's idle hooks // (draining a Bridge-queued `decision` record) and skip the presentation if the -// decision already resolved the gate. Bridge cannot wake a parked FO, so without -// this a queued captain decision sits unprocessed while the FO redundantly -// presents — the gate-vs-inbox race observed on DRC-3354. +// decision already resolved the gate. Bridge wake is best-effort, while delivery +// is confirmed only by the FO-owned drain and ack; without this a queued captain +// decision can still collide with a redundant terminal prompt. func TestGateAssembleVerdictDrainsBeforePresenting(t *testing.T) { path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") data, err := os.ReadFile(path) @@ -26,7 +26,8 @@ func TestGateAssembleVerdictDrainsBeforePresenting(t *testing.T) { for _, r := range []string{ "drain before presenting", - "Bridge cannot wake a parked FO", + "Bridge wake is best-effort", + "FO-owned drain and ack", "do NOT present", } { if !strings.Contains(content, r) { diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 229d5d5b8..86355185a 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -131,7 +131,7 @@ If the stage is gated, `«gate.assemble-verdict»(slug, stage)`, then route on t ## «gate.assemble-verdict»(slug, stage): assemble the gate review and render the verdict -- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's workflow `idle` hooks once (keyed by its `$SLUG`) so a captain decision already queued from Bridge for this gate — a `decision` inbox record (e.g. set `verdict`) — is drained and applied now, then re-read the entity's status. Bridge cannot wake a parked FO (it queues, the FO pulls), so a decision that arrived since the last idle tick would otherwise sit unprocessed while you redundantly present a gate the captain has already answered. If the drain advanced the entity past `{stage}` (the queued decision resolved this gate), do NOT present — report what you applied and return. Acting on the captain's own queued decision is honoring it, not a self-approval (the **block** below still forbids inventing a verdict). If nothing was queued, the drain is a no-op and you present normally. +- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's workflow `idle` hooks once (keyed by its `$SLUG`) so a captain decision already queued from Bridge for this gate — a `decision` inbox record (field/value for self-described gates, or verdict/directives for plain gates) — is drained and applied now, then re-read the entity's status. Bridge wake is best-effort, while delivery is confirmed only by this FO-owned drain and ack; without this drain, a decision that arrived since the last idle tick could still sit unprocessed while you redundantly present a gate the captain has already answered. If the drain advanced the entity past `{stage}` (the queued decision resolved this gate), do NOT present — report what you applied and return. Acting on the captain's own queued decision is honoring it, not a self-approval (the **block** below still forbids inventing a verdict). If nothing was queued, the drain is a no-op and you present normally. - **effect — extract (deterministic):** roll up the structured inputs via the shipped modes — `status --read --checklist` and `status --read --ac-scan`. These feed the verdict; they do not make it. - **effect — decide (judgment):** the verdict (approve/reject, is-this-AC-satisfied, is-this-direction-sound) is irreducible judgment; the FO renders its own `Recommend` line. Present via `Skill(skill="spacedock:present-gate")` and its template + assembly rules. - **done-when:** the gate review is presented and the FO is waiting on the captain's decision, the worker kept alive. From eb1fba3a41bee290de4bec57045d0b43b425fc03 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 2 Jul 2026 20:09:00 +0800 Subject: [PATCH 13/21] fix(bridge): harden wake durability and correct egress docs 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 (.json, not .json). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dev/bridge-egress-contract.md | 2 +- internal/bridgeegress/egress.go | 4 ++ internal/bridgeingress/wake.go | 55 ++++++++++++++-- internal/bridgeingress/wake_test.go | 66 +++++++++++++++++++ scripts/codex-bridge-events.sh | 19 ------ .../claude-first-officer-runtime.md | 2 +- .../references/codex-first-officer-runtime.md | 2 +- .../references/pi-first-officer-runtime.md | 2 +- .../codex_bridge_egress_hook_test.go | 43 ------------ 9 files changed, 122 insertions(+), 73 deletions(-) delete mode 100755 scripts/codex-bridge-events.sh diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 82114b865..5131fd786 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -93,7 +93,7 @@ One JSON object per line, appended when the FO is blocked by a captain-owned hos ## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) -Last-write (first-write-wins per host actor), one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. +First-write-wins per host actor, one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. ``` {"host":"","session_id":"<«session-id»>","agent_id":"","actor_id":"","entity":"","workflow":""} diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go index e3438d2fd..378815d6e 100644 --- a/internal/bridgeegress/egress.go +++ b/internal/bridgeegress/egress.go @@ -18,6 +18,9 @@ import ( const ( defaultMaxLines = 2000 defaultKeepLines = 1000 + // maxScanLine raises bufio's 64KB default so a single oversized line can't + // make the size-cap trim bail (leaving events.jsonl to grow unbounded). + maxScanLine = 1 << 20 ) var safeIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) @@ -246,6 +249,7 @@ func truncateEvents(path string, opts Options) { } var lines []string scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) for scanner.Scan() { lines = append(lines, scanner.Text()) } diff --git a/internal/bridgeingress/wake.go b/internal/bridgeingress/wake.go index 06f6fb657..56c3a8d99 100644 --- a/internal/bridgeingress/wake.go +++ b/internal/bridgeingress/wake.go @@ -19,6 +19,17 @@ import ( const liveWindow = 30 * time.Minute +// maxScanLine raises the per-line scan limit above bufio's 64KB default. Bridge +// control records are small, but a large captain intent must not make the scan +// hard-fail (inbox) or silently stop short (replies/events). +const maxScanLine = 1 << 20 + +func lineScanner(f *os.File) *bufio.Scanner { + s := bufio.NewScanner(f) + s.Buffer(make([]byte, 0, 64*1024), maxScanLine) + return s +} + var safeSlugPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // ResumeFunc resumes a host session with the supplied prompt. @@ -305,12 +316,33 @@ Run the Bridge inbox idle drain now for only those workflow slugs. Honor target_ `, root, filepath.Join(root, "_bridge", "inbox.jsonl"), joinInts(w.Lines), strings.Join(w.Targets(), ",")) } +// staleLockTTL bounds how long a wake lock may persist before another wake may +// reclaim it. A wake pass only starts resume processes and returns — it never +// blocks on Codex — so it holds the lock for well under a second. A lock older +// than this TTL was left by a crashed or killed wake that never ran its deferred +// unlock; reclaiming it keeps a single failure from wedging durable delivery +// permanently. +const staleLockTTL = 5 * time.Minute + func acquireLock(root string) (func(), bool) { dir := filepath.Join(root, "_bridge") if err := os.MkdirAll(dir, 0o755); err != nil { return func() {}, false } path := filepath.Join(dir, ".wake-lock.codex") + if unlock, ok := takeLock(path); ok { + return unlock, true + } + // The lock exists. Reclaim it only if it is stale; otherwise another wake is + // genuinely running. + if info, err := os.Stat(path); err != nil || time.Since(info.ModTime()) <= staleLockTTL { + return func() {}, false + } + _ = os.Remove(path) + return takeLock(path) +} + +func takeLock(path string) (func(), bool) { f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return func() {}, false @@ -332,7 +364,7 @@ func readInbox(root string) ([]inboxRecord, error) { var out []inboxRecord lineNo := 0 - scanner := bufio.NewScanner(f) + scanner := lineScanner(f) for scanner.Scan() { lineNo++ if strings.TrimSpace(scanner.Text()) == "" { @@ -387,7 +419,7 @@ func loadReplies(root string) map[string]bool { return out } defer f.Close() - scanner := bufio.NewScanner(f) + scanner := lineScanner(f) for scanner.Scan() { if strings.TrimSpace(scanner.Text()) == "" { continue @@ -484,18 +516,27 @@ func loadHeartbeatAnyAge(root, slug string) (heartbeat, bool) { } func resumableSessionID(root, slug string) (string, bool) { - if hb, ok := loadHeartbeatAnyAge(root, slug); ok && strings.TrimSpace(hb.SessionID) != "" { - return strings.TrimSpace(hb.SessionID), true + if hb, ok := loadHeartbeatAnyAge(root, slug); ok { + if id := strings.TrimSpace(hb.SessionID); safeSessionID(id) { + return id, true + } } - if sessionID, ok := sessionIDFromMarkers(root, slug); ok { + if sessionID, ok := sessionIDFromMarkers(root, slug); ok && safeSessionID(sessionID) { return sessionID, true } - if sessionID, ok := latestCodexEventSession(root); ok { + if sessionID, ok := latestCodexEventSession(root); ok && safeSessionID(sessionID) { return sessionID, true } return "", false } +// safeSessionID guards a session id read from _bridge/ state before it becomes a +// codex argv positional, so a poisoned marker/heartbeat/event line cannot inject +// a leading-dash token that codex would parse as a flag. +func safeSessionID(s string) bool { + return s != "" && s != "." && s != ".." && safeSlugPattern.MatchString(s) +} + func sessionIDFromMarkers(root, slug string) (string, bool) { matches, _ := filepath.Glob(filepath.Join(root, "_bridge", "sessions", "*.json")) sort.Strings(matches) @@ -535,7 +576,7 @@ func latestCodexEventSession(root string) (string, bool) { } defer f.Close() var best eventRecord - scanner := bufio.NewScanner(f) + scanner := lineScanner(f) for scanner.Scan() { var rec eventRecord if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { diff --git a/internal/bridgeingress/wake_test.go b/internal/bridgeingress/wake_test.go index 74735848a..926b4f28b 100644 --- a/internal/bridgeingress/wake_test.go +++ b/internal/bridgeingress/wake_test.go @@ -264,6 +264,72 @@ func TestWakeMultipleTargetsCoalesceOneSession(t *testing.T) { } } +func TestWakeReclaimsStaleLock(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + + // A crashed wake left a lock behind; its mtime is far older than staleLockTTL. + lock := filepath.Join(root, "_bridge", ".wake-lock.codex") + if err := os.WriteFile(lock, []byte("999999\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-staleLockTTL - time.Minute) + if err := os.Chtimes(lock, old, old); err != nil { + t.Fatal(err) + } + + var gotSession string + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(_ context.Context, sessionID, _ string) error { + gotSession = sessionID + return nil + }, + }) + + if res.Status != "woke" || gotSession != "session-a" { + t.Fatalf("result=%+v session=%q, want stale lock reclaimed and session woken", res, gotSession) + } +} + +func TestWakeSkipsWhenFreshLockHeld(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-02T11:59:00Z","kind":"tell","target":"a"}`, + ) + writeHeartbeat(t, root, "a", "session-a", now.Add(-time.Minute)) + + // A live wake holds the lock (default mtime is now — well within the TTL). + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".wake-lock.codex"), []byte("12345\n"), 0o600); err != nil { + t.Fatal(err) + } + + res := Wake(context.Background(), Options{ + Host: "codex", + Root: root, + Now: func() time.Time { return now }, + Resume: func(context.Context, string, string) error { + t.Fatal("resume must not run while a fresh lock is held") + return nil + }, + }) + + if res.Status != "locked" { + t.Fatalf("result = %+v, want locked", res) + } +} + func writeInbox(t *testing.T, root string, lines ...string) { t.Helper() dir := filepath.Join(root, "_bridge") diff --git a/scripts/codex-bridge-events.sh b/scripts/codex-bridge-events.sh deleted file mode 100755 index c89049cfe..000000000 --- a/scripts/codex-bridge-events.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# ABOUTME: Codex plugin hook wrapper for Bridge egress; observe-only and silent. -# -# Codex loads hooks from hooks/codex-hooks.json, not hooks/hooks.json, because the -# shared Claude hook file uses async:true and Codex skips async command hooks. -# This wrapper intentionally delegates to the public Spacedock egress command so -# Codex packaging does not grow its own private event schema. -set -u - -if [ -n "${SPACEDOCK_BIN:-}" ] && [ -x "${SPACEDOCK_BIN:-}" ]; then - bin="${SPACEDOCK_BIN}" -elif command -v spacedock >/dev/null 2>&1; then - bin="spacedock" -else - exit 0 -fi - -"$bin" bridge egress emit --host codex >/dev/null 2>&1 || : -exit 0 diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index 25be825a1..6fbd4717d 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -36,7 +36,7 @@ To file a seed task, do NOT use the Write tool to hand-assemble frontmatter afte ## Bridge egress (FO liveness/activity → Bridge) -Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"ts","event","session_id","agent_id","agent_type","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod and needs no adapter binding.) +Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod and needs no adapter binding.) - **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The wrapper delegates to `spacedock bridge egress emit --host claude`, which normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi have event producers but do not yet claim deterministic marker parity — see those adapters.) - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude, so `fo.$SLUG.json` carries the same id the event stream and session markers use and Bridge can join liveness to activity. No per-tick `export` is needed; leave `SD_SESSION_ID` unset on Claude (the event producer stamps `events.jsonl`/markers from the hook payload's session id, so an override that differs would desync the heartbeat from the event stream). diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index a687dd1ca..ec876f486 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -53,7 +53,7 @@ The captain is the user of the Codex session. Communicate gate results, clarific ## Bridge egress (FO liveness/activity → Bridge) -Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex the event producer is packaged through Codex plugin hooks; deterministic session→entity markers are not yet claimed. +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex the event producer is packaged through Codex plugin hooks; deterministic session→entity markers are not yet claimed. - **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file, `CLAUDE_PLUGIN_ROOT`, or Codex plugin-root state. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index c05b44cf9..8c3036dad 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -23,7 +23,7 @@ The durable proof for Pi support is not transcript phrasing. A valid live proof ## Bridge egress (FO liveness/activity → Bridge) -Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi the event producer is packaged through the Spacedock Pi extension; deterministic session→entity markers are not yet claimed. +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi the event producer is packaged through the Spacedock Pi extension; deterministic session→entity markers are not yet claimed. - **FO event emission** — PACKAGED/SOURCE-COVERED on Pi: `package.json` advertises `.pi/extensions/spacedock.ts`, and that extension forwards Pi lifecycle event payloads to `spacedock bridge egress emit --host pi`. The shared emitter normalizes Pi-native lifecycle names (`session_shutdown`, `agent_end`, `turn_end`, `tool_execution_end`, etc.) into Bridge's canonical event grammar before writing `_bridge/events.jsonl`. The package-status gate requires the Spacedock Pi extension as well as the ensign skill, so a skills-only package is not treated as Bridge-egress-capable. This does not prove `_bridge/sessions/` marker parity: Pi marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`; on Pi this stays empty (no stable per-session id is exposed), so the heartbeat carries an empty session id — still a valid liveness tick (Bridge reads freshness from `ts`). Bind it once Pi exposes a stable per-session id. diff --git a/skills/integration/codex_bridge_egress_hook_test.go b/skills/integration/codex_bridge_egress_hook_test.go index b86f1bf7f..27d92c230 100644 --- a/skills/integration/codex_bridge_egress_hook_test.go +++ b/skills/integration/codex_bridge_egress_hook_test.go @@ -5,7 +5,6 @@ package integration import ( "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -93,48 +92,6 @@ func TestCodexBridgeHooksAreNonAsyncAndCallEgressDirectly(t *testing.T) { } } -func TestCodexBridgeWrapperCallsSharedEgressEmitterSilently(t *testing.T) { - root := repoRoot(t) - wrapper := filepath.Join(root, "scripts", "codex-bridge-events.sh") - fi, err := os.Stat(wrapper) - if err != nil { - t.Fatalf("codex wrapper missing: %v", err) - } - if fi.Mode()&0o111 == 0 { - t.Fatalf("codex wrapper must be executable: mode %v", fi.Mode()) - } - - binDir := t.TempDir() - logPath := filepath.Join(t.TempDir(), "argv.log") - fake := filepath.Join(binDir, "spacedock") - if err := os.WriteFile(fake, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$SPACEDOCK_FAKE_ARGV_LOG\"\nprintf 'stdout leak\\n'\nprintf 'stderr leak\\n' >&2\nexit 42\n"), 0o755); err != nil { - t.Fatalf("write fake spacedock: %v", err) - } - - cmd := exec.Command("bash", wrapper) - cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"codex-parent-session","cwd":"/repo/spacedock"}`) - cmd.Env = append(os.Environ(), - "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), - "SPACEDOCK_BIN=", - "SPACEDOCK_FAKE_ARGV_LOG="+logPath, - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("codex wrapper must remain observe-only even when emitter fails: %v\n%s", err, out) - } - if string(out) != "" { - t.Fatalf("codex wrapper must be silent; got %q", out) - } - - argv, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("fake spacedock was not invoked: %v", err) - } - if got, want := strings.TrimSpace(string(argv)), "bridge egress emit --host codex"; got != want { - t.Fatalf("spacedock argv = %q, want %q", got, want) - } -} - func TestCodexBridgeEgressMinimalPayloadFixture(t *testing.T) { path := filepath.Join(repoRoot(t), "skills", "integration", "testdata", "codex", "bridge-egress-minimal-session-start.json") data, err := os.ReadFile(path) From 0f1cd3ed62b4f0978fe65dd0c0832813ea6b1a8d Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 08:52:27 +0800 Subject: [PATCH 14/21] feat(bridge): packaged inbox drain + Claude durable wake 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) --- docs/dev/_mods/bridge-inbox.md | 90 +-- hooks/hooks.json | 3 +- internal/bridgeingress/check_test.go | 76 +++ internal/bridgeingress/drain.go | 609 ++++++++++++++++++ internal/bridgeingress/drain_test.go | 199 ++++++ internal/cli/bridge_inbox_test.go | 90 +++ internal/cli/cli.go | 95 ++- scripts/spacedock-bridge-inbox-check.sh | 22 + .../claude-first-officer-runtime.md | 21 +- .../references/codex-first-officer-runtime.md | 6 + .../bridge_inbox_wake_hook_test.go | 64 ++ 11 files changed, 1216 insertions(+), 59 deletions(-) create mode 100644 internal/bridgeingress/check_test.go create mode 100644 internal/bridgeingress/drain.go create mode 100644 internal/bridgeingress/drain_test.go create mode 100644 internal/cli/bridge_inbox_test.go create mode 100755 scripts/spacedock-bridge-inbox-check.sh create mode 100644 skills/integration/bridge_inbox_wake_hook_test.go diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index ec82a87dd..1d59a0533 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -9,19 +9,22 @@ fo-realm: "FO realm — the FO maintains this file directly; it is FO process (t [Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, writes a per-workflow liveness heartbeat Bridge reads, and appends best-effort explanatory replies/acks to `_bridge/fo-replies.jsonl`. +**The mechanism is packaged — do not hand-write it.** Cursor math, per-line routing, the liveness heartbeat, and the JSONL reply/ack serialization all live in the binary, reached through four verbs: + +- `spacedock bridge inbox drain --host «host» --slug «slug»` — stamps the heartbeat and returns the new records addressed to this workflow as JSON. It does **not** advance the cursor. +- `spacedock bridge inbox ack --host «host» --slug «slug» --line «n» --id «id» --ts «ts» --kind «intent-kind» --status «status» [--text … --granted … --entity … --field … --value … --verdict … --request-id …]` — appends one compact, newline-terminated reply/ack line (it derives the ack `kind` from the intent kind). +- `spacedock bridge inbox commit --slug «slug» --cursor «high_water»` — advances this workflow's cursor to the drained high-water mark (monotonic; a lower value is ignored). +- `spacedock bridge inbox check --host «host»` — the Claude `Stop`-hook decision helper (see **Wake**), not part of the manual loop. + +`«host»` is your runtime adapter's host token (`claude` / `codex` / `pi`); `«slug»` is this workflow's slug (below). The commands resolve the session id from the adapter's binding themselves, so no `$SLUG`/cursor/JSON shell is authored by hand. **Your only job is judgment:** read the records the drain hands back, *act* on each (interpret a `tell`, resolve a gate), then ack and commit. Do not reimplement drain/ack/commit with `wc`/`sed`/`cat`/`echo`/`jq` — hand-rolled shell across separate tool calls is exactly what corrupted cursors and wrote non-compact JSONL in the past. + **Path alignment (load-bearing).** Bridge anchors the inbox, heartbeat, feed, and reply stream on its `--repo-root` flag, falling back to `--fleet` only when `--repo-root` is unset. So intent reaches this FO **only when the captain launches Bridge with `--repo-root` pointing at this FO's cwd** (the repo root). Under a multi-workflow layout (`--fleet /docs/spacedock` with no `--repo-root`), Bridge would write to `/docs/spacedock/_bridge/` while the FO reads `/_bridge/` — and intent silently never arrives. The dir is the same `_bridge/` Bridge resolves *iff* the two roots agree. **This is a pull, not a push.** Delivery latency is one FO loop cadence: the captain's intent is read whenever the FO next idles or boots, never instantly. Bridge's UI says as much ("queued — read on the FO's next tick"); do not promise synchronous delivery. -**Your workflow slug.** Several FOs (one per commissioned workflow) can share one repo root and one `_bridge/` dir; everything below is scoped to THIS FO's workflow by its slug. Derive the slug once and validate it — it names the per-workflow cursor and heartbeat files, and an unsafe value must never escape `_bridge/`: +**Wake (getting a parked FO to drain).** A busy FO drains eagerly each loop iteration and at idle, so intent reaches it within a tick. A **parked** FO — one that has stopped and is waiting at the prompt — is nudged differently per host. On Codex, Bridge resumes the session with `spacedock bridge ingress wake`. On Claude, external session resume is unsafe (the transcript has no write locking), so instead the packaged `Stop` hook runs `spacedock bridge inbox check` at every turn boundary: when intent is queued for this session's workflow it returns a `block` decision that keeps the FO going long enough to drain, entirely in-session. A fully idle/closed Claude session cannot be woken remotely; Bridge surfaces its queued count honestly and the captain nudges it. Never resume a live Claude session out-of-band to force a drain. -``` -SLUG=$(basename "{dir}") -case "$SLUG" in - ""|.|..) echo "bridge-inbox: empty/relative slug — skipping" >&2; exit 0 ;; - *[!A-Za-z0-9._-]*) echo "bridge-inbox: unsafe slug '$SLUG' — skipping" >&2; exit 0 ;; -esac -``` +**Your workflow slug.** Several FOs (one per commissioned workflow) can share one repo root and one `_bridge/` dir; everything below is scoped to THIS FO's workflow by its slug. The slug is the basename of this workflow's dir (`basename {dir}`) — it names the per-workflow cursor and heartbeat files. Pass it verbatim as `--slug` to each `spacedock bridge inbox` verb; the command validates it and refuses an unsafe value (one that could escape `_bridge/`) rather than acting on it, so you never guard it by hand. Below, `$SLUG` refers to that slug. **Inbox record schema** (one JSON object per line, written by Bridge): @@ -37,53 +40,28 @@ Routing is: - If `target_set` is absent, preserve old target behavior: act when `target == "$SLUG"`, `target == "all"`, or `target` is missing/empty. - A record not addressed to this workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. -**Consume by a per-workflow cursor, never by rewrite.** Bridge only appends to the one shared `inbox.jsonl`; this mod advances only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed). Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and re-firing with no new lines is a no-op. - -**Run each hook's steps in one shell.** Every Bash invocation is a fresh shell, so `$SLUG` (and `$CURSOR`) only persist within a single invocation. Begin each hook below by deriving and validating `$SLUG` (the block above), then run that hook's heartbeat and drain steps in the **same** shell — do not split the heartbeat and drain into separate invocations that each expect `$SLUG` to already be set, or the second runs with an empty slug and writes a stray `_bridge/fo..json` Bridge never reads. +**Consume by a per-workflow cursor, never by rewrite (the command owns it).** Bridge only appends to the one shared `inbox.jsonl`; the drain/commit verbs advance only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed), performing the one-time migration from the pre-versioning shared `_bridge/.inbox-cursor` for you. Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and a drain with no new lines is a no-op. Because the whole read/route/heartbeat step is one atomic command, there is no `$SLUG`/`$CURSOR` shell state to lose across tool calls. ## Hook: startup -1. Derive and validate `$SLUG` (above), in the shell you run the rest of this tick in. -2. Write the heartbeat (see **Heartbeat** below) so Bridge shows this workflow attached as soon as the FO boots. -3. Drain any intent the captain queued while no FO was attached, so a freshly-booted FO picks up standing instructions before its first dispatch — run the **Drain** procedure below. +1. Run the **Drain procedure** below with `--host «host» --slug $SLUG`. `drain` stamps the liveness heartbeat first (so Bridge shows this workflow attached as soon as the FO boots), then returns any intent the captain queued while no FO was attached, so a freshly-booted FO picks up standing instructions before its first dispatch. ## Hook: idle -Derive and validate `$SLUG` (above) in this tick's shell, then refresh the heartbeat and drain — all in the same shell. +Run the **Drain procedure** below with `--host «host» --slug $SLUG`. A single `drain` call both refreshes the heartbeat and returns new intent. ### Heartbeat -Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it treats this workflow as live only when the `ts` is **fresh** (within 30 minutes) and **not in the future**. Stamp a present-time UTC timestamp on every tick so an attached FO keeps showing live: - -``` -mkdir -p _bridge -printf '{"session_id":"%s","ts":"%s","state":"idle"}\n' \ - "${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json -``` - -`SD_SESSION_ID` is the host-neutral session-id token owned by your runtime adapter's **Bridge egress** binding; the snippet reads it first and falls back to the host's own session var (`$CLAUDE_CODE_SESSION_ID` on Claude, `$CODEX_THREAD_ID` on Codex) so the heartbeat never silently blanks, with no per-tick `export` needed. An empty value (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, `fo-replies.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. +Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it treats this workflow as live only when the `ts` is **fresh** (within 30 minutes) and **not in the future**. `spacedock bridge inbox drain` stamps a present-time UTC timestamp (and the `host` and `session_id` from your adapter's binding) on every call, so an attached FO that drains each tick keeps showing live — you never write `fo.$SLUG.json` by hand. An empty session id (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, `fo-replies.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. -`state` is `idle`: this mod runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. Writing the heartbeat is observe-only; never let it block the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) +`state` is `idle`: the drain runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. The heartbeat is observe-only; a drain that cannot write it degrades to no-op and never blocks the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) -### Drain +### Drain procedure Drain newly-queued captain intent addressed to this workflow, if any: -1. If `_bridge/inbox.jsonl` does not exist, skip — no Bridge is attached. -2. Adopt the old shared cursor on first run (one-time migration: before this version the mod used a single shared `_bridge/.inbox-cursor`; seed from it so you do not re-drain — and re-apply — intent already processed, e.g. an old `conn` grant), then read this workflow's cursor: - ``` - if [ ! -f _bridge/.inbox-cursor.$SLUG ] && [ -f _bridge/.inbox-cursor ]; then - cp _bridge/.inbox-cursor _bridge/.inbox-cursor.$SLUG - fi - CURSOR=$(cat _bridge/.inbox-cursor.$SLUG 2>/dev/null || echo 0) - ``` -3. Snapshot the current line count and read exactly the new records by physical line number (bounding the read so a concurrent Bridge append can't make the cursor skip a line): - ``` - NEW=$(wc -l < _bridge/inbox.jsonl | tr -d ' ') - sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl - ``` - If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, keep its physical `LINE` number (`CURSOR + 1`, `CURSOR + 2`, ...), then parse `id` / `ts` / `kind` / `text` / `granted` / `target` / `target_set` (and `entity` plus either `field` / `value` or `verdict` / `directives` on a `decision` record; `request_id` on a `permission-decision` record). Use a real JSON parser; do not parse these records with grep/sed/regex. **Check routing first:** when `target_set` is present, act only if `"$SLUG"` is a member of that array; ignore `target` entirely, including `target == "all"`, because `target_set` is the authoritative frozen recipient set. When `target_set` is absent, act only if `target` is `"$SLUG"`, `"all"`, or missing/empty. If the record is for another workflow's FO, skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise act, then append the corresponding reply/ack after the FO has interpreted, accepted, or applied the intent — not merely after shell-reading the line: +1. **Drain.** Run `spacedock bridge inbox drain --host «host» --slug $SLUG`. It stamps the heartbeat and returns JSON: `{"cursor":N,"high_water":M,"count":K,"records":[…]}`. The `records` are exactly the new lines addressed to this workflow (routing already applied), each carrying its physical `LINE` number, and already excluding any record you have acked before (idempotent replay-safe). If `count` is `0`, there is nothing to act on — you are done (the heartbeat is already refreshed). No `_bridge/inbox.jsonl` yet means no Bridge is attached; the command reports `no-inbox` and you skip. +2. **Act + ack, in order.** For each returned record, act on it (below), then acknowledge it with `spacedock bridge inbox ack --host «host» --slug $SLUG --line «line» --id «id» --ts «ts» --kind «kind» --status «status»` plus any relevant `--text`/`--granted`/`--entity`/`--field`/`--value`/`--verdict`/`--request-id`. The `ack` verb derives the reply `kind` from the intent `kind` and writes one compact line for you. Ack **after** you have interpreted, accepted, or applied the intent — not merely after shell-reading the line. The per-kind action and the `status` to use: - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and append a `reply` record with `status:"answered"`. - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). Then append a `conn-ack` record with `status:"accepted"`. `granted: false` → take the conn back: stop at every gate for the captain's call again. Then append a `conn-ack` record with `status:"released"`. - **`kind == "decision"`** — the captain resolved a gate from Bridge. This is a captain decision, not FO self-approval, and Bridge has not advanced the entity. Resolve `entity` (its slug) in THIS workflow, verify the entity is still at a compatible current gate, and apply the workflow's normal gate-resolution flow: @@ -91,33 +69,33 @@ Drain newly-queued captain intent addressed to this workflow, if any: - Plain decision shape: `verdict` plus optional `directives`. `approve` advances through the gate's own approve path and side effects. `reject` / `redo` route to the gate's `feedback-to` stage with the supplied directives. If the workflow has external actions (GitHub review, Linear update, labels, etc.), perform them before terminal state or final acknowledgement. - Append a `decision-ack` record only after the decision is applied, blocked, or rejected. Use `status:"applied"` when gate resolution finished or was already satisfied; `status:"blocked"` when the intent is valid but execution could not finish; `status:"rejected"` when the intent is invalid, stale, or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. - **`kind == "permission-decision"`** — the captain resolved a top-level FO permission alert. Match `request_id` to the open `_bridge/fo-alerts.jsonl` record you emitted. `value: "deny"` means do not retry the blocked action; append a `permission-ack` with `status:"denied"` and report the block remains. `value: "approve-once"` means retry the exact blocked host action once using the runtime's escalation path. `value: "approve-rule"` means retry with the alert's proposed `prefix_rule` when one was present; otherwise treat it as `approve-once`. Append `permission-ack` with `status:"accepted"` before retrying; append `status:"blocked"` if the retry could not be started. This Bridge approval is FO intent, not a bypass of a host-native security prompt; if the host still presents a native approval dialog, honor it normally. -5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`. -6. Report to the captain: how many intents you drained (for this workflow) and what you did with each. +3. **Commit.** After acting on and acking every returned record, advance the cursor once with `spacedock bridge inbox commit --slug $SLUG --cursor «high_water»` (the `high_water` from step 1). This moves the cursor past everything read this tick — including records for other workflows the drain filtered out — so they are never reconsidered. Commit is monotonic and is the last step, so nothing is lost if the loop dies mid-drain. +4. Report to the captain: how many intents you drained (for this workflow) and what you did with each. If a record is malformed (not valid JSON, missing required fields for its `kind`, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record. Append a rejected ack only when the record is addressed to `"$SLUG"` and has enough valid metadata to produce a valid reply shape: an `id`, `ts`, and a known `kind` that determines `reply`/`conn-ack`/`decision-ack` plus `intent_kind`. Unknown-kind or unrouteable records cannot be represented by the reply schema; report the skip to the captain, advance the cursor, and do not invent an id or invalid ack kind. ### Replies / acks -Append one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected: +`spacedock bridge inbox ack` appends one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected. You never format this JSON by hand — you pass flat flags and the command serializes the record below: ``` {"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision"|"permission-decision","status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","verdict":"...","request_id":"...","session_id":"optional","host":"optional"} ``` -Rules: - -- `target` is the actual acknowledging workflow slug (`$SLUG`), never `"all"`. -- `in_reply_to_line` is the physical inbox line number you processed, not the count of addressed records. -- `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts`. -- `intent_kind` echoes the inbox `kind`. -- `text` is optional but, when present, must be a single line. -- Echo `granted`, `entity`, `field`, `value`, `verdict`, and `request_id` when present and relevant. -- `session_id` should be `"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}"` when non-empty; `host` is optional. -- Write one complete newline-terminated JSON object in one append operation, e.g. build the full JSON line with a structured encoder and append it with a single `>> _bridge/fo-replies.jsonl` write. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl`. -- Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed reply append must never block the FO from completing the drained intent or advancing the cursor after action. +What the command guarantees (so you don't have to): + +- `target` is the actual acknowledging workflow slug (`$SLUG`), never `"all"` — pass `--slug $SLUG`. +- `in_reply_to_line` is the physical inbox line number you processed (`--line`), not the count of addressed records. +- `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts` (`--id`, `--ts`). +- `intent_kind` echoes the inbox `kind` (`--kind`); the command maps it to the reply `kind`. +- `text` is optional (`--text`) and is flattened to a single line for you. +- Pass `--granted`, `--entity`, `--field`, `--value`, `--verdict`, and `--request-id` when present and relevant; omitted flags are omitted from the record. +- The command resolves `session_id` from the adapter's binding and stamps `host`; you do not supply them. +- It writes one complete newline-terminated JSON object in one append operation. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl` yourself. +- Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed ack must never block the FO from completing the drained intent or committing the cursor after action. - Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. Still prefer idempotent behavior. -**Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (step 5 follows step 4), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acting and writing the cursor re-delivers that batch on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed above, which guards against re-applying the *entire* pre-versioning history.) +**Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (commit is the last step, after act + ack), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acking and committing re-surfaces at worst nothing (the drain filters already-acked records) and between acting and acking re-delivers that record on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed the command applies, which guards against re-applying the *entire* pre-versioning history.) ## Feed diff --git a/hooks/hooks.json b/hooks/hooks.json index 280485146..48b93e977 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -13,7 +13,8 @@ { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } ], "Stop": [ - { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] }, + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-inbox-check.sh" } ] } ], "SubagentStop": [ { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } diff --git a/internal/bridgeingress/check_test.go b/internal/bridgeingress/check_test.go new file mode 100644 index 000000000..eb34b22fa --- /dev/null +++ b/internal/bridgeingress/check_test.go @@ -0,0 +1,76 @@ +package bridgeingress + +import ( + "strings" + "testing" + "time" +) + +func TestCheckBlocksWhenIntentQueuedForSession(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"hi","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a"}) + if d.Decision != "block" { + t.Fatalf("decision = %q, want block", d.Decision) + } + for _, want := range []string{"alpha", "spacedock bridge inbox drain", "1 queued"} { + if !strings.Contains(d.Reason, want) { + t.Fatalf("reason missing %q:\n%s", want, d.Reason) + } + } +} + +func TestCheckEmptyWhenStopHookActive(t *testing.T) { + root := t.TempDir() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", time.Now().UTC()) + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a", StopHookActive: true}) + if d.Decision != "" { + t.Fatalf("stop_hook_active must not block again, got %+v", d) + } +} + +func TestCheckEmptyWhenSessionUnknown(t *testing.T) { + root := t.TempDir() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", time.Now().UTC()) + // A different session id must not block for a sibling FO's intent. + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-OTHER"}) + if d.Decision != "" { + t.Fatalf("unknown session must not block, got %+v", d) + } +} + +func TestCheckEmptyWhenNothingPending(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + writeInboxCursor(t, root, "alpha", "1") // already drained + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a"}) + if d.Decision != "" { + t.Fatalf("no pending intent must not block, got %+v", d) + } +} + +func TestCheckFromReaderParsesStopPayload(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + payload := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":false,"hook_event_name":"Stop"}` + d := CheckFromReader(strings.NewReader(payload), CheckOptions{Host: "claude"}) + if d.Decision != "block" { + t.Fatalf("reader path should block, got %+v", d) + } + + // stop_hook_active in the payload suppresses a re-block. + payload2 := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":true}` + if d2 := CheckFromReader(strings.NewReader(payload2), CheckOptions{Host: "claude"}); d2.Decision != "" { + t.Fatalf("payload stop_hook_active must suppress block, got %+v", d2) + } +} diff --git a/internal/bridgeingress/drain.go b/internal/bridgeingress/drain.go new file mode 100644 index 000000000..0a8c7b9d9 --- /dev/null +++ b/internal/bridgeingress/drain.go @@ -0,0 +1,609 @@ +// ABOUTME: Deterministic Bridge inbox drain/ack/commit/check for FO sessions. +// ABOUTME: Moves cursor math, routing, heartbeat, and ack serialization out of FO prose into the binary. +package bridgeingress + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// The drain family packages the mechanism the bridge-inbox mod used to make the +// FO hand-execute as raw shell each tick: cursor read/advance, per-line routing, +// heartbeat stamping, and reply/ack serialization. The FO now calls these verbs +// and keeps only the judgment (interpreting a tell, resolving a gate). This is +// host-neutral; --host only selects which session-id token the heartbeat carries. + +// DrainOptions controls one inbox drain (peek) pass. +type DrainOptions struct { + Host string + Root string + Slug string + SessionID string + Members []string + Now func() time.Time +} + +// DrainRecord is one addressed, not-yet-acked inbox record handed back to the FO. +type DrainRecord struct { + Line int `json:"line"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + Granted *bool `json:"granted,omitempty"` + Target string `json:"target,omitempty"` + TargetSet []string `json:"target_set,omitempty"` + Entity string `json:"entity,omitempty"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` + Verdict string `json:"verdict,omitempty"` + Directives []string `json:"directives,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// DrainResult is the JSON the FO reads after a drain. It never advances the +// cursor: the FO acts on records, acks them, then commits the high-water mark. +type DrainResult struct { + Status string `json:"status"` + Slug string `json:"slug"` + Host string `json:"host,omitempty"` + Cursor int `json:"cursor"` + HighWater int `json:"high_water"` + Count int `json:"count"` + Heartbeat bool `json:"heartbeat"` + Records []DrainRecord `json:"records"` + Error string `json:"error,omitempty"` +} + +type fullInboxRecord struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + RawTS string `json:"-"` + Kind string `json:"kind"` + Text string `json:"text"` + Granted *bool `json:"granted"` + Target string `json:"target"` + TargetSet []string `json:"target_set"` + Entity string `json:"entity"` + Field string `json:"field"` + Value string `json:"value"` + Verdict string `json:"verdict"` + Directives []string `json:"directives"` + RequestID string `json:"request_id"` + Line int `json:"-"` +} + +func (r fullInboxRecord) routing() inboxRecord { + return inboxRecord{ID: r.ID, TS: r.TS, Kind: r.Kind, Target: r.Target, TargetSet: r.TargetSet, Line: r.Line} +} + +// Drain reads newly-queued captain intent addressed to the given workflow slug, +// stamps the liveness heartbeat, and returns the records without advancing the +// cursor. It is idempotent: records already acked (per fo-replies.jsonl) are +// filtered out even if the cursor has not yet advanced past them. +func Drain(opts DrainOptions) DrainResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return DrainResult{Status: "failed", Slug: slug, Error: "invalid or missing --slug"} + } + host := normalizeHost(opts.Host) + root := absRootOr(opts.Root) + now := nowFunc(opts.Now) + + // Heartbeat is observe-only liveness and must be written on every tick, even + // when no Bridge is attached or the inbox is empty. + hbWritten := stampHeartbeat(root, slug, resolveSessionID(host, opts.SessionID), host, now()) + + res := DrainResult{Status: "ok", Slug: slug, Host: host, Heartbeat: hbWritten, Records: []DrainRecord{}} + + inboxPath := filepath.Join(root, "_bridge", "inbox.jsonl") + if _, err := os.Stat(inboxPath); err != nil { + res.Status = "no-inbox" + return res + } + + adoptCursor(root, slug) + cursor := inboxCursor(root, slug) + records, highWater, err := readInboxFull(inboxPath) + if err != nil { + return DrainResult{Status: "failed", Slug: slug, Host: host, Heartbeat: hbWritten, Error: err.Error(), Records: []DrainRecord{}} + } + res.Cursor = cursor + res.HighWater = highWater + + replies := loadReplies(root) + members := opts.Members + if len(members) == 0 { + members = []string{slug} + } + for _, rec := range records { + if rec.Line <= cursor { + continue + } + if !addressedTo(root, rec.routing(), slug, members) { + continue + } + if replies[replyKey(rec.routing(), slug)] { + continue + } + res.Records = append(res.Records, toDrainRecord(rec)) + } + res.Count = len(res.Records) + return res +} + +// addressedTo reports whether an inbox record routes to this workflow slug, +// honoring an authoritative frozen target_set over the legacy target field. +func addressedTo(root string, rec inboxRecord, slug string, members []string) bool { + for _, t := range targetsFor(root, rec, members) { + if t == slug { + return true + } + } + return false +} + +// AckOptions controls one reply/ack append. +type AckOptions struct { + Host string + Root string + Slug string + Line int + ID string + TS string + IntentKind string + Status string + Text string + Granted *bool + Entity string + Field string + Value string + Verdict string + RequestID string + SessionID string + Now func() time.Time +} + +// AckResult is the JSON printed after an ack append. +type AckResult struct { + Appended bool `json:"appended"` + Kind string `json:"kind,omitempty"` + Target string `json:"target,omitempty"` + Line int `json:"line,omitempty"` + Error string `json:"error,omitempty"` +} + +type replyOut struct { + Schema int `json:"schema"` + TS string `json:"ts"` + Kind string `json:"kind"` + Target string `json:"target"` + InReplyToID string `json:"in_reply_to_id,omitempty"` + InReplyToLine int `json:"in_reply_to_line"` + InReplyToTS string `json:"in_reply_to_ts,omitempty"` + IntentKind string `json:"intent_kind"` + Status string `json:"status"` + Text string `json:"text,omitempty"` + Granted *bool `json:"granted,omitempty"` + Entity string `json:"entity,omitempty"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` + Verdict string `json:"verdict,omitempty"` + RequestID string `json:"request_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Host string `json:"host,omitempty"` +} + +// ackKindFor maps an inbox intent kind to its reply/ack record kind. +func ackKindFor(intentKind string) (string, bool) { + switch intentKind { + case "tell": + return "reply", true + case "conn": + return "conn-ack", true + case "decision": + return "decision-ack", true + case "permission-decision": + return "permission-ack", true + default: + return "", false + } +} + +// Ack appends one newline-terminated, compact reply record to fo-replies.jsonl. +// Serialization is owned by the binary so the FO never hand-encodes JSONL — the +// non-compact / multi-line JSONL corruption class is eliminated. +func Ack(opts AckOptions) AckResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return AckResult{Error: "invalid or missing --slug"} + } + if opts.Line <= 0 { + return AckResult{Error: "missing or non-positive --line"} + } + kind, ok := ackKindFor(opts.IntentKind) + if !ok { + return AckResult{Error: "unknown --kind (want tell|conn|decision|permission-decision)"} + } + if strings.TrimSpace(opts.Status) == "" { + return AckResult{Error: "missing --status"} + } + host := normalizeHost(opts.Host) + root := absRootOr(opts.Root) + now := nowFunc(opts.Now) + + rec := replyOut{ + Schema: 1, + TS: now().UTC().Format(time.RFC3339), + Kind: kind, + Target: slug, + InReplyToID: opts.ID, + InReplyToLine: opts.Line, + InReplyToTS: strings.TrimSpace(opts.TS), + IntentKind: opts.IntentKind, + Status: opts.Status, + Text: singleLine(opts.Text), + Granted: opts.Granted, + Entity: opts.Entity, + Field: opts.Field, + Value: opts.Value, + Verdict: opts.Verdict, + RequestID: opts.RequestID, + SessionID: resolveSessionID(host, opts.SessionID), + Host: host, + } + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return AckResult{Error: err.Error()} + } + data, err := json.Marshal(rec) + if err != nil { + return AckResult{Error: err.Error()} + } + f, err := os.OpenFile(filepath.Join(dir, "fo-replies.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return AckResult{Error: err.Error()} + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return AckResult{Error: err.Error()} + } + return AckResult{Appended: true, Kind: kind, Target: slug, Line: opts.Line} +} + +// CommitOptions controls one cursor advance. +type CommitOptions struct { + Root string + Slug string + Cursor int +} + +// CommitResult is the JSON printed after a cursor advance. +type CommitResult struct { + Cursor int `json:"cursor"` + Error string `json:"error,omitempty"` +} + +// Commit advances this workflow's inbox cursor to the supplied high-water mark. +// It is monotonic: a value below the current cursor is ignored so a stale commit +// can never re-deliver already-processed intent. +func Commit(opts CommitOptions) CommitResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return CommitResult{Error: "invalid or missing --slug"} + } + if opts.Cursor < 0 { + return CommitResult{Error: "negative --cursor"} + } + root := absRootOr(opts.Root) + adoptCursor(root, slug) + current := inboxCursor(root, slug) + target := opts.Cursor + if target < current { + target = current + } + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return CommitResult{Error: err.Error()} + } + if err := os.WriteFile(filepath.Join(dir, ".inbox-cursor."+slug), []byte(strconv.Itoa(target)+"\n"), 0o644); err != nil { + return CommitResult{Error: err.Error()} + } + return CommitResult{Cursor: target} +} + +// CheckOptions controls a Stop-hook drain check. +type CheckOptions struct { + Host string + Root string + Slug string + SessionID string + Members []string + // StopHookActive mirrors the Claude Stop hook payload field. When true the + // check never blocks again, so a session that fails to drain cannot loop. + StopHookActive bool +} + +// HookDecision is the Claude Stop hook contract. An empty struct (marshals to +// "{}") lets the session stop; Decision "block" with Reason forces one more turn. +type HookDecision struct { + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type stopPayload struct { + CWD string `json:"cwd"` + SessionID string `json:"session_id"` + StopHookActive bool `json:"stop_hook_active"` +} + +// CheckFromReader parses a Claude Stop hook payload from stdin (cwd, session_id, +// stop_hook_active), merges any explicit CheckOptions overrides, and returns the +// hook decision. It never errors: a Stop hook must be safe, so any failure to +// resolve state yields an empty decision (let the session stop). +func CheckFromReader(r io.Reader, opts CheckOptions) HookDecision { + if data, err := io.ReadAll(r); err == nil && len(data) > 0 { + var p stopPayload + if json.Unmarshal(data, &p) == nil { + if opts.Root == "" && p.CWD != "" { + opts.Root = p.CWD + } + if opts.SessionID == "" { + opts.SessionID = p.SessionID + } + if p.StopHookActive { + opts.StopHookActive = true + } + } + } + return Check(opts) +} + +// Check computes the Stop-hook decision for a session: block with a drain +// instruction when captain intent is queued for the session's workflow(s). +func Check(opts CheckOptions) HookDecision { + if opts.StopHookActive { + return HookDecision{} + } + root := absRootOr(opts.Root) + if _, err := os.Stat(filepath.Join(root, "_bridge", "inbox.jsonl")); err != nil { + return HookDecision{} + } + slugs := resolveSessionSlugs(root, strings.TrimSpace(opts.Slug), strings.TrimSpace(opts.SessionID)) + if len(slugs) == 0 { + return HookDecision{} + } + + records, _, err := readInboxFull(filepath.Join(root, "_bridge", "inbox.jsonl")) + if err != nil { + return HookDecision{} + } + replies := loadReplies(root) + pendingBySlug := map[string]int{} + for _, slug := range slugs { + members := []string{slug} + for _, rec := range records { + if rec.Line <= inboxCursor(root, slug) { + continue + } + if !addressedTo(root, rec.routing(), slug, members) { + continue + } + if replies[replyKey(rec.routing(), slug)] { + continue + } + pendingBySlug[slug]++ + } + } + total := 0 + var pendingSlugs []string + for _, slug := range slugs { + if pendingBySlug[slug] > 0 { + total += pendingBySlug[slug] + pendingSlugs = append(pendingSlugs, slug) + } + } + if total == 0 { + return HookDecision{} + } + return HookDecision{Decision: "block", Reason: drainReason(total, pendingSlugs, normalizeHost(opts.Host))} +} + +func drainReason(total int, slugs []string, host string) string { + if host == "" { + host = "claude" + } + slug := slugs[0] + plural := "record" + if total != 1 { + plural = "records" + } + return "Bridge has " + strconv.Itoa(total) + " queued captain-intent " + plural + + " in _bridge/inbox.jsonl for workflow(s): " + strings.Join(slugs, ", ") + + ". Before stopping, drain them: run `spacedock bridge inbox drain --host " + host + " --slug " + slug + + "` (repeat per slug), act on each record, ack each with `spacedock bridge inbox ack ...`, then advance the cursor with `spacedock bridge inbox commit ...`." +} + +// resolveSessionSlugs finds the workflow slug(s) this session drives. An explicit +// --slug wins. Otherwise it matches the session id against heartbeat and session +// markers so a Stop hook only ever blocks its OWN session's pending intent, never +// a sibling FO's sharing the same repo root. +func resolveSessionSlugs(root, explicitSlug, sessionID string) []string { + if explicitSlug != "" { + if validSlug(explicitSlug) { + return []string{explicitSlug} + } + return nil + } + if !safeSessionID(sessionID) { + return nil + } + seen := map[string]bool{} + var out []string + for _, slug := range discoverHeartbeatSlugs(root) { + if hb, ok := loadHeartbeatAnyAge(root, slug); ok && strings.TrimSpace(hb.SessionID) == sessionID { + if !seen[slug] { + seen[slug] = true + out = append(out, slug) + } + } + } + // Session markers (sessions/.json) map an actor id to a workflow; the + // FO's own actor id is its session id on Claude. + if slug, ok := workflowForSession(root, sessionID); ok && !seen[slug] { + seen[slug] = true + out = append(out, slug) + } + return out +} + +func workflowForSession(root, sessionID string) (string, bool) { + matches, _ := filepath.Glob(filepath.Join(root, "_bridge", "sessions", "*.json")) + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var rec sessionMarker + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + if strings.TrimSpace(rec.SessionID) == sessionID && validSlug(rec.Workflow) { + return rec.Workflow, true + } + } + return "", false +} + +type heartbeatOut struct { + SessionID string `json:"session_id"` + Host string `json:"host,omitempty"` + TS string `json:"ts"` + State string `json:"state"` +} + +func stampHeartbeat(root, slug, sessionID, host string, now time.Time) bool { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return false + } + hb := heartbeatOut{SessionID: sessionID, Host: host, TS: now.UTC().Format(time.RFC3339), State: "idle"} + data, err := json.Marshal(hb) + if err != nil { + return false + } + if err := os.WriteFile(filepath.Join(dir, "fo."+slug+".json"), append(data, '\n'), 0o644); err != nil { + return false + } + return true +} + +// adoptCursor performs the one-time migration from the pre-versioning shared +// cursor to this workflow's own cursor, so a freshly-slugged FO does not re-drain +// (and re-apply) intent already processed under the shared cursor. +func adoptCursor(root, slug string) { + if !validSlug(slug) { + return + } + slugPath := filepath.Join(root, "_bridge", ".inbox-cursor."+slug) + if _, err := os.Stat(slugPath); err == nil { + return + } + sharedPath := filepath.Join(root, "_bridge", ".inbox-cursor") + data, err := os.ReadFile(sharedPath) + if err != nil { + return + } + _ = os.WriteFile(slugPath, data, 0o644) +} + +func readInboxFull(path string) ([]fullInboxRecord, int, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, 0, nil + } + if err != nil { + return nil, 0, err + } + defer f.Close() + + var out []fullInboxRecord + lineNo := 0 + scanner := lineScanner(f) + for scanner.Scan() { + lineNo++ + if strings.TrimSpace(scanner.Text()) == "" { + continue + } + var rec fullInboxRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + rec.Line = lineNo + out = append(out, rec) + } + if err := scanner.Err(); err != nil { + return nil, 0, err + } + return out, lineNo, nil +} + +func toDrainRecord(r fullInboxRecord) DrainRecord { + ts := "" + if !r.TS.IsZero() { + ts = r.TS.UTC().Format(time.RFC3339) + } + return DrainRecord{ + Line: r.Line, ID: r.ID, TS: ts, Kind: r.Kind, Text: r.Text, Granted: r.Granted, + Target: r.Target, TargetSet: r.TargetSet, Entity: r.Entity, Field: r.Field, + Value: r.Value, Verdict: r.Verdict, Directives: r.Directives, RequestID: r.RequestID, + } +} + +func resolveSessionID(host, flag string) string { + if s := strings.TrimSpace(flag); s != "" { + return s + } + if s := strings.TrimSpace(os.Getenv("SD_SESSION_ID")); s != "" { + return s + } + switch host { + case "claude": + return strings.TrimSpace(os.Getenv("CLAUDE_CODE_SESSION_ID")) + case "codex": + return strings.TrimSpace(os.Getenv("CODEX_THREAD_ID")) + } + return "" +} + +func validSlug(slug string) bool { + return slug != "" && slug != "." && slug != ".." && safeSlugPattern.MatchString(slug) +} + +func absRootOr(root string) string { + if root == "" { + root = "." + } + if abs, err := filepath.Abs(root); err == nil { + return abs + } + return root +} + +func nowFunc(fn func() time.Time) func() time.Time { + if fn != nil { + return fn + } + return time.Now +} + +func singleLine(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} diff --git a/internal/bridgeingress/drain_test.go b/internal/bridgeingress/drain_test.go new file mode 100644 index 000000000..18985ff71 --- /dev/null +++ b/internal/bridgeingress/drain_test.go @@ -0,0 +1,199 @@ +package bridgeingress + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDrainReturnsAddressedRecordsAndStampsHostHeartbeat(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"do the thing","target_set":["alpha"]}`, + `{"id":"i2","ts":"2026-07-03T11:01:00Z","kind":"tell","text":"not yours","target_set":["beta"]}`, + `{"id":"i3","ts":"2026-07-03T11:02:00Z","kind":"decision","entity":"e1","field":"status","value":"approved","target":"alpha"}`, + ) + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", SessionID: "sess-1", Now: func() time.Time { return now }}) + + if res.Status != "ok" { + t.Fatalf("status = %q, want ok", res.Status) + } + if !res.Heartbeat { + t.Fatal("heartbeat not written") + } + if res.Cursor != 0 || res.HighWater != 3 { + t.Fatalf("cursor=%d high_water=%d, want 0/3", res.Cursor, res.HighWater) + } + if res.Count != 2 || len(res.Records) != 2 { + t.Fatalf("count=%d, want 2 addressed records; got %+v", res.Count, res.Records) + } + if res.Records[0].ID != "i1" || res.Records[0].Text != "do the thing" || res.Records[0].Line != 1 { + t.Fatalf("record[0] = %+v, want i1 line 1", res.Records[0]) + } + if res.Records[1].ID != "i3" || res.Records[1].Kind != "decision" || res.Records[1].Field != "status" || res.Records[1].Value != "approved" { + t.Fatalf("record[1] = %+v, want decision i3 with field/value", res.Records[1]) + } + + // Heartbeat carries the host so Bridge can route wake per harness (WS-4). + var hb heartbeatOut + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo.alpha.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &hb); err != nil { + t.Fatal(err) + } + if hb.Host != "claude" || hb.SessionID != "sess-1" || hb.State != "idle" { + t.Fatalf("heartbeat = %+v, want claude/sess-1/idle", hb) + } + + // Drain never advances the cursor. + if _, err := os.Stat(filepath.Join(root, "_bridge", ".inbox-cursor.alpha")); !os.IsNotExist(err) { + t.Fatalf("drain must not write a cursor: %v", err) + } +} + +func TestDrainRespectsCursorAndAckIdempotency(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"first","target":"alpha"}`, + `{"id":"i2","ts":"2026-07-03T11:01:00Z","kind":"tell","text":"second","target":"alpha"}`, + ) + // Cursor past line 1: only line 2 remains. + writeInboxCursor(t, root, "alpha", "1") + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 || res.Records[0].ID != "i2" { + t.Fatalf("with cursor=1 want only i2, got %+v", res.Records) + } + + // Ack line 2 without committing the cursor: a re-drain must not resurface it. + if ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "i2", TS: "2026-07-03T11:01:00Z", IntentKind: "tell", Status: "answered"}); !ar.Appended { + t.Fatalf("ack failed: %+v", ar) + } + res2 := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res2.Count != 0 { + t.Fatalf("acked record must not resurface (idempotency), got %+v", res2.Records) + } +} + +func TestAckIsCompactSingleLineAndMapsKind(t *testing.T) { + root := t.TempDir() + cases := []struct{ intent, wantKind string }{ + {"tell", "reply"}, + {"conn", "conn-ack"}, + {"decision", "decision-ack"}, + {"permission-decision", "permission-ack"}, + } + for _, c := range cases { + res := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, ID: "x", TS: "2026-07-03T11:00:00Z", IntentKind: c.intent, Status: "applied", Text: "multi\nline\ttext"}) + if !res.Appended || res.Kind != c.wantKind { + t.Fatalf("intent %q -> %+v, want kind %q", c.intent, res, c.wantKind) + } + } + + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != len(cases) { + t.Fatalf("want %d compact lines, got %d:\n%s", len(cases), len(lines), raw) + } + for _, line := range lines { + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("line is not valid JSON: %q (%v)", line, err) + } + if v, _ := rec["schema"].(float64); v != 1 { + t.Fatalf("schema != 1 in %q", line) + } + if strings.Contains(line, "\n") || strings.Contains(line, "\t") { + t.Fatalf("reply line not single-line/compact: %q", line) + } + } + // The multi-line --text must be flattened to a single line. + if strings.Contains(string(raw), "multi\nline") { + t.Fatalf("text was not flattened:\n%s", raw) + } +} + +func TestAckOmitsGrantedWhenAbsent(t *testing.T) { + root := t.TempDir() + Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, ID: "x", IntentKind: "tell", Status: "answered"}) + raw, _ := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if strings.Contains(string(raw), "granted") { + t.Fatalf("absent --granted must be omitted, got %s", raw) + } + tr := true + Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "y", IntentKind: "conn", Status: "accepted", Granted: &tr}) + raw2, _ := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if !strings.Contains(string(raw2), `"granted":true`) { + t.Fatalf("present --granted must be serialized, got %s", raw2) + } +} + +func TestCommitAdvancesCursorMonotonically(t *testing.T) { + root := t.TempDir() + if r := Commit(CommitOptions{Root: root, Slug: "alpha", Cursor: 5}); r.Cursor != 5 { + t.Fatalf("commit = %+v, want 5", r) + } + if got := inboxCursor(root, "alpha"); got != 5 { + t.Fatalf("cursor file = %d, want 5", got) + } + // A stale/lower commit must not regress the cursor. + if r := Commit(CommitOptions{Root: root, Slug: "alpha", Cursor: 2}); r.Cursor != 5 { + t.Fatalf("regressing commit = %+v, want held at 5", r) + } + if got := inboxCursor(root, "alpha"); got != 5 { + t.Fatalf("cursor regressed to %d, want 5", got) + } +} + +func TestDrainInvalidSlugRejected(t *testing.T) { + root := t.TempDir() + if r := Drain(DrainOptions{Host: "claude", Root: root, Slug: "../escape"}); r.Status != "failed" { + t.Fatalf("unsafe slug must fail, got %+v", r) + } +} + +// TestPackagedDrainAckCommitSatisfiesWakeGuard is the load-bearing integration: +// after the packaged drain/ack/commit cycle, the Codex wake path (which owns the +// double-delivery guard) must treat the intent as delivered and NOT re-wake. This +// proves the packaged ack is byte-compatible with wake's replyKey/loadReplies. +func TestPackagedDrainAckCommitSatisfiesWakeGuard(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"hello","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + // FO drains, acts, acks (without committing yet): the reply alone must satisfy + // the wake guard even before the cursor advances. + res := Drain(DrainOptions{Host: "codex", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 drained record, got %+v", res.Records) + } + rec := res.Records[0] + if ar := Ack(AckOptions{Host: "codex", Root: root, Slug: "alpha", Line: rec.Line, ID: rec.ID, TS: rec.TS, IntentKind: rec.Kind, Status: "answered"}); !ar.Appended { + t.Fatalf("ack failed: %+v", ar) + } + + woke := false + wr := Wake(context.Background(), Options{Host: "codex", Root: root, Now: func() time.Time { return now }, Resume: func(context.Context, string, string) error { + woke = true + return nil + }}) + if woke { + t.Fatal("wake re-resumed an already-acked intent — replyKey byte-compat broken") + } + if wr.Status != "noop" { + t.Fatalf("wake status = %q, want noop (nothing pending)", wr.Status) + } +} diff --git a/internal/cli/bridge_inbox_test.go b/internal/cli/bridge_inbox_test.go new file mode 100644 index 000000000..8cc83e551 --- /dev/null +++ b/internal/cli/bridge_inbox_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBridgeInboxDrainAckCommitCLI drives the full FO-facing verb cycle through +// the hidden CLI to lock the argv grammar and JSON result shapes. +func TestBridgeInboxDrainAckCommitCLI(t *testing.T) { + root := t.TempDir() + bridgeDir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bridgeDir, "inbox.jsonl"), + []byte(`{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"go","target":"alpha"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + drain := runInbox(t, root, "drain", "--host", "claude", "--repo-root", root, "--slug", "alpha", "--session-id", "sess-a") + if drain["status"] != "ok" || drain["count"].(float64) != 1 { + t.Fatalf("drain = %v, want ok/count 1", drain) + } + + ack := runInbox(t, root, "ack", "--repo-root", root, "--slug", "alpha", "--line", "1", "--id", "i1", + "--ts", "2026-07-03T11:00:00Z", "--kind", "tell", "--status", "answered") + if ack["appended"] != true || ack["kind"] != "reply" { + t.Fatalf("ack = %v, want appended reply", ack) + } + + commit := runInbox(t, root, "commit", "--repo-root", root, "--slug", "alpha", "--cursor", "1") + if commit["cursor"].(float64) != 1 { + t.Fatalf("commit = %v, want cursor 1", commit) + } + + redrain := runInbox(t, root, "drain", "--host", "claude", "--repo-root", root, "--slug", "alpha") + if redrain["count"].(float64) != 0 { + t.Fatalf("re-drain = %v, want count 0", redrain) + } +} + +func TestBridgeInboxCheckReadsStdin(t *testing.T) { + root := t.TempDir() + bridgeDir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(bridgeDir, "inbox.jsonl"), + []byte(`{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`+"\n"), 0o644) + os.WriteFile(filepath.Join(bridgeDir, "fo.alpha.json"), + []byte(`{"session_id":"sess-a","host":"claude","ts":"2026-07-03T11:00:00Z","state":"idle"}`), 0o644) + + var stdout, stderr bytes.Buffer + payload := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":false}` + code := run(context.Background(), + []string{"bridge", "inbox", "check", "--host", "claude"}, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(payload), &stdout, &stderr, &fakeRunner{}, nil) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if got["decision"] != "block" { + t.Fatalf("decision = %v, want block", got["decision"]) + } +} + +func runInbox(t *testing.T, root string, args ...string) map[string]any { + t.Helper() + var stdout, stderr bytes.Buffer + full := append([]string{"bridge", "inbox"}, args...) + code := run(context.Background(), full, nil, filepath.Join(root, "elsewhere"), + strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + if code != 0 { + t.Fatalf("%v exit = %d stderr=%s", args, code, stderr.String()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("%v stdout JSON: %v\n%s", args, err, stdout.String()) + } + return got +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bfe6fd925..3d10ce08a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,6 +10,7 @@ import ( "io" "os" "os/exec" + "strconv" "strings" "github.com/spf13/cobra" @@ -468,7 +469,7 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout // print compact JSON results that Bridge can surface without knowing host internals. func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { return &cobra.Command{ - Use: "bridge egress emit --host | ingress wake --host codex | alert permission", + Use: "bridge egress emit --host | ingress wake --host codex | inbox drain|ack|commit|check | alert permission", Hidden: true, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -489,6 +490,9 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) return nil } + if len(args) >= 2 && args[0] == "inbox" { + return runBridgeInbox(cmd, args[1:], dir, stdin) + } if len(args) >= 2 && args[0] == "alert" && args[1] == "permission" { opts, parseErr := parseBridgeAlertPermission(args[2:], dir) if parseErr != "" { @@ -507,6 +511,62 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { } } +// runBridgeInbox dispatches the deterministic drain/ack/commit/check verbs the +// FO uses in place of hand-written shell. Each prints a compact JSON result. +func runBridgeInbox(cmd *cobra.Command, args []string, dir string, stdin io.Reader) error { + if len(args) == 0 { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]string{"error": "missing inbox subcommand (drain|ack|commit|check)"}) + return nil + } + sub := args[0] + rest := args[1:] + enc := json.NewEncoder(cmd.OutOrStdout()) + switch sub { + case "drain": + return enc.Encode(bridgeingress.Drain(bridgeingress.DrainOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + Members: parseBridgeCSVFlag(rest, "--members"), + })) + case "ack": + return enc.Encode(bridgeingress.Ack(bridgeingress.AckOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + Line: parseBridgeIntFlag(rest, "--line", 0), + ID: parseBridgeStringFlag(rest, "--id", ""), + TS: parseBridgeStringFlag(rest, "--ts", ""), + IntentKind: parseBridgeStringFlag(rest, "--kind", ""), + Status: parseBridgeStringFlag(rest, "--status", ""), + Text: parseBridgeStringFlag(rest, "--text", ""), + Granted: parseBridgeBoolFlag(rest, "--granted"), + Entity: parseBridgeStringFlag(rest, "--entity", ""), + Field: parseBridgeStringFlag(rest, "--field", ""), + Value: parseBridgeStringFlag(rest, "--value", ""), + Verdict: parseBridgeStringFlag(rest, "--verdict", ""), + RequestID: parseBridgeStringFlag(rest, "--request-id", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + })) + case "commit": + return enc.Encode(bridgeingress.Commit(bridgeingress.CommitOptions{ + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + Cursor: parseBridgeIntFlag(rest, "--cursor", -1), + })) + case "check": + return enc.Encode(bridgeingress.CheckFromReader(stdin, bridgeingress.CheckOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", ""), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + })) + default: + return enc.Encode(map[string]string{"error": "unknown inbox subcommand: " + sub}) + } +} + func parseBridgeAlertPermission(args []string, fallbackRoot string) (bridgealert.PermissionOptions, string) { opts := bridgealert.PermissionOptions{Root: fallbackRoot} for i := 0; i < len(args); i++ { @@ -579,6 +639,39 @@ func parseBridgeCSVFlag(args []string, name string) []string { return csvParts(raw) } +func parseBridgeIntFlag(args []string, name string, fallback int) int { + raw := parseBridgeStringFlag(args, name, "") + if raw == "" { + return fallback + } + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return fallback + } + return n +} + +// parseBridgeBoolFlag returns a tri-state pointer: nil when the flag is absent, +// so an omitted --granted is not serialized as false into the ack record. +func parseBridgeBoolFlag(args []string, name string) *bool { + for i := 0; i < len(args); i++ { + if args[i] == name { + // bare flag or followed by a value + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + v := strings.EqualFold(strings.TrimSpace(args[i+1]), "true") + return &v + } + v := true + return &v + } + if strings.HasPrefix(args[i], name+"=") { + v := strings.EqualFold(strings.TrimPrefix(args[i], name+"="), "true") + return &v + } + } + return nil +} + func csvParts(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) diff --git a/scripts/spacedock-bridge-inbox-check.sh b/scripts/spacedock-bridge-inbox-check.sh new file mode 100755 index 000000000..f7713da22 --- /dev/null +++ b/scripts/spacedock-bridge-inbox-check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# ABOUTME: Claude Stop-hook wrapper — blocks the stop when Bridge intent is queued. +# +# Registered by hooks/hooks.json on Stop (synchronous, NOT async: an async hook +# cannot return a decision). It reads the Stop payload on stdin and delegates the +# decision to the spacedock binary, which resolves this session's workflow slug +# and emits {"decision":"block","reason":...} when captain intent is pending, or +# {} to let the session stop. This is the Claude durable-wake path: a parked FO +# drains queued intent in-session at the turn boundary, with no unsafe external +# session resume. Any failure degrades to no output (the session stops normally). +set -u + +if [ -n "${SPACEDOCK_BIN:-}" ] && [ -x "${SPACEDOCK_BIN:-}" ]; then + bin="${SPACEDOCK_BIN}" +elif command -v spacedock >/dev/null 2>&1; then + bin="spacedock" +else + exit 0 +fi + +"$bin" bridge inbox check --host claude 2>/dev/null || : +exit 0 diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index 6fbd4717d..e2b292168 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -36,7 +36,26 @@ To file a seed task, do NOT use the Write tool to hand-assemble frontmatter afte ## Bridge egress (FO liveness/activity → Bridge) -Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod and needs no adapter binding.) +Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod through the packaged `spacedock bridge inbox` verbs; see **Bridge ingress** below.) - **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The wrapper delegates to `spacedock bridge egress emit --host claude`, which normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi have event producers but do not yet claim deterministic marker parity — see those adapters.) - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude, so `fo.$SLUG.json` carries the same id the event stream and session markers use and Bridge can join liveness to activity. No per-tick `export` is needed; leave `SD_SESSION_ID` unset on Claude (the event producer stamps `events.jsonl`/markers from the hook payload's session id, so an override that differs would desync the heartbeat from the event stream). + +## Bridge ingress (captain intent → FO) + +Captain intent that Bridge queues in `_bridge/inbox.jsonl` is drained by the host-neutral `bridge-inbox` mod, but the mechanism is **packaged in the binary** so the FO never hand-writes cursor/JSONL shell. Bind `«host» = claude` for every verb; the command resolves the session id from the egress binding above. + +### Runtime implementation + +- **`«inbox.drain»`** → `spacedock bridge inbox drain --host claude --slug $SLUG`. Stamps the liveness heartbeat (`_bridge/fo.$SLUG.json`, now carrying `host`), then returns the new, addressed, not-yet-acked records as JSON with a `high_water` mark. Does not advance the cursor. Run it at the eager top-of-loop drain and at startup/idle (per `bridge-inbox` `## Hook: startup`/`idle`). +- **`«inbox.ack»`** → `spacedock bridge inbox ack --host claude --slug $SLUG --line «n» --id «id» --ts «ts» --kind «intent-kind» --status «status» […]`. Appends one compact reply/ack line after you have acted on the record; the command derives the reply kind and serializes the JSON. +- **`«inbox.commit»`** → `spacedock bridge inbox commit --slug $SLUG --cursor «high_water»`. Advances the per-workflow cursor once, after act + ack for the whole batch. Monotonic. +- **`«inbox.wake»`** → the Claude durable wake is **in-session**, not an external resume: `hooks/hooks.json` registers a synchronous `Stop` hook (`scripts/spacedock-bridge-inbox-check.sh` → `spacedock bridge inbox check --host claude`) that reads the Stop payload, resolves this session's slug from its heartbeat/marker, and returns a `block` decision when intent is queued so the FO drains before stopping. A parked/closed Claude session cannot be safely resumed out-of-band (the transcript has no write locking), so there is no `spacedock bridge ingress wake --host claude` — do not add one. Bridge shows a queued count for a truly idle session and the captain nudges it. + +### State-machine semantics (so you never read source to act) + +`status --next` and the entity's own `status` field answer two different questions; conflating them sends an ensign to the wrong stage: + +- The entity's **`status` frontmatter field is the current stage** — the stage the entity is *in now*, and the stage an ensign dispatched now works. +- `status --next` is a **look-ahead preview** of the stage the entity would enter *after* its current stage completes. It is a preview, not a dispatch target. Do not dispatch an ensign into the `--next` stage; dispatch into the current stage and let stage completion advance the entity (the FO advances with `status --set`/the gate flow, per `## Completion and Gates`). +- At a gate, the current stage is the gate stage; resolve the gate (captain decision or given-the-conn auto-approve) before the entity moves on. `status --boot --json` reports the workflow's stage order and `id_style`; consult it rather than reconstructing the state machine from source. diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index ec876f486..a4d86699e 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -57,3 +57,9 @@ Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/s - **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file, `CLAUDE_PLUGIN_ROOT`, or Codex plugin-root state. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. - **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). + +## Bridge ingress (captain intent → FO) + +Captain intent Bridge queues in `_bridge/inbox.jsonl` is drained through the same host-neutral packaged verbs Claude uses, bound `«host» = codex`: `spacedock bridge inbox drain --host codex --slug $SLUG` (stamps the heartbeat with `host`, returns addressed records), `... ack ...` (appends the compact reply/ack), `... commit --cursor «high_water»` (advances the per-workflow cursor). The FO never hand-writes cursor/JSONL shell. + +- **`«inbox.wake»`** — Codex supports a durable **external** wake (unlike Claude): Bridge runs `spacedock bridge ingress wake --host codex`, which resumes a parked Codex session via `codex exec resume` and prompts it to drain. A wake is only an attempt; delivery is confirmed by the FO-owned drain + ack, so it is safe against the gate-vs-inbox race. diff --git a/skills/integration/bridge_inbox_wake_hook_test.go b/skills/integration/bridge_inbox_wake_hook_test.go new file mode 100644 index 000000000..0c76ea8e6 --- /dev/null +++ b/skills/integration/bridge_inbox_wake_hook_test.go @@ -0,0 +1,64 @@ +// ABOUTME: Claude Bridge-ingress wake wiring — the Stop hook that drains queued +// ABOUTME: captain intent must be SYNCHRONOUS (an async hook cannot return a decision). +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestClaudeStopHookRegistersSynchronousInboxCheck locks the Claude durable-wake +// wiring: hooks/hooks.json must register scripts/spacedock-bridge-inbox-check.sh on +// Stop WITHOUT async, or the block decision that keeps a parked FO draining is +// dropped (async Stop hooks are fire-and-forget). The pre-existing async egress +// Stop hook may coexist; this asserts the check hook specifically is synchronous. +func TestClaudeStopHookRegistersSynchronousInboxCheck(t *testing.T) { + root := repoRoot(t) + path := filepath.Join(root, "hooks", "hooks.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read hooks: %v", err) + } + + var cfg struct { + Hooks map[string][]struct { + Hooks []map[string]any `json:"hooks"` + } `json:"hooks"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse hooks: %v", err) + } + + const script = "spacedock-bridge-inbox-check.sh" + var found, sync bool + for _, group := range cfg.Hooks["Stop"] { + for _, h := range group.Hooks { + cmd, _ := h["command"].(string) + if !strings.Contains(cmd, script) { + continue + } + found = true + if async, ok := h["async"].(bool); !ok || !async { + sync = true + } + } + } + if !found { + t.Fatalf("Stop hook does not register %s — a parked Claude FO is never nudged to drain:\n%s", script, data) + } + if !sync { + t.Fatalf("%s Stop hook must be synchronous (no async:true); an async hook cannot return the block decision that forces a drain", script) + } + + scriptPath := filepath.Join(root, "scripts", script) + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatalf("wake hook script missing: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("%s is not executable (mode %v)", script, info.Mode()) + } +} From 0d96a40965f27e9cd010d021053bbd423470fec4 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 09:20:02 +0800 Subject: [PATCH 15/21] fix(review): address DRC-3798 self-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check: hoist the per-slug inbox cursor read out of the per-record loop (was re-reading .inbox-cursor. 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) --- internal/bridgeingress/drain.go | 17 ++++++++++++++--- internal/bridgeingress/drain_test.go | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/bridgeingress/drain.go b/internal/bridgeingress/drain.go index 0a8c7b9d9..3314d7673 100644 --- a/internal/bridgeingress/drain.go +++ b/internal/bridgeingress/drain.go @@ -387,8 +387,9 @@ func Check(opts CheckOptions) HookDecision { pendingBySlug := map[string]int{} for _, slug := range slugs { members := []string{slug} + cursor := inboxCursor(root, slug) for _, rec := range records { - if rec.Line <= inboxCursor(root, slug) { + if rec.Line <= cursor { continue } if !addressedTo(root, rec.routing(), slug, members) { @@ -544,6 +545,14 @@ func readInboxFull(path string) ([]fullInboxRecord, int, error) { if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { continue } + // Preserve the exact on-disk ts string so drain→ack round-trips it verbatim; + // reformatting to UTC would break wake's ts-based replyKey fallback for an + // id-less record. + var raw struct { + TS string `json:"ts"` + } + _ = json.Unmarshal(scanner.Bytes(), &raw) + rec.RawTS = raw.TS rec.Line = lineNo out = append(out, rec) } @@ -554,8 +563,10 @@ func readInboxFull(path string) ([]fullInboxRecord, int, error) { } func toDrainRecord(r fullInboxRecord) DrainRecord { - ts := "" - if !r.TS.IsZero() { + // Prefer the exact on-disk ts string; fall back to the parsed form only if the + // raw field is somehow empty. + ts := r.RawTS + if ts == "" && !r.TS.IsZero() { ts = r.TS.UTC().Format(time.RFC3339) } return DrainRecord{ diff --git a/internal/bridgeingress/drain_test.go b/internal/bridgeingress/drain_test.go index 18985ff71..ded0349a5 100644 --- a/internal/bridgeingress/drain_test.go +++ b/internal/bridgeingress/drain_test.go @@ -59,6 +59,21 @@ func TestDrainReturnsAddressedRecordsAndStampsHostHeartbeat(t *testing.T) { } } +func TestDrainPreservesRawTimestamp(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + // A non-UTC offset ts: drain must round-trip it verbatim, not reformat to UTC, + // or wake's ts-based replyKey fallback (id-less records) would mismatch. + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T20:00:00+08:00","kind":"tell","text":"hi","target":"alpha"}`) + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 record, got %+v", res.Records) + } + if res.Records[0].TS != "2026-07-03T20:00:00+08:00" { + t.Fatalf("ts = %q, want the verbatim on-disk offset ts", res.Records[0].TS) + } +} + func TestDrainRespectsCursorAndAckIdempotency(t *testing.T) { root := t.TempDir() now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) From 1ea0966a38107eee4cc68c3e2cd9d0d14c254627 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 10:03:32 +0800 Subject: [PATCH 16/21] fix(bridge): anchor egress at nearest git root, not raw cwd 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 --- internal/bridgeegress/egress.go | 31 +++++++++++++- internal/bridgeegress/egress_test.go | 61 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go index 378815d6e..82e6c0072 100644 --- a/internal/bridgeegress/egress.go +++ b/internal/bridgeegress/egress.go @@ -135,7 +135,7 @@ func Emit(data []byte, opts Options) { }, } - bridgeDir := filepath.Join(cwdAbs, "_bridge") + bridgeDir := filepath.Join(canonicalBridgeRoot(cwdAbs), "_bridge") if err := os.MkdirAll(bridgeDir, 0o755); err != nil { return } @@ -164,6 +164,35 @@ func Emit(data []byte, opts Options) { }) } +// canonicalBridgeRoot resolves the worktree/checkout root that owns the shared +// _bridge/ directory Bridge reads. Egress fires from whatever cwd the emitting +// session happens to hold — a workflow subdir, a nested package — but Bridge +// reads exactly one _bridge/ at the root the FO launched in. Anchoring events +// and session markers there (instead of filepath.Join(cwd, "_bridge")) keeps +// them in that one canonical location rather than scattering stray _bridge/ +// dirs wherever a session ran. +// +// It walks up to the nearest enclosing git root — the first ancestor containing +// a ".git" entry, whether a directory (a normal checkout) or a file (a linked +// worktree). It STOPS there and does NOT resolve a linked worktree back to its +// main checkout: an FO (and its Bridge) commonly run from a worktree, and Bridge +// reads that worktree's own _bridge, not the main checkout's. When no git root +// is found it falls back to the input, so a non-repo cwd is unchanged and this +// stays observe-only (never fails the caller). +func canonicalBridgeRoot(start string) string { + dir := start + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return start + } + dir = parent + } +} + // DeriveEntity maps a read entity file path to (workflow, entity). It supports // legacy docs/spacedock layouts and workflow-local split-root state checkouts. func DeriveEntity(cwd, entityPath string) (string, string, bool) { diff --git a/internal/bridgeegress/egress_test.go b/internal/bridgeegress/egress_test.go index ee2d4c300..b6dec0812 100644 --- a/internal/bridgeegress/egress_test.go +++ b/internal/bridgeegress/egress_test.go @@ -283,6 +283,67 @@ func TestEmitAppendsAndTruncatesEvents(t *testing.T) { } } +func TestEmitAnchorsBridgeDirAtRepoRootFromNestedCWD(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "docs", "spacedock", "pr-review-queue") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + Emit([]byte(`{"cwd":`+quote(nested)+`,"event":"PostToolUse","session_id":"ses-nested"}`), fixedOptions("claude")) + + if _, err := os.Stat(filepath.Join(root, "_bridge", "events.jsonl")); err != nil { + t.Fatalf("event not anchored at repo root: %v", err) + } + if _, err := os.Stat(filepath.Join(nested, "_bridge")); !os.IsNotExist(err) { + t.Fatalf("stray _bridge scattered under nested cwd: %v", err) + } +} + +func TestEmitAnchorsBridgeDirAtWorktreeRootNotMainCheckout(t *testing.T) { + // A linked worktree (its ".git" is a file, not a dir). An FO and its Bridge + // commonly run FROM a worktree, and Bridge reads that worktree's own + // _bridge/ — NOT the main checkout's. So egress from a subdir of the + // worktree must anchor at the worktree root, and must NOT resolve upward to + // the main checkout. + main := t.TempDir() + if err := os.MkdirAll(filepath.Join(main, ".git", "worktrees", "wt1"), 0o755); err != nil { + t.Fatal(err) + } + worktree := filepath.Join(t.TempDir(), "feature-wt") + nested := filepath.Join(worktree, "docs", "spacedock", "pr-review-queue") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + gitdir := filepath.Join(main, ".git", "worktrees", "wt1") + if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + Emit([]byte(`{"cwd":`+quote(nested)+`,"event":"PostToolUse","session_id":"ses-wt"}`), fixedOptions("claude")) + + if _, err := os.Stat(filepath.Join(worktree, "_bridge", "events.jsonl")); err != nil { + t.Fatalf("event not anchored at the worktree root Bridge reads: %v", err) + } + if _, err := os.Stat(filepath.Join(main, "_bridge")); !os.IsNotExist(err) { + t.Fatalf("egress leaked to the main checkout, which the worktree's Bridge does not read") + } + if _, err := os.Stat(filepath.Join(nested, "_bridge")); !os.IsNotExist(err) { + t.Fatalf("stray _bridge scattered under nested cwd: %v", err) + } +} + +func TestEmitFallsBackToCWDWhenNoGitRoot(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{"cwd":`+quote(root)+`,"event":"PostToolUse","session_id":"ses-nogit"}`), fixedOptions("claude")) + if _, err := os.Stat(filepath.Join(root, "_bridge", "events.jsonl")); err != nil { + t.Fatalf("non-repo cwd should still write _bridge at cwd: %v", err) + } +} + func fixedOptions(host string) Options { return Options{ Host: host, From 641920e923a03894e3edfa04352cedb2218caf79 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 12:55:46 +0800 Subject: [PATCH 17/21] feat(bridge): loud ack correlator guard + drain auto-acting ack 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. --- internal/bridgeingress/drain.go | 58 +++++++++++++++++++-- internal/bridgeingress/drain_test.go | 75 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/internal/bridgeingress/drain.go b/internal/bridgeingress/drain.go index 3314d7673..cf90608a4 100644 --- a/internal/bridgeingress/drain.go +++ b/internal/bridgeingress/drain.go @@ -134,9 +134,40 @@ func Drain(opts DrainOptions) DrainResult { res.Records = append(res.Records, toDrainRecord(rec)) } res.Count = len(res.Records) + + // Auto-emit the interim "acting" ack for each freshly-drained addressed record + // so the command-ack lifecycle advances received->acting mechanically, owned by + // the binary rather than FO prose. Each record here has no prior reply (loadReplies + // already filtered acked records out above), so no duplicate acting ack can stack. + // Best-effort: a failure to write the interim ack must not fail the drain itself. + sessionID := resolveSessionID(host, opts.SessionID) + for _, dr := range res.Records { + ackKind, ok := ackKindFor(dr.Kind) + if !ok { + continue + } + _ = appendReply(root, replyOut{ + Schema: 1, + TS: now().UTC().Format(time.RFC3339), + Kind: ackKind, + Target: slug, + InReplyToID: dr.ID, + InReplyToLine: dr.Line, + InReplyToTS: dr.TS, + IntentKind: dr.Kind, + Status: ackStatusActing, + RequestID: dr.RequestID, + SessionID: sessionID, + Host: host, + }) + } return res } +// ackStatusActing is the interim command-ack lifecycle status the drain verb +// auto-appends on receipt (received->acting). Terminal statuses are FO-authored. +const ackStatusActing = "acting" + // addressedTo reports whether an inbox record routes to this workflow slug, // honoring an authoritative frozen target_set over the legacy target field. func addressedTo(root string, rec inboxRecord, slug string, members []string) bool { @@ -233,6 +264,13 @@ func Ack(opts AckOptions) AckResult { if strings.TrimSpace(opts.Status) == "" { return AckResult{Error: "missing --status"} } + // LOUD correlator guard: a reply with neither an id nor a ts has no strong + // correlator, so the Bridge reader can never fold it onto its intent and would + // silently drop it. Fail at write time instead of appending an orphan. The + // common path always carries an id from the drained record, so it is unaffected. + if opts.ID == "" && strings.TrimSpace(opts.TS) == "" { + return AckResult{Error: "ack has no correlator: pass --id (from the drained record) or --ts"} + } host := normalizeHost(opts.Host) root := absRootOr(opts.Root) now := nowFunc(opts.Now) @@ -257,23 +295,33 @@ func Ack(opts AckOptions) AckResult { SessionID: resolveSessionID(host, opts.SessionID), Host: host, } + if err := appendReply(root, rec); err != nil { + return AckResult{Error: err.Error()} + } + return AckResult{Appended: true, Kind: kind, Target: slug, Line: opts.Line} +} + +// appendReply marshals one reply/ack record to a compact single line and appends +// it to fo-replies.jsonl. Serialization is owned here so the FO (Ack) and the +// mechanical drain auto-ack share one byte-exact JSONL write path. +func appendReply(root string, rec replyOut) error { dir := filepath.Join(root, "_bridge") if err := os.MkdirAll(dir, 0o755); err != nil { - return AckResult{Error: err.Error()} + return err } data, err := json.Marshal(rec) if err != nil { - return AckResult{Error: err.Error()} + return err } f, err := os.OpenFile(filepath.Join(dir, "fo-replies.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { - return AckResult{Error: err.Error()} + return err } defer f.Close() if _, err := f.Write(append(data, '\n')); err != nil { - return AckResult{Error: err.Error()} + return err } - return AckResult{Appended: true, Kind: kind, Target: slug, Line: opts.Line} + return nil } // CommitOptions controls one cursor advance. diff --git a/internal/bridgeingress/drain_test.go b/internal/bridgeingress/drain_test.go index ded0349a5..965cfbc7a 100644 --- a/internal/bridgeingress/drain_test.go +++ b/internal/bridgeingress/drain_test.go @@ -155,6 +155,81 @@ func TestAckOmitsGrantedWhenAbsent(t *testing.T) { } } +func TestAck_NoCorrelatorFailsLoudly(t *testing.T) { + root := t.TempDir() + // No id AND no ts: the reply has no strong correlator, so it must fail loudly at + // write time rather than append an orphan the Bridge reader would silently drop. + ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, IntentKind: "tell", Status: "answered"}) + if ar.Appended { + t.Fatalf("no-correlator ack must not append, got %+v", ar) + } + if ar.Error == "" { + t.Fatalf("no-correlator ack must return an error, got %+v", ar) + } + // fo-replies.jsonl must not exist / must be untouched. + if _, err := os.Stat(filepath.Join(root, "_bridge", "fo-replies.jsonl")); !os.IsNotExist(err) { + t.Fatalf("no-correlator ack must not write fo-replies.jsonl: %v", err) + } +} + +func TestAck_IdOnlyReplyIsCorrelatable(t *testing.T) { + root := t.TempDir() + // id present, ts empty: id alone is a strong correlator, so the ack appends. + ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "i2", IntentKind: "tell", Status: "answered"}) + if !ar.Appended || ar.Error != "" { + t.Fatalf("id-only ack must append, got %+v", ar) + } + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec replyOut + if err := json.Unmarshal([]byte(strings.TrimSpace(string(raw))), &rec); err != nil { + t.Fatalf("reply line not valid JSON: %q (%v)", raw, err) + } + if rec.InReplyToID != "i2" { + t.Fatalf("in_reply_to_id = %q, want i2", rec.InReplyToID) + } + if rec.InReplyToTS != "" { + t.Fatalf("empty --ts must echo as empty in_reply_to_ts, got %q", rec.InReplyToTS) + } +} + +func TestDrain_EmitsActingAck(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"do it","target":"alpha"}`) + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", SessionID: "sess-1", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 drained record, got %+v", res.Records) + } + dr := res.Records[0] + + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatalf("drain must auto-write an acting ack: %v", err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("want exactly one acting ack, got %d:\n%s", len(lines), raw) + } + var rec replyOut + if err := json.Unmarshal([]byte(lines[0]), &rec); err != nil { + t.Fatalf("acting ack not valid JSON: %q (%v)", lines[0], err) + } + if rec.Status != "acting" { + t.Fatalf("status = %q, want acting", rec.Status) + } + // The acting ack matches the drained record's id/line/ts/kind. + if rec.InReplyToID != dr.ID || rec.InReplyToLine != dr.Line || rec.InReplyToTS != dr.TS { + t.Fatalf("acting ack correlators = %q/%d/%q, want %q/%d/%q", rec.InReplyToID, rec.InReplyToLine, rec.InReplyToTS, dr.ID, dr.Line, dr.TS) + } + if rec.IntentKind != dr.Kind || rec.Kind != "reply" { + t.Fatalf("acting ack kinds = intent %q / reply %q, want %q / reply", rec.IntentKind, rec.Kind, dr.Kind) + } +} + func TestCommitAdvancesCursorMonotonically(t *testing.T) { root := t.TempDir() if r := Commit(CommitOptions{Root: root, Slug: "alpha", Cursor: 5}); r.Cursor != 5 { From c8eca69134b3aa49c6d1c190d30cc6c0f6c10978 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 13:01:27 +0800 Subject: [PATCH 18/21] feat(bridge): add fo-initiate writer + verb for FO-authored gates 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) Signed-off-by: Jared Scott --- docs/dev/bridge-egress-contract.md | 18 ++ internal/bridgeinitiate/initiate.go | 272 +++++++++++++++++++++++ internal/bridgeinitiate/initiate_test.go | 246 ++++++++++++++++++++ internal/cli/cli.go | 60 ++++- skills/present-gate/SKILL.md | 30 +++ 5 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 internal/bridgeinitiate/initiate.go create mode 100644 internal/bridgeinitiate/initiate_test.go diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md index 5131fd786..ebd8cb810 100644 --- a/docs/dev/bridge-egress-contract.md +++ b/docs/dev/bridge-egress-contract.md @@ -91,6 +91,24 @@ One JSON object per line, appended when the FO is blocked by a captain-owned hos - Bridge overlays a later inbox `permission-decision` record for the same `request_id` to show approved/denied state; the alert file itself remains append-only. - → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge alert permission ...`. The helper returns `{"id":"...","request_id":"...","queued":true}` and appends one line to `_bridge/fo-alerts.jsonl`. If the helper cannot queue the alert, it still exits without blocking the FO command and returns `{"queued":false,"error":"..."}`. +## `_bridge/fo-initiate.jsonl` — FO-authored feed lines and decidable gates + +One JSON object per line, appended when the FO originates a message to the captain that is not a reply to a captain intent: a status note, a recommendation, or a decidable `gate-review`. Bridge reads it, folds records by `id` (keeping the latest per id), and renders each as an FO-authored feed line — a `gate-review` gets an inline Approve/Reject affordance. + +``` +{"schema":1,"id":"","ts":"","kind":"","workflow":"","entity":"","ship_id":"/","host":"","session_id":"<«session-id»>","headline":"","body":"optional supporting prose","request_id":"","status":"open"} +``` + +- `id` is REQUIRED and is the fold key. Unlike `fo-alerts.jsonl` there is NO random fallback: idempotency depends on a stable caller-supplied id. The FO re-emits the same record each drain tick; Bridge collapses them to one card by `id`. For `gate-review`, `present-gate` derives `id`/`request_id` deterministically from `(entity, stage)` so re-emit is byte-stable. +- `kind` ∈ `status | reco | gate-review`. `status`/`reco` are plain FO-authored lines; `gate-review` is a decidable card. +- `headline` is REQUIRED, bounded to 240 chars; `body` is optional, bounded to 2000 chars. Both are collapsed to one line (control chars and whitespace runs become single spaces). +- `request_id` is the gate loop-closure correlator; it defaults to `id` for `gate-review` and is omitted for `status`/`reco`. Approve/Reject write a decision intent to `inbox.jsonl` carrying this `request_id`; Bridge's fo-initiate reader overlays that decision to flip the card's status. +- `status`: the writer ALWAYS writes `open`. The READER overlays `resolved`/`approved`/`rejected` from decision intents — never trust a written non-open status. +- **Path anchoring:** the writer resolves `filepath.Abs(--repo-root or cwd)/_bridge` — EXACTLY like `bridgealert.AppendPermission`, NOT `bridgeegress.canonicalBridgeRoot`. It MUST be passed the same repo root Bridge resolves from, or the write lands in a divergent `_bridge/`. +- **Bounded, but open gates never evicted:** the file is capped (the writer truncates to a recent-tail window on each append), but it NEVER drops the latest record of a still-open `gate-review` id, so an open gate cannot scroll out of the read window. +- **Channel boundary:** a decidable gate lives in `fo-initiate.jsonl` ONLY — never `fo-feed.jsonl` (ambient git narration) and never `fo-replies.jsonl` (which requires an `in_reply_to` correlator and would silently drop an uncorrelated FO-originated push). +- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge initiate --kind ...`. The helper returns `{"id":"...","request_id":"...","queued":true}` and appends one line. If it cannot queue, it exits without blocking the FO and returns `{"queued":false,"error":"..."}`. Gate emission is wired in the `present-gate` skill (loaded by every host), not in a per-host hook. + ## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) First-write-wins per host actor, one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. diff --git a/internal/bridgeinitiate/initiate.go b/internal/bridgeinitiate/initiate.go new file mode 100644 index 000000000..22be1a5f9 --- /dev/null +++ b/internal/bridgeinitiate/initiate.go @@ -0,0 +1,272 @@ +// ABOUTME: FO-to-Bridge initiation writer for FO-authored feed lines and gates. +// ABOUTME: Append-only and best-effort; Bridge reads _bridge/fo-initiate.jsonl. +package bridgeinitiate + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + maxHeadlineLen = 240 + maxBodyLen = 2000 + + // Truncation bounds mirror bridgeegress.truncateEvents. A still-open + // gate-review is retained past the tail regardless of these bounds. + defaultMaxLines = 2000 + defaultKeepLines = 1000 + maxScanLine = 1 << 20 +) + +// validKinds are the initiation decidability classes Bridge understands. +var validKinds = map[string]bool{ + "status": true, + "reco": true, + "gate-review": true, +} + +// InitiationRecord is the stable JSONL shape Bridge reads from +// _bridge/fo-initiate.jsonl. The writer ALWAYS stamps status "open"; the reader +// overlays resolved/approved/rejected from decision intents. +type InitiationRecord struct { + Schema int `json:"schema"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Workflow string `json:"workflow,omitempty"` + Entity string `json:"entity,omitempty"` + ShipID string `json:"ship_id,omitempty"` + Host string `json:"host,omitempty"` + SessionID string `json:"session_id,omitempty"` + Headline string `json:"headline"` + Body string `json:"body,omitempty"` + RequestID string `json:"request_id,omitempty"` + Status string `json:"status"` +} + +// InitiationOptions controls one initiation append. +type InitiationOptions struct { + Root string + Now func() time.Time + ID string + Kind string + Workflow string + Entity string + ShipID string + Host string + SessionID string + Headline string + Body string + RequestID string +} + +// Result is the compact JSON result printed for Bridge. +type Result struct { + ID string `json:"id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Queued bool `json:"queued"` + Error string `json:"error,omitempty"` +} + +// AppendInitiation writes one open initiation under root/_bridge. Unlike alert +// writes, id is REQUIRED with no random fallback: idempotency depends on a +// stable caller-supplied fold key. +func AppendInitiation(opts InitiationOptions) (Result, error) { + id := strings.TrimSpace(opts.ID) + result := Result{ID: id, RequestID: id} + if id == "" { + return result.withError("initiation: id is required"), nil + } + + kind := strings.TrimSpace(opts.Kind) + if !validKinds[kind] { + return result.withError("initiation: kind must be one of status|reco|gate-review"), nil + } + + headline := oneLineSummary(opts.Headline, maxHeadlineLen) + if headline == "" { + return result.withError("initiation: headline is required"), nil + } + + requestID := strings.TrimSpace(opts.RequestID) + if requestID == "" && kind == "gate-review" { + requestID = id + } + result.RequestID = requestID + + root := opts.Root + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return result.withError(err.Error()), nil + } + + now := time.Now().UTC + if opts.Now != nil { + now = func() time.Time { return opts.Now().UTC() } + } + + rec := InitiationRecord{ + Schema: 1, + ID: id, + TS: now().Format(time.RFC3339), + Kind: kind, + Workflow: strings.TrimSpace(opts.Workflow), + Entity: strings.TrimSpace(opts.Entity), + ShipID: strings.TrimSpace(opts.ShipID), + Host: strings.TrimSpace(opts.Host), + SessionID: strings.TrimSpace(opts.SessionID), + Headline: headline, + Body: oneLineSummary(opts.Body, maxBodyLen), + RequestID: requestID, + Status: "open", + } + + dir := filepath.Join(absRoot, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return result.withError(err.Error()), nil + } + path := filepath.Join(dir, "fo-initiate.jsonl") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return result.withError(err.Error()), nil + } + data, err := json.Marshal(rec) + if err != nil { + _ = f.Close() + return result.withError(err.Error()), nil + } + if _, err := f.Write(append(data, '\n')); err != nil { + _ = f.Close() + return result.withError(err.Error()), nil + } + if err := f.Close(); err != nil { + return result.withError(err.Error()), nil + } + + truncateInitiate(path) + result.Queued = true + return result, nil +} + +func (r Result) withError(msg string) Result { + r.Queued = false + r.Error = msg + return r +} + +// truncateInitiate mirrors bridgeegress.truncateEvents (temp-file + rename tail +// retention) but NEVER drops the latest record of a still-open gate-review id, +// so an open gate can't be truncated out of the read window. +func truncateInitiate(path string) { + f, err := os.Open(path) + if err != nil { + return + } + var lines []string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + if len(lines) <= defaultMaxLines || scanner.Err() != nil { + return + } + + keepLines := defaultKeepLines + if keepLines > len(lines) { + keepLines = len(lines) + } + tailStart := len(lines) - keepLines + + // Latest record per id wins; retain the latest line of every still-open + // gate-review id even when it falls before the tail window. + latestLine := make(map[string]int) + for i, line := range lines { + rec, ok := parseRecord(line) + if !ok { + continue + } + latestLine[rec.ID] = i + } + protected := make(map[int]bool) + for _, i := range latestLine { + rec, ok := parseRecord(lines[i]) + if !ok { + continue + } + if rec.Kind == "gate-review" && rec.Status == "open" && i < tailStart { + protected[i] = true + } + } + + var kept []string + for i := 0; i < tailStart; i++ { + if protected[i] { + kept = append(kept, lines[i]) + } + } + kept = append(kept, lines[tailStart:]...) + + out := strings.Join(kept, "\n") + "\n" + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp.*") + if err != nil { + return + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(out); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + } +} + +func parseRecord(line string) (InitiationRecord, bool) { + line = strings.TrimSpace(line) + if line == "" { + return InitiationRecord{}, false + } + var rec InitiationRecord + if err := json.Unmarshal([]byte(line), &rec); err != nil { + return InitiationRecord{}, false + } + return rec, true +} + +// oneLineSummary collapses control chars/whitespace to single spaces and bounds +// the result to maxLen runes. Mirrors bridgealert.oneLineSummary. +func oneLineSummary(in string, maxLen int) string { + in = strings.TrimSpace(in) + var b strings.Builder + prevSpace := false + for _, r := range in { + if unicode.IsControl(r) || unicode.IsSpace(r) { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + if b.Len() >= maxLen { + break + } + } + return strings.TrimSpace(b.String()) +} diff --git a/internal/bridgeinitiate/initiate_test.go b/internal/bridgeinitiate/initiate_test.go new file mode 100644 index 000000000..6c26a375d --- /dev/null +++ b/internal/bridgeinitiate/initiate_test.go @@ -0,0 +1,246 @@ +package bridgeinitiate + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAppendInitiationWritesRecord(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + Now: func() time.Time { return time.Date(2026, 7, 3, 5, 0, 0, 0, time.UTC) }, + ID: "gate-ship-a-ideation", + Kind: "gate-review", + Workflow: "pr-review-queue", + Entity: "ship-a", + ShipID: "pr-review-queue/ship-a", + Host: "claude", + SessionID: "s1", + Headline: "ship-a ideation ready for a call", + Body: "chosen direction: reuse existing writer", + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "gate-ship-a-ideation" || !got.Queued { + t.Fatalf("result = %+v", got) + } + if got.RequestID != "gate-ship-a-ideation" { + t.Fatalf("request id = %q, want default to id for gate-review", got.RequestID) + } + + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-initiate.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec InitiationRecord + if err := json.Unmarshal(bytes.TrimSpace(data), &rec); err != nil { + t.Fatalf("record JSON: %v\n%s", err, data) + } + if rec.Schema != 1 || rec.Status != "open" { + t.Fatalf("record = %+v", rec) + } + if rec.Kind != "gate-review" || rec.RequestID != "gate-ship-a-ideation" { + t.Fatalf("record = %+v", rec) + } + if rec.TS != "2026-07-03T05:00:00Z" || rec.Headline != "ship-a ideation ready for a call" { + t.Fatalf("record fields = %+v", rec) + } + if rec.ShipID != "pr-review-queue/ship-a" || rec.Workflow != "pr-review-queue" { + t.Fatalf("record routing = %+v", rec) + } +} + +func TestAppendInitiationDefaultsRequestIDOnlyForGate(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "status-1", + Kind: "status", + Headline: "advancing ship-b", + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v", got) + } + if got.RequestID != "" { + t.Fatalf("request id = %q, want empty for non-gate kind", got.RequestID) + } +} + +func TestAppendInitiationMissingIDIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + Kind: "status", + Headline: "no id here", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationInvalidKindIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + ID: "x1", + Kind: "chatter", + Headline: "bad kind", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationMissingHeadlineIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + ID: "x1", + Kind: "status", + Headline: " ", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationNormalizesBounds(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "x1", + Kind: "reco", + Headline: "line one\ntwo\tthree", + Body: "body\n" + strings.Repeat("z", maxBodyLen+500), + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v", got) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-initiate.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec InitiationRecord + if err := json.Unmarshal(bytes.TrimSpace(data), &rec); err != nil { + t.Fatal(err) + } + if rec.Headline != "line one two three" { + t.Fatalf("headline = %q", rec.Headline) + } + if strings.ContainsAny(rec.Body, "\n\t") || len(rec.Body) > maxBodyLen { + t.Fatalf("body len=%d %q", len(rec.Body), rec.Body) + } +} + +func TestAppendInitiation_AnchorsAtRepoRoot(t *testing.T) { + root := t.TempDir() + if _, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "anchor-1", + Kind: "status", + Headline: "anchoring check", + }); err != nil { + t.Fatal(err) + } + absRoot, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + // The file must land at filepath.Abs(root)/_bridge/fo-initiate.jsonl — the + // same anchor bridgealert.AppendPermission uses — NOT a canonicalBridgeRoot + // git-root walk-up. + want := filepath.Join(absRoot, "_bridge", "fo-initiate.jsonl") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected file at %s: %v", want, err) + } +} + +func TestTruncateInitiate_KeepsOpenGate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fo-initiate.jsonl") + + var lines []string + // An OLD still-open gate-review that must survive truncation. + openGate := InitiationRecord{ + Schema: 1, ID: "gate-old", TS: "2026-07-03T00:00:00Z", + Kind: "gate-review", Headline: "old open gate", RequestID: "gate-old", Status: "open", + } + lines = append(lines, mustJSON(t, openGate)) + // A resolved old gate that is NOT protected (should be dropped). + resolvedGate := InitiationRecord{ + Schema: 1, ID: "gate-done", TS: "2026-07-03T00:00:01Z", + Kind: "gate-review", Headline: "resolved gate", RequestID: "gate-done", Status: "resolved", + } + lines = append(lines, mustJSON(t, resolvedGate)) + // Enough status records to exceed maxLines and push the gate before the tail. + for i := 0; i < defaultMaxLines+200; i++ { + lines = append(lines, mustJSON(t, InitiationRecord{ + Schema: 1, ID: fmt.Sprintf("status-%d", i), TS: "2026-07-03T01:00:00Z", + Kind: "status", Headline: fmt.Sprintf("noise %d", i), Status: "open", + })) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + truncateInitiate(path) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + kept := strings.Split(strings.TrimSpace(string(data)), "\n") + // keepLines tail + exactly the one protected open gate. + if len(kept) != defaultKeepLines+1 { + t.Fatalf("kept %d lines, want %d", len(kept), defaultKeepLines+1) + } + + var foundOpenGate, foundResolvedGate bool + for _, line := range kept { + rec, ok := parseRecord(line) + if !ok { + t.Fatalf("unparseable kept line: %s", line) + } + if rec.ID == "gate-old" { + foundOpenGate = true + } + if rec.ID == "gate-done" { + foundResolvedGate = true + } + } + if !foundOpenGate { + t.Fatal("still-open gate-review was evicted by truncation") + } + if foundResolvedGate { + t.Fatal("resolved gate-review should not be retained past the cap") + } +} + +func mustJSON(t *testing.T, rec InitiationRecord) string { + t.Helper() + data, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3d10ce08a..92bafeb06 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -18,6 +18,7 @@ import ( "github.com/spacedock-dev/spacedock/internal/bridgealert" "github.com/spacedock-dev/spacedock/internal/bridgeegress" "github.com/spacedock-dev/spacedock/internal/bridgeingress" + "github.com/spacedock-dev/spacedock/internal/bridgeinitiate" "github.com/spacedock-dev/spacedock/internal/claudeteam" "github.com/spacedock-dev/spacedock/internal/contract" "github.com/spacedock-dev/spacedock/internal/dispatch" @@ -469,7 +470,7 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout // print compact JSON results that Bridge can surface without knowing host internals. func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { return &cobra.Command{ - Use: "bridge egress emit --host | ingress wake --host codex | inbox drain|ack|commit|check | alert permission", + Use: "bridge egress emit --host | ingress wake --host codex | inbox drain|ack|commit|check | alert permission | initiate --kind status|reco|gate-review", Hidden: true, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -506,6 +507,19 @@ func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) return nil } + if len(args) >= 1 && args[0] == "initiate" { + opts, parseErr := parseBridgeInitiate(args[1:], dir) + if parseErr != "" { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(bridgeinitiate.Result{Queued: false, Error: parseErr}) + return nil + } + result, err := bridgeinitiate.AppendInitiation(opts) + if err != nil { + return err + } + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) + return nil + } return nil }, } @@ -607,6 +621,50 @@ func parseBridgeAlertPermission(args []string, fallbackRoot string) (bridgealert return opts, "" } +func parseBridgeInitiate(args []string, fallbackRoot string) (bridgeinitiate.InitiationOptions, string) { + opts := bridgeinitiate.InitiationOptions{Root: fallbackRoot} + for i := 0; i < len(args); i++ { + key, value, inline := strings.Cut(args[i], "=") + if !strings.HasPrefix(key, "--") { + return opts, "unexpected positional argument: " + args[i] + } + if !inline { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return opts, "missing value for " + key + } + i++ + value = args[i] + } + switch key { + case "--repo-root": + opts.Root = value + case "--id": + opts.ID = value + case "--kind": + opts.Kind = value + case "--workflow": + opts.Workflow = value + case "--entity": + opts.Entity = value + case "--ship-id": + opts.ShipID = value + case "--host": + opts.Host = value + case "--session-id": + opts.SessionID = value + case "--headline": + opts.Headline = value + case "--body": + opts.Body = value + case "--request-id": + opts.RequestID = value + default: + return opts, "unknown flag: " + key + } + } + return opts, "" +} + func parseBridgeHost(args []string) string { for i := 0; i < len(args); i++ { if args[i] == "--host" && i+1 < len(args) { diff --git a/skills/present-gate/SKILL.md b/skills/present-gate/SKILL.md index c9712c1da..75c6339b5 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -43,3 +43,33 @@ The template is the floor, not the ceiling. The FO MUST hold to the following di 8. **One sentence of worktree heads-up when approval changes worktree state.** When approving opens or closes a worktree, the Decision line names it: "approve to enter implementation in worktree `.worktrees/{worker_key}-{slug}`". One sentence, not a section. 9. **Target length: 15-25 lines of FO-authored prose.** The full gate message should fit in 15-25 lines. If it exceeds 25, the FO is over-narrating; cut. 10. **FO-authored prose speaks the workflow's declared label.** Where the gate-summary prose the FO writes — the `Chosen direction:` line, the `Checklist:` gist roll-up, the `Decision:` line — names the kind of thing under review, use the workflow's declared `entity-label` / `entity-label-plural` (read at Startup step 4), not the generic "entity". A `ticket` workflow's Decision line says "approve to enter implementation on this ticket"; an `experiment` workflow says "experiment". The `{entity title}` placeholder and the structural headings (`Gate review:`, `Checklist:`, `Decision:`) stay generic — only the FO-authored noun localizes. + +## Emit the gate to Bridge (host-neutral) + +After rendering the gate-review to the captain in-session, the FO ALSO pushes the same gate to Bridge so a remote captain can decide it from the command-center UI. This is host-neutral — it lives here in `present-gate` (loaded by every host), never in a Claude-only hook. + +Emit it with: + +``` +spacedock bridge initiate \ + --kind gate-review \ + --workflow \ + --entity \ + --ship-id / \ + --request-id \ + --headline \ + [--body ] +``` + +- `--id` defaults to `--request-id` for a gate-review; pass `--request-id` and let the id follow. Both MUST be a STABLE function of `(entity, stage)` so re-emitting the same gate on each drain tick folds to ONE card in Bridge instead of stacking duplicates. Derive it deterministically (e.g. `gate--`), not from a timestamp or random value. +- `--headline` is the gate lede (bounded to 240 chars). Keep `--body` to a short supporting line; it is bounded to 2000 chars. +- Pass `--repo-root` only if the FO's cwd is not the repo root; the writer anchors at `filepath.Abs(--repo-root or cwd)/_bridge`, the same path Bridge resolves from. + +### Channel boundary — a gate goes to fo-initiate ONLY + +A gate-review is a decidable interrupt. It goes to `_bridge/fo-initiate.jsonl` and NOWHERE ELSE: + +- NEVER `fo-feed.jsonl` — that stream is ambient git narration (dispatch/advance/complete), not a decidable ask; a gate rendered there has no Approve/Reject affordance. +- NEVER `fo-replies.jsonl` — that stream requires an `in_reply_to` correlator to a captain intent; an FO-initiated gate has no such parent and would be silently dropped. + +Approve/Reject on the fo-initiate card close the loop back through the inbox by `request_id`; that is why the `request_id` must be stable and shared with the gate the captain sees. From a9d35b4d2958f15779fb1a636bb1bc6115f587bc Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 3 Jul 2026 13:12:50 +0800 Subject: [PATCH 19/21] fix(bridge): correct present-gate emit doc and prune resolved gates 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) Signed-off-by: Jared Scott --- internal/bridgeinitiate/initiate.go | 56 ++++++++++++++++++++-- internal/bridgeinitiate/initiate_test.go | 59 ++++++++++++++++++++++++ skills/present-gate/SKILL.md | 7 ++- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/internal/bridgeinitiate/initiate.go b/internal/bridgeinitiate/initiate.go index 22be1a5f9..9cb381958 100644 --- a/internal/bridgeinitiate/initiate.go +++ b/internal/bridgeinitiate/initiate.go @@ -164,7 +164,12 @@ func (r Result) withError(msg string) Result { // truncateInitiate mirrors bridgeegress.truncateEvents (temp-file + rename tail // retention) but NEVER drops the latest record of a still-open gate-review id, -// so an open gate can't be truncated out of the read window. +// so an open gate can't be truncated out of the read window. Resolution is +// determined the way the reader determines it — a captain decision intent in the +// sibling inbox.jsonl keyed by request_id — because the writer ALWAYS stamps +// on-disk status "open", so a resolved gate's line still reads "open" here. +// Without the inbox overlay every gate would be protected forever and the file +// would grow unbounded in historical (already-decided) gate-reviews. func truncateInitiate(path string) { f, err := os.Open(path) if err != nil { @@ -187,8 +192,11 @@ func truncateInitiate(path string) { } tailStart := len(lines) - keepLines + resolved := loadResolvedGateRequestIDs(filepath.Dir(path)) + // Latest record per id wins; retain the latest line of every still-open - // gate-review id even when it falls before the tail window. + // gate-review id even when it falls before the tail window. A gate whose + // request_id has a captain decision in the inbox is resolved and evictable. latestLine := make(map[string]int) for i, line := range lines { rec, ok := parseRecord(line) @@ -203,7 +211,7 @@ func truncateInitiate(path string) { if !ok { continue } - if rec.Kind == "gate-review" && rec.Status == "open" && i < tailStart { + if rec.Kind == "gate-review" && rec.Status == "open" && !resolved[gateResolutionKey(rec)] && i < tailStart { protected[i] = true } } @@ -248,6 +256,48 @@ func parseRecord(line string) (InitiationRecord, bool) { return rec, true } +// gateResolutionKey is the request_id a decision intent correlates against, +// falling back to the id (the writer defaults request_id to id for gate-review). +func gateResolutionKey(rec InitiationRecord) string { + if rec.RequestID != "" { + return rec.RequestID + } + return rec.ID +} + +// loadResolvedGateRequestIDs reads the sibling inbox.jsonl and returns the set +// of request_ids carried by captain "decision" intents — the same overlay the +// Bridge reader uses to flip a gate-review to resolved. Best-effort: a missing +// or unreadable inbox yields an empty set, so every open gate stays protected +// (the safe fallback — never evict a gate that might still be open). +func loadResolvedGateRequestIDs(dir string) map[string]bool { + resolved := make(map[string]bool) + f, err := os.Open(filepath.Join(dir, "inbox.jsonl")) + if err != nil { + return resolved + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var rec struct { + Kind string `json:"kind"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil { + continue + } + if rec.Kind == "decision" && rec.RequestID != "" { + resolved[rec.RequestID] = true + } + } + return resolved +} + // oneLineSummary collapses control chars/whitespace to single spaces and bounds // the result to maxLen runes. Mirrors bridgealert.oneLineSummary. func oneLineSummary(in string, maxLen int) string { diff --git a/internal/bridgeinitiate/initiate_test.go b/internal/bridgeinitiate/initiate_test.go index 6c26a375d..f644e09ea 100644 --- a/internal/bridgeinitiate/initiate_test.go +++ b/internal/bridgeinitiate/initiate_test.go @@ -236,6 +236,65 @@ func TestTruncateInitiate_KeepsOpenGate(t *testing.T) { } } +func TestTruncateInitiate_EvictsGateResolvedViaInbox(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fo-initiate.jsonl") + + var lines []string + // Both gates are written status "open" — the production writer NEVER stamps + // "resolved" on disk. Resolution is signalled only by an inbox decision intent. + undecidedGate := InitiationRecord{ + Schema: 1, ID: "gate-open", TS: "2026-07-03T00:00:00Z", + Kind: "gate-review", Headline: "undecided gate", RequestID: "gate-open", Status: "open", + } + decidedGate := InitiationRecord{ + Schema: 1, ID: "gate-decided", TS: "2026-07-03T00:00:01Z", + Kind: "gate-review", Headline: "captain decided this", RequestID: "gate-decided", Status: "open", + } + lines = append(lines, mustJSON(t, undecidedGate), mustJSON(t, decidedGate)) + for i := 0; i < defaultMaxLines+200; i++ { + lines = append(lines, mustJSON(t, InitiationRecord{ + Schema: 1, ID: fmt.Sprintf("status-%d", i), TS: "2026-07-03T01:00:00Z", + Kind: "status", Headline: fmt.Sprintf("noise %d", i), Status: "open", + })) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + // A captain decision intent for gate-decided lands in the sibling inbox. + inbox := `{"schema":1,"kind":"decision","request_id":"gate-decided","verdict":"approve"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "inbox.jsonl"), []byte(inbox), 0o644); err != nil { + t.Fatal(err) + } + + truncateInitiate(path) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var foundUndecided, foundDecided bool + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + rec, ok := parseRecord(line) + if !ok { + t.Fatalf("unparseable kept line: %s", line) + } + switch rec.ID { + case "gate-open": + foundUndecided = true + case "gate-decided": + foundDecided = true + } + } + if !foundUndecided { + t.Fatal("undecided open gate-review was evicted by truncation") + } + if foundDecided { + t.Fatal("gate-review resolved by an inbox decision should be evicted past the cap") + } +} + func mustJSON(t *testing.T, rec InitiationRecord) string { t.Helper() data, err := json.Marshal(rec) diff --git a/skills/present-gate/SKILL.md b/skills/present-gate/SKILL.md index 75c6339b5..f8c6340dc 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -56,12 +56,15 @@ spacedock bridge initiate \ --workflow \ --entity \ --ship-id / \ - --request-id \ + --id \ + --host \ + --session-id \ --headline \ [--body ] ``` -- `--id` defaults to `--request-id` for a gate-review; pass `--request-id` and let the id follow. Both MUST be a STABLE function of `(entity, stage)` so re-emitting the same gate on each drain tick folds to ONE card in Bridge instead of stacking duplicates. Derive it deterministically (e.g. `gate--`), not from a timestamp or random value. +- `--id` is REQUIRED and is the fold key. For a gate-review the writer defaults `request_id` to `--id`, so pass `--id` alone and let the request_id follow (pass `--request-id` explicitly only when it must differ from the id). The id MUST be a STABLE function of `(entity, stage)` so re-emitting the same gate on each drain tick folds to ONE card in Bridge instead of stacking duplicates. Derive it deterministically (e.g. `gate--`), not from a timestamp or random value. Omitting `--id` is a loud error and writes nothing. +- `--host` and `--session-id` carry host attribution so the Bridge card shows which host raised the gate. Both are optional, but omit them and the card carries no attribution — always pass the current host and session id. - `--headline` is the gate lede (bounded to 240 chars). Keep `--body` to a short supporting line; it is bounded to 2000 chars. - Pass `--repo-root` only if the FO's cwd is not the repo root; the writer anchors at `filepath.Abs(--repo-root or cwd)/_bridge`, the same path Bridge resolves from. From 49073da225d4da605f878c8e19ad45fb6bb89a54 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 8 Jul 2026 13:24:25 +0800 Subject: [PATCH 20/21] feat(bridge): bound fo-replies.jsonl with a cursor-floor retention window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit truncateReplies caps _bridge/fo-replies.jsonl (size cap + recency keep-window + temp-file/atomic rename), mirroring bridgeegress.truncateEvents, with one hard safety rule: a reply is dropped only when it is BOTH outside the recency window AND strictly below the retention floor — the lowest committed cursor across every .inbox-cursor.. loadReplies still needs any reply at/above that floor for at-least-once drain dedup, so dropping one would let an already-answered intent re-drain. With no committed cursor the floor is unknown and nothing is dropped. Observe-only: any filesystem error is a no-op. Called after each Ack append. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/bridgeingress/drain.go | 107 ++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/internal/bridgeingress/drain.go b/internal/bridgeingress/drain.go index cf90608a4..c5950816f 100644 --- a/internal/bridgeingress/drain.go +++ b/internal/bridgeingress/drain.go @@ -298,6 +298,7 @@ func Ack(opts AckOptions) AckResult { if err := appendReply(root, rec); err != nil { return AckResult{Error: err.Error()} } + truncateReplies(filepath.Join(root, "_bridge")) return AckResult{Appended: true, Kind: kind, Target: slug, Line: opts.Line} } @@ -324,6 +325,112 @@ func appendReply(root string, rec replyOut) error { return nil } +const ( + maxReplyLines = 2000 + keepReplyLines = 1000 +) + +// truncateReplies bounds fo-replies.jsonl, mirroring bridgeegress.truncateEvents +// (size cap, recency keep-window, temp-file + atomic rename). It differs by one +// hard safety rule: a reply is dropped only when it is BOTH outside the recency +// window AND strictly below the retention floor. The floor is the lowest committed +// cursor across every .inbox-cursor. file — loadReplies still needs any reply +// whose in_reply_to_line is at or above that floor for at-least-once drain dedup, so +// dropping one would let an already-answered intent re-drain. Replies strictly below +// the floor are committed on every slug and safe to drop. With no committed cursor +// (floor unknown) nothing is dropped. Observe-only: any filesystem error is a no-op. +func truncateReplies(dir string) { + floor, ok := lowestCommittedCursor(dir) + if !ok { + return + } + path := filepath.Join(dir, "fo-replies.jsonl") + f, err := os.Open(path) + if err != nil { + return + } + var lines []string + scanner := lineScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + if len(lines) <= maxReplyLines || scanner.Err() != nil { + return + } + keep := keepReplyLines + if keep > len(lines) { + keep = len(lines) + } + windowStart := len(lines) - keep + kept := make([]string, 0, len(lines)) + for i, line := range lines { + if i >= windowStart || replyAtOrAboveFloor(line, floor) { + kept = append(kept, line) + } + } + if len(kept) == len(lines) { + return + } + body := strings.Join(kept, "\n") + "\n" + tmp, err := os.CreateTemp(dir, "fo-replies.jsonl.tmp.*") + if err != nil { + return + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(body); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + } +} + +// lowestCommittedCursor returns the minimum committed inbox cursor across all +// per-slug .inbox-cursor. files in dir. The legacy shared .inbox-cursor +// (no slug suffix) is intentionally excluded — adoptCursor migrates it into a +// per-slug file before any drain, and the floor is defined over per-slug commits. +// The bool is false when no readable per-slug cursor exists, so the caller keeps +// every reply rather than guessing a floor. +func lowestCommittedCursor(dir string) (int, bool) { + matches, _ := filepath.Glob(filepath.Join(dir, ".inbox-cursor.*")) + min := -1 + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || n < 0 { + continue + } + if min < 0 || n < min { + min = n + } + } + if min < 0 { + return 0, false + } + return min, true +} + +// replyAtOrAboveFloor reports whether a fo-replies.jsonl line must be retained for +// drain dedup: its in_reply_to_line is at or above the retention floor. An +// unparseable line contributes nothing to loadReplies, so it is not retained here. +func replyAtOrAboveFloor(line string, floor int) bool { + var rec replyRecord + if json.Unmarshal([]byte(line), &rec) != nil { + return false + } + return rec.InReplyToLine >= floor +} + // CommitOptions controls one cursor advance. type CommitOptions struct { Root string From ba6444bcd273e11c7e8084411e8fb78abc03bb14 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 8 Jul 2026 13:34:37 +0800 Subject: [PATCH 21/21] test(bridge): cover truncateReplies floor+window retention rule Unit tests for the fo-replies.jsonl bound: no-op without a committed cursor (floor unknown), no-op at/under the size cap, the core rule that a reply is dropped only when BOTH below the retention floor AND outside the recency window (at/above-floor lines outside the window are kept for drain dedup; below-floor lines inside the window are kept by recency; order preserved), window-only retention when everything is below floor, and floor = min committed cursor across per-slug .inbox-cursor. files. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/bridgeingress/truncate_test.go | 213 ++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 internal/bridgeingress/truncate_test.go diff --git a/internal/bridgeingress/truncate_test.go b/internal/bridgeingress/truncate_test.go new file mode 100644 index 000000000..43663b676 --- /dev/null +++ b/internal/bridgeingress/truncate_test.go @@ -0,0 +1,213 @@ +package bridgeingress + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// writeReplyFixture writes one fo-replies.jsonl line per element of inReplyToLines, +// each a minimal record carrying that in_reply_to_line (the only field truncateReplies +// reads). Returns the _bridge dir. +func writeReplyFixture(t *testing.T, root string, inReplyToLines []int) string { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir _bridge: %v", err) + } + var b strings.Builder + for _, n := range inReplyToLines { + fmt.Fprintf(&b, `{"in_reply_to_line":%d}`+"\n", n) + } + if err := os.WriteFile(filepath.Join(dir, "fo-replies.jsonl"), []byte(b.String()), 0o644); err != nil { + t.Fatalf("write fo-replies.jsonl: %v", err) + } + return dir +} + +// writeCursorFile writes a per-slug committed cursor (the same "\n" shape Commit +// writes), so truncateReplies derives a retention floor from it. +func writeCursorFile(t *testing.T, dir, slug string, cursor int) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".inbox-cursor."+slug), []byte(strconv.Itoa(cursor)+"\n"), 0o644); err != nil { + t.Fatalf("write cursor %s: %v", slug, err) + } +} + +// readReplyInReplyToLines reads fo-replies.jsonl back as the ordered slice of +// in_reply_to_line values it carries. +func readReplyInReplyToLines(t *testing.T, dir string) []int { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "fo-replies.jsonl")) + if err != nil { + t.Fatalf("read fo-replies.jsonl: %v", err) + } + trimmed := strings.TrimRight(string(data), "\n") + if trimmed == "" { + return nil + } + var out []int + for _, line := range strings.Split(trimmed, "\n") { + var v struct { + N int `json:"in_reply_to_line"` + } + if err := json.Unmarshal([]byte(line), &v); err != nil { + t.Fatalf("kept line does not parse: %q: %v", line, err) + } + out = append(out, v.N) + } + return out +} + +func countValue(vals []int, target int) int { + n := 0 + for _, v := range vals { + if v == target { + n++ + } + } + return n +} + +// TestTruncateRepliesNoOpWithoutCursor: with no committed per-slug cursor the floor +// is unknown, so nothing is dropped even well past the size cap (dropping blind would +// risk re-draining an already-answered intent). +func TestTruncateRepliesNoOpWithoutCursor(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != n { + t.Errorf("no cursor present: want file untouched (%d lines), got %d", n, got) + } +} + +// TestTruncateRepliesNoOpUnderCap: at or below the size cap the file is left intact +// even when every line sits below the floor and outside the recency window. +func TestTruncateRepliesNoOpUnderCap(t *testing.T) { + root := t.TempDir() + n := maxReplyLines // exactly the cap → the > cap guard is false + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 999999) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != n { + t.Errorf("under cap: want file untouched (%d lines), got %d", n, got) + } +} + +// TestTruncateRepliesDropsBelowFloorOutsideWindow is the core safety rule: over the +// cap, a reply is dropped only when it is BOTH below the retention floor AND outside +// the recency window. A below-floor line inside the window is kept; an at-or-above-floor +// line outside the window is kept (dedup preservation); a below-floor line outside the +// window is the only thing dropped. Original order is preserved. +func TestTruncateRepliesDropsBelowFloorOutsideWindow(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 // 2100 + windowStart := n - keepReplyLines + const floor = 500 + const keepMarker = 9999 // >= floor, placed outside the window: must survive + const dropMarker = 10 // < floor, outside the window: must be dropped + const windowMarker = 1 // < floor, inside the window: kept by recency + + lines := make([]int, n) + for i := range lines { + switch { + case i >= windowStart: + lines[i] = windowMarker + case i == 0: + lines[i] = keepMarker + default: + lines[i] = dropMarker + } + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", floor) + + truncateReplies(dir) + + kept := readReplyInReplyToLines(t, dir) + if got, want := len(kept), 1+keepReplyLines; got != want { + t.Fatalf("kept %d lines, want %d (the one at/above-floor outside line + the %d-line window)", got, want, keepReplyLines) + } + if kept[0] != keepMarker { + t.Errorf("first kept line = %d, want the at/above-floor line %d preserved in original order", kept[0], keepMarker) + } + if c := countValue(kept, dropMarker); c != 0 { + t.Errorf("below-floor outside-window lines survived: %d remain, want 0", c) + } + if c := countValue(kept, windowMarker); c != keepReplyLines { + t.Errorf("recency window not fully retained: %d window lines, want %d", c, keepReplyLines) + } +} + +// TestTruncateRepliesKeepsOnlyWindowWhenAllBelowFloor: over the cap with every line +// below the floor, exactly the recency window survives. +func TestTruncateRepliesKeepsOnlyWindowWhenAllBelowFloor(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 999999) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != keepReplyLines { + t.Errorf("all below floor: want exactly the %d-line window, got %d", keepReplyLines, got) + } +} + +// TestTruncateRepliesFloorIsLowestAcrossSlugs: the floor is the MINIMUM committed +// cursor across every per-slug file, so a reply at/above the lowest slug's cursor is +// retained even though it is below another slug's cursor. +func TestTruncateRepliesFloorIsLowestAcrossSlugs(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + windowStart := n - keepReplyLines + const lowFloor = 50 + const atLowFloor = 100 // >= 50 (lowest) but well below 500: retained via the min floor + const belowAll = 10 // < 50: dropped + + lines := make([]int, n) + for i := range lines { + switch { + case i >= windowStart: + lines[i] = 1 + case i == 0: + lines[i] = atLowFloor + default: + lines[i] = belowAll + } + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 500) + writeCursorFile(t, dir, "beta", lowFloor) + + truncateReplies(dir) + + kept := readReplyInReplyToLines(t, dir) + if countValue(kept, atLowFloor) != 1 { + t.Errorf("line at the lowest slug's floor was dropped — floor is not the min across slugs") + } + if countValue(kept, belowAll) != 0 { + t.Errorf("line below every floor survived, want 0") + } +}