Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d93b2aa
Bridge seam: bridge-inbox drain mod + normalized FO event hooks
gcko Jun 23, 2026
fbed026
docs(contributing): add Develop / Build from Source / Run your Branch…
gcko Jun 23, 2026
da70ac2
Bridge seam: target-filtered drain + per-workflow cursor + FO heartbeat
gcko Jun 23, 2026
b1db14a
Bridge seam: address review notes — path-alignment, slug scope, drain…
gcko Jun 23, 2026
b2c3547
First officer: opt-in fleet mode — one session, many commissions (#437)
gcko Jun 24, 2026
f4d6764
Fleet mode: per-member idle/startup mod hooks, not once-per-fleet
gcko Jun 24, 2026
ca3f5bc
Fleet mode: let the directive name a subset of workflows to drive
gcko Jun 24, 2026
ac4e3a7
First officer: write the bridge-inbox liveness heartbeat before-greet
gcko Jun 25, 2026
f43c090
Ensign: write the bridge session→entity link at boot
gcko Jun 25, 2026
e210aee
Bridge inbox: drain a captain "decision" record (self-described gates)
gcko Jun 25, 2026
d0b9559
First officer: drain the inbox before presenting a gate
gcko Jun 25, 2026
9eaeaf0
Bridge events: write the session→entity marker from the hook, not ens…
gcko Jun 26, 2026
3f16957
Bridge events: match a repo-relative entity path in the session-marke…
gcko Jun 26, 2026
a1f1fdc
Ensign: never commit at the bare repo root (RC1)
gcko Jun 26, 2026
417dfa2
First officer: pin the launch branch, halt on a concurrent switch (RC2b)
gcko Jun 26, 2026
99bd10b
First officer: eager captain-intent drain + liveness refresh, and a f…
gcko Jun 26, 2026
bcbdba9
fix(bridge-events): derive workflow from path under docs/spacedock
gcko Jun 26, 2026
b1064f9
Merge main into bridge-seam-inbox-events
gcko Jun 30, 2026
39f4aa3
Merge main into bridge-seam-inbox-events (PR 435)
gcko Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"automation"
],
"skills": "./skills/",
"hooks": "./hooks/hooks.json",
"requires-contract": ">=3,<4",
"interface": {
"displayName": "Spacedock",
Expand Down
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -22,4 +26,8 @@ dist/
# profile; .claude holds Claude Code's agent worktrees (full repo checkouts).
.safehouse
.claude/
/spacedock

# 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/
76 changes: 76 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 103 additions & 0 deletions docs/dev/_mods/bridge-inbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
name: bridge-inbox
description: Drain captain intent queued by the Bridge command-center UI (_bridge/inbox.jsonl), routing per-workflow, and write the FO liveness heartbeat each tick
version: 0.2.0
fo-realm: "FO realm — the FO maintains this file directly; it is FO process (the seam to the Bridge UI), not product built under the dev workflow."
---

# Bridge Inbox

[Bridge](https://github.com/spacedock-dev/bridge) is a read-only command-center UI over this fleet. It cannot push into a running FO session (a Claude Code session has no inbound API), so it writes captain intent to a durable, **append-only** inbox at `_bridge/inbox.jsonl` (relative to the FO's working directory — the repo root where `spacedock claude` was launched). This mod drains that inbox on the FO's own loop ticks, acting only on intent **addressed to this workflow**, and writes a per-workflow liveness heartbeat Bridge reads.

**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 <repo>/docs/spacedock` with no `--repo-root`), Bridge would write to `<repo>/docs/spacedock/_bridge/` while the FO reads `<repo>/_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 "{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": "<rfc3339>", "kind": "tell" | "conn" | "decision", "text": "<string>", "granted": <bool, conn only>, "target": "<workflow-slug>" | "all", "entity": "<slug, decision only>", "field": "<frontmatter field, decision only>", "value": "<value, decision only>"}
```

`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.

**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. 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

Derive and validate `$SLUG` (above) in this tick's shell, then refresh the heartbeat and drain — all in the same shell.

### 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. Adopt the old shared cursor on first run (one-time migration: before this version the mod used a single shared `_bridge/.inbox-cursor`; seed from it so you do not re-drain — and re-apply — intent already processed, e.g. an old `conn` grant), then read this workflow's cursor:
```
if [ ! -f _bridge/.inbox-cursor.$SLUG ] && [ -f _bridge/.inbox-cursor ]; then
cp _bridge/.inbox-cursor _bridge/.inbox-cursor.$SLUG
fi
CURSOR=$(cat _bridge/.inbox-cursor.$SLUG 2>/dev/null || echo 0)
```
3. Snapshot the current line count and read exactly the new records (bounding the read so a concurrent Bridge append can't make the cursor skip a line):
```
NEW=$(wc -l < _bridge/inbox.jsonl | tr -d ' ')
sed -n "$((CURSOR + 1)),${NEW}p" _bridge/inbox.jsonl
```
If `NEW` is not greater than `CURSOR`, there is nothing new — skip (idempotent).
4. For each new record, in order, parse `kind` / `text` / `granted` / `target` (and `entity` / `field` / `value` on a `decision` record). **Check the target first:** if `target` is present and is neither `"$SLUG"` nor `"all"`, this record is for another workflow's FO — skip it (it is not yours to act on); it still counts as processed (the cursor advances past it in step 5). Otherwise (target is `"$SLUG"`, `"all"`, or absent) act:
- **`kind == "tell"`** — the captain sent you a message. Treat `text` as a directive or clarification for this tick: act on it as you would a captain instruction (commission or clear work, answer the implied question, adjust course), and acknowledge it to the captain.
- **`kind == "conn"`** — a conn-handover change. `granted: true` → adopt the conn within the stated goal `text`: drive the entities the conn covers to done without stopping at their gates, per the conn rules in `first-officer-shared-core` (escalations remain non-delegable and still surface to the captain). `granted: false` → take the conn back: stop at every gate for the captain's call again.
- **`kind == "decision"`** — the captain resolved a self-described decision gate from Bridge (Bridge cannot perform the gate's external side-effects — a Linear write, a label — so it queues the decision here instead of advancing the entity). Resolve `entity` (its slug) in THIS workflow, then treat `field`/`value` as the captain's gate verdict: set the field with `${SPACEDOCK_BIN:-spacedock} status --set --workflow-dir {dir} <entity> <field>=<value>`, then drive that entity through its current gate exactly as if the captain had decided it at the gate — your normal gate-resolution runs the workflow's own stage actions (including any external writes the stage prose defines) and advances it. Idempotent: if the entity is already resolved/terminal with that value, it is a no-op. If `entity` does not resolve in this workflow (it belongs to another member's slug), skip it like a mismatched target. Acknowledge to the captain which entity you resolved and how.
5. Advance this workflow's cursor to the snapshot you read: `echo "$NEW" > _bridge/.inbox-cursor.$SLUG`.
6. Report to the captain: how many intents you drained (for this workflow) and what you did with each.

If a record is malformed (not valid JSON, or an unknown `kind`), skip it but still advance the cursor past it, and note the skip to the captain — never block the loop on a bad record.

**Delivery is at-least-once, not exactly-once.** The cursor advances only *after* you act (step 5 follows step 4), so nothing is lost if the loop dies mid-drain. The trade-off: a crash between acting and writing the cursor re-delivers that batch on the next tick. Treat `conn`/`tell` handling as idempotent — re-adopting a `conn` you already hold (or re-relinquishing one you already gave back) is a no-op, and a repeated `tell` is at worst a duplicate acknowledgement. (This is distinct from the first-run migration seed above, which guards against re-applying the *entire* pre-versioning history.)

## 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`.
22 changes: 22 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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 } ] }
]
}
}
54 changes: 54 additions & 0 deletions internal/contractlint/bridge_heartbeat_boot_test.go
Original file line number Diff line number Diff line change
@@ -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 `<root>/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/<name>.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.")
}
}
Loading
Loading