diff --git a/internal/dispatch/build_pi_host_test.go b/internal/dispatch/build_pi_host_test.go index 54e8b3d8..9a0e1c73 100644 --- a/internal/dispatch/build_pi_host_test.go +++ b/internal/dispatch/build_pi_host_test.go @@ -4,10 +4,14 @@ package dispatch import ( "encoding/json" + "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" + + "github.com/spacedock-dev/spacedock/internal/piruntime" ) func TestBuildPiHostPromptShape(t *testing.T) { @@ -68,6 +72,281 @@ func TestBuildPiHostPromptShape(t *testing.T) { } } +func TestBuildPiHostArtifactCarriesCanonicalStageFactsThroughPiWrapper(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-canonical-stage" + worktreePath := filepath.Join(root, worktreeRel) + if err := os.MkdirAll(worktreePath, 0o755); err != nil { + t.Fatal(err) + } + entityPath := filepath.Join(stateDir, "canonical-stage", "index.md") + writeFile(t, entityPath, entityFM("Canonical Stage", "implementation", worktreeRel)) + gitInit(t, root) + + checklist := []string{"- keep the builder assignment", "- write the stage report"} + stdin := mergeStdin(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": root, + "stage": "implementation", + "checklist": checklist, + "bare_mode": true, + "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 { + Name *string `json:"name"` + Description string `json:"description"` + DispatchFile string `json:"dispatch_file_path"` + 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) + } + if out.Name != nil { + t.Fatalf("bare Pi dispatch should not require team-style worker name, got %q", *out.Name) + } + if out.Description != "Canonical Stage: implementation" { + t.Fatalf("description = %q", out.Description) + } + if !strings.Contains(out.DispatchFile, "spacedock-ensign-canonical-stage-implementation.md") { + t.Fatalf("dispatch file path does not carry builder-derived slug/stage: %q", out.DispatchFile) + } + + wrapped := piruntime.SubagentStageDispatch(out.Prompt, "implementation", "canonical-stage implementation") + if wrapped.Context != "fresh" { + t.Fatalf("Pi wrapper context = %q, want fresh", wrapped.Context) + } + if wrapped.Task != out.Prompt { + t.Fatalf("Pi wrapper replaced the dispatch-build prompt:\nwant: %s\n got: %s", out.Prompt, wrapped.Task) + } + if strings.Contains(wrapped.Task, "acceptance") { + t.Fatalf("Pi stage wrapper task unexpectedly contains same-agent acceptance contract: %q", wrapped.Task) + } + + body := readDispatchBody(t, out.DispatchFile) + for _, want := range []string{ + "You are working on: Canonical Stage", + "Stage: implementation", + "Read the entity file at " + entityPath, + "Your working directory for CODE is " + worktreePath, + "All CODE reads and writes MUST use paths under " + worktreePath, + "- keep the builder assignment", + "- write the stage report", + } { + if !strings.Contains(body, want) { + t.Fatalf("Pi dispatch artifact missing builder-derived fact %q:\n%s", want, body) + } + } + assertRenderedStageDefCommand(t, body, root, "implementation") + if !strings.Contains(wrapped.Task, out.DispatchFile) { + t.Fatalf("Pi wrapper does not forward dispatch file path %s in task %q", out.DispatchFile, wrapped.Task) + } +} + +func TestPiStageDispatchSmokeUsesBuildArtifactThroughWrapper(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-smoke-stage" + worktreePath := filepath.Join(root, worktreeRel) + if err := os.MkdirAll(worktreePath, 0o755); err != nil { + t.Fatal(err) + } + entityPath := filepath.Join(stateDir, "smoke-stage", "index.md") + writeFile(t, entityPath, entityFM("Smoke Stage", "implementation", worktreeRel)) + gitInit(t, stateDir) + + checklist := []string{"- run the fixture Pi worker", "- append durable stage report"} + stdin := mergeStdin(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": root, + "stage": "implementation", + "checklist": checklist, + "bare_mode": true, + "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 { + DispatchFile string `json:"dispatch_file_path"` + 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) + } + wrapped := piruntime.SubagentStageDispatch(out.Prompt, "implementation", "z6 implementation fixback") + payload, err := json.Marshal(wrapped) + if err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestPiStageDispatchSmokeFixtureWorker$", "-test.v") + cmd.Env = append(os.Environ(), + "SPACEDOCK_PI_FIXTURE_WORKER=1", + "SPACEDOCK_PI_WRAPPER_JSON="+string(payload), + "SPACEDOCK_PI_ENTITY_PATH="+entityPath, + "SPACEDOCK_PI_STATE_DIR="+stateDir, + "SPACEDOCK_PI_WORKFLOW_DIR="+root, + "SPACEDOCK_PI_WORKTREE_PATH="+worktreePath, + ) + workerOut, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fixture Pi worker process failed: %v\n%s", err, workerOut) + } + + entityBody, err := os.ReadFile(entityPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "## Stage Report: implementation fixback smoke", + "- process: fixture Pi worker exited 0", + "- dispatch_file: " + out.DispatchFile, + "- wrapper_context: fresh", + "- checklist_seen: run the fixture Pi worker; append durable stage report", + } { + if !strings.Contains(string(entityBody), want) { + t.Fatalf("entity report missing durable evidence %q:\n%s", want, entityBody) + } + } + + logCmd := exec.Command("git", "-C", stateDir, "log", "--oneline", "--", entityPath) + logOut, err := logCmd.CombinedOutput() + if err != nil { + t.Fatalf("state git log failed: %v\n%s", err, logOut) + } + if !strings.Contains(string(logOut), "fixture Pi stage report") { + t.Fatalf("state git log missing fixture stage-report commit:\n%s", logOut) + } + statusCmd := exec.Command("git", "-C", stateDir, "status", "--short", "--", entityPath) + statusOut, err := statusCmd.CombinedOutput() + if err != nil { + t.Fatalf("state git status failed: %v\n%s", err, statusOut) + } + if strings.TrimSpace(string(statusOut)) != "" { + t.Fatalf("state entity should be clean after path-scoped commit:\n%s", statusOut) + } +} + +func TestPiStageDispatchSmokeFixtureWorker(t *testing.T) { + if os.Getenv("SPACEDOCK_PI_FIXTURE_WORKER") != "1" { + return + } + var wrapped piruntime.SubagentDispatch + if err := json.Unmarshal([]byte(os.Getenv("SPACEDOCK_PI_WRAPPER_JSON")), &wrapped); err != nil { + t.Fatalf("wrapper JSON: %v", err) + } + if wrapped.Context != "fresh" { + t.Fatalf("wrapper context = %q, want fresh", wrapped.Context) + } + if strings.Contains(os.Getenv("SPACEDOCK_PI_WRAPPER_JSON"), "acceptance") { + t.Fatalf("wrapper unexpectedly serialized same-agent acceptance: %s", os.Getenv("SPACEDOCK_PI_WRAPPER_JSON")) + } + + dispatchPath, err := piDispatchFileFromTask(wrapped.Task) + if err != nil { + t.Fatal(err) + } + dispatchBody, err := os.ReadFile(dispatchPath) + if err != nil { + t.Fatalf("read dispatch artifact: %v", err) + } + entityPath := os.Getenv("SPACEDOCK_PI_ENTITY_PATH") + workflowDir := os.Getenv("SPACEDOCK_PI_WORKFLOW_DIR") + worktreePath := os.Getenv("SPACEDOCK_PI_WORKTREE_PATH") + dispatchText := string(dispatchBody) + for _, want := range []string{ + "You are working on: Smoke Stage", + "Stage: implementation", + "Read the entity file at " + entityPath, + "Your working directory for CODE is " + worktreePath, + "- run the fixture Pi worker", + "- append durable stage report", + } { + if !strings.Contains(dispatchText, want) { + t.Fatalf("dispatch artifact missing %q:\n%s", want, dispatchBody) + } + } + assertRenderedStageDefCommand(t, dispatchText, workflowDir, "implementation") + + report := fmt.Sprintf(` +## Stage Report: implementation fixback smoke + +- process: fixture Pi worker exited 0 +- dispatch_file: %s +- wrapper_context: %s +- wrapper_phase: %s +- wrapper_label: %s +- checklist_seen: run the fixture Pi worker; append durable stage report +`, dispatchPath, wrapped.Context, wrapped.Phase, wrapped.Label) + f, err := os.OpenFile(entityPath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open entity report: %v", err) + } + if _, err := f.WriteString(report); err != nil { + _ = f.Close() + t.Fatalf("append entity report: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close entity report: %v", err) + } + + stateDir := os.Getenv("SPACEDOCK_PI_STATE_DIR") + for _, args := range [][]string{ + {"-c", "user.email=t@t", "-c", "user.name=t", "add", entityPath}, + {"-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "fixture Pi stage report", "--", entityPath}, + } { + cmd := exec.Command("git", append([]string{"-C", stateDir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func assertRenderedStageDefCommand(t *testing.T, body, workflowDir, stage string) { + t.Helper() + semantic := "dispatch show-stage-def --workflow-dir " + shlexQuote(workflowDir) + " --stage " + stage + for _, want := range []string{ + "spacedock_launcher() {", + "spacedock_launcher " + semantic, + } { + if !strings.Contains(body, want) { + t.Fatalf("dispatch artifact missing rendered stage-definition command %q:\n%s", want, body) + } + } +} + +func piDispatchFileFromTask(task string) (string, error) { + const prefix = "Read " + if !strings.HasPrefix(task, prefix) { + return "", fmt.Errorf("Pi task does not start with %q: %q", prefix, task) + } + rest := strings.TrimPrefix(task, prefix) + marker := " and treat its content as your assignment" + idx := strings.Index(rest, marker) + if idx < 0 { + return "", fmt.Errorf("Pi task does not forward read-dispatch-file assignment: %q", task) + } + return rest[:idx], nil +} + func TestBuildPiHostPreservesSplitRootEntityPath(t *testing.T) { root := t.TempDir() stateDir := filepath.Join(root, "state-checkout") diff --git a/internal/piruntime/subagents.go b/internal/piruntime/subagents.go new file mode 100644 index 00000000..e4fe56b9 --- /dev/null +++ b/internal/piruntime/subagents.go @@ -0,0 +1,25 @@ +// ABOUTME: Pi-subagents assignment wrappers for Spacedock stage dispatches. +// ABOUTME: Keeps Pi transport fields additive around dispatch-build artifacts. +package piruntime + +// SubagentDispatch is the Spacedock-owned subset of the pi-subagents stage +// dispatch wrapper. The canonical assignment remains the dispatch-build artifact; +// this wrapper only adds Pi transport metadata. +type SubagentDispatch struct { + Task string `json:"task"` + Context string `json:"context"` + Phase string `json:"phase,omitempty"` + Label string `json:"label,omitempty"` +} + +// SubagentStageDispatch wraps an already-built dispatch artifact prompt/content +// for pi-subagents. It intentionally has no acceptance field: stage acceptance is +// owned by the canonical assignment/checklist and independent Spacedock validation. +func SubagentStageDispatch(assignment, phase, label string) SubagentDispatch { + return SubagentDispatch{ + Task: assignment, + Context: "fresh", + Phase: phase, + Label: label, + } +} diff --git a/internal/piruntime/subagents_test.go b/internal/piruntime/subagents_test.go new file mode 100644 index 00000000..8a42b17a --- /dev/null +++ b/internal/piruntime/subagents_test.go @@ -0,0 +1,32 @@ +// ABOUTME: Pi-subagents stage dispatch wrapper invariants. +// ABOUTME: Proves the wrapper adds only transport fields around canonical assignments. +package piruntime + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSubagentStageDispatchAddsOnlyPiTransportFields(t *testing.T) { + assignment := "Read /tmp/spacedock-dispatches/spacedock-ensign-fixture-implementation.md and treat its content as your assignment." + + wrapped := SubagentStageDispatch(assignment, "implementation", "fixture implementation") + if wrapped.Context != "fresh" { + t.Fatalf("context = %q, want fresh", wrapped.Context) + } + if wrapped.Task != assignment { + t.Fatalf("wrapper rewrote assignment:\nwant: %s\n got: %s", assignment, wrapped.Task) + } + if wrapped.Phase != "implementation" || wrapped.Label != "fixture implementation" { + t.Fatalf("phase/label not preserved: %#v", wrapped) + } + + payload, err := json.Marshal(wrapped) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), "acceptance") { + t.Fatalf("stage wrapper must not contain same-agent acceptance contract: %s", payload) + } +} diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index 7909028d..32249233 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -13,9 +13,11 @@ The Spacedock contract talks in terms of dispatch, completion, follow-up, and sh ## 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. +Use `spacedock dispatch build` with `host: "pi"` in the input JSON. The build artifact is the assignment source of truth: it carries the entity slug/name, entity path, workflow directory, target stage, stage definition fetch command, worktree path when applicable, completion checklist, and completion-signal wording. Forward the emitted dispatch file prompt or dispatch file content to the Pi worker without composing a replacement assignment and 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. +A Pi first officer may dispatch via `subagent(...)` when that tool is available. The wrapper may add only Pi transport metadata around the build artifact: `context: "fresh"` plus optional human-facing `phase` / `label` values. Those wrapper fields are additive; they must not redefine or replace the builder-derived slug/name, workflow directory, entity path, target stage, worktree path, completion checklist, or completion contract. The child must load the Spacedock ensign skill and Pi ensign runtime adapter before working. + +Manual Pi prompt composition is a break-glass fallback only when `spacedock dispatch build` is unavailable, exits non-zero, or the developer is explicitly debugging the dispatch builder. Any fallback must record the builder failure or unavailability reason and include the minimum canonical schema facts so the degraded dispatch is auditable. For Spacedock stage dispatches through `pi-subagents`, call `subagent(...)` with explicit `context: "fresh"`. Do not rely on the worker agent's default context; Spacedock stage workers must be seeded by the task prompt/dispatch content, workflow directory, entity file, and completion checklist rather than inherited first-officer transcript context. diff --git a/skills/integration/skill_surface_test.go b/skills/integration/skill_surface_test.go index 93b8e673..68e447fe 100644 --- a/skills/integration/skill_surface_test.go +++ b/skills/integration/skill_surface_test.go @@ -138,6 +138,75 @@ func TestPiFirstOfficerRuntimeRequiresFreshSubagentContextForStages(t *testing.T } } +func TestPiFirstOfficerRuntimeRequiresDispatchBuildArtifactForStages(t *testing.T) { + markNonAC(t, "Pi dispatch builder behavior is exercised by internal/dispatch build_pi_host tests and the Pi live runner; this is the shipped FO instruction-surface lint that requires FO to consume the generated dispatch artifact") + 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) + } + dispatch := sectionAfter(string(data), "## Dispatch") + if dispatch == "" { + t.Fatal("Pi first-officer runtime is missing the Dispatch section") + } + + required := []string{ + "spacedock dispatch build", + "host: \"pi\"", + "assignment source of truth", + "entity slug/name", + "entity path", + "workflow directory", + "target stage", + "worktree path", + "completion checklist", + "emitted dispatch file prompt or dispatch file content", + "without composing a replacement assignment", + "context: \"fresh\"", + "phase", + "label", + "additive", + "must not redefine or replace", + } + for _, want := range required { + if !strings.Contains(dispatch, want) { + t.Errorf("Pi first-officer Dispatch section does not contain required dispatch-build invariant %q", want) + } + } +} + +func TestPiFirstOfficerRuntimeLimitsManualPromptFallback(t *testing.T) { + markNonAC(t, "Pi dispatch builder behavior is exercised by internal/dispatch build_pi_host tests and the Pi live runner; this is the shipped FO instruction-surface lint limiting manual prompt composition to break-glass fallback") + 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) + } + dispatch := sectionAfter(string(data), "## Dispatch") + if dispatch == "" { + t.Fatal("Pi first-officer runtime is missing the Dispatch section") + } + + required := []string{ + "Manual Pi prompt composition", + "break-glass fallback only", + "spacedock dispatch build", + "unavailable", + "exits non-zero", + "explicitly debugging the dispatch builder", + "record the builder failure or unavailability reason", + "minimum canonical schema facts", + "auditable", + } + for _, want := range required { + if !strings.Contains(dispatch, want) { + t.Errorf("Pi first-officer Dispatch section does not contain required manual-fallback invariant %q", want) + } + } +} + func TestPiFirstOfficerRuntimeForbidsSubagentAcceptanceForStages(t *testing.T) { markNonAC(t, "Pi live runner (internal/ensigncycle TestLivePiSubagentEnsignSmoke exercises the Pi subagent dispatch path)") root := skillsRoot(t)