diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3744e6225..e9d0d373a 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/.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-inbox.md b/docs/dev/_mods/bridge-inbox.md index 85f14f337..1d59a0533 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -1,90 +1,101 @@ --- name: bridge-inbox -description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl), routing per-workflow, and write the FO liveness heartbeat each tick -version: 0.2.0 +description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl), route per-workflow, acknowledge in _bridge/fo-replies.jsonl, and write the FO liveness heartbeat each tick +version: 0.3.0 fo-realm: "FO realm — the FO maintains this file directly; it is FO process (the seam to the Bridge UI), not product built under the dev workflow." --- # Bridge Inbox -[Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, and writes a per-workflow liveness heartbeat Bridge reads. +[Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, writes a per-workflow liveness heartbeat Bridge reads, and appends best-effort explanatory replies/acks to `_bridge/fo-replies.jsonl`. -**Path alignment (load-bearing).** Bridge anchors the inbox, heartbeat, and feed on its `--repo-root` flag, falling back to `--fleet` only when `--repo-root` is unset. So intent reaches this FO **only when the captain launches Bridge with `--repo-root` pointing at this FO's cwd** (the repo root). Under a multi-workflow layout (`--fleet /docs/spacedock` with no `--repo-root`), Bridge would write to `/docs/spacedock/_bridge/` while the FO reads `/_bridge/` — and intent silently never arrives. The dir is the same `_bridge/` Bridge resolves *iff* the two roots agree. +**The mechanism is packaged — do not hand-write it.** Cursor math, per-line routing, the liveness heartbeat, and the JSONL reply/ack serialization all live in the binary, reached through four verbs: + +- `spacedock bridge inbox drain --host «host» --slug «slug»` — stamps the heartbeat and returns the new records addressed to this workflow as JSON. It does **not** advance the cursor. +- `spacedock bridge inbox ack --host «host» --slug «slug» --line «n» --id «id» --ts «ts» --kind «intent-kind» --status «status» [--text … --granted … --entity … --field … --value … --verdict … --request-id …]` — appends one compact, newline-terminated reply/ack line (it derives the ack `kind` from the intent kind). +- `spacedock bridge inbox commit --slug «slug» --cursor «high_water»` — advances this workflow's cursor to the drained high-water mark (monotonic; a lower value is ignored). +- `spacedock bridge inbox check --host «host»` — the Claude `Stop`-hook decision helper (see **Wake**), not part of the manual loop. + +`«host»` is your runtime adapter's host token (`claude` / `codex` / `pi`); `«slug»` is this workflow's slug (below). The commands resolve the session id from the adapter's binding themselves, so no `$SLUG`/cursor/JSON shell is authored by hand. **Your only job is judgment:** read the records the drain hands back, *act* on each (interpret a `tell`, resolve a gate), then ack and commit. Do not reimplement drain/ack/commit with `wc`/`sed`/`cat`/`echo`/`jq` — hand-rolled shell across separate tool calls is exactly what corrupted cursors and wrote non-compact JSONL in the past. + +**Path alignment (load-bearing).** Bridge anchors the inbox, heartbeat, feed, and reply stream on its `--repo-root` flag, falling back to `--fleet` only when `--repo-root` is unset. So intent reaches this FO **only when the captain launches Bridge with `--repo-root` pointing at this FO's cwd** (the repo root). Under a multi-workflow layout (`--fleet /docs/spacedock` with no `--repo-root`), Bridge would write to `/docs/spacedock/_bridge/` while the FO reads `/_bridge/` — and intent silently never arrives. The dir is the same `_bridge/` Bridge resolves *iff* the two roots agree. **This is a pull, not a push.** Delivery latency is one FO loop cadence: the captain's intent is read whenever the FO next idles or boots, never instantly. Bridge's UI says as much ("queued — read on the FO's next tick"); do not promise synchronous delivery. -**Your workflow slug.** Several FOs (one per commissioned workflow) can share one repo root and one `_bridge/` dir; everything below is scoped to THIS FO's workflow by its slug. Derive the slug once and validate it — it names the per-workflow cursor and heartbeat files, and an unsafe value must never escape `_bridge/`: +**Wake (getting a parked FO to drain).** A busy FO drains eagerly each loop iteration and at idle, so intent reaches it within a tick. A **parked** FO — one that has stopped and is waiting at the prompt — is nudged differently per host. On Codex, Bridge resumes the session with `spacedock bridge ingress wake`. On Claude, external session resume is unsafe (the transcript has no write locking), so instead the packaged `Stop` hook runs `spacedock bridge inbox check` at every turn boundary: when intent is queued for this session's workflow it returns a `block` decision that keeps the FO going long enough to drain, entirely in-session. A fully idle/closed Claude session cannot be woken remotely; Bridge surfaces its queued count honestly and the captain nudges it. Never resume a live Claude session out-of-band to force a drain. -``` -SLUG=$(basename "{dir}") -case "$SLUG" in - ""|.|..) echo "bridge-inbox: empty/relative slug — skipping" >&2; exit 0 ;; - *[!A-Za-z0-9._-]*) echo "bridge-inbox: unsafe slug '$SLUG' — skipping" >&2; exit 0 ;; -esac -``` +**Your workflow slug.** Several FOs (one per commissioned workflow) can share one repo root and one `_bridge/` dir; everything below is scoped to THIS FO's workflow by its slug. The slug is the basename of this workflow's dir (`basename {dir}`) — it names the per-workflow cursor and heartbeat files. Pass it verbatim as `--slug` to each `spacedock bridge inbox` verb; the command validates it and refuses an unsafe value (one that could escape `_bridge/`) rather than acting on it, so you never guard it by hand. Below, `$SLUG` refers to that slug. **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"ts": "", "kind": "tell" | "conn" | "decision", "text": "", "granted": , "target": "" | "all", "entity": "", "field": "", "value": ""} +{"id":"","ts":"","kind":"tell"|"conn"|"decision"|"permission-decision","text":"","granted":,"target":""|"all","target_set":["", "..."],"entity":"","field":"","value":"","verdict":"approve|reject|redo, plain decision only","directives":["plain decision route-back notes"],"request_id":""} ``` -`target` routes the intent. Act on a record only when `target == "$SLUG"` **or** `target == "all"`; a **missing/empty `target` means `all`** (backward-compatible with older Bridge records, which carried no target). A record targeted at another workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. +`id` is Bridge's opaque unique id for this intent. Current Bridge records carry `target_set` when Bridge can freeze a recipient set: for `target == "all"` with known fleet members, Bridge writes the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. `target` remains for backward compatibility with older records and unknown-recipient broadcasts. -**Consume by a per-workflow cursor, never by rewrite.** Bridge only appends to the one shared `inbox.jsonl`; this mod advances only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed). Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and re-firing with no new lines is a no-op. +Routing is: -**Run each hook's steps in one shell.** Every Bash invocation is a fresh shell, so `$SLUG` (and `$CURSOR`) only persist within a single invocation. Begin each hook below by deriving and validating `$SLUG` (the block above), then run that hook's heartbeat and drain steps in the **same** shell — do not split the heartbeat and drain into separate invocations that each expect `$SLUG` to already be set, or the second runs with an empty slug and writes a stray `_bridge/fo..json` Bridge never reads. +- If `target_set` is present, act only when `"$SLUG"` is in `target_set`. Ignore `target` entirely for routing in that case, including `target == "all"`; the frozen `target_set` is authoritative. +- If `target_set` is absent, preserve old target behavior: act when `target == "$SLUG"`, `target == "all"`, or `target` is missing/empty. +- A record not addressed to this workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. + +**Consume by a per-workflow cursor, never by rewrite (the command owns it).** Bridge only appends to the one shared `inbox.jsonl`; the drain/commit verbs advance only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed), performing the one-time migration from the pre-versioning shared `_bridge/.inbox-cursor` for you. Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and a drain with no new lines is a no-op. Because the whole read/route/heartbeat step is one atomic command, there is no `$SLUG`/`$CURSOR` shell state to lose across tool calls. ## Hook: startup -1. Derive and validate `$SLUG` (above), in the shell you run the rest of this tick in. -2. Write the heartbeat (see **Heartbeat** below) so Bridge shows this workflow attached as soon as the FO boots. -3. Drain any intent the captain queued while no FO was attached, so a freshly-booted FO picks up standing instructions before its first dispatch — run the **Drain** procedure below. +1. Run the **Drain procedure** below with `--host «host» --slug $SLUG`. `drain` stamps the liveness heartbeat first (so Bridge shows this workflow attached as soon as the FO boots), then returns any intent the captain queued while no FO was attached, so a freshly-booted FO picks up standing instructions before its first dispatch. ## Hook: idle -Derive and validate `$SLUG` (above) in this tick's shell, then refresh the heartbeat and drain — all in the same shell. +Run the **Drain procedure** below with `--host «host» --slug $SLUG`. A single `drain` call both refreshes the heartbeat and returns new intent. ### Heartbeat -Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it treats this workflow as live only when the `ts` is **fresh** (within 30 minutes) and **not in the future**. Stamp a present-time UTC timestamp on every tick so an attached FO keeps showing live: +Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it treats this workflow as live only when the `ts` is **fresh** (within 30 minutes) and **not in the future**. `spacedock bridge inbox drain` stamps a present-time UTC timestamp (and the `host` and `session_id` from your adapter's binding) on every call, so an attached FO that drains each tick keeps showing live — you never write `fo.$SLUG.json` by hand. An empty session id (a host that exposes none) is still a valid liveness tick — Bridge reads freshness from `ts`. The full egress-surface contract — `events.jsonl`, this heartbeat, `fo-feed.jsonl`, `fo-replies.jsonl`, the session→entity marker, and which host produces each — is `docs/dev/bridge-egress-contract.md`. + +`state` is `idle`: the drain runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. The heartbeat is observe-only; a drain that cannot write it degrades to no-op and never blocks the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) + +### Drain procedure + +Drain newly-queued captain intent addressed to this workflow, if any: + +1. **Drain.** Run `spacedock bridge inbox drain --host «host» --slug $SLUG`. It stamps the heartbeat and returns JSON: `{"cursor":N,"high_water":M,"count":K,"records":[…]}`. The `records` are exactly the new lines addressed to this workflow (routing already applied), each carrying its physical `LINE` number, and already excluding any record you have acked before (idempotent replay-safe). If `count` is `0`, there is nothing to act on — you are done (the heartbeat is already refreshed). No `_bridge/inbox.jsonl` yet means no Bridge is attached; the command reports `no-inbox` and you skip. +2. **Act + ack, in order.** For each returned record, act on it (below), then acknowledge it with `spacedock bridge inbox ack --host «host» --slug $SLUG --line «line» --id «id» --ts «ts» --kind «kind» --status «status»` plus any relevant `--text`/`--granted`/`--entity`/`--field`/`--value`/`--verdict`/`--request-id`. The `ack` verb derives the reply `kind` from the intent `kind` and writes one compact line for you. Ack **after** you have interpreted, accepted, or applied the intent — not merely after shell-reading the line. The per-kind action and the `status` to use: + - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and append a `reply` record with `status:"answered"`. + - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). Then append a `conn-ack` record with `status:"accepted"`. `granted: false` → take the conn back: stop at every gate for the captain's call again. Then append a `conn-ack` record with `status:"released"`. + - **`kind == "decision"`** — the captain resolved a gate from Bridge. This is a captain decision, not FO self-approval, and Bridge has not advanced the entity. Resolve `entity` (its slug) in THIS workflow, verify the entity is still at a compatible current gate, and apply the workflow's normal gate-resolution flow: + - Self-described decision shape: `field` plus `value`. Treat it as the captain's selected gate value: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then continue the current gate exactly as if the captain had decided it in chat. + - Plain decision shape: `verdict` plus optional `directives`. `approve` advances through the gate's own approve path and side effects. `reject` / `redo` route to the gate's `feedback-to` stage with the supplied directives. If the workflow has external actions (GitHub review, Linear update, labels, etc.), perform them before terminal state or final acknowledgement. + - Append a `decision-ack` record only after the decision is applied, blocked, or rejected. Use `status:"applied"` when gate resolution finished or was already satisfied; `status:"blocked"` when the intent is valid but execution could not finish; `status:"rejected"` when the intent is invalid, stale, or unresolvable. If `entity` does not resolve in this workflow, acknowledge it as `rejected` rather than silently treating it like a mismatched target; this record was addressed to this workflow and Bridge needs to close the loop. + - **`kind == "permission-decision"`** — the captain resolved a top-level FO permission alert. Match `request_id` to the open `_bridge/fo-alerts.jsonl` record you emitted. `value: "deny"` means do not retry the blocked action; append a `permission-ack` with `status:"denied"` and report the block remains. `value: "approve-once"` means retry the exact blocked host action once using the runtime's escalation path. `value: "approve-rule"` means retry with the alert's proposed `prefix_rule` when one was present; otherwise treat it as `approve-once`. Append `permission-ack` with `status:"accepted"` before retrying; append `status:"blocked"` if the retry could not be started. This Bridge approval is FO intent, not a bypass of a host-native security prompt; if the host still presents a native approval dialog, honor it normally. +3. **Commit.** After acting on and acking every returned record, advance the cursor once with `spacedock bridge inbox commit --slug $SLUG --cursor «high_water»` (the `high_water` from step 1). This moves the cursor past everything read this tick — including records for other workflows the drain filtered out — so they are never reconsidered. Commit is monotonic and is the last step, so nothing is lost if the loop dies mid-drain. +4. Report to the captain: how many intents you drained (for this workflow) and what you did with each. + +If a record is malformed (not valid JSON, missing required fields for its `kind`, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record. Append a rejected ack only when the record is addressed to `"$SLUG"` and has enough valid metadata to produce a valid reply shape: an `id`, `ts`, and a known `kind` that determines `reply`/`conn-ack`/`decision-ack` plus `intent_kind`. Unknown-kind or unrouteable records cannot be represented by the reply schema; report the skip to the captain, advance the cursor, and do not invent an id or invalid ack kind. + +### Replies / acks + +`spacedock bridge inbox ack` appends one reply/ack to `_bridge/fo-replies.jsonl` for each addressed inbox record you handled or rejected. You never format this JSON by hand — you pass flat flags and the command serializes the record below: ``` -mkdir -p _bridge -printf '{"session_id":"%s","ts":"%s","state":"idle"}\n' \ - "${CLAUDE_CODE_SESSION_ID:-}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json +{"schema":1,"ts":"","kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"tell"|"conn"|"decision"|"permission-decision","status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked","text":"optional one-line note","granted":true|false,"entity":"...","field":"...","value":"...","verdict":"...","request_id":"...","session_id":"optional","host":"optional"} ``` -`state` is `idle`: this mod runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. Writing the heartbeat is observe-only; never let it block the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) +What the command guarantees (so you don't have to): -### Drain - -Drain newly-queued captain intent addressed to this workflow, if any: +- `target` is the actual acknowledging workflow slug (`$SLUG`), never `"all"` — pass `--slug $SLUG`. +- `in_reply_to_line` is the physical inbox line number you processed (`--line`), not the count of addressed records. +- `in_reply_to_id` and `in_reply_to_ts` echo the original inbox `id` and `ts` (`--id`, `--ts`). +- `intent_kind` echoes the inbox `kind` (`--kind`); the command maps it to the reply `kind`. +- `text` is optional (`--text`) and is flattened to a single line for you. +- Pass `--granted`, `--entity`, `--field`, `--value`, `--verdict`, and `--request-id` when present and relevant; omitted flags are omitted from the record. +- The command resolves `session_id` from the adapter's binding and stamps `host`; you do not supply them. +- It writes one complete newline-terminated JSON object in one append operation. Do not rewrite, truncate, sort, or compact `fo-replies.jsonl` yourself. +- Cursor remains the delivery/read source of truth; `fo-replies.jsonl` is best-effort explanatory ack content. A failed ack must never block the FO from completing the drained intent or committing the cursor after action. +- Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. Still prefer idempotent behavior. -1. If `_bridge/inbox.jsonl` does not exist, skip — no Bridge is attached. -2. Adopt the old shared cursor on first run (one-time migration: before this version the mod used a single shared `_bridge/.inbox-cursor`; seed from it so you do not re-drain — and re-apply — intent already processed, e.g. an old `conn` grant), then read this workflow's cursor: - ``` - if [ ! -f _bridge/.inbox-cursor.$SLUG ] && [ -f _bridge/.inbox-cursor ]; then - cp _bridge/.inbox-cursor _bridge/.inbox-cursor.$SLUG - fi - CURSOR=$(cat _bridge/.inbox-cursor.$SLUG 2>/dev/null || echo 0) - ``` -3. Snapshot the current line count and read exactly the new records (bounding the read so a concurrent Bridge append can't make the cursor skip a line): - ``` - NEW=$(wc -l < _bridge/inbox.jsonl | tr -d ' ') - sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl - ``` - If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, parse `kind` / `text` / `granted` / `target` (and `entity` / `field` / `value` on a `decision` record). **Check the target first:** if `target` is present and is neither `"$SLUG"` nor `"all"`, this record is for another workflow's FO — skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise (target is `"$SLUG"`, `"all"`, or absent) act: - - **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and acknowledge it to the captain. - - **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). `granted: false` → take the conn back: stop at every gate for the captain's call again. - - **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} =`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. If `entity` does not resolve in this workflow (it belongs to another member's slug), skip it like a mismatched target. Acknowledge to the captain which entity you resolved and how. -5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`. -6. Report to the captain: how many intents you drained (for this workflow) and what you did with each. - -If a record is malformed (not valid JSON, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record. - -**Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (step 5 follows step 4), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acting and writing the cursor re-delivers that batch on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed above, which guards against re-applying the *entire* pre-versioning history.) +**Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (commit is the last step, after act + ack), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acking and committing re-surfaces at worst nothing (the drain filters already-acked records) and between acting and acking re-delivers that record on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed the command applies, which guards against re-applying the *entire* pre-versioning history.) ## Feed diff --git a/docs/dev/bridge-egress-contract.md b/docs/dev/bridge-egress-contract.md new file mode 100644 index 000000000..ebd8cb810 --- /dev/null +++ b/docs/dev/bridge-egress-contract.md @@ -0,0 +1,134 @@ +# Bridge egress contract (FO liveness/replies → Bridge) + +The Bridge seam is two flows over the shared `_bridge/` dir (resolved from the repo root where the FO launched). They run in opposite directions and are documented apart: + +- **Ingress** — captain intent → FO — is the `bridge-inbox` mod (`docs/dev/_mods/bridge-inbox.md`). It drains `_bridge/inbox.jsonl` on the FO's own loop ticks; it rides the portable Spacedock mod-hook loop and is already host-neutral. Bridge writes every new intent with an opaque `id` and writes a frozen `target_set` array when it can resolve the workflow slugs expected to drain and acknowledge the intent. For `target == "all"` with known fleet members, Bridge expands the current fleet member slugs into `target_set`; for a specific target, Bridge writes `[slug]`. If Bridge cannot resolve any current member slugs for a broadcast, it omits `target_set` so the FO preserves legacy `target == "all"` routing. When `target_set` is present it is authoritative, and the legacy `target` field is ignored for routing; `target` remains only for backward compatibility with records that predate `target_set` and unknown-recipient broadcasts. +- **Egress** — FO liveness/activity/replies → Bridge — is *this* contract: the `_bridge/` files a running FO (and its ensigns) write for the external Bridge command-center UI to tail. Bridge reads what already exists; it is not a source of truth. + +**The schema is Spacedock-owned and harness-neutral. The *producer* of the host-event files (`events.jsonl` + the session marker) is per-host** — the heartbeat, feed, and FO reply stream are written host-neutrally by the `bridge-inbox` mod. The per-host producers are bound the same way runtime lifecycle capabilities are (see `fo-dispatch-core.md`'s "Claude: PRESENT / Codex: ABSENT / Pi: …" idiom): the shared contract names the file shape; the host adapter (`skills/first-officer/references/{claude,codex,pi}-first-officer-runtime.md`, under their `## Bridge egress` section) owns the concrete mechanism that emits it. The current implementation uses the shared `spacedock bridge egress emit --host ` command for host normalization. Claude, Codex, and Pi have packaged event producers; deterministic session→entity marker parity is proven only for Claude so far. + +All files are gitignored session runtime (`.gitignore` carries `_bridge/`), append-only or last-write where noted, and strictly observe-only: a telemetry side-channel must never block, fail, or alter the FO. + +## Session-id binding `«session-id»` + +Two of the surfaces stamp the FO/ensign session id. The neutral shell token is **`SD_SESSION_ID`**, owned by the host adapter's `## Bridge egress` binding. The bridge-inbox heartbeat reads it neutral-first with a built-in per-host fallback, so it never silently blanks and needs no per-tick `export`: + +``` +"${SD_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CODEX_THREAD_ID:-}}}" +``` + +- → **Claude:** resolves to `$CLAUDE_CODE_SESSION_ID` (set by Claude Code in the session). · **Codex:** resolves to `$CODEX_THREAD_ID` when the host exposes it to the FO shell. · **Pi:** ABSENT → empty. + +`SD_SESSION_ID` is honored by the **heartbeat** producer only. The Claude event producer stamps `events.jsonl` and the session marker from its hook payload's `.session_id`, *not* from `SD_SESSION_ID` — so on Claude leave `SD_SESSION_ID` unset (the fallback already yields `$CLAUDE_CODE_SESSION_ID`, the same id the payload carries). Setting `SD_SESSION_ID` to a *different* value would desync the heartbeat from the event stream and break the join the contract exists to enable; an explicit override is only safe when it equals the host's own session id. + +An empty value is honest, not a bug: the heartbeat is still a valid liveness tick (Bridge reads freshness from `ts`); only the join between this heartbeat and the event stream is unavailable. + +## `_bridge/events.jsonl` — FO/ensign activity stream + +One JSON object per line, appended on each lifecycle event. Liveness, not content: no tool inputs/outputs, no prompt text. + +``` +{"timestamp":"","ts":"","host":"","event":"","session_id":"<«session-id»>","agent_id":"","agent_type":"","actor_id":"","detail":{"tool":"","source":""}} +``` + +- `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge tells FO-vs-ensign activity apart. +- `event` is normalized by `spacedock bridge egress emit` into Bridge's canonical grammar before it reaches `events.jsonl`: `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Notification`, `Stop`, `SubagentStart`, or `SubagentStop`. Host-native names must not leak into this file; for example Pi `session_shutdown`/`turn_end` normalize to `Stop`, `agent_end` normalizes to `SubagentStop`, and `tool_execution_end` normalizes to `PostToolUse`. +- Best-effort size cap: the producer trims to the most recent lines past a bound (it is liveness, not a ledger; a few lost lines to a concurrent trim are acceptable). +- → **Claude:** PRESENT — `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` for Claude lifecycle events (all async). The wrapper delegates to the shared `spacedock bridge egress emit --host claude` command so Bridge never couples to Claude's internal transcript format. +- → **Codex:** PACKAGED/FIXTURE-COVERED — `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. The hook does not depend on plugin-root environment variables, so installed cache layout and dev checkout layout are both valid. This proves packaging and minimal lifecycle payload handling, not live marker parity. +- → **Pi:** PACKAGED/SOURCE-COVERED — `package.json` advertises `.pi/extensions/spacedock.ts`, and the Pi extension forwards lifecycle event payloads to `spacedock bridge egress emit --host pi`. Local tests cover registration/source wiring and package discovery, not a live Pi run. + +## `_bridge/fo.$SLUG.json` — per-workflow liveness heartbeat + +Last-write (one object, not a stream), one file per workflow slug. Bridge treats the workflow as live only when `ts` is fresh (within 30 minutes) and not in the future. + +``` +{"session_id":"<«session-id»>","ts":"","state":"idle"} +``` + +- `state` is `idle`: the producer runs at startup/idle boundaries (between dispatches), so it cannot honestly claim `working` — the finer working/idle signal lives in `events.jsonl`. +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod writes this each startup/idle tick (it rides the portable hook loop). The only per-host part is `«session-id»` — see the binding above. The heartbeat still attaches the FO on every host. + +## `_bridge/fo-feed.jsonl` — fleet-history narration + +One JSON object per line, appended when the FO dispatches, advances, or completes an entity. Drives Bridge's fleet-history rail for a local-only workflow (entities gitignored, so the `dispatch:`/`advance:` git narration is empty). + +``` +{"ts":"","verb":"","entity":"","workflow":"<$SLUG>","stage":"","text":""} +``` + +- `verb`: `dispatch` (sent an ensign to a stage), `advance` (moved an entity to its next stage), `complete` (entity reached terminal). +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod's feed step appends it. No per-host binding — it is FO-loop narration, not a host event. + +## `_bridge/fo-replies.jsonl` — captain-intent acknowledgements + +One JSON object per line, appended after the FO has interpreted, accepted, or applied an inbox intent. This is the explanatory reply/ack stream for Bridge's conversation loop; the per-workflow inbox cursor remains the delivery/read source of truth. Duplicate replies are allowed under at-least-once replay; Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind. The stream is best-effort explanatory content, not an exactly-once delivery ledger. + +``` +{"schema":1,"ts":"","kind":"","target":"","in_reply_to_id":"","in_reply_to_line":123,"in_reply_to_ts":"","intent_kind":"","status":"","text":"optional one-line note","granted":true,"entity":"...","field":"...","value":"...","verdict":"...","request_id":"...","session_id":"optional","host":"optional"} +``` + +- `target` is the actual acknowledging workflow slug, never `all`. +- `in_reply_to_line` is the physical line number in `_bridge/inbox.jsonl` that produced the ack. +- `kind`: `reply` for `tell`, `conn-ack` for `conn`, `decision-ack` for `decision`, `permission-ack` for `permission-decision`. +- `status`: `answered` for a handled `tell`; `accepted` when the FO adopts a conn grant or accepts a permission retry; `released` when the FO gives the conn back; `applied` when a decision is present and gate resolution finished or was already satisfied; `denied` when the captain rejects a permission request; `blocked` when a valid intent could not finish; `rejected` when an intent is invalid or unresolvable. +- Echo `granted`, `entity`, `field`, `value`, `verdict`, and `request_id` when present and relevant to the intent. Keep `text` one line. +- Append-only and best-effort: write one complete newline-terminated JSON object in one append operation, and never rewrite `fo-replies.jsonl`. +- → **All hosts (host-neutral producer):** the `bridge-inbox` mod appends replies/acks while draining `_bridge/inbox.jsonl`. + +## `_bridge/fo-alerts.jsonl` — top-level FO alerts + +One JSON object per line, appended when the FO is blocked by a captain-owned host decision that should surface above ordinary chat replies. The first producer is the sandbox permission path: when a command cannot proceed because the host needs approval, the FO writes an open permission request before waiting for a decision. + +``` +{"schema":1,"id":"perm_","ts":"","kind":"permission-request","severity":"blocked","workflow":"","entity":"","host":"","session_id":"<«session-id»>","reason":"","command":"","prefix_rule":["git","-C"],"status":"open"} +``` + +- `id` is the stable join key for the captain's response. +- `workflow` and `entity` scope the alert for fleet UI routing; `entity` may be empty when the block happens before an entity is selected. +- `command` is a concise command summary, not a secret-bearing shell transcript. +- `prefix_rule` is optional and present only when the FO has a narrowly scoped reusable approval to propose. +- Bridge overlays a later inbox `permission-decision` record for the same `request_id` to show approved/denied state; the alert file itself remains append-only. +- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge alert permission ...`. The helper returns `{"id":"...","request_id":"...","queued":true}` and appends one line to `_bridge/fo-alerts.jsonl`. If the helper cannot queue the alert, it still exits without blocking the FO command and returns `{"queued":false,"error":"..."}`. + +## `_bridge/fo-initiate.jsonl` — FO-authored feed lines and decidable gates + +One JSON object per line, appended when the FO originates a message to the captain that is not a reply to a captain intent: a status note, a recommendation, or a decidable `gate-review`. Bridge reads it, folds records by `id` (keeping the latest per id), and renders each as an FO-authored feed line — a `gate-review` gets an inline Approve/Reject affordance. + +``` +{"schema":1,"id":"","ts":"","kind":"","workflow":"","entity":"","ship_id":"/","host":"","session_id":"<«session-id»>","headline":"","body":"optional supporting prose","request_id":"","status":"open"} +``` + +- `id` is REQUIRED and is the fold key. Unlike `fo-alerts.jsonl` there is NO random fallback: idempotency depends on a stable caller-supplied id. The FO re-emits the same record each drain tick; Bridge collapses them to one card by `id`. For `gate-review`, `present-gate` derives `id`/`request_id` deterministically from `(entity, stage)` so re-emit is byte-stable. +- `kind` ∈ `status | reco | gate-review`. `status`/`reco` are plain FO-authored lines; `gate-review` is a decidable card. +- `headline` is REQUIRED, bounded to 240 chars; `body` is optional, bounded to 2000 chars. Both are collapsed to one line (control chars and whitespace runs become single spaces). +- `request_id` is the gate loop-closure correlator; it defaults to `id` for `gate-review` and is omitted for `status`/`reco`. Approve/Reject write a decision intent to `inbox.jsonl` carrying this `request_id`; Bridge's fo-initiate reader overlays that decision to flip the card's status. +- `status`: the writer ALWAYS writes `open`. The READER overlays `resolved`/`approved`/`rejected` from decision intents — never trust a written non-open status. +- **Path anchoring:** the writer resolves `filepath.Abs(--repo-root or cwd)/_bridge` — EXACTLY like `bridgealert.AppendPermission`, NOT `bridgeegress.canonicalBridgeRoot`. It MUST be passed the same repo root Bridge resolves from, or the write lands in a divergent `_bridge/`. +- **Bounded, but open gates never evicted:** the file is capped (the writer truncates to a recent-tail window on each append), but it NEVER drops the latest record of a still-open `gate-review` id, so an open gate cannot scroll out of the read window. +- **Channel boundary:** a decidable gate lives in `fo-initiate.jsonl` ONLY — never `fo-feed.jsonl` (ambient git narration) and never `fo-replies.jsonl` (which requires an `in_reply_to` correlator and would silently drop an uncorrelated FO-originated push). +- → **All hosts (host-neutral producer):** the FO writes this through `spacedock bridge initiate --kind ...`. The helper returns `{"id":"...","request_id":"...","queued":true}` and appends one line. If it cannot queue, it exits without blocking the FO and returns `{"queued":false,"error":"..."}`. Gate emission is wired in the `present-gate` skill (loaded by every host), not in a per-host hook. + +## `_bridge/sessions/.json` — session→entity marker (RUNNING-badge source) + +First-write-wins per host actor, one file per live working actor. The filename is the normalized `actor_id`: Claude main/ensign markers currently use the session id, while hosts that provide child ids can use a host-scoped composite such as `session_id.agent_id`. The marker maps that actor to the ship it is driving, so Bridge can render the deterministic live FO-vs-ensign RUNNING badge. + +``` +{"host":"","session_id":"<«session-id»>","agent_id":"","actor_id":"","entity":"","workflow":""} +``` + +- The `entity`/`workflow` pair is derived from the ensign's first Read of its entity file under `docs/spacedock//.md` (flat or `/index.md`); the path carries both the workflow (so Bridge's join is collision-free across workflows reusing a ticket id) and the slug. First-write-wins records the ensign's own entity, read before any duplicate-check sibling read. Archived (`_archive/`) entities are never marked. +- → **Claude:** PRESENT — derived by the shared emitter from the Claude `PostToolUse`/`Read` hook payload (which fires on every tool call). +- → **Codex/Pi:** NOT CLAIMED — the shared emitter can write a marker from an explicit normalized `entity_path`, but the packaged Codex/Pi producers do not yet have durable live proof that they can supply the child actor and entity path reliably. Until that proof exists, Bridge must treat their event streams as activity-only and omit deterministic per-ship RUNNING markers. + +## Decision: deterministic RUNNING badge is Claude-proven only (for now) + +The session→entity marker is the **only** deterministic source for the live FO-vs-ensign RUNNING badge, and the live derivation is **Claude-proven only for now**. It relies on a hook that fires on *every* tool call (Claude Code `PostToolUse`) to record the marker on the ensign's first entity Read. The prose-driven alternative — having the ensign run a first-action shell to write its own marker — is unreliable: the ensign skips that step roughly three times out of four. Deriving it in the hook is what makes the badge dependable. Codex and Pi now have event producers, but their marker path stays unclaimed until their adapters prove durable child identity plus entity path evidence. + +**On non-Claude hosts Bridge degrades gracefully, it does not break:** + +- The heartbeat (`fo.$SLUG.json`) is host-neutral, so the FO still shows **attached**. +- Git `dispatch:`/`advance:` narration and `fo-feed.jsonl` (both host-neutral) still drive the **fleet-history** rail. +- Only the live per-ship **RUNNING badge** (FO-vs-ensign, this very moment) is absent unless the host has written a session marker — Bridge simply does not render it, rather than showing a wrong one. + +When Codex or Pi can prove durable child identity plus entity path evidence, bind the session marker in that host's adapter (`## Bridge egress`) against this same schema; the badge lights up with no Bridge change. diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json new file mode 100644 index 000000000..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 index 280485146..48b93e977 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -13,7 +13,8 @@ { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } ], "Stop": [ - { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] }, + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-inbox-check.sh" } ] } ], "SubagentStop": [ { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } diff --git a/internal/bridgealert/alert.go b/internal/bridgealert/alert.go new file mode 100644 index 000000000..443e500e2 --- /dev/null +++ b/internal/bridgealert/alert.go @@ -0,0 +1,170 @@ +// ABOUTME: FO-to-Bridge alert writer for top-level captain interrupts. +// ABOUTME: Append-only and best-effort; Bridge reads the resulting JSONL. +package bridgealert + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + maxReasonLen = 240 + maxCommandLen = 500 +) + +// PermissionAlert is the stable JSONL shape Bridge reads from +// _bridge/fo-alerts.jsonl. +type PermissionAlert struct { + Schema int `json:"schema"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Workflow string `json:"workflow,omitempty"` + Entity string `json:"entity,omitempty"` + Host string `json:"host,omitempty"` + SessionID string `json:"session_id,omitempty"` + Reason string `json:"reason"` + Command string `json:"command,omitempty"` + PrefixRule []string `json:"prefix_rule,omitempty"` + Status string `json:"status"` +} + +// PermissionOptions controls one permission-alert append. +type PermissionOptions struct { + Root string + Now func() time.Time + ID string + Workflow string + Entity string + Host string + SessionID string + Reason string + Command string + PrefixRule []string +} + +type Result struct { + ID string `json:"id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Queued bool `json:"queued"` + Error string `json:"error,omitempty"` +} + +// AppendPermission writes one open permission alert under root/_bridge. +func AppendPermission(opts PermissionOptions) (Result, error) { + id := strings.TrimSpace(opts.ID) + if id == "" { + id = fallbackID() + if generated, err := newID(); err == nil { + id = generated + } + } + result := Result{ID: id, RequestID: id} + root := opts.Root + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return result.withError(err.Error()), nil + } + reason := oneLineSummary(opts.Reason, maxReasonLen) + if reason == "" { + return result.withError("permission alert: reason is required"), nil + } + command := oneLineSummary(opts.Command, maxCommandLen) + now := time.Now().UTC + if opts.Now != nil { + now = func() time.Time { return opts.Now().UTC() } + } + alert := PermissionAlert{ + Schema: 1, + ID: id, + TS: now().Format(time.RFC3339), + Kind: "permission-request", + Severity: "blocked", + Workflow: strings.TrimSpace(opts.Workflow), + Entity: strings.TrimSpace(opts.Entity), + Host: strings.TrimSpace(opts.Host), + SessionID: strings.TrimSpace(opts.SessionID), + Reason: reason, + Command: command, + PrefixRule: cleanedPrefixRule(opts.PrefixRule), + Status: "open", + } + dir := filepath.Join(absRoot, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return result.withError(err.Error()), nil + } + f, err := os.OpenFile(filepath.Join(dir, "fo-alerts.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return result.withError(err.Error()), nil + } + defer func() { _ = f.Close() }() + data, err := json.Marshal(alert) + if err != nil { + return result.withError(err.Error()), nil + } + if _, err := f.Write(append(data, '\n')); err != nil { + return result.withError(err.Error()), nil + } + result.Queued = true + return result, nil +} + +func (r Result) withError(msg string) Result { + r.Queued = false + r.Error = msg + return r +} + +func oneLineSummary(in string, maxLen int) string { + in = strings.TrimSpace(in) + var b strings.Builder + prevSpace := false + for _, r := range in { + if unicode.IsControl(r) || unicode.IsSpace(r) { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + if b.Len() >= maxLen { + break + } + } + return strings.TrimSpace(b.String()) +} + +func cleanedPrefixRule(in []string) []string { + out := make([]string, 0, len(in)) + for _, part := range in { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func newID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "perm_" + hex.EncodeToString(b[:]), nil +} + +func fallbackID() string { + return "perm_" + hex.EncodeToString([]byte(time.Now().UTC().Format("20060102150405.000000000"))) +} diff --git a/internal/bridgealert/alert_test.go b/internal/bridgealert/alert_test.go new file mode 100644 index 000000000..9fb489f7b --- /dev/null +++ b/internal/bridgealert/alert_test.go @@ -0,0 +1,107 @@ +package bridgealert + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAppendPermissionWritesAlert(t *testing.T) { + root := t.TempDir() + got, err := AppendPermission(PermissionOptions{ + Root: root, + Now: func() time.Time { return time.Date(2026, 7, 2, 5, 0, 0, 0, time.UTC) }, + ID: "perm-1", + Workflow: "pr-review-queue", + Entity: "datarecce-recce-pr-1", + Host: "codex", + SessionID: "s1", + Reason: "sandbox blocked state gitdir", + Command: "git status", + PrefixRule: []string{"git", "-C"}, + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "perm-1" || !got.Queued { + t.Fatalf("result = %+v", got) + } + if got.RequestID != "perm-1" { + t.Fatalf("request id = %q, want alias", got.RequestID) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + var alert PermissionAlert + if err := json.Unmarshal(bytes.TrimSpace(data), &alert); err != nil { + t.Fatalf("alert JSON: %v\n%s", err, data) + } + if alert.Kind != "permission-request" || alert.Severity != "blocked" || alert.Status != "open" { + t.Fatalf("alert = %+v", alert) + } + if alert.TS != "2026-07-02T05:00:00Z" || alert.Workflow != "pr-review-queue" || alert.Command != "git status" { + t.Fatalf("alert fields = %+v", alert) + } + if len(alert.PrefixRule) != 2 || alert.PrefixRule[0] != "git" { + t.Fatalf("prefix rule = %+v", alert.PrefixRule) + } +} + +func TestAppendPermissionMissingReasonIsNonBlocking(t *testing.T) { + got, err := AppendPermission(PermissionOptions{Root: t.TempDir(), ID: "perm-1"}) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendPermissionWriteFailureIsNonBlocking(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "_bridge"), []byte("not a dir"), 0o600); err != nil { + t.Fatal(err) + } + got, err := AppendPermission(PermissionOptions{Root: root, ID: "perm-1", Reason: "sandbox blocked"}) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendPermissionNormalizesOneLineSummaries(t *testing.T) { + root := t.TempDir() + got, err := AppendPermission(PermissionOptions{ + Root: root, + ID: "perm-1", + Reason: "sandbox\nblocked\tstate gitdir", + Command: "git status\n" + strings.Repeat("x", 800), + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v, want queued", got) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + var alert PermissionAlert + if err := json.Unmarshal(bytes.TrimSpace(data), &alert); err != nil { + t.Fatal(err) + } + if alert.Reason != "sandbox blocked state gitdir" { + t.Fatalf("reason = %q", alert.Reason) + } + if strings.ContainsAny(alert.Command, "\n\t") || len(alert.Command) > maxCommandLen { + t.Fatalf("command = %q len=%d", alert.Command, len(alert.Command)) + } +} diff --git a/internal/bridgeegress/egress.go b/internal/bridgeegress/egress.go new file mode 100644 index 000000000..82e6c0072 --- /dev/null +++ b/internal/bridgeegress/egress.go @@ -0,0 +1,527 @@ +// 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 { + if p.Timestamp != "" { + return p.Timestamp + } + if p.TS != "" { + return p.TS + } + now := time.Now + if opts.Now != nil { + now = opts.Now + } + return now().UTC().Format(time.RFC3339) +} + +func 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..b6dec0812 --- /dev/null +++ b/internal/bridgeegress/egress_test.go @@ -0,0 +1,382 @@ +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) + } +} + +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_test.go b/internal/bridgeingress/check_test.go new file mode 100644 index 000000000..eb34b22fa --- /dev/null +++ b/internal/bridgeingress/check_test.go @@ -0,0 +1,76 @@ +package bridgeingress + +import ( + "strings" + "testing" + "time" +) + +func TestCheckBlocksWhenIntentQueuedForSession(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"hi","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a"}) + if d.Decision != "block" { + t.Fatalf("decision = %q, want block", d.Decision) + } + for _, want := range []string{"alpha", "spacedock bridge inbox drain", "1 queued"} { + if !strings.Contains(d.Reason, want) { + t.Fatalf("reason missing %q:\n%s", want, d.Reason) + } + } +} + +func TestCheckEmptyWhenStopHookActive(t *testing.T) { + root := t.TempDir() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", time.Now().UTC()) + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a", StopHookActive: true}) + if d.Decision != "" { + t.Fatalf("stop_hook_active must not block again, got %+v", d) + } +} + +func TestCheckEmptyWhenSessionUnknown(t *testing.T) { + root := t.TempDir() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", time.Now().UTC()) + // A different session id must not block for a sibling FO's intent. + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-OTHER"}) + if d.Decision != "" { + t.Fatalf("unknown session must not block, got %+v", d) + } +} + +func TestCheckEmptyWhenNothingPending(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + writeInboxCursor(t, root, "alpha", "1") // already drained + d := Check(CheckOptions{Host: "claude", Root: root, SessionID: "sess-a"}) + if d.Decision != "" { + t.Fatalf("no pending intent must not block, got %+v", d) + } +} + +func TestCheckFromReaderParsesStopPayload(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + payload := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":false,"hook_event_name":"Stop"}` + d := CheckFromReader(strings.NewReader(payload), CheckOptions{Host: "claude"}) + if d.Decision != "block" { + t.Fatalf("reader path should block, got %+v", d) + } + + // stop_hook_active in the payload suppresses a re-block. + payload2 := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":true}` + if d2 := CheckFromReader(strings.NewReader(payload2), CheckOptions{Host: "claude"}); d2.Decision != "" { + t.Fatalf("payload stop_hook_active must suppress block, got %+v", d2) + } +} diff --git a/internal/bridgeingress/drain.go b/internal/bridgeingress/drain.go new file mode 100644 index 000000000..c5950816f --- /dev/null +++ b/internal/bridgeingress/drain.go @@ -0,0 +1,775 @@ +// ABOUTME: Deterministic Bridge inbox drain/ack/commit/check for FO sessions. +// ABOUTME: Moves cursor math, routing, heartbeat, and ack serialization out of FO prose into the binary. +package bridgeingress + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// The drain family packages the mechanism the bridge-inbox mod used to make the +// FO hand-execute as raw shell each tick: cursor read/advance, per-line routing, +// heartbeat stamping, and reply/ack serialization. The FO now calls these verbs +// and keeps only the judgment (interpreting a tell, resolving a gate). This is +// host-neutral; --host only selects which session-id token the heartbeat carries. + +// DrainOptions controls one inbox drain (peek) pass. +type DrainOptions struct { + Host string + Root string + Slug string + SessionID string + Members []string + Now func() time.Time +} + +// DrainRecord is one addressed, not-yet-acked inbox record handed back to the FO. +type DrainRecord struct { + Line int `json:"line"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + Granted *bool `json:"granted,omitempty"` + Target string `json:"target,omitempty"` + TargetSet []string `json:"target_set,omitempty"` + Entity string `json:"entity,omitempty"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` + Verdict string `json:"verdict,omitempty"` + Directives []string `json:"directives,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// DrainResult is the JSON the FO reads after a drain. It never advances the +// cursor: the FO acts on records, acks them, then commits the high-water mark. +type DrainResult struct { + Status string `json:"status"` + Slug string `json:"slug"` + Host string `json:"host,omitempty"` + Cursor int `json:"cursor"` + HighWater int `json:"high_water"` + Count int `json:"count"` + Heartbeat bool `json:"heartbeat"` + Records []DrainRecord `json:"records"` + Error string `json:"error,omitempty"` +} + +type fullInboxRecord struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + RawTS string `json:"-"` + Kind string `json:"kind"` + Text string `json:"text"` + Granted *bool `json:"granted"` + Target string `json:"target"` + TargetSet []string `json:"target_set"` + Entity string `json:"entity"` + Field string `json:"field"` + Value string `json:"value"` + Verdict string `json:"verdict"` + Directives []string `json:"directives"` + RequestID string `json:"request_id"` + Line int `json:"-"` +} + +func (r fullInboxRecord) routing() inboxRecord { + return inboxRecord{ID: r.ID, TS: r.TS, Kind: r.Kind, Target: r.Target, TargetSet: r.TargetSet, Line: r.Line} +} + +// Drain reads newly-queued captain intent addressed to the given workflow slug, +// stamps the liveness heartbeat, and returns the records without advancing the +// cursor. It is idempotent: records already acked (per fo-replies.jsonl) are +// filtered out even if the cursor has not yet advanced past them. +func Drain(opts DrainOptions) DrainResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return DrainResult{Status: "failed", Slug: slug, Error: "invalid or missing --slug"} + } + host := normalizeHost(opts.Host) + root := absRootOr(opts.Root) + now := nowFunc(opts.Now) + + // Heartbeat is observe-only liveness and must be written on every tick, even + // when no Bridge is attached or the inbox is empty. + hbWritten := stampHeartbeat(root, slug, resolveSessionID(host, opts.SessionID), host, now()) + + res := DrainResult{Status: "ok", Slug: slug, Host: host, Heartbeat: hbWritten, Records: []DrainRecord{}} + + inboxPath := filepath.Join(root, "_bridge", "inbox.jsonl") + if _, err := os.Stat(inboxPath); err != nil { + res.Status = "no-inbox" + return res + } + + adoptCursor(root, slug) + cursor := inboxCursor(root, slug) + records, highWater, err := readInboxFull(inboxPath) + if err != nil { + return DrainResult{Status: "failed", Slug: slug, Host: host, Heartbeat: hbWritten, Error: err.Error(), Records: []DrainRecord{}} + } + res.Cursor = cursor + res.HighWater = highWater + + replies := loadReplies(root) + members := opts.Members + if len(members) == 0 { + members = []string{slug} + } + for _, rec := range records { + if rec.Line <= cursor { + continue + } + if !addressedTo(root, rec.routing(), slug, members) { + continue + } + if replies[replyKey(rec.routing(), slug)] { + continue + } + res.Records = append(res.Records, toDrainRecord(rec)) + } + res.Count = len(res.Records) + + // Auto-emit the interim "acting" ack for each freshly-drained addressed record + // so the command-ack lifecycle advances received->acting mechanically, owned by + // the binary rather than FO prose. Each record here has no prior reply (loadReplies + // already filtered acked records out above), so no duplicate acting ack can stack. + // Best-effort: a failure to write the interim ack must not fail the drain itself. + sessionID := resolveSessionID(host, opts.SessionID) + for _, dr := range res.Records { + ackKind, ok := ackKindFor(dr.Kind) + if !ok { + continue + } + _ = appendReply(root, replyOut{ + Schema: 1, + TS: now().UTC().Format(time.RFC3339), + Kind: ackKind, + Target: slug, + InReplyToID: dr.ID, + InReplyToLine: dr.Line, + InReplyToTS: dr.TS, + IntentKind: dr.Kind, + Status: ackStatusActing, + RequestID: dr.RequestID, + SessionID: sessionID, + Host: host, + }) + } + return res +} + +// ackStatusActing is the interim command-ack lifecycle status the drain verb +// auto-appends on receipt (received->acting). Terminal statuses are FO-authored. +const ackStatusActing = "acting" + +// addressedTo reports whether an inbox record routes to this workflow slug, +// honoring an authoritative frozen target_set over the legacy target field. +func addressedTo(root string, rec inboxRecord, slug string, members []string) bool { + for _, t := range targetsFor(root, rec, members) { + if t == slug { + return true + } + } + return false +} + +// AckOptions controls one reply/ack append. +type AckOptions struct { + Host string + Root string + Slug string + Line int + ID string + TS string + IntentKind string + Status string + Text string + Granted *bool + Entity string + Field string + Value string + Verdict string + RequestID string + SessionID string + Now func() time.Time +} + +// AckResult is the JSON printed after an ack append. +type AckResult struct { + Appended bool `json:"appended"` + Kind string `json:"kind,omitempty"` + Target string `json:"target,omitempty"` + Line int `json:"line,omitempty"` + Error string `json:"error,omitempty"` +} + +type replyOut struct { + Schema int `json:"schema"` + TS string `json:"ts"` + Kind string `json:"kind"` + Target string `json:"target"` + InReplyToID string `json:"in_reply_to_id,omitempty"` + InReplyToLine int `json:"in_reply_to_line"` + InReplyToTS string `json:"in_reply_to_ts,omitempty"` + IntentKind string `json:"intent_kind"` + Status string `json:"status"` + Text string `json:"text,omitempty"` + Granted *bool `json:"granted,omitempty"` + Entity string `json:"entity,omitempty"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` + Verdict string `json:"verdict,omitempty"` + RequestID string `json:"request_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Host string `json:"host,omitempty"` +} + +// ackKindFor maps an inbox intent kind to its reply/ack record kind. +func ackKindFor(intentKind string) (string, bool) { + switch intentKind { + case "tell": + return "reply", true + case "conn": + return "conn-ack", true + case "decision": + return "decision-ack", true + case "permission-decision": + return "permission-ack", true + default: + return "", false + } +} + +// Ack appends one newline-terminated, compact reply record to fo-replies.jsonl. +// Serialization is owned by the binary so the FO never hand-encodes JSONL — the +// non-compact / multi-line JSONL corruption class is eliminated. +func Ack(opts AckOptions) AckResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return AckResult{Error: "invalid or missing --slug"} + } + if opts.Line <= 0 { + return AckResult{Error: "missing or non-positive --line"} + } + kind, ok := ackKindFor(opts.IntentKind) + if !ok { + return AckResult{Error: "unknown --kind (want tell|conn|decision|permission-decision)"} + } + if strings.TrimSpace(opts.Status) == "" { + return AckResult{Error: "missing --status"} + } + // LOUD correlator guard: a reply with neither an id nor a ts has no strong + // correlator, so the Bridge reader can never fold it onto its intent and would + // silently drop it. Fail at write time instead of appending an orphan. The + // common path always carries an id from the drained record, so it is unaffected. + if opts.ID == "" && strings.TrimSpace(opts.TS) == "" { + return AckResult{Error: "ack has no correlator: pass --id (from the drained record) or --ts"} + } + host := normalizeHost(opts.Host) + root := absRootOr(opts.Root) + now := nowFunc(opts.Now) + + rec := replyOut{ + Schema: 1, + TS: now().UTC().Format(time.RFC3339), + Kind: kind, + Target: slug, + InReplyToID: opts.ID, + InReplyToLine: opts.Line, + InReplyToTS: strings.TrimSpace(opts.TS), + IntentKind: opts.IntentKind, + Status: opts.Status, + Text: singleLine(opts.Text), + Granted: opts.Granted, + Entity: opts.Entity, + Field: opts.Field, + Value: opts.Value, + Verdict: opts.Verdict, + RequestID: opts.RequestID, + SessionID: resolveSessionID(host, opts.SessionID), + Host: host, + } + if err := appendReply(root, rec); err != nil { + return AckResult{Error: err.Error()} + } + truncateReplies(filepath.Join(root, "_bridge")) + return AckResult{Appended: true, Kind: kind, Target: slug, Line: opts.Line} +} + +// appendReply marshals one reply/ack record to a compact single line and appends +// it to fo-replies.jsonl. Serialization is owned here so the FO (Ack) and the +// mechanical drain auto-ack share one byte-exact JSONL write path. +func appendReply(root string, rec replyOut) error { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.Marshal(rec) + if err != nil { + return err + } + f, err := os.OpenFile(filepath.Join(dir, "fo-replies.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return err + } + return nil +} + +const ( + maxReplyLines = 2000 + keepReplyLines = 1000 +) + +// truncateReplies bounds fo-replies.jsonl, mirroring bridgeegress.truncateEvents +// (size cap, recency keep-window, temp-file + atomic rename). It differs by one +// hard safety rule: a reply is dropped only when it is BOTH outside the recency +// window AND strictly below the retention floor. The floor is the lowest committed +// cursor across every .inbox-cursor. file — loadReplies still needs any reply +// whose in_reply_to_line is at or above that floor for at-least-once drain dedup, so +// dropping one would let an already-answered intent re-drain. Replies strictly below +// the floor are committed on every slug and safe to drop. With no committed cursor +// (floor unknown) nothing is dropped. Observe-only: any filesystem error is a no-op. +func truncateReplies(dir string) { + floor, ok := lowestCommittedCursor(dir) + if !ok { + return + } + path := filepath.Join(dir, "fo-replies.jsonl") + f, err := os.Open(path) + if err != nil { + return + } + var lines []string + scanner := lineScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + if len(lines) <= maxReplyLines || scanner.Err() != nil { + return + } + keep := keepReplyLines + if keep > len(lines) { + keep = len(lines) + } + windowStart := len(lines) - keep + kept := make([]string, 0, len(lines)) + for i, line := range lines { + if i >= windowStart || replyAtOrAboveFloor(line, floor) { + kept = append(kept, line) + } + } + if len(kept) == len(lines) { + return + } + body := strings.Join(kept, "\n") + "\n" + tmp, err := os.CreateTemp(dir, "fo-replies.jsonl.tmp.*") + if err != nil { + return + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(body); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + } +} + +// lowestCommittedCursor returns the minimum committed inbox cursor across all +// per-slug .inbox-cursor. files in dir. The legacy shared .inbox-cursor +// (no slug suffix) is intentionally excluded — adoptCursor migrates it into a +// per-slug file before any drain, and the floor is defined over per-slug commits. +// The bool is false when no readable per-slug cursor exists, so the caller keeps +// every reply rather than guessing a floor. +func lowestCommittedCursor(dir string) (int, bool) { + matches, _ := filepath.Glob(filepath.Join(dir, ".inbox-cursor.*")) + min := -1 + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || n < 0 { + continue + } + if min < 0 || n < min { + min = n + } + } + if min < 0 { + return 0, false + } + return min, true +} + +// replyAtOrAboveFloor reports whether a fo-replies.jsonl line must be retained for +// drain dedup: its in_reply_to_line is at or above the retention floor. An +// unparseable line contributes nothing to loadReplies, so it is not retained here. +func replyAtOrAboveFloor(line string, floor int) bool { + var rec replyRecord + if json.Unmarshal([]byte(line), &rec) != nil { + return false + } + return rec.InReplyToLine >= floor +} + +// CommitOptions controls one cursor advance. +type CommitOptions struct { + Root string + Slug string + Cursor int +} + +// CommitResult is the JSON printed after a cursor advance. +type CommitResult struct { + Cursor int `json:"cursor"` + Error string `json:"error,omitempty"` +} + +// Commit advances this workflow's inbox cursor to the supplied high-water mark. +// It is monotonic: a value below the current cursor is ignored so a stale commit +// can never re-deliver already-processed intent. +func Commit(opts CommitOptions) CommitResult { + slug := strings.TrimSpace(opts.Slug) + if !validSlug(slug) { + return CommitResult{Error: "invalid or missing --slug"} + } + if opts.Cursor < 0 { + return CommitResult{Error: "negative --cursor"} + } + root := absRootOr(opts.Root) + adoptCursor(root, slug) + current := inboxCursor(root, slug) + target := opts.Cursor + if target < current { + target = current + } + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return CommitResult{Error: err.Error()} + } + if err := os.WriteFile(filepath.Join(dir, ".inbox-cursor."+slug), []byte(strconv.Itoa(target)+"\n"), 0o644); err != nil { + return CommitResult{Error: err.Error()} + } + return CommitResult{Cursor: target} +} + +// CheckOptions controls a Stop-hook drain check. +type CheckOptions struct { + Host string + Root string + Slug string + SessionID string + Members []string + // StopHookActive mirrors the Claude Stop hook payload field. When true the + // check never blocks again, so a session that fails to drain cannot loop. + StopHookActive bool +} + +// HookDecision is the Claude Stop hook contract. An empty struct (marshals to +// "{}") lets the session stop; Decision "block" with Reason forces one more turn. +type HookDecision struct { + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type stopPayload struct { + CWD string `json:"cwd"` + SessionID string `json:"session_id"` + StopHookActive bool `json:"stop_hook_active"` +} + +// CheckFromReader parses a Claude Stop hook payload from stdin (cwd, session_id, +// stop_hook_active), merges any explicit CheckOptions overrides, and returns the +// hook decision. It never errors: a Stop hook must be safe, so any failure to +// resolve state yields an empty decision (let the session stop). +func CheckFromReader(r io.Reader, opts CheckOptions) HookDecision { + if data, err := io.ReadAll(r); err == nil && len(data) > 0 { + var p stopPayload + if json.Unmarshal(data, &p) == nil { + if opts.Root == "" && p.CWD != "" { + opts.Root = p.CWD + } + if opts.SessionID == "" { + opts.SessionID = p.SessionID + } + if p.StopHookActive { + opts.StopHookActive = true + } + } + } + return Check(opts) +} + +// Check computes the Stop-hook decision for a session: block with a drain +// instruction when captain intent is queued for the session's workflow(s). +func Check(opts CheckOptions) HookDecision { + if opts.StopHookActive { + return HookDecision{} + } + root := absRootOr(opts.Root) + if _, err := os.Stat(filepath.Join(root, "_bridge", "inbox.jsonl")); err != nil { + return HookDecision{} + } + slugs := resolveSessionSlugs(root, strings.TrimSpace(opts.Slug), strings.TrimSpace(opts.SessionID)) + if len(slugs) == 0 { + return HookDecision{} + } + + records, _, err := readInboxFull(filepath.Join(root, "_bridge", "inbox.jsonl")) + if err != nil { + return HookDecision{} + } + replies := loadReplies(root) + pendingBySlug := map[string]int{} + for _, slug := range slugs { + members := []string{slug} + 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, normalizeHost(opts.Host))} +} + +func drainReason(total int, slugs []string, host string) string { + if host == "" { + host = "claude" + } + slug := slugs[0] + plural := "record" + if total != 1 { + plural = "records" + } + return "Bridge has " + strconv.Itoa(total) + " queued captain-intent " + plural + + " in _bridge/inbox.jsonl for workflow(s): " + strings.Join(slugs, ", ") + + ". Before stopping, drain them: run `spacedock bridge inbox drain --host " + host + " --slug " + slug + + "` (repeat per slug), act on each record, ack each with `spacedock bridge inbox ack ...`, then advance the cursor with `spacedock bridge inbox commit ...`." +} + +// resolveSessionSlugs finds the workflow slug(s) this session drives. An explicit +// --slug wins. Otherwise it matches the session id against heartbeat and session +// markers so a Stop hook only ever blocks its OWN session's pending intent, never +// a sibling FO's sharing the same repo root. +func resolveSessionSlugs(root, explicitSlug, sessionID string) []string { + if explicitSlug != "" { + if validSlug(explicitSlug) { + return []string{explicitSlug} + } + return nil + } + if !safeSessionID(sessionID) { + return nil + } + seen := map[string]bool{} + var out []string + for _, slug := range discoverHeartbeatSlugs(root) { + if hb, ok := loadHeartbeatAnyAge(root, slug); ok && strings.TrimSpace(hb.SessionID) == sessionID { + if !seen[slug] { + seen[slug] = true + out = append(out, slug) + } + } + } + // Session markers (sessions/.json) map an actor id to a workflow; the + // FO's own actor id is its session id on Claude. + if slug, ok := workflowForSession(root, sessionID); ok && !seen[slug] { + seen[slug] = true + out = append(out, slug) + } + return out +} + +func workflowForSession(root, sessionID string) (string, bool) { + matches, _ := filepath.Glob(filepath.Join(root, "_bridge", "sessions", "*.json")) + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var rec sessionMarker + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + if strings.TrimSpace(rec.SessionID) == sessionID && validSlug(rec.Workflow) { + return rec.Workflow, true + } + } + return "", false +} + +type heartbeatOut struct { + SessionID string `json:"session_id"` + Host string `json:"host,omitempty"` + TS string `json:"ts"` + State string `json:"state"` +} + +func stampHeartbeat(root, slug, sessionID, host string, now time.Time) bool { + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return false + } + hb := heartbeatOut{SessionID: sessionID, Host: host, TS: now.UTC().Format(time.RFC3339), State: "idle"} + data, err := json.Marshal(hb) + if err != nil { + return false + } + if err := os.WriteFile(filepath.Join(dir, "fo."+slug+".json"), append(data, '\n'), 0o644); err != nil { + return false + } + return true +} + +// adoptCursor performs the one-time migration from the pre-versioning shared +// cursor to this workflow's own cursor, so a freshly-slugged FO does not re-drain +// (and re-apply) intent already processed under the shared cursor. +func adoptCursor(root, slug string) { + if !validSlug(slug) { + return + } + slugPath := filepath.Join(root, "_bridge", ".inbox-cursor."+slug) + if _, err := os.Stat(slugPath); err == nil { + return + } + sharedPath := filepath.Join(root, "_bridge", ".inbox-cursor") + data, err := os.ReadFile(sharedPath) + if err != nil { + return + } + _ = os.WriteFile(slugPath, data, 0o644) +} + +func readInboxFull(path string) ([]fullInboxRecord, int, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, 0, nil + } + if err != nil { + return nil, 0, err + } + defer f.Close() + + var out []fullInboxRecord + lineNo := 0 + scanner := lineScanner(f) + for scanner.Scan() { + lineNo++ + if strings.TrimSpace(scanner.Text()) == "" { + continue + } + var rec fullInboxRecord + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + continue + } + // Preserve the exact on-disk ts string so drain→ack round-trips it verbatim; + // reformatting to UTC would break wake's ts-based replyKey fallback for an + // id-less record. + var raw struct { + TS string `json:"ts"` + } + _ = json.Unmarshal(scanner.Bytes(), &raw) + rec.RawTS = raw.TS + rec.Line = lineNo + out = append(out, rec) + } + if err := scanner.Err(); err != nil { + return nil, 0, err + } + return out, lineNo, nil +} + +func toDrainRecord(r fullInboxRecord) DrainRecord { + // Prefer the exact on-disk ts string; fall back to the parsed form only if the + // raw field is somehow empty. + ts := r.RawTS + if ts == "" && !r.TS.IsZero() { + ts = r.TS.UTC().Format(time.RFC3339) + } + return DrainRecord{ + Line: r.Line, ID: r.ID, TS: ts, Kind: r.Kind, Text: r.Text, Granted: r.Granted, + Target: r.Target, TargetSet: r.TargetSet, Entity: r.Entity, Field: r.Field, + Value: r.Value, Verdict: r.Verdict, Directives: r.Directives, RequestID: r.RequestID, + } +} + +func resolveSessionID(host, flag string) string { + if s := strings.TrimSpace(flag); s != "" { + return s + } + if s := strings.TrimSpace(os.Getenv("SD_SESSION_ID")); s != "" { + return s + } + switch host { + case "claude": + return strings.TrimSpace(os.Getenv("CLAUDE_CODE_SESSION_ID")) + case "codex": + return strings.TrimSpace(os.Getenv("CODEX_THREAD_ID")) + } + return "" +} + +func validSlug(slug string) bool { + return slug != "" && slug != "." && slug != ".." && safeSlugPattern.MatchString(slug) +} + +func absRootOr(root string) string { + if root == "" { + root = "." + } + if abs, err := filepath.Abs(root); err == nil { + return abs + } + return root +} + +func nowFunc(fn func() time.Time) func() time.Time { + if fn != nil { + return fn + } + return time.Now +} + +func singleLine(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} diff --git a/internal/bridgeingress/drain_test.go b/internal/bridgeingress/drain_test.go new file mode 100644 index 000000000..965cfbc7a --- /dev/null +++ b/internal/bridgeingress/drain_test.go @@ -0,0 +1,289 @@ +package bridgeingress + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDrainReturnsAddressedRecordsAndStampsHostHeartbeat(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"do the thing","target_set":["alpha"]}`, + `{"id":"i2","ts":"2026-07-03T11:01:00Z","kind":"tell","text":"not yours","target_set":["beta"]}`, + `{"id":"i3","ts":"2026-07-03T11:02:00Z","kind":"decision","entity":"e1","field":"status","value":"approved","target":"alpha"}`, + ) + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", SessionID: "sess-1", Now: func() time.Time { return now }}) + + if res.Status != "ok" { + t.Fatalf("status = %q, want ok", res.Status) + } + if !res.Heartbeat { + t.Fatal("heartbeat not written") + } + if res.Cursor != 0 || res.HighWater != 3 { + t.Fatalf("cursor=%d high_water=%d, want 0/3", res.Cursor, res.HighWater) + } + if res.Count != 2 || len(res.Records) != 2 { + t.Fatalf("count=%d, want 2 addressed records; got %+v", res.Count, res.Records) + } + if res.Records[0].ID != "i1" || res.Records[0].Text != "do the thing" || res.Records[0].Line != 1 { + t.Fatalf("record[0] = %+v, want i1 line 1", res.Records[0]) + } + if res.Records[1].ID != "i3" || res.Records[1].Kind != "decision" || res.Records[1].Field != "status" || res.Records[1].Value != "approved" { + t.Fatalf("record[1] = %+v, want decision i3 with field/value", res.Records[1]) + } + + // Heartbeat carries the host so Bridge can route wake per harness (WS-4). + var hb heartbeatOut + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo.alpha.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &hb); err != nil { + t.Fatal(err) + } + if hb.Host != "claude" || hb.SessionID != "sess-1" || hb.State != "idle" { + t.Fatalf("heartbeat = %+v, want claude/sess-1/idle", hb) + } + + // Drain never advances the cursor. + if _, err := os.Stat(filepath.Join(root, "_bridge", ".inbox-cursor.alpha")); !os.IsNotExist(err) { + t.Fatalf("drain must not write a cursor: %v", err) + } +} + +func TestDrainPreservesRawTimestamp(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + // A non-UTC offset ts: drain must round-trip it verbatim, not reformat to UTC, + // or wake's ts-based replyKey fallback (id-less records) would mismatch. + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T20:00:00+08:00","kind":"tell","text":"hi","target":"alpha"}`) + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 record, got %+v", res.Records) + } + if res.Records[0].TS != "2026-07-03T20:00:00+08:00" { + t.Fatalf("ts = %q, want the verbatim on-disk offset ts", res.Records[0].TS) + } +} + +func TestDrainRespectsCursorAndAckIdempotency(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, + `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"first","target":"alpha"}`, + `{"id":"i2","ts":"2026-07-03T11:01:00Z","kind":"tell","text":"second","target":"alpha"}`, + ) + // Cursor past line 1: only line 2 remains. + writeInboxCursor(t, root, "alpha", "1") + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 || res.Records[0].ID != "i2" { + t.Fatalf("with cursor=1 want only i2, got %+v", res.Records) + } + + // Ack line 2 without committing the cursor: a re-drain must not resurface it. + if ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "i2", TS: "2026-07-03T11:01:00Z", IntentKind: "tell", Status: "answered"}); !ar.Appended { + t.Fatalf("ack failed: %+v", ar) + } + res2 := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res2.Count != 0 { + t.Fatalf("acked record must not resurface (idempotency), got %+v", res2.Records) + } +} + +func TestAckIsCompactSingleLineAndMapsKind(t *testing.T) { + root := t.TempDir() + cases := []struct{ intent, wantKind string }{ + {"tell", "reply"}, + {"conn", "conn-ack"}, + {"decision", "decision-ack"}, + {"permission-decision", "permission-ack"}, + } + for _, c := range cases { + res := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, ID: "x", TS: "2026-07-03T11:00:00Z", IntentKind: c.intent, Status: "applied", Text: "multi\nline\ttext"}) + if !res.Appended || res.Kind != c.wantKind { + t.Fatalf("intent %q -> %+v, want kind %q", c.intent, res, c.wantKind) + } + } + + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != len(cases) { + t.Fatalf("want %d compact lines, got %d:\n%s", len(cases), len(lines), raw) + } + for _, line := range lines { + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("line is not valid JSON: %q (%v)", line, err) + } + if v, _ := rec["schema"].(float64); v != 1 { + t.Fatalf("schema != 1 in %q", line) + } + if strings.Contains(line, "\n") || strings.Contains(line, "\t") { + t.Fatalf("reply line not single-line/compact: %q", line) + } + } + // The multi-line --text must be flattened to a single line. + if strings.Contains(string(raw), "multi\nline") { + t.Fatalf("text was not flattened:\n%s", raw) + } +} + +func TestAckOmitsGrantedWhenAbsent(t *testing.T) { + root := t.TempDir() + Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, ID: "x", IntentKind: "tell", Status: "answered"}) + raw, _ := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if strings.Contains(string(raw), "granted") { + t.Fatalf("absent --granted must be omitted, got %s", raw) + } + tr := true + Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "y", IntentKind: "conn", Status: "accepted", Granted: &tr}) + raw2, _ := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if !strings.Contains(string(raw2), `"granted":true`) { + t.Fatalf("present --granted must be serialized, got %s", raw2) + } +} + +func TestAck_NoCorrelatorFailsLoudly(t *testing.T) { + root := t.TempDir() + // No id AND no ts: the reply has no strong correlator, so it must fail loudly at + // write time rather than append an orphan the Bridge reader would silently drop. + ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 1, IntentKind: "tell", Status: "answered"}) + if ar.Appended { + t.Fatalf("no-correlator ack must not append, got %+v", ar) + } + if ar.Error == "" { + t.Fatalf("no-correlator ack must return an error, got %+v", ar) + } + // fo-replies.jsonl must not exist / must be untouched. + if _, err := os.Stat(filepath.Join(root, "_bridge", "fo-replies.jsonl")); !os.IsNotExist(err) { + t.Fatalf("no-correlator ack must not write fo-replies.jsonl: %v", err) + } +} + +func TestAck_IdOnlyReplyIsCorrelatable(t *testing.T) { + root := t.TempDir() + // id present, ts empty: id alone is a strong correlator, so the ack appends. + ar := Ack(AckOptions{Host: "claude", Root: root, Slug: "alpha", Line: 2, ID: "i2", IntentKind: "tell", Status: "answered"}) + if !ar.Appended || ar.Error != "" { + t.Fatalf("id-only ack must append, got %+v", ar) + } + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec replyOut + if err := json.Unmarshal([]byte(strings.TrimSpace(string(raw))), &rec); err != nil { + t.Fatalf("reply line not valid JSON: %q (%v)", raw, err) + } + if rec.InReplyToID != "i2" { + t.Fatalf("in_reply_to_id = %q, want i2", rec.InReplyToID) + } + if rec.InReplyToTS != "" { + t.Fatalf("empty --ts must echo as empty in_reply_to_ts, got %q", rec.InReplyToTS) + } +} + +func TestDrain_EmitsActingAck(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"do it","target":"alpha"}`) + + res := Drain(DrainOptions{Host: "claude", Root: root, Slug: "alpha", SessionID: "sess-1", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 drained record, got %+v", res.Records) + } + dr := res.Records[0] + + raw, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-replies.jsonl")) + if err != nil { + t.Fatalf("drain must auto-write an acting ack: %v", err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("want exactly one acting ack, got %d:\n%s", len(lines), raw) + } + var rec replyOut + if err := json.Unmarshal([]byte(lines[0]), &rec); err != nil { + t.Fatalf("acting ack not valid JSON: %q (%v)", lines[0], err) + } + if rec.Status != "acting" { + t.Fatalf("status = %q, want acting", rec.Status) + } + // The acting ack matches the drained record's id/line/ts/kind. + if rec.InReplyToID != dr.ID || rec.InReplyToLine != dr.Line || rec.InReplyToTS != dr.TS { + t.Fatalf("acting ack correlators = %q/%d/%q, want %q/%d/%q", rec.InReplyToID, rec.InReplyToLine, rec.InReplyToTS, dr.ID, dr.Line, dr.TS) + } + if rec.IntentKind != dr.Kind || rec.Kind != "reply" { + t.Fatalf("acting ack kinds = intent %q / reply %q, want %q / reply", rec.IntentKind, rec.Kind, dr.Kind) + } +} + +func TestCommitAdvancesCursorMonotonically(t *testing.T) { + root := t.TempDir() + if r := Commit(CommitOptions{Root: root, Slug: "alpha", Cursor: 5}); r.Cursor != 5 { + t.Fatalf("commit = %+v, want 5", r) + } + if got := inboxCursor(root, "alpha"); got != 5 { + t.Fatalf("cursor file = %d, want 5", got) + } + // A stale/lower commit must not regress the cursor. + if r := Commit(CommitOptions{Root: root, Slug: "alpha", Cursor: 2}); r.Cursor != 5 { + t.Fatalf("regressing commit = %+v, want held at 5", r) + } + if got := inboxCursor(root, "alpha"); got != 5 { + t.Fatalf("cursor regressed to %d, want 5", got) + } +} + +func TestDrainInvalidSlugRejected(t *testing.T) { + root := t.TempDir() + if r := Drain(DrainOptions{Host: "claude", Root: root, Slug: "../escape"}); r.Status != "failed" { + t.Fatalf("unsafe slug must fail, got %+v", r) + } +} + +// TestPackagedDrainAckCommitSatisfiesWakeGuard is the load-bearing integration: +// after the packaged drain/ack/commit cycle, the Codex wake path (which owns the +// double-delivery guard) must treat the intent as delivered and NOT re-wake. This +// proves the packaged ack is byte-compatible with wake's replyKey/loadReplies. +func TestPackagedDrainAckCommitSatisfiesWakeGuard(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + writeInbox(t, root, `{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"hello","target":"alpha"}`) + writeHeartbeat(t, root, "alpha", "sess-a", now) + + // FO drains, acts, acks (without committing yet): the reply alone must satisfy + // the wake guard even before the cursor advances. + res := Drain(DrainOptions{Host: "codex", Root: root, Slug: "alpha", Now: func() time.Time { return now }}) + if res.Count != 1 { + t.Fatalf("want 1 drained record, got %+v", res.Records) + } + rec := res.Records[0] + if ar := Ack(AckOptions{Host: "codex", Root: root, Slug: "alpha", Line: rec.Line, ID: rec.ID, TS: rec.TS, IntentKind: rec.Kind, Status: "answered"}); !ar.Appended { + t.Fatalf("ack failed: %+v", ar) + } + + woke := false + wr := Wake(context.Background(), Options{Host: "codex", Root: root, Now: func() time.Time { return now }, Resume: func(context.Context, string, string) error { + woke = true + return nil + }}) + if woke { + t.Fatal("wake re-resumed an already-acked intent — replyKey byte-compat broken") + } + if wr.Status != "noop" { + t.Fatalf("wake status = %q, want noop (nothing pending)", wr.Status) + } +} diff --git a/internal/bridgeingress/truncate_test.go b/internal/bridgeingress/truncate_test.go new file mode 100644 index 000000000..43663b676 --- /dev/null +++ b/internal/bridgeingress/truncate_test.go @@ -0,0 +1,213 @@ +package bridgeingress + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// writeReplyFixture writes one fo-replies.jsonl line per element of inReplyToLines, +// each a minimal record carrying that in_reply_to_line (the only field truncateReplies +// reads). Returns the _bridge dir. +func writeReplyFixture(t *testing.T, root string, inReplyToLines []int) string { + t.Helper() + dir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir _bridge: %v", err) + } + var b strings.Builder + for _, n := range inReplyToLines { + fmt.Fprintf(&b, `{"in_reply_to_line":%d}`+"\n", n) + } + if err := os.WriteFile(filepath.Join(dir, "fo-replies.jsonl"), []byte(b.String()), 0o644); err != nil { + t.Fatalf("write fo-replies.jsonl: %v", err) + } + return dir +} + +// writeCursorFile writes a per-slug committed cursor (the same "\n" shape Commit +// writes), so truncateReplies derives a retention floor from it. +func writeCursorFile(t *testing.T, dir, slug string, cursor int) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".inbox-cursor."+slug), []byte(strconv.Itoa(cursor)+"\n"), 0o644); err != nil { + t.Fatalf("write cursor %s: %v", slug, err) + } +} + +// readReplyInReplyToLines reads fo-replies.jsonl back as the ordered slice of +// in_reply_to_line values it carries. +func readReplyInReplyToLines(t *testing.T, dir string) []int { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "fo-replies.jsonl")) + if err != nil { + t.Fatalf("read fo-replies.jsonl: %v", err) + } + trimmed := strings.TrimRight(string(data), "\n") + if trimmed == "" { + return nil + } + var out []int + for _, line := range strings.Split(trimmed, "\n") { + var v struct { + N int `json:"in_reply_to_line"` + } + if err := json.Unmarshal([]byte(line), &v); err != nil { + t.Fatalf("kept line does not parse: %q: %v", line, err) + } + out = append(out, v.N) + } + return out +} + +func countValue(vals []int, target int) int { + n := 0 + for _, v := range vals { + if v == target { + n++ + } + } + return n +} + +// TestTruncateRepliesNoOpWithoutCursor: with no committed per-slug cursor the floor +// is unknown, so nothing is dropped even well past the size cap (dropping blind would +// risk re-draining an already-answered intent). +func TestTruncateRepliesNoOpWithoutCursor(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != n { + t.Errorf("no cursor present: want file untouched (%d lines), got %d", n, got) + } +} + +// TestTruncateRepliesNoOpUnderCap: at or below the size cap the file is left intact +// even when every line sits below the floor and outside the recency window. +func TestTruncateRepliesNoOpUnderCap(t *testing.T) { + root := t.TempDir() + n := maxReplyLines // exactly the cap → the > cap guard is false + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 999999) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != n { + t.Errorf("under cap: want file untouched (%d lines), got %d", n, got) + } +} + +// TestTruncateRepliesDropsBelowFloorOutsideWindow is the core safety rule: over the +// cap, a reply is dropped only when it is BOTH below the retention floor AND outside +// the recency window. A below-floor line inside the window is kept; an at-or-above-floor +// line outside the window is kept (dedup preservation); a below-floor line outside the +// window is the only thing dropped. Original order is preserved. +func TestTruncateRepliesDropsBelowFloorOutsideWindow(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 // 2100 + windowStart := n - keepReplyLines + const floor = 500 + const keepMarker = 9999 // >= floor, placed outside the window: must survive + const dropMarker = 10 // < floor, outside the window: must be dropped + const windowMarker = 1 // < floor, inside the window: kept by recency + + lines := make([]int, n) + for i := range lines { + switch { + case i >= windowStart: + lines[i] = windowMarker + case i == 0: + lines[i] = keepMarker + default: + lines[i] = dropMarker + } + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", floor) + + truncateReplies(dir) + + kept := readReplyInReplyToLines(t, dir) + if got, want := len(kept), 1+keepReplyLines; got != want { + t.Fatalf("kept %d lines, want %d (the one at/above-floor outside line + the %d-line window)", got, want, keepReplyLines) + } + if kept[0] != keepMarker { + t.Errorf("first kept line = %d, want the at/above-floor line %d preserved in original order", kept[0], keepMarker) + } + if c := countValue(kept, dropMarker); c != 0 { + t.Errorf("below-floor outside-window lines survived: %d remain, want 0", c) + } + if c := countValue(kept, windowMarker); c != keepReplyLines { + t.Errorf("recency window not fully retained: %d window lines, want %d", c, keepReplyLines) + } +} + +// TestTruncateRepliesKeepsOnlyWindowWhenAllBelowFloor: over the cap with every line +// below the floor, exactly the recency window survives. +func TestTruncateRepliesKeepsOnlyWindowWhenAllBelowFloor(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + lines := make([]int, n) + for i := range lines { + lines[i] = 1 + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 999999) + + truncateReplies(dir) + + if got := len(readReplyInReplyToLines(t, dir)); got != keepReplyLines { + t.Errorf("all below floor: want exactly the %d-line window, got %d", keepReplyLines, got) + } +} + +// TestTruncateRepliesFloorIsLowestAcrossSlugs: the floor is the MINIMUM committed +// cursor across every per-slug file, so a reply at/above the lowest slug's cursor is +// retained even though it is below another slug's cursor. +func TestTruncateRepliesFloorIsLowestAcrossSlugs(t *testing.T) { + root := t.TempDir() + n := maxReplyLines + 100 + windowStart := n - keepReplyLines + const lowFloor = 50 + const atLowFloor = 100 // >= 50 (lowest) but well below 500: retained via the min floor + const belowAll = 10 // < 50: dropped + + lines := make([]int, n) + for i := range lines { + switch { + case i >= windowStart: + lines[i] = 1 + case i == 0: + lines[i] = atLowFloor + default: + lines[i] = belowAll + } + } + dir := writeReplyFixture(t, root, lines) + writeCursorFile(t, dir, "alpha", 500) + writeCursorFile(t, dir, "beta", lowFloor) + + truncateReplies(dir) + + kept := readReplyInReplyToLines(t, dir) + if countValue(kept, atLowFloor) != 1 { + t.Errorf("line at the lowest slug's floor was dropped — floor is not the min across slugs") + } + if countValue(kept, belowAll) != 0 { + t.Errorf("line below every floor survived, want 0") + } +} diff --git a/internal/bridgeingress/wake.go b/internal/bridgeingress/wake.go new file mode 100644 index 000000000..56c3a8d99 --- /dev/null +++ b/internal/bridgeingress/wake.go @@ -0,0 +1,692 @@ +// 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 +} + +func wakePrompt(root string, w *sessionWake) string { + return fmt.Sprintf(`Bridge queued captain intent for this Spacedock first-officer session. + +Repo root: %s +Inbox: %s +Pending physical inbox lines for this session: %s +Addressed workflow slugs: %s + +Run the Bridge inbox idle drain now for only those workflow slugs. Honor target_set routing, append per-slug FO replies or acknowledgements for valid captain intents, advance the matching _bridge/.inbox-cursor. files, then continue the normal first-officer event loop. +`, root, filepath.Join(root, "_bridge", "inbox.jsonl"), joinInts(w.Lines), strings.Join(w.Targets(), ",")) +} + +// 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 + } + if rec.Schema != 1 || rec.Target == "" || rec.InReplyToLine <= 0 || rec.IntentKind == "" { + continue + } + if !safeSlugPattern.MatchString(rec.Target) || rec.Target == "." || rec.Target == ".." { + continue + } + out[replyKey(inboxRecord{ID: rec.InReplyToID, TS: rec.InReplyToTS, Kind: rec.IntentKind, Line: rec.InReplyToLine}, rec.Target)] = true + } + return out +} + +func replyKey(rec inboxRecord, target string) string { + id := rec.ID + if id == "" { + id = rec.TS.Format(time.RFC3339Nano) + } + return strconv.Itoa(rec.Line) + "\x00" + id + "\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/bridgeinitiate/initiate.go b/internal/bridgeinitiate/initiate.go new file mode 100644 index 000000000..9cb381958 --- /dev/null +++ b/internal/bridgeinitiate/initiate.go @@ -0,0 +1,322 @@ +// ABOUTME: FO-to-Bridge initiation writer for FO-authored feed lines and gates. +// ABOUTME: Append-only and best-effort; Bridge reads _bridge/fo-initiate.jsonl. +package bridgeinitiate + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + maxHeadlineLen = 240 + maxBodyLen = 2000 + + // Truncation bounds mirror bridgeegress.truncateEvents. A still-open + // gate-review is retained past the tail regardless of these bounds. + defaultMaxLines = 2000 + defaultKeepLines = 1000 + maxScanLine = 1 << 20 +) + +// validKinds are the initiation decidability classes Bridge understands. +var validKinds = map[string]bool{ + "status": true, + "reco": true, + "gate-review": true, +} + +// InitiationRecord is the stable JSONL shape Bridge reads from +// _bridge/fo-initiate.jsonl. The writer ALWAYS stamps status "open"; the reader +// overlays resolved/approved/rejected from decision intents. +type InitiationRecord struct { + Schema int `json:"schema"` + ID string `json:"id"` + TS string `json:"ts"` + Kind string `json:"kind"` + Workflow string `json:"workflow,omitempty"` + Entity string `json:"entity,omitempty"` + ShipID string `json:"ship_id,omitempty"` + Host string `json:"host,omitempty"` + SessionID string `json:"session_id,omitempty"` + Headline string `json:"headline"` + Body string `json:"body,omitempty"` + RequestID string `json:"request_id,omitempty"` + Status string `json:"status"` +} + +// InitiationOptions controls one initiation append. +type InitiationOptions struct { + Root string + Now func() time.Time + ID string + Kind string + Workflow string + Entity string + ShipID string + Host string + SessionID string + Headline string + Body string + RequestID string +} + +// Result is the compact JSON result printed for Bridge. +type Result struct { + ID string `json:"id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Queued bool `json:"queued"` + Error string `json:"error,omitempty"` +} + +// AppendInitiation writes one open initiation under root/_bridge. Unlike alert +// writes, id is REQUIRED with no random fallback: idempotency depends on a +// stable caller-supplied fold key. +func AppendInitiation(opts InitiationOptions) (Result, error) { + id := strings.TrimSpace(opts.ID) + result := Result{ID: id, RequestID: id} + if id == "" { + return result.withError("initiation: id is required"), nil + } + + kind := strings.TrimSpace(opts.Kind) + if !validKinds[kind] { + return result.withError("initiation: kind must be one of status|reco|gate-review"), nil + } + + headline := oneLineSummary(opts.Headline, maxHeadlineLen) + if headline == "" { + return result.withError("initiation: headline is required"), nil + } + + requestID := strings.TrimSpace(opts.RequestID) + if requestID == "" && kind == "gate-review" { + requestID = id + } + result.RequestID = requestID + + root := opts.Root + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return result.withError(err.Error()), nil + } + + now := time.Now().UTC + if opts.Now != nil { + now = func() time.Time { return opts.Now().UTC() } + } + + rec := InitiationRecord{ + Schema: 1, + ID: id, + TS: now().Format(time.RFC3339), + Kind: kind, + Workflow: strings.TrimSpace(opts.Workflow), + Entity: strings.TrimSpace(opts.Entity), + ShipID: strings.TrimSpace(opts.ShipID), + Host: strings.TrimSpace(opts.Host), + SessionID: strings.TrimSpace(opts.SessionID), + Headline: headline, + Body: oneLineSummary(opts.Body, maxBodyLen), + RequestID: requestID, + Status: "open", + } + + dir := filepath.Join(absRoot, "_bridge") + if err := os.MkdirAll(dir, 0o755); err != nil { + return result.withError(err.Error()), nil + } + path := filepath.Join(dir, "fo-initiate.jsonl") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return result.withError(err.Error()), nil + } + data, err := json.Marshal(rec) + if err != nil { + _ = f.Close() + return result.withError(err.Error()), nil + } + if _, err := f.Write(append(data, '\n')); err != nil { + _ = f.Close() + return result.withError(err.Error()), nil + } + if err := f.Close(); err != nil { + return result.withError(err.Error()), nil + } + + truncateInitiate(path) + result.Queued = true + return result, nil +} + +func (r Result) withError(msg string) Result { + r.Queued = false + r.Error = msg + return r +} + +// truncateInitiate mirrors bridgeegress.truncateEvents (temp-file + rename tail +// retention) but NEVER drops the latest record of a still-open gate-review id, +// so an open gate can't be truncated out of the read window. Resolution is +// determined the way the reader determines it — a captain decision intent in the +// sibling inbox.jsonl keyed by request_id — because the writer ALWAYS stamps +// on-disk status "open", so a resolved gate's line still reads "open" here. +// Without the inbox overlay every gate would be protected forever and the file +// would grow unbounded in historical (already-decided) gate-reviews. +func truncateInitiate(path string) { + f, err := os.Open(path) + if err != nil { + return + } + var lines []string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + if len(lines) <= defaultMaxLines || scanner.Err() != nil { + return + } + + keepLines := defaultKeepLines + if keepLines > len(lines) { + keepLines = len(lines) + } + tailStart := len(lines) - keepLines + + resolved := loadResolvedGateRequestIDs(filepath.Dir(path)) + + // Latest record per id wins; retain the latest line of every still-open + // gate-review id even when it falls before the tail window. A gate whose + // request_id has a captain decision in the inbox is resolved and evictable. + latestLine := make(map[string]int) + for i, line := range lines { + rec, ok := parseRecord(line) + if !ok { + continue + } + latestLine[rec.ID] = i + } + protected := make(map[int]bool) + for _, i := range latestLine { + rec, ok := parseRecord(lines[i]) + if !ok { + continue + } + if rec.Kind == "gate-review" && rec.Status == "open" && !resolved[gateResolutionKey(rec)] && i < tailStart { + protected[i] = true + } + } + + var kept []string + for i := 0; i < tailStart; i++ { + if protected[i] { + kept = append(kept, lines[i]) + } + } + kept = append(kept, lines[tailStart:]...) + + out := strings.Join(kept, "\n") + "\n" + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp.*") + if err != nil { + return + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(out); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + } +} + +func parseRecord(line string) (InitiationRecord, bool) { + line = strings.TrimSpace(line) + if line == "" { + return InitiationRecord{}, false + } + var rec InitiationRecord + if err := json.Unmarshal([]byte(line), &rec); err != nil { + return InitiationRecord{}, false + } + return rec, true +} + +// gateResolutionKey is the request_id a decision intent correlates against, +// falling back to the id (the writer defaults request_id to id for gate-review). +func gateResolutionKey(rec InitiationRecord) string { + if rec.RequestID != "" { + return rec.RequestID + } + return rec.ID +} + +// loadResolvedGateRequestIDs reads the sibling inbox.jsonl and returns the set +// of request_ids carried by captain "decision" intents — the same overlay the +// Bridge reader uses to flip a gate-review to resolved. Best-effort: a missing +// or unreadable inbox yields an empty set, so every open gate stays protected +// (the safe fallback — never evict a gate that might still be open). +func loadResolvedGateRequestIDs(dir string) map[string]bool { + resolved := make(map[string]bool) + f, err := os.Open(filepath.Join(dir, "inbox.jsonl")) + if err != nil { + return resolved + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxScanLine) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var rec struct { + Kind string `json:"kind"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil { + continue + } + if rec.Kind == "decision" && rec.RequestID != "" { + resolved[rec.RequestID] = true + } + } + return resolved +} + +// oneLineSummary collapses control chars/whitespace to single spaces and bounds +// the result to maxLen runes. Mirrors bridgealert.oneLineSummary. +func oneLineSummary(in string, maxLen int) string { + in = strings.TrimSpace(in) + var b strings.Builder + prevSpace := false + for _, r := range in { + if unicode.IsControl(r) || unicode.IsSpace(r) { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + if b.Len() >= maxLen { + break + } + } + return strings.TrimSpace(b.String()) +} diff --git a/internal/bridgeinitiate/initiate_test.go b/internal/bridgeinitiate/initiate_test.go new file mode 100644 index 000000000..f644e09ea --- /dev/null +++ b/internal/bridgeinitiate/initiate_test.go @@ -0,0 +1,305 @@ +package bridgeinitiate + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAppendInitiationWritesRecord(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + Now: func() time.Time { return time.Date(2026, 7, 3, 5, 0, 0, 0, time.UTC) }, + ID: "gate-ship-a-ideation", + Kind: "gate-review", + Workflow: "pr-review-queue", + Entity: "ship-a", + ShipID: "pr-review-queue/ship-a", + Host: "claude", + SessionID: "s1", + Headline: "ship-a ideation ready for a call", + Body: "chosen direction: reuse existing writer", + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "gate-ship-a-ideation" || !got.Queued { + t.Fatalf("result = %+v", got) + } + if got.RequestID != "gate-ship-a-ideation" { + t.Fatalf("request id = %q, want default to id for gate-review", got.RequestID) + } + + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-initiate.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec InitiationRecord + if err := json.Unmarshal(bytes.TrimSpace(data), &rec); err != nil { + t.Fatalf("record JSON: %v\n%s", err, data) + } + if rec.Schema != 1 || rec.Status != "open" { + t.Fatalf("record = %+v", rec) + } + if rec.Kind != "gate-review" || rec.RequestID != "gate-ship-a-ideation" { + t.Fatalf("record = %+v", rec) + } + if rec.TS != "2026-07-03T05:00:00Z" || rec.Headline != "ship-a ideation ready for a call" { + t.Fatalf("record fields = %+v", rec) + } + if rec.ShipID != "pr-review-queue/ship-a" || rec.Workflow != "pr-review-queue" { + t.Fatalf("record routing = %+v", rec) + } +} + +func TestAppendInitiationDefaultsRequestIDOnlyForGate(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "status-1", + Kind: "status", + Headline: "advancing ship-b", + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v", got) + } + if got.RequestID != "" { + t.Fatalf("request id = %q, want empty for non-gate kind", got.RequestID) + } +} + +func TestAppendInitiationMissingIDIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + Kind: "status", + Headline: "no id here", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationInvalidKindIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + ID: "x1", + Kind: "chatter", + Headline: "bad kind", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationMissingHeadlineIsLoudError(t *testing.T) { + got, err := AppendInitiation(InitiationOptions{ + Root: t.TempDir(), + ID: "x1", + Kind: "status", + Headline: " ", + }) + if err != nil { + t.Fatal(err) + } + if got.Queued || got.Error == "" { + t.Fatalf("result = %+v, want non-queued error result", got) + } +} + +func TestAppendInitiationNormalizesBounds(t *testing.T) { + root := t.TempDir() + got, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "x1", + Kind: "reco", + Headline: "line one\ntwo\tthree", + Body: "body\n" + strings.Repeat("z", maxBodyLen+500), + }) + if err != nil { + t.Fatal(err) + } + if !got.Queued { + t.Fatalf("result = %+v", got) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-initiate.jsonl")) + if err != nil { + t.Fatal(err) + } + var rec InitiationRecord + if err := json.Unmarshal(bytes.TrimSpace(data), &rec); err != nil { + t.Fatal(err) + } + if rec.Headline != "line one two three" { + t.Fatalf("headline = %q", rec.Headline) + } + if strings.ContainsAny(rec.Body, "\n\t") || len(rec.Body) > maxBodyLen { + t.Fatalf("body len=%d %q", len(rec.Body), rec.Body) + } +} + +func TestAppendInitiation_AnchorsAtRepoRoot(t *testing.T) { + root := t.TempDir() + if _, err := AppendInitiation(InitiationOptions{ + Root: root, + ID: "anchor-1", + Kind: "status", + Headline: "anchoring check", + }); err != nil { + t.Fatal(err) + } + absRoot, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + // The file must land at filepath.Abs(root)/_bridge/fo-initiate.jsonl — the + // same anchor bridgealert.AppendPermission uses — NOT a canonicalBridgeRoot + // git-root walk-up. + want := filepath.Join(absRoot, "_bridge", "fo-initiate.jsonl") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected file at %s: %v", want, err) + } +} + +func TestTruncateInitiate_KeepsOpenGate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fo-initiate.jsonl") + + var lines []string + // An OLD still-open gate-review that must survive truncation. + openGate := InitiationRecord{ + Schema: 1, ID: "gate-old", TS: "2026-07-03T00:00:00Z", + Kind: "gate-review", Headline: "old open gate", RequestID: "gate-old", Status: "open", + } + lines = append(lines, mustJSON(t, openGate)) + // A resolved old gate that is NOT protected (should be dropped). + resolvedGate := InitiationRecord{ + Schema: 1, ID: "gate-done", TS: "2026-07-03T00:00:01Z", + Kind: "gate-review", Headline: "resolved gate", RequestID: "gate-done", Status: "resolved", + } + lines = append(lines, mustJSON(t, resolvedGate)) + // Enough status records to exceed maxLines and push the gate before the tail. + for i := 0; i < defaultMaxLines+200; i++ { + lines = append(lines, mustJSON(t, InitiationRecord{ + Schema: 1, ID: fmt.Sprintf("status-%d", i), TS: "2026-07-03T01:00:00Z", + Kind: "status", Headline: fmt.Sprintf("noise %d", i), Status: "open", + })) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + truncateInitiate(path) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + kept := strings.Split(strings.TrimSpace(string(data)), "\n") + // keepLines tail + exactly the one protected open gate. + if len(kept) != defaultKeepLines+1 { + t.Fatalf("kept %d lines, want %d", len(kept), defaultKeepLines+1) + } + + var foundOpenGate, foundResolvedGate bool + for _, line := range kept { + rec, ok := parseRecord(line) + if !ok { + t.Fatalf("unparseable kept line: %s", line) + } + if rec.ID == "gate-old" { + foundOpenGate = true + } + if rec.ID == "gate-done" { + foundResolvedGate = true + } + } + if !foundOpenGate { + t.Fatal("still-open gate-review was evicted by truncation") + } + if foundResolvedGate { + t.Fatal("resolved gate-review should not be retained past the cap") + } +} + +func TestTruncateInitiate_EvictsGateResolvedViaInbox(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fo-initiate.jsonl") + + var lines []string + // Both gates are written status "open" — the production writer NEVER stamps + // "resolved" on disk. Resolution is signalled only by an inbox decision intent. + undecidedGate := InitiationRecord{ + Schema: 1, ID: "gate-open", TS: "2026-07-03T00:00:00Z", + Kind: "gate-review", Headline: "undecided gate", RequestID: "gate-open", Status: "open", + } + decidedGate := InitiationRecord{ + Schema: 1, ID: "gate-decided", TS: "2026-07-03T00:00:01Z", + Kind: "gate-review", Headline: "captain decided this", RequestID: "gate-decided", Status: "open", + } + lines = append(lines, mustJSON(t, undecidedGate), mustJSON(t, decidedGate)) + for i := 0; i < defaultMaxLines+200; i++ { + lines = append(lines, mustJSON(t, InitiationRecord{ + Schema: 1, ID: fmt.Sprintf("status-%d", i), TS: "2026-07-03T01:00:00Z", + Kind: "status", Headline: fmt.Sprintf("noise %d", i), Status: "open", + })) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + // A captain decision intent for gate-decided lands in the sibling inbox. + inbox := `{"schema":1,"kind":"decision","request_id":"gate-decided","verdict":"approve"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "inbox.jsonl"), []byte(inbox), 0o644); err != nil { + t.Fatal(err) + } + + truncateInitiate(path) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var foundUndecided, foundDecided bool + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + rec, ok := parseRecord(line) + if !ok { + t.Fatalf("unparseable kept line: %s", line) + } + switch rec.ID { + case "gate-open": + foundUndecided = true + case "gate-decided": + foundDecided = true + } + } + if !foundUndecided { + t.Fatal("undecided open gate-review was evicted by truncation") + } + if foundDecided { + t.Fatal("gate-review resolved by an inbox decision should be evicted past the cap") + } +} + +func mustJSON(t *testing.T, rec InitiationRecord) string { + t.Helper() + data, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/cli/bridge_alert_test.go b/internal/cli/bridge_alert_test.go new file mode 100644 index 000000000..46547bac3 --- /dev/null +++ b/internal/cli/bridge_alert_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBridgeAlertPermissionHiddenCLIWritesAlert(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{ + "bridge", "alert", "permission", + "--repo-root", root, + "--id", "perm-1", + "--host", "codex", + "--workflow", "pr-review-queue", + "--entity", "ship-1", + "--session-id", "s1", + "--reason", "sandbox blocked state gitdir", + "--command", "git status", + "--prefix-rule", "git,-C", + }, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var result struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + Queued bool `json:"queued"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if result.ID != "perm-1" || result.RequestID != "perm-1" || !result.Queued { + t.Fatalf("result = %+v", result) + } + data, err := os.ReadFile(filepath.Join(root, "_bridge", "fo-alerts.jsonl")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"reason":"sandbox blocked state gitdir"`) || + !strings.Contains(string(data), `"prefix_rule":["git","-C"]`) { + t.Fatalf("alert file missing fields:\n%s", data) + } +} + +func TestBridgeAlertPermissionHiddenCLIRejectsMissingFlagValueWithoutFailing(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run(context.Background(), + []string{ + "bridge", "alert", "permission", + "--repo-root", root, + "--id", "perm-1", + "--reason", + "--command", "git status", + }, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + + if code != 0 { + t.Fatalf("exit = %d, stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var result struct { + Queued bool `json:"queued"` + Error string `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if result.Queued || !strings.Contains(result.Error, "missing value for --reason") { + t.Fatalf("result = %+v", result) + } + if _, err := os.Stat(filepath.Join(root, "_bridge", "fo-alerts.jsonl")); !os.IsNotExist(err) { + t.Fatalf("alert file err = %v, want not exist", err) + } +} diff --git a/internal/cli/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_inbox_test.go b/internal/cli/bridge_inbox_test.go new file mode 100644 index 000000000..8cc83e551 --- /dev/null +++ b/internal/cli/bridge_inbox_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBridgeInboxDrainAckCommitCLI drives the full FO-facing verb cycle through +// the hidden CLI to lock the argv grammar and JSON result shapes. +func TestBridgeInboxDrainAckCommitCLI(t *testing.T) { + root := t.TempDir() + bridgeDir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bridgeDir, "inbox.jsonl"), + []byte(`{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","text":"go","target":"alpha"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + drain := runInbox(t, root, "drain", "--host", "claude", "--repo-root", root, "--slug", "alpha", "--session-id", "sess-a") + if drain["status"] != "ok" || drain["count"].(float64) != 1 { + t.Fatalf("drain = %v, want ok/count 1", drain) + } + + ack := runInbox(t, root, "ack", "--repo-root", root, "--slug", "alpha", "--line", "1", "--id", "i1", + "--ts", "2026-07-03T11:00:00Z", "--kind", "tell", "--status", "answered") + if ack["appended"] != true || ack["kind"] != "reply" { + t.Fatalf("ack = %v, want appended reply", ack) + } + + commit := runInbox(t, root, "commit", "--repo-root", root, "--slug", "alpha", "--cursor", "1") + if commit["cursor"].(float64) != 1 { + t.Fatalf("commit = %v, want cursor 1", commit) + } + + redrain := runInbox(t, root, "drain", "--host", "claude", "--repo-root", root, "--slug", "alpha") + if redrain["count"].(float64) != 0 { + t.Fatalf("re-drain = %v, want count 0", redrain) + } +} + +func TestBridgeInboxCheckReadsStdin(t *testing.T) { + root := t.TempDir() + bridgeDir := filepath.Join(root, "_bridge") + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(bridgeDir, "inbox.jsonl"), + []byte(`{"id":"i1","ts":"2026-07-03T11:00:00Z","kind":"tell","target":"alpha"}`+"\n"), 0o644) + os.WriteFile(filepath.Join(bridgeDir, "fo.alpha.json"), + []byte(`{"session_id":"sess-a","host":"claude","ts":"2026-07-03T11:00:00Z","state":"idle"}`), 0o644) + + var stdout, stderr bytes.Buffer + payload := `{"cwd":"` + root + `","session_id":"sess-a","stop_hook_active":false}` + code := run(context.Background(), + []string{"bridge", "inbox", "check", "--host", "claude"}, + nil, filepath.Join(root, "elsewhere"), strings.NewReader(payload), &stdout, &stderr, &fakeRunner{}, nil) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("stdout JSON: %v\n%s", err, stdout.String()) + } + if got["decision"] != "block" { + t.Fatalf("decision = %v, want block", got["decision"]) + } +} + +func runInbox(t *testing.T, root string, args ...string) map[string]any { + t.Helper() + var stdout, stderr bytes.Buffer + full := append([]string{"bridge", "inbox"}, args...) + code := run(context.Background(), full, nil, filepath.Join(root, "elsewhere"), + strings.NewReader(""), &stdout, &stderr, &fakeRunner{}, nil) + if code != 0 { + t.Fatalf("%v exit = %d stderr=%s", args, code, stderr.String()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("%v stdout JSON: %v\n%s", args, err, stdout.String()) + } + return got +} diff --git a/internal/cli/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..167170ad1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4,15 +4,21 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" "os" "os/exec" + "strconv" "strings" "github.com/spf13/cobra" + "github.com/spacedock-dev/spacedock/internal/bridgealert" + "github.com/spacedock-dev/spacedock/internal/bridgeegress" + "github.com/spacedock-dev/spacedock/internal/bridgeingress" + "github.com/spacedock-dev/spacedock/internal/bridgeinitiate" "github.com/spacedock-dev/spacedock/internal/claudeteam" "github.com/spacedock-dev/spacedock/internal/dispatch" "github.com/spacedock-dev/spacedock/internal/safehouse" @@ -149,6 +155,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 +464,283 @@ func newDispatchCommand(probe claudeteam.TeamStateProbe, stdin io.Reader, stdout } } +// newBridgeCommand is a hidden Bridge-facing surface. Egress stays silent and +// no-op-safe because it is observe-only telemetry; ingress wake and alert writes +// print compact JSON results that Bridge can surface without knowing host internals. +func newBridgeCommand(dir string, stdin io.Reader) *cobra.Command { + return &cobra.Command{ + Use: "bridge egress emit --host | ingress wake --host codex | inbox drain|ack|commit|check | alert permission | initiate --kind status|reco|gate-review", + Hidden: true, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + 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" { + return runBridgeInbox(cmd, args[1:], dir, stdin) + } + if len(args) >= 2 && args[0] == "alert" && args[1] == "permission" { + opts, parseErr := parseBridgeAlertPermission(args[2:], dir) + if parseErr != "" { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(bridgealert.Result{Queued: false, Error: parseErr}) + return nil + } + result, err := bridgealert.AppendPermission(opts) + if err != nil { + return err + } + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) + return nil + } + if len(args) >= 1 && args[0] == "initiate" { + opts, parseErr := parseBridgeInitiate(args[1:], dir) + if parseErr != "" { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(bridgeinitiate.Result{Queued: false, Error: parseErr}) + return nil + } + result, err := bridgeinitiate.AppendInitiation(opts) + if err != nil { + return err + } + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(result) + return nil + } + return nil + }, + } +} + +// runBridgeInbox dispatches the deterministic drain/ack/commit/check verbs the +// FO uses in place of hand-written shell. Each prints a compact JSON result. +func runBridgeInbox(cmd *cobra.Command, args []string, dir string, stdin io.Reader) error { + if len(args) == 0 { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]string{"error": "missing inbox subcommand (drain|ack|commit|check)"}) + return nil + } + sub := args[0] + rest := args[1:] + enc := json.NewEncoder(cmd.OutOrStdout()) + switch sub { + case "drain": + return enc.Encode(bridgeingress.Drain(bridgeingress.DrainOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + Members: parseBridgeCSVFlag(rest, "--members"), + })) + case "ack": + return enc.Encode(bridgeingress.Ack(bridgeingress.AckOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + Line: parseBridgeIntFlag(rest, "--line", 0), + ID: parseBridgeStringFlag(rest, "--id", ""), + TS: parseBridgeStringFlag(rest, "--ts", ""), + IntentKind: parseBridgeStringFlag(rest, "--kind", ""), + Status: parseBridgeStringFlag(rest, "--status", ""), + Text: parseBridgeStringFlag(rest, "--text", ""), + Granted: parseBridgeBoolFlag(rest, "--granted"), + Entity: parseBridgeStringFlag(rest, "--entity", ""), + Field: parseBridgeStringFlag(rest, "--field", ""), + Value: parseBridgeStringFlag(rest, "--value", ""), + Verdict: parseBridgeStringFlag(rest, "--verdict", ""), + RequestID: parseBridgeStringFlag(rest, "--request-id", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + })) + case "commit": + return enc.Encode(bridgeingress.Commit(bridgeingress.CommitOptions{ + Root: parseBridgeStringFlag(rest, "--repo-root", dir), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + Cursor: parseBridgeIntFlag(rest, "--cursor", -1), + })) + case "check": + return enc.Encode(bridgeingress.CheckFromReader(stdin, bridgeingress.CheckOptions{ + Host: parseBridgeHost(rest), + Root: parseBridgeStringFlag(rest, "--repo-root", ""), + Slug: parseBridgeStringFlag(rest, "--slug", ""), + SessionID: parseBridgeStringFlag(rest, "--session-id", ""), + })) + default: + return enc.Encode(map[string]string{"error": "unknown inbox subcommand: " + sub}) + } +} + +func parseBridgeAlertPermission(args []string, fallbackRoot string) (bridgealert.PermissionOptions, string) { + opts := bridgealert.PermissionOptions{Root: fallbackRoot} + for i := 0; i < len(args); i++ { + key, value, inline := strings.Cut(args[i], "=") + if !strings.HasPrefix(key, "--") { + return opts, "unexpected positional argument: " + args[i] + } + if !inline { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return opts, "missing value for " + key + } + i++ + value = args[i] + } + switch key { + case "--repo-root": + opts.Root = value + case "--id": + opts.ID = value + case "--workflow": + opts.Workflow = value + case "--entity": + opts.Entity = value + case "--host": + opts.Host = value + case "--session-id": + opts.SessionID = value + case "--reason": + opts.Reason = value + case "--command": + opts.Command = value + case "--prefix-rule": + opts.PrefixRule = csvParts(value) + default: + return opts, "unknown flag: " + key + } + } + return opts, "" +} + +func parseBridgeInitiate(args []string, fallbackRoot string) (bridgeinitiate.InitiationOptions, string) { + opts := bridgeinitiate.InitiationOptions{Root: fallbackRoot} + for i := 0; i < len(args); i++ { + key, value, inline := strings.Cut(args[i], "=") + if !strings.HasPrefix(key, "--") { + return opts, "unexpected positional argument: " + args[i] + } + if !inline { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return opts, "missing value for " + key + } + i++ + value = args[i] + } + switch key { + case "--repo-root": + opts.Root = value + case "--id": + opts.ID = value + case "--kind": + opts.Kind = value + case "--workflow": + opts.Workflow = value + case "--entity": + opts.Entity = value + case "--ship-id": + opts.ShipID = value + case "--host": + opts.Host = value + case "--session-id": + opts.SessionID = value + case "--headline": + opts.Headline = value + case "--body": + opts.Body = value + case "--request-id": + opts.RequestID = value + default: + return opts, "unknown flag: " + key + } + } + return opts, "" +} + +func parseBridgeHost(args []string) string { + for i := 0; i < len(args); i++ { + if args[i] == "--host" && i+1 < len(args) { + 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 parseBridgeIntFlag(args []string, name string, fallback int) int { + raw := parseBridgeStringFlag(args, name, "") + if raw == "" { + return fallback + } + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return fallback + } + return n +} + +// parseBridgeBoolFlag returns a tri-state pointer: nil when the flag is absent, +// so an omitted --granted is not serialized as false into the ack record. +func parseBridgeBoolFlag(args []string, name string) *bool { + for i := 0; i < len(args); i++ { + if args[i] == name { + // bare flag or followed by a value + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + v := strings.EqualFold(strings.TrimSpace(args[i+1]), "true") + return &v + } + v := true + return &v + } + if strings.HasPrefix(args[i], name+"=") { + v := strings.EqualFold(strings.TrimPrefix(args[i], name+"="), "true") + return &v + } + } + return nil +} + +func csvParts(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + 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/cli/pi.go b/internal/cli/pi.go index 36df1ef86..aeb6ba217 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -49,6 +49,7 @@ type piPackageStatus struct { registered bool ensignDiscoverable bool firstOfficerDiscoverable bool + extensionDiscoverable bool source string // the settings.json packages entry for spacedock packageRoot string // the resolved package root } @@ -251,13 +252,13 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR // empirically). Pass the extension + the checkout's skills explicitly so the // parent session loads the Spacedock first-officer/ensign skills via the // extension's resources_discover. This is the dev-override equivalent of what - // `pi install` registers for the installed path. The os.Stat guard makes the + // `pi install` registers for the installed path. The Stat guard makes the // addition graceful: if the extension is absent at the resolved path, the // flags are not added (no crash, falls back to the installed-path mechanism). if cfg.repoRoot != "" { spacedockExt := filepath.Join(cfg.repoRoot, ".pi", "extensions", "spacedock.ts") spacedockSkills := filepath.Join(cfg.repoRoot, "skills") - if _, err := os.Stat(spacedockExt); err == nil { + if err := ops.Stat(spacedockExt); err == nil { argv = append(argv, "--extension", spacedockExt, "--skill", spacedockSkills) } } @@ -564,26 +565,30 @@ func checkPiRuntime(ops piRuntimeOps, cfg piRuntimeConfig) piCheckResult { res.intercomPackageOK = ops.Stat(cfg.intercomPackageRoot) == nil res.intercomSkillOK = ops.Stat(filepath.Join(cfg.intercomPackageRoot, "skills", "pi-intercom", "SKILL.md")) == nil // The retired repo-path Stat checks (firstOfficerOK/ensignOK) are replaced by - // spacedockPackageOK: the package is registered AND ensign is discoverable via - // the package-root skill scan — the real discovery contract, not a filesystem - // coincidence at a cwd-derived path. + // spacedockPackageOK: the package is registered, its Pi extension is + // discoverable, and ensign is discoverable via the package-root skill scan — + // the real discovery contract, not a filesystem coincidence at a cwd-derived + // path. status := ops.SpacedockPackageStatus(cfg.agentDir, cfg.home) res.packageStatus = status - res.spacedockPackageOK = status.registered && status.ensignDiscoverable + res.spacedockPackageOK = status.registered && status.extensionDiscoverable && status.ensignDiscoverable // Dev override: --plugin-dir / SPACEDOCK_REPO_ROOT points at a local // Spacedock checkout. When the package is not registered in settings.json // (e.g. a fresh pi-home), the dev-override checkout satisfies the gate if - // it contains the ensign skill (skills/ensign/SKILL.md). This restores the - // documented dev-override launch path that the package-OK gate + // it contains both the Spacedock Pi extension and the ensign skill + // (skills/ensign/SKILL.md). This restores the documented dev-override launch path that the package-OK gate // inadvertently broke. When repoRoot is empty, spacedockPackageOK still // requires the registered package (the install-managed contract). + devOverrideExtensionOK := cfg.repoRoot != "" && ops.Stat(filepath.Join(cfg.repoRoot, ".pi", "extensions", "spacedock.ts")) == nil if !res.spacedockPackageOK && cfg.repoRoot != "" && + devOverrideExtensionOK && ops.Stat(filepath.Join(cfg.repoRoot, "skills", "ensign", "SKILL.md")) == nil { res.spacedockPackageOK = true res.packageStatus = piPackageStatus{ registered: true, ensignDiscoverable: true, firstOfficerDiscoverable: ops.Stat(filepath.Join(cfg.repoRoot, "skills", "first-officer", "SKILL.md")) == nil, + extensionDiscoverable: true, source: cfg.repoRoot + " (dev override)", packageRoot: cfg.repoRoot, } @@ -699,11 +704,19 @@ func piSpacedockPackageStatus(agentDir, home string) piPackageStatus { if root == "" { continue } - name, skillPaths := readPackagePiSkills(root) + name, extensions, skillPaths := readPackagePiManifest(root) if name != "spacedock" { continue } st := piPackageStatus{registered: true, source: src, packageRoot: root} + for _, ext := range extensions { + clean := cleanRelativePath(ext) + if clean == filepath.Join(".pi", "extensions", "spacedock.ts") { + if _, err := os.Stat(filepath.Join(root, clean)); err == nil { + st.extensionDiscoverable = true + } + } + } for _, sp := range skillPaths { dir := filepath.Join(root, sp) if piSkillFileExists(dir, "ensign") { @@ -732,21 +745,26 @@ func piPackageSourceFromEntry(raw json.RawMessage) string { return "" } -func readPackagePiSkills(root string) (name string, skills []string) { +func readPackagePiManifest(root string) (name string, extensions, skills []string) { data, err := os.ReadFile(filepath.Join(root, "package.json")) if err != nil { - return "", nil + return "", nil, nil } var pkg struct { Name string `json:"name"` Pi struct { - Skills []string `json:"skills"` + Extensions []string `json:"extensions"` + Skills []string `json:"skills"` } `json:"pi"` } if json.Unmarshal(data, &pkg) != nil { - return "", nil + return "", nil, nil } - return pkg.Name, pkg.Pi.Skills + return pkg.Name, pkg.Pi.Extensions, pkg.Pi.Skills +} + +func cleanRelativePath(p string) string { + return filepath.Clean(strings.TrimPrefix(p, "./")) } func piSkillFileExists(dir, skill string) bool { diff --git a/internal/cli/pi_egress_test.go b/internal/cli/pi_egress_test.go new file mode 100644 index 000000000..6567b642e --- /dev/null +++ b/internal/cli/pi_egress_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPiExtensionWiresBridgeEgressThroughSpacedockCLI(t *testing.T) { + srcPath := filepath.Join("..", "..", ".pi", "extensions", "spacedock.ts") + data, err := os.ReadFile(srcPath) + if err != nil { + t.Fatal(err) + } + src := string(data) + for _, want := range []string{ + "process.env.SPACEDOCK_BIN || \"spacedock\"", + "[\"bridge\", \"egress\", \"emit\", \"--host\", \"pi\"]", + "\"session_start\"", + "\"session_shutdown\"", + "\"agent_start\"", + "\"agent_end\"", + "\"tool_execution_start\"", + "\"tool_execution_end\"", + "\"tool_call\"", + "\"tool_result\"", + "stdio: [\"pipe\", \"ignore\", \"ignore\"]", + } { + if !strings.Contains(src, want) { + t.Fatalf("Pi extension missing %q:\n%s", want, src) + } + } + for _, notWant := range []string{ + "_bridge/events.jsonl", + "_bridge/sessions", + "session marker", + } { + if strings.Contains(src, notWant) { + t.Fatalf("Pi extension must not directly write %q or claim marker parity:\n%s", notWant, src) + } + } +} + +func TestPackageManifestAdvertisesPiExtension(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "package.json")) + if err != nil { + t.Fatal(err) + } + var pkg struct { + Pi struct { + Extensions []string `json:"extensions"` + Skills []string `json:"skills"` + } `json:"pi"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + t.Fatal(err) + } + if !containsString(pkg.Pi.Extensions, "./.pi/extensions/spacedock.ts") { + t.Fatalf("package.json pi.extensions must include ./.pi/extensions/spacedock.ts, got %v", pkg.Pi.Extensions) + } + if !containsString(pkg.Pi.Skills, "./skills") { + t.Fatalf("package.json pi.skills must include ./skills, got %v", pkg.Pi.Skills) + } +} + +func TestPiPackageStatusRequiresSpacedockExtension(t *testing.T) { + home := t.TempDir() + agentDir := filepath.Join(home, ".pi", "agent") + pkgRoot := filepath.Join(agentDir, "packages", "spacedock") + writeFileWithDirs(t, filepath.Join(agentDir, "settings.json"), `{"packages":["`+pkgRoot+`"]}`+"\n") + writeFileWithDirs(t, filepath.Join(pkgRoot, "package.json"), `{ + "name": "spacedock", + "pi": { + "extensions": ["./.pi/extensions/spacedock.ts"], + "skills": ["./skills"] + } + }`+"\n") + writePiSkillFixtures(t, pkgRoot) + writeFileWithDirs(t, filepath.Join(pkgRoot, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") + + status := piSpacedockPackageStatus(agentDir, home) + if !status.registered || !status.ensignDiscoverable || !status.firstOfficerDiscoverable { + t.Fatalf("status should find registered skills before extension gate, got %+v", status) + } + if !status.extensionDiscoverable { + t.Fatalf("status should find package extension, got %+v", status) + } + + if err := os.Remove(filepath.Join(pkgRoot, ".pi", "extensions", "spacedock.ts")); err != nil { + t.Fatal(err) + } + status = piSpacedockPackageStatus(agentDir, home) + if !status.registered || !status.ensignDiscoverable { + t.Fatalf("status should still detect package and skills without extension, got %+v", status) + } + if status.extensionDiscoverable { + t.Fatalf("extensionDiscoverable=true after removing extension: %+v", status) + } + check := checkPiRuntime(&fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(pkgRoot, t.TempDir()), + packageStatus: status, + }, piRuntimeConfigFromEnv([]string{"HOME=" + home}, "/non-repo-cwd", "")) + if check.spacedockPackageOK { + t.Fatalf("runtime gate must reject installed Spacedock package without Pi extension: %+v", check) + } +} + +func containsString(values []string, want string) bool { + for _, got := range values { + if got == want { + return true + } + } + return false +} diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 6c6242b8f..afb736ba0 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -61,6 +61,7 @@ func healthyPiPackageStatus() piPackageStatus { registered: true, ensignDiscoverable: true, firstOfficerDiscoverable: true, + extensionDiscoverable: true, source: "git:github.com/spacedock-dev/spacedock", packageRoot: "/pkg-store/spacedock", } @@ -153,9 +154,11 @@ func TestRunPi_DevOverridePassesSpacedockExtensionAndSkills(t *testing.T) { writeFileWithDirs(t, filepath.Join(repo, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") pkg := t.TempDir() writePiSubagentsFixtures(t, pkg) + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) ops := &fakePiRuntimeOps{ lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + statOK: statOK, packageStatus: healthyPiPackageStatus(), } var stdout, stderr bytes.Buffer @@ -653,9 +656,10 @@ func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { // TestPiRuntimeDevOverrideSatisfiesPackageGate verifies the regression fix for // the --plugin-dir / SPACEDOCK_REPO_ROOT dev-override launch path: when the // Spacedock package is NOT registered in settings.json (fresh pi-home), a -// dev-override repoRoot that contains skills/ensign/SKILL.md satisfies the -// package gate so the launch path reaches the ensign. The inverse (no -// repoRoot, no package) still fails the gate — the install-managed contract. +// dev-override repoRoot that contains the Spacedock Pi extension and +// skills/ensign/SKILL.md satisfies the package gate so the launch path reaches +// the ensign. The inverse (no repoRoot, no package) still fails the gate — the +// install-managed contract. func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { repo := t.TempDir() writePiSkillFixtures(t, repo) @@ -670,7 +674,11 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { } check := checkPiRuntime(&fakePiRuntimeOps{ lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + statOK: func() map[string]bool { + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) + return statOK + }(), // No package registered in settings.json. packageStatus: piPackageStatus{}, }, cfg) @@ -690,11 +698,16 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { t.Run("runPi launches with dev override and no installed package", func(t *testing.T) { home := t.TempDir() + writeFileWithDirs(t, filepath.Join(repo, ".pi", "extensions", "spacedock.ts"), "export default function(){}\n") var stdout, stderr bytes.Buffer code := runPi(context.Background(), []string{"do work", "--plugin-dir", repo, "--", "--print"}, "/non-repo-cwd", piTestEnv(pkg, home), &fakePiRuntimeOps{ - lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), + lookPath: piHealthyPathFixtures(), + statOK: func() map[string]bool { + statOK := statOKForPiResources(repo, pkg) + markSpacedockPiExtension(statOK, repo) + return statOK + }(), packageStatus: piPackageStatus{}, // not installed }, &stdout, &stderr) if code != 0 { @@ -744,6 +757,22 @@ func TestPiRuntimeDevOverrideSatisfiesPackageGate(t *testing.T) { t.Fatalf("dev override without ensign skill must not satisfy spacedockPackageOK") } }) + + t.Run("dev override without Spacedock extension does not satisfy gate", func(t *testing.T) { + repoWithoutExtension := t.TempDir() + writePiSkillFixtures(t, repoWithoutExtension) + home := t.TempDir() + statOK := statOKForPiResources(repoWithoutExtension, pkg) + cfg := piRuntimeConfigFromEnv(append(piTestEnv(pkg, home), "SPACEDOCK_REPO_ROOT="+repoWithoutExtension), "/non-repo-cwd", "") + check := checkPiRuntime(&fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOK, + packageStatus: piPackageStatus{}, + }, cfg) + if check.spacedockPackageOK { + t.Fatalf("dev override without Spacedock extension must not satisfy spacedockPackageOK") + } + }) } func assertEqual(t *testing.T, got, want string) { @@ -785,6 +814,10 @@ func statOKForPiResources(repo, pkg string) map[string]bool { } } +func markSpacedockPiExtension(statOK map[string]bool, repo string) { + statOK[filepath.Join(repo, ".pi", "extensions", "spacedock.ts")] = true +} + func piHealthyPathFixtures() map[string]string { return map[string]string{ "pi": "/bin/pi", diff --git a/internal/cli/state_init_test.go b/internal/cli/state_init_test.go index 3355654f6..468f4b16b 100644 --- a/internal/cli/state_init_test.go +++ b/internal/cli/state_init_test.go @@ -33,7 +33,7 @@ stages: // git runs a git command in dir, failing the test on a non-zero exit. func git(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", @@ -50,7 +50,7 @@ func git(t *testing.T, dir string, args ...string) string { // where a 2nd `git worktree add` is expected to FATAL. func gitOK(t *testing.T, dir string, args ...string) (string, bool) { t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go index a3d4e1f53..876eedff6 100644 --- a/internal/contractlint/fo_feed_and_eager_drain_test.go +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -55,3 +55,96 @@ func TestFOFeedNarration(t *testing.T) { } } } + +// TestBridgeConversationReplyContract locks the full Bridge conversation loop: +// Bridge writes stable intent ids and a frozen target_set, and the FO writes a +// post-action reply/ack line to _bridge/fo-replies.jsonl. +func TestBridgeConversationReplyContract(t *testing.T) { + modPath := filepath.Join(repoRoot(t), "docs", "dev", "_mods", "bridge-inbox.md") + modData, err := os.ReadFile(modPath) + if err != nil { + t.Fatalf("read bridge-inbox mod: %v", err) + } + mod := string(modData) + for _, r := range []string{ + `"id":""`, + `"target_set":["", "..."]`, + `If Bridge cannot resolve any current member slugs for a broadcast, it omits ` + "`target_set`", + `If ` + "`target_set`" + ` is present, act only when ` + "`\"$SLUG\"`" + ` is in ` + "`target_set`", + `Ignore ` + "`target`" + ` entirely for routing in that case, including ` + "`target == \"all\"`", + `If ` + "`target_set`" + ` is absent, preserve old target behavior`, + `physical ` + "`LINE`" + ` number`, + `not merely after shell-reading the line`, + `_bridge/fo-replies.jsonl`, + `"kind":"reply"|"conn-ack"|"decision-ack"|"permission-ack"`, + `"in_reply_to_line":123`, + `"status":"answered"|"accepted"|"released"|"applied"|"denied"|"rejected"|"blocked"`, + `target` + "` is the actual acknowledging workflow slug (`$SLUG`), never " + "`\"all\"`", + `Cursor remains the delivery/read source of truth`, + `Bridge folds them by intent id (or legacy line fallback), acknowledging target, and reply kind`, + `one complete newline-terminated JSON object in one append operation`, + `Do not rewrite, truncate, sort, or compact ` + "`fo-replies.jsonl`", + `Append a rejected ack only when the record is addressed to ` + "`\"$SLUG\"`" + ` and has enough valid metadata to produce a valid reply shape`, + `Unknown-kind or unrouteable records cannot be represented by the reply schema`, + } { + if !strings.Contains(mod, r) { + t.Errorf("bridge-inbox mod no longer pins the reply-loop contract: missing %q.", r) + } + } + + contractPath := filepath.Join(repoRoot(t), "docs", "dev", "bridge-egress-contract.md") + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatalf("read bridge egress contract: %v", err) + } + contract := string(contractData) + for _, r := range []string{ + `Bridge writes every new intent with an opaque ` + "`id`", + `writes a frozen ` + "`target_set`" + ` array when it can resolve the workflow slugs`, + `unknown-recipient broadcasts`, + `When ` + "`target_set`" + ` is present it is authoritative`, + `## ` + "`_bridge/fo-replies.jsonl`" + ` — captain-intent acknowledgements`, + `"schema":1`, + `"target":""`, + `"intent_kind":""`, + `"status":""`, + `target` + "` is the actual acknowledging workflow slug, never " + "`all`", + `applied` + "` when a decision is present and gate resolution finished or was already satisfied", + `blocked` + "` when a valid intent could not finish", + `rejected` + "` when an intent is invalid or unresolvable", + `not an exactly-once delivery ledger`, + `Append-only and best-effort: write one complete newline-terminated JSON object in one append operation`, + } { + if !strings.Contains(contract, r) { + t.Errorf("bridge egress contract no longer pins the reply-loop contract: missing %q.", r) + } + } +} + +// TestFleetModeMentionsFrozenTargetSet guards the high-level FO routing guide: +// fleet-mode drains must obey Bridge's frozen target_set before falling back to +// legacy target behavior. +func TestFleetModeMentionsFrozenTargetSet(t *testing.T) { + // Fleet Mode relocated out of the boot-resident shared core into the deferred + // references/fo-fleet.md (main's <=26755-byte collapse cap; boot core stays lean), + // so the target_set/egress routing guide lives — and is pinned — there. + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "fo-fleet.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fo-fleet: %v", err) + } + c := string(data) + for _, r := range []string{ + `frozen ` + "`target_set`", + `act only when the member's ` + "`$SLUG`" + ` is in ` + "`target_set`", + `_bridge/fo-replies.jsonl`, + `with ` + "`target`" + ` set to the actual member slug, never ` + "`all`", + `Legacy records without ` + "`target_set`" + ` keep old ` + "`target`" + ` behavior`, + `Claude, Codex, and Pi have packaged event producers`, + `deterministic session→entity marker parity remains Claude-proven only`, + } { + if !strings.Contains(c, r) { + t.Errorf("fleet-mode routing guide no longer pins target_set/ack behavior: missing %q.", r) + } + } +} diff --git a/internal/contractlint/gate_drain_before_present_test.go b/internal/contractlint/gate_drain_before_present_test.go index c9046cb47..be992e116 100644 --- a/internal/contractlint/gate_drain_before_present_test.go +++ b/internal/contractlint/gate_drain_before_present_test.go @@ -13,9 +13,9 @@ import ( // TestGateAssembleVerdictDrainsBeforePresenting locks the drain-before-present // fix: when the FO reaches a gate, it must first fire that entity's idle hooks // (draining a Bridge-queued `decision` record) and skip the presentation if the -// decision already resolved the gate. Bridge cannot wake a parked FO, so without -// this a queued captain decision sits unprocessed while the FO redundantly -// presents — the gate-vs-inbox race observed on DRC-3354. +// decision already resolved the gate. Bridge wake is best-effort, while delivery +// is confirmed only by the FO-owned drain and ack; without this a queued captain +// decision can still collide with a redundant terminal prompt. func TestGateAssembleVerdictDrainsBeforePresenting(t *testing.T) { path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") data, err := os.ReadFile(path) @@ -26,7 +26,8 @@ func TestGateAssembleVerdictDrainsBeforePresenting(t *testing.T) { for _, r := range []string{ "drain before presenting", - "Bridge cannot wake a parked FO", + "Bridge wake is best-effort", + "FO-owned drain and ack", "do NOT present", } { if !strings.Contains(content, r) { diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index e3bf83b23..999a84018 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -1,110 +1,18 @@ #!/usr/bin/env bash -# ABOUTME: Spacedock plugin hook — emit a normalized FO event line to _bridge/events.jsonl -# ABOUTME: for the external Bridge command-center UI to tail. Observe-only; never blocks the session. +# ABOUTME: Claude plugin hook wrapper for Bridge egress; observe-only and silent. # -# Registered for SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop -# in hooks/hooks.json (all async). It writes a stable, Spacedock-owned event contract so -# Bridge does not have to couple to Claude Code's internal transcript JSONL format. -# -# Events land in «session-cwd»/_bridge/events.jsonl — the same _bridge/ dir the bridge-inbox -# mod drains and that Bridge resolves from the repo root. agent_id/agent_type are empty for -# the main FO session and set for ensign subagents, so Bridge can tell FO vs ensign activity. -# -# Honesty + safety: this only observes. It must never alter the session, so it always exits 0 -# and degrades to a silent no-op when jq is unavailable, the payload lacks a cwd, or the write -# fails — a telemetry side-channel must not be able to break the FO. +# Registered by hooks/hooks.json for Claude lifecycle events. The normalized +# Bridge schema and marker logic live in the spacedock binary, so host wrappers do +# not grow private JSON contracts. set -u -# No jq → no-op. (Spacedock already assumes jq for its gh-driven hooks.) -command -v jq >/dev/null 2>&1 || exit 0 - -payload="$(cat 2>/dev/null)" || exit 0 -[ -n "$payload" ] || exit 0 - -cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" || exit 0 -[ -n "$cwd" ] || exit 0 - -dir="$cwd/_bridge" -mkdir -p "$dir" 2>/dev/null || exit 0 - -ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -# Normalize to a stable line. Only generic, non-sensitive fields are emitted (no tool inputs -# or outputs, no prompt text) — liveness, not content. -events="$dir/events.jsonl" -printf '%s' "$payload" | jq -c \ - --arg ts "$ts" \ - '{ - ts: $ts, - event: (.hook_event_name // "unknown"), - session_id: (.session_id // ""), - agent_id: (.agent_id // ""), - agent_type: (.agent_type // ""), - detail: { - tool: (.tool_name // ""), - source: (.source // "") - } - }' >> "$events" 2>/dev/null || exit 0 - -# ── Session→entity marker (DETERMINISTIC running-badge source) ─────────────────────── -# Bridge maps a live working session to its ship via «cwd»/_bridge/sessions/.json. -# Derive it HERE — from the hook, which fires on every tool call — instead of relying on the -# ensign to run a first-action shell (it skips that ~3/4 of the time). On an ENSIGN's first -# Read of its entity file (.../docs/spacedock//.md, flat or /index.md), -# record {session_id, entity, workflow}: the path carries BOTH the workflow (so Bridge's join -# is collision-free across workflows that reuse a ticket id) and the full slug. First-write- -# wins per session, so the ensign's OWN entity — read before any duplicate-check sibling reads -# — is what gets recorded. Observe-only and best-effort: every step degrades to a no-op. -m_sid="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)" -m_type="$(printf '%s' "$payload" | jq -r '.agent_type // empty' 2>/dev/null)" -m_evt="$(printf '%s' "$payload" | jq -r '.hook_event_name // empty' 2>/dev/null)" -m_tool="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null)" -case "$m_sid" in *[!A-Za-z0-9._-]*) m_sid="" ;; esac # unsafe id → skip (never escape _bridge/) -if [ -n "$m_sid" ] && [ "$m_type" = "spacedock:ensign" ] && [ "$m_evt" = "PostToolUse" ] && [ "$m_tool" = "Read" ]; then - marker="$dir/sessions/$m_sid.json" - if [ ! -f "$marker" ]; then # first-write-wins → the ensign's own entity (read first) - fp="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" - case "$fp" in - */docs/spacedock/*/*.md|docs/spacedock/*/*.md) # absolute OR repo-relative (the FO passes a relative {entity_file_path}) - # Workflow = the path segment right AFTER docs/spacedock/ — robust to a - # split-root entity nested under /.spacedock-state/.md (taking the - # entity's parent dir would wrongly yield ".spacedock-state"). - rest="${fp##*/docs/spacedock/}" # absolute / nested: strip through the last /docs/spacedock/ - rest="${rest#docs/spacedock/}" # repo-relative: strip the leading docs/spacedock/ - wf="${rest%%/*}" # first segment after docs/spacedock/ = the workflow dir - if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: ...//index.md - slug="$(basename "$(dirname "$fp")")" - else # flat entity: .../.md - slug="$(basename "$fp" .md)" - fi - case "$fp" in */_archive/*) wf="" ;; esac # never mark an archived (terminal) entity - case "$wf" in ""|_*|*[!A-Za-z0-9._-]*) wf="" ;; esac # skip _-dirs, unsafe - case "$slug" in ""|README|*[!A-Za-z0-9._-]*) slug="" ;; esac - if [ -n "$slug" ] && [ -n "$wf" ]; then - mkdir -p "$dir/sessions" 2>/dev/null && - printf '{"session_id":"%s","entity":"%s","workflow":"%s"}\n' "$m_sid" "$slug" "$wf" > "$marker" 2>/dev/null || : - fi - ;; - esac - fi -fi - -# Best-effort size cap: a liveness side-channel must not grow without bound (PostToolUse -# fires on every tool call). When the log passes max_lines, keep only the most recent -# keep_lines. Lock-free and best-effort by design — this is liveness, not a ledger, so -# losing a few lines to a concurrent append during the rare trim is acceptable, and every -# step degrades to a no-op ($$ keeps the temp unique across concurrent async hooks; any -# failure leaves the existing log untouched). Never block or break the FO. -max_lines=2000 -keep_lines=1000 -n="$(wc -l < "$events" 2>/dev/null || echo 0)" -if [ "${n:-0}" -gt "$max_lines" ] 2>/dev/null; then - tmp="$events.tmp.$$" - if tail -n "$keep_lines" "$events" > "$tmp" 2>/dev/null; then - mv -f "$tmp" "$events" 2>/dev/null || rm -f "$tmp" 2>/dev/null - else - rm -f "$tmp" 2>/dev/null - fi +if [ -n "${SPACEDOCK_BIN:-}" ] && [ -x "${SPACEDOCK_BIN:-}" ]; then + bin="${SPACEDOCK_BIN}" +elif command -v spacedock >/dev/null 2>&1; then + bin="spacedock" +else + exit 0 fi +"$bin" bridge egress emit --host claude >/dev/null 2>&1 || : exit 0 diff --git a/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 0c08c1f8e..36f73fefe 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,7 +6,7 @@ How the shared ensign core executes on Claude Code. The ensign is dispatched by the first officer via the Agent tool. The dispatch prompt is authoritative for all assignment fields: entity, stage, stage definition, workflow location, and checklist. -Bridge's "running" badge needs no action from you: the Spacedock event hook records your session→entity link **deterministically** when you Read your entity file (it derives the entity + workflow from the path), so Bridge can show the ship you are driving as running. Just do your work — reading the entity file is part of it. +Bridge's "running" badge needs no action from you: the Spacedock plugin hooks (`hooks/hooks.json` → `scripts/spacedock-bridge-events.sh`) record your session→entity link **deterministically** on your first Read of your entity file (the hook derives the entity + workflow from the path and writes `_bridge/sessions/.json`), so Bridge shows the ship you are driving as running. Just do your work — reading the entity file is part of it. (This egress producer is bound on Claude only; the FO adapter's Bridge-egress binding states the per-host coverage.) ## Clarification diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index 14c3dbaa2..d002ff6fb 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -33,3 +33,29 @@ See `## Probe and Ideation Discipline` in the shared core — its Grep-over-Read ## Filing New Entities To file a seed task, do NOT use the Write tool to hand-assemble frontmatter after a `status --next-id` preview — that two-step flow can land a stale id when the `--next-id` candidate drifts between preview and write. Use `${SPACEDOCK_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 `Skill(skill="spacedock:fo-write-core")` for the full contract). `--next-id` is a candidate-preview surface only. `new` writes but does not commit; for split-root state checkouts the FO still does the path-scoped commit + push after `new` (per the shared core's State Management rule). + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge's read-only command center reads FO liveness and activity from `_bridge/events.jsonl` (the normalized event stream) and `_bridge/sessions/.json` (the session→entity marker), keyed by a per-host session id. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); this adapter binds the Claude producer for it. (The ingress half — captain intent the FO drains — rides the host-neutral bridge-inbox mod through the packaged `spacedock bridge inbox` verbs; see **Bridge ingress** below.) + +- **FO event emission** — PRESENT on Claude, via the plugin hooks: `hooks/hooks.json` registers `scripts/spacedock-bridge-events.sh` on SessionStart/UserPromptSubmit/PostToolUse/Notification/Stop/SubagentStop (all async, observe-only). The wrapper delegates to `spacedock bridge egress emit --host claude`, which normalizes each Claude hook payload to the events.jsonl contract line and, on an ensign's first Read of its entity file, derives the deterministic `_bridge/sessions/.json` marker (the running-badge source). `agent_id`/`agent_type` are empty for the main FO session and set for ensign subagents, so Bridge distinguishes FO vs ensign activity. No FO action is required — the hooks fire on every tool call. (Codex/Pi have event producers but do not yet claim deterministic marker parity — see those adapters.) +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CLAUDE_CODE_SESSION_ID` on Claude, so `fo.$SLUG.json` carries the same id the event stream and session markers use and Bridge can join liveness to activity. No per-tick `export` is needed; leave `SD_SESSION_ID` unset on Claude (the event producer stamps `events.jsonl`/markers from the hook payload's session id, so an override that differs would desync the heartbeat from the event stream). + +## Bridge ingress (captain intent → FO) + +Captain intent that Bridge queues in `_bridge/inbox.jsonl` is drained by the host-neutral `bridge-inbox` mod, but the mechanism is **packaged in the binary** so the FO never hand-writes cursor/JSONL shell. Bind `«host» = claude` for every verb; the command resolves the session id from the egress binding above. + +### Runtime implementation + +- **`«inbox.drain»`** → `spacedock bridge inbox drain --host claude --slug $SLUG`. Stamps the liveness heartbeat (`_bridge/fo.$SLUG.json`, now carrying `host`), then returns the new, addressed, not-yet-acked records as JSON with a `high_water` mark. Does not advance the cursor. Run it at the eager top-of-loop drain and at startup/idle (per `bridge-inbox` `## Hook: startup`/`idle`). +- **`«inbox.ack»`** → `spacedock bridge inbox ack --host claude --slug $SLUG --line «n» --id «id» --ts «ts» --kind «intent-kind» --status «status» […]`. Appends one compact reply/ack line after you have acted on the record; the command derives the reply kind and serializes the JSON. +- **`«inbox.commit»`** → `spacedock bridge inbox commit --slug $SLUG --cursor «high_water»`. Advances the per-workflow cursor once, after act + ack for the whole batch. Monotonic. +- **`«inbox.wake»`** → the Claude durable wake is **in-session**, not an external resume: `hooks/hooks.json` registers a synchronous `Stop` hook (`scripts/spacedock-bridge-inbox-check.sh` → `spacedock bridge inbox check --host claude`) that reads the Stop payload, resolves this session's slug from its heartbeat/marker, and returns a `block` decision when intent is queued so the FO drains before stopping. A parked/closed Claude session cannot be safely resumed out-of-band (the transcript has no write locking), so there is no `spacedock bridge ingress wake --host claude` — do not add one. Bridge shows a queued count for a truly idle session and the captain nudges it. + +### State-machine semantics (so you never read source to act) + +`status --next` and the entity's own `status` field answer two different questions; conflating them sends an ensign to the wrong stage: + +- The entity's **`status` frontmatter field is the current stage** — the stage the entity is *in now*, and the stage an ensign dispatched now works. +- `status --next` is a **look-ahead preview** of the stage the entity would enter *after* its current stage completes. It is a preview, not a dispatch target. Do not dispatch an ensign into the `--next` stage; dispatch into the current stage and let stage completion advance the entity (the FO advances with `status --set`/the gate flow, per `## Completion and Gates`). +- At a gate, the current stage is the gate stage; resolve the gate (captain decision or given-the-conn auto-approve) before the entity moves on. `status --boot --json` reports the workflow's stage order and `id_style`; consult it rather than reconstructing the state machine from source. diff --git a/skills/first-officer/references/codex-first-officer-runtime.md b/skills/first-officer/references/codex-first-officer-runtime.md index e4d302f76..67b12eb7f 100644 --- a/skills/first-officer/references/codex-first-officer-runtime.md +++ b/skills/first-officer/references/codex-first-officer-runtime.md @@ -50,3 +50,16 @@ Feedback rejection is the load-bearing exception to casual fresh dispatch. When ## Captain Interaction The captain is the user of the Codex session. Communicate gate results, clarifications, and status directly in the conversation. + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Codex the event producer is packaged through Codex plugin hooks; deterministic session→entity markers are not yet claimed. + +- **FO event emission** — PACKAGED/FIXTURE-COVERED on Codex: `.codex-plugin/plugin.json` points at `hooks/codex-hooks.json`, whose non-async command hooks call `spacedock bridge egress emit --host codex` directly via `SPACEDOCK_BIN` or `PATH`. This covers packaging and minimal lifecycle payload normalization without reusing Claude's async hook file, `CLAUDE_PLUGIN_ROOT`, or Codex plugin-root state. It does not prove `_bridge/sessions/` marker parity: Codex marker support requires durable live evidence for child identity plus entity path. +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`, falling back to `$CODEX_THREAD_ID` on Codex when the harness exposes it; when no thread id is exposed it stays empty and the heartbeat carries an empty session id (still a valid liveness tick — Bridge reads freshness from `ts`). + +## Bridge ingress (captain intent → FO) + +Captain intent Bridge queues in `_bridge/inbox.jsonl` is drained through the same host-neutral packaged verbs Claude uses, bound `«host» = codex`: `spacedock bridge inbox drain --host codex --slug $SLUG` (stamps the heartbeat with `host`, returns addressed records), `... ack ...` (appends the compact reply/ack), `... commit --cursor «high_water»` (advances the per-workflow cursor). The FO never hand-writes cursor/JSONL shell. + +- **`«inbox.wake»`** — Codex supports a durable **external** wake (unlike Claude): Bridge runs `spacedock bridge ingress wake --host codex`, which resumes a parked Codex session via `codex exec resume` and prompts it to drain. A wake is only an attempt; delivery is confirmed by the FO-owned drain + ack, so it is safe against the gate-vs-inbox race. diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 3c7a64671..7b9d72863 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -16,7 +16,7 @@ Shared first-officer semantics — the boot-resident core. The deferred status, - **one or many:** a LIST of the discovered workflow(s); one is a list of length 1 with no eager convergence. NAME them in the greet; the captain converges and acts on one via «engage»(workflow) — UNLESS a quotable fleet directive adopts the named/ALL discovered workflows as a member set (deferred `references/fo-fleet.md`). Single-entity mode fails with an ambiguity error when many. - **launch branch:** record the working tree's branch (`git rev-parse --abbrev-ref HEAD`) as your **launch branch**. A concurrent `git checkout` in this shared tree moves HEAD and deletes tracked files under you. So **before each dispatch — and before any state-changing git op — re-check the branch; if it differs, HALT**: `branch changed underneath me (); stopping`. Pin on the branch NAME (a same-branch fast-forward is not a halt); a dedicated worktree avoids this. The record's counts and PR fields are a possibly-stale local view, labeled as such, until the first «engage». -2b. **Bridge liveness heartbeat (before-greet).** When the boot record's MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its startup hook BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots. This is boot liveness, NOT event-loop work: a greet-and-stop boot (step 3 interactive) STILL runs it. The write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` no-ops the drain. In fleet mode it runs once per member. Skip when unregistered — the common boot reads no mod file. +2b. **Bridge liveness heartbeat (before-greet).** When the boot record's MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its startup hook BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat and run the initial inbox drain, so a live FO shows attached in Bridge from boot. Boot liveness, NOT event-loop work: a greet-and-stop boot (step 3 interactive) STILL runs it. Observe-only; an absent `_bridge/inbox.jsonl` no-ops the drain. Per member in fleet mode. Skip when unregistered. 3. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary from the boot record. - **Interactive:** present the summary — the managed workflow(s) with their dispatchable / ready-gate counts — and hint `Use engage ` to act; then STOP for input. Do NOT auto-dispatch, and do NOT render a `present-gate` review at the greet: NAME any ready `gate: true` gate in the summary, but assemble its review only when «engage» reaches it — the expensive deferrals, gate assembly included, stay past the greet, reached on the captain's first «engage». - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop (converging each workflow at its first «engage») to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. @@ -40,6 +40,7 @@ A greet-and-stop boot loads NONE of these — it composes its summary from the b - `references/fo-dispatch-core.md` — first worker dispatch. - `references/fo-merge-core.md` — terminal boundary. - `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: the `spacedock bridge alert permission` helper (deny / approve-once / approve-rule). ## Single-Entity Scope @@ -82,7 +83,7 @@ If the stage is gated, `«gate.assemble-verdict»(slug, stage)`, then route on t ## «gate.assemble-verdict»(slug, stage): assemble the gate review and render the verdict -- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's `idle` hooks once (keyed by its `$SLUG`) so a `decision` inbox record queued from Bridge for this gate is drained and applied now; re-read status. Bridge cannot wake a parked FO (it queues, the FO pulls), so a decision since the last idle tick would 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 still forbids inventing a verdict). Otherwise it is a no-op; present normally. +- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's `idle` hooks once (keyed by its `$SLUG`) so a Bridge-queued `decision` record for this gate is drained and applied now; re-read status. Bridge wake is best-effort, delivery confirmed only by this FO-owned drain and ack, so a 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. diff --git a/skills/first-officer/references/fo-bridge.md b/skills/first-officer/references/fo-bridge.md new file mode 100644 index 000000000..d6d4f98a7 --- /dev/null +++ b/skills/first-officer/references/fo-bridge.md @@ -0,0 +1,13 @@ +# FO Bridge Egress — Permission Blocks and Alerts + +Deferred reference for the FO's Bridge egress signals beyond the boot heartbeat. Loaded when the FO hits a permission block; not read at boot. The heartbeat + inbox-drain half lives in the boot-resident core (Startup step 2b) and the fleet routing in `references/fo-fleet.md`; the richer egress surface is documented in `docs/dev/bridge-egress-contract.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 top-level Bridge alert before parking the loop. Use the hidden helper from the repo root: + +``` +${SPACEDOCK_BIN:-spacedock} bridge alert permission --host --workflow --entity --reason "" --command "" [--prefix-rule "git,-C"] +``` + +The helper appends `_bridge/fo-alerts.jsonl`, which Bridge renders as an Approve/Deny alert. The helper's returned `id` is the alert join key; Bridge echoes it back as `request_id` on the typed `permission-decision` inbox record. `deny` leaves the action blocked, `approve-once` retries the exact blocked action once through the runtime's escalation path, and `approve-rule` retries with the proposed reusable prefix rule when one was present. This is an FO intent signal, not a bypass of host-native security; if the host still presents its own approval prompt, honor that prompt normally. diff --git a/skills/first-officer/references/fo-fleet.md b/skills/first-officer/references/fo-fleet.md index dfc259b0b..8cd8140cb 100644 --- a/skills/first-officer/references/fo-fleet.md +++ b/skills/first-officer/references/fo-fleet.md @@ -6,7 +6,8 @@ Deferred operating contract for driving MULTIPLE commissioned workflows from ONE - **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 (the Startup step 2 zero-discovery block holds; 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.** Startup step 2 (`«state.boot»` local identify) 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` (split-root pull/resume; the 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) then `state sweep` (its merged-PR advancement) before driving it. Members may carry independent split-root state checkouts. - **Per-member boot liveness.** The before-greet Bridge liveness heartbeat (Startup step 2b) runs once per member, keyed by that member's `$SLUG`: each member's bridge-inbox startup hook writes its own `_bridge/fo..json` heartbeat + 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. +- **Per-host Bridge egress.** The heartbeat's `session_id` and the richer `_bridge/events.jsonl` stream / session→entity markers it joins are a per-host Bridge egress capability defined in the active runtime adapter: Claude, Codex, and Pi have packaged event producers, while deterministic session→entity marker parity remains Claude-proven only. The heartbeat resolves `SD_SESSION_ID` first and falls back across host session ids automatically, so liveness stays host-neutral and no manual step is needed (full surface: `docs/dev/bridge-egress-contract.md`). - **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 3). Headless: drive every member's dispatchables per step 3; "given the conn" resolves gates across the members the grant names. - **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. -- **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. A drained record's `target` selects scope: `all` (or absent) is drained by EVERY member's cursor (fleet-wide — so a fleet-wide `tell` is acknowledged once per member, a fleet-wide `conn` grant adopts the conn for each member's entities); a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. +- **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. Current Bridge records carry a frozen `target_set`: act only when the member's `$SLUG` is in `target_set`, and append that member's ack/reply to `_bridge/fo-replies.jsonl` with `target` set to the actual member slug, never `all`. Legacy records without `target_set` keep old `target` behavior: `all` (or absent/empty) is drained by EVERY member's cursor, while a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. - **Write scope and gates are unchanged.** Each member's entities, gates, `## Stage Report` review, and FO write scope are exactly as in single-workflow mode, scoped by the member's `{workflow_dir}`. diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index 7f4b3dfd1..8c3036dad 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -20,3 +20,10 @@ The build artifact carries the entity slug/name, entity path, workflow directory Live Pi tests should run with an isolated Pi config directory and an isolated session directory. The harness may copy the operator's existing Pi auth file into the isolated config directory so OAuth/subscription credentials are reused without sharing global sessions, packages, or settings. The durable proof for Pi support is not transcript phrasing. A valid live proof dispatches a Pi ensign against a temp split-root workflow and verifies process exit, state checkout file changes, git log, and stage report content. + +## Bridge egress (FO liveness/activity → Bridge) + +Bridge reads FO liveness and activity from `_bridge/events.jsonl` and `_bridge/sessions/.json`. The events.jsonl line schema is the Spacedock-owned contract `{"timestamp","ts","host","event","session_id","agent_id","agent_type","actor_id","detail":{"tool","source"}}` (full surface: `docs/dev/bridge-egress-contract.md`); each host binds its own producer for it. On Pi the event producer is packaged through the Spacedock Pi extension; deterministic session→entity markers are not yet claimed. + +- **FO event emission** — PACKAGED/SOURCE-COVERED on Pi: `package.json` advertises `.pi/extensions/spacedock.ts`, and that extension forwards Pi lifecycle event payloads to `spacedock bridge egress emit --host pi`. The shared emitter normalizes Pi-native lifecycle names (`session_shutdown`, `agent_end`, `turn_end`, `tool_execution_end`, etc.) into Bridge's canonical event grammar before writing `_bridge/events.jsonl`. The package-status gate requires the Spacedock Pi extension as well as the ensign skill, so a skills-only package is not treated as Bridge-egress-capable. This does not prove `_bridge/sessions/` marker parity: Pi marker support requires durable live evidence for child identity plus entity path. +- **«session-id» binding** — the bridge-inbox heartbeat resolves the neutral `SD_SESSION_ID`; on Pi this stays empty (no stable per-session id is exposed), so the heartbeat carries an empty session id — still a valid liveness tick (Bridge reads freshness from `ts`). Bind it once Pi exposes a stable per-session id. diff --git a/skills/integration/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 index c3529f0e1..716bfc84e 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -1,10 +1,11 @@ -// ABOUTME: Bridge session-marker smoke — the event hook deterministically records -// ABOUTME: a dispatched ensign's session→entity(+workflow) link from its entity-file -// ABOUTME: Read, so Bridge's "running" badge no longer depends on the ensign running -// ABOUTME: a first-action shell (which it skipped ~3/4 of the time). DRC running-badge. +// ABOUTME: Bridge egress-contract conformance — the Claude adapter (hooks.json + +// ABOUTME: spacedock-bridge-events.sh) must turn its host-shaped hook payload into the +// ABOUTME: harness-neutral egress contract: a canonical events.jsonl liveness line plus a +// ABOUTME: deterministic session→entity(+workflow) marker. DRC harness-agnostic FO events. package integration import ( + "encoding/json" "os" "os/exec" "path/filepath" @@ -12,108 +13,194 @@ import ( "testing" ) -// runEventHook feeds one hook payload (the JSON Claude Code pipes to the hook) to -// scripts/spacedock-bridge-events.sh and returns once it exits. cwd anchors the -// _bridge/ dir the hook writes to. -func runEventHook(t *testing.T, payload string) { +// runClaudeAdapter feeds ONE Claude-Code-shaped hook payload — the Claude adapter's input — +// to scripts/spacedock-bridge-events.sh (the Claude binding of the egress producer) and +// returns once it exits. cwd anchors the _bridge/ dir the adapter writes to. +func runClaudeAdapter(t *testing.T, payload string) { t.Helper() hook := filepath.Join("..", "..", "scripts", "spacedock-bridge-events.sh") if _, err := os.Stat(hook); err != nil { - t.Fatalf("hook script not found at %s: %v", hook, err) + t.Fatalf("Claude adapter script not found at %s: %v", hook, err) } cmd := exec.Command("bash", hook) cmd.Stdin = strings.NewReader(payload) + cmd.Env = append(os.Environ(), "SPACEDOCK_BIN="+bridgeAdapterBinary(t)) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("event hook failed: %v\n%s", err, out) + t.Fatalf("Claude adapter failed: %v\n%s", err, out) } } -func readPayload(cwd, sid, agentType, filePath string) string { +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 + `"}}` } -// TestEventHookWritesSessionMarker locks the deterministic producer: an ensign's -// Read of its entity file records {session_id, entity, workflow} — carrying the -// workflow so Bridge's join is collision-free — and it is first-write-wins (a later -// sibling Read does not overwrite), while a non-ensign (FO) Read writes nothing. -func TestEventHookWritesSessionMarker(t *testing.T) { - root := t.TempDir() - ent := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "drc-3467.md") +// egressLine is the Spacedock-owned, harness-neutral events.jsonl contract line every host +// adapter must produce. Pointer fields distinguish "key absent" (nil) from "present but empty", +// and the nested detail struct enforces detail.{tool,source} nesting — a producer that flattens +// tool/source to the top level fails to populate detail and is rejected. +type egressLine struct { + TS string `json:"ts"` + Event string `json:"event"` + SessionID string `json:"session_id"` + AgentID *string `json:"agent_id"` + AgentType *string `json:"agent_type"` + Detail *struct { + Tool *string `json:"tool"` + Source *string `json:"source"` + } `json:"detail"` +} - // 1. Ensign reads its own entity file → marker written with entity + workflow. - runEventHook(t, readPayload(root, "ses-1", "spacedock:ensign", ent)) - marker := filepath.Join(root, "_bridge", "sessions", "ses-1.json") - data, err := os.ReadFile(marker) +// 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("marker not written: %v", err) + 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) } - got := string(data) - for _, want := range []string{`"session_id":"ses-1"`, `"entity":"drc-3467"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(got, want) { - t.Errorf("marker missing %s\ngot: %s", want, got) - } + if 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") - runEventHook(t, readPayload(root, "ses-1", "spacedock:ensign", sibling)) - data2, _ := os.ReadFile(marker) - if !strings.Contains(string(data2), `"entity":"drc-3467"`) { - t.Errorf("sibling Read overwrote the marker (lost first-write-wins):\n%s", data2) - } + runClaudeAdapter(t, claudeAdapterRead(root, "ses-1", "spacedock:ensign", sibling)) + assertSessionMarker(t, root, "ses-1", "drc-3467", "linear-drc-review") - // 3. A non-ensign (FO) Read of an entity file writes no marker. - runEventHook(t, readPayload(root, "fo-sess", "spacedock:first-officer", ent)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "fo-sess.json")); err == nil { + // 3. A non-ensign (FO) Read of an entity file writes no marker. The egress liveness line + // is still emitted (the FO is live) and carries the FO's agent_type, but no + // session→entity link is recorded. + runClaudeAdapter(t, claudeAdapterRead(root, "fo-sess", "spacedock:first-officer", ent)) + assertEgressContractLine(t, root, "PostToolUse", "fo-sess", "spacedock:first-officer", "Read") + if markerExists(root, "fo-sess") { t.Errorf("FO Read should not produce a session marker") } - // 3b. RELATIVE entity path — the FO passes a repo-relative {entity_file_path}, so - // the ensign's scoped Read carries "docs/spacedock//.md" (no leading - // slash). The hook must still record it (regression: the absolute-only pattern - // missed every live ensign). - runEventHook(t, readPayload(root, "ses-rel", "spacedock:ensign", + // 3b. RELATIVE entity path — the FO passes a repo-relative {entity_file_path}, so the + // ensign's scoped Read carries "docs/spacedock//.md" (no leading slash). The + // adapter must still record it (regression: the absolute-only pattern missed every live + // ensign). + runClaudeAdapter(t, claudeAdapterRead(root, "ses-rel", "spacedock:ensign", "docs/spacedock/linear-drc-review/drc-7000.md")) - relData, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", "ses-rel.json")) - if err != nil { - t.Fatalf("relative-path Read produced no marker (the live-ensign regression): %v", err) - } - for _, want := range []string{`"entity":"drc-7000"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(string(relData), want) { - t.Errorf("relative-path marker missing %s\ngot: %s", want, relData) - } - } + assertSessionMarker(t, root, "ses-rel", "drc-7000", "linear-drc-review") // 4. _archive entity Reads are skipped. arch := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "_archive", "drc-1.md") - runEventHook(t, readPayload(root, "ses-arch", "spacedock:ensign", arch)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-arch.json")); err == nil { + runClaudeAdapter(t, claudeAdapterRead(root, "ses-arch", "spacedock:ensign", arch)) + if markerExists(root, "ses-arch") { t.Errorf("_archive Read should not produce a session marker") } // 5. SPLIT-ROOT entity path: the entity now lives at /.spacedock-state/.md, // so the workflow must be derived from the segment after docs/spacedock/ — NOT the - // entity's parent dir (which would wrongly be ".spacedock-state"). + // entity's parent dir (which would wrongly be ".spacedock-state"). assertSessionMarker's + // exact workflow match ("linear-drc-review") rejects the ".spacedock-state" mis-derivation. sr := filepath.Join(root, "docs", "spacedock", "linear-drc-review", ".spacedock-state", "drc-8000.md") - runEventHook(t, readPayload(root, "ses-sr", "spacedock:ensign", sr)) - srData, err := os.ReadFile(filepath.Join(root, "_bridge", "sessions", "ses-sr.json")) - if err != nil { - t.Fatalf("split-root Read produced no marker: %v", err) - } - for _, want := range []string{`"entity":"drc-8000"`, `"workflow":"linear-drc-review"`} { - if !strings.Contains(string(srData), want) { - t.Errorf("split-root marker missing %s (workflow must be the dir above .spacedock-state)\ngot: %s", want, srData) - } - } - if strings.Contains(string(srData), `.spacedock-state`) { - t.Errorf("split-root marker wrongly recorded the state dir as the workflow: %s", srData) - } + runClaudeAdapter(t, claudeAdapterRead(root, "ses-sr", "spacedock:ensign", sr)) + assertSessionMarker(t, root, "ses-sr", "drc-8000", "linear-drc-review") // 6. SPLIT-ROOT _archive: an archived entity in the state checkout is still skipped. srArch := filepath.Join(root, "docs", "spacedock", "linear-drc-review", ".spacedock-state", "_archive", "drc-9.md") - runEventHook(t, readPayload(root, "ses-srarch", "spacedock:ensign", srArch)) - if _, err := os.Stat(filepath.Join(root, "_bridge", "sessions", "ses-srarch.json")); err == nil { + runClaudeAdapter(t, claudeAdapterRead(root, "ses-srarch", "spacedock:ensign", srArch)) + if markerExists(root, "ses-srarch") { t.Errorf("split-root _archive Read should not produce a session marker") } } 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 d5110e644..bb078d6b5 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -44,3 +44,36 @@ 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` (read at Startup step 2), 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. + +Emit it with: + +``` +${SPACEDOCK_BIN:-spacedock} bridge initiate \ + --kind gate-review \ + --workflow \ + --entity \ + --ship-id / \ + --id \ + --host \ + --session-id \ + --headline \ + [--body ] +``` + +- `--id` is REQUIRED and is the fold key. For a gate-review the writer defaults `request_id` to `--id`, so pass `--id` alone and let the request_id follow (pass `--request-id` explicitly only when it must differ from the id). The id MUST be a STABLE function of `(entity, stage)` so re-emitting the same gate on each drain tick folds to ONE card in Bridge instead of stacking duplicates. Derive it deterministically (e.g. `gate--`), not from a timestamp or random value. Omitting `--id` is a loud error and writes nothing. +- `--host` and `--session-id` carry host attribution so the Bridge card shows which host raised the gate. Both are optional, but omit them and the card carries no attribution — always pass the current host and session id. +- `--headline` is the gate lede (bounded to 240 chars). Keep `--body` to a short supporting line; it is bounded to 2000 chars. +- Pass `--repo-root` only if the FO's cwd is not the repo root; the writer anchors at `filepath.Abs(--repo-root or cwd)/_bridge`, the same path Bridge resolves from. + +### Channel boundary — a gate goes to fo-initiate ONLY + +A gate-review is a decidable interrupt. It goes to `_bridge/fo-initiate.jsonl` and NOWHERE ELSE: + +- NEVER `fo-feed.jsonl` — that stream is ambient git narration (dispatch/advance/complete), not a decidable ask; a gate rendered there has no Approve/Reject affordance. +- NEVER `fo-replies.jsonl` — that stream requires an `in_reply_to` correlator to a captain intent; an FO-initiated gate has no such parent and would be silently dropped. + +Approve/Reject on the fo-initiate card close the loop back through the inbox by `request_id`; that is why the `request_id` must be stable and shared with the gate the captain sees.