From d93b2aa3731d12eaf8e254226cc9374017c8f772 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Tue, 23 Jun 2026 09:10:07 +0800 Subject: [PATCH 01/17] Bridge seam: bridge-inbox drain mod + normalized FO event hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spacedock side of the seam to the Bridge command-center UI (the Bridge half shipped in spacedock-dev/bridge#12). Two additive pieces; no core-skill or binary change. 1. docs/dev/_mods/bridge-inbox.md — an idle/startup-hook mod (mirroring pr-merge) that drains captain intent Bridge queues at _bridge/inbox.jsonl. Records are {ts, kind:tell|conn, text, granted}; tell is a talk-to-FO directive, conn is a handover grant/revoke. Consumes via an append-only-safe cursor (_bridge/.inbox-cursor) so neither side rewrites the inbox and re-firing is idempotent. Honest: delivery is FO-pull (one loop cadence), never synchronous. 2. hooks/hooks.json + scripts/spacedock-bridge-events.sh — register observe-only, async Claude Code hooks (SessionStart/UserPromptSubmit/PostToolUse/Notification/ Stop/SubagentStop) that append a stable, normalized event line ({ts,event,session_id,agent_id,agent_type,detail}) to «cwd»/_bridge/events.jsonl, so Bridge can tail a Spacedock-owned contract instead of coupling to Claude Code's internal transcript format. The script never blocks the session: exits 0 and no-ops on missing jq/cwd/write. agent_id/agent_type distinguish FO (empty) from ensign subagents. Wired via the plugin manifest "hooks" key. _bridge/ is gitignored (transient per-session runtime state). Verified: jq-valid manifests, shellcheck clean, gofmt clean; the contract (plugin manifest), status and dispatch (mod parsing) suites pass. (Three pre-existing env-only failures on main — git-tag signing + Codex CLI absence — are unrelated.) Closes #434 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Nuow8cNPWYSEZNLnakn6Wt --- .claude-plugin/plugin.json | 1 + .gitignore | 5 ++++ docs/dev/_mods/bridge-inbox.md | 44 +++++++++++++++++++++++++++ hooks/hooks.json | 22 ++++++++++++++ scripts/spacedock-bridge-events.sh | 48 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 docs/dev/_mods/bridge-inbox.md create mode 100644 hooks/hooks.json create mode 100755 scripts/spacedock-bridge-events.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a2ad973cc..76675763f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -16,6 +16,7 @@ "automation" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "requires-contract": ">=1,<2", "interface": { "displayName": "Spacedock", diff --git a/.gitignore b/.gitignore index 6224dcbe6..0256825ff 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist/ # profile; .claude holds Claude Code's agent worktrees (full repo checkouts). .safehouse .claude/ + +# Bridge seam runtime state — the captain-intent inbox + cursor (drained by the +# bridge-inbox mod) and the normalized FO event log (emitted by the plugin hook). +# Transient per-session signals, never repo content. +_bridge/ diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md new file mode 100644 index 000000000..8f96a40d0 --- /dev/null +++ b/docs/dev/_mods/bridge-inbox.md @@ -0,0 +1,44 @@ +--- +name: bridge-inbox +description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl) and surface it to the FO each tick +version: 0.1.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, the same root Bridge resolves). This hook drains that inbox on the FO's own loop ticks and acts on the intent. + +**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. + +**Inbox record schema** (one JSON object per line, written by Bridge): + +``` +{"ts": "", "kind": "tell" | "conn", "text": "", "granted": } +``` + +**Consume by cursor, never by rewrite.** Bridge only appends; this hook only advances `_bridge/.inbox-cursor` (the count of inbox lines already processed). Neither side rewrites `inbox.jsonl`, so concurrent Bridge appends and FO reads never clobber, and re-firing the hook with no new lines is a no-op. + +## Hook: startup + +Drain any intent the captain queued while no FO session was attached, so a freshly-booted FO picks up standing instructions before its first dispatch. Run the same drain procedure as the idle hook below. + +## Hook: idle + +Drain newly-queued captain intent, if any: + +1. If `_bridge/inbox.jsonl` does not exist, skip — no Bridge is attached. +2. Read the cursor (lines already processed): `CURSOR=$(cat _bridge/.inbox-cursor 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` and 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. +5. Advance the cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor`. +6. Report to the captain: how many intents you drained 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. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 000000000..280485146 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,22 @@ +{ + "hooks": { + "SessionStart": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "PostToolUse": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "Notification": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "Stop": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ], + "SubagentStop": [ + { "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/spacedock-bridge-events.sh", "async": true } ] } + ] + } +} diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh new file mode 100755 index 000000000..ac3a32c5c --- /dev/null +++ b/scripts/spacedock-bridge-events.sh @@ -0,0 +1,48 @@ +#!/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. +# +# 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. +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. +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 // "") + } + }' >> "$dir/events.jsonl" 2>/dev/null || exit 0 + +exit 0 From fbed026602b1c90f4a0ae0a410d61fd87393eecc Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Tue, 23 Jun 2026 10:18:52 +0800 Subject: [PATCH 02/17] docs(contributing): add Develop / Build from Source / Run your Branch sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the from-source dev loop that was previously tribal knowledge: build (`go build -o ./spacedock ./cmd/spacedock`), run the branch against checkout skills (`--plugin-dir "$PWD"`), and avoid colliding with an installed (e.g. Homebrew) Spacedock across all three surfaces — PATH, plugin resolution, and the next/main channel stamp. Gitignore the local `/spacedock` build artifact so the documented build keeps the tree clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +++ CONTRIBUTING.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/.gitignore b/.gitignore index 0256825ff..f4422babd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # goreleaser build output (binaries, tarballs, checksums, generated cask). dist/ +# Local from-source build of the launcher (`go build -o ./spacedock ./cmd/spacedock`, +# the build-and-run path in CONTRIBUTING.md). A dev artifact, never committed. +/spacedock + # MkDocs build output. The docs Pages workflow rebuilds `site/` from `docs/site/` # + mkdocs.yml on every run; it is a generated artifact, never committed. /site/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5a3d6af6..61edce29b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,79 @@ # Contributing Thanks for considering a contribution. Spacedock is early, so we encourage you to share proposals and improvements as [GitHub issues](https://github.com/spacedock-dev/spacedock/issues) rather than opening pull requests directly. That lets us discuss the direction before anyone writes code. + +## Develop + +Spacedock is a Go module (`github.com/spacedock-dev/spacedock`, Go 1.22+). The +launcher binary lives in `cmd/spacedock/` (process entry point); command +routing, usage text, and exit-code behavior live in `internal/cli/`. The +plugin's skills live under `skills/`. + +[`AGENTS.md`](AGENTS.md) is the source of truth for the development workflow — +read it before non-trivial work. The baseline gate every change must pass +before you call it done: + +```bash +go test ./... # baseline gate for every change +go test ./... -race # catch data races +gofmt -w ./cmd ./internal +``` + +Add focused tests for a change before implementing it. The live runtime E2E +suites (real coding-agent hosts) are separate and gated behind a `live` build +tag — see [`docs/runtime-live-ci.md`](docs/runtime-live-ci.md). + +## Build from Source + +```bash +go build -o ./spacedock ./cmd/spacedock +``` + +This drops a `spacedock` binary at the repo root (gitignored, so it never +shows up in `git status`). To put a checkout-built binary on your `PATH` +instead, use `go install ./cmd/spacedock` — note it may shadow a +Homebrew-installed `spacedock`, so prefer the explicit `./spacedock` path +below when you want to be sure which binary you are running. + +## Run your Branch + +Run your freshly-built binary against the skills in your checkout, so both the +launcher and the skills come from your branch rather than an installed release: + +```bash +go build -o ./spacedock ./cmd/spacedock +./spacedock claude --plugin-dir "$PWD" "/spacedock:survey" +``` + +Replace `claude` with `codex` or `pi` for the respective coding-agent hosts. + +`--plugin-dir "$PWD"` loads the local plugin checkout directly and bypasses +installed-plugin resolution — it is the development path, not an install +substitute. It does **not** wrap the launch in the safehouse sandbox (see +[Sandboxing](docs/site/get-started/install.md#sandboxing)). This needs no +install step and no merge to `main`: it exercises the full current-checkout +stack — the launcher binary plus the local skills — straight from your working +tree. + +If a launch misbehaves, run `spacedock doctor`. + +### Avoid colliding with an installed Spacedock + +If you also have Spacedock installed (e.g. via Homebrew), the build-and-run +path above stays isolated from it across all three collision surfaces: + +- **Which binary runs** — invoke the local build by explicit path (`./spacedock`), + not bare `spacedock` (which resolves the installed one on your `PATH`). Avoid + `go install` for this, since whether its `$GOPATH/bin` copy wins over the + installed binary depends on `PATH` order. +- **Which skills run** — `--plugin-dir "$PWD"` loads your checkout's skills and + mutates no host plugin state, so the installed plugin is untouched. +- **Host install state** — a plain `go build` is stamped on the `next` (edge) + channel, so a released install (stamped `main`) lives under a separate host + plugin entry (`spacedock@spacedock-edge` vs `spacedock@spacedock`); the two + coexist. `SPACEDOCK_DEV_BRANCH=main|next` overrides the channel at runtime. + +The one combination that *does* overwrite a released install is running the +local binary with both `SPACEDOCK_DEV_BRANCH=main` **and** `spacedock install` — +that targets the same `spacedock@spacedock` entry. The default `next` stamp, and +the `--plugin-dir` path (which never installs), both avoid it. From da70ac2e5e85ea89eed559e9d86f2f93b10343fa Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Tue, 23 Jun 2026 21:44:57 +0800 Subject: [PATCH 03/17] Bridge seam: target-filtered drain + per-workflow cursor + FO heartbeat The Spacedock half of multi-session control (the Bridge half is merged): teach the bridge-inbox mod to honor the per-record `target` Bridge writes, drain via a per-workflow cursor, and emit the per-workflow FO heartbeat Bridge's roster reads. - Inbox schema block now documents `target` (""|"all"; absent => all). - Drain acts only on records targeted at this workflow ($SLUG) or "all"; a record for another workflow is skipped but still advances the cursor. - Cursor is per-workflow `.inbox-cursor.$SLUG`; concurrent FOs never race. One-time migration seeds it from the old shared `.inbox-cursor` so the first run does not re-drain (and replay) already-processed intent. - Heartbeat `fo.$SLUG.json` {session_id, ts (present UTC RFC3339), state:idle} written on boot + each idle tick; state is idle (the mod runs between dispatches and cannot honestly claim working). - Slug derived from {workflow_dir} basename and validated (rejects empty/./.. and any non-[A-Za-z0-9._-] char) so it can never escape _bridge/. Markdown mod only; no Go change. Closes #436. --- docs/dev/_mods/bridge-inbox.md | 57 +++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 8f96a40d0..104e3a105 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -1,44 +1,79 @@ --- name: bridge-inbox -description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl) and surface it to the FO each tick -version: 0.1.0 +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 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, the same root Bridge resolves). This hook drains that inbox on the FO's own loop ticks and acts on the intent. +[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, the same root Bridge resolves). 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. **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/`: + +``` +SLUG=$(basename "{workflow_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 +``` + **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"ts": "", "kind": "tell" | "conn", "text": "", "granted": } +{"ts": "", "kind": "tell" | "conn", "text": "", "granted": , "target": "" | "all"} ``` -**Consume by cursor, never by rewrite.** Bridge only appends; this hook only advances `_bridge/.inbox-cursor` (the count of inbox lines already processed). Neither side rewrites `inbox.jsonl`, so concurrent Bridge appends and FO reads never clobber, and re-firing the hook with no new lines is a no-op. +`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. + +**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. ## Hook: startup -Drain any intent the captain queued while no FO session was attached, so a freshly-booted FO picks up standing instructions before its first dispatch. Run the same drain procedure as the idle hook below. +1. Write the heartbeat (see **Heartbeat** below) so Bridge shows this workflow attached as soon as the FO boots. +2. 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. ## Hook: idle -Drain newly-queued captain intent, if any: +Refresh the heartbeat, then drain. + +### Heartbeat + +Bridge shows per-workflow FO liveness by reading `_bridge/fo.$SLUG.json`; it treats this workflow as live only when the `ts` is **fresh** (within 30 minutes) and **not in the future**. Stamp a present-time UTC timestamp on every tick so an attached FO keeps showing live: + +``` +mkdir -p _bridge +printf '{"session_id":"%s","ts":"%s","state":"idle"}\n' \ + "${CLAUDE_CODE_SESSION_ID:-}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > _bridge/fo.$SLUG.json +``` + +`state` is `idle`: this mod runs at startup/idle boundaries (you are between dispatches when it fires), so it cannot honestly claim `working` — the finer working/idle signal already lives in `_bridge/events.jsonl`. Writing the heartbeat is observe-only; never let it block the loop. (A session left idle with no captain interaction for over 30 minutes stops ticking and goes honestly not-attached in Bridge — that is intended, not a bug.) + +### Drain + +Drain newly-queued captain intent addressed to this workflow, if any: 1. If `_bridge/inbox.jsonl` does not exist, skip — no Bridge is attached. -2. Read the cursor (lines already processed): `CURSOR=$(cat _bridge/.inbox-cursor 2>/dev/null || echo 0)`. +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` and act: +4. For each new record, in order, parse `kind` / `text` / `granted` / `target`. **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. -5. Advance the cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor`. -6. Report to the captain: how many intents you drained and what you did with each. +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. From b1db14a3431eb7357dcaf4c6206a9f11b00d039f Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Tue, 23 Jun 2026 22:03:17 +0800 Subject: [PATCH 04/17] =?UTF-8?q?Bridge=20seam:=20address=20review=20notes?= =?UTF-8?q?=20=E2=80=94=20path-alignment,=20slug=20scope,=20drain=20semant?= =?UTF-8?q?ics,=20bounded=20event=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #435 (DRC-3727 + DRC-3732) surfaced six non-blocking notes; this fixes them: - bridge-inbox.md: state the load-bearing --repo-root path-alignment requirement (Bridge anchors inbox/heartbeat/feed on --repo-root, falls back to --fleet) so routing to this FO can't silently break under a multi-workflow --fleet layout. - bridge-inbox.md: require each hook to derive+validate $SLUG in the same shell it runs the heartbeat/drain in — a fresh-shell split would write a stray fo..json. - bridge-inbox.md: document at-least-once drain delivery (cursor advances after acting) and the idempotent conn/tell handling it implies. - bridge-inbox.md: align placeholder to {dir} (matches the sibling pr-merge mod). - spacedock-bridge-events.sh: best-effort, lock-free size cap on events.jsonl so the PostToolUse-driven liveness log can't grow without bound; degrades to a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dev/_mods/bridge-inbox.md | 17 ++++++++++++----- scripts/spacedock-bridge-events.sh | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 104e3a105..96cc6e735 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -7,14 +7,16 @@ fo-realm: "FO realm — the FO maintains this file directly; it is FO process (t # 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, the same root Bridge resolves). 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**, and writes a per-workflow liveness heartbeat Bridge reads. + +**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. **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/`: ``` -SLUG=$(basename "{workflow_dir}") +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 ;; @@ -31,14 +33,17 @@ esac **Consume by a per-workflow cursor, never by rewrite.** Bridge only appends to the one shared `inbox.jsonl`; this mod advances only `_bridge/.inbox-cursor.$SLUG` (the count of inbox lines this workflow's FO has processed). Each workflow's FO owns its own cursor, so several FOs draining the same inbox never clobber each other, and re-firing with no new lines is a no-op. +**Run each hook's steps in one shell.** Every Bash invocation is a fresh shell, so `$SLUG` (and `$CURSOR`) only persist within a single invocation. Begin each hook below by deriving and validating `$SLUG` (the block above), then run that hook's heartbeat and drain steps in the **same** shell — do not split the heartbeat and drain into separate invocations that each expect `$SLUG` to already be set, or the second runs with an empty slug and writes a stray `_bridge/fo..json` Bridge never reads. + ## Hook: startup -1. Write the heartbeat (see **Heartbeat** below) so Bridge shows this workflow attached as soon as the FO boots. -2. 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. 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. ## Hook: idle -Refresh the heartbeat, then drain. +Derive and validate `$SLUG` (above) in this tick's shell, then refresh the heartbeat and drain — all in the same shell. ### Heartbeat @@ -77,3 +82,5 @@ Drain newly-queued captain intent addressed to this workflow, if any: 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.) diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index ac3a32c5c..c23425379 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -31,6 +31,7 @@ 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" \ '{ @@ -43,6 +44,24 @@ printf '%s' "$payload" | jq -c \ tool: (.tool_name // ""), source: (.source // "") } - }' >> "$dir/events.jsonl" 2>/dev/null || exit 0 + }' >> "$events" 2>/dev/null || exit 0 + +# 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 +fi exit 0 From b2c3547c10f534e378706830b86a98e330fc5720 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 24 Jun 2026 10:05:28 +0800 Subject: [PATCH 05/17] =?UTF-8?q?First=20officer:=20opt-in=20fleet=20mode?= =?UTF-8?q?=20=E2=80=94=20one=20session,=20many=20commissions=20(#437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in fleet mode so a single FO session can drive MULTIPLE commissioned workflows, alongside (not replacing) the default single- workflow model. Skill-only: the binary already enumerates multiple workflows (status --discover) and parameterizes every call by --workflow-dir, so fleet mode changes what the FO does with the discovered set, not the command surface. - shared core: a quotable launch directive ("drive the fleet" / "run all workflows" / "fleet mode") flips fleet mode; the FO adopts ALL discovered workflows as a member set instead of presenting the list. Per-member boot (taxonomy read, state.boot/ensure-ready/sweep-merged), including independent split-root checkouts whose halts don't block peers. New "## Fleet Mode" section; Startup step 3 + SKILL.md trigger. - dispatch core: round-robin the per-entity event loop across members, each iteration scoped to the member's {workflow_dir}; one roster reconcile spans the fleet. Standing teammates accumulate as the union of members' declarations (idempotent; shared names share one instance). Kept host-neutral — no Claude-team tokens in the shared dispatch core. - captain intent: the bridge-inbox `target` (this PR) gives fleet mode per-workflow conn/tell granularity for free; one fleet FO, one cursor. Default (no directive) is byte-for-byte unchanged: zero discover still reports-and-stops; multiple still presents the list. Tests: skills/integration fleet-discover smoke locks the adopt-all precondition (status --discover lists all members; per-member status is independent). contractlint stays green (host-neutral dispatch core, no new hook headings). A //go:build live multi-workflow drive remains follow-up, like the existing live ensign-cycle. Refs #437. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/first-officer/SKILL.md | 2 + .../references/first-officer-shared-core.md | 14 ++- .../references/fo-dispatch-core.md | 4 + .../integration/fleet_discover_smoke_test.go | 113 ++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 skills/integration/fleet_discover_smoke_test.go diff --git a/skills/first-officer/SKILL.md b/skills/first-officer/SKILL.md index 47a60570f..9e15c7261 100644 --- a/skills/first-officer/SKILL.md +++ b/skills/first-officer/SKILL.md @@ -13,6 +13,8 @@ If this skill is invoked directly in a non-interactive run and the prompt names - before the final response, explicitly shut down any worker that is no longer needed for later routing or gate handling - once the bounded stop condition is satisfied, send one concise final response and exit immediately +If this skill is invoked with a quotable fleet directive ("drive the fleet" / "run all workflows" / "fleet mode") and discovery finds multiple commissioned workflows, enter fleet mode (operating contract `## Fleet Mode`): adopt every discovered workflow as the member set and drive them all from this one session. Absent the directive, discovery behaves as before — one workflow, or present the list on ambiguity. + ## How the first officer operates You are dispatcher, responsible for making sure the work is done by the crew. What awesome looks like: diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 78b699839..705e6add8 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -12,7 +12,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg In every class, do NOT proceed to discovery or `--boot`. 2. Discover the project root with `git rev-parse --show-toplevel`. -3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → present the list (or fail with an ambiguity error in single-entity mode). +3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → present the list (or fail with an ambiguity error in single-entity mode) — UNLESS the launch carries a quotable fleet directive (per `## Fleet Mode`), in which case adopt ALL discovered paths as the member set instead of presenting the list. Zero discover still reports-and-stops in every mode. - **block (zero discover):** do NOT broad-search the filesystem to hunt a workflow — no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root. Report no workflow and stop. (Code-gated by the `detectBroadSearchAtBoot` boot detector.) 4. Read the workflow stage taxonomy via `${SPACEDOCK_BIN:-spacedock} status --read {workflow_dir}/README.md --json` — its `stages` array carries stage names/ordering and the per-stage `initial`/`terminal`/`gate`/`worktree`/`feedback-to`/`agent` flags the greet and gate need, plus the mission line / entity labels (`entity-label` / `entity-label-plural`) / `id-style` from the flat `frontmatter` object. DEFER the README body (per-stage prose, proof policy, templates, CI docs); it loads only when its consuming phase runs (a dispatch copies a stage subsection via `show-stage-def`; the merge ceremony reads `merge:` policy). 5. `«state.boot»()` — read all startup information in one call. Consume it as JSON (every value a string); the human-formatted table is NOT rendered for the FO's own reasoning. The before-greet boot is all READS — none reads a mod file or creates a team. Sections: @@ -83,6 +83,18 @@ A `--next-id` candidate (SD-B32 `NEXT_ID` from `--boot` / `--next-id`) is a prev A headless run scoped to one named entity — not a distinct mode. Startup step 8's headless rule governs; scoping only narrows it: resolve the named reference (slug/title/id), stop on ambiguity; drive that entity only; gates and stop conditions per step 8 (and `## Completion and Gates` when given the conn). If the README defines `## Output Format`, use it; otherwise report status, verdict, and entity ID. +## Fleet Mode + +An opt-in mode for driving MULTIPLE commissioned workflows from ONE session — the dual of Single-Entity Scope (which narrows to one entity; this widens to every workflow). Default behavior is unchanged: absent the directive, discovery resolves one workflow or presents the list (Startup step 3). + +- **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. +- **Member set.** On the trigger, adopt every `${SPACEDOCK_BIN:-spacedock} status --discover` path as a member workflow. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero → report-and-stop (the Startup step 3 block holds; never broad-search to widen the set). +- **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. +- **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 8). Headless: drive every member's dispatchables per step 8; "given the conn" resolves gates across the members the grant names. +- **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. +- **Captain intent routing.** When a bridge-inbox drain is registered, a drained record's `target` selects scope: `all` (or absent) applies fleet-wide; a `{slug}` applies only to that member. One fleet FO owns one cursor, so there is no cross-session addressing race. +- **Write scope and gates are unchanged.** Each member's entities, gates, `## Stage Report` review, and FO write scope are exactly as in single-workflow mode, scoped by the member's `{workflow_dir}`. + ## Working Directory Stay at the project root. Do not `cd` into worktrees. Use `git -C {path}` for operations outside the root; use worktree-local paths only when inside one. diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index c5ce870ff..3902d47a4 100644 --- a/skills/first-officer/references/fo-dispatch-core.md +++ b/skills/first-officer/references/fo-dispatch-core.md @@ -6,6 +6,8 @@ The per-entity dispatch procedure, worker resolution, dispatch-adapter assembly, **Standing-teammate injection.** Before the first worker dispatch, inject the workflow's declared standing teammates via the runtime adapter's standing-injection call (it forwards each returned spawn spec to the spawn call with the same verbatim discipline as `«dispatch.build»` output). Idempotent (already-alive members omitted), a no-op when none is declared or the runtime has no shared-teammate surface. Lifetime is the adapter's. Read each teammate's routing usage from its mod. +In fleet mode (shared core `## Fleet Mode`) the session team accumulates the UNION of every member workflow's declared standing teammates, each injected at that member's first dispatch. Injection stays idempotent, so members declaring the same teammate name share ONE live instance; read each such teammate's routing usage from the owning member's mod. + For each entity reported by `status --next`: 1. Read the entity file and the target stage definition. @@ -144,3 +146,5 @@ These are FO-internal scheduling reads — consume them as `--json` (compact, by - → **prose**, becomes `` `spacedock dispatch next-action` `` — no driver binary backs it yet; the FO hand-follows the skeleton above. Repeat from step 1 after each completion until the captain ends the session or, in single-entity mode, the target entity is resolved. + +**Fleet mode — round-robin across member workflows.** When the session adopted a member set (shared core `## Fleet Mode`), wrap `«dispatch.next-action»()` in an outer round-robin over members: run one iteration scoped to each member's `{workflow_dir}` (every `status` / `dispatch` / `--set` call already carries `--workflow-dir`, so no command changes), dispatching whichever members have ready work. The fleet iteration ends only when NO member is dispatchable; the idle-hook + `«roster-reconcile»` step-0 sweep (when PRESENT) fires once across the shared roster — the roster spans every member's workers, so one reconcile covers the fleet. A single-member set is byte-identical to single-workflow mode. A per-member halt (rebase-conflict, unmet clarification, a gate awaiting the captain) suspends only that member's slot; the round-robin keeps advancing the others. diff --git a/skills/integration/fleet_discover_smoke_test.go b/skills/integration/fleet_discover_smoke_test.go new file mode 100644 index 000000000..21da0a50c --- /dev/null +++ b/skills/integration/fleet_discover_smoke_test.go @@ -0,0 +1,113 @@ +// ABOUTME: Fleet-mode smoke — the binary precondition the FO's `## Fleet Mode` +// ABOUTME: adopt-all relies on: `status --discover` lists every commissioned member +// ABOUTME: workflow and each member's `status` runs independently under one root. +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fleetMemberReadme is a single-root commissioned workflow README: it carries the +// `commissioned-by` marker `--discover` keys on AND a minimal valid stage set so +// `status` lists its entities. Single-root keeps the smoke test free of a state +// checkout — fleet mode's per-member split-root handling is exercised by the live +// cycle, not this binary-surface precondition check. +const fleetMemberReadme = `--- +commissioned-by: spacedock@1.0 +entity-type: task +entity-label: task +entity-label-plural: tasks +id-style: slug +stages: + defaults: + worktree: false + concurrency: 1 + states: + - name: backlog + initial: true + - name: done + terminal: true +--- + +# Member Workflow + +### backlog + +Start. + +- **Outputs:** seed. + +### done + +Terminal. +` + +// stageFleet builds a fleet root holding TWO commissioned member workflows under +// one git repo, each with one flat backlog entity. Returns the root and the two +// member definition dirs. This is the multi-workflow topology the FO adopts as a +// member set when launched with a fleet directive (shared core `## Fleet Mode`). +func stageFleet(t *testing.T) (root, alphaDir, betaDir string) { + t.Helper() + root = t.TempDir() + mk := func(slug, title string) string { + dir := filepath.Join(root, slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(fleetMemberReadme), 0o644); err != nil { + t.Fatal(err) + } + entity := "---\nid: \"\"\ntitle: " + title + "\nstatus: backlog\nscore: \"0.50\"\nsource: smoke\n---\n# " + title + "\n\nSeed entity.\n" + if err := os.WriteFile(filepath.Join(dir, slug+"-1.md"), []byte(entity), 0o644); err != nil { + t.Fatal(err) + } + return dir + } + alphaDir = mk("wf-alpha", "Alpha one") + betaDir = mk("wf-beta", "Beta one") + gitInitFixture(t, root) + return root, alphaDir, betaDir +} + +// TestFleetDiscoverListsAllMembers locks the fleet-mode adopt-all precondition: +// `status --discover` over a multi-workflow root returns BOTH commissioned member +// dirs (what the FO adopts as the member set instead of presenting the list), and +// each member's `status` lists its own entity independently under the one root — +// the per-member operation the round-robin event loop drives. +func TestFleetDiscoverListsAllMembers(t *testing.T) { + root, alphaDir, betaDir := stageFleet(t) + + // --discover must enumerate BOTH members (adopt-all consumes this set). + cmd := exec.Command(spacedockBinary(t), "status", "--discover", "--root", root) + cmd.Env = append(os.Environ(), "HOME="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("status --discover failed: %v\n%s", err, out) + } + discover := string(out) + for _, member := range []string{"wf-alpha", "wf-beta"} { + if !strings.Contains(discover, member) { + t.Fatalf("--discover did not enumerate member %q (fleet adopt-all would miss it):\n%s", member, discover) + } + } + + // Each member's status runs independently, scoped by its own --workflow-dir — + // the per-member iteration the fleet round-robin loop performs. + alpha, code := runStatus(t, alphaDir) + if code != 0 || !strings.Contains(alpha, "Alpha one") { + t.Fatalf("member wf-alpha status (exit %d) missing its entity:\n%s", code, alpha) + } + beta, code := runStatus(t, betaDir) + if code != 0 || !strings.Contains(beta, "Beta one") { + t.Fatalf("member wf-beta status (exit %d) missing its entity:\n%s", code, beta) + } + + // Per-member independence: alpha's view must not leak beta's entity. + if strings.Contains(alpha, "Beta one") || strings.Contains(beta, "Alpha one") { + t.Fatalf("member views are not independent — cross-member entity leak:\nalpha:\n%s\nbeta:\n%s", alpha, beta) + } +} From f4d67644f2e2ce3f27d2a3daae5e088bd4f01f35 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 24 Jun 2026 10:23:17 +0800 Subject: [PATCH 06/17] Fleet mode: per-member idle/startup mod hooks, not once-per-fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet-mode prose said the idle-hook fires "once across the shared roster" — bundling it with «roster-reconcile». But the bridge-inbox mod is per-workflow (fo.$SLUG.json, .inbox-cursor.$SLUG) and Bridge's LoadFORoster reads fo..json for EVERY member. Firing the idle-hook once would write only one member's heartbeat, so every other member shows not-attached in Bridge while actively driven, and only one inbox drains — contradicting #437's AC ("per-workflow _mods fire correctly, keyed by each $SLUG") and #436's per-workflow heartbeat. Separate the two scopes: per-workflow idle/startup mod hooks fire once PER MEMBER (each keyed by its $SLUG); only «roster-reconcile» is genuinely once-across-the-fleet. Correct "one fleet FO owns one cursor" to one cursor + one heartbeat per member, and state the target:all fan-out (drained by every member's cursor) honestly. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/first-officer/references/first-officer-shared-core.md | 4 ++-- skills/first-officer/references/fo-dispatch-core.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 705e6add8..a6b47c439 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -89,10 +89,10 @@ An opt-in mode for driving MULTIPLE commissioned workflows from ONE session — - **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. - **Member set.** On the trigger, adopt every `${SPACEDOCK_BIN:-spacedock} status --discover` path as a member workflow. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero → report-and-stop (the Startup step 3 block holds; never broad-search to widen the set). -- **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. +- **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. A member's per-workflow `## Hook: startup` mods (e.g. bridge-inbox's boot heartbeat + initial drain) likewise fire once per member, keyed by that member's `$SLUG`. - **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 8). Headless: drive every member's dispatchables per step 8; "given the conn" resolves gates across the members the grant names. - **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. -- **Captain intent routing.** When a bridge-inbox drain is registered, a drained record's `target` selects scope: `all` (or absent) applies fleet-wide; a `{slug}` applies only to that member. One fleet FO owns one cursor, so 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. 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. - **Write scope and gates are unchanged.** Each member's entities, gates, `## Stage Report` review, and FO write scope are exactly as in single-workflow mode, scoped by the member's `{workflow_dir}`. ## Working Directory diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index 3902d47a4..ac9461510 100644 --- a/skills/first-officer/references/fo-dispatch-core.md +++ b/skills/first-officer/references/fo-dispatch-core.md @@ -147,4 +147,4 @@ These are FO-internal scheduling reads — consume them as `--json` (compact, by Repeat from step 1 after each completion until the captain ends the session or, in single-entity mode, the target entity is resolved. -**Fleet mode — round-robin across member workflows.** When the session adopted a member set (shared core `## Fleet Mode`), wrap `«dispatch.next-action»()` in an outer round-robin over members: run one iteration scoped to each member's `{workflow_dir}` (every `status` / `dispatch` / `--set` call already carries `--workflow-dir`, so no command changes), dispatching whichever members have ready work. The fleet iteration ends only when NO member is dispatchable; the idle-hook + `«roster-reconcile»` step-0 sweep (when PRESENT) fires once across the shared roster — the roster spans every member's workers, so one reconcile covers the fleet. A single-member set is byte-identical to single-workflow mode. A per-member halt (rebase-conflict, unmet clarification, a gate awaiting the captain) suspends only that member's slot; the round-robin keeps advancing the others. +**Fleet mode — round-robin across member workflows.** When the session adopted a member set (shared core `## Fleet Mode`), wrap `«dispatch.next-action»()` in an outer round-robin over members: run one iteration scoped to each member's `{workflow_dir}` (every `status` / `dispatch` / `--set` call already carries `--workflow-dir`, so no command changes), dispatching whichever members have ready work. The fleet iteration ends only when NO member is dispatchable. At that boundary two things fire with DIFFERENT scope — do not conflate them: per-workflow `idle` hooks fire **once per member**, each keyed by that member's `{workflow_dir}`/`$SLUG` (so each member's bridge-inbox heartbeat + drain, intake, and pr-merge idle hooks run for its OWN slug — Bridge sees every member live, not just one, and each member's inbox is drained against its own cursor); the `«roster-reconcile»` step-0 sweep (when PRESENT) fires **once across the shared roster** — the roster spans every member's workers, so one reconcile covers the fleet. A single-member set is byte-identical to single-workflow mode. A per-member halt (rebase-conflict, unmet clarification, a gate awaiting the captain) suspends only that member's slot; the round-robin keeps advancing the others. From ca3f5bcde6ff7e0b762c3be8b62a99da1b55de02 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 24 Jun 2026 11:49:44 +0800 Subject: [PATCH 07/17] Fleet mode: let the directive name a subset of workflows to drive An antagonistic review of the fleet-mode playbook found the prose only supported adopt-ALL-discovered, while the user's actual use ("drive a few") needs a subset. Member set is now: the workflows the directive NAMES (each resolved against `status --discover` by slug/path; an unresolved name is reported and skipped, never broad-searched), else EVERY discovered path. A bare "drive the fleet" is unchanged (adopt all); the interactive greet lists the resolved set to confirm before dispatch. Compatibility-first; contractlint stays green. Refs #437. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/first-officer/references/first-officer-shared-core.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index a6b47c439..279dcfdd9 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -12,7 +12,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg In every class, do NOT proceed to discovery or `--boot`. 2. Discover the project root with `git rev-parse --show-toplevel`. -3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → present the list (or fail with an ambiguity error in single-entity mode) — UNLESS the launch carries a quotable fleet directive (per `## Fleet Mode`), in which case adopt ALL discovered paths as the member set instead of presenting the list. Zero discover still reports-and-stops in every mode. +3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → present the list (or fail with an ambiguity error in single-entity mode) — UNLESS the launch carries a quotable fleet directive (per `## Fleet Mode`), in which case adopt the directive's named workflows as the member set (or ALL discovered paths when it names none) instead of presenting the list. Zero discover still reports-and-stops in every mode. - **block (zero discover):** do NOT broad-search the filesystem to hunt a workflow — no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root. Report no workflow and stop. (Code-gated by the `detectBroadSearchAtBoot` boot detector.) 4. Read the workflow stage taxonomy via `${SPACEDOCK_BIN:-spacedock} status --read {workflow_dir}/README.md --json` — its `stages` array carries stage names/ordering and the per-stage `initial`/`terminal`/`gate`/`worktree`/`feedback-to`/`agent` flags the greet and gate need, plus the mission line / entity labels (`entity-label` / `entity-label-plural`) / `id-style` from the flat `frontmatter` object. DEFER the README body (per-stage prose, proof policy, templates, CI docs); it loads only when its consuming phase runs (a dispatch copies a stage subsection via `show-stage-def`; the merge ceremony reads `merge:` policy). 5. `«state.boot»()` — read all startup information in one call. Consume it as JSON (every value a string); the human-formatted table is NOT rendered for the FO's own reasoning. The before-greet boot is all READS — none reads a mod file or creates a team. Sections: @@ -88,7 +88,7 @@ A headless run scoped to one named entity — not a distinct mode. Startup step An opt-in mode for driving MULTIPLE commissioned workflows from ONE session — the dual of Single-Entity Scope (which narrows to one entity; this widens to every workflow). Default behavior is unchanged: absent the directive, discovery resolves one workflow or presents the list (Startup step 3). - **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. -- **Member set.** On the trigger, adopt every `${SPACEDOCK_BIN:-spacedock} status --discover` path as a member workflow. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero → report-and-stop (the Startup step 3 block holds; never broad-search to widen the set). +- **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 3 block holds; never broad-search to widen the set). The interactive greet (below) lists the resolved member set, so the captain confirms it before any dispatch. - **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. A member's per-workflow `## Hook: startup` mods (e.g. bridge-inbox's boot heartbeat + initial drain) likewise fire once per member, keyed by that member's `$SLUG`. - **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 8). Headless: drive every member's dispatchables per step 8; "given the conn" resolves gates across the members the grant names. - **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. From ac4e3a718aca2ac3554272e89cf46f0b64120d5d Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 25 Jun 2026 10:06:51 +0800 Subject: [PATCH 08/17] First officer: write the bridge-inbox liveness heartbeat before-greet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A greet-and-stop fleet boot showed "no FO attached" in Bridge despite a live FO: the bridge-inbox `## Hook: startup` (the per-$SLUG _bridge/fo.$SLUG.json heartbeat + initial drain) was swept into the blanket "Startup hooks run deferred" rule, so it deferred to first dispatch like the comm-officer spawn. But the captain's directive was "greet and stop before dispatching" — which never enters the event loop — and after dispatch the entities held at gates, suspending each member's slot, so the FO never reached the idle boundary that fires the idle heartbeat either. Net: no heartbeat all session, and Bridge's LoadFORoster (fresh fo..json within 30 min) honestly reported not-attached. Make the heartbeat boot liveness, not event-loop work: - New before-greet Startup step 7b: when the MODS map registers a `startup` bridge-inbox hook, run its `## Hook: startup` (write the per-$SLUG heartbeat + initial drain) BEFORE the greet, so a live FO shows attached the moment it boots. A greet-and-stop boot STILL runs it, unlike the deferred comm-officer spawn. - MODS note (step 5): "Startup hooks run deferred" -> "deferred EXCEPT where a boot step runs one before-greet", carving out the heartbeat. - Fleet Mode: the per-member `## Hook: startup` bullet now points at step 7b — every member shows live in Bridge's roster from boot, even in a greet-and-stop launch, not only after the first dispatch. Referenced by mod NAME, not a concrete `_mods/bridge-inbox.md` path: only pr-merge has a canonical /mods/ copy, so a concrete path would dead-end the boot-resident closure check. Tests: - contractlint bridge_heartbeat_boot_test: prose-contract anchor — reds if the before-greet step is dropped or the deferral carve-out reverts. - skills/integration bridge_inbox_boot_hook_smoke: binary precondition — `status --boot` MODS-REPORT surfaces `startup: bridge-inbox` so the FO knows to run step 7b. contractlint stays green. Refs #436, #437. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridge_heartbeat_boot_test.go | 54 +++++++++++++ .../references/first-officer-shared-core.md | 5 +- .../bridge_inbox_boot_hook_smoke_test.go | 75 +++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 internal/contractlint/bridge_heartbeat_boot_test.go create mode 100644 skills/integration/bridge_inbox_boot_hook_smoke_test.go diff --git a/internal/contractlint/bridge_heartbeat_boot_test.go b/internal/contractlint/bridge_heartbeat_boot_test.go new file mode 100644 index 000000000..d533a4c66 --- /dev/null +++ b/internal/contractlint/bridge_heartbeat_boot_test.go @@ -0,0 +1,54 @@ +// ABOUTME: Contract anchor — the bridge-inbox liveness heartbeat is a BEFORE-GREET +// ABOUTME: boot step, so a live FO shows attached in Bridge from boot even in a +// ABOUTME: greet-and-stop launch (the regression that read "no FO attached"). +package contractlint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBridgeHeartbeatRunsBeforeGreet locks the boot-time liveness contract that the +// `bridge-inbox` mod's `## Hook: startup` (the per-`$SLUG` `_bridge/fo.$SLUG.json` +// heartbeat + initial drain) is run BEFORE the greet — not deferred to first dispatch +// like the comm-officer spawn. Without this, a greet-and-stop boot (interactive +// step 8, which never enters the event loop) writes no heartbeat, so Bridge reads +// "no FO attached" though a live FO exists. The shipped behavior that produced this +// test: the FO greeted, parked at a gate, and never wrote a heartbeat all session. +func TestBridgeHeartbeatRunsBeforeGreet(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shared core: %v", err) + } + content := string(data) + + // The before-greet boot step must exist, name the canonical mod and the per-slug + // heartbeat file, and state explicitly that a greet-and-stop boot still runs it. + // Named by mod NAME (not a concrete `_mods/bridge-inbox.md` path): only pr-merge + // has a canonical `/mods/` copy, so naming a concrete bridge-inbox path + // would dead-end the boot-resident closure check. The MODS map gives the FO the + // mod name; it resolves `{workflow_dir}/_mods/.md` at runtime. + required := []string{ + "Bridge liveness heartbeat (before-greet)", + "startup` hook for `bridge-inbox", + "_bridge/fo.$SLUG.json", + "greet-and-stop boot", + } + for _, r := range required { + if !strings.Contains(content, r) { + t.Errorf("first-officer-shared-core.md no longer anchors the before-greet bridge-inbox heartbeat: missing %q.\n"+ + "Without a before-greet heartbeat, a greet-and-stop FO never attaches in Bridge.", r) + } + } + + // Guard the specific deferral carve-out: the MODS note must NOT claim ALL startup + // hooks defer, or the FO will again defer the heartbeat to first dispatch. + if strings.Contains(content, "Startup hooks run deferred:") && + !strings.Contains(content, "Startup hooks run deferred EXCEPT") { + t.Errorf("the MODS startup-hook note reverted to an unconditional 'Startup hooks run deferred:' — " + + "the bridge-inbox heartbeat must be carved out as before-greet, or greet-and-stop boots show no FO attached.") + } +} diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 279dcfdd9..174b66092 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 dispatch and merg - **block (zero discover):** do NOT broad-search the filesystem to hunt a workflow — no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root. Report no workflow and stop. (Code-gated by the `detectBroadSearchAtBoot` boot detector.) 4. Read the workflow stage taxonomy via `${SPACEDOCK_BIN:-spacedock} status --read {workflow_dir}/README.md --json` — its `stages` array carries stage names/ordering and the per-stage `initial`/`terminal`/`gate`/`worktree`/`feedback-to`/`agent` flags the greet and gate need, plus the mission line / entity labels (`entity-label` / `entity-label-plural`) / `id-style` from the flat `frontmatter` object. DEFER the README body (per-stage prose, proof policy, templates, CI docs); it loads only when its consuming phase runs (a dispatch copies a stage subsection via `show-stage-def`; the merge ceremony reads `merge:` policy). 5. `«state.boot»()` — read all startup information in one call. Consume it as JSON (every value a string); the human-formatted table is NOT rendered for the FO's own reasoning. The before-greet boot is all READS — none reads a mod file or creates a team. Sections: - - **MODS** (MODS-REPORT) — the `mods` map names which hooks are registered at which lifecycle point (startup, idle, merge). Reading the map does NOT open any mod file; it lets the greet *report* a registered hook (a pending merge-PR advancement, a comm-officer spawn) without opening the mod. Startup hooks run deferred: the comm-officer spawn defers to first dispatch (it needs a live team); the pr-merge advancement runs before-greet at the Merged-PR sweep below, gated on an actually-merged PR. + - **MODS** (MODS-REPORT) — the `mods` map names which hooks are registered at which lifecycle point (startup, idle, merge). Reading the map does NOT open any mod file; it lets the greet *report* a registered hook (a pending merge-PR advancement, a comm-officer spawn) without opening the mod. Startup hooks run deferred EXCEPT where a boot step below runs one before-greet: the comm-officer spawn defers to first dispatch (it needs a live team); the pr-merge advancement runs before-greet at the Merged-PR sweep below, gated on an actually-merged PR; the bridge-inbox liveness heartbeat (and its initial drain) runs before-greet at the Bridge liveness heartbeat step (step 7b) whenever a `startup` bridge-inbox hook is registered, so a live FO shows attached in Bridge the moment it boots — even a greet-and-stop boot that never enters the event loop. - **ID_STYLE** — `sequential`, `sd-b32`, or `slug`. - **NEXT_ID** — strategy-dependent ID candidate (not a reservation for `sd-b32`; `n/a (id-style: slug)` for `slug`). - **MIN_PREFIX** — `sd-b32` only; currently `MIN_PREFIX: 2`. @@ -27,6 +27,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg - **STATE_BACKEND** — `split-root` or `single-root`, the resolved entity dir, and whether it is present. 6. `«state.ensure-ready»()` — converge the split-root checkout to linked-and-integrated before any dispatch (the halt-gate + the pull-on-boot). A single-root workflow is a no-op. 7. `«state.sweep-merged»()` — advance every merged-PR entity to terminal at boot, before the greet. The common boot (no merged PR) reads zero mod files. +7b. **Bridge liveness heartbeat (before-greet).** When the MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its `## Hook: startup` BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat (present-time UTC) and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots, not only after the first dispatch. This is boot liveness, NOT event-loop work: a greet-and-stop boot (interactive step 8, which never enters the event loop) STILL runs it, unlike the deferred comm-officer spawn. The heartbeat write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` makes the drain a no-op. In fleet mode this runs once per member, keyed by each member's `$SLUG`/`{workflow_dir}` (see `## Fleet Mode`). Skip when no `startup` bridge-inbox hook is registered — the common boot reads zero mod files. 8. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary (boot JSON + README frontmatter) as today, including `gh`-absent UNKNOWN PR status. - **Interactive:** present the summary (and any ready `gate: true` gate as captain-facing text), then STOP for input; do NOT auto-dispatch. The expensive deferrals stay past the greet, reached on the captain's first direction. - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. @@ -89,7 +90,7 @@ An opt-in mode for driving MULTIPLE commissioned workflows from ONE session — - **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. - **Member set.** On the trigger, the member set is: the workflows the directive NAMES when it names any (each resolved against `${SPACEDOCK_BIN:-spacedock} status --discover` by slug or path — a named workflow that does not resolve is reported and skipped, never broad-searched for); otherwise EVERY discovered path. So "fleet mode: drive A, B, C" adopts exactly {A, B, C}, while a bare "drive the fleet" adopts all discovered. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero discovered → report-and-stop (the Startup step 3 block holds; never broad-search to widen the set). The interactive greet (below) lists the resolved member set, so the captain confirms it before any dispatch. -- **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. A member's per-workflow `## Hook: startup` mods (e.g. bridge-inbox's boot heartbeat + initial drain) likewise fire once per member, keyed by that member's `$SLUG`. +- **Per-member boot.** Startup steps 4–7 run once per member: each member's own README taxonomy (step 4), `«state.boot»` (step 5), `«state.ensure-ready»` (step 6), `«state.sweep-merged»` (step 7). Members may carry independent split-root state checkouts; ensure-ready / pull-on-boot / the rebase-conflict halt are per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members. A member's per-workflow `## Hook: startup` mods fire once per member, keyed by that member's `$SLUG`: bridge-inbox's boot heartbeat + initial drain run at the before-greet Bridge liveness heartbeat step (Startup step 7b) for EACH member, so every member shows live in Bridge's roster from boot — even in a greet-and-stop launch, not only after the first dispatch. - **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 8). Headless: drive every member's dispatchables per step 8; "given the conn" resolves gates across the members the grant names. - **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. - **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. A drained record's `target` selects scope: `all` (or absent) is drained by EVERY member's cursor (fleet-wide — so a fleet-wide `tell` is acknowledged once per member, a fleet-wide `conn` grant adopts the conn for each member's entities); a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. diff --git a/skills/integration/bridge_inbox_boot_hook_smoke_test.go b/skills/integration/bridge_inbox_boot_hook_smoke_test.go new file mode 100644 index 000000000..d6b26efe6 --- /dev/null +++ b/skills/integration/bridge_inbox_boot_hook_smoke_test.go @@ -0,0 +1,75 @@ +// ABOUTME: Bridge-inbox boot smoke — the binary precondition the FO's before-greet +// ABOUTME: liveness heartbeat (shared core Startup step 7b) relies on: `status --boot` +// ABOUTME: MODS-REPORT surfaces a registered `startup` bridge-inbox hook so the FO +// ABOUTME: knows to write `_bridge/fo.$SLUG.json` before the greet, even greet-and-stop. +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// bridgeInboxMod is a minimal mod carrying a `## Hook: startup` heading — enough for +// the boot MODS-REPORT to register it under the `startup` lifecycle point. The real +// mod's heartbeat/drain body is exercised by the FO at runtime; this fixture locks +// only that the boot surface ADVERTISES the startup hook to the FO. +const bridgeInboxMod = `--- +name: bridge-inbox +description: liveness heartbeat + captain-intent drain +--- + +## Hook: startup + +Write the heartbeat, then drain. + +## Hook: idle + +Refresh the heartbeat, then drain. +` + +// TestBootReportsBridgeInboxStartupHook locks the precondition for shared core +// Startup step 7b: a workflow carrying `_mods/bridge-inbox.md` with a `## Hook: +// startup` must show that hook under MODS in `status --boot`, so the FO runs the +// before-greet liveness heartbeat. If the boot stopped surfacing the startup hook, +// the FO could not know to write `_bridge/fo.$SLUG.json` before the greet, and a +// greet-and-stop launch would read "no FO attached" in Bridge. +func TestBootReportsBridgeInboxStartupHook(t *testing.T) { + root := t.TempDir() + defDir := filepath.Join(root, "wf-hb") + if err := os.MkdirAll(filepath.Join(defDir, "_mods"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(defDir, "README.md"), []byte(fleetMemberReadme), 0o644); err != nil { + t.Fatal(err) + } + entity := "---\nid: \"\"\ntitle: HB one\nstatus: backlog\nscore: \"0.50\"\nsource: smoke\n---\n# HB one\n\nSeed entity.\n" + if err := os.WriteFile(filepath.Join(defDir, "wf-hb-1.md"), []byte(entity), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(defDir, "_mods", "bridge-inbox.md"), []byte(bridgeInboxMod), 0o644); err != nil { + t.Fatal(err) + } + gitInitFixture(t, root) + + out, code := runStatus(t, defDir, "--boot") + if code != 0 { + t.Fatalf("status --boot exit %d:\n%s", code, out) + } + + // MODS-REPORT must carry a `startup:` line that names bridge-inbox. + var startupLine string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "startup:") { + startupLine = line + break + } + } + if startupLine == "" { + t.Fatalf("status --boot MODS-REPORT has no `startup:` hook line — the FO cannot learn the bridge-inbox startup hook is registered:\n%s", out) + } + if !strings.Contains(startupLine, "bridge-inbox") { + t.Fatalf("status --boot `startup:` line %q does not name bridge-inbox — the before-greet liveness heartbeat (step 7b) precondition is broken:\n%s", startupLine, out) + } +} From f43c090e6199a4566445a40e879488170079aad0 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 25 Jun 2026 15:45:45 +0800 Subject: [PATCH 09/17] =?UTF-8?q?Ensign:=20write=20the=20bridge=20session?= =?UTF-8?q?=E2=86=92entity=20link=20at=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bridge command-center shows a ship as "running" only when it can tie a live ensign to an entity. The event stream (_bridge/events.jsonl) carries each ensign's session_id but NOT the entity, so on its own it can say "an ensign is working" but not which ship — every actively-worked ticket stayed QUEUED for workflows the FO drives without committing per-dispatch narration (the durable git-feed source is empty there). Give Bridge the missing join: as its first action, a Claude ensign writes _bridge/sessions/.json = {session_id, entity, stage}, keyed by CLAUDE_CODE_SESSION_ID (which equals the hook-payload session_id in events.jsonl) and anchored at the repo root. Bridge joins its WORKING sessions against this map to flip the entity running in real time, then the map is irrelevant — liveness comes from the event stream, not the file's age, so no refresh or cleanup is needed. Observe-only and non-blocking: every step degrades to a no-op (unset or unsafe session id, no git root, write failure), so it can never break an assignment. This is the producer half of the Bridge running-badge consumer (spacedock-dev/bridge#29). Test: skills/integration extracts the EXACT documented shell from claude-ensign-runtime.md and runs it (no prose/test drift), asserting the marker content and that an unset session id is a clean no-op. contractlint stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/claude-ensign-runtime.md | 18 +++ .../integration/bridge_session_link_test.go | 111 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 skills/integration/bridge_session_link_test.go diff --git a/skills/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 9603faeb3..80e87d08d 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,6 +6,24 @@ 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 Session Link + +As your FIRST action after reading your assignment (before stage work), record this session→entity link so the Bridge command-center UI shows the entity you are driving as **running** in real time. Bridge joins the `_bridge/events.jsonl` activity stream — which carries your `session_id` but not the entity — against this file, so without it your live work cannot be tied to a ship. Run it once, in one shell: + +``` +SID="${CLAUDE_CODE_SESSION_ID:-}" +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +case "$SID" in ""|*[!A-Za-z0-9._-]*) SID="" ;; esac # skip on an unset/unsafe id +if [ -n "$SID" ] && [ -n "$ROOT" ]; then + mkdir -p "$ROOT/_bridge/sessions" 2>/dev/null && + printf '{"session_id":"%s","entity":"%s","stage":"%s"}\n' \ + "$SID" "ENTITY_SLUG" "STAGE_NAME" > "$ROOT/_bridge/sessions/$SID.json" 2>/dev/null +fi +true +``` + +Substitute `ENTITY_SLUG` with your entity's slug (the entity file's basename without `.md`, or its parent directory name for an `index.md` entity) and `STAGE_NAME` with your assigned stage. This is observe-only liveness: every step degrades to a no-op, so never let it block or fail your assignment, and you do not need to update or remove it — a finished session simply stops appearing in the live stream, and Bridge derives liveness from the event stream, not this file's age. + ## Clarification If requirements are unclear or ambiguous, ask for clarification via `SendMessage(to="team-lead")` rather than guessing. Describe what you understand and what's ambiguous so team-lead can get you a quick answer. diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go new file mode 100644 index 000000000..9091ec065 --- /dev/null +++ b/skills/integration/bridge_session_link_test.go @@ -0,0 +1,111 @@ +// ABOUTME: Bridge session-link smoke — runs the EXACT shell the Claude ensign +// ABOUTME: runtime documents (extracted from the doc, no drift) and asserts it +// ABOUTME: writes the _bridge/sessions/.json map Bridge joins against +// ABOUTME: events.jsonl to show the ensign's entity as RUNNING in real time. +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// firstFencedBlock returns the first ``` ... ``` block that follows the given +// heading line in a markdown file. Running the doc's own snippet (rather than a +// copy) keeps this test honest: if the documented shell is removed or its contract +// changes, the extraction fails or the assertions break. +func firstFencedBlock(t *testing.T, path, heading string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + lines := strings.Split(string(data), "\n") + i := 0 + for ; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == heading { + break + } + } + if i == len(lines) { + t.Fatalf("heading %q not found in %s", heading, path) + } + // Find the opening fence after the heading. + for ; i < len(lines); i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { + break + } + } + if i == len(lines) { + t.Fatalf("no fenced block after %q in %s", heading, path) + } + var body []string + for j := i + 1; j < len(lines); j++ { + if strings.HasPrefix(strings.TrimSpace(lines[j]), "```") { + return strings.Join(body, "\n") + } + body = append(body, lines[j]) + } + t.Fatalf("unterminated fenced block after %q in %s", heading, path) + return "" +} + +// gitInitBare returns a fresh temp dir initialized as a git repo (no commit needed) +// so the documented snippet's `git rev-parse --show-toplevel` resolves to it. +func gitInitBare(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("git", "init", "-q") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + return dir +} + +// TestEnsignBridgeSessionLink runs the documented session-link shell and proves it +// writes the session→entity map keyed by CLAUDE_CODE_SESSION_ID at the repo root — +// the producer half of Bridge's live "running" badge. +func TestEnsignBridgeSessionLink(t *testing.T) { + doc := filepath.Join("..", "ensign", "references", "claude-ensign-runtime.md") + snippet := firstFencedBlock(t, doc, "## Bridge Session Link") + // Substitute the placeholders an ensign fills from its assignment. + snippet = strings.ReplaceAll(snippet, "ENTITY_SLUG", "drc-3339") + snippet = strings.ReplaceAll(snippet, "STAGE_NAME", "review") + + root := gitInitBare(t) // so `git rev-parse --show-toplevel` resolves to root + const sid = "ses-abc-123" + + cmd := exec.Command("bash", "-c", snippet) + cmd.Dir = root + cmd.Env = append(os.Environ(), "CLAUDE_CODE_SESSION_ID="+sid) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("documented session-link shell failed: %v\n%s", err, out) + } + + marker := filepath.Join(root, "_bridge", "sessions", sid+".json") + data, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("session→entity marker not written at %s: %v", marker, err) + } + got := string(data) + for _, want := range []string{`"session_id":"ses-abc-123"`, `"entity":"drc-3339"`, `"stage":"review"`} { + if !strings.Contains(got, want) { + t.Errorf("marker missing %s\ngot: %s", want, got) + } + } + + // Safety contract: an unset session id must be a clean no-op (no stray file). + root2 := gitInitBare(t) + cmd2 := exec.Command("bash", "-c", snippet) + cmd2.Dir = root2 + cmd2.Env = append(os.Environ(), "CLAUDE_CODE_SESSION_ID=") + if out, err := cmd2.CombinedOutput(); err != nil { + t.Fatalf("snippet must no-op (exit 0) on unset id, got: %v\n%s", err, out) + } + if entries, _ := os.ReadDir(filepath.Join(root2, "_bridge", "sessions")); len(entries) != 0 { + t.Errorf("unset session id should write nothing, found %d files", len(entries)) + } +} From e210aeeed10fa3b69187fc34b5eecee5213f57dd Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 25 Jun 2026 16:40:03 +0800 Subject: [PATCH 10/17] Bridge inbox: drain a captain "decision" record (self-described gates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Spacedock↔Bridge hookup for self-described decision gates (Bridge PR spacedock-dev/bridge#30). Bridge cannot perform a gate's external side-effects (a Linear write, a label), so when the captain resolves a decision gate (e.g. linear-drc-review's escalated CLOSE-NEEDS-DECISION) it does NOT advance the entity — it queues a `decision` record to the inbox. This mod drains it. - Inbox schema gains the `decision` kind + its `entity`/`field`/`value` fields. - Drain step 4 gains a `kind == "decision"` branch: resolve `entity` in THIS workflow, set `field=value` via `status --set`, then drive the entity through its current gate exactly as a captain gate decision — the FO's normal gate-resolution runs the workflow's own stage actions (including external writes the stage prose defines) and advances it. Idempotent; an entity that belongs to another member's slug is skipped like a mismatched target. No parser change: `status --read` already tolerates the optional `decision` stage key (Bridge parses it from the README directly). A skills/integration smoke locks that tolerance (a decision-block workflow still parses every stage through `status --read`). contractlint green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dev/_mods/bridge-inbox.md | 5 +- .../decision_gate_tolerance_test.go | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 skills/integration/decision_gate_tolerance_test.go diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 96cc6e735..38e3f7505 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -26,7 +26,7 @@ esac **Inbox record schema** (one JSON object per line, written by Bridge): ``` -{"ts": "", "kind": "tell" | "conn", "text": "", "granted": , "target": "" | "all"} +{"ts": "", "kind": "tell" | "conn" | "decision", "text": "", "granted": , "target": "" | "all", "entity": "", "field": "", "value": ""} ``` `target` routes the intent. Act on a record only when `target == "$SLUG"` **or** `target == "all"`; a **missing/empty `target` means `all`** (backward-compatible with older Bridge records, which carried no target). A record targeted at another workflow is skipped — but still counts as processed, so this workflow's cursor advances past it. @@ -75,9 +75,10 @@ Drain newly-queued captain intent addressed to this workflow, if any: sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl ``` If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent). -4. For each new record, in order, parse `kind` / `text` / `granted` / `target`. **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: +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. diff --git a/skills/integration/decision_gate_tolerance_test.go b/skills/integration/decision_gate_tolerance_test.go new file mode 100644 index 000000000..61d250e0a --- /dev/null +++ b/skills/integration/decision_gate_tolerance_test.go @@ -0,0 +1,46 @@ +// ABOUTME: Decision-gate tolerance smoke — a gate's self-described `decision` +// ABOUTME: block (the Bridge↔FO hookup for CLOSE/KEEP gates) is an OPTIONAL stage +// ABOUTME: key Bridge parses directly; this locks that Spacedock's own `status +// ABOUTME: --read` tolerates it (ignores it) rather than failing the parse. +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestStatusReadToleratesDecisionBlock locks that a workflow whose gate declares a +// machine-readable `decision` block still parses through `status --read`: the +// command exits 0 and emits every stage. Bridge consumes the block from the README +// directly; Spacedock must not choke on it (the FO's `status --set`/boot reads run +// against these same workflows). +func TestStatusReadToleratesDecisionBlock(t *testing.T) { + dir := t.TempDir() + readme := filepath.Join(dir, "README.md") + content := "---\ncommissioned-by: spacedock@1.0\nentity-type: ticket\nid-style: slug\n" + + "stages:\n states:\n - name: review\n initial: true\n" + + " - name: escalated\n gate: true\n feedback-to: review\n" + + " decision:\n field: verdict\n options:\n" + + " - {label: Close, value: CLOSED, handoff: fo}\n" + + " - {label: Keep, value: IMPROVED, handoff: fo}\n" + + " - name: reviewed\n terminal: true\n---\n# rev\n" + if err := os.WriteFile(readme, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(spacedockBinary(t), "status", "--read", readme, "--json") + cmd.Env = append(os.Environ(), "HOME="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("status --read on a decision-block workflow failed: %v\n%s", err, out) + } + got := string(out) + for _, stage := range []string{"review", "escalated", "reviewed"} { + if !strings.Contains(got, `"name":"`+stage+`"`) { + t.Errorf("status --read did not emit stage %q (decision block broke the parse):\n%s", stage, got) + } + } +} From d0b95599e750a5496c792a700f006ff4e222447f Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Thu, 25 Jun 2026 20:17:15 +0800 Subject: [PATCH 11/17] First officer: drain the inbox before presenting a gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a gate-vs-inbox race: when the captain queued a decision from Bridge for a gate (e.g. DRC-3354 escalated → CLOSE), the FO presented that same gate in the terminal and waited — never draining the queued decision. The two surfaces collided; the decision sat unprocessed (cursor never advanced) until the captain manually said "drain your inbox". Bridge cannot wake a parked FO (it queues; the FO pulls on its loop ticks), and a gate-stop was not a drain point — so a decision that arrived since the last idle tick was stranded. `«gate.assemble-verdict»` now drains first: before assembling the review it fires THIS entity's `idle` hooks (keyed by its `$SLUG`) so a queued `decision` record is applied, then re-reads status — if the decision resolved the gate (entity advanced past the gated stage), it does NOT present, it reports what it applied and returns. Generic (any idle-hook mod runs — not hardcoded to bridge-inbox), per-member scoped, and honoring the captain's own queued decision is not a self-approval (the block still forbids inventing a verdict). Nothing queued ⇒ no-op ⇒ present normally. This is the single presentation chokepoint (interactive + headless both route through it); the before-greet boot drain already covers pre-boot decisions. Inherent limit unchanged: a decision that arrives while the FO is already parked still needs the next turn — Bridge has no push. Test: contractlint anchor locks the drain-before-present effect and that it precedes the decide/present effect. contractlint stays green (66 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gate_drain_before_present_test.go | 45 +++++++++++++++++++ .../references/first-officer-shared-core.md | 1 + 2 files changed, 46 insertions(+) create mode 100644 internal/contractlint/gate_drain_before_present_test.go diff --git a/internal/contractlint/gate_drain_before_present_test.go b/internal/contractlint/gate_drain_before_present_test.go new file mode 100644 index 000000000..c9046cb47 --- /dev/null +++ b/internal/contractlint/gate_drain_before_present_test.go @@ -0,0 +1,45 @@ +// ABOUTME: Contract anchor — «gate.assemble-verdict» drains the inbox BEFORE +// ABOUTME: presenting a gate, so a captain decision queued from Bridge for that +// ABOUTME: gate is honored instead of colliding with a redundant terminal prompt. +package contractlint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 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. +func TestGateAssembleVerdictDrainsBeforePresenting(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shared core: %v", err) + } + content := string(data) + + for _, r := range []string{ + "drain before presenting", + "Bridge cannot wake a parked FO", + "do NOT present", + } { + if !strings.Contains(content, r) { + t.Errorf("«gate.assemble-verdict» no longer drains the inbox before presenting a gate: missing %q.\n"+ + "Without it, a Bridge-queued captain decision collides with a redundant terminal gate prompt.", r) + } + } + + // Ordering: the drain effect must precede the decide/present effect — draining + // after presenting would not prevent the redundant prompt. + drainAt := strings.Index(content, "drain before presenting") + decideAt := strings.Index(content, "effect — decide") + if drainAt < 0 || decideAt < 0 || drainAt > decideAt { + t.Errorf("the drain effect must come BEFORE the decide/present effect in «gate.assemble-verdict» (drain@%d, decide@%d)", drainAt, decideAt) + } +} diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 174b66092..d6e5bcb39 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -131,6 +131,7 @@ If the stage is gated, `«gate.assemble-verdict»(slug, stage)`, then route on t ## «gate.assemble-verdict»(slug, stage): assemble the gate review and render the verdict +- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's workflow `idle` hooks once (keyed by its `$SLUG`) so a captain decision already queued from Bridge for this gate — a `decision` inbox record (e.g. set `verdict`) — is drained and applied now, then re-read the entity's status. Bridge cannot wake a parked FO (it queues, the FO pulls), so a decision that arrived since the last idle tick would otherwise sit unprocessed while you redundantly present a gate the captain has already answered. If the drain advanced the entity past `{stage}` (the queued decision resolved this gate), do NOT present — report what you applied and return. Acting on the captain's own queued decision is honoring it, not a self-approval (the **block** below still forbids inventing a verdict). If nothing was queued, the drain is a no-op and you present normally. - **effect — extract (deterministic):** roll up the structured inputs via the shipped modes — `status --read --checklist` and `status --read --ac-scan`. These feed the verdict; they do not make it. - **effect — decide (judgment):** the verdict (approve/reject, is-this-AC-satisfied, is-this-direction-sound) is irreducible judgment; the FO renders its own `Recommend` line. Present via `Skill(skill="spacedock:present-gate")` and its template + assembly rules. - **done-when:** the gate review is presented and the FO is waiting on the captain's decision, the worker kept alive. From 9eaeaf00f3acaea7148c23fedc0495bb163f2fcd Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 09:31:44 +0800 Subject: [PATCH 12/17] =?UTF-8?q?Bridge=20events:=20write=20the=20session?= =?UTF-8?q?=E2=86=92entity=20marker=20from=20the=20hook,=20not=20ensign=20?= =?UTF-8?q?prose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge's "running" badge maps a live working session to its ship via _bridge/sessions/.json. That marker was written by an ensign first-action shell — which the ensign LLM skipped ~3 of every 4 dispatches (measured: 10 of 13 recent ensign sessions had no marker), so the actively-working ensigns couldn't be mapped and almost nothing showed as running. Move the write into the event hook, which fires deterministically on every tool call (the same mechanism that keeps events.jsonl accurate). On an ENSIGN's Read of its entity file (.../docs/spacedock//.md, flat or /index.md) it records {session_id, entity, workflow}: - Reliable — hook-driven, not LLM-compliance-driven. - Collision-free — the path carries the WORKFLOW, so a ticket id reused across workflows (e.g. drc-3467 in both linear-drc-review and linear-drc-ship) is no longer ambiguous (Bridge scopes the join by workflow; consumer change is a separate Bridge PR). - First-write-wins per session → the ensign's OWN entity (read before any duplicate-check sibling reads) is recorded; siblings don't overwrite. - Ensign-only (agent_type), _archive/_mods and unsafe slugs rejected, observe-only and best-effort (every step degrades to a no-op). Removes the now-redundant "Bridge Session Link" first-action from the Claude ensign runtime (the ensign needs to do nothing — reading its entity file is already part of its work). Test: skills/integration drives the hook with synthetic payloads — entity + workflow recorded, first-write-wins on a sibling Read, FO Read writes nothing, _archive Read skipped. contractlint stays green (66). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spacedock-bridge-events.sh | 36 +++++ .../references/claude-ensign-runtime.md | 18 +-- .../integration/bridge_session_link_test.go | 129 +++++++----------- 3 files changed, 85 insertions(+), 98 deletions(-) diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index c23425379..1064ad94c 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -46,6 +46,42 @@ printf '%s' "$payload" | jq -c \ } }' >> "$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) + if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: //index.md + slug="$(basename "$(dirname "$fp")")"; wf="$(basename "$(dirname "$(dirname "$fp")")")" + else # flat entity: /.md + slug="$(basename "$fp" .md)"; wf="$(basename "$(dirname "$fp")")" + fi + case "$wf" in ""|_*|*[!A-Za-z0-9._-]*) wf="" ;; esac # skip _archive/_mods, 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 diff --git a/skills/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 80e87d08d..0c08c1f8e 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,23 +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 Session Link - -As your FIRST action after reading your assignment (before stage work), record this session→entity link so the Bridge command-center UI shows the entity you are driving as **running** in real time. Bridge joins the `_bridge/events.jsonl` activity stream — which carries your `session_id` but not the entity — against this file, so without it your live work cannot be tied to a ship. Run it once, in one shell: - -``` -SID="${CLAUDE_CODE_SESSION_ID:-}" -ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" -case "$SID" in ""|*[!A-Za-z0-9._-]*) SID="" ;; esac # skip on an unset/unsafe id -if [ -n "$SID" ] && [ -n "$ROOT" ]; then - mkdir -p "$ROOT/_bridge/sessions" 2>/dev/null && - printf '{"session_id":"%s","entity":"%s","stage":"%s"}\n' \ - "$SID" "ENTITY_SLUG" "STAGE_NAME" > "$ROOT/_bridge/sessions/$SID.json" 2>/dev/null -fi -true -``` - -Substitute `ENTITY_SLUG` with your entity's slug (the entity file's basename without `.md`, or its parent directory name for an `index.md` entity) and `STAGE_NAME` with your assigned stage. This is observe-only liveness: every step degrades to a no-op, so never let it block or fail your assignment, and you do not need to update or remove it — a finished session simply stops appearing in the live stream, and Bridge derives liveness from the event stream, not this file's age. +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. ## Clarification diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index 9091ec065..dfabc0b6d 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -1,7 +1,7 @@ -// ABOUTME: Bridge session-link smoke — runs the EXACT shell the Claude ensign -// ABOUTME: runtime documents (extracted from the doc, no drift) and asserts it -// ABOUTME: writes the _bridge/sessions/.json map Bridge joins against -// ABOUTME: events.jsonl to show the ensign's entity as RUNNING in real time. +// 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. package integration import ( @@ -12,100 +12,67 @@ import ( "testing" ) -// firstFencedBlock returns the first ``` ... ``` block that follows the given -// heading line in a markdown file. Running the doc's own snippet (rather than a -// copy) keeps this test honest: if the documented shell is removed or its contract -// changes, the extraction fails or the assertions break. -func firstFencedBlock(t *testing.T, path, heading string) string { +// 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) { t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - lines := strings.Split(string(data), "\n") - i := 0 - for ; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == heading { - break - } - } - if i == len(lines) { - t.Fatalf("heading %q not found in %s", heading, path) - } - // Find the opening fence after the heading. - for ; i < len(lines); i++ { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { - break - } - } - if i == len(lines) { - t.Fatalf("no fenced block after %q in %s", heading, path) + 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) } - var body []string - for j := i + 1; j < len(lines); j++ { - if strings.HasPrefix(strings.TrimSpace(lines[j]), "```") { - return strings.Join(body, "\n") - } - body = append(body, lines[j]) - } - t.Fatalf("unterminated fenced block after %q in %s", heading, path) - return "" -} - -// gitInitBare returns a fresh temp dir initialized as a git repo (no commit needed) -// so the documented snippet's `git rev-parse --show-toplevel` resolves to it. -func gitInitBare(t *testing.T) string { - t.Helper() - dir := t.TempDir() - cmd := exec.Command("git", "init", "-q") - cmd.Dir = dir + cmd := exec.Command("bash", hook) + cmd.Stdin = strings.NewReader(payload) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git init: %v\n%s", err, out) + t.Fatalf("event hook failed: %v\n%s", err, out) } - return dir } -// TestEnsignBridgeSessionLink runs the documented session-link shell and proves it -// writes the session→entity map keyed by CLAUDE_CODE_SESSION_ID at the repo root — -// the producer half of Bridge's live "running" badge. -func TestEnsignBridgeSessionLink(t *testing.T) { - doc := filepath.Join("..", "ensign", "references", "claude-ensign-runtime.md") - snippet := firstFencedBlock(t, doc, "## Bridge Session Link") - // Substitute the placeholders an ensign fills from its assignment. - snippet = strings.ReplaceAll(snippet, "ENTITY_SLUG", "drc-3339") - snippet = strings.ReplaceAll(snippet, "STAGE_NAME", "review") +func readPayload(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 + `"}}` +} - root := gitInitBare(t) // so `git rev-parse --show-toplevel` resolves to root - const sid = "ses-abc-123" +// 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") - cmd := exec.Command("bash", "-c", snippet) - cmd.Dir = root - cmd.Env = append(os.Environ(), "CLAUDE_CODE_SESSION_ID="+sid) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("documented session-link shell failed: %v\n%s", err, out) - } - - marker := filepath.Join(root, "_bridge", "sessions", sid+".json") + // 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) if err != nil { - t.Fatalf("session→entity marker not written at %s: %v", marker, err) + t.Fatalf("marker not written: %v", err) } got := string(data) - for _, want := range []string{`"session_id":"ses-abc-123"`, `"entity":"drc-3339"`, `"stage":"review"`} { + 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) } } - // Safety contract: an unset session id must be a clean no-op (no stray file). - root2 := gitInitBare(t) - cmd2 := exec.Command("bash", "-c", snippet) - cmd2.Dir = root2 - cmd2.Env = append(os.Environ(), "CLAUDE_CODE_SESSION_ID=") - if out, err := cmd2.CombinedOutput(); err != nil { - t.Fatalf("snippet must no-op (exit 0) on unset id, got: %v\n%s", err, out) + // 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) + } + + // 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 { + t.Errorf("FO Read should not produce a session marker") } - if entries, _ := os.ReadDir(filepath.Join(root2, "_bridge", "sessions")); len(entries) != 0 { - t.Errorf("unset session id should write nothing, found %d files", len(entries)) + + // 4. _archive entity Reads are skipped (workflow dir starting with _ is rejected). + 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 { + t.Errorf("_archive Read should not produce a session marker") } } From 3f16957870b804e7bbf874a3a66b0e468b2e6b80 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 09:53:19 +0800 Subject: [PATCH 13/17] Bridge events: match a repo-relative entity path in the session-marker hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FO passes a repo-relative {entity_file_path} (docs/spacedock//.md), so an ensign's scoped Read carries that relative path. The marker hook only matched */docs/spacedock/*/*.md (leading slash required), so it missed EVERY live ensign — markers were only written by my synthetic absolute-path tests. Match the relative form too. Verified: relative-path Read now records {entity, workflow}. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spacedock-bridge-events.sh | 2 +- skills/integration/bridge_session_link_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index 1064ad94c..bb7f6daba 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -65,7 +65,7 @@ if [ -n "$m_sid" ] && [ "$m_type" = "spacedock:ensign" ] && [ "$m_evt" = "PostTo 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|docs/spacedock/*/*.md) # absolute OR repo-relative (the FO passes a relative {entity_file_path}) if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: //index.md slug="$(basename "$(dirname "$fp")")"; wf="$(basename "$(dirname "$(dirname "$fp")")")" else # flat entity: /.md diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index dfabc0b6d..658f7c885 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -69,6 +69,22 @@ func TestEventHookWritesSessionMarker(t *testing.T) { 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", + "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) + } + } + // 4. _archive entity Reads are skipped (workflow dir starting with _ is rejected). arch := filepath.Join(root, "docs", "spacedock", "linear-drc-review", "_archive", "drc-1.md") runEventHook(t, readPayload(root, "ses-arch", "spacedock:ensign", arch)) From a1f1fdcc3488b562fbbbeeb4dda60658f3db4e88 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 10:24:36 +0800 Subject: [PATCH 14/17] Ensign: never commit at the bare repo root (RC1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review ensign for a single-root, non-worktree, local-only-entity workflow (linear-drc-review) followed the generic "MUST commit before signaling" and ran a bare git add/commit at the repo ROOT. A concurrent actor had switched that shared working tree to feature/more-migration-issues, so the entity landed on an unrelated branch (and that ancient branch lacked the workflow gitignore rule, so the meant-to-be-local entity got committed). Root cause: the commit contract only defined a target for worktree and split-root stages; a single-root non-worktree stage fell through to a bare-root commit. Scope the rule: commit ONLY to your isolated target (a worktree, or a split-root state checkout) — NEVER git add/commit at the bare repo root. A single-root non-worktree stage has no ensign commit target: write the entity in place (plus the stage external write, e.g. Linear) and signal; trunk/state-transition commits are the FO scope. New "### Single-Root, No Commit Target" subsection; step 5 and the MUST-commit rule both scoped. Test: contractlint anchor locks the bare-root prohibition + the carve-out and reds if MUST-commit goes unqualified again. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ensign_no_bare_root_commit_test.go | 44 +++++++++++++++++++ .../ensign/references/ensign-shared-core.md | 12 ++++- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 internal/contractlint/ensign_no_bare_root_commit_test.go diff --git a/internal/contractlint/ensign_no_bare_root_commit_test.go b/internal/contractlint/ensign_no_bare_root_commit_test.go new file mode 100644 index 000000000..3b03715ff --- /dev/null +++ b/internal/contractlint/ensign_no_bare_root_commit_test.go @@ -0,0 +1,44 @@ +// ABOUTME: Contract anchor — an ensign commits only to its isolated target (a +// ABOUTME: worktree or a split-root state checkout), NEVER at the bare repo root. +// ABOUTME: A bare-root commit on a shared working tree lands the entity on whatever +// ABOUTME: branch a concurrent actor switched it to (the DRC-3653 collision). RC1. +package contractlint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEnsignNeverCommitsAtBareRepoRoot locks RC1: the ensign contract forbids +// `git add`/`git commit` at the bare repo root, and a single-root non-worktree +// stage (no worktree, no `state:` checkout) has no commit target — it writes the +// entity in place and signals. Without this, "MUST commit before signaling" drove +// a review ensign to commit at the shared repo root, which a concurrent branch +// switch had moved to an unrelated branch. +func TestEnsignNeverCommitsAtBareRepoRoot(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "ensign", "references", "ensign-shared-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read ensign shared core: %v", err) + } + content := string(data) + + for _, r := range []string{ + "bare repo root", // the forbidden target, named + "Single-Root, No Commit Target", // the carve-out subsection for non-worktree single-root stages + "no ensign commit target", // the explicit "nothing to commit" statement + } { + if !strings.Contains(content, r) { + t.Errorf("ensign-shared-core.md no longer forbids the bare-root commit (RC1): missing %q.\n"+ + "A non-worktree single-root ensign must NOT git-commit at the repo root — it pollutes whatever branch the shared tree is on.", r) + } + } + + // The "MUST commit before signaling" rule must be qualified — an unqualified + // "MUST commit" is what pushed the ensign to commit with no valid target. + if strings.Contains(content, "MUST commit before signaling completion.**") { + t.Errorf("the commit rule is unqualified again ('MUST commit before signaling completion.') — it must be scoped to a worktree/state-checkout target, never the bare repo root.") + } +} diff --git a/skills/ensign/references/ensign-shared-core.md b/skills/ensign/references/ensign-shared-core.md index 9a1c5ed97..91eeeaaac 100644 --- a/skills/ensign/references/ensign-shared-core.md +++ b/skills/ensign/references/ensign-shared-core.md @@ -19,7 +19,7 @@ Read the assignment context provided by the first officer. It defines: 2. If you were given a worktree path, keep all reads, writes, and commits under that worktree. 3. Perform the work described in the stage definition. 4. Update the entity file body, not the frontmatter. -5. Commit your work before signaling completion. +5. Commit your work before signaling completion — but ONLY to your isolated commit target: a worktree (commit there) or, for a split-root workflow, the state checkout (path-scoped, below). **NEVER `git add` / `git commit` at the bare repo root.** A single-root, non-worktree stage has no ensign commit target (see **Single-Root, No Commit Target** below) — write the entity in place and signal; do not commit. ## Proving your work @@ -43,12 +43,20 @@ When the workflow is split-root (README declares `state:` checkout, e.g. `state: **Rebase-conflict halt.** If `pull --rebase` CONFLICTS (two writers editing the SAME entity's frontmatter), HALT, `git -C {state_checkout} rebase --abort`, surface the conflicting entity path(s) and peer commit to the first officer, and stop. Do NOT `--force` / `--force-with-lease` push; do NOT auto-resolve (`-X ours/theirs` or discarding either side silently loses a peer's edit). This is manual intervention — the escalate-rather-than-guess discipline below. +### Single-Root, No Commit Target + +A workflow that is single-root (no `state:` checkout declared) AND gives you no worktree path has **no ensign commit target**. Its entity files live alongside the code at the repo root and are typically local-only (gitignored; the durable record is the entity write plus whatever external system the stage writes to — e.g. a Linear update). For such a stage: + +- Write the entity file (and perform the stage's external write, e.g. the Linear change) — **that is your deliverable**. There is nothing for you to git-commit. +- **NEVER run `git add` / `git commit` (or `git -f add`) at the repo root.** You share that working tree with the first officer and every other non-worktree agent, and it can be on any branch a concurrent actor switched it to. A bare-root commit lands your entity on whatever branch happens to be checked out — polluting an unrelated branch, and (on a branch whose `.gitignore` lacks this workflow's rule) committing a file that is meant to stay local. Trunk/state-transition commits are the **first officer's** scope (it owns them), not yours. +- Do not `git checkout` / switch branches, and do not assume any particular branch — your work does not depend on it. + ## Rules - Do NOT modify YAML frontmatter in entity files. - Do NOT modify files under `agents/` or `references/` — plugin scaffolding. - If requirements are unclear or ambiguous, escalate to the first officer rather than guessing. -- **MUST commit before signaling completion.** Signaling done without committing forces the FO to re-dispatch just to get a commit — the most common cause of nudge loops. If unsure whether work is complete, commit what you have and signal with concerns rather than going idle uncommitted. +- **MUST commit before signaling completion — to your commit target, never the bare repo root.** When you have a worktree or split-root state checkout, commit there before signaling; skipping it forces the FO to re-dispatch just to get a commit (the most common nudge-loop cause). When you have neither (a single-root, non-worktree stage — see **Single-Root, No Commit Target**), there is nothing to commit: leave the entity written in place and signal. Either way, **never `git add` / `git commit` at the bare repo root.** If unsure whether work is complete, commit what you have to your target (or, with no target, leave it written) and signal with concerns rather than going idle. - **Do not idle between steps.** If you are mid-task with remaining work, the next action is the next step — not waiting for external input. The stage definition is your complete specification. ## Background Bash Discipline From 417dfa261d760667d96fd16d8be677ac03bd1952 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 10:27:09 +0800 Subject: [PATCH 15/17] First officer: pin the launch branch, halt on a concurrent switch (RC2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FO shares one repo-root working tree with every non-worktree agent and any concurrent human/CI actor. A concurrent git checkout moved HEAD to feature/more-migration-issues mid-session, deleting the tracked workflow READMEs and stranding the FO on the wrong branch. Boot step 2 now records the launch branch (git rev-parse --abbrev-ref HEAD) and requires the FO to re-check it before each dispatch / state-changing git op, and HALT with a captain-facing surface if it changed — rather than dispatching or committing into a switched tree. Pinned on the branch NAME so a same-branch fast-forward is normal, not a halt. Points at the fleet playbook isolation guidance (RC2a) as the real prevention; this is the fail-safe. Defense-in-depth with RC1 (no bare-root commit) and RC2a (isolated checkout). Test: contractlint anchor locks the launch-branch record + halt + branch-NAME pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../contractlint/fo_branch_pin_halt_test.go | 42 +++++++++++++++++++ .../references/first-officer-shared-core.md | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 internal/contractlint/fo_branch_pin_halt_test.go diff --git a/internal/contractlint/fo_branch_pin_halt_test.go b/internal/contractlint/fo_branch_pin_halt_test.go new file mode 100644 index 000000000..a1a5e292e --- /dev/null +++ b/internal/contractlint/fo_branch_pin_halt_test.go @@ -0,0 +1,42 @@ +// ABOUTME: Contract anchor — the FO records its launch branch and HALTS if the +// ABOUTME: shared working tree's branch changes underneath it, instead of +// ABOUTME: dispatching/committing into a tree a concurrent actor switched (RC2b). +package contractlint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestFOPinsLaunchBranch locks RC2b: at boot the FO records the working tree's +// branch and re-checks it before dispatch / state-changing git ops, halting on a +// change. Pinned on the branch NAME so a same-branch fast-forward is not a halt. +// Without this, a concurrent branch switch (the DRC-3653 incident) silently moves +// the FO onto the wrong branch and deletes its tracked READMEs. +func TestFOPinsLaunchBranch(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "first-officer-shared-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shared core: %v", err) + } + content := string(data) + + for _, r := range []string{ + "launch branch", // the recorded baseline + "before each dispatch", // when it is re-checked + "branch changed underneath me", // the halt surface + } { + if !strings.Contains(content, r) { + t.Errorf("first-officer-shared-core.md no longer pins the launch branch (RC2b): missing %q.\n"+ + "Without it, a concurrent branch switch moves the FO onto the wrong branch and deletes its tracked workflow files.", r) + } + } + + // Must pin on the branch NAME, not the commit — a same-branch fast-forward is + // normal and must not trigger a false halt. + if !strings.Contains(content, "branch NAME") { + t.Errorf("the branch-pin must be on the branch NAME (a same-branch fast-forward is not a halt) — missing that qualification.") + } +} diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index d6e5bcb39..028e7aff5 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -11,7 +11,7 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg - **Binary present but contract out of range** — `` is below the lower bound (binary too old) or at/above the upper bound (plugin too old). ABORT with the mismatch message and run `${SPACEDOCK_BIN:-spacedock} doctor` for the per-class remedy. In every class, do NOT proceed to discovery or `--boot`. -2. Discover the project root with `git rev-parse --show-toplevel`. +2. Discover the project root with `git rev-parse --show-toplevel`, and record the branch it is on (`git rev-parse --abbrev-ref HEAD`) as your **launch branch**. You share this one working tree with every non-worktree agent and possibly a concurrent human/CI actor; a `git checkout` there moves HEAD and deletes tracked files (e.g. the workflow READMEs) out from under you. So **before each dispatch — and before any state-changing git operation — re-check the branch; if it differs from your launch branch, HALT** rather than dispatching or committing into a tree a concurrent actor switched. Surface: `the working tree's branch changed underneath me () — a concurrent actor moved it; stopping to avoid working on the wrong branch. Restore it (git checkout ) or relaunch me from an isolated checkout.` (Pin on the branch NAME: a fast-forward that adds commits on the SAME branch is normal and not a halt. Running from a dedicated worktree/clone no other actor touches avoids this entirely — see the fleet playbook.) 3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → present the list (or fail with an ambiguity error in single-entity mode) — UNLESS the launch carries a quotable fleet directive (per `## Fleet Mode`), in which case adopt the directive's named workflows as the member set (or ALL discovered paths when it names none) instead of presenting the list. Zero discover still reports-and-stops in every mode. - **block (zero discover):** do NOT broad-search the filesystem to hunt a workflow — no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root. Report no workflow and stop. (Code-gated by the `detectBroadSearchAtBoot` boot detector.) 4. Read the workflow stage taxonomy via `${SPACEDOCK_BIN:-spacedock} status --read {workflow_dir}/README.md --json` — its `stages` array carries stage names/ordering and the per-stage `initial`/`terminal`/`gate`/`worktree`/`feedback-to`/`agent` flags the greet and gate need, plus the mission line / entity labels (`entity-label` / `entity-label-plural`) / `id-style` from the flat `frontmatter` object. DEFER the README body (per-stage prose, proof policy, templates, CI docs); it loads only when its consuming phase runs (a dispatch copies a stage subsection via `show-stage-def`; the merge ceremony reads `merge:` policy). From 99bd10b179274b1ab6121606ff6942327e8ac5bf Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 14:26:52 +0800 Subject: [PATCH 16/17] First officer: eager captain-intent drain + liveness refresh, and a fleet-history feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Bridge-seam gaps for a busy/Driving FO: - Steering + liveness (Issue 2, event loop step 0.6): the captain inbox + heartbeat were only serviced at the idle boundary (step 3), which a continuously-dispatching FO never reaches — so a queued `pause`/redirect sat unread AND the heartbeat went stale (Bridge showed the FO not-attached mid-drive). Now run the bridge-inbox idle work — heartbeat refresh AND drain — at the TOP of every iteration. A drained pause halts further dispatch this iteration. (The heartbeat half is the antagonistic-review fix: draining alone left liveness broken for the exact busy-FO case.) - Fleet history (Issue 1, bridge-inbox mod): a local-only workflow commits no dispatch:/advance: git narration, so Bridge\047s fleet-history was empty while the FO drove. The mod now appends a narration line to _bridge/fo-feed.jsonl on dispatch/advance/complete (best-effort enrichment; Bridge also derives reliable dispatch events from the hook-written session markers, so the history is never empty even when this prose is skipped). Consumer + the reliable marker-derived feed land in Bridge (feat/fo-feed-consumer). Tests: contractlint anchors for the eager step (incl. heartbeat refresh) and the mod feed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dev/_mods/bridge-inbox.md | 16 ++++++ .../fo_feed_and_eager_drain_test.go | 57 +++++++++++++++++++ .../references/fo-dispatch-core.md | 1 + 3 files changed, 74 insertions(+) create mode 100644 internal/contractlint/fo_feed_and_eager_drain_test.go diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md index 38e3f7505..85f14f337 100644 --- a/docs/dev/_mods/bridge-inbox.md +++ b/docs/dev/_mods/bridge-inbox.md @@ -85,3 +85,19 @@ Drain newly-queued captain intent addressed to this workflow, if any: 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.) + +## Feed + +Bridge's fleet-history rail shows the FO's narration. For a workflow whose entities are committed it can read the `dispatch:`/`advance:` git narration — but a local-only workflow (entities gitignored; no such commits) leaves that history empty even while you drive. So append a narration line to `_bridge/fo-feed.jsonl` (relative to the repo root, the same `_bridge/` the heartbeat and inbox use) each time you **dispatch**, **advance**, or **complete** an entity: + +``` +mkdir -p _bridge +printf '{"ts":"%s","verb":"%s","entity":"%s","workflow":"%s","stage":"%s","text":"%s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "dispatch" "{slug}" "$SLUG" "{stage}" "{short note}" \ + >> _bridge/fo-feed.jsonl +``` + +- `verb` is `dispatch` (you sent an ensign to a stage), `advance` (you moved an entity to its next stage), or `complete` (an entity reached terminal). +- `entity` is the entity slug; `workflow` is `$SLUG` (this member's workflow); `stage` is the stage entered. +- `text` is a one-line human summary (≤120 chars, no newlines or `"`). Keep it factual — the captain reads this stream to follow the drive. +- Append-only and best-effort, exactly like the event stream: never let it block or fail the loop, and never rewrite the file (Bridge tails it; a concurrent append is fine). It is gitignored session runtime, like `events.jsonl`. diff --git a/internal/contractlint/fo_feed_and_eager_drain_test.go b/internal/contractlint/fo_feed_and_eager_drain_test.go new file mode 100644 index 000000000..a3d4e1f53 --- /dev/null +++ b/internal/contractlint/fo_feed_and_eager_drain_test.go @@ -0,0 +1,57 @@ +// ABOUTME: Contract anchors for the two Bridge-seam steering/visibility fixes: +// ABOUTME: (Issue 2) the FO drains captain intent EAGERLY at the top of each loop +// ABOUTME: iteration so a mid-drive `pause` is honored; (Issue 1) the bridge-inbox +// ABOUTME: mod writes a `_bridge/fo-feed.jsonl` narration the fleet-history reads. +package contractlint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEagerCaptainIntentDrain locks Issue 2: the event loop drains the captain +// inbox at the TOP of each iteration, not only at the idle boundary — a Driving FO +// never reaches idle, so an idle-only drain leaves a queued `pause` unread. +func TestEagerCaptainIntentDrain(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "first-officer", "references", "fo-dispatch-core.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fo-dispatch-core: %v", err) + } + c := string(data) + for _, r := range []string{ + "Drain captain intent + refresh liveness (eager", // the new pre-dispatch step + "heartbeat refresh", // RC: a busy FO must not go stale/not-attached + "never reaches idle", // why idle-only is insufficient + "halts further dispatch", // a pause takes effect this iteration + } { + if !strings.Contains(c, r) { + t.Errorf("fo-dispatch-core.md no longer drains captain intent eagerly (Issue 2): missing %q.\n"+ + "A Driving FO would never reach the idle boundary, so a queued pause/redirect sits unread.", r) + } + } +} + +// TestFOFeedNarration locks Issue 1: the bridge-inbox mod writes a session +// narration to _bridge/fo-feed.jsonl on dispatch/advance/complete, so the +// fleet-history rail shows activity for a local-only workflow that commits no +// dispatch:/advance: git narration. +func TestFOFeedNarration(t *testing.T) { + path := filepath.Join(repoRoot(t), "docs", "dev", "_mods", "bridge-inbox.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read bridge-inbox mod: %v", err) + } + c := string(data) + for _, r := range []string{ + "_bridge/fo-feed.jsonl", // the feed file Bridge reads + "dispatch", // the verbs narrated + "local-only workflow", // the case it exists for + } { + if !strings.Contains(c, r) { + t.Errorf("bridge-inbox mod no longer writes the FO feed (Issue 1): missing %q.", r) + } + } +} diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index ac9461510..edbaa6450 100644 --- a/skills/first-officer/references/fo-dispatch-core.md +++ b/skills/first-officer/references/fo-dispatch-core.md @@ -138,6 +138,7 @@ These are FO-internal scheduling reads — consume them as `--json` (compact, by `«roster-reconcile»` inserts a host step 0 before step 0.5; a host where it is ABSENT omits it. The skeleton is: 0.5. **Drain inbound worker messages.** When `«addressable-worker»` is PRESENT, drain pending worker messages (its listen call) at each iteration before checking dispatchables. Reply to a `need_decision` / `interview_request` within the worker's timeout window; read and acknowledge a `progress_update` (no reply required). When `«addressable-worker»` is ABSENT, this step is omitted. +0.6. **Drain captain intent + refresh liveness (eager — before dispatching).** When a bridge-inbox hook is registered, run its idle-tick work — BOTH the **heartbeat refresh** and the **drain** — at the TOP of every iteration, not only at the idle boundary (step 3). A *Driving* FO with continuous work never reaches idle, and an idle-only step 3 then has two failures: (a) a queued `pause` / redirect / `conn` change sits unread while you keep dispatching — the captain cannot steer mid-drive; and (b) the liveness heartbeat (`fo.$SLUG.json`) goes stale, so Bridge wrongly shows the FO **not attached** even though it is actively driving. Refreshing the heartbeat AND draining here fixes both — the FO stays live and steerable throughout a sustained drive. Acting on a drained `tell` takes effect THIS iteration: a pause or stop-direction halts further dispatch and waits for the captain (do not dispatch in step 2 this iteration); a redirect adjusts course before the next dispatch; a `conn`/`decision` is applied as in the mod. Per-member in fleet mode (each member refreshes its own heartbeat and drains its own cursor). Cheap and idempotent (the heartbeat is observe-only; the cursor only advances on a new line); when no bridge-inbox hook is registered, this step is omitted. 1. **Check mod-blocked entities** — Run `status --where "mod-block !=" --json --fields id,slug,mod-block`. For each entity in `entities`, re-read the blocking mod and resume its pending action (e.g. re-present the PR summary); do not dispatch new work for it. 2. **Run `status --next --json --fields id,slug`** — Dispatch any newly ready entity in `dispatchable` (each row carries the fixed `id,slug,current,next,worktree` plus named frontmatter keys; `--fields` is additive over those five, the computed dispatch columns are not projectable). 3. **If nothing is dispatchable** — Fire `idle` hooks, re-run the `«roster-reconcile»` step-0 sweep when PRESENT on the host, then re-run `status --next`. Dispatch anything newly unblocked; otherwise end the iteration. From bcbdba94eb1edc03c72d8fff6f486bf34dbf65b7 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Fri, 26 Jun 2026 16:16:24 +0800 Subject: [PATCH 17/17] fix(bridge-events): derive workflow from path under docs/spacedock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under split-root state (recce-cloud-infra#1484) an ensign's entity now lives at docs/spacedock//.spacedock-state/.md, so deriving the workflow from the entity file's parent dir recorded ".spacedock-state" as the workflow — breaking Bridge's workflow-scoped running-badge join for every split-root ensign. Derive the workflow from the path SEGMENT after docs/spacedock/ instead of the parent dir, handling absolute, repo-relative, and nested (.spacedock-state) paths uniformly. _archive entities (flat or in the state checkout) still write no marker. Slug comes from index.md's folder or the file basename. Adds session-marker test cases for a split-root entity path (workflow = the dir above .spacedock-state, not ".spacedock-state") and a split-root _archive skip. Signed-off-by: Jared Scott --- scripts/spacedock-bridge-events.sh | 17 ++++++++---- .../integration/bridge_session_link_test.go | 27 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh index bb7f6daba..e3bf83b23 100755 --- a/scripts/spacedock-bridge-events.sh +++ b/scripts/spacedock-bridge-events.sh @@ -66,12 +66,19 @@ if [ -n "$m_sid" ] && [ "$m_type" = "spacedock:ensign" ] && [ "$m_evt" = "PostTo 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}) - if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: //index.md - slug="$(basename "$(dirname "$fp")")"; wf="$(basename "$(dirname "$(dirname "$fp")")")" - else # flat entity: /.md - slug="$(basename "$fp" .md)"; wf="$(basename "$(dirname "$fp")")" + # 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 "$wf" in ""|_*|*[!A-Za-z0-9._-]*) wf="" ;; esac # skip _archive/_mods, unsafe + 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 && diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go index 658f7c885..c3529f0e1 100644 --- a/skills/integration/bridge_session_link_test.go +++ b/skills/integration/bridge_session_link_test.go @@ -85,10 +85,35 @@ func TestEventHookWritesSessionMarker(t *testing.T) { } } - // 4. _archive entity Reads are skipped (workflow dir starting with _ is rejected). + // 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 { 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"). + 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) + } + + // 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 { + t.Errorf("split-root _archive Read should not produce a session marker") + } }