From c5b85f9b7a2643dddfbee9914120cafd1e27bddd Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 14:18:31 -0700 Subject: [PATCH 01/11] feat: add pi runtime dispatch baseline --- internal/dispatch/build.go | 31 +++++++-- internal/dispatch/build_pi_host_test.go | 69 +++++++++++++++++++ internal/dispatch/dispatch.go | 2 +- skills/ensign/SKILL.md | 1 + skills/ensign/references/pi-ensign-runtime.md | 30 ++++++++ skills/first-officer/SKILL.md | 1 + .../references/pi-first-officer-runtime.md | 40 +++++++++++ skills/integration/skill_surface_test.go | 24 +++++++ 8 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 internal/dispatch/build_pi_host_test.go create mode 100644 skills/ensign/references/pi-ensign-runtime.md create mode 100644 skills/first-officer/references/pi-first-officer-runtime.md diff --git a/internal/dispatch/build.go b/internal/dispatch/build.go index 38d47b7a8..21679cf75 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -182,10 +182,10 @@ func rawJSON(v any) json.RawMessage { func resolveBuildHost(flagHost, jsonHost string, getenv func(string) string) (string, error) { if flagHost != "" && !validBuildHost(flagHost) { - return "", fmt.Errorf("unsupported host %q (want claude or codex)", flagHost) + return "", fmt.Errorf("unsupported host %q (want claude, codex, or pi)", flagHost) } if jsonHost != "" && !validBuildHost(jsonHost) { - return "", fmt.Errorf("unsupported host %q (want claude or codex)", jsonHost) + return "", fmt.Errorf("unsupported host %q (want claude, codex, or pi)", jsonHost) } if flagHost != "" && jsonHost != "" && flagHost != jsonHost { return "", fmt.Errorf("conflicting explicit host sources: --host=%q, JSON host=%q", flagHost, jsonHost) @@ -200,7 +200,7 @@ func resolveBuildHost(flagHost, jsonHost string, getenv func(string) string) (st codex := getenv("CODEX_THREAD_ID") != "" claude := getenv("CLAUDECODE") != "" if codex && claude { - return "", fmt.Errorf("ambiguous runtime host sources: CODEX_THREAD_ID and CLAUDECODE are both set; pass --host claude or --host codex") + return "", fmt.Errorf("ambiguous runtime host sources: CODEX_THREAD_ID and CLAUDECODE are both set; pass --host claude, codex, or pi") } if codex { return "codex", nil @@ -212,7 +212,7 @@ func resolveBuildHost(flagHost, jsonHost string, getenv func(string) string) (st } func validBuildHost(host string) bool { - return host == "claude" || host == "codex" + return host == "claude" || host == "codex" || host == "pi" } func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields map[string]json.RawMessage, stdout, stderr io.Writer) int { @@ -588,6 +588,16 @@ func firstActionBlock(host string) string { "Do not try to invoke a Claude skill wrapper; Codex dispatch uses this file " + "pointer as the contract surface.\n" } + if host == "pi" { + return "## First action\n" + + "\n" + + "Read this dispatch file directly and treat its content as your operating contract and assignment.\n" + + "\n" + + "This file contains the shared ensign discipline entry points (stage-report format, polling, " + + "worktree ownership, and completion protocol) plus the stage-specific assignment. " + + "Pi dispatch is delivered through a Pi-native substrate such as pi-subagents; the Pi subagent completion result " + + "is the completion signal observed by the first officer. Do not emit Claude team-tool calls.\n" + } return "## First action\n" + "\n" + "Before anything else, invoke your operating contract:\n" + @@ -612,6 +622,15 @@ func completionSignalBlock(host, entityTitle, stage, entityFileRef string) strin "Do not emit a Claude `SendMessage` call; the Codex mailbox notification is the completion signal.", entityTitle, stage, entityFileRef) } + if host == "pi" { + return fmt.Sprintf( + "\n\n### Completion Signal\n\n"+ + "When you finish (after all commits and stage report writes are done), return one concise final message in this Pi worker turn:\n\n"+ + " Done: %s completed %s. Report written to %s.\n\n"+ + "The first officer observes completion through the Pi subagent completion result. "+ + "Do not emit Claude message-tool calls; Pi dispatch completion is the worker's final result.", + entityTitle, stage, entityFileRef) + } return fmt.Sprintf( "\n\n### Completion Signal\n\n"+ "When you finish (after all commits and stage report writes are done), "+ @@ -627,7 +646,7 @@ func completionSignalBlock(host, entityTitle, stage, entityFileRef string) strin } func dispatchPointerPrompt(host, dispatchFilePath string) string { - if host == "codex" { + if host == "codex" || host == "pi" { return fmt.Sprintf("Read %s and treat its content as your assignment.", dispatchFilePath) } return fmt.Sprintf( @@ -722,7 +741,7 @@ func emitBuildSchema(stdout io.Writer) int { }, "host": map[string]any{ "type": "string", - "enum": []string{"claude", "codex"}, + "enum": []string{"claude", "codex", "pi"}, }, }, } diff --git a/internal/dispatch/build_pi_host_test.go b/internal/dispatch/build_pi_host_test.go new file mode 100644 index 000000000..1731126af --- /dev/null +++ b/internal/dispatch/build_pi_host_test.go @@ -0,0 +1,69 @@ +// ABOUTME: Pi dispatch-build host shape — the outer prompt and dispatch body avoid +// ABOUTME: Claude team tool signatures and target pi-subagents style completion. +package dispatch + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildPiHostPromptShape(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) + worktreeRel := ".worktrees/spacedock-ensign-thing" + if err := os.MkdirAll(filepath.Join(root, worktreeRel), 0o755); err != nil { + t.Fatal(err) + } + entityPath := filepath.Join(root, "thing.md") + writeFile(t, entityPath, entityFM("Thing", "implementation", worktreeRel)) + gitInit(t, root) + + stdin := mergeStdin(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": root, + "stage": "implementation", + "checklist": []string{"- a", "- b"}, + "team_name": "fixture-pi-team", + "bare_mode": false, + "host": "pi", + }, nil) + + native := runNative(stdin, "build", "--workflow-dir", root) + if native.exit != 0 { + t.Fatalf("build exit=%d stderr=%q", native.exit, native.stderr) + } + var out struct { + Prompt string `json:"prompt"` + } + if err := json.Unmarshal([]byte(native.stdout), &out); err != nil { + t.Fatalf("stdout is not build JSON: %v\n%s", err, native.stdout) + } + for _, banned := range []string{"Skill(skill=", "Agent(", "SendMessage", "TeamCreate", "TeamDelete"} { + if strings.Contains(out.Prompt, banned) { + t.Fatalf("pi prompt must not depend on Claude syntax %q: %q", banned, out.Prompt) + } + } + if !strings.Contains(out.Prompt, "Read ") || !strings.Contains(out.Prompt, "treat its content as your assignment") { + t.Fatalf("pi prompt should be the read-dispatch-file form: %q", out.Prompt) + } + + body := readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) + for _, banned := range []string{"Skill(skill=\"spacedock:ensign\")", "SendMessage(to=\"team-lead\"", "TeamCreate", "TeamDelete", "Agent("} { + if strings.Contains(body, banned) { + t.Fatalf("pi dispatch body must omit Claude syntax %q:\n%s", banned, body) + } + } + for _, want := range []string{ + "Read this dispatch file directly", + "Pi subagent completion result", + "Do not emit Claude team-tool calls", + } { + if !strings.Contains(body, want) { + t.Fatalf("pi dispatch body missing %q:\n%s", want, body) + } + } +} diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index 54d590c24..5c26cd68a 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -251,7 +251,7 @@ func printUsage(w io.Writer) { Usage: spacedock dispatch build --workflow-dir DIR (stdin JSON -> stdout JSON) - spacedock dispatch build --workflow-dir DIR --entity-path FILE --stage STAGE --checklist-file FILE [--host claude|codex] + spacedock dispatch build --workflow-dir DIR --entity-path FILE --stage STAGE --checklist-file FILE [--host claude|codex|pi] spacedock dispatch build --print-schema spacedock dispatch build --validate-only FILE spacedock dispatch show-stage-def --workflow-dir DIR --stage STAGE diff --git a/skills/ensign/SKILL.md b/skills/ensign/SKILL.md index dbe5ed0b2..939e95cee 100644 --- a/skills/ensign/SKILL.md +++ b/skills/ensign/SKILL.md @@ -12,5 +12,6 @@ description: Execute workflow stage work as a dispatched worker. Load the runtime adapter for your platform: - Claude Code (`CLAUDECODE` env var is set): read `references/claude-ensign-runtime.md` - Codex (`CODEX_THREAD_ID` env var is set): read `references/codex-ensign-runtime.md` +- Pi (`PI_CODING_AGENT_DIR` is set, or this session is running under Pi without the Claude/Codex markers above): read `references/pi-ensign-runtime.md` Then read your assignment and begin work. diff --git a/skills/ensign/references/pi-ensign-runtime.md b/skills/ensign/references/pi-ensign-runtime.md new file mode 100644 index 000000000..7217c80b4 --- /dev/null +++ b/skills/ensign/references/pi-ensign-runtime.md @@ -0,0 +1,30 @@ +# Pi Ensign Runtime + +This file defines how the shared ensign core executes on Pi. + +## Agent Surface + +A Pi ensign receives a bounded assignment from a Pi-native substrate such as `pi-subagents` or, through an adapter, `pi-agent-teams`. The assignment content is authoritative: entity path, workflow directory, target stage, stage definition fetch command, worktree path when present, and completion checklist. + +Do not assume Claude team tools exist in Pi. Completion is reported by the worker's final result in the Pi turn or by the active Pi adapter's task-completion notification. + +## Pi-Specific Rules + +- If the dispatch prompt is a read-dispatch-file pointer, read that file first and treat its content as the assignment. +- If no worktree path is provided, stay on the repo root/main-branch checkout named by the assignment. +- If a worktree path is provided, keep code reads, writes, tests, and code commits under that worktree. +- In split-root workflows, the entity body and stage report stay at the state-checkout entity path provided by the dispatch; do not invent a worktree copy of the entity. +- Do not modify YAML frontmatter in the entity file. +- Commit the entity body/stage report path-scoped in the state checkout when the assignment asks for a state commit. +- Treat each follow-up assignment as a fresh cycle; never assume a previous completion still satisfies the current assignment. + +## Completion + +When done, return one concise final result that names: + +- the entity and stage completed; +- the entity file containing the stage report; +- the commit or durable evidence produced; +- any residual risk or blocker. + +After sending that completion result, stop. Do not idle waiting for another message unless the active Pi substrate explicitly delivers one. diff --git a/skills/first-officer/SKILL.md b/skills/first-officer/SKILL.md index b6aee33d3..05048cdf2 100644 --- a/skills/first-officer/SKILL.md +++ b/skills/first-officer/SKILL.md @@ -22,5 +22,6 @@ If this skill is invoked directly in a non-interactive run and the prompt names Load the runtime adapter for your platform: - Claude Code (`CLAUDECODE` env var is set): read `references/claude-first-officer-runtime.md` - Codex (`CODEX_THREAD_ID` env var is set): read `references/codex-first-officer-runtime.md` +- Pi (`PI_CODING_AGENT_DIR` is set, or this session is running under Pi without the Claude/Codex markers above): read `references/pi-first-officer-runtime.md` Then begin the Startup procedure from the shared core. diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md new file mode 100644 index 000000000..d661cae0e --- /dev/null +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -0,0 +1,40 @@ +# Pi First Officer Runtime + +This file defines how the shared first-officer core executes on Pi. + +## Runtime Shape + +Pi is a first-class runtime target, but it does not expose Claude Code team-tool signatures. Do not call or ask workers to call Claude team tools. Pi dispatch uses a Pi-native substrate selected by the launch/test harness: + +- default: `pi-subagents`, where the parent first officer uses the `subagent(...)` tool to run a bounded ensign assignment and observes the returned result as completion evidence; +- optional: `pi-agent-teams`, where an adapter maps Spacedock lifecycle intents to the `teams` tool actions (`member_spawn` or `delegate`, `message_dm`, `member_shutdown`, `team_done`). + +The Spacedock contract talks in terms of dispatch, completion, follow-up, and shutdown. The Pi adapter owns how those lifecycle events map to the active substrate. + +## Dispatch + +Use `spacedock dispatch build` with `host: "pi"` in the input JSON. Forward the emitted dispatch file prompt to the Pi worker without rewriting it into Claude syntax. For the first slice, dispatch should normally run with `bare_mode: true`: the Pi subagent result is the completion signal, so no `team_name` is required. + +A Pi first officer may dispatch via `subagent(...)` when that tool is available. The task must include the emitted dispatch-file prompt or the dispatch file content, the workflow directory, the entity path, and the completion checklist. The child must load the Spacedock ensign skill and Pi ensign runtime adapter before working. + +## Awaiting Completion + +For `pi-subagents`, the completion signal is the subagent result returned to the parent. After the result arrives, read the entity file and verify the stage report exactly as the shared core requires. Do not advance state based only on a cheerful worker summary. + +For `pi-agent-teams`, completion is observed through the adapter's task/member notification and then verified against the entity file. The adapter should expose a clear completed/failed result to the first officer; the entity stage report remains the source of truth. + +## Follow-up and Reuse + +Fresh redispatch is the default safe behavior for the first Pi slice. If a Pi substrate exposes a resumable worker handle, record only the minimum metadata needed to prevent stale reuse mistakes: worker label, substrate, run/session handle, entity slug, stage, state, and completion epoch. A follow-up assignment must increment the epoch, and a previous completion must never satisfy the new epoch. + +## Shutdown + +For `pi-subagents`, a completed child invocation needs no mailbox shutdown. Mark the worker complete/closed in first-officer memory and continue. + +For `pi-agent-teams`, use the adapter's lifecycle mapping to request member shutdown or end the team run. Do not emulate Claude team deletion. + +## Live Harness Isolation + +Live Pi tests should run with an isolated Pi config directory and an isolated session directory. The harness may copy the operator's existing Pi auth file into the isolated config directory so OAuth/subscription credentials are reused without sharing global sessions, packages, or settings. This mirrors the Codex live runner pattern: isolate runtime state, reuse credentials only. + +The durable proof for Pi support is not transcript phrasing. A valid live proof dispatches a Pi ensign against a temp split-root workflow and verifies exit code, state checkout file changes, git log, and stage report content. diff --git a/skills/integration/skill_surface_test.go b/skills/integration/skill_surface_test.go index 3c874b2ec..bcdb813e1 100644 --- a/skills/integration/skill_surface_test.go +++ b/skills/integration/skill_surface_test.go @@ -80,6 +80,30 @@ var referenceRe = regexp.MustCompile(`@?(references/[A-Za-z0-9_./-]+\.md)`) // (a ported skill pointing at a path that does not exist on `next`) fails here. // Brace-placeholder template paths (e.g. references/templates/{name}.md) are // resolved against their concrete siblings rather than the literal `{name}`. +func TestPiRuntimeAdaptersAreLoadable(t *testing.T) { + root := skillsRoot(t) + cases := []struct { + skill string + ref string + }{ + {skill: "first-officer", ref: "references/pi-first-officer-runtime.md"}, + {skill: "ensign", ref: "references/pi-ensign-runtime.md"}, + } + for _, tc := range cases { + skillDir := filepath.Join(root, tc.skill) + data, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + t.Fatalf("%s: %v", tc.skill, err) + } + if !strings.Contains(string(data), tc.ref) { + t.Errorf("%s/SKILL.md does not advertise Pi runtime adapter %s", tc.skill, tc.ref) + } + if _, err := os.Stat(filepath.Join(skillDir, tc.ref)); err != nil { + t.Errorf("%s: Pi runtime adapter %s is not loadable: %v", tc.skill, tc.ref, err) + } + } +} + func TestUserSkillReferenceClosureResolves(t *testing.T) { root := skillsRoot(t) for _, skill := range userSkills { From 18bbda1d5c045a62c56dcdea6629377b267f222c Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 14:24:45 -0700 Subject: [PATCH 02/11] feat: add pi runtime adapter contracts --- internal/dispatch/build_pi_host_test.go | 43 +++++++++ internal/piruntime/registry.go | 112 ++++++++++++++++++++++++ internal/piruntime/registry_test.go | 76 ++++++++++++++++ internal/piruntime/teams.go | 61 +++++++++++++ internal/piruntime/teams_test.go | 47 ++++++++++ 5 files changed, 339 insertions(+) create mode 100644 internal/piruntime/registry.go create mode 100644 internal/piruntime/registry_test.go create mode 100644 internal/piruntime/teams.go create mode 100644 internal/piruntime/teams_test.go diff --git a/internal/dispatch/build_pi_host_test.go b/internal/dispatch/build_pi_host_test.go index 1731126af..54e8b3d8f 100644 --- a/internal/dispatch/build_pi_host_test.go +++ b/internal/dispatch/build_pi_host_test.go @@ -67,3 +67,46 @@ func TestBuildPiHostPromptShape(t *testing.T) { } } } + +func TestBuildPiHostPreservesSplitRootEntityPath(t *testing.T) { + root := t.TempDir() + stateDir := filepath.Join(root, "state-checkout") + writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(true)) + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + worktreeRel := ".worktrees/spacedock-ensign-thing" + if err := os.MkdirAll(filepath.Join(root, worktreeRel), 0o755); err != nil { + t.Fatal(err) + } + entityPath := filepath.Join(stateDir, "thing", "index.md") + writeFile(t, entityPath, entityFM("Thing", "implementation", worktreeRel)) + gitInit(t, root) + + stdin := mergeStdin(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": root, + "stage": "implementation", + "checklist": []string{"- preserve split-root entity path"}, + "team_name": "fixture-pi-team", + "bare_mode": false, + "host": "pi", + }, nil) + + native := runNative(stdin, "build", "--workflow-dir", root) + if native.exit != 0 { + t.Fatalf("build exit=%d stderr=%q", native.exit, native.stderr) + } + body := readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) + worktreeEntityPath := filepath.Join(root, worktreeRel, "state-checkout", "thing", "index.md") + if strings.Contains(body, worktreeEntityPath) { + t.Fatalf("pi split-root dispatch must not rewrite entity path into code worktree: %s\n%s", worktreeEntityPath, body) + } + if !strings.Contains(body, "Read the entity file at "+entityPath) { + t.Fatalf("pi split-root dispatch body does not point at state-checkout entity %s:\n%s", entityPath, body) + } + if !strings.Contains(body, "This workflow is split-root") { + t.Fatalf("pi split-root dispatch body missing split-root state guidance:\n%s", body) + } +} diff --git a/internal/piruntime/registry.go b/internal/piruntime/registry.go new file mode 100644 index 000000000..51db527a6 --- /dev/null +++ b/internal/piruntime/registry.go @@ -0,0 +1,112 @@ +// ABOUTME: Minimal Pi worker registry for epoch-scoped completion evidence. +// ABOUTME: Prevents stale completion text from satisfying a later routed turn. +package piruntime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// WorkerRecord is the minimal workflow metadata needed to reason about a Pi +// worker handle without building a second session system on top of Pi. +type WorkerRecord struct { + WorkerLabel string `json:"worker_label"` + Substrate string `json:"substrate"` + RunID string `json:"run_id,omitempty"` + SessionFile string `json:"session_file,omitempty"` + EntitySlug string `json:"entity_slug,omitempty"` + Stage string `json:"stage,omitempty"` + State string `json:"state"` + CompletionEpoch int `json:"completion_epoch"` +} + +// CompletionEvidence is the completion tuple observed from a Pi substrate. +type CompletionEvidence struct { + WorkerLabel string + RunID string + CompletionEpoch int +} + +type Registry struct { + path string + records map[string]WorkerRecord +} + +func OpenRegistry(path string) (*Registry, error) { + r := &Registry{path: path, records: map[string]WorkerRecord{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return r, nil + } + return nil, err + } + if len(b) == 0 { + return r, nil + } + if err := json.Unmarshal(b, &r.records); err != nil { + return nil, err + } + return r, nil +} + +func (r *Registry) Get(workerLabel string) (WorkerRecord, bool) { + rec, ok := r.records[workerLabel] + return rec, ok +} + +func (r *Registry) Upsert(rec WorkerRecord) error { + if rec.WorkerLabel == "" { + return fmt.Errorf("worker label is required") + } + r.records[rec.WorkerLabel] = rec + return r.save() +} + +func (r *Registry) MarkActiveAgain(workerLabel, stage string) (WorkerRecord, error) { + rec, ok := r.records[workerLabel] + if !ok { + return WorkerRecord{}, fmt.Errorf("worker %q not found", workerLabel) + } + if rec.State == "active" { + return WorkerRecord{}, fmt.Errorf("worker %q is already active", workerLabel) + } + rec.State = "active" + rec.Stage = stage + rec.CompletionEpoch++ + r.records[workerLabel] = rec + return rec, r.save() +} + +func (r *Registry) MarkCompleted(workerLabel, runID string) error { + rec, ok := r.records[workerLabel] + if !ok { + return fmt.Errorf("worker %q not found", workerLabel) + } + rec.State = "completed" + rec.RunID = runID + r.records[workerLabel] = rec + return r.save() +} + +func (r *Registry) CompletionIsCurrent(ev CompletionEvidence) bool { + rec, ok := r.records[ev.WorkerLabel] + if !ok { + return false + } + return rec.State == "completed" && rec.RunID == ev.RunID && rec.CompletionEpoch == ev.CompletionEpoch +} + +func (r *Registry) save() error { + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(r.records, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + return os.WriteFile(r.path, b, 0o644) +} diff --git a/internal/piruntime/registry_test.go b/internal/piruntime/registry_test.go new file mode 100644 index 000000000..3a6038ed0 --- /dev/null +++ b/internal/piruntime/registry_test.go @@ -0,0 +1,76 @@ +// ABOUTME: Pi worker registry contract tests — follow-up completion evidence is +// ABOUTME: epoch-scoped so stale results cannot satisfy a reused worker turn. +package piruntime + +import ( + "path/filepath" + "testing" +) + +func TestRegistryRejectsStaleCompletionAfterFollowup(t *testing.T) { + path := filepath.Join(t.TempDir(), "workers.json") + reg, err := OpenRegistry(path) + if err != nil { + t.Fatal(err) + } + + initial := WorkerRecord{ + WorkerLabel: "spacedock-ensign-pi-runtime-support-implementation", + Substrate: "subagents", + RunID: "run-1", + EntitySlug: "pi-runtime-support", + Stage: "implementation", + State: "completed", + CompletionEpoch: 0, + } + if err := reg.Upsert(initial); err != nil { + t.Fatal(err) + } + stale := CompletionEvidence{WorkerLabel: initial.WorkerLabel, RunID: "run-1", CompletionEpoch: 0} + if !reg.CompletionIsCurrent(stale) { + t.Fatal("initial completion should be current before follow-up") + } + + followup, err := reg.MarkActiveAgain(initial.WorkerLabel, "validation") + if err != nil { + t.Fatal(err) + } + if followup.CompletionEpoch != 1 || followup.State != "active" || followup.Stage != "validation" { + t.Fatalf("bad follow-up record: %#v", followup) + } + if reg.CompletionIsCurrent(stale) { + t.Fatal("stale completion from epoch 0 must not satisfy epoch 1 follow-up") + } + + current := CompletionEvidence{WorkerLabel: initial.WorkerLabel, RunID: "run-2", CompletionEpoch: 1} + if err := reg.MarkCompleted(initial.WorkerLabel, current.RunID); err != nil { + t.Fatal(err) + } + if !reg.CompletionIsCurrent(current) { + t.Fatal("epoch 1 completion should be current after MarkCompleted") + } +} + +func TestRegistryPersistsRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "workers.json") + reg, err := OpenRegistry(path) + if err != nil { + t.Fatal(err) + } + want := WorkerRecord{WorkerLabel: "worker-a", Substrate: "subagents", RunID: "run-1", State: "completed"} + if err := reg.Upsert(want); err != nil { + t.Fatal(err) + } + + reopened, err := OpenRegistry(path) + if err != nil { + t.Fatal(err) + } + got, ok := reopened.Get("worker-a") + if !ok { + t.Fatal("persisted worker missing after reopen") + } + if got.RunID != want.RunID || got.Substrate != want.Substrate || got.State != want.State { + t.Fatalf("persisted record mismatch: got %#v want %#v", got, want) + } +} diff --git a/internal/piruntime/teams.go b/internal/piruntime/teams.go new file mode 100644 index 000000000..5869771d6 --- /dev/null +++ b/internal/piruntime/teams.go @@ -0,0 +1,61 @@ +// ABOUTME: Pi runtime adapter helpers for pi-agent-teams action payloads. +// ABOUTME: Keeps Spacedock lifecycle intents away from Claude team-tool shapes. +package piruntime + +import ( + "encoding/json" + "strings" +) + +// TeamsTask is one task entry for pi-agent-teams action=delegate. +type TeamsTask struct { + Text string `json:"text"` + Assignee string `json:"assignee,omitempty"` +} + +// TeamsAction is the small Spacedock-owned subset of the pi-agent-teams `teams` +// tool schema needed to express dispatch, follow-up, shutdown, and team done. +type TeamsAction struct { + Action string `json:"action"` + Tasks []TeamsTask `json:"tasks,omitempty"` + Name string `json:"name,omitempty"` + Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` + All bool `json:"all,omitempty"` +} + +func TeamsDelegateAction(workerName, assignment string) TeamsAction { + return TeamsAction{ + Action: "delegate", + Tasks: []TeamsTask{{ + Text: assignment, + Assignee: workerName, + }}, + } +} + +func TeamsDirectMessageAction(workerName, message string) TeamsAction { + return TeamsAction{Action: "message_dm", Name: workerName, Message: message} +} + +func TeamsShutdownAction(workerName, reason string) TeamsAction { + return TeamsAction{Action: "member_shutdown", Name: workerName, Reason: reason} +} + +func TeamsDoneAction(force bool) TeamsAction { + return TeamsAction{Action: "team_done", All: force} +} + +// ContainsClaudeTeamToolName reports whether an adapter payload accidentally +// embeds Claude team-tool names. It is intentionally string-based over the JSON +// payload so it catches both field values and nested task text. +func ContainsClaudeTeamToolName(action TeamsAction) bool { + b, _ := json.Marshal(action) + s := string(b) + for _, banned := range []string{"Agent", "SendMessage", "TeamCreate", "TeamDelete"} { + if strings.Contains(s, banned) { + return true + } + } + return false +} diff --git a/internal/piruntime/teams_test.go b/internal/piruntime/teams_test.go new file mode 100644 index 000000000..375a9729d --- /dev/null +++ b/internal/piruntime/teams_test.go @@ -0,0 +1,47 @@ +// ABOUTME: Pi teams adapter contract tests — Spacedock lifecycle intents map +// ABOUTME: to pi-agent-teams `teams` action payloads, not Claude team tools. +package piruntime + +import "testing" + +func TestTeamsAdapterMapsLifecycleActions(t *testing.T) { + spawn := TeamsDelegateAction("spacedock-ensign-pi-runtime-support-implementation", "Read /tmp/dispatch.md and implement.") + if spawn.Action != "delegate" { + t.Fatalf("spawn action=%q, want delegate", spawn.Action) + } + if len(spawn.Tasks) != 1 || spawn.Tasks[0].Assignee != "spacedock-ensign-pi-runtime-support-implementation" { + t.Fatalf("delegate task assignment not preserved: %#v", spawn.Tasks) + } + if spawn.Tasks[0].Text != "Read /tmp/dispatch.md and implement." { + t.Fatalf("delegate task text drifted: %q", spawn.Tasks[0].Text) + } + + followup := TeamsDirectMessageAction("worker-a", "Fix the rejected AC-2 evidence.") + if followup.Action != "message_dm" || followup.Name != "worker-a" || followup.Message != "Fix the rejected AC-2 evidence." { + t.Fatalf("bad follow-up mapping: %#v", followup) + } + + shutdown := TeamsShutdownAction("worker-a", "stage complete") + if shutdown.Action != "member_shutdown" || shutdown.Name != "worker-a" || shutdown.Reason != "stage complete" { + t.Fatalf("bad shutdown mapping: %#v", shutdown) + } + + done := TeamsDoneAction(true) + if done.Action != "team_done" || !done.All { + t.Fatalf("bad team done mapping: %#v", done) + } +} + +func TestTeamsAdapterPayloadsContainNoClaudeToolNames(t *testing.T) { + payloads := []TeamsAction{ + TeamsDelegateAction("worker-a", "Do work"), + TeamsDirectMessageAction("worker-a", "Continue"), + TeamsShutdownAction("worker-a", "done"), + TeamsDoneAction(true), + } + for _, payload := range payloads { + if ContainsClaudeTeamToolName(payload) { + t.Fatalf("payload contains Claude team-tool name: %#v", payload) + } + } +} From ce192a7c2b75c53366bf106a551fe691b27b42f5 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 14:45:33 -0700 Subject: [PATCH 03/11] test: prove pi subagent live smoke --- internal/ensigncycle/pi_live_runner_test.go | 220 ++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 internal/ensigncycle/pi_live_runner_test.go diff --git a/internal/ensigncycle/pi_live_runner_test.go b/internal/ensigncycle/pi_live_runner_test.go new file mode 100644 index 000000000..a038fb5c5 --- /dev/null +++ b/internal/ensigncycle/pi_live_runner_test.go @@ -0,0 +1,220 @@ +//go:build live + +package ensigncycle + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +const piLiveSmokeMarker = "PI-LIVE-SUBAGENT-ENSIGN-SMOKE" + +func TestLivePiSubagentEnsignSmoke(t *testing.T) { + piBin, err := exec.LookPath("pi") + if err != nil { + t.Skip("pi not on PATH; install Pi CLI before running the live Pi smoke") + } + repo := repoRoot(t) + piSubagentsRoot := piSubagentsPackageRoot(t) + binary := spacedockBinary(t) + + piHome := t.TempDir() + sessionDir := t.TempDir() + cleanHome := t.TempDir() + seedPiLocalAuth(t, piHome, os.Getenv("HOME")) + + workflowRoot, stateRoot, entityPath := writePiSplitRootSmokeWorkflow(t) + artifactDir := filepath.Join(piLiveArtifactDir(t, "pi-subagent-ensign-smoke"), "run") + if err := os.MkdirAll(artifactDir, 0o755); err != nil { + t.Fatal(err) + } + stdoutPath := filepath.Join(artifactDir, "pi-stdout.txt") + stderrPath := filepath.Join(artifactDir, "pi-stderr.txt") + + prompt := piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, piBin, + "--print", + "--session-dir", sessionDir, + "--extension", filepath.Join(piSubagentsRoot, "src", "extension", "index.ts"), + "--skill", filepath.Join(piSubagentsRoot, "skills", "pi-subagents"), + "--skill", filepath.Join(repo, "skills", "first-officer"), + "--skill", filepath.Join(repo, "skills", "ensign"), + prompt, + ) + cmd.Dir = workflowRoot + cmd.Env = piLiveEnv(piHome, sessionDir, cleanHome, filepath.Dir(binary)) + stdout, err := os.Create(stdoutPath) + if err != nil { + t.Fatal(err) + } + defer stdout.Close() + stderr, err := os.Create(stderrPath) + if err != nil { + t.Fatal(err) + } + defer stderr.Close() + cmd.Stdout = stdout + cmd.Stderr = stderr + + runErr := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("pi live smoke timed out; artifacts in %s", artifactDir) + } + if runErr != nil { + t.Fatalf("pi live smoke failed: %v; artifacts in %s\nstderr tail:\n%s", runErr, artifactDir, tail(readFile(t, stderrPath), 4000)) + } + + entity := readFile(t, entityPath) + for _, want := range []string{piLiveSmokeMarker, "## Stage Report: implementation", "- DONE:", "### Summary"} { + if !strings.Contains(entity, want) { + t.Fatalf("entity missing %q after pi subagent smoke; artifacts in %s\n%s", want, artifactDir, entity) + } + } + log := git(t, stateRoot, "log", "--oneline", "--", "pi-live-smoke", "index.md") + if !strings.Contains(log, "ensign: pi live smoke") { + t.Fatalf("state checkout git log missing worker commit; artifacts in %s\n%s", artifactDir, log) + } + if strings.TrimSpace(git(t, stateRoot, "status", "--short", "--", "pi-live-smoke", "index.md")) != "" { + t.Fatalf("state checkout entity has uncommitted changes after worker commit; artifacts in %s\n%s", artifactDir, git(t, stateRoot, "status", "--short")) + } +} + +func piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath string) string { + return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. + +Use the pi-subagents subagent(...) tool exactly once to dispatch one Pi ensign worker. Do not use or mention Claude Agent, SendMessage, TeamCreate, or TeamDelete tools. + +Dispatch a worker with agent "delegate" and this task: + +Load and follow the local Spacedock ensign skill at %[1]s/skills/ensign/SKILL.md and the Pi ensign adapter at %[1]s/skills/ensign/references/pi-ensign-runtime.md. This is a split-root Spacedock workflow. + +Workflow directory: %[2]s +State checkout: %[3]s +Entity file: %[4]s +Target stage: implementation + +Required worker actions: +1. Read the workflow README and entity file. +2. Do not edit YAML frontmatter. +3. Append an implementation stage report to the entity body containing the exact marker %[5]s, at least one '- DONE:' item, and a '### Summary' subsection. +4. Commit only the entity path in the state checkout with message 'ensign: pi live smoke'. Use a path-scoped git add/commit for pi-live-smoke/index.md. +5. Return a concise completion result naming the entity file and commit evidence. + +After subagent(...) returns, you as first officer must verify the entity file contains %[5]s and verify the state checkout git log contains 'ensign: pi live smoke'. Exit successfully only after those durable checks pass.`, repo, workflowRoot, stateRoot, entityPath, piLiveSmokeMarker) +} + +func writePiSplitRootSmokeWorkflow(t *testing.T) (workflowRoot, stateRoot, entityPath string) { + t.Helper() + workflowRoot = t.TempDir() + stateRoot = filepath.Join(workflowRoot, ".spacedock-state") + writeFile(t, filepath.Join(workflowRoot, "README.md"), piSplitRootSmokeReadme()) + entityPath = filepath.Join(stateRoot, "pi-live-smoke", "index.md") + writeFile(t, entityPath, piLiveSmokeEntity()) + gitInit(t, workflowRoot) + gitInit(t, stateRoot) + return workflowRoot, stateRoot, entityPath +} + +func piSplitRootSmokeReadme() string { + return "---\n" + + "entity-type: task\n" + + "id-style: slug\n" + + "state: .spacedock-state\n" + + "stages:\n" + + " defaults:\n" + + " worktree: false\n" + + " concurrency: 1\n" + + " states:\n" + + " - name: implementation\n" + + " initial: true\n" + + " - name: done\n" + + " terminal: true\n" + + "---\n" + + "# Pi Split Root Smoke\n\n" + + "### implementation\n\n" + + "Append the live Pi smoke marker to the entity stage report.\n\n" + + "- **Outputs:** Stage report containing the exact Pi live smoke marker.\n\n" + + "### done\n\nTerminal state.\n" +} + +func piLiveSmokeEntity() string { + return "---\n" + + "id: pi-live-smoke\n" + + "title: Pi Live Smoke\n" + + "status: implementation\n" + + "completed:\n" + + "verdict:\n" + + "worktree:\n" + + "---\n" + + "# Pi Live Smoke\n\n" + + "This entity is mutated only by the Pi subagent live smoke.\n" +} + +func seedPiLocalAuth(t *testing.T, piHome, realHome string) { + t.Helper() + if realHome == "" { + t.Skip("no HOME set; cannot locate ~/.pi/agent/auth.json for Pi live smoke") + } + authPath := filepath.Join(realHome, ".pi", "agent", "auth.json") + b, err := os.ReadFile(authPath) + if err != nil { + t.Skipf("no live Pi auth available: expected %s; run pi login or provide the auth file", authPath) + } + if strings.TrimSpace(string(b)) == "" { + t.Skipf("live Pi auth file is empty: %s", authPath) + } + if err := os.MkdirAll(piHome, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(piHome, "auth.json"), b, 0o600); err != nil { + t.Fatal(err) + } +} + +func piLiveEnv(piHome, sessionDir, cleanHome, binaryDir string) []string { + env := os.Environ() + env = append(env, + "HOME="+cleanHome, + "PI_CODING_AGENT_DIR="+piHome, + "PI_CODING_AGENT_SESSION_DIR="+sessionDir, + "PI_OFFLINE=1", + ) + return withBinaryOnPath(env, filepath.Join(binaryDir, "spacedock")) +} + +func piSubagentsPackageRoot(t *testing.T) string { + t.Helper() + if p := os.Getenv("PI_SUBAGENTS_PACKAGE_ROOT"); p != "" { + return p + } + home := os.Getenv("HOME") + if home == "" { + t.Fatal("HOME is empty; set PI_SUBAGENTS_PACKAGE_ROOT to the local pi-subagents package") + } + p := filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-subagents") + if _, err := os.Stat(filepath.Join(p, "src", "extension", "index.ts")); err != nil { + t.Fatalf("pi-subagents package extension not found at %s: %v; set PI_SUBAGENTS_PACKAGE_ROOT", p, err) + } + return p +} + +func piLiveArtifactDir(t *testing.T, name string) string { + t.Helper() + root := os.Getenv("SPACEDOCK_LIVE_ARTIFACT_DIR") + if root == "" { + return t.TempDir() + } + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return dir +} From c35f35b46c306dcd09e640cbb20ed9c5889021c7 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 14:52:16 -0700 Subject: [PATCH 04/11] docs: describe runtime support bringup --- docs/dev/README.md | 2 + docs/runtime-support.md | 184 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 docs/runtime-support.md diff --git a/docs/dev/README.md b/docs/dev/README.md index 83817c88b..fb9c9cec0 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -147,6 +147,8 @@ spacedock status --workflow-dir docs/dev --next The live lanes prove runtime behavior, not text shape. Static grep checks over workflow YAML or skill prose are not a substitute for launching the real host front door, observing its output, and checking the resulting workflow state. +See [`../runtime-support.md`](../runtime-support.md) for how to add a new runtime host, including the "assume it already works" prompt and the exact Pi `pi-subagents` live-smoke mechanism used to manifest runtime support from first-contact setup friction. + A runtime regression should be caught once per user journey and then exercised by EACH supported host. The shared runtime scenarios make that real: one host-neutral scenario table, two per-host runner adapters (Claude and Codex) implementing the same scenario IDs, and a parity guard that fails if a scenario exists for one host only. ### Shared runtime scenarios diff --git a/docs/runtime-support.md b/docs/runtime-support.md new file mode 100644 index 000000000..cfaf9ccca --- /dev/null +++ b/docs/runtime-support.md @@ -0,0 +1,184 @@ +# Adding runtime support + +Runtime support means Spacedock can launch or drive a host as a first officer, dispatch ensigns through that host's native agent mechanism, and prove the resulting workflow state. A host is not supported because its instructions mention Spacedock; it is supported when a live or fixture-backed run exercises the host and verifies durable state. + +Use this guide when adding a new host such as Pi, or when turning a spike into a supported runtime lane. + +## Runtime layers + +Add support in small layers. Each layer should have its own proof. + +1. **Skill adapters** + - Add `skills/first-officer/references/-first-officer-runtime.md`. + - Add `skills/ensign/references/-ensign-runtime.md`. + - Wire both from the corresponding `SKILL.md` runtime-adapter section. + - The adapter must name the host's native mechanism. Do not emulate Claude `Agent`, `SendMessage`, `TeamCreate`, or `TeamDelete` unless the host really provides those tools. + +2. **Dispatch host mode** + - Teach `spacedock dispatch build` to accept `host: ""` when the assignment shape differs by host. + - Keep entity paths and worktree paths explicit, especially for split-root workflows (`state: .spacedock-state`). + - Test both positive shape and banned-tool negative cases. + +3. **Runtime contracts and registries** + - If the host has long-lived workers, define the minimum worker record: label, substrate, run/session handle, entity, stage, state, and completion epoch. + - Reject stale completion evidence after follow-up or reuse. A previous completion must never satisfy a later assignment. + - If the host has a team API, adapt Spacedock lifecycle intents to the host's native action schema. + +4. **Launch/install UX** + - Add `spacedock ` only after the manual/live harness proves the runtime path. + - Add `spacedock install --host ` only when the install path is known and can be checked without mutating unrelated global host state. + - Add `doctor --host ` when there is a manifest, package, or runtime health check to verify. + +5. **Live runner** + - Prove the host with a live-gated test when the claim is runtime integration. + - Prefer a temp workflow fixture, isolated host config/session dirs, and copied credentials over global host state. + - Assert process exit, entity content, git log, and clean state. Do not pass by transcript phrasing. + +## Acceptance checklist + +A new runtime support slice is not done until the entity or PR records evidence for each applicable item: + +- Dispatch output uses the host-native contract and excludes incompatible host tool names. +- The first-officer and ensign skills load host runtime adapters. +- Split-root entity paths remain in the state checkout and are not rewritten into a code worktree. +- Follow-up/reuse cannot accept stale completion evidence, if reuse exists. +- Optional team substrates are represented as adapters over their real action schema. +- A live smoke proves the default dispatch path when runtime behavior is the claim. +- Install/launch commands exist only after the underlying mechanism is proven. + +## Test strategy + +Use the smallest proof at the same abstraction level as the claim: + +- **Text claim:** parse or inspect the real instruction files. +- **Dispatch shape claim:** run `spacedock dispatch build` with a fixture and inspect emitted JSON/body. +- **Adapter claim:** table-test lifecycle intents to exact host-native payloads. +- **Registry claim:** unit-test persistence and stale epoch rejection. +- **Runtime claim:** live-gated host run that mutates a temp workflow and verifies durable state. + +A substring search over code or prose is not proof of behavior. It is acceptable only when the claim itself is about text being present or absent. + +## Manifesting from void + +When a runtime seems unsupported on first contact, do not treat setup friction as proof the product path is impossible. Use a deliberate "assume it works" prompt to force the implementation loop to iron out auth, package paths, and tool-shape mismatches before declaring a blocker. + +Use this operating prompt for the first implementation/validation loop: + +```text +Assume support is supposed to work. Do not treat missing polish, auth setup friction, or tool-shape mismatch as proof the runtime is impossible. In first-officer capacity, iron out the frictions: + +- if auth is missing in an isolated harness, copy/reuse the existing host auth file correctly; +- if the dispatch substrate needs a local package/extension path, wire it explicitly; +- if the host tool shape differs from Claude/Codex, adapt to the host-native contract rather than emulating Claude tools; +- if a live test fails due to harness setup, fix the harness and rerun; +- only stop for a real product/design blocker, not for first-contact setup friction. +``` + +For Pi, the concrete version was: + +```text +Assume Pi support is supposed to work. Do not treat missing polish, auth setup friction, or tool-shape mismatch as proof the runtime is impossible. In FO capacity, iron out the frictions: + +- if Pi auth is missing in an isolated harness, copy/reuse the existing Pi OAuth auth file correctly; +- if the dispatch substrate needs a local package/extension path, wire it explicitly; +- if the Pi tool shape differs from Claude/Codex, adapt to the Pi-native contract rather than emulating Claude tools; +- if a live test fails due to harness setup, fix the harness and rerun; +- only stop for a real product/design blocker, not for first-contact setup friction. +``` + +That prompt matters because it changes the default failure interpretation. A missing `auth.json`, an extension not auto-discovered in a temp home, or a different subagent tool schema is harness work. A real blocker is a proven inability to launch, delegate, observe completion, or verify durable workflow state after the harness is correct. + +## Pi live-smoke mechanism + +The Pi proof used a live-gated test named: + +```bash +go test -tags live -run TestLivePiSubagentEnsignSmoke ./internal/ensigncycle -v -count=1 +``` + +The harness did this: + +1. Resolve `pi` from `PATH` and the local Spacedock repo root. +2. Resolve the installed `pi-subagents` package root, defaulting to: + + ```text + ~/.pi/agent/npm/node_modules/pi-subagents + ``` + +3. Create temp runtime state: + + ```text + PI_CODING_AGENT_DIR= + PI_CODING_AGENT_SESSION_DIR= + --session-dir + HOME= + ``` + +4. Copy only the operator's existing OAuth file into the isolated Pi home: + + ```text + ~/.pi/agent/auth.json -> $PI_CODING_AGENT_DIR/auth.json + ``` + +5. Launch `pi --print` with explicit local resources: + + ```text + --extension ~/.pi/agent/npm/node_modules/pi-subagents/src/extension/index.ts + --skill ~/.pi/agent/npm/node_modules/pi-subagents/skills/pi-subagents + --skill /skills/first-officer + --skill /skills/ensign + ``` + +6. Create a temp split-root workflow: + - `README.md` declares `state: .spacedock-state`. + - The entity is folder-form in `.spacedock-state/pi-live-smoke/index.md`. + - Both workflow root and state checkout are git repositories. + +7. Ask the Pi parent to call `subagent(...)` exactly once. +8. Require the worker to append a stage report and commit only the state-checkout entity path. +9. Assert durable outcomes: + - Pi process exits successfully. + - Entity body contains the exact smoke marker and stage report shape. + - State checkout git log contains the worker commit. + - The entity path has no uncommitted changes. + +## Exact Pi parent prompt + +The live test formats this prompt with repository and temp paths. Keep the structure when debugging Pi runtime support; only substitute the paths and marker. + +```text +You are the Spacedock first officer for a live Pi smoke test. + +Use the pi-subagents subagent(...) tool exactly once to dispatch one Pi ensign worker. Do not use or mention Claude Agent, SendMessage, TeamCreate, or TeamDelete tools. + +Dispatch a worker with agent "delegate" and this task: + +Load and follow the local Spacedock ensign skill at /skills/ensign/SKILL.md and the Pi ensign adapter at /skills/ensign/references/pi-ensign-runtime.md. This is a split-root Spacedock workflow. + +Workflow directory: +State checkout: +Entity file: +Target stage: implementation + +Required worker actions: +1. Read the workflow README and entity file. +2. Do not edit YAML frontmatter. +3. Append an implementation stage report to the entity body containing the exact marker PI-LIVE-SUBAGENT-ENSIGN-SMOKE, at least one '- DONE:' item, and a '### Summary' subsection. +4. Commit only the entity path in the state checkout with message 'ensign: pi live smoke'. Use a path-scoped git add/commit for pi-live-smoke/index.md. +5. Return a concise completion result naming the entity file and commit evidence. + +After subagent(...) returns, you as first officer must verify the entity file contains PI-LIVE-SUBAGENT-ENSIGN-SMOKE and verify the state checkout git log contains 'ensign: pi live smoke'. Exit successfully only after those durable checks pass. +``` + +## Skill install and load paths + +For Pi, there is no Spacedock `install --host pi` yet. The proven live path loads local resources explicitly: + +```text +/skills/first-officer +/skills/ensign +~/.pi/agent/npm/node_modules/pi-subagents/skills/pi-subagents +~/.pi/agent/npm/node_modules/pi-subagents/src/extension/index.ts +``` + +When `spacedock install --host pi` is added, it should reproduce this proven shape without requiring the user to manually relogin or copy package files. It should not mutate global `~/.pi/agent` during live tests; tests should keep using isolated Pi homes with copied auth. From 5ab38e0271b01995c43056812d00f7c751c02454 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 14:53:41 -0700 Subject: [PATCH 05/11] docs: point agents at runtime support guide --- AGENTS.md | 6 ++++++ docs/dev/README.md | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index adb5918b5..b4b6a2093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,3 +53,9 @@ Cut releases from `next` via an annotated `vX.Y.Z` tag — see `docs/releasing.m - Keep skill instructions declarative. Let the binary own path resolution and mutation guards. - Add skill smoke tests before changing first-officer or ensign command text. - Preserve current FO/ensign write-scope rules: the first officer mutates entity state; ensigns write assigned code, reports, and artifacts. + +## Runtime Support + +- When adding a new runtime host or debugging first-contact runtime friction, read `docs/runtime-support.md` first. +- Use the documented "assume it already works" operating prompt before declaring a host impossible due to auth setup, extension/package discovery, or tool-shape mismatch. +- Prove runtime claims with live or fixture-backed durable state evidence: process exit, entity body, state-checkout git log, and clean status. Do not substitute transcript phrasing or instruction-prose grep for behavior. diff --git a/docs/dev/README.md b/docs/dev/README.md index fb9c9cec0..83817c88b 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -147,8 +147,6 @@ spacedock status --workflow-dir docs/dev --next The live lanes prove runtime behavior, not text shape. Static grep checks over workflow YAML or skill prose are not a substitute for launching the real host front door, observing its output, and checking the resulting workflow state. -See [`../runtime-support.md`](../runtime-support.md) for how to add a new runtime host, including the "assume it already works" prompt and the exact Pi `pi-subagents` live-smoke mechanism used to manifest runtime support from first-contact setup friction. - A runtime regression should be caught once per user journey and then exercised by EACH supported host. The shared runtime scenarios make that real: one host-neutral scenario table, two per-host runner adapters (Claude and Codex) implementing the same scenario IDs, and a parity guard that fails if a scenario exists for one host only. ### Shared runtime scenarios From 18d571f5ea03d5b9a8a676ab2af8285d7a1936c0 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 15:09:38 -0700 Subject: [PATCH 06/11] feat: add pi runtime frontdoor UX --- internal/cli/cli.go | 41 ++- internal/cli/help.go | 34 ++- internal/cli/pi.go | 317 ++++++++++++++++++++ internal/cli/pi_frontdoor_test.go | 226 ++++++++++++++ internal/ensigncycle/pi_live_runner_test.go | 83 +++-- 5 files changed, 673 insertions(+), 28 deletions(-) create mode 100644 internal/cli/pi.go create mode 100644 internal/cli/pi_frontdoor_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c0f5d16b0..bd08f5ac6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -138,6 +138,7 @@ func newRootCommand(ctx context.Context, rawArgs []string, env []string, dir str root.AddCommand( newClaudeCommand(ctx, env, dir, stdout, stderr), newCodexCommand(ctx, env, dir, stdout, stderr), + newPiCommand(ctx, env, dir, stdout, stderr), newInstallCommand(ctx, env, stdout, stderr), newDoctorCommand(ctx, env, stdout, stderr), newStatusCommand(ctx, env, dir, stdin, stdout, stderr, runner), @@ -197,6 +198,28 @@ func newCodexCommand(ctx context.Context, env []string, dir string, stdout, stde return cmd } +// newPiCommand wires `spacedock pi` to Pi's native skill/extension resource +// loading instead of Claude/Codex plugin or team-tool semantics. +func newPiCommand(ctx context.Context, env []string, dir string, stdout, stderr io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "pi [task] [-- pi-flags]", + Short: "Start Pi as your Spacedock first officer", + GroupID: "launch", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + if wantsHelp(args) { + return cmd.Help() + } + if code := runPi(ctx, args, dir, env, execPiRuntimeOps{}, stdout, stderr); code != 0 { + return exitCodeError{code} + } + return nil + }, + } + setPiHelp(cmd, stdout) + return cmd +} + // newInstallCommand wires `spacedock install` (the renamed `init`). Behavior is // unchanged from init: install the per-host plugin then run doctor (claude), or // emit the documented codex add prose. DisableFlagParsing keeps the post-subcommand @@ -204,7 +227,7 @@ func newCodexCommand(ctx context.Context, env []string, dir string, stdout, stde // exactly as before); `-h`/`--help` is intercepted here. func newInstallCommand(ctx context.Context, env []string, stdout, stderr io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "install [--host claude|codex] [--check]", + Use: "install [--host claude|codex|pi] [--check]", Short: "Install the Spacedock plugin for a host, then check it", GroupID: "setup", DisableFlagParsing: true, @@ -213,18 +236,19 @@ func newInstallCommand(ctx context.Context, env []string, stdout, stderr io.Writ return cmd.Help() } applyDevBranchOverride(env) - if code := runInit(ctx, args, execHost{}, stdout, stderr); code != 0 { + if code := runInitWithPi(ctx, args, execHost{}, execPiRuntimeOps{}, env, stdout, stderr); code != 0 { return exitCodeError{code} } return nil }, } - cmd.Flags().String("host", "claude", "Host to install the plugin for (claude or codex)") + cmd.Flags().String("host", "claude", "Host to install the plugin for (claude, codex, or pi)") cmd.Flags().Bool("check", false, "Run the compatibility report without installing") setSetupHelp(cmd, stdout, ` Examples: spacedock install spacedock install --host codex + spacedock install --host pi --plugin-dir ./checkout spacedock install --check `) return cmd @@ -234,7 +258,7 @@ Examples: // `--host`/`--plugin-manifest` handling preserved verbatim. func newDoctorCommand(ctx context.Context, env []string, stdout, stderr io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "doctor [--host claude|codex]", + Use: "doctor [--host claude|codex|pi]", Short: "Check the installed plugin and this binary are compatible", GroupID: "setup", DisableFlagParsing: true, @@ -243,18 +267,19 @@ func newDoctorCommand(ctx context.Context, env []string, stdout, stderr io.Write return cmd.Help() } applyDevBranchOverride(env) - if code := runDoctor(ctx, args, execHost{}, stdout, stderr); code != 0 { + if code := runDoctorWithPi(ctx, args, execHost{}, execPiRuntimeOps{}, env, stdout, stderr); code != 0 { return exitCodeError{code} } return nil }, } - cmd.Flags().String("host", "claude", "Host to check (claude or codex)") + cmd.Flags().String("host", "claude", "Host to check (claude, codex, or pi)") cmd.Flags().String("plugin-manifest", "", "Read this manifest directly instead of resolving the installed plugin") setSetupHelp(cmd, stdout, ` Examples: spacedock doctor spacedock doctor --host codex + spacedock doctor --host pi --plugin-dir ./checkout `) return cmd } @@ -507,7 +532,7 @@ _spacedock() { local cur prev verbs status_flags cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - verbs="claude codex install doctor status new state completion dispatch --version --help" + verbs="claude codex pi install doctor status new state completion dispatch --version --help" status_flags="--workflow-dir --next --next-id --boot --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root" if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "$verbs" -- "$cur") ) @@ -527,7 +552,7 @@ const zshCompletion = `#compdef spacedock # spacedock zsh completion _spacedock() { local -a verbs status_flags - verbs=(claude codex install doctor status new state completion dispatch --version --help) + verbs=(claude codex pi install doctor status new state completion dispatch --version --help) status_flags=(--workflow-dir --next --next-id --boot --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root) if (( CURRENT == 2 )); then compadd -- $verbs diff --git a/internal/cli/help.go b/internal/cli/help.go index 775038cc6..2cf268a31 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -19,9 +19,10 @@ const topLevelHelp = tagline + ` Launch claude [task] [-- claude-flags] Start Claude Code as your Spacedock first officer codex [task] [-- codex-flags] Start Codex as your Spacedock first officer + pi [task] [-- pi-flags] Start Pi as your Spacedock first officer Setup - install [--host claude|codex] Install the Spacedock plugin for a host, then check it - doctor [--host claude|codex] Check the installed plugin and this binary are compatible + install [--host claude|codex|pi] Install the Spacedock plugin for a host, then check it + doctor [--host claude|codex|pi] Check the installed plugin and this binary are compatible Workflow status [args] Show or update workflow state new [--folder] SLUG Create an entity from a stdin body (auto-discovers the workflow) @@ -75,6 +76,35 @@ Examples: }) } +// setPiHelp installs the Pi-specific launch help. Pi loads explicit skills and +// extensions instead of a Claude/Codex plugin manifest. +func setPiHelp(cmd *cobra.Command, w io.Writer) { + cmd.Flags().String("plugin-dir", "", "Load a local Spacedock skill checkout") + cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + fmt.Fprint(w, tagline+` + +Usage: + spacedock pi [task] [--plugin-dir ] [-- pi-flags] + +Start Pi as your Spacedock first officer by loading the Pi-native pi-subagents +extension/skill and the Spacedock first-officer/ensign skills. The optional task +is appended to the launch prompt; everything after -- forwards verbatim to pi. + +Flags: +`) + fmt.Fprint(w, c.Flags().FlagUsages()) + fmt.Fprint(w, ` +Forwarding: + Tokens before -- are spacedock's (the task + --plugin-dir). Tokens after -- + forward verbatim to pi, e.g. --model, --print, or --session-dir. + +Examples: + spacedock pi --plugin-dir ./checkout + spacedock pi "drive the workflow" --plugin-dir ./checkout -- --model google/gemini +`) + }) +} + // setSetupHelp installs a per-command help renderer for install/doctor: the // command's own flags and an Examples block. A per-command HelpFunc is set so the // root's grouped HelpFunc is not inherited. diff --git a/internal/cli/pi.go b/internal/cli/pi.go new file mode 100644 index 000000000..a641927d1 --- /dev/null +++ b/internal/cli/pi.go @@ -0,0 +1,317 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/pflag" +) + +const piBootstrapPrompt = "Use $spacedock:first-officer for this whole Pi session." + +type piRuntimeOps interface { + LookPath(name string) (string, error) + Stat(path string) error + Launch(argv []string) error +} + +type execPiRuntimeOps struct{} + +func (execPiRuntimeOps) LookPath(name string) (string, error) { return exec.LookPath(name) } +func (execPiRuntimeOps) Stat(path string) error { _, err := os.Stat(path); return err } +func (execPiRuntimeOps) Launch(argv []string) error { return execHost{}.Launch(argv) } + +type piRuntimeConfig struct { + repoRoot string + packageRoot string + extensionPath string + subagentsSkill string + firstOfficer string + ensign string + authPath string + pluginDirSource string +} + +type piCheckResult struct { + piBinOK bool + piBin string + authOK bool + extensionOK bool + subagentsSkillOK bool + firstOfficerOK bool + ensignOK bool + packageRoot string + repoRoot string + authPath string +} + +func runPi(ctx context.Context, args []string, dir string, env []string, ops piRuntimeOps, stdout, stderr io.Writer) int { + _ = ctx + fd, pluginDirs, err := parsePiFrontDoorArgs(args) + if err != nil { + fmt.Fprintf(stderr, "spacedock pi: %v\n", err) + return 2 + } + cfg := piRuntimeConfigFromEnv(env, dir, lastString(pluginDirs)) + check := checkPiRuntime(ops, cfg) + if !piRuntimeLaunchReady(check) { + fmt.Fprint(stderr, "spacedock pi: Pi runtime is not ready; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") + printPiDoctorReport(stdout, check) + return 1 + } + + argv := []string{ + "pi", + "--extension", cfg.extensionPath, + "--skill", cfg.subagentsSkill, + "--skill", cfg.firstOfficerDir(), + "--skill", cfg.ensignDir(), + } + argv = append(argv, fd.passthrough...) + argv = append(argv, launchPrompt(piBootstrapPrompt, fd)) + if err := ops.Launch(argv); err != nil { + fmt.Fprintf(stderr, "spacedock pi: launch failed: %v\n", err) + return 1 + } + return 0 +} + +func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps piRuntimeOps, env []string, stdout, stderr io.Writer) int { + host, checkOnly, pluginDir, code := parsePiSetupArgs("install", args, stderr) + if code != 0 { + return code + } + if host != "pi" { + return runInit(ctx, stripPluginDirArg(args), hostOps, stdout, stderr) + } + cfg := piRuntimeConfigFromEnv(env, cwd(), pluginDir) + check := checkPiRuntime(piOps, cfg) + if piRuntimeLaunchReady(check) { + fmt.Fprintf(stdout, "Pi runtime ready.\n pi-subagents: %s\n Spacedock skills: %s\n", check.packageRoot, check.repoRoot) + return 0 + } + if checkOnly { + printPiDoctorReport(stdout, check) + return piDoctorExit(check) + } + fmt.Fprintf(stdout, + "Pi runtime setup incomplete.\n\n"+ + "Required next steps:\n"+ + " 1. Install Pi and authenticate so %s exists.\n"+ + " 2. Install the subagent substrate, for example: pi install npm:pi-subagents\n"+ + " 3. If pi-subagents is installed outside the default location, set PI_SUBAGENTS_PACKAGE_ROOT.\n"+ + " 4. Re-run: spacedock doctor --host pi --plugin-dir %s\n\n", check.authPath, check.repoRoot) + printPiDoctorReport(stdout, check) + return 0 +} + +func runDoctorWithPi(ctx context.Context, args []string, hostOps hostOps, piOps piRuntimeOps, env []string, stdout, stderr io.Writer) int { + host, _, pluginDir, code := parsePiSetupArgs("doctor", args, stderr) + if code != 0 { + return code + } + if host != "pi" { + return runDoctor(ctx, stripPluginDirArg(args), hostOps, stdout, stderr) + } + cfg := piRuntimeConfigFromEnv(env, cwd(), pluginDir) + check := checkPiRuntime(piOps, cfg) + printPiDoctorReport(stdout, check) + return piDoctorExit(check) +} + +func parsePiFrontDoorArgs(args []string) (fd frontDoorArgs, pluginDirs []string, err error) { + fs := pflag.NewFlagSet("spacedock-pi", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + pluginDir := fs.StringArray("plugin-dir", nil, "Load local Spacedock skill checkout") + if err := fs.Parse(args); err != nil { + return frontDoorArgs{}, nil, err + } + positionals := fs.Args() + dash := fs.ArgsLenAtDash() + var taskTokens []string + if dash < 0 { + taskTokens = positionals + } else { + taskTokens = positionals[:dash] + fd.passthrough = positionals[dash:] + } + if len(taskTokens) > 0 { + fd.task = strings.Join(taskTokens, " ") + fd.hasTask = true + } + return fd, *pluginDir, nil +} + +func parsePiSetupArgs(command string, args []string, stderr io.Writer) (host string, check bool, pluginDir string, code int) { + host = "claude" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--host": + if i+1 >= len(args) { + fmt.Fprintf(stderr, "spacedock %s: --host requires a value (claude, codex, or pi)\n", command) + return "", false, "", 2 + } + host = args[i+1] + i++ + case "--check": + if command != "install" { + fmt.Fprintf(stderr, "spacedock %s: unknown argument %q\n", command, args[i]) + return "", false, "", 2 + } + check = true + case "--plugin-manifest": + if command != "doctor" { + fmt.Fprintf(stderr, "spacedock %s: unknown argument %q\n", command, args[i]) + return "", false, "", 2 + } + if i+1 >= len(args) { + fmt.Fprintln(stderr, "spacedock doctor: --plugin-manifest requires a path") + return "", false, "", 2 + } + i++ + case "--plugin-dir": + if i+1 >= len(args) { + fmt.Fprintf(stderr, "spacedock %s: --plugin-dir requires a path\n", command) + return "", false, "", 2 + } + pluginDir = args[i+1] + i++ + default: + fmt.Fprintf(stderr, "spacedock %s: unknown argument %q\n", command, args[i]) + return "", false, "", 2 + } + } + return host, check, pluginDir, 0 +} + +func stripPluginDirArg(args []string) []string { + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + if args[i] == "--plugin-dir" && i+1 < len(args) { + i++ + continue + } + out = append(out, args[i]) + } + return out +} + +func piRuntimeConfigFromEnv(env []string, dir, pluginDir string) piRuntimeConfig { + envMap := envMap(env) + repo := pluginDir + pluginDirSource := "--plugin-dir" + if repo == "" { + repo = envMap["SPACEDOCK_REPO_ROOT"] + pluginDirSource = "SPACEDOCK_REPO_ROOT" + } + if repo == "" { + repo = dir + pluginDirSource = "working directory" + } + pkg := envMap["PI_SUBAGENTS_PACKAGE_ROOT"] + if pkg == "" { + home := envMap["HOME"] + if home == "" { + home = os.Getenv("HOME") + } + pkg = filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-subagents") + } + authRoot := envMap["PI_CODING_AGENT_DIR"] + authPath := "" + if authRoot != "" { + authPath = filepath.Join(authRoot, "auth.json") + } else { + home := envMap["HOME"] + if home == "" { + home = os.Getenv("HOME") + } + authPath = filepath.Join(home, ".pi", "agent", "auth.json") + } + return piRuntimeConfig{ + repoRoot: repo, + packageRoot: pkg, + extensionPath: filepath.Join(pkg, "src", "extension", "index.ts"), + subagentsSkill: filepath.Join(pkg, "skills", "pi-subagents"), + firstOfficer: filepath.Join(repo, "skills", "first-officer", "SKILL.md"), + ensign: filepath.Join(repo, "skills", "ensign", "SKILL.md"), + authPath: authPath, + pluginDirSource: pluginDirSource, + } +} + +func checkPiRuntime(ops piRuntimeOps, cfg piRuntimeConfig) piCheckResult { + bin, err := ops.LookPath("pi") + res := piCheckResult{piBinOK: err == nil, piBin: bin, packageRoot: cfg.packageRoot, repoRoot: cfg.repoRoot, authPath: cfg.authPath} + res.authOK = ops.Stat(cfg.authPath) == nil + res.extensionOK = ops.Stat(cfg.extensionPath) == nil + res.subagentsSkillOK = ops.Stat(filepath.Join(cfg.subagentsSkill, "SKILL.md")) == nil + res.firstOfficerOK = ops.Stat(cfg.firstOfficer) == nil + res.ensignOK = ops.Stat(cfg.ensign) == nil + return res +} + +func piRuntimeLaunchReady(c piCheckResult) bool { + return c.piBinOK && c.extensionOK && c.subagentsSkillOK && c.firstOfficerOK && c.ensignOK +} + +func piDoctorHealthy(c piCheckResult) bool { + return piRuntimeLaunchReady(c) && c.authOK +} + +func piDoctorExit(c piCheckResult) int { + if piDoctorHealthy(c) { + return 0 + } + return 1 +} + +func printPiDoctorReport(w io.Writer, c piCheckResult) { + fmt.Fprintln(w, "Pi runtime check") + printPiCheck(w, c.piBinOK, "pi CLI", c.piBin, "install Pi and ensure `pi` is on PATH") + printPiCheck(w, c.authOK, "Pi auth", c.authPath, "run `pi` login/auth flow; live tests copy this file into an isolated PI_CODING_AGENT_DIR") + printPiCheck(w, c.extensionOK, "pi-subagents extension", filepath.Join(c.packageRoot, "src", "extension", "index.ts"), "run `pi install npm:pi-subagents` or set PI_SUBAGENTS_PACKAGE_ROOT") + printPiCheck(w, c.subagentsSkillOK, "pi-subagents skill", filepath.Join(c.packageRoot, "skills", "pi-subagents"), "run `pi install npm:pi-subagents` or set PI_SUBAGENTS_PACKAGE_ROOT") + printPiCheck(w, c.firstOfficerOK, "Spacedock first-officer skill", filepath.Join(c.repoRoot, "skills", "first-officer"), "pass --plugin-dir or set SPACEDOCK_REPO_ROOT") + printPiCheck(w, c.ensignOK, "Spacedock ensign skill", filepath.Join(c.repoRoot, "skills", "ensign"), "pass --plugin-dir or set SPACEDOCK_REPO_ROOT") +} + +func printPiCheck(w io.Writer, ok bool, label, path, remedy string) { + status := "OK" + if !ok { + status = "MISSING" + } + if path != "" { + fmt.Fprintf(w, "%s %s: %s\n", status, label, path) + } else { + fmt.Fprintf(w, "%s %s\n", status, label) + } + if !ok { + fmt.Fprintf(w, " remedy: %s\n", remedy) + } +} + +func (c piRuntimeConfig) firstOfficerDir() string { return filepath.Dir(c.firstOfficer) } +func (c piRuntimeConfig) ensignDir() string { return filepath.Dir(c.ensign) } + +func lastString(v []string) string { + if len(v) == 0 { + return "" + } + return v[len(v)-1] +} + +func envMap(env []string) map[string]string { + m := map[string]string{} + for _, kv := range env { + k, v, ok := strings.Cut(kv, "=") + if ok { + m[k] = v + } + } + return m +} diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go new file mode 100644 index 000000000..254e91e8e --- /dev/null +++ b/internal/cli/pi_frontdoor_test.go @@ -0,0 +1,226 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakePiRuntimeOps struct { + lookPath map[string]string + statOK map[string]bool + launched []string +} + +func (f *fakePiRuntimeOps) LookPath(name string) (string, error) { + if p, ok := f.lookPath[name]; ok { + return p, nil + } + return "", errors.New("not found") +} + +func (f *fakePiRuntimeOps) Stat(path string) error { + if f.statOK[path] { + return nil + } + return errors.New("missing") +} + +func (f *fakePiRuntimeOps) Launch(argv []string) error { + f.launched = append([]string(nil), argv...) + return nil +} + +func TestPiCommandRegisteredInTopLevelHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{ + "pi [task] [-- pi-flags]", + "Start Pi as your Spacedock first officer", + "install [--host claude|codex|pi]", + "doctor [--host claude|codex|pi]", + } { + if !strings.Contains(out, want) { + t.Fatalf("top-level help missing %q:\n%s", want, out) + } + } +} + +func TestPiFrontDoorLaunchesWithNativeResourcePaths(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + ops := &fakePiRuntimeOps{ + lookPath: map[string]string{"pi": "/bin/pi"}, + statOK: statOKForPiResources(repo, pkg), + } + var stdout, stderr bytes.Buffer + + code := runPi(context.Background(), []string{"review this", "--plugin-dir", repo, "--", "--model", "google/gemini"}, t.TempDir(), piTestEnv(pkg, t.TempDir()), ops, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + wantPrefix := []string{ + "pi", + "--extension", filepath.Join(pkg, "src", "extension", "index.ts"), + "--skill", filepath.Join(pkg, "skills", "pi-subagents"), + "--skill", filepath.Join(repo, "skills", "first-officer"), + "--skill", filepath.Join(repo, "skills", "ensign"), + "--model", "google/gemini", + } + if len(ops.launched) < len(wantPrefix)+1 { + t.Fatalf("launch argv too short: %v", ops.launched) + } + for i, want := range wantPrefix { + if ops.launched[i] != want { + t.Fatalf("launch argv[%d]=%q want %q\nargv=%v", i, ops.launched[i], want, ops.launched) + } + } + joined := strings.Join(ops.launched, " ") + for _, banned := range []string{"Agent", "SendMessage", "TeamCreate", "TeamDelete", "--agent", "codex"} { + if strings.Contains(joined, banned) { + t.Fatalf("pi launch argv contains banned runtime token %q: %v", banned, ops.launched) + } + } + prompt := ops.launched[len(ops.launched)-1] + if !strings.Contains(prompt, "Use $spacedock:first-officer") || !strings.Contains(prompt, "review this") { + t.Fatalf("pi prompt missing FO skill or task: %q", prompt) + } +} + +func TestPiInstallAcceptedAndDoesNotUsePluginCommands(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + ops := &fakeHost{} + var stdout, stderr bytes.Buffer + + code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, ops, &fakePiRuntimeOps{ + lookPath: map[string]string{"pi": "/bin/pi"}, + statOK: statOKForPiResources(repo, pkg), + }, piTestEnv(pkg, t.TempDir()), &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + if len(ops.installCmds) != 0 { + t.Fatalf("install --host pi called host plugin install seam: %v", ops.installCmds) + } + out := stdout.String() + for _, want := range []string{"Pi runtime ready", "pi-subagents", pkg, repo} { + if !strings.Contains(out, want) { + t.Fatalf("install --host pi output missing %q:\n%s", want, out) + } + } +} + +func TestPiInstallMissingSubagentsPrintsActionableInstructions(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + var stdout, stderr bytes.Buffer + + code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{ + lookPath: map[string]string{"pi": "/bin/pi"}, + statOK: map[string]bool{ + filepath.Join(repo, "skills", "first-officer", "SKILL.md"): true, + filepath.Join(repo, "skills", "ensign", "SKILL.md"): true, + }, + }, piTestEnv(pkg, t.TempDir()), &stdout, &stderr) + if code != 0 { + t.Fatalf("install --host pi should be idempotent/instructive, exit=%d stderr=%q", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"Pi runtime setup incomplete", "pi install npm:pi-subagents", "PI_SUBAGENTS_PACKAGE_ROOT"} { + if !strings.Contains(out, want) { + t.Fatalf("missing-subagents output missing %q:\n%s", want, out) + } + } +} + +func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + home := t.TempDir() + auth := filepath.Join(home, ".pi", "agent", "auth.json") + + t.Run("missing", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runDoctorWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{}, piTestEnv(pkg, home), &stdout, &stderr) + if code == 0 { + t.Fatalf("exit=0 want non-zero for missing pi runtime") + } + out := stdout.String() + for _, want := range []string{"Pi runtime check", "MISSING pi CLI", "MISSING Pi auth", "MISSING pi-subagents"} { + if !strings.Contains(out, want) { + t.Fatalf("missing doctor output missing %q:\n%s", want, out) + } + } + }) + + t.Run("healthy", func(t *testing.T) { + var stdout, stderr bytes.Buffer + statOK := statOKForPiResources(repo, pkg) + statOK[auth] = true + code := runDoctorWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{ + lookPath: map[string]string{"pi": "/bin/pi"}, + statOK: statOK, + }, piTestEnv(pkg, home), &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + out := stdout.String() + for _, want := range []string{"OK pi CLI", "OK Pi auth", "OK pi-subagents extension", "OK Spacedock first-officer skill", "OK Spacedock ensign skill"} { + if !strings.Contains(out, want) { + t.Fatalf("healthy doctor output missing %q:\n%s", want, out) + } + } + }) +} + +func writePiSkillFixtures(t *testing.T, repo string) { + t.Helper() + writeFileWithDirs(t, filepath.Join(repo, "skills", "first-officer", "SKILL.md"), "---\nname: first-officer\ndescription: test\n---\n") + writeFileWithDirs(t, filepath.Join(repo, "skills", "ensign", "SKILL.md"), "---\nname: ensign\ndescription: test\n---\n") +} + +func writePiSubagentsFixtures(t *testing.T, pkg string) { + t.Helper() + writeFileWithDirs(t, filepath.Join(pkg, "src", "extension", "index.ts"), "export default function() {}\n") + writeFileWithDirs(t, filepath.Join(pkg, "skills", "pi-subagents", "SKILL.md"), "---\nname: pi-subagents\ndescription: test\n---\n") +} + +func writeFileWithDirs(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, path, content) +} + +func statOKForPiResources(repo, pkg string) map[string]bool { + return map[string]bool{ + filepath.Join(pkg, "src", "extension", "index.ts"): true, + filepath.Join(pkg, "skills", "pi-subagents", "SKILL.md"): true, + filepath.Join(repo, "skills", "first-officer", "SKILL.md"): true, + filepath.Join(repo, "skills", "ensign", "SKILL.md"): true, + } +} + +func piTestEnv(pkg, home string) []string { + return []string{ + "PI_SUBAGENTS_PACKAGE_ROOT=" + pkg, + "HOME=" + home, + } +} diff --git a/internal/ensigncycle/pi_live_runner_test.go b/internal/ensigncycle/pi_live_runner_test.go index a038fb5c5..d36458c77 100644 --- a/internal/ensigncycle/pi_live_runner_test.go +++ b/internal/ensigncycle/pi_live_runner_test.go @@ -22,35 +22,64 @@ func TestLivePiSubagentEnsignSmoke(t *testing.T) { } repo := repoRoot(t) piSubagentsRoot := piSubagentsPackageRoot(t) - binary := spacedockBinary(t) + binary := piSpacedockBinary(t, repo) + workflowRoot, stateRoot, entityPath, artifactDir, env := newPiLiveSmokeFixture(t, "pi-subagent-ensign-smoke", repo, piSubagentsRoot, binary) + prompt := piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath) + runPiLiveCommand(t, artifactDir, workflowRoot, env, piBin, + "--print", + "--session-dir", filepath.Join(artifactDir, "sessions"), + "--extension", filepath.Join(piSubagentsRoot, "src", "extension", "index.ts"), + "--skill", filepath.Join(piSubagentsRoot, "skills", "pi-subagents"), + "--skill", filepath.Join(repo, "skills", "first-officer"), + "--skill", filepath.Join(repo, "skills", "ensign"), + prompt, + ) + assertPiLiveSmokeResult(t, stateRoot, entityPath, artifactDir) +} + +func TestLivePiFrontDoorSmoke(t *testing.T) { + repo := repoRoot(t) + piSubagentsRoot := piSubagentsPackageRoot(t) + binary := piSpacedockBinary(t, repo) + workflowRoot, stateRoot, entityPath, artifactDir, env := newPiLiveSmokeFixture(t, "pi-frontdoor-smoke", repo, piSubagentsRoot, binary) + + prompt := piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath) + runPiLiveCommand(t, artifactDir, workflowRoot, env, binary, + "pi", + prompt, + "--plugin-dir", repo, + "--", + "--print", + "--session-dir", filepath.Join(artifactDir, "sessions"), + ) + assertPiLiveSmokeResult(t, stateRoot, entityPath, artifactDir) +} + +func newPiLiveSmokeFixture(t *testing.T, name, repo, piSubagentsRoot, binary string) (workflowRoot, stateRoot, entityPath, artifactDir string, env []string) { + t.Helper() piHome := t.TempDir() sessionDir := t.TempDir() cleanHome := t.TempDir() seedPiLocalAuth(t, piHome, os.Getenv("HOME")) - - workflowRoot, stateRoot, entityPath := writePiSplitRootSmokeWorkflow(t) - artifactDir := filepath.Join(piLiveArtifactDir(t, "pi-subagent-ensign-smoke"), "run") - if err := os.MkdirAll(artifactDir, 0o755); err != nil { + workflowRoot, stateRoot, entityPath = writePiSplitRootSmokeWorkflow(t) + artifactDir = filepath.Join(piLiveArtifactDir(t, name), "run") + if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { t.Fatal(err) } + env = piLiveEnv(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot) + return workflowRoot, stateRoot, entityPath, artifactDir, env +} + +func runPiLiveCommand(t *testing.T, artifactDir, workflowRoot string, env []string, argv ...string) { + t.Helper() stdoutPath := filepath.Join(artifactDir, "pi-stdout.txt") stderrPath := filepath.Join(artifactDir, "pi-stderr.txt") - - prompt := piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, piBin, - "--print", - "--session-dir", sessionDir, - "--extension", filepath.Join(piSubagentsRoot, "src", "extension", "index.ts"), - "--skill", filepath.Join(piSubagentsRoot, "skills", "pi-subagents"), - "--skill", filepath.Join(repo, "skills", "first-officer"), - "--skill", filepath.Join(repo, "skills", "ensign"), - prompt, - ) + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = workflowRoot - cmd.Env = piLiveEnv(piHome, sessionDir, cleanHome, filepath.Dir(binary)) + cmd.Env = env stdout, err := os.Create(stdoutPath) if err != nil { t.Fatal(err) @@ -71,7 +100,10 @@ func TestLivePiSubagentEnsignSmoke(t *testing.T) { if runErr != nil { t.Fatalf("pi live smoke failed: %v; artifacts in %s\nstderr tail:\n%s", runErr, artifactDir, tail(readFile(t, stderrPath), 4000)) } +} +func assertPiLiveSmokeResult(t *testing.T, stateRoot, entityPath, artifactDir string) { + t.Helper() entity := readFile(t, entityPath) for _, want := range []string{piLiveSmokeMarker, "## Stage Report: implementation", "- DONE:", "### Summary"} { if !strings.Contains(entity, want) { @@ -87,6 +119,20 @@ func TestLivePiSubagentEnsignSmoke(t *testing.T) { } } +func piSpacedockBinary(t *testing.T, repo string) string { + t.Helper() + if os.Getenv("SPACEDOCK_BIN") != "" { + return spacedockBinary(t) + } + out := filepath.Join(t.TempDir(), "spacedock") + cmd := exec.Command("go", "build", "-o", out, "./cmd/spacedock") + cmd.Dir = repo + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build spacedock for Pi live smoke: %v\n%s", err, b) + } + return out +} + func piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath string) string { return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. @@ -179,12 +225,13 @@ func seedPiLocalAuth(t *testing.T, piHome, realHome string) { } } -func piLiveEnv(piHome, sessionDir, cleanHome, binaryDir string) []string { +func piLiveEnv(piHome, sessionDir, cleanHome, binaryDir, piSubagentsRoot string) []string { env := os.Environ() env = append(env, "HOME="+cleanHome, "PI_CODING_AGENT_DIR="+piHome, "PI_CODING_AGENT_SESSION_DIR="+sessionDir, + "PI_SUBAGENTS_PACKAGE_ROOT="+piSubagentsRoot, "PI_OFFLINE=1", ) return withBinaryOnPath(env, filepath.Join(binaryDir, "spacedock")) From 4457175248baf5a0b2b908874ac1eb21cfc37c27 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 18:17:34 -0700 Subject: [PATCH 07/11] test: require exact Pi live smoke report heading --- internal/ensigncycle/pi_live_runner_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/ensigncycle/pi_live_runner_test.go b/internal/ensigncycle/pi_live_runner_test.go index d36458c77..98f2c7a63 100644 --- a/internal/ensigncycle/pi_live_runner_test.go +++ b/internal/ensigncycle/pi_live_runner_test.go @@ -133,6 +133,14 @@ func piSpacedockBinary(t *testing.T, repo string) string { return out } +func TestPiLiveSmokePromptRequiresExactStageReportHeading(t *testing.T) { + prompt := piLiveSmokePrompt("/repo", "/workflow", "/workflow/.spacedock-state", "/workflow/.spacedock-state/pi-live-smoke/index.md") + want := "exact heading '## Stage Report: implementation'" + if !strings.Contains(prompt, want) { + t.Fatalf("pi live smoke prompt missing %q:\n%s", want, prompt) + } +} + func piLiveSmokePrompt(repo, workflowRoot, stateRoot, entityPath string) string { return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. @@ -150,7 +158,7 @@ Target stage: implementation Required worker actions: 1. Read the workflow README and entity file. 2. Do not edit YAML frontmatter. -3. Append an implementation stage report to the entity body containing the exact marker %[5]s, at least one '- DONE:' item, and a '### Summary' subsection. +3. Append an implementation stage report to the entity body with the exact heading '## Stage Report: implementation', containing the exact marker %[5]s, at least one '- DONE:' item, and a '### Summary' subsection. 4. Commit only the entity path in the state checkout with message 'ensign: pi live smoke'. Use a path-scoped git add/commit for pi-live-smoke/index.md. 5. Return a concise completion result naming the entity file and commit evidence. From 66cdf149cb058290a7b4e3d7f3b15e3d81319386 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 19:09:25 -0700 Subject: [PATCH 08/11] fix pi subagent acceptance stage contract --- .../references/pi-first-officer-runtime.md | 2 ++ skills/integration/skill_surface_test.go | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index d661cae0e..6602990ba 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -17,6 +17,8 @@ Use `spacedock dispatch build` with `host: "pi"` in the input JSON. Forward the A Pi first officer may dispatch via `subagent(...)` when that tool is available. The task must include the emitted dispatch-file prompt or the dispatch file content, the workflow directory, the entity path, and the completion checklist. The child must load the Spacedock ensign skill and Pi ensign runtime adapter before working. +For Spacedock stage dispatches through `pi-subagents`, do not use the `subagent(... acceptance: ...)` contract. Put acceptance requirements in the task prompt/dispatch content instead. Spacedock owns the independent implementation-to-validation workflow: the gate is verification via entity stage reports, product/state commits, and independent validation, not same-agent acceptance finalization by the child that did the work. + ## Awaiting Completion For `pi-subagents`, the completion signal is the subagent result returned to the parent. After the result arrives, read the entity file and verify the stage report exactly as the shared core requires. Do not advance state based only on a cheerful worker summary. diff --git a/skills/integration/skill_surface_test.go b/skills/integration/skill_surface_test.go index bcdb813e1..0024a2b82 100644 --- a/skills/integration/skill_surface_test.go +++ b/skills/integration/skill_surface_test.go @@ -104,6 +104,37 @@ func TestPiRuntimeAdaptersAreLoadable(t *testing.T) { } } +func TestPiFirstOfficerRuntimeForbidsSubagentAcceptanceForStages(t *testing.T) { + root := skillsRoot(t) + path := filepath.Join(root, "first-officer", "references", "pi-first-officer-runtime.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read Pi first-officer runtime: %v", err) + } + content := string(data) + dispatch := sectionAfter(content, "## Dispatch") + if dispatch == "" { + t.Fatal("Pi first-officer runtime is missing the Dispatch section") + } + + required := []string{ + "pi-subagents", + "subagent(... acceptance: ...)", + "do not use", + "task prompt/dispatch content", + "independent implementation-to-validation workflow", + "entity stage reports", + "product/state commits", + "independent validation", + "not same-agent acceptance finalization", + } + for _, want := range required { + if !strings.Contains(dispatch, want) { + t.Errorf("Pi first-officer Dispatch section does not contain required acceptance-contract invariant %q", want) + } + } +} + func TestUserSkillReferenceClosureResolves(t *testing.T) { root := skillsRoot(t) for _, skill := range userSkills { From 4d47f484db37f6a6c5fef9f6cf514bc622c27992 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 21:07:35 -0700 Subject: [PATCH 09/11] fix pi setup plugin-dir compatibility --- docs/runtime-support.md | 4 +-- internal/cli/pi.go | 16 ++-------- internal/cli/pi_frontdoor_test.go | 49 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/runtime-support.md b/docs/runtime-support.md index cfaf9ccca..78d5e0d7f 100644 --- a/docs/runtime-support.md +++ b/docs/runtime-support.md @@ -172,7 +172,7 @@ After subagent(...) returns, you as first officer must verify the entity file co ## Skill install and load paths -For Pi, there is no Spacedock `install --host pi` yet. The proven live path loads local resources explicitly: +For Pi, `spacedock pi` launches the proven front door by loading local resources explicitly: ```text /skills/first-officer @@ -181,4 +181,4 @@ For Pi, there is no Spacedock `install --host pi` yet. The proven live path load ~/.pi/agent/npm/node_modules/pi-subagents/src/extension/index.ts ``` -When `spacedock install --host pi` is added, it should reproduce this proven shape without requiring the user to manually relogin or copy package files. It should not mutate global `~/.pi/agent` during live tests; tests should keep using isolated Pi homes with copied auth. +`spacedock install --host pi` is an idempotent readiness check and setup guide for that substrate; it does not install a Claude/Codex-style marketplace plugin. `spacedock doctor --host pi` reports the Pi CLI, auth file, `pi-subagents` extension/skill, and local Spacedock skill health. Both commands accept `--plugin-dir ` for the local skill checkout. Live tests should not mutate global `~/.pi/agent`; they should keep using isolated Pi homes with copied auth. diff --git a/internal/cli/pi.go b/internal/cli/pi.go index a641927d1..7385b8a85 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -87,7 +87,7 @@ func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps pi return code } if host != "pi" { - return runInit(ctx, stripPluginDirArg(args), hostOps, stdout, stderr) + return runInit(ctx, args, hostOps, stdout, stderr) } cfg := piRuntimeConfigFromEnv(env, cwd(), pluginDir) check := checkPiRuntime(piOps, cfg) @@ -116,7 +116,7 @@ func runDoctorWithPi(ctx context.Context, args []string, hostOps hostOps, piOps return code } if host != "pi" { - return runDoctor(ctx, stripPluginDirArg(args), hostOps, stdout, stderr) + return runDoctor(ctx, args, hostOps, stdout, stderr) } cfg := piRuntimeConfigFromEnv(env, cwd(), pluginDir) check := checkPiRuntime(piOps, cfg) @@ -189,18 +189,6 @@ func parsePiSetupArgs(command string, args []string, stderr io.Writer) (host str return host, check, pluginDir, 0 } -func stripPluginDirArg(args []string) []string { - out := make([]string, 0, len(args)) - for i := 0; i < len(args); i++ { - if args[i] == "--plugin-dir" && i+1 < len(args) { - i++ - continue - } - out = append(out, args[i]) - } - return out -} - func piRuntimeConfigFromEnv(env []string, dir, pluginDir string) piRuntimeConfig { envMap := envMap(env) repo := pluginDir diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 254e91e8e..1cb244dbb 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "io" "os" "path/filepath" "strings" @@ -147,6 +148,54 @@ func TestPiInstallMissingSubagentsPrintsActionableInstructions(t *testing.T) { } } +func TestNonPiSetupRejectsPluginDir(t *testing.T) { + for _, tc := range []struct { + name string + run func(hostOps, io.Writer, io.Writer) int + }{ + { + name: "install claude", + run: func(hostOps hostOps, stdout, stderr io.Writer) int { + return runInitWithPi(context.Background(), []string{"--host", "claude", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) + }, + }, + { + name: "install codex", + run: func(hostOps hostOps, stdout, stderr io.Writer) int { + return runInitWithPi(context.Background(), []string{"--host", "codex", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) + }, + }, + { + name: "doctor claude", + run: func(hostOps hostOps, stdout, stderr io.Writer) int { + return runDoctorWithPi(context.Background(), []string{"--host", "claude", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) + }, + }, + { + name: "doctor codex", + run: func(hostOps hostOps, stdout, stderr io.Writer) int { + return runDoctorWithPi(context.Background(), []string{"--host", "codex", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ops := &fakeHost{manifest: compatibleManifest(t)} + var stdout, stderr bytes.Buffer + + code := tc.run(ops, &stdout, &stderr) + if code != 2 { + t.Fatalf("exit=%d want 2; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "unknown argument \"--plugin-dir\"") { + t.Fatalf("stderr should reject --plugin-dir, got %q", stderr.String()) + } + if len(ops.installCmds) != 0 { + t.Fatalf("install seam called despite rejected --plugin-dir: %v", ops.installCmds) + } + }) + } +} + func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { repo := t.TempDir() writePiSkillFixtures(t, repo) From cc09fdbfde06a9134c28afb387fa4defd672f431 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 22:53:29 -0700 Subject: [PATCH 10/11] pi install rejects plugin-dir --- docs/runtime-support.md | 2 +- internal/cli/cli.go | 2 +- internal/cli/pi.go | 6 ++++- internal/cli/pi_frontdoor_test.go | 38 ++++++++++++++++++++++++------- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/runtime-support.md b/docs/runtime-support.md index 78d5e0d7f..9d8ed01e3 100644 --- a/docs/runtime-support.md +++ b/docs/runtime-support.md @@ -181,4 +181,4 @@ For Pi, `spacedock pi` launches the proven front door by loading local resources ~/.pi/agent/npm/node_modules/pi-subagents/src/extension/index.ts ``` -`spacedock install --host pi` is an idempotent readiness check and setup guide for that substrate; it does not install a Claude/Codex-style marketplace plugin. `spacedock doctor --host pi` reports the Pi CLI, auth file, `pi-subagents` extension/skill, and local Spacedock skill health. Both commands accept `--plugin-dir ` for the local skill checkout. Live tests should not mutate global `~/.pi/agent`; they should keep using isolated Pi homes with copied auth. +`spacedock install --host pi` is an idempotent readiness check and setup guide for that substrate; it does not install a Claude/Codex-style marketplace plugin and does not accept `--plugin-dir`. Resolve the local skill checkout by running it from the checkout or setting `SPACEDOCK_REPO_ROOT`. `spacedock doctor --host pi` reports the Pi CLI, auth file, `pi-subagents` extension/skill, and local Spacedock skill health; it still accepts `--plugin-dir ` for local skill checkout diagnostics. Live tests should not mutate global `~/.pi/agent`; they should keep using isolated Pi homes with copied auth. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bd08f5ac6..2087be279 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -248,7 +248,7 @@ func newInstallCommand(ctx context.Context, env []string, stdout, stderr io.Writ Examples: spacedock install spacedock install --host codex - spacedock install --host pi --plugin-dir ./checkout + spacedock install --host pi spacedock install --check `) return cmd diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 7385b8a85..a5e582ca0 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -105,7 +105,7 @@ func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps pi " 1. Install Pi and authenticate so %s exists.\n"+ " 2. Install the subagent substrate, for example: pi install npm:pi-subagents\n"+ " 3. If pi-subagents is installed outside the default location, set PI_SUBAGENTS_PACKAGE_ROOT.\n"+ - " 4. Re-run: spacedock doctor --host pi --plugin-dir %s\n\n", check.authPath, check.repoRoot) + " 4. Re-run: spacedock doctor --host pi\n\n", check.authPath) printPiDoctorReport(stdout, check) return 0 } @@ -175,6 +175,10 @@ func parsePiSetupArgs(command string, args []string, stderr io.Writer) (host str } i++ case "--plugin-dir": + if command == "install" { + fmt.Fprintln(stderr, "spacedock install: --plugin-dir is not supported; use SPACEDOCK_REPO_ROOT or run from the Spacedock checkout") + return "", false, "", 2 + } if i+1 >= len(args) { fmt.Fprintf(stderr, "spacedock %s: --plugin-dir requires a path\n", command) return "", false, "", 2 diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 1cb244dbb..9b1ffbc7a 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -98,6 +98,23 @@ func TestPiFrontDoorLaunchesWithNativeResourcePaths(t *testing.T) { } } +func TestPiInstallRejectsPluginDir(t *testing.T) { + var stdout, stderr bytes.Buffer + ops := &fakeHost{} + piOps := &fakePiRuntimeOps{} + + code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", "/checkout"}, ops, piOps, nil, &stdout, &stderr) + if code != 2 { + t.Fatalf("exit=%d want 2; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "--plugin-dir is not supported") { + t.Fatalf("stderr should clearly reject install --plugin-dir, got %q", stderr.String()) + } + if len(ops.installCmds) != 0 { + t.Fatalf("install seam called despite rejected --plugin-dir: %v", ops.installCmds) + } +} + func TestPiInstallAcceptedAndDoesNotUsePluginCommands(t *testing.T) { repo := t.TempDir() writePiSkillFixtures(t, repo) @@ -106,10 +123,10 @@ func TestPiInstallAcceptedAndDoesNotUsePluginCommands(t *testing.T) { ops := &fakeHost{} var stdout, stderr bytes.Buffer - code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, ops, &fakePiRuntimeOps{ + code := runInitWithPi(context.Background(), []string{"--host", "pi"}, ops, &fakePiRuntimeOps{ lookPath: map[string]string{"pi": "/bin/pi"}, statOK: statOKForPiResources(repo, pkg), - }, piTestEnv(pkg, t.TempDir()), &stdout, &stderr) + }, append(piTestEnv(pkg, t.TempDir()), "SPACEDOCK_REPO_ROOT="+repo), &stdout, &stderr) if code != 0 { t.Fatalf("exit=%d stderr=%q stdout=%q", code, stderr.String(), stdout.String()) } @@ -130,13 +147,13 @@ func TestPiInstallMissingSubagentsPrintsActionableInstructions(t *testing.T) { pkg := t.TempDir() var stdout, stderr bytes.Buffer - code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{ + code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, &fakePiRuntimeOps{ lookPath: map[string]string{"pi": "/bin/pi"}, statOK: map[string]bool{ filepath.Join(repo, "skills", "first-officer", "SKILL.md"): true, filepath.Join(repo, "skills", "ensign", "SKILL.md"): true, }, - }, piTestEnv(pkg, t.TempDir()), &stdout, &stderr) + }, append(piTestEnv(pkg, t.TempDir()), "SPACEDOCK_REPO_ROOT="+repo), &stdout, &stderr) if code != 0 { t.Fatalf("install --host pi should be idempotent/instructive, exit=%d stderr=%q", code, stderr.String()) } @@ -150,32 +167,37 @@ func TestPiInstallMissingSubagentsPrintsActionableInstructions(t *testing.T) { func TestNonPiSetupRejectsPluginDir(t *testing.T) { for _, tc := range []struct { - name string - run func(hostOps, io.Writer, io.Writer) int + name string + run func(hostOps, io.Writer, io.Writer) int + wantStderr string }{ { name: "install claude", run: func(hostOps hostOps, stdout, stderr io.Writer) int { return runInitWithPi(context.Background(), []string{"--host", "claude", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) }, + wantStderr: "--plugin-dir is not supported", }, { name: "install codex", run: func(hostOps hostOps, stdout, stderr io.Writer) int { return runInitWithPi(context.Background(), []string{"--host", "codex", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) }, + wantStderr: "--plugin-dir is not supported", }, { name: "doctor claude", run: func(hostOps hostOps, stdout, stderr io.Writer) int { return runDoctorWithPi(context.Background(), []string{"--host", "claude", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) }, + wantStderr: "unknown argument \"--plugin-dir\"", }, { name: "doctor codex", run: func(hostOps hostOps, stdout, stderr io.Writer) int { return runDoctorWithPi(context.Background(), []string{"--host", "codex", "--plugin-dir", "/checkout"}, hostOps, &fakePiRuntimeOps{}, nil, stdout, stderr) }, + wantStderr: "unknown argument \"--plugin-dir\"", }, } { t.Run(tc.name, func(t *testing.T) { @@ -186,8 +208,8 @@ func TestNonPiSetupRejectsPluginDir(t *testing.T) { if code != 2 { t.Fatalf("exit=%d want 2; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - if !strings.Contains(stderr.String(), "unknown argument \"--plugin-dir\"") { - t.Fatalf("stderr should reject --plugin-dir, got %q", stderr.String()) + if !strings.Contains(stderr.String(), tc.wantStderr) { + t.Fatalf("stderr should contain %q, got %q", tc.wantStderr, stderr.String()) } if len(ops.installCmds) != 0 { t.Fatalf("install seam called despite rejected --plugin-dir: %v", ops.installCmds) From 99da167285ad119d878ab6f7766b8deafbef9f2e Mon Sep 17 00:00:00 2001 From: CL Kao Date: Wed, 3 Jun 2026 23:58:00 -0700 Subject: [PATCH 11/11] test: update pi dispatch host expectations --- internal/dispatch/build_json_ergonomics_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/dispatch/build_json_ergonomics_test.go b/internal/dispatch/build_json_ergonomics_test.go index 4cdb39e18..bd9ac35c8 100644 --- a/internal/dispatch/build_json_ergonomics_test.go +++ b/internal/dispatch/build_json_ergonomics_test.go @@ -148,7 +148,7 @@ func TestBuildHostResolutionFromFlagJSONAndEnv(t *testing.T) { root, _ := buildHostFixture(t) native := runNativePreservingHostEnv(buildHostStdin(t, root, nil), "build", "--workflow-dir", root, "--host", "banana") - assertBuildHostError(t, native, "unsupported host", "claude or codex") + assertBuildHostError(t, native, "unsupported host", "claude, codex, or pi") }) t.Run("missing-source", func(t *testing.T) { @@ -203,8 +203,8 @@ func TestBuildSchemaAndValidateOnly(t *testing.T) { if !ok { t.Fatalf("schema missing host property:\n%s", native.stdout) } - if got := strings.Join(anyStrings(host["enum"]), ","); got != "claude,codex" { - t.Fatalf("host enum = %q, want claude,codex", got) + if got := strings.Join(anyStrings(host["enum"]), ","); got != "claude,codex,pi" { + t.Fatalf("host enum = %q, want claude,codex,pi", got) } if containsAnyString(schema["required"], "host") { t.Fatalf("host must be optional in schema required list:\n%s", native.stdout)