diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3744e6225..50f5e63b8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -16,6 +16,7 @@ "automation" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "requires-contract": ">=3,<4", "interface": { "displayName": "Spacedock", diff --git a/.gitignore b/.gitignore index 31a3fbcd0..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/ @@ -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/ 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. diff --git a/docs/dev/_mods/bridge-inbox.md b/docs/dev/_mods/bridge-inbox.md new file mode 100644 index 000000000..85f14f337 --- /dev/null +++ b/docs/dev/_mods/bridge-inbox.md @@ -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 /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 "{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" | "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. + +**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} =`, 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`. 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/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/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/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/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/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/scripts/spacedock-bridge-events.sh b/scripts/spacedock-bridge-events.sh new file mode 100755 index 000000000..e3bf83b23 --- /dev/null +++ b/scripts/spacedock-bridge-events.sh @@ -0,0 +1,110 @@ +#!/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. +events="$dir/events.jsonl" +printf '%s' "$payload" | jq -c \ + --arg ts "$ts" \ + '{ + ts: $ts, + event: (.hook_event_name // "unknown"), + session_id: (.session_id // ""), + agent_id: (.agent_id // ""), + agent_type: (.agent_type // ""), + detail: { + tool: (.tool_name // ""), + source: (.source // "") + } + }' >> "$events" 2>/dev/null || exit 0 + +# ── Session→entity marker (DETERMINISTIC running-badge source) ─────────────────────── +# Bridge maps a live working session to its ship via «cwd»/_bridge/sessions/.json. +# Derive it HERE — from the hook, which fires on every tool call — instead of relying on the +# ensign to run a first-action shell (it skips that ~3/4 of the time). On an ENSIGN's first +# Read of its entity file (.../docs/spacedock//.md, flat or /index.md), +# record {session_id, entity, workflow}: the path carries BOTH the workflow (so Bridge's join +# is collision-free across workflows that reuse a ticket id) and the full slug. First-write- +# wins per session, so the ensign's OWN entity — read before any duplicate-check sibling reads +# — is what gets recorded. Observe-only and best-effort: every step degrades to a no-op. +m_sid="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)" +m_type="$(printf '%s' "$payload" | jq -r '.agent_type // empty' 2>/dev/null)" +m_evt="$(printf '%s' "$payload" | jq -r '.hook_event_name // empty' 2>/dev/null)" +m_tool="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null)" +case "$m_sid" in *[!A-Za-z0-9._-]*) m_sid="" ;; esac # unsafe id → skip (never escape _bridge/) +if [ -n "$m_sid" ] && [ "$m_type" = "spacedock:ensign" ] && [ "$m_evt" = "PostToolUse" ] && [ "$m_tool" = "Read" ]; then + marker="$dir/sessions/$m_sid.json" + if [ ! -f "$marker" ]; then # first-write-wins → the ensign's own entity (read first) + fp="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" + case "$fp" in + */docs/spacedock/*/*.md|docs/spacedock/*/*.md) # absolute OR repo-relative (the FO passes a relative {entity_file_path}) + # Workflow = the path segment right AFTER docs/spacedock/ — robust to a + # split-root entity nested under /.spacedock-state/.md (taking the + # entity's parent dir would wrongly yield ".spacedock-state"). + rest="${fp##*/docs/spacedock/}" # absolute / nested: strip through the last /docs/spacedock/ + rest="${rest#docs/spacedock/}" # repo-relative: strip the leading docs/spacedock/ + wf="${rest%%/*}" # first segment after docs/spacedock/ = the workflow dir + if [ "$(basename "$fp")" = "index.md" ]; then # folder entity: ...//index.md + slug="$(basename "$(dirname "$fp")")" + else # flat entity: .../.md + slug="$(basename "$fp" .md)" + fi + case "$fp" in */_archive/*) wf="" ;; esac # never mark an archived (terminal) entity + case "$wf" in ""|_*|*[!A-Za-z0-9._-]*) wf="" ;; esac # skip _-dirs, unsafe + case "$slug" in ""|README|*[!A-Za-z0-9._-]*) slug="" ;; esac + if [ -n "$slug" ] && [ -n "$wf" ]; then + mkdir -p "$dir/sessions" 2>/dev/null && + printf '{"session_id":"%s","entity":"%s","workflow":"%s"}\n' "$m_sid" "$slug" "$wf" > "$marker" 2>/dev/null || : + fi + ;; + esac + fi +fi + +# Best-effort size cap: a liveness side-channel must not grow without bound (PostToolUse +# fires on every tool call). When the log passes max_lines, keep only the most recent +# keep_lines. Lock-free and best-effort by design — this is liveness, not a ledger, so +# losing a few lines to a concurrent append during the rare trim is acceptable, and every +# step degrades to a no-op ($$ keeps the temp unique across concurrent async hooks; any +# failure leaves the existing log untouched). Never block or break the FO. +max_lines=2000 +keep_lines=1000 +n="$(wc -l < "$events" 2>/dev/null || echo 0)" +if [ "${n:-0}" -gt "$max_lines" ] 2>/dev/null; then + tmp="$events.tmp.$$" + if tail -n "$keep_lines" "$events" > "$tmp" 2>/dev/null; then + mv -f "$tmp" "$events" 2>/dev/null || rm -f "$tmp" 2>/dev/null + else + rm -f "$tmp" 2>/dev/null + fi +fi + +exit 0 diff --git a/skills/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 9603faeb3..0c08c1f8e 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -6,6 +6,8 @@ How the shared ensign core executes on Claude Code. The ensign is dispatched by the first officer via the Agent tool. The dispatch prompt is authoritative for all assignment fields: entity, stage, stage definition, workflow location, and checklist. +Bridge's "running" badge needs no action from you: the Spacedock 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 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/ensign/references/ensign-shared-core.md b/skills/ensign/references/ensign-shared-core.md index 595918ba5..fc751666c 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 diff --git a/skills/first-officer/SKILL.md b/skills/first-officer/SKILL.md index 47a60570f..3bfc0d292 100644 --- a/skills/first-officer/SKILL.md +++ b/skills/first-officer/SKILL.md @@ -13,6 +13,8 @@ If this skill is invoked directly in a non-interactive run and the prompt names - before the final response, explicitly shut down any worker that is no longer needed for later routing or gate handling - once the bounded stop condition is satisfied, send one concise final response and exit immediately +If this skill is invoked with a quotable fleet directive ("drive the fleet" / "run all workflows" / "fleet mode") and discovery finds multiple commissioned workflows, enter fleet mode (operating contract `references/fo-fleet.md`): adopt 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 a2317d241..3c7a64671 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -11,10 +11,12 @@ Shared first-officer semantics — the boot-resident core. The deferred status, - **Binary present but wrong version** — the version's major.minor is below the required minor (binary too old) or above it (these skills are too old — update the plugin), or the version token carries no major.minor at all (`dev` — an integer-era source build; rebuild it). 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. **Boot — local identify.** `${SPACEDOCK_BIN:-spacedock} status --boot --identify --json` runs the whole pre-greet identify in ONE call — project root, workflow discovery, the stage taxonomy, and the local boot sections — folded into one JSON record. Consume it as JSON (every value a string); the human table is NOT for the FO's own reasoning. Every part is a **local read** (filesystem, git-read, entity frontmatter, the host team-state probe): **no `gh`, no `state ready` pull, no sweep, no mod-file open, no team creation, no mutation** — a greet-only session writes nothing. The record self-describes its sections; read its keys, do not restate them here. PR_STATE is a **local `pr:` mirror, labeled not-gh-checked**; the live PR state fills in at «engage». Semantics are uniform across the discovered set: +2. **Boot — local identify.** `${SPACEDOCK_BIN:-spacedock} status --boot --identify --json` runs the whole pre-greet identify in ONE call — project root, workflow discovery, the stage taxonomy, and the local boot sections — folded into one JSON record. Consume it as JSON (every value a string); the human table is NOT for the FO's own reasoning. Every part is a **local read** (filesystem, git-read, entity frontmatter, the host team-state probe): **no `gh`, no `state ready` pull, no sweep, no team creation, no mutation** — a greet-only session writes nothing. Startup hooks run deferred EXCEPT the bridge-inbox heartbeat, which step 2b runs before-greet. The record self-describes its sections; read its keys, do not restate them here. PR_STATE is a **local `pr:` mirror, labeled not-gh-checked**; the live PR state fills in at «engage». Semantics are uniform across the discovered set: - **zero discovery:** no managed workflow — report and STOP; do NOT broad-search the filesystem to hunt one (no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root; code-gated by the `detectBroadSearchAtBoot` boot detector). - - **one or many:** a LIST of the discovered workflow(s); one is a list of length 1 with no eager convergence. NAME them in the greet; the captain converges and acts on one via «engage»(workflow). Single-entity mode fails with an ambiguity error when many. + - **one or many:** a LIST of the discovered workflow(s); one is a list of length 1 with no eager convergence. NAME them in the greet; the captain converges and acts on one via «engage»(workflow) — UNLESS a quotable fleet directive adopts the named/ALL discovered workflows as a member set (deferred `references/fo-fleet.md`). Single-entity mode fails with an ambiguity error when many. + - **launch branch:** record the working tree's branch (`git rev-parse --abbrev-ref HEAD`) as your **launch branch**. A concurrent `git checkout` in this shared tree moves HEAD and deletes tracked files under you. So **before each dispatch — and before any state-changing git op — re-check the branch; if it differs, HALT**: `branch changed underneath me (); stopping`. Pin on the branch NAME (a same-branch fast-forward is not a halt); a dedicated worktree avoids this. The record's counts and PR fields are a possibly-stale local view, labeled as such, until the first «engage». +2b. **Bridge liveness heartbeat (before-greet).** When the boot record's MODS map registers a `startup` hook for `bridge-inbox`, read that mod (`{workflow_dir}/_mods/{mod_name}.md`) and run its startup hook BEFORE the greet — write the per-`$SLUG` `_bridge/fo.$SLUG.json` heartbeat and run the initial inbox drain — so a live FO shows attached in Bridge the moment it boots. This is boot liveness, NOT event-loop work: a greet-and-stop boot (step 3 interactive) STILL runs it. The write is observe-only and never blocks the boot; an absent `_bridge/inbox.jsonl` no-ops the drain. In fleet mode it runs once per member. Skip when unregistered — the common boot reads no mod file. 3. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary from the boot record. - **Interactive:** present the summary — the managed workflow(s) with their dispatchable / ready-gate counts — and hint `Use engage ` to act; then STOP for input. Do NOT auto-dispatch, and do NOT render a `present-gate` review at the greet: NAME any ready `gate: true` gate in the summary, but assemble its review only when «engage» reaches it — the expensive deferrals, gate assembly included, stay past the greet, reached on the captain's first «engage». - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop (converging each workflow at its first «engage») to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. @@ -80,6 +82,7 @@ If the stage is gated, `«gate.assemble-verdict»(slug, stage)`, then route on t ## «gate.assemble-verdict»(slug, stage): assemble the gate review and render the verdict +- **effect — drain before presenting (honor a queued Bridge decision):** BEFORE assembling, fire THIS entity's `idle` hooks once (keyed by its `$SLUG`) so a `decision` inbox record queued from Bridge for this gate is drained and applied now; re-read status. Bridge cannot wake a parked FO (it queues, the FO pulls), so a decision since the last idle tick would otherwise sit unprocessed while you redundantly present. If the drain advanced the entity past `{stage}`, do NOT present — report what you applied and return (the **block** below still forbids inventing a verdict). Otherwise it is a no-op; present normally. - **effect — extract (deterministic):** roll up the structured inputs via the shipped modes — `status --read --checklist` and `status --read --ac-scan`. These feed the verdict; they do not make it. - **effect — decide (judgment):** the verdict (approve/reject, is-this-AC-satisfied, is-this-direction-sound) is irreducible judgment; the FO renders its own `Recommend` line. Present via `Skill(skill="spacedock:present-gate")` and its template + assembly rules. - **done-when:** the gate review is presented and the FO is waiting on the captain's decision, the worker kept alive. diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index d57720eec..f4d03dcf6 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 (the deferred fleet reference `references/fo-fleet.md`) 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. @@ -147,6 +149,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. @@ -155,3 +158,5 @@ These are FO-internal scheduling reads — consume them as `--json` (compact, by - → **prose** (deterministic mechanism, binary pending — NOT judgment-owned), becomes `` `spacedock dispatch next-action` `` — no driver binary backs it yet (descoped to roadmap 0222); the FO hand-follows the deterministic skeleton above and does not probe for the unshipped command (runtime-support.md's `→ prose` trichotomy). Repeat 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 (the deferred fleet reference `references/fo-fleet.md`), wrap `«dispatch.next-action»()` in an outer round-robin over members: run one iteration scoped to each member's `{workflow_dir}` (every `status` / `dispatch` / `--set` call already carries `--workflow-dir`, so no command changes), dispatching whichever members have ready work. The 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. diff --git a/skills/first-officer/references/fo-fleet.md b/skills/first-officer/references/fo-fleet.md new file mode 100644 index 000000000..dfc259b0b --- /dev/null +++ b/skills/first-officer/references/fo-fleet.md @@ -0,0 +1,12 @@ +# Fleet Mode + +Deferred operating contract for driving MULTIPLE commissioned workflows from ONE session — the dual of Single-Entity Scope (which narrows to one entity; this widens to every workflow). Loaded only on a quotable fleet directive; absent it, discovery resolves one workflow or presents the list (shared-core Startup step 3), unchanged. + +- **Trigger.** A fleet directive in the launch prompt you can QUOTE ("drive the fleet" / "run all workflows" / "fleet mode") — the same quotable-grant discipline the conn uses (`## Completion and Gates`). A bare "run the workflows" without a quotable fleet phrase is NOT the trigger; resolve normally and, on ambiguity, present the list. +- **Member set.** On the trigger, the member set is: the workflows the directive NAMES when it names any (each resolved against `${SPACEDOCK_BIN:-spacedock} status --discover` by slug or path — a named workflow that does not resolve is reported and skipped, never broad-searched for); otherwise EVERY discovered path. So "fleet mode: drive A, B, C" adopts exactly {A, B, C}, while a bare "drive the fleet" adopts all discovered. One discovered path → fleet mode is a no-op (identical to single-workflow). Zero discovered → report-and-stop (the Startup step 2 zero-discovery block holds; never broad-search to widen the set). The interactive greet lists the resolved member set, so the captain confirms it before any dispatch. +- **Per-member identify + converge.** Startup step 2 (`«state.boot»` local identify) folds every discovered member into its one boot record, so the greet names all members from a single call. Convergence is per member and deferred to «engage»: engaging a member runs its own `state ready` (split-root pull/resume; the rebase-conflict halt is per-member — a halt or block in ONE member does NOT stop the others; report it and proceed with the healthy members) then `state sweep` (its merged-PR advancement) before driving it. Members may carry independent split-root state checkouts. +- **Per-member boot liveness.** The before-greet Bridge liveness heartbeat (Startup step 2b) runs once per member, keyed by that member's `$SLUG`: each member's bridge-inbox startup hook writes its own `_bridge/fo..json` heartbeat + initial drain, so every member shows live in Bridge's roster from boot — even in a greet-and-stop launch, not only after the first dispatch. +- **Greet.** Interactive: present a per-member summary and each member's ready gates, then STOP (Startup step 3). Headless: drive every member's dispatchables per step 3; "given the conn" resolves gates across the members the grant names. +- **Event loop.** The deferred dispatch module owns the multi-member loop (`references/fo-dispatch-core.md` `## Event Loop`): the FO round-robins the per-entity event-loop iteration across members, each iteration scoped to that member's `{workflow_dir}` through the existing `--workflow-dir` commands — no command changes. +- **Captain intent routing.** When a bridge-inbox drain is registered, it runs **per member** (keyed by each member's `$SLUG`), so a fleet FO owns one cursor **per member** (`_bridge/.inbox-cursor.`) and writes one heartbeat **per member** (`_bridge/fo..json`) — every member shows live in Bridge's roster, not just one. A drained record's `target` selects scope: `all` (or absent) is drained by EVERY member's cursor (fleet-wide — so a fleet-wide `tell` is acknowledged once per member, a fleet-wide `conn` grant adopts the conn for each member's entities); a `{slug}` is acted on only by that member and skipped-but-cursor-advanced by the others. Because the one fleet FO advances all the per-member cursors itself off one shared `inbox.jsonl`, there is no cross-session addressing race. +- **Write scope and gates are unchanged.** Each member's entities, gates, `## Stage Report` review, and FO write scope are exactly as in single-workflow mode, scoped by the member's `{workflow_dir}`. diff --git a/skills/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) + } +} diff --git a/skills/integration/bridge_session_link_test.go b/skills/integration/bridge_session_link_test.go new file mode 100644 index 000000000..c3529f0e1 --- /dev/null +++ b/skills/integration/bridge_session_link_test.go @@ -0,0 +1,119 @@ +// 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 ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// runEventHook feeds one hook payload (the JSON Claude Code pipes to the hook) to +// scripts/spacedock-bridge-events.sh and returns once it exits. cwd anchors the +// _bridge/ dir the hook writes to. +func runEventHook(t *testing.T, payload string) { + t.Helper() + hook := filepath.Join("..", "..", "scripts", "spacedock-bridge-events.sh") + if _, err := os.Stat(hook); err != nil { + t.Fatalf("hook script not found at %s: %v", hook, err) + } + cmd := exec.Command("bash", hook) + cmd.Stdin = strings.NewReader(payload) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("event hook failed: %v\n%s", err, out) + } +} + +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 + `"}}` +} + +// 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") + + // 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("marker not written: %v", err) + } + got := string(data) + for _, want := range []string{`"session_id":"ses-1"`, `"entity":"drc-3467"`, `"workflow":"linear-drc-review"`} { + if !strings.Contains(got, want) { + t.Errorf("marker missing %s\ngot: %s", want, got) + } + } + + // 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") + } + + // 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. + 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") + } +} 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) + } + } +} 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) + } +}