diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 87140b7c9..10696c388 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -16,6 +16,7 @@ "automation" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "requires-contract": ">=3,<4", "interface": { "displayName": "Spacedock", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 87140b7c9..8f74f38f5 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": ">=3,<4", "interface": { "displayName": "Spacedock", diff --git a/.gitignore b/.gitignore index 31a3fbcd0..4fbfc3908 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ dist/ .safehouse .claude/ /spacedock + +# Bridge seam scratch (captain-intent inbox, cursors, heartbeats, events). +_bridge/ 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/_mods/bridge-seam.md b/docs/dev/_mods/bridge-seam.md new file mode 100644 index 000000000..217342c9e --- /dev/null +++ b/docs/dev/_mods/bridge-seam.md @@ -0,0 +1,142 @@ +--- +name: bridge-seam +description: Produce the Bridge `_bridge/` seam by direct file writes — a liveness heartbeat carrying the harness session id, captain-intent drain by cursor, terminal acks, and FO-authored cards/alerts — per Bridge's `docs/seam-contract.md` +version: 0.1.0 +--- + +# Bridge Seam + +[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 / Codex / Pi session has no inbound API), so the seam between Bridge and the FO is a set of **plain files under `_bridge/`** (relative to the FO's working directory — the fleet root where the FO was launched). Bridge writes exactly one of them, the captain-intent queue `_bridge/inbox.jsonl`; every other file the FO **writes** and Bridge **reads**. This mod is the producer side of that contract: on boot and on every loop tick the FO writes a liveness heartbeat, drains the intent queue by a monotonic per-slug cursor, and appends terminal acks — all as direct file writes, no Spacedock CLI verb. + +**Direct file writes, not a verb.** The mechanism is a file recipe a bare agent can follow: read a cursor integer, read newline-delimited JSONL, append single JSONL lines, replace a small JSON file. There are no packaged drain/ack/commit verbs — those are retired; the FO performs the file operations itself. The one binary touchpoint is the hook-driven egress producer (`spacedock bridge egress emit`, wired by the plugin hooks) and, on Claude, the synchronous Stop check (`spacedock bridge inbox check`) that keeps a parked FO alive long enough to drain. Neither is invoked by hand from this mod. + +**The full contract is authoritative in Bridge's `docs/seam-contract.md`** (§2 per-file shapes, §3 the drain recipe); a Spacedock-local overview lives at `docs/dev/bridge-seam.md`. Every JSON shape below is quoted from that contract. Where a reader tolerates more than shown, the reader's tolerance is the contract. + +**This is a pull, not a push.** Delivery latency is one FO loop cadence: queued captain intent is read whenever the FO next boots or idles, never instantly. A **parked** FO (stopped, waiting at the prompt) is nudged per host — on Codex, Bridge resumes the session with `spacedock bridge ingress wake`; on Claude, the packaged `Stop` hook runs `spacedock bridge inbox check` at every turn boundary and returns a `block` decision while intent is queued, so the FO drains in-session. Never resume a live Claude session out-of-band; its transcript has no write locking. + +**Per-host session-id source (load-bearing).** The heartbeat MUST carry `session_id` set to the harness's own session id, because the Claude Stop check resolves *which* workflow slugs belong to the stopping session by matching the Stop payload's `session_id` against the `session_id` in each `_bridge/fo..json` heartbeat. A heartbeat with a missing or wrong session id silently kills Claude intent delivery: the check resolves no slug, never blocks, and the queued intent sits forever. Read the id from the per-host variable: + +| host | session-id source | +|---|---| +| claude | `$CLAUDE_CODE_SESSION_ID` | +| codex | `$CODEX_THREAD_ID` | +| pi | the pi runtime's session id | + +**Mod placement (known limitation).** Lifecycle hooks run from `{workflow_dir}/_mods/`, so a workflow gets this seam only when `bridge-seam.md` is present in its `_mods/` dir. The dogfood copy at `docs/dev/_mods/bridge-seam.md` covers this repo's own workflows; a non-dogfood workflow must copy the mod into its `_mods/`. Automatic scaffolding at commission/refit time is a named follow-up, not solved here. + +## Hook: startup + +For each workflow slug `$SLUG` this FO owns (one per commissioned workflow — see **Your workflow slug(s)**), **before the greet**: + +1. Write the liveness heartbeat `_bridge/fo.$SLUG.json` carrying the harness session id (see **Heartbeat**), with `state:"working"`. This makes Bridge show the workflow attached the moment the FO boots — even a greet-and-stop launch. +2. Run the **Drain procedure** so a freshly-booted FO picks up any intent the captain queued while no FO was attached, before its first dispatch. + +## Hook: idle + +At the top of each loop tick: + +1. Run the **Drain procedure** for each `$SLUG` — it acts on any new intent and refreshes the heartbeat (`ts = now`). +2. When parking (turn end / awaiting the captain), fulfil the **On park** obligations. + +## Agent Prompt + +You are the producer side of the Bridge `_bridge/` seam. Your job is judgment plus a handful of exact file writes. Do not reimplement this with a retired CLI verb; do the file operations directly, using the shapes below verbatim. + +### Your workflow slug(s) + +`$SLUG` is this workflow's slug — the basename of its directory (`basename {dir}`). It names the per-slug cursor and heartbeat files. It must be a **safe slug**: a single path element, no `/`, no `.`/`..`. Several FOs (one per commissioned workflow) can share one fleet root and one `_bridge/` dir; every step below is scoped to a single `$SLUG`. In a **fleet** (this FO owns several members), perform every per-slug step — heartbeat, cursor, drain, acks — **once per member slug**. Bridge shows a member live only when *its own* `fo..json` is fresh; a single shared heartbeat shows only one member attached. + +### Heartbeat + +Bridge reads per-workflow FO liveness from `_bridge/fo.$SLUG.json` (whole-file replace). It treats the workflow as live only when `ts` is **fresh** (within 30 minutes) and **not future-dated**. Write it on boot and refresh it every tick and every drain. Exact shape (contract §2.4): + +```json +{"session_id":"sess_9f","ts":"2026-07-14T18:06:30Z","state":"idle","host":"claude"} +``` + +- `session_id` — the harness session id from the per-host source table above (`$CLAUDE_CODE_SESSION_ID` / `$CODEX_THREAD_ID` / pi's id). **This field is load-bearing for Claude wake delivery** — never omit it or write a placeholder. +- `ts` — present-time RFC3339 UTC. A zero/absent or future ts ⇒ Bridge reports not-attached (never fabricates `working`). +- `state` — `working` while acting; `idle` when parked awaiting the captain. +- `host` — `claude` \| `codex` \| `pi` (the harness stamping it). + +### Drain procedure + +Per member slug `$SLUG`, on boot and every loop tick: + +1. **Read the cursor.** `C = int(contents of "_bridge/.inbox-cursor.$SLUG")`, or `0` if the file is absent, empty, non-integer, or negative. The cursor is the count of physical inbox lines this slug's FO has already drained (its high-water line number). +2. **Read the inbox by physical line number.** Read `_bridge/inbox.jsonl` line by line, counting **1-based physical newline-terminated lines** — every newline-terminated line consumes a number **even a malformed one** (skip it from acting but still count it); a trailing fragment with **no** newline at EOF is a torn write, skipped and **not** counted. This is the `wc -l` rule Bridge uses for read-back, so your cursor agrees with Bridge's line numbers. Each inbox line looks like (contract §2.1): + ```json + {"id":"bi_ab12","ts":"2026-07-14T18:03:00Z","kind":"tell","text":"ping","target":"all"} + ``` + Fields: `id` (stable, the primary reply correlator), `ts`, `kind` (`tell`\|`conn`\|`decision`\|`permission-decision`), optional `text`, `granted` (`conn`), `target`, `target_set`, `entity`/`field`/`value`/`verdict`/`directives` (`decision`), `request_id`. +3. **Route.** For each line with physical number `N > C`, decide whether it is addressed to `$SLUG`: + - If `target_set` is **present**, act **only** when `$SLUG` is in `target_set`. The frozen `target_set` is authoritative — ignore `target` entirely, including `target:"all"`. + - If `target_set` is **absent**, act when `target == "$SLUG"`, `target == "all"`, or `target` is missing/empty. + - A line not addressed to `$SLUG` is skipped, but still counts as processed so the cursor advances past it. +4. **Dedup, then act.** Before acting on an addressed intent with `id` X, scan `_bridge/fo-replies.jsonl` for a **terminal** ack whose `in_reply_to_id` is X (terminal = any status except the interim `acting`). If one exists, you already handled X — skip it (still counted). Otherwise act on it by kind: + - `tell` — treat `text` as a captain directive for this tick; act on it, then append a `reply` ack with `status:"answered"`. + - `conn` — a conn-handover change. `granted:true` → adopt the conn within the stated goal `text` (drive the covered entities to done without stopping at their gates; escalations stay non-delegable), then append a `conn-ack` with `status:"accepted"`. `granted:false` → take the conn back (stop at every gate again), then `conn-ack` `status:"released"`. + - `decision` — the captain resolved a gate from Bridge (a captain decision, not FO self-approval; Bridge has not advanced the entity). Resolve `entity` in this workflow and apply the normal gate flow: self-described shape (`field`+`value`) → set the field and continue the gate as if decided in chat; plain shape (`verdict`+optional `directives`) → `approve` advances, `reject`/`redo` route to the gate's `feedback-to` stage with the directives; perform any external actions (GitHub, Linear) before terminal state. Then append a `decision-ack` with `status:"applied"` (finished/already-satisfied), `status:"blocked"` (valid but could not finish), or `status:"rejected"` (invalid/stale/unresolvable — including when `entity` does not resolve here). You may append an interim `acting` ack first. + - `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"` → do not retry; append `permission-ack` `status:"denied"`. `value:"approve-once"` → retry the exact blocked action once via the runtime's escalation path. `value:"approve-rule"` → retry with the alert's `prefix_rule` if present, else treat as `approve-once`. Append `permission-ack` `status:"accepted"` before retrying, or `status:"blocked"` if the retry could not start. A Bridge approval is FO intent, not a bypass of a host-native security prompt; honor any native dialog. +5. **Advance the cursor to the highest line you actually read.** After acting on every addressed line this tick, set `L` = the **highest physical line number you counted in step 2's read** (not a fresh independent `wc -l`). Write `L` to `_bridge/.inbox-cursor.$SLUG` as a single decimal integer (whole-file replace). Deriving `L` from the same pass that did the routing — rather than recounting — makes it **structurally impossible to advance past a line you never examined**: a second count could race a concurrent append and jump the cursor over an unread intent, which is permanent silent loss (see the safety note). Using the step-2 high-water number instead means any line that arrived after your read simply stays pending for the next tick. This still advances past other slugs' lines the routing filtered out, because step 2 counted them too. +6. **Refresh the heartbeat** (`ts = now`, `state:"working"` while still acting). +7. Report to the captain how many intents you drained for this workflow and what you did with each. + +Missing/empty `_bridge/inbox.jsonl` ⇒ no Bridge attached; skip (write nothing but the heartbeat). A malformed line ⇒ skip acting on it but still count it toward the cursor; note the skip to the captain. + +### Cursor safety + +The cursor is **monotonic — never lower it.** Only ever raise `.inbox-cursor.$SLUG` to the current physical line count. If you are ever unsure of the count, re-read the inbox and recount from scratch before writing; do not guess a higher number. **An over-advanced cursor silently skips captain intent and nothing re-blocks it**: the Claude Stop check only ever sees intent *below* the cursor as still-pending, so any line you jumped over is invisible to the check and will never trigger another block. Under-counting is self-healing (the next tick re-drains); over-counting is permanent silent loss. When in doubt, count low. + +### Replies / acks (`_bridge/fo-replies.jsonl`) + +Append one JSONL line per addressed intent you handled or rejected (append-only). Bridge **drops** any line whose `schema` is not exactly `1`, or that lacks a correlator. Exact shape (contract §2.3): + +```json +{"schema":1,"ts":"2026-07-14T18:05:00Z","kind":"reply","target":"my-wf","in_reply_to_id":"bi_ab12","intent_kind":"tell","status":"answered","text":"done"} +``` + +Required on every ack: `schema:1`; non-zero RFC3339 `ts`; `kind`; `target` = your `$SLUG` (safe slug); `in_reply_to_id` = the intent's `id` (the strong correlator — a record must carry either `in_reply_to_id`, or both `in_reply_to_line>0` and a non-zero `in_reply_to_ts`); `intent_kind` = the correlated intent's `kind`; a `status` valid for the `kind`. + +`kind` is derived from the intent kind, and `status` must be valid for that `kind`: + +| intent `kind` | reply `kind` | terminal statuses (done / failed) | interim | +|---|---|---|---| +| `tell` | `reply` | `answered` / `rejected`,`blocked` | `acting` | +| `conn` | `conn-ack` | `accepted`,`released` / `rejected`,`blocked` | `acting` | +| `decision` | `decision-ack` | `applied` / `rejected`,`blocked` | `acting` | +| `permission-decision` | `permission-ack` | `accepted` / `denied`,`rejected`,`blocked` | `acting` | + +`acting` is the interim ack (received → acting → terminal) and is legal for all four kinds; a terminal ack never regresses to `acting`. Optional echo fields: `text`, `granted` (`conn-ack`), `entity`/`field`/`value` (`decision-ack`), `request_id`, `verdict`, `session_id`, `host`. A well-formed ack that correlates to no loaded intent is surfaced by Bridge (muted), never a hard error. + +### Cards: status / reco / gate-review (`_bridge/fo-initiate.jsonl`) + +FO judgment the captain sees. Append-only; Bridge **drops** a line unless `schema` is `1`, `id` is non-empty, and `kind` is known. Exact shape (contract §2.7): + +```json +{"schema":1,"id":"init_7a","ts":"2026-07-14T18:07:00Z","kind":"gate-review","workflow":"linear-drc-ship","entity":"drc-3467","headline":"Ready to ship?","body":"...","status":"open"} +``` + +`kind` is `status` (ambient), `reco` (recommendation), or `gate-review` (decidable). Always write `status:"open"` — Bridge overlays resolution itself from the matching inbox `decision` (correlated by `request_id`, which defaults to `id`). Re-emitting the same `id` folds to one card (latest `ts` wins). An open `gate-review` is never evicted by the card cap, however old. + +### Permission alerts (`_bridge/fo-alerts.jsonl`) + +A high-priority FO→captain interrupt when a command is blocked. Append-only; a line is dropped unless `id` is non-empty and `kind == "permission-request"`. Exact shape (contract §2.8): + +```json +{"schema":1,"id":"al_3c","ts":"2026-07-14T18:08:00Z","kind":"permission-request","workflow":"linear-drc-ship","reason":"rm outside repo","command":"rm -rf /tmp/x","prefix_rule":["rm -rf /tmp/"],"status":"open"} +``` + +`id` is the correlator for the captain's `permission-decision`. `status` empty defaults to `open`; Bridge overlays the decision. (The deferred permission-alert helper `fo-bridge.md` owns the exact emit prose.) + +### On park + +Do not silently park — a parked turn with no card is indistinguishable from finished work. + +1. If you reached a gate, append a `gate-review` to `_bridge/fo-initiate.jsonl` with `status:"open"` and a `request_id` **before** parking. +2. If a command was blocked, append a `permission-request` to `_bridge/fo-alerts.jsonl`. +3. Refresh the heartbeat with `state:"idle"`. + +### Lifecycle egress (harness-driven, not authored here) + +The live working/idle badge and per-ship running signal come from `_bridge/events.jsonl` (turn-lifecycle lines) and `_bridge/sessions/.json` (session→entity markers). Those are produced by the packaged plugin hooks (`spacedock bridge egress emit`) inside the harness turn lifecycle, not written by hand from this mod. Without them Bridge still renders durable state from git narration + cursors + heartbeats. `_bridge/fo-feed.jsonl` is **optional** enrichment only (contract §5): every signal it carries is already covered by git narration and the marker-derived feed, so this mod does not produce it. diff --git a/docs/dev/bridge-seam.md b/docs/dev/bridge-seam.md new file mode 100644 index 000000000..5254ad96f --- /dev/null +++ b/docs/dev/bridge-seam.md @@ -0,0 +1,90 @@ +# Bridge seam (Spacedock side) + +The Bridge command center observes and steers a Spacedock First Officer through +append-only files under a fleet's `_bridge/` directory. **Bridge owns the +schema**; the authoritative, file-by-file contract is Bridge's +`docs/seam-contract.md` (in the `bridge` repo). This document is the +Spacedock-local overview: what Spacedock produces, and how. + +## The one rule + +Bridge consumes **files, not verbs.** Every FO→Bridge signal (heartbeat, drain +cursor, terminal ack, gate-review, status, permission alert) is a direct file +write the FO performs itself, per the `bridge-seam` mod's `## Agent Prompt` and +the per-host `## Bridge seam` runtime sections. The consolidation retired the +packaged `inbox drain|ack|commit`, `alert`, and `initiate` verbs — an agent with +only Bridge's `docs/seam-contract.md` can implement the producer side. + +## What Spacedock still ships (the irreducible core) + +Three things genuinely need the harness and remain as `spacedock bridge …` +entrypoints — all hook- or daemon-invoked, never called by the FO: + +| Entrypoint | Invoked by | Purpose | +|---|---|---| +| `bridge egress emit --host ` | plugin lifecycle hooks | normalize a harness turn payload → one `_bridge/events.jsonl` line; derive the `_bridge/sessions/.json` marker on an ensign's entity Read | +| `bridge inbox check --host claude` | synchronous Claude `Stop` hook | return `{"decision":"block"}` while this session has queued intent, so a Claude FO drains before stopping (its only durable wake — a parked Claude transcript cannot be resumed out-of-band) | +| `bridge ingress wake --host codex` | Bridge / daemon | resume a parked Codex session via `codex exec resume` and prompt it to drain | + +Plus one contract-conformant FO mod (`mods/bridge-seam.md`) carrying the file +protocol, and the FO/ensign prose that binds each host's producer. + +## Per-host capability matrix + +| Capability | Claude | Codex | Pi | +|---|---|---|---| +| `events.jsonl` liveness/activity egress | PRESENT (`hooks/hooks.json`) | PACKAGED (`hooks/codex-hooks.json`) | PACKAGED (`.pi/extensions/spacedock.ts`) | +| deterministic `sessions/.json` running marker | PRESENT | not yet claimed | not yet claimed | +| durable wake | in-session (sync Stop hook) | external (`ingress wake`) | none yet (queued count until self-drain) | +| heartbeat session-id source | `$CLAUDE_CODE_SESSION_ID` | `$CODEX_THREAD_ID` | runtime id (often empty today) | + +**Load-bearing:** the `_bridge/fo..json` heartbeat MUST carry the harness +session id. The Claude Stop-hook check resolves which slugs belong to a stopping +session by matching the Stop payload's `session_id` against the heartbeats (and, +secondarily, against session→entity markers — but an FO session writes no marker, +markers being ensign-on-entity-Read only, so the heartbeat is the effective +resolver). A heartbeat that omits or mismatches that id makes the check resolve +nothing, so a Claude FO is never blocked and queued captain intent is delivered +never. The harness id is real and already read elsewhere in spacedock +(`internal/dispatch/build.go` reads `$CLAUDE_CODE_SESSION_ID`). + +## Hook registrations + +- `.claude-plugin/plugin.json` → `hooks/hooks.json`: six async egress events + (SessionStart, UserPromptSubmit, PostToolUse, Notification, Stop, SubagentStop) + via `scripts/spacedock-bridge-events.sh`, plus a **synchronous** Stop entry + (`scripts/spacedock-bridge-inbox-check.sh`) — the block-until-drained wake. +- `.codex-plugin/plugin.json` → `hooks/codex-hooks.json`: six non-async + command hooks calling `bridge egress emit --host codex`. No Stop-block (Codex + uses external wake). +- `.pi/extensions/spacedock.ts`: forwards Pi lifecycle payloads to + `bridge egress emit --host pi`. + +## Durable-wake caveat (Claude) + +The synchronous Stop hook (`bridge inbox check`) is the durable wake for an +**interactive** unmanaged Claude FO: on turn end it emits `{"decision":"block"}` +while intent is queued so the FO drains before stopping. Under `claude -p` +(headless), a Stop-hook block decision is not a guaranteed re-entry — but a +headless FO in practice is **daemon-managed**, and a managed FO is woken by the +Bridge daemon's in-process `--resume`, not by this hook (the hook is not on the +managed delivery path). So the wake paths are: interactive Claude → Stop hook; +managed Claude → daemon resume; Codex → external `ingress wake`; anything with +no live session → the durable queue, drained at the FO's next boot. + +Inbox scanning is **uncapped** (`bridge inbox check` reads `_bridge/inbox.jsonl` +line-by-line with no per-line limit, matching Bridge's own reader). The 1 MiB +cap in this seam applies to the `events.jsonl` scan/trim in the egress producer, +not to the inbox check. A per-line inbox cap is a filed Bridge-side follow-up, +not a contract limit. + +## Not shipped here (intentional) + +`fo-feed.jsonl` is not produced. Its dispatch/advance signal is covered by git +narration plus the marker-derived feed, so **no Bridge surface breaks** without +it (`LoadFOFeedCombined` degrades cleanly) — but note the `complete` verb and +free-text feed notes are unique to fo-feed and are simply absent, not +reconstructed (see Bridge `docs/seam-contract.md` §5). Getting the +`bridge-seam` mod into non-dogfood workflows still needs commission/refit +scaffolding (a named follow-up); today it ships canonically at `mods/` and as a +dogfood copy under `docs/dev/_mods/`. diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json new file mode 100644 index 000000000..d40e44153 --- /dev/null +++ b/hooks/codex-hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "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'" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "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'" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "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'" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "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'" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "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'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 000000000..48b93e977 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,23 @@ +{ + "hooks": { + "SessionStart": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "PostToolUse": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "Notification": [ + { "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-inbox-check.sh" } ] } + ], + "SubagentStop": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ] + } +} diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go new file mode 100644 index 000000000..cc102b455 --- /dev/null +++ b/internal/bridgeegress/egress.go @@ -0,0 +1,535 @@ +// 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 + // 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._-]+$`) + +// 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 := normalizeHost(firstNonEmpty(opts.Host, p.Host)) + eventName := canonicalEventName(host, 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(canonicalBridgeRoot(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, + }) +} + +// 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) { + 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) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) + 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 { + now := time.Now + if opts.Now != nil { + now = opts.Now + } + // Bridge parses events.jsonl `ts` into a time.Time (foactivity.go eventLine), + // so a passthrough value that is not RFC3339 fails the whole-line unmarshal and + // the event is silently dropped — no liveness signal. Claude payloads carry no + // timestamp (we default below), but a Codex/Pi hook payload might carry one in + // another format; only pass a payload timestamp through when it actually parses + // as RFC3339, else stamp our own. + for _, cand := range []string{p.Timestamp, p.TS} { + if cand == "" { + continue + } + if _, err := time.Parse(time.RFC3339, cand); err == nil { + return cand + } + } + 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 normalizeHost(host string) string { + return strings.ToLower(strings.TrimSpace(host)) +} + +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) { + 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..ee128325e --- /dev/null +++ b/internal/bridgeegress/egress_test.go @@ -0,0 +1,438 @@ +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 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{ + []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 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") + 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 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) + } +} + +// TestEmitClaudeSessionStartMatchesContractKeys pins the emitted events.jsonl +// line for a Claude SessionStart against the seam contract §2.5 shape: the line +// must carry a non-empty `ts`, an `event`, and the attributing `session_id` +// (the three fields Bridge's LoadFOActivity reads to attribute a working +// signal), and it must NOT drop the `ts` recency anchor. Bridge tolerates +// unknown fields (§4), but a producer that omits ts/session_id is unattributable +// and skipped, so this asserts they are present and correctly populated. +func TestEmitClaudeSessionStartMatchesContractKeys(t *testing.T) { + root := t.TempDir() + Emit([]byte(`{ + "cwd":`+quote(root)+`, + "hook_event_name":"SessionStart", + "session_id":"ses-contract", + "agent_type":"spacedock:first-officer", + "source":"startup" + }`), fixedOptions("claude")) + + data, err := os.ReadFile(filepath.Join(root, "_bridge", "events.jsonl")) + if err != nil { + t.Fatal(err) + } + line := strings.TrimSpace(string(data)) + + // Contract §2.5: `ts` (recency anchor), `event`, `session_id` (required to + // attribute the line) must all be present and non-empty in the raw JSON. + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + t.Fatalf("event JSON: %v\n%s", err, line) + } + for _, key := range []string{"ts", "event", "session_id"} { + v, ok := raw[key] + if !ok { + t.Fatalf("contract §2.5 key %q missing from emitted line:\n%s", key, line) + } + if s, isStr := v.(string); !isStr || s == "" { + t.Fatalf("contract §2.5 key %q empty/non-string (%v):\n%s", key, v, line) + } + } + + var event Event + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatal(err) + } + if event.TS != "2026-07-01T01:02:03Z" || event.TS != event.Timestamp { + t.Fatalf("ts anchor = %q (timestamp %q), want fixed clock", event.TS, event.Timestamp) + } + if event.Event != "SessionStart" || event.SessionID != "ses-contract" { + t.Fatalf("event identity = %+v, want SessionStart/ses-contract", event) + } + // A SessionStart is a working signal (§2.5 vocabulary); the actor must be + // attributed — for Claude the actor_id is the session id. + if event.ActorID != "ses-contract" { + t.Fatalf("actor_id = %q, want Claude session id", event.ActorID) + } +} + +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/bridgeingress/check.go b/internal/bridgeingress/check.go new file mode 100644 index 000000000..d9a7a2e48 --- /dev/null +++ b/internal/bridgeingress/check.go @@ -0,0 +1,306 @@ +// ABOUTME: Synchronous Stop-hook inbox check for FO sessions. +// ABOUTME: Blocks a stopping session for one more turn when captain intent is queued; never errors. +package bridgeingress + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Check is the read-only half of the seam that used to sit inside the +// drain/ack/commit family: it decides whether a stopping FO session still has +// queued captain intent and, if so, forces one more turn so the FO drains it. +// The FO does the draining itself by writing _bridge/ files directly per +// docs/seam-contract.md §3 — there is no drain/ack/commit verb. Check shares the +// inbox/reply/heartbeat readers with wake.go (inboxCursor, loadReplies, +// replyKey, targetsFor, discoverHeartbeatSlugs, loadHeartbeatAnyAge, +// safeSessionID, lineScanner, safeSlugPattern). + +// fullInboxRecord is the full-field parse of one inbox line. Check only needs +// its line number and routing projection, but readInboxFull returns this shape, +// so the type and its routing() projection travel with the reader. +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} +} + +// 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 +} + +// CheckOptions controls a Stop-hook drain check. +type CheckOptions struct { + Host string + Root string + Slug string + SessionID 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{} + } + // Resolve the SAME _bridge/ root the egress producer writes to: walk up to the + // nearest .git so a Stop-payload cwd that is a repo subdir still finds the + // fleet root's inbox (otherwise check reads an empty subdir _bridge/ → {} → + // Claude intent silently undelivered). Mirrors bridgeegress.canonicalBridgeRoot. + root := gitRootOr(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} + cursor := inboxCursor(root, 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 + } + 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)} +} + +// drainReason is the Stop-hook block reason. It instructs the FO to drain by +// writing _bridge/ files directly (docs/seam-contract.md §3 / the bridge-seam +// mod) and names no `spacedock bridge inbox` verb — draining, acking, and cursor +// advancement are FO-authored file writes, not CLI verbs (findings B.2). +func drainReason(total int, slugs []string) string { + 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 by writing _bridge/ files directly, per the bridge-seam mod. For each slug: read _bridge/inbox.jsonl lines past _bridge/.inbox-cursor., act on each addressed intent, append a terminal ack line to _bridge/fo-replies.jsonl, write the inbox's new physical line count to _bridge/.inbox-cursor. (monotonic whole-file replace), and refresh the heartbeat _bridge/fo..json." +} + +// 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. A heartbeat that omits its +// session_id therefore resolves nothing (review B2): the check never blocks and +// the intent sits queued until the heartbeat is written with the correct id. +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 +} + +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 + r := bufio.NewReader(f) + for { + raw, rerr := r.ReadString('\n') + // Only a newline-TERMINATED line counts toward the physical line number — + // Bridge's read-back (`fointents.go`) and the FO's `wc -l` both count + // newlines, so a torn trailing fragment at EOF is skipped, not counted. + // Counting it here would over-report pending by one and force a spurious + // Stop-block turn. + if strings.HasSuffix(raw, "\n") { + lineNo++ // 1-based; advances even for a blank/malformed line + if trimmed := strings.TrimSpace(raw); trimmed != "" { + var rec fullInboxRecord + if json.Unmarshal([]byte(trimmed), &rec) == nil { + // Preserve the exact on-disk ts string for verbatim round-trip. + var tsOnly struct { + TS string `json:"ts"` + } + _ = json.Unmarshal([]byte(trimmed), &tsOnly) + rec.RawTS = tsOnly.TS + rec.Line = lineNo + out = append(out, rec) + } + } + } + if rerr != nil { + if rerr == io.EOF { + break + } + return nil, 0, rerr + } + } + return out, lineNo, nil +} + +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 +} + +// gitRootOr walks up from start to the nearest directory containing a `.git` +// entry (a dir, or a file for a linked worktree), returning that directory — +// the fleet root the seam files live under. Returns start unchanged when no +// `.git` is found. Mirrors bridgeegress.canonicalBridgeRoot so the check and the +// egress producer resolve the identical `_bridge/` root. +func gitRootOr(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 + } +} diff --git a/internal/bridgeingress/check_test.go b/internal/bridgeingress/check_test.go new file mode 100644 index 000000000..ee7a8aa37 --- /dev/null +++ b/internal/bridgeingress/check_test.go @@ -0,0 +1,121 @@ +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) + } + // The block reason must speak the direct _bridge/ file protocol (seam-contract + // §3): name the inbox, the per-slug cursor, and the ack file — and the count. + for _, want := range []string{"alpha", "1 queued", "_bridge/inbox.jsonl", "_bridge/.inbox-cursor.", "_bridge/fo-replies.jsonl"} { + if !strings.Contains(d.Reason, want) { + t.Fatalf("reason missing %q:\n%s", want, d.Reason) + } + } +} + +// TestCheckReasonNamesNoDroppedVerb pins that the Stop-hook block reason never +// instructs a dropped `spacedock bridge inbox drain|ack|commit` (or alert / +// initiate) verb — FO judgment is direct file writes, not a CLI verb (scope 2). +func TestCheckReasonNamesNoDroppedVerb(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"}) + if d.Decision != "block" { + t.Fatalf("decision = %q, want block", d.Decision) + } + for _, banned := range []string{ + "spacedock bridge inbox drain", + "spacedock bridge inbox ack", + "spacedock bridge inbox commit", + "bridge alert", + "bridge initiate", + } { + if strings.Contains(d.Reason, banned) { + t.Fatalf("reason names dropped verb %q:\n%s", banned, 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) + } +} + +// TestCheckEmptyWhenHeartbeatMissingSessionID documents the review-B2 failure +// mode the mod protocol prevents: a heartbeat written WITHOUT a session_id +// resolves no slug for the stopping session, so the check never blocks and the +// queued intent sits undelivered. The load-bearing fix lives in the producer +// (the mod's startup/idle hooks MUST write the harness session id); this test +// pins that the reader honestly resolves nothing rather than fabricating a block. +func TestCheckEmptyWhenHeartbeatMissingSessionID(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", "", now) // heartbeat with an EMPTY session_id + + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a"}) + if d.Decision != "" { + t.Fatalf("missing heartbeat session_id must resolve nothing (no 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/wake.go b/internal/bridgeingress/wake.go new file mode 100644 index 000000000..5126704e4 --- /dev/null +++ b/internal/bridgeingress/wake.go @@ -0,0 +1,720 @@ +// 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 + +// 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. +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 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"` + 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 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 == "" { + 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() + + allRecords, err := readInbox(absRoot) + if err != nil { + return Result{Status: "failed", Error: err.Error()} + } + 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 := pendingTargetsFor(absRoot, rec, opts.Members, replies) + if len(targets) == 0 { + continue + } + for _, target := range targets { + sessionID, ok := resumableSessionID(absRoot, target) + if !ok { + targetsMissingSession[target] = true + continue + } + w := sessions[sessionID] + if w == nil { + w = &sessionWake{SessionID: sessionID, TargetSet: map[string]bool{}} + sessions[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 resumable Codex session id", + }) + return Result{Status: "skipped-no-session", Lines: recordLines(records), Targets: targets, Message: "no resumable 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, + }) + } + + 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) + } + go func() { + _ = cmd.Wait() + }() + return nil +} + +// wakePrompt instructs the resumed Codex FO to drain the inbox using the direct +// _bridge/ file protocol (docs/seam-contract.md §3) — the FO writes files, not a +// spacedock verb. It deliberately names no `spacedock bridge inbox ...` command: +// draining, acking, and cursor advancement are file writes the FO performs +// itself (findings B.2). The three substrings the wake tests pin — the pending +// line list, the addressed slug list, and the inbox path — are preserved. +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 + +Drain the Bridge inbox now for ONLY those workflow slugs, writing _bridge/ files directly per the seam contract (no spacedock inbox command): +1. For each slug S, read the current cursor from _bridge/.inbox-cursor. (treat a missing/unparseable file as 0). +2. Read _bridge/inbox.jsonl line by line, counting physical newline-terminated lines 1-based (a malformed line still consumes its number; skip a trailing fragment with no newline). For each line numbered N greater than the cursor whose routing addresses S (target "all"/absent, target == S, or S in target_set), act on the intent. +3. Before acting on an intent id X, scan _bridge/fo-replies.jsonl for a terminal ack whose in_reply_to_id is X and skip the intent if one already exists (dedup). +4. Append a terminal ack line to _bridge/fo-replies.jsonl for each intent you handle: schema:1, in_reply_to_id set to the intent id, intent_kind set to the intent kind, the matching reply kind, a valid terminal status, and your slug as target. +5. Write the inbox's new physical line count (wc -l) to _bridge/.inbox-cursor. as a whole-file replace — monotonic, never lower it. +6. Refresh the heartbeat _bridge/fo..json (ts now, state "working" while acting). +Then continue the normal first-officer event loop. +`, 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 + } + _, _ = fmt.Fprintf(f, "%d\n", os.Getpid()) + _ = f.Close() + return func() { _ = os.Remove(path) }, true +} + +func readInbox(root string) ([]inboxRecord, error) { + f, err := os.Open(filepath.Join(root, "_bridge", "inbox.jsonl")) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + var out []inboxRecord + lineNo := 0 + scanner := lineScanner(f) + for scanner.Scan() { + lineNo++ + if 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, 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 := lineScanner(f) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "" { + continue + } + var rec replyRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + // Accept an ack correlated by strong id (what the seam mod writes) OR the + // legacy line form; require an intent kind and a target. Only a TERMINAL + // ack means the intent is done — an interim `acting`/`received` leaves it + // pending, so those do not suppress it. + if rec.Schema != 1 || rec.Target == "" || rec.IntentKind == "" { + continue + } + if rec.InReplyToID == "" && rec.InReplyToLine <= 0 { + continue + } + switch rec.Status { + case "", "acting", "received", "queued": + 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 +} + +// replyKey correlates an inbox intent to its ack. The primary key is the strong +// id (`in_reply_to_id`), which is what the seam mod writes (contract §2.3) and +// what Bridge's own lifecycle reader prefers; the physical line is NOT part of +// the id key, so an id-only ack still matches. Only when no id exists does it +// fall back to the legacy line+ts form. +func replyKey(rec inboxRecord, target string) string { + if rec.ID != "" { + return "id\x00" + rec.ID + "\x00" + rec.Kind + "\x00" + target + } + return "line\x00" + strconv.Itoa(rec.Line) + "\x00" + rec.TS.Format(time.RFC3339Nano) + "\x00" + rec.Kind + "\x00" + target +} + +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 + } + 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 + } + 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 { + return hb, false + } + return hb, true +} + +func resumableSessionID(root, slug string) (string, bool) { + 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 && safeSessionID(sessionID) { + return sessionID, true + } + 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) + 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 := lineScanner(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) { + 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..926b4f28b --- /dev/null +++ b/internal/bridgeingress/wake_test.go @@ -0,0 +1,422 @@ +package bridgeingress + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWakeResumesFreshHeartbeatSessionWithoutAdvancingWakeCursor(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 _, 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) + 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 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"}`, + ) + writeWakeCursor(t, root, "1") + 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: 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 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) + } +} + +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") + 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 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) + 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_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/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 973df50ff..04f791245 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4,6 +4,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -13,6 +14,8 @@ 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/dispatch" "github.com/spacedock-dev/spacedock/internal/safehouse" @@ -149,6 +152,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 } @@ -457,6 +461,98 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout } } +// newBridgeCommand is a hidden Bridge-facing surface carrying exactly three +// hook-/daemon-invoked entrypoints — never FO judgment: `egress emit` (the +// host-neutral events.jsonl + session-marker writer), `ingress wake` (the codex +// external resume), and `inbox check` (the synchronous Stop-hook drain gate). +// Egress stays silent and no-op-safe because it is observe-only telemetry; wake +// and check print compact JSON that Bridge (or the Stop hook) consumes without +// knowing host internals. The FO's own judgment (draining, acking, gating, +// alerting) is direct _bridge/ file writes per docs/seam-contract.md §3, not a +// verb here. +func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { + return &cobra.Command{ + Use: "bridge egress emit --host | ingress wake --host codex | inbox check", + Hidden: true, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + 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 + } + if len(args) >= 2 && args[0] == "inbox" && args[1] == "check" { + rest := args[2:] + decision := bridgeingress.CheckFromReader(stdin, bridgeingress.CheckOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", ""), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + }) + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(decision) + return nil + } + 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 "" +} + +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 + } + return csvParts(raw) +} + +func csvParts(raw string) []string { + 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 diff --git a/internal/contractlint/bridge_seam_test.go b/internal/contractlint/bridge_seam_test.go new file mode 100644 index 000000000..6135df98c --- /dev/null +++ b/internal/contractlint/bridge_seam_test.go @@ -0,0 +1,124 @@ +// ABOUTME: Structural gates for the consolidated bridge seam (mod + hooks + prose). +// ABOUTME: Absence of dropped verbs, reference closure, mod frontmatter, session-id markers. +package contractlint + +import ( + "path/filepath" + "strings" + "testing" +) + +// bridgeSeamProseFiles are the shipped instruction files that carry seam prose. +// The full drain protocol lives in the bridge-seam mod; these speak it too. +var bridgeSeamProseFiles = []string{ + "mods/bridge-seam.md", + "docs/dev/_mods/bridge-seam.md", + "skills/first-officer/references/first-officer-shared-core.md", + "skills/first-officer/references/fo-dispatch-core.md", + "skills/first-officer/references/fo-fleet.md", + "skills/first-officer/references/fo-bridge.md", + "skills/first-officer/references/claude-first-officer-runtime.md", + "skills/first-officer/references/codex-first-officer-runtime.md", + "skills/first-officer/references/pi-first-officer-runtime.md", + "skills/present-gate/SKILL.md", +} + +// droppedBridgeVerbs are the #445 CLI verbs the consolidation ABSORBED into the +// file protocol. The FO writes the seam files directly, so an instruction that +// tells it to shell one of these is a regression — a producer coupled to a verb +// that no longer exists. (`egress emit`, `inbox check`, and `ingress wake` +// survive as hook/daemon entrypoints and are deliberately NOT listed.) +var droppedBridgeVerbs = []string{ + "bridge inbox drain", + "bridge inbox ack", + "bridge inbox commit", + "bridge alert", + "bridge initiate", +} + +// TestBridgeSeamNoDroppedVerbs is a structural-absence gate: no shipped seam +// instruction may invoke a dropped verb. Keeps the "consumes files, not verbs" +// invariant from drifting back to a CLI coupling. +func TestBridgeSeamNoDroppedVerbs(t *testing.T) { + for _, rel := range bridgeSeamProseFiles { + body := readRepoFile(t, filepath.FromSlash(rel)) + for _, verb := range droppedBridgeVerbs { + if strings.Contains(body, verb) { + t.Errorf("%s references dropped verb %q — the seam is a direct file write, not a CLI verb", rel, verb) + } + } + } +} + +// TestBridgeSeamReferenceClosure is a reference-closure gate: the mod ships at +// both the canonical and dogfood paths, and every deferred reference the seam +// prose points at resolves to a file that exists. +func TestBridgeSeamReferenceClosure(t *testing.T) { + for _, rel := range []string{ + "mods/bridge-seam.md", + "docs/dev/_mods/bridge-seam.md", + "skills/first-officer/references/fo-bridge.md", + "skills/first-officer/references/fo-fleet.md", + "docs/dev/bridge-seam.md", + } { + if body := readRepoFile(t, filepath.FromSlash(rel)); strings.TrimSpace(body) == "" { + t.Errorf("required seam file %s is missing or empty", rel) + } + } +} + +// TestBridgeSeamDogfoodCopyMatchesCanonical is a dedup gate: the dogfood install +// copy must be byte-identical to the canonical mod, so drift (e.g. a reworded +// line that reintroduces a dropped-verb string on only one path) cannot slip the +// per-file gates. +func TestBridgeSeamDogfoodCopyMatchesCanonical(t *testing.T) { + canonical := readRepoFile(t, filepath.FromSlash("mods/bridge-seam.md")) + dogfood := readRepoFile(t, filepath.FromSlash("docs/dev/_mods/bridge-seam.md")) + if canonical != dogfood { + t.Error("docs/dev/_mods/bridge-seam.md drifted from mods/bridge-seam.md — keep the dogfood copy byte-identical") + } +} + +// TestBridgeSeamModFrontmatter is a frontmatter-validity gate: the mod declares a +// name and is NON-standing (a standing:true mod routes through a different, wrong +// execution path — a persistent teammate spawn rather than FO-loop hooks). +func TestBridgeSeamModFrontmatter(t *testing.T) { + body := readRepoFile(t, filepath.FromSlash("mods/bridge-seam.md")) + if !strings.HasPrefix(strings.TrimSpace(body), "---") { + t.Fatal("mods/bridge-seam.md has no frontmatter block") + } + fm := body[strings.Index(body, "---")+3:] + fm = fm[:strings.Index(fm, "---")] + if !strings.Contains(fm, "name:") { + t.Error("mods/bridge-seam.md frontmatter missing name:") + } + if strings.Contains(fm, "standing:") { + t.Error("mods/bridge-seam.md must NOT declare standing: — it is an FO-loop hook mod, not a standing teammate") + } + for _, h := range []string{"## Hook: startup", "## Hook: idle", "## Agent Prompt"} { + if !strings.Contains(body, h) { + t.Errorf("mods/bridge-seam.md missing required section %q", h) + } + } +} + +// perHostSessionIDMarkers are the harness session-id sources the heartbeat must +// stamp so the Stop-hook inbox check can resolve which slugs belong to a stopping +// session (the load-bearing wake-resolution binding). Portability-marker presence: +// a heartbeat that omits the harness id silently kills Claude intent delivery. +var perHostSessionIDMarkers = []string{ + "CLAUDE_CODE_SESSION_ID", // claude + "CODEX_THREAD_ID", // codex +} + +// TestBridgeSeamSessionIDBinding is a portability-marker gate: the mod names the +// per-host session-id source, so the wake-resolution binding cannot silently drop +// out of the drain protocol. +func TestBridgeSeamSessionIDBinding(t *testing.T) { + body := readRepoFile(t, filepath.FromSlash("mods/bridge-seam.md")) + for _, marker := range perHostSessionIDMarkers { + if !strings.Contains(body, marker) { + t.Errorf("mods/bridge-seam.md missing per-host session-id marker %q — the heartbeat must carry the harness session id or the Stop-hook wake resolves nothing", marker) + } + } +} diff --git a/internal/contractlint/fo_function_reference_invariant_test.go b/internal/contractlint/fo_function_reference_invariant_test.go index f3b702eec..831f53509 100644 --- a/internal/contractlint/fo_function_reference_invariant_test.go +++ b/internal/contractlint/fo_function_reference_invariant_test.go @@ -12,7 +12,14 @@ import ( "testing" ) -const foFunctionReferenceBaselineBytes = 122400 +// Re-baselined by the consolidated bridge-seam PR: the seam is a new capability +// that adds prose across the FO boot, dispatch, gate, and per-host runtime +// surfaces (superseding the growth #435/#445 introduced). Counted-surface growth +// was minimized by keeping the full drain protocol in the uncounted `bridge-seam` +// mod and deferred `fo-bridge.md`/`fo-fleet.md`; only the pointers and load-bearing +// session-id binding live in the counted files. The ratchet still guards against +// accidental future growth from this new floor. +const foFunctionReferenceBaselineBytes = 130300 var foFunctionReferencePaths = []string{ "skills/first-officer/SKILL.md", @@ -280,7 +287,7 @@ func TestFOLocalOrderedProceduresPreserved(t *testing.T) { {"skills/first-officer/references/first-officer-shared-core.md", "## Startup", []string{"1", "2", "3"}, []string{"Binary version gate", "«state.boot»", "«interaction.boundary»"}}, {"skills/first-officer/references/fo-dispatch-core.md", "## Dispatch", sequence(1, 9), []string{"entity file", "«dispatch.checklist»", "conflicts", "dispatch_agent_id", "status --workflow-dir", "Commit", "worktree", "«dispatch.build»", "«completion-signal»"}}, {"skills/first-officer/references/fo-dispatch-core.md", "## Reuse and Fresh Dispatch", sequence(0, 4), []string{"«context-budget»", "«addressable-worker»", "fresh: true", "worktree", "«reuse.model-match»"}}, - {"skills/first-officer/references/fo-dispatch-core.md", "## «dispatch.next-action»(): pick the next event-loop action — dispatch a ready entity, resume a block, or end the iteration", []string{"0.5", "1", "2", "3"}, []string{"«addressable-worker»", "mod-block", "status --next", "«hooks.run»", "«roster-reconcile»"}}, + {"skills/first-officer/references/fo-dispatch-core.md", "## «dispatch.next-action»(): pick the next event-loop action — dispatch a ready entity, resume a block, or end the iteration", []string{"0.5", "0.6", "1", "2", "3"}, []string{"«addressable-worker»", "bridge-seam", "mod-block", "status --next", "«hooks.run»", "«roster-reconcile»"}}, {"skills/present-gate/SKILL.md", "### Captain-facing assembly rules", sequence(1, 11), []string{"Lede first", "Chosen direction", "Stage Report", "Reviewer findings", "Recommendation", "Bounce-back", "format-pedantry", "worktree", "Target length", "declared label", "verification state"}}, {"skills/feedback-rejection-flow/SKILL.md", "## Feedback Rejection Flow", sequence(1, 7), []string{"feedback-to", "Feedback Cycles", "cycle 3", "«context-budget»", "«addressable-worker»", "reviewer", "gate flow"}}, {"skills/using-legacy-claude-team/SKILL.md", "## «legacy-team.recover»(): recover a desynchronized legacy team", sequence(1, 3), []string{"Fresh-suffixed TeamCreate", "Degraded Mode", "Surface to captain"}}, diff --git a/internal/contractlint/startup_collapse_test.go b/internal/contractlint/startup_collapse_test.go index fafe90af2..a5af86492 100644 --- a/internal/contractlint/startup_collapse_test.go +++ b/internal/contractlint/startup_collapse_test.go @@ -14,7 +14,12 @@ import ( // the post-vcm-merge branch this implementation opened from (`wc -c` == 26755). // AC-1's value half asserts the post-change file is STRICTLY smaller: a shorter // recipe that grew the file fails. -const preChangeSharedCoreBytes = 26755 +// Re-baselined by the consolidated bridge-seam PR: shared-core gains the +// before-greet `«bridge.boot-liveness»` step + function (the one liveness signal +// that cannot wait for the event loop), mirroring the before-greet heartbeat step +// #435 added here. The recipe stayed lean (no new top-level Startup step; 2b is a +// sub-step); this is the new floor the shrink assertion guards from. +const preChangeSharedCoreBytes = 27200 // startupStepRe matches a top-level numbered Startup step: a line beginning with // `N.` at column zero. Sub-bullets (indented `-`) and the discovery sub-cases are diff --git a/internal/contractlint/structural_checks_test.go b/internal/contractlint/structural_checks_test.go index a2cb3e737..cf882f8f9 100644 --- a/internal/contractlint/structural_checks_test.go +++ b/internal/contractlint/structural_checks_test.go @@ -258,6 +258,10 @@ func TestNoUnexpectedModHookOrPRMergeIntroduced(t *testing.T) { // `## Hook: merge` mechanism surface) into the merge core; it legitimately // carries the `## Hook:` token. filepath.Join("skills", "first-officer", "references", "fo-merge-core.md"): true, + // The Bridge seam mod carries `## Hook: startup`/`## Hook: idle` prose + // conventions (non-standing) that drive the FO's per-tick heartbeat + + // captain-intent drain — a legitimate lifecycle-hook surface. + filepath.Join("mods", "bridge-seam.md"): true, } allowedPRMergeFiles := map[string]bool{ filepath.Join("mods", "pr-merge.md"): true, diff --git a/internal/release/journeydelta.go b/internal/release/journeydelta.go index 850e2f9d2..b8e5b3a76 100644 --- a/internal/release/journeydelta.go +++ b/internal/release/journeydelta.go @@ -59,9 +59,9 @@ func deltaKeyString(k journeyDeltaKey) string { // PR run's fresh observation against the previously published release's // latest-by-captured_at baseline for the same scenario/runtime/model. type JourneyDelta struct { - ScenarioID string - Runtime string - Model string + ScenarioID string + Runtime string + Model string HasBaseline bool BaselineRunURL string TurnsDelta int diff --git a/mods/bridge-seam.md b/mods/bridge-seam.md new file mode 100644 index 000000000..217342c9e --- /dev/null +++ b/mods/bridge-seam.md @@ -0,0 +1,142 @@ +--- +name: bridge-seam +description: Produce the Bridge `_bridge/` seam by direct file writes — a liveness heartbeat carrying the harness session id, captain-intent drain by cursor, terminal acks, and FO-authored cards/alerts — per Bridge's `docs/seam-contract.md` +version: 0.1.0 +--- + +# Bridge Seam + +[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 / Codex / Pi session has no inbound API), so the seam between Bridge and the FO is a set of **plain files under `_bridge/`** (relative to the FO's working directory — the fleet root where the FO was launched). Bridge writes exactly one of them, the captain-intent queue `_bridge/inbox.jsonl`; every other file the FO **writes** and Bridge **reads**. This mod is the producer side of that contract: on boot and on every loop tick the FO writes a liveness heartbeat, drains the intent queue by a monotonic per-slug cursor, and appends terminal acks — all as direct file writes, no Spacedock CLI verb. + +**Direct file writes, not a verb.** The mechanism is a file recipe a bare agent can follow: read a cursor integer, read newline-delimited JSONL, append single JSONL lines, replace a small JSON file. There are no packaged drain/ack/commit verbs — those are retired; the FO performs the file operations itself. The one binary touchpoint is the hook-driven egress producer (`spacedock bridge egress emit`, wired by the plugin hooks) and, on Claude, the synchronous Stop check (`spacedock bridge inbox check`) that keeps a parked FO alive long enough to drain. Neither is invoked by hand from this mod. + +**The full contract is authoritative in Bridge's `docs/seam-contract.md`** (§2 per-file shapes, §3 the drain recipe); a Spacedock-local overview lives at `docs/dev/bridge-seam.md`. Every JSON shape below is quoted from that contract. Where a reader tolerates more than shown, the reader's tolerance is the contract. + +**This is a pull, not a push.** Delivery latency is one FO loop cadence: queued captain intent is read whenever the FO next boots or idles, never instantly. A **parked** FO (stopped, waiting at the prompt) is nudged per host — on Codex, Bridge resumes the session with `spacedock bridge ingress wake`; on Claude, the packaged `Stop` hook runs `spacedock bridge inbox check` at every turn boundary and returns a `block` decision while intent is queued, so the FO drains in-session. Never resume a live Claude session out-of-band; its transcript has no write locking. + +**Per-host session-id source (load-bearing).** The heartbeat MUST carry `session_id` set to the harness's own session id, because the Claude Stop check resolves *which* workflow slugs belong to the stopping session by matching the Stop payload's `session_id` against the `session_id` in each `_bridge/fo..json` heartbeat. A heartbeat with a missing or wrong session id silently kills Claude intent delivery: the check resolves no slug, never blocks, and the queued intent sits forever. Read the id from the per-host variable: + +| host | session-id source | +|---|---| +| claude | `$CLAUDE_CODE_SESSION_ID` | +| codex | `$CODEX_THREAD_ID` | +| pi | the pi runtime's session id | + +**Mod placement (known limitation).** Lifecycle hooks run from `{workflow_dir}/_mods/`, so a workflow gets this seam only when `bridge-seam.md` is present in its `_mods/` dir. The dogfood copy at `docs/dev/_mods/bridge-seam.md` covers this repo's own workflows; a non-dogfood workflow must copy the mod into its `_mods/`. Automatic scaffolding at commission/refit time is a named follow-up, not solved here. + +## Hook: startup + +For each workflow slug `$SLUG` this FO owns (one per commissioned workflow — see **Your workflow slug(s)**), **before the greet**: + +1. Write the liveness heartbeat `_bridge/fo.$SLUG.json` carrying the harness session id (see **Heartbeat**), with `state:"working"`. This makes Bridge show the workflow attached the moment the FO boots — even a greet-and-stop launch. +2. Run the **Drain procedure** so a freshly-booted FO picks up any intent the captain queued while no FO was attached, before its first dispatch. + +## Hook: idle + +At the top of each loop tick: + +1. Run the **Drain procedure** for each `$SLUG` — it acts on any new intent and refreshes the heartbeat (`ts = now`). +2. When parking (turn end / awaiting the captain), fulfil the **On park** obligations. + +## Agent Prompt + +You are the producer side of the Bridge `_bridge/` seam. Your job is judgment plus a handful of exact file writes. Do not reimplement this with a retired CLI verb; do the file operations directly, using the shapes below verbatim. + +### Your workflow slug(s) + +`$SLUG` is this workflow's slug — the basename of its directory (`basename {dir}`). It names the per-slug cursor and heartbeat files. It must be a **safe slug**: a single path element, no `/`, no `.`/`..`. Several FOs (one per commissioned workflow) can share one fleet root and one `_bridge/` dir; every step below is scoped to a single `$SLUG`. In a **fleet** (this FO owns several members), perform every per-slug step — heartbeat, cursor, drain, acks — **once per member slug**. Bridge shows a member live only when *its own* `fo..json` is fresh; a single shared heartbeat shows only one member attached. + +### Heartbeat + +Bridge reads per-workflow FO liveness from `_bridge/fo.$SLUG.json` (whole-file replace). It treats the workflow as live only when `ts` is **fresh** (within 30 minutes) and **not future-dated**. Write it on boot and refresh it every tick and every drain. Exact shape (contract §2.4): + +```json +{"session_id":"sess_9f","ts":"2026-07-14T18:06:30Z","state":"idle","host":"claude"} +``` + +- `session_id` — the harness session id from the per-host source table above (`$CLAUDE_CODE_SESSION_ID` / `$CODEX_THREAD_ID` / pi's id). **This field is load-bearing for Claude wake delivery** — never omit it or write a placeholder. +- `ts` — present-time RFC3339 UTC. A zero/absent or future ts ⇒ Bridge reports not-attached (never fabricates `working`). +- `state` — `working` while acting; `idle` when parked awaiting the captain. +- `host` — `claude` \| `codex` \| `pi` (the harness stamping it). + +### Drain procedure + +Per member slug `$SLUG`, on boot and every loop tick: + +1. **Read the cursor.** `C = int(contents of "_bridge/.inbox-cursor.$SLUG")`, or `0` if the file is absent, empty, non-integer, or negative. The cursor is the count of physical inbox lines this slug's FO has already drained (its high-water line number). +2. **Read the inbox by physical line number.** Read `_bridge/inbox.jsonl` line by line, counting **1-based physical newline-terminated lines** — every newline-terminated line consumes a number **even a malformed one** (skip it from acting but still count it); a trailing fragment with **no** newline at EOF is a torn write, skipped and **not** counted. This is the `wc -l` rule Bridge uses for read-back, so your cursor agrees with Bridge's line numbers. Each inbox line looks like (contract §2.1): + ```json + {"id":"bi_ab12","ts":"2026-07-14T18:03:00Z","kind":"tell","text":"ping","target":"all"} + ``` + Fields: `id` (stable, the primary reply correlator), `ts`, `kind` (`tell`\|`conn`\|`decision`\|`permission-decision`), optional `text`, `granted` (`conn`), `target`, `target_set`, `entity`/`field`/`value`/`verdict`/`directives` (`decision`), `request_id`. +3. **Route.** For each line with physical number `N > C`, decide whether it is addressed to `$SLUG`: + - If `target_set` is **present**, act **only** when `$SLUG` is in `target_set`. The frozen `target_set` is authoritative — ignore `target` entirely, including `target:"all"`. + - If `target_set` is **absent**, act when `target == "$SLUG"`, `target == "all"`, or `target` is missing/empty. + - A line not addressed to `$SLUG` is skipped, but still counts as processed so the cursor advances past it. +4. **Dedup, then act.** Before acting on an addressed intent with `id` X, scan `_bridge/fo-replies.jsonl` for a **terminal** ack whose `in_reply_to_id` is X (terminal = any status except the interim `acting`). If one exists, you already handled X — skip it (still counted). Otherwise act on it by kind: + - `tell` — treat `text` as a captain directive for this tick; act on it, then append a `reply` ack with `status:"answered"`. + - `conn` — a conn-handover change. `granted:true` → adopt the conn within the stated goal `text` (drive the covered entities to done without stopping at their gates; escalations stay non-delegable), then append a `conn-ack` with `status:"accepted"`. `granted:false` → take the conn back (stop at every gate again), then `conn-ack` `status:"released"`. + - `decision` — the captain resolved a gate from Bridge (a captain decision, not FO self-approval; Bridge has not advanced the entity). Resolve `entity` in this workflow and apply the normal gate flow: self-described shape (`field`+`value`) → set the field and continue the gate as if decided in chat; plain shape (`verdict`+optional `directives`) → `approve` advances, `reject`/`redo` route to the gate's `feedback-to` stage with the directives; perform any external actions (GitHub, Linear) before terminal state. Then append a `decision-ack` with `status:"applied"` (finished/already-satisfied), `status:"blocked"` (valid but could not finish), or `status:"rejected"` (invalid/stale/unresolvable — including when `entity` does not resolve here). You may append an interim `acting` ack first. + - `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"` → do not retry; append `permission-ack` `status:"denied"`. `value:"approve-once"` → retry the exact blocked action once via the runtime's escalation path. `value:"approve-rule"` → retry with the alert's `prefix_rule` if present, else treat as `approve-once`. Append `permission-ack` `status:"accepted"` before retrying, or `status:"blocked"` if the retry could not start. A Bridge approval is FO intent, not a bypass of a host-native security prompt; honor any native dialog. +5. **Advance the cursor to the highest line you actually read.** After acting on every addressed line this tick, set `L` = the **highest physical line number you counted in step 2's read** (not a fresh independent `wc -l`). Write `L` to `_bridge/.inbox-cursor.$SLUG` as a single decimal integer (whole-file replace). Deriving `L` from the same pass that did the routing — rather than recounting — makes it **structurally impossible to advance past a line you never examined**: a second count could race a concurrent append and jump the cursor over an unread intent, which is permanent silent loss (see the safety note). Using the step-2 high-water number instead means any line that arrived after your read simply stays pending for the next tick. This still advances past other slugs' lines the routing filtered out, because step 2 counted them too. +6. **Refresh the heartbeat** (`ts = now`, `state:"working"` while still acting). +7. Report to the captain how many intents you drained for this workflow and what you did with each. + +Missing/empty `_bridge/inbox.jsonl` ⇒ no Bridge attached; skip (write nothing but the heartbeat). A malformed line ⇒ skip acting on it but still count it toward the cursor; note the skip to the captain. + +### Cursor safety + +The cursor is **monotonic — never lower it.** Only ever raise `.inbox-cursor.$SLUG` to the current physical line count. If you are ever unsure of the count, re-read the inbox and recount from scratch before writing; do not guess a higher number. **An over-advanced cursor silently skips captain intent and nothing re-blocks it**: the Claude Stop check only ever sees intent *below* the cursor as still-pending, so any line you jumped over is invisible to the check and will never trigger another block. Under-counting is self-healing (the next tick re-drains); over-counting is permanent silent loss. When in doubt, count low. + +### Replies / acks (`_bridge/fo-replies.jsonl`) + +Append one JSONL line per addressed intent you handled or rejected (append-only). Bridge **drops** any line whose `schema` is not exactly `1`, or that lacks a correlator. Exact shape (contract §2.3): + +```json +{"schema":1,"ts":"2026-07-14T18:05:00Z","kind":"reply","target":"my-wf","in_reply_to_id":"bi_ab12","intent_kind":"tell","status":"answered","text":"done"} +``` + +Required on every ack: `schema:1`; non-zero RFC3339 `ts`; `kind`; `target` = your `$SLUG` (safe slug); `in_reply_to_id` = the intent's `id` (the strong correlator — a record must carry either `in_reply_to_id`, or both `in_reply_to_line>0` and a non-zero `in_reply_to_ts`); `intent_kind` = the correlated intent's `kind`; a `status` valid for the `kind`. + +`kind` is derived from the intent kind, and `status` must be valid for that `kind`: + +| intent `kind` | reply `kind` | terminal statuses (done / failed) | interim | +|---|---|---|---| +| `tell` | `reply` | `answered` / `rejected`,`blocked` | `acting` | +| `conn` | `conn-ack` | `accepted`,`released` / `rejected`,`blocked` | `acting` | +| `decision` | `decision-ack` | `applied` / `rejected`,`blocked` | `acting` | +| `permission-decision` | `permission-ack` | `accepted` / `denied`,`rejected`,`blocked` | `acting` | + +`acting` is the interim ack (received → acting → terminal) and is legal for all four kinds; a terminal ack never regresses to `acting`. Optional echo fields: `text`, `granted` (`conn-ack`), `entity`/`field`/`value` (`decision-ack`), `request_id`, `verdict`, `session_id`, `host`. A well-formed ack that correlates to no loaded intent is surfaced by Bridge (muted), never a hard error. + +### Cards: status / reco / gate-review (`_bridge/fo-initiate.jsonl`) + +FO judgment the captain sees. Append-only; Bridge **drops** a line unless `schema` is `1`, `id` is non-empty, and `kind` is known. Exact shape (contract §2.7): + +```json +{"schema":1,"id":"init_7a","ts":"2026-07-14T18:07:00Z","kind":"gate-review","workflow":"linear-drc-ship","entity":"drc-3467","headline":"Ready to ship?","body":"...","status":"open"} +``` + +`kind` is `status` (ambient), `reco` (recommendation), or `gate-review` (decidable). Always write `status:"open"` — Bridge overlays resolution itself from the matching inbox `decision` (correlated by `request_id`, which defaults to `id`). Re-emitting the same `id` folds to one card (latest `ts` wins). An open `gate-review` is never evicted by the card cap, however old. + +### Permission alerts (`_bridge/fo-alerts.jsonl`) + +A high-priority FO→captain interrupt when a command is blocked. Append-only; a line is dropped unless `id` is non-empty and `kind == "permission-request"`. Exact shape (contract §2.8): + +```json +{"schema":1,"id":"al_3c","ts":"2026-07-14T18:08:00Z","kind":"permission-request","workflow":"linear-drc-ship","reason":"rm outside repo","command":"rm -rf /tmp/x","prefix_rule":["rm -rf /tmp/"],"status":"open"} +``` + +`id` is the correlator for the captain's `permission-decision`. `status` empty defaults to `open`; Bridge overlays the decision. (The deferred permission-alert helper `fo-bridge.md` owns the exact emit prose.) + +### On park + +Do not silently park — a parked turn with no card is indistinguishable from finished work. + +1. If you reached a gate, append a `gate-review` to `_bridge/fo-initiate.jsonl` with `status:"open"` and a `request_id` **before** parking. +2. If a command was blocked, append a `permission-request` to `_bridge/fo-alerts.jsonl`. +3. Refresh the heartbeat with `state:"idle"`. + +### Lifecycle egress (harness-driven, not authored here) + +The live working/idle badge and per-ship running signal come from `_bridge/events.jsonl` (turn-lifecycle lines) and `_bridge/sessions/.json` (session→entity markers). Those are produced by the packaged plugin hooks (`spacedock bridge egress emit`) inside the harness turn lifecycle, not written by hand from this mod. Without them Bridge still renders durable state from git narration + cursors + heartbeats. `_bridge/fo-feed.jsonl` is **optional** enrichment only (contract §5): every signal it carries is already covered by git narration and the marker-derived feed, so this mod does not produce it. diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh new file mode 100755 index 000000000..999a84018 --- /dev/null +++ b/scripts/spacedock-bridge-events.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# ABOUTME: Claude plugin hook wrapper for Bridge egress; observe-only and silent. +# +# 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 + +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/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/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 9603faeb3..a8ef08e05 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,6 +6,8 @@ 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 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 seam` section states the per-host coverage.) + ## Clarification If requirements are unclear or ambiguous, ask for clarification via `SendMessage(to="team-lead")` rather than guessing. Describe what you understand and what's ambiguous so team-lead can get you a quick answer. diff --git a/skills/first-officer/SKILL.md b/skills/first-officer/SKILL.md index 8a8b07247..91bcf25d1 100644 --- a/skills/first-officer/SKILL.md +++ b/skills/first-officer/SKILL.md @@ -13,6 +13,8 @@ If this skill is invoked directly in a non-interactive run and the prompt names - before the final response, explicitly shut down any worker that is no longer needed for later routing or gate handling - once the bounded stop condition is satisfied, send one concise final response and exit immediately +If this skill is invoked with a quotable fleet directive ("drive the fleet" / "run all workflows" / "fleet mode") and discovery finds multiple commissioned workflows, enter fleet mode (operating contract `references/fo-fleet.md`): adopt the named-or-all discovered workflows as the member set and drive them from this one session. Absent the directive, discovery behaves as before — one workflow, or present the list on ambiguity. + ## How the first officer operates You are dispatcher, responsible for making sure the work is done by the crew. What awesome looks like: diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index a0ba0ab9f..05047fb31 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -35,3 +35,11 @@ See `## Probe and Ideation Discipline` in the shared core — its Grep-over-Read Before filing, read the workflow README's `## Task Template`; use its frontmatter and section scaffolding as the starting shape for the entity body you pipe to `spacedock new`. 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_BIN:-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 the eagerly loaded `«write.classify»` 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 seam (liveness/activity egress + captain-intent ingress) + +Bridge observes this FO through `_bridge/` files per Bridge's `docs/seam-contract.md` (local overview: `docs/dev/bridge-seam.md`). The FO writes the seam files directly per the `bridge-seam` mod's `## Agent Prompt` — there are NO `spacedock bridge inbox|alert|initiate` verbs. Two packaged binaries remain, both hook-invoked (never FO-called): the egress emitter and the Stop-hook inbox check. + +- **Event egress — PRESENT on Claude, via plugin hooks (no FO action).** `.claude-plugin/plugin.json` → `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` (async, observe-only) which calls `spacedock bridge egress emit --host claude`; it writes `_bridge/events.jsonl` and, on an ensign's first Read of its entity file, the deterministic `_bridge/sessions/.json` marker. `agent_id`/`agent_type` are empty for the main FO, set for ensigns. +- **Session-id binding (LOAD-BEARING).** The `bridge-seam` heartbeat MUST stamp `session_id` = `$CLAUDE_CODE_SESSION_ID`. The durable Claude wake is the synchronous `Stop` hook (`scripts/spacedock-bridge-inbox-check.sh` → `spacedock bridge inbox check --host claude`), which resolves which slugs belong to the stopping session by matching the Stop payload's `session_id` against the `fo.$SLUG.json` heartbeats. Omit or mismatch that id → the check resolves no slug → never blocks → queued intent is delivered NEVER. +- **Wake is in-session, not external.** A parked Claude transcript has no write locking, so there is NO `bridge ingress wake --host claude` — do not add one. For an **interactive** FO the Stop hook returns `block` while intent is queued so the FO drains before stopping; a truly idle session shows a queued count for the captain to nudge. A headless (`claude -p`) FO is daemon-managed and woken by the daemon's in-process resume, not this hook (see `docs/dev/bridge-seam.md`, "Durable-wake caveat"). diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index 4f7c7b86d..897d65e86 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -52,3 +52,11 @@ 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 seam (liveness/activity egress + captain-intent ingress) + +Bridge observes this FO through `_bridge/` files per `docs/seam-contract.md` (local overview: `docs/dev/bridge-seam.md`). The FO writes the seam files directly per the `bridge-seam` mod — no inbox/alert/initiate verbs. Codex differs from Claude in one way: it supports a durable EXTERNAL wake. + +- **Event egress — PACKAGED on Codex.** `.codex-plugin/plugin.json` → `hooks/codex-hooks.json` (non-async command hooks) calls `spacedock bridge egress emit --host codex` (bin via `$SPACEDOCK_BIN`/`PATH`). The emitter normalizes Codex-native lifecycle names into the canonical event grammar before writing `_bridge/events.jsonl`. Deterministic `_bridge/sessions/` marker parity is not yet claimed on Codex. +- **Session-id binding.** The `bridge-seam` heartbeat stamps `session_id` = `$CODEX_THREAD_ID` when exposed; else empty (still a valid liveness tick — Bridge reads freshness from `ts`). +- **Durable wake is external.** Bridge runs `spacedock bridge ingress wake --host codex`, which resumes the parked 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. No Stop-block hook on Codex. diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 1a832f5df..8c760b6bc 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -12,6 +12,7 @@ Shared first-officer semantics — the boot-resident core. Status and dispatch l In every class, do NOT proceed to discovery or `--boot`. 2. **Boot — local identify.** Invoke `«state.boot»()` once and retain its boot record. +2b. **Bridge liveness — before greet.** Invoke `«bridge.boot-liveness»()` with the boot record. When the `bridge-seam` mod is registered it writes each member's `_bridge/fo.$SLUG.json` heartbeat and runs the initial inbox drain BEFORE the greet, so a live FO shows attached in Bridge from boot — even a greet-and-stop launch. A no-op when the mod is not registered. 3. **Interaction boundary.** Invoke `«interaction.boundary»()` with that boot record and the launch context. ## «interaction.boundary»(): route interactive and headless launch behavior @@ -43,6 +44,8 @@ A greet-and-stop boot loads NONE of these — it composes its summary from `«st - `Skill(skill="spacedock:fo-status-viewer")` — first status query (`--set` / `--next-id` / `--resolve` / issue filing). - `references/fo-dispatch-core.md` — read before the first worker dispatch, before invoking `«dispatch.next-action»()`, or before mutating dispatch state. `«dispatch.build»` output is not a dispatch: forward every ready entity's artifact to `«worker.spawn»`; never author its stage report or claim completion without the worker's `«completion-signal»`. - `Skill(skill="spacedock:fo-dispatch-recovery")` — dispatch failure recovery (Degraded Mode, break-glass manual dispatch, budget-fail/dead-ensign handling); named at its triggers inside the Claude dispatch module — no boot and no happy-path dispatch loads it. +- `references/fo-bridge.md` — first permission block: appends the `permission-request` alert line to `_bridge/fo-alerts.jsonl` directly (deny / approve-once / approve-rule), a direct file write, no `spacedock bridge` verb. +- `references/fo-fleet.md` — a quotable fleet directive adopts the named/ALL discovered workflows as one member set. ## Single-Entity Scope @@ -85,6 +88,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):** when the `bridge-seam` mod is registered, run THIS entity's `idle` drain once (keyed by its `$SLUG`) BEFORE assembling, so a Bridge-queued `decision` record for this gate is applied now; re-read status. Bridge wake is best-effort and delivery is confirmed only by this FO-owned drain, so a captain decision could otherwise sit unprocessed while you redundantly present. If the drain advanced the entity past `{stage}`, do NOT present — report what you applied and return (the **block** below forbids inventing a verdict). Otherwise 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. @@ -116,6 +120,14 @@ The FO declares state intent by invoking the prose-functions below. Each is idem - **done-when:** the self-describing boot record is in hand, its counts and PR fields labeled possibly stale, and the greet has mutated nothing. - → **shipped**: `` `spacedock status --boot --identify --json` `` (extended to fold in discovery + taxonomy and render PR_STATE local); convergence moves to «engage». +## «bridge.boot-liveness»(): write the FO heartbeat before the greet + +- **effect:** when the boot record's MODS map registers the `bridge-seam` mod (a `## Hook: startup` section), read that mod at `{workflow_dir}/_mods/bridge-seam.md` and run its startup hook per member `$SLUG` — write `_bridge/fo.$SLUG.json` and run the initial drain — BEFORE composing the greet. This is the one liveness signal that cannot wait for the event loop: without it a greet-and-stop boot shows "no FO attached" in Bridge though the session is live. The heartbeat MUST carry `session_id` = the harness's own session id (Claude `$CLAUDE_CODE_SESSION_ID`, Codex `$CODEX_THREAD_ID`, Pi its runtime id, else empty), so Bridge's Stop-hook wake can resolve which slugs belong to this session; the mod's `## Agent Prompt` states the exact write. +- **observe-only:** an absent `_bridge/inbox.jsonl` no-ops the drain; the heartbeat write still fires. A greet-only session mutates no workflow state — this touches only `_bridge/`. +- **fleet:** runs once per member, keyed by that member's `$SLUG`. +- **done-when:** each member's heartbeat is fresh and its initial drain has run, or the mod is unregistered (skip). +- → **shipped:** the FO writes the files directly per the `bridge-seam` mod; no `spacedock bridge` verb is involved. + ## «state.commit»(slug): record an entity's change durably - → **shipped**: `` `spacedock state commit ` `` — on exit 3 → `«halt.rebase-conflict»(paths)`. diff --git a/skills/first-officer/references/fo-bridge.md b/skills/first-officer/references/fo-bridge.md new file mode 100644 index 000000000..5b8f493a9 --- /dev/null +++ b/skills/first-officer/references/fo-bridge.md @@ -0,0 +1,28 @@ +# FO Bridge — Permission-Block Alerts + +Deferred reference for the one Bridge signal the boot-resident core does not carry: the FO→captain **permission-block alert**. Loaded when the FO hits a permission or sandbox block, not at boot. The heartbeat, the captain-intent drain, the terminal acks, and the gate/status cards all live in the `bridge-seam` mod (`_mods/bridge-seam.md`, its `## Agent Prompt`); this file covers only the alert append. The full seam is Bridge's `docs/seam-contract.md` (§2.8 is the alert shape); the Spacedock-local overview is `docs/dev/bridge-seam.md`. + +## 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 high-priority Bridge interrupt **before parking the loop**. There is no packaged permission-alert verb — this is a direct file write, exactly like the rest of the seam: append one JSONL line to `_bridge/fo-alerts.jsonl` (relative to the fleet root where the FO launched), using the §2.8 shape verbatim: + +```json +{"schema":1,"id":"al_3c","ts":"2026-07-14T18:08:00Z","kind":"permission-request","workflow":"linear-drc-ship","entity":"drc-3467","reason":"rm outside repo","command":"rm -rf /tmp/x","prefix_rule":["rm -rf /tmp/"],"status":"open"} +``` + +- `id` — a non-empty stable correlator you mint (e.g. `al_`). Bridge **drops** any line whose `id` is empty or whose `kind` is not `permission-request`. This `id` is the join key the captain's answer echoes back. +- `kind` — always `permission-request` (the only alert kind today). +- `reason` / `command` — one-line why-blocked and the command summary the captain reads. +- `prefix_rule` — optional proposed allow-rule prefix (a string array) for an `approve-rule` answer. +- `workflow` / `entity` / `host` / `session_id` — optional provenance/scoping; pass them so the card attributes the block. +- `status` — always write `open`; Bridge overlays the resolution itself from the matching inbox `permission-decision`. + +## Answering the block + +The captain's answer arrives as a `permission-decision` intent on `_bridge/inbox.jsonl` whose `request_id` matches your alert `id`. You drain and apply it through the ordinary **Drain procedure** in the `bridge-seam` mod — no verb here either. The `value` decides the retry: + +- `deny` → do not retry; append a `permission-ack` with `status:"denied"`. +- `approve-once` → retry the exact blocked action once through the runtime's escalation path; append `permission-ack` `status:"accepted"` first (or `status:"blocked"` if the retry cannot start). +- `approve-rule` → retry with the alert's `prefix_rule` if present, else treat as `approve-once`; ack `accepted`. + +A Bridge approval is FO intent, not a bypass of a host-native security prompt: if the host still presents its own approval dialog, honor that dialog normally. diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index bb0deab39..8c5292ff0 100644 --- a/skills/first-officer/references/fo-dispatch-core.md +++ b/skills/first-officer/references/fo-dispatch-core.md @@ -156,6 +156,7 @@ These are FO-internal scheduling reads — consume them as `--json` (compact, by When PRESENT, invoke `«roster-reconcile»()` before the inbound-message drain. The skeleton is: 0.5. **Drain inbound worker messages.** When `«addressable-worker»` is PRESENT, drain pending worker messages (its listen call) at each iteration before checking dispatchables. Reply to a `need_decision` / `interview_request` within the worker's timeout window; read and acknowledge a `progress_update` (no reply required). When `«addressable-worker»` is ABSENT, this step is omitted. +0.6. **Drain captain intent + refresh liveness (eager — before dispatching).** When the `bridge-seam` mod is registered, run its idle-tick work — BOTH the heartbeat refresh AND the inbox drain — at the TOP of every iteration, not only at the idle boundary below. A *Driving* FO with continuous work never reaches idle, so an idle-only drain leaves a queued `pause`/redirect/`conn`/`decision` unread mid-drive (the captain cannot steer) and lets the `_bridge/fo.$SLUG.json` heartbeat go stale (Bridge wrongly shows the FO not attached). A drained `pause`/stop halts further dispatch this iteration and waits for the captain; a redirect adjusts course; a `conn`/`decision` applies per the mod's protocol. Cheap and idempotent. Per member in fleet mode. Omitted when the mod is not registered. 1. **Check mod-blocked entities** — Run `status --where "mod-block !=" --json --fields id,slug,mod-block`. For each entity in `entities`, re-read the blocking mod and resume its pending action (e.g. re-present the PR summary); do not dispatch new work for it. 2. **Run `status --next --json --fields id,slug`** — Dispatch any newly ready entity in `dispatchable` (each row carries the fixed `id,slug,current,next,worktree` plus named frontmatter keys; `--fields` is additive over those five, the computed dispatch columns are not projectable). 3. **If nothing is dispatchable** — After the first empty `status --next`, invoke `«hooks.run»("idle")` exactly once, then `«roster-reconcile»()` when PRESENT, then the second `status --next`. Dispatch anything newly unblocked; otherwise end the iteration. @@ -164,3 +165,5 @@ When PRESENT, invoke `«roster-reconcile»()` before the inbound-message drain. - → **prose** (deterministic mechanism, binary pending — NOT judgment-owned), becomes `` `spacedock dispatch next-action` `` — no driver binary backs it yet (descoped to roadmap 0222); the FO hand-follows the deterministic skeleton above and does not probe for the unshipped command (runtime-support.md's `→ prose` trichotomy). Repeat the skeleton after each completion until the captain ends the session or, in single-entity mode, the target entity is resolved. + +**Fleet mode — round-robin across member workflows.** When the session adopted a member set (`references/fo-fleet.md`), wrap `«dispatch.next-action»()` in an outer round-robin over members: run one iteration scoped to each member's `{workflow_dir}` (every `status` / `dispatch` / `--set` call already carries `--workflow-dir`, so no command changes), dispatching whichever members have ready work. The iteration ends only when NO member is dispatchable. At that boundary the eager drain and the per-workflow `idle` hooks fire **once per member**, each keyed by that member's `$SLUG`, so every member's heartbeat refreshes and its own cursor drains (Bridge sees all members live, not one); the `«roster-reconcile»` sweep fires **once across the shared roster**. A single-member set is byte-identical to single-workflow mode. A per-member halt suspends only that member's slot; the round-robin keeps advancing the others. diff --git a/skills/first-officer/references/fo-fleet.md b/skills/first-officer/references/fo-fleet.md new file mode 100644 index 000000000..104412798 --- /dev/null +++ b/skills/first-officer/references/fo-fleet.md @@ -0,0 +1,12 @@ +# Fleet Mode + +Deferred operating contract for driving MULTIPLE commissioned workflows from ONE session — the dual of Single-Entity Scope (which narrows to one entity; this widens to every workflow). Loaded only on a quotable fleet directive; absent it, discovery resolves one workflow or presents the list (`«interaction.boundary»()`), unchanged. + +- **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. +- **Member set.** On the trigger, the member set is: the workflows the directive NAMES when it names any (each resolved against `${SPACEDOCK_BIN:-spacedock} status --discover` by slug or path — a named workflow that does not resolve is reported and skipped, never broad-searched for); otherwise EVERY discovered path. So "fleet mode: drive A, B, C" adopts exactly {A, B, C}, while a bare "drive the fleet" adopts all discovered. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero discovered → report-and-stop (never broad-search to widen the set). The interactive greet lists the resolved member set, so the captain confirms it before any dispatch. +- **Per-member identify + converge.** `«state.boot»()` folds every discovered member into its one boot record, so the greet names all members from a single call. Convergence is per member and deferred to «engage»: engaging a member runs its own `state ready` then `state sweep` before driving it. A rebase-conflict halt is per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. Members may carry independent split-root state checkouts. +- **Per-member boot liveness.** `«bridge.boot-liveness»()` runs once per member, keyed by that member's `$SLUG`: each member's `bridge-seam` startup hook writes its own `_bridge/fo..json` heartbeat (carrying the harness session id — see the runtime adapter's `## Bridge seam`) and runs its initial drain, 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. Headless: drive every member's dispatchables; "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` → "Fleet mode — round-robin"): the FO round-robins the per-entity iteration across members, each scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. +- **Captain intent routing.** The `bridge-seam` drain 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. Records carry a frozen `target_set`: act only when the member's `$SLUG` is in `target_set`, and append that member's ack to `_bridge/fo-replies.jsonl` with `target` set to the actual member slug, never `all`. Legacy records without `target_set`: `all` (or absent/empty) is drained by EVERY member's cursor, a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all 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}`. diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index cee3d2e10..be8b6e0be 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -20,3 +20,11 @@ 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 seam (liveness/activity egress + captain-intent ingress) + +Bridge observes this FO through `_bridge/` files per `docs/seam-contract.md` (local overview: `docs/dev/bridge-seam.md`). The FO writes the seam files directly per the `bridge-seam` mod — no inbox/alert/initiate verbs. + +- **Event egress — PACKAGED on Pi.** `package.json` advertises `.pi/extensions/spacedock.ts`, which forwards Pi lifecycle payloads to `spacedock bridge egress emit --host pi`; the emitter normalizes Pi-native names into the canonical event grammar before writing `_bridge/events.jsonl`. Deterministic `_bridge/sessions/` marker parity is not yet claimed on Pi. +- **Session-id binding.** The `bridge-seam` heartbeat stamps `session_id` from Pi's runtime session id when exposed; commonly empty today (still a valid liveness tick). Bind it once Pi exposes a stable per-session id. +- **Durable wake.** Pi has no Stop-block hook and no external resume today; a queued intent to an idle Pi FO shows as a queued count until the FO next drains. Prefer the daemon-managed path for Pi fleets. 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()) + } +} diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go new file mode 100644 index 000000000..716bfc84e --- /dev/null +++ b/skills/integration/bridge_session_link_test.go @@ -0,0 +1,206 @@ +// 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" + "strings" + "testing" +) + +// 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("Claude adapter script not found at %s: %v", hook, err) + } + 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() + 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 +// 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 + `"}}` +} + +// 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) + } +} + +// 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 for %s not written: %v", sid, err) + } + var m struct { + SessionID string `json:"session_id"` + Entity string `json:"entity"` + Workflow string `json:"workflow"` + } + 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") + 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. 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 + // 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")) + 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") + 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"). 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") + 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") + 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") + } +} 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..27d92c230 --- /dev/null +++ b/skills/integration/codex_bridge_egress_hook_test.go @@ -0,0 +1,117 @@ +// ABOUTME: Codex Bridge egress packaging tests — Codex must use its own non-async +// ABOUTME: hooks and call the shared Spacedock egress command without plugin-root state. +package integration + +import ( + "encoding/json" + "os" + "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 TestCodexBridgeHooksAreNonAsyncAndCallEgressDirectly(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 _, 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) + } + } + } + } + } +} + +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" +} diff --git a/skills/present-gate/SKILL.md b/skills/present-gate/SKILL.md index cbb341eaf..dda3aefa3 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -44,3 +44,26 @@ The template is the floor, not the ceiling. The FO MUST hold to the following di 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` from `«state.boot»()`, 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. 11. **Surface verification state as evidence, not as a label.** When the gate turns on checks that ran outside this presentation (CI lanes, a validation report), hold them to the shared core's self-evidence bar (`## Working Principles`): state which relevant checks actually ran and passed, and read any failure from this run's evidence (the failing test/assertion), never from an inherited "known flake" label. The captain votes on which checks are green and why a red is red. + +## 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. It is a **direct file append**, not a verb: add one line to `_bridge/fo-initiate.jsonl` (relative to the fleet root), using the `docs/seam-contract.md` §2.7 shape verbatim: + +```json +{"schema":1,"id":"gate--","ts":"","kind":"gate-review","workflow":"","entity":"","ship_id":"/","host":"","session_id":"","headline":"","body":"","status":"open"} +``` + +- `schema` MUST be `1` and `id` non-empty and `kind` `gate-review` — Bridge drops the line otherwise. +- `id` is the fold key AND (defaulting `request_id` to it) the loop-closure correlator. It MUST be a STABLE function of `(entity, stage)` — e.g. `gate--` — so re-emitting the same gate on each drain tick folds to ONE card instead of stacking duplicates. Never derive it from a timestamp or random value. +- `status` is ALWAYS `open`; Bridge overlays the resolution itself from the matching inbox `decision` intent (`approve`→approved, `reject`→rejected) by `request_id`. Do not write a resolved status. +- `host`/`session_id` carry attribution so the card shows which host raised the gate; pass the current host and session id. +- `headline` ≤ 240 chars (the gate lede); keep `body` ≤ 2000. + +### 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), 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` (a `decision` intent the FO drains per the `bridge-seam` mod); that is why the id must be stable and shared with the gate the captain sees.