diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index 891b0a087..1a5e4768f 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -811,7 +811,7 @@ jobs: if: ${{ !cancelled() }} run: | set -o pipefail - gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run TestLivePiFrontDoorSmoke ./internal/ensigncycle + gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run 'TestLivePiFrontDoorSmoke|TestLivePiNonSelfDescribingDispatch' ./internal/ensigncycle - name: Upload live artifacts if: always() diff --git a/docs/runtime-live-ci-registry.md b/docs/runtime-live-ci-registry.md index 4ba9c502d..550e5c879 100644 --- a/docs/runtime-live-ci-registry.md +++ b/docs/runtime-live-ci-registry.md @@ -313,6 +313,22 @@ limited to the named runtime boundary. - **Fixture:** `pi/split-root-smoke` — a current-checkout Pi environment and split-root workflow with one child-dispatchable member. +### `pi-non-self-describing-dispatch` + +- **Entry point:** `TestLivePiNonSelfDescribingDispatch` +- **Lane:** `pi-live` +- **Required outcome:** A Pi worker dispatched with a checklist equal to a real + entity's acceptance criteria (no skill-path, stage-report heading, or + DONE/Summary hints) still writes a complete `## Stage Report: implementation` + with a clean state-checkout commit. The worker's only stage-report format + source is the `### Stage Report format` block the dispatch build artifact + embeds for host=pi. This is the tautology-closing lane: reverting the body + embed makes it RED while the self-describing `pi-front-door-subagent-dispatch` + lane stays green. +- **Fixture:** `pi/non-self-describing-smoke` — a split-root Pi workflow whose + implementation stage-def names only the real work (no stage-report mention), + so the embedded dispatch body block is the worker's only format source. + ## Non-gating live experiments These tests are intentionally not release evidence and are not selected by a diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 2b9d14e92..2a453161a 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -268,7 +268,15 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR } } argv = append(argv, fd.passthrough...) - argv = append(argv, launchPrompt(piBootstrapPrompt, fd)) + // Suppress the fresh-start bootstrap prompt on a resume, mirroring the + // Claude/Codex front door (frontdoor.go containsResume): a resume carries + // its own session intent and the FO contract survives in the system prompt + // via resources_discover, so re-injecting piBootstrapPrompt would tell the + // resumed session to load the contract as if starting fresh. Covers --resume, + // --resume=, -r, --continue, -c (the same token set as containsResume). + if !containsResume(fd.passthrough) { + argv = append(argv, launchPrompt(piBootstrapPrompt, fd)) + } // Resolve the fnm per-shell multishell symlink to its stable node-installation // bin so execHost.Launch's stdlib exec.LookPath() hands Node a script // path fnm never tears down. On any miss/failure argv[0] stays "pi" (current diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 6b7808258..0a51f97aa 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -1105,3 +1105,73 @@ func TestPiSpacedockPackageStatus_SubagentsRegistered(t *testing.T) { }) } } + +// TestPiResumeSuppressesBootstrapPrompt pins AC-1/AC-2: a Pi launch with a resume +// token in the passthrough (--resume, --resume=, -r, --continue, -c) must +// NOT append piBootstrapPrompt to the argv, while a non-resume launch still +// does. The non-resume case is the independent baseline that can move the wrong +// way (if the gate regresses or the non-resume path loses the prompt). +func TestPiResumeSuppressesBootstrapPrompt(t *testing.T) { + resumeTokens := []string{ + "--resume", + "--resume=abc123", + "-r", + "--continue", + "-c", + } + for _, token := range resumeTokens { + t.Run("resume/"+token, func(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + ops := &fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(repo, pkg), + packageStatus: healthyPiPackageStatus(), + } + var stdout, stderr bytes.Buffer + args := []string{"--plugin-dir", repo, "--", token} + code := runPi(context.Background(), args, 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()) + } + for _, tok := range ops.launched { + if strings.Contains(tok, piBootstrapPrompt) { + t.Fatalf("resume token %q: argv contains piBootstrapPrompt: %v", token, ops.launched) + } + } + }) + } + + nonResumeCases := []struct { + name string + passthru []string + }{ + {"model_flag", []string{"--model", "google/gemini"}}, + {"task_string", []string{"review this code"}}, + } + for _, tc := range nonResumeCases { + t.Run("nonresume/"+tc.name, func(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + ops := &fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(repo, pkg), + packageStatus: healthyPiPackageStatus(), + } + var stdout, stderr bytes.Buffer + args := append([]string{"--plugin-dir", repo, "--"}, tc.passthru...) + code := runPi(context.Background(), args, 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()) + } + prompt := ops.launched[len(ops.launched)-1] + if !strings.Contains(prompt, piBootstrapPrompt) { + t.Fatalf("non-resume passthrough %v: last argv token missing piBootstrapPrompt: %v", tc.passthru, ops.launched) + } + }) + } +} diff --git a/internal/dispatch/build.go b/internal/dispatch/build.go index 955607fed..5ec6bf8d7 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -673,6 +673,15 @@ func runBuildFields(probe claudeteam.TeamStateProbe, workflowLauncher string, op "### Completion checklist\n\n%s\n\n### Summary\n{brief description of what was accomplished}\n", checklistText)) + // 8a. Stage-report format template (Pi only). Pi does not auto-load the + // ensign skill (it is discoverable, not loaded), so the dispatch body must + // carry the stage-report protocol the worker is expected to produce. + // Claude's Skill() and Codex's $spacedock:ensign bootstrap already supply + // the format, so the block is Pi-only to avoid untestable redundancy. + if host == "pi" { + parts = append(parts, stageReportFormatBlock()) + } + // 9 (retired): standing-teammate auto-injection via a legacy team_name only // ever fired in the deleted legacy branch — merged and bare dispatches always // omitted the command (documented behavior, unchanged by this removal). The @@ -881,6 +890,23 @@ func pathSafeSessionToken(sessionID string) string { return token } +// stageReportFormatBlock emits the stage-report protocol template a Pi-dispatched +// worker needs to produce its `## Stage Report:` section. Pi does not auto-load +// the ensign skill, so the dispatch body is the worker's only format source. The +// structure is sourced from skills/ensign/references/ensign-shared-core.md. +func stageReportFormatBlock() string { + return `### Stage Report format + +` + "Append a `## Stage Report: {stage}` section at the end of the entity file using this structure:" + ` + +` + + "- DONE: {item text}\n {one-line evidence or reference}\n" + + "- SKIPPED: {item text}\n {one-line rationale}\n" + + "- FAILED: {item text}\n {one-line details}\n\n" + + "### Summary\n{2-3 sentences: what was done, key decisions, anything notable}\n\n" + + "Every checklist item must appear. Use `- DONE:` / `- SKIPPED:` / `- FAILED:` markers. Do not use checkbox markers. Append at the end of the entity file.\n" +} + func firstActionBlock(host string) string { if host == "codex" { return "## First action\n" + @@ -898,8 +924,9 @@ func firstActionBlock(host string) string { "\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. " + + "This file carries the stage-report format template plus the stage-specific assignment. " + + "The ensign skill supplies the remaining shared discipline (polling, worktree ownership, " + + "completion protocol); on Pi it is discoverable (`skill=\"ensign\"`), not auto-loaded. " + "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" } diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go new file mode 100644 index 000000000..7d3d10be8 --- /dev/null +++ b/internal/dispatch/build_stage_report_protocol_test.go @@ -0,0 +1,136 @@ +// ABOUTME: AC-2 — the dispatch build artifact body carries the stage-report +// ABOUTME: protocol template for host=pi, and omits it for claude and codex. +package dispatch + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBuildPiArtifactCarriesStageReportProtocol (AC-2) builds an artifact with +// a non-self-describing checklist (one that does NOT mention the ensign skill +// path, the stage-report heading, or the DONE/Summary structure) for host=pi +// and asserts the generated body carries the protocol tokens: the +// `## Stage Report:` template heading, the `- DONE:`/`- SKIPPED:`/`- FAILED:` +// markers, and `### Summary`. The same test asserts host=claude and host=codex +// artifacts do NOT carry the embedded `### Stage Report format` block — the +// embed is Pi-only because Claude's Skill() and Codex's $spacedock:ensign +// bootstrap already supply the format. +func TestBuildPiArtifactCarriesStageReportProtocol(t *testing.T) { + // A non-self-describing checklist: the entity's real acceptance criteria, + // with no skill-path, heading, or format hints. + checklist := []string{ + "- commit the deliverable on the worktree branch", + "- run go test ./... green", + } + + for _, host := range []string{"pi", "claude", "codex"} { + t.Run(host, func(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) + worktreeRel := ".worktrees/spacedock-ensign-stage-report" + 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": checklist, + "bare_mode": false, + "host": host, + }, 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)) + + if host == "pi" { + for _, want := range []string{ + "## Stage Report:", + "- DONE:", + "- SKIPPED:", + "- FAILED:", + "### Summary", + "### Stage Report format", + } { + if !strings.Contains(body, want) { + t.Fatalf("pi dispatch body missing protocol token %q:\n%s", want, body) + } + } + } else { + // Claude and Codex must NOT carry the embedded block; the skill + // supplies the format for those hosts. + for _, banned := range []string{ + "### Stage Report format", + "## Stage Report: {stage}", + } { + if strings.Contains(body, banned) { + t.Fatalf("%s dispatch body must not carry the embedded stage-report block (token %q):\n%s", host, banned, body) + } + } + } + }) + } +} + +// TestBuildPiFirstActionNarrowedToStageReportFormat (AC-1) asserts the Pi +// First-action claim no longer overclaims the full ensign discipline (polling, +// worktree ownership, completion protocol) and instead attributes the +// stage-report format to the body and the rest to the ensign skill. +func TestBuildPiFirstActionNarrowedToStageReportFormat(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) + worktreeRel := ".worktrees/spacedock-ensign-first-action" + 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"}, + "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)) + + // The overclaim is gone. + for _, overclaim := range []string{ + "This file contains the shared ensign discipline entry points", + } { + if strings.Contains(body, overclaim) { + t.Fatalf("pi First-action still overclaims: %q present in body:\n%s", overclaim, body) + } + } + // The narrowed claim attributes the format template to the body and the + // rest of the discipline to the ensign skill. + for _, want := range []string{ + "This file carries the stage-report format template", + "The ensign skill supplies the remaining shared discipline", + "not auto-loaded", + } { + if !strings.Contains(body, want) { + t.Fatalf("pi First-action missing narrowed claim %q:\n%s", want, body) + } + } +} diff --git a/internal/ensigncycle/pi_nonself_describing_build_test.go b/internal/ensigncycle/pi_nonself_describing_build_test.go new file mode 100644 index 000000000..0ec846e5f --- /dev/null +++ b/internal/ensigncycle/pi_nonself_describing_build_test.go @@ -0,0 +1,131 @@ +package ensigncycle + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestPiNonSelfDescribingDispatchBuildBodyCarriesProtocol is the offline +// (non-live) guard for the non-self-describing lane (AC-2 body presence + +// AC-3 tautology closure): it builds the dispatch artifact with a checklist +// equal to a real entity's acceptance criteria — no ensign skill path, no +// stage-report heading, no DONE/Summary structure — and asserts the body +// carries the embedded stage-report protocol tokens while the checklist +// stdin does not smuggle any format hint. This runs without the `live` tag +// so the AC-2 body presence and the AC-3 tautology-closure (checklist has no +// format hint) are checked on every test run, not only live dispatches. +func TestPiNonSelfDescribingDispatchBuildBodyCarriesProtocol(t *testing.T) { + binary := buildRecordedGateBinary(t) + workflowRoot, stateRoot, entityPath := writePiNonSelfDescribingSmokeWorkflow(t) + _ = stateRoot + checklist := []string{ + "- append the smoke marker line `PI-NONSD-SMOKE-MARKER` to the entity file", + "- commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-nonsd-smoke/index.md)", + } + stdin, err := json.Marshal(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": workflowRoot, + "stage": "implementation", + "checklist": checklist, + "bare_mode": true, + "host": "pi", + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "dispatch", "build", "--workflow-dir", workflowRoot) + cmd.Dir = workflowRoot + cmd.Stdin = strings.NewReader(string(stdin)) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("dispatch build --host pi failed: %v\nstderr:\n%s", err, stderr.String()) + } + var envelope piSmokeEnvelope + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("dispatch build stdout is not the build envelope: %v\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + } + body, err := os.ReadFile(envelope.DispatchFile) + if err != nil { + t.Fatalf("read dispatch artifact: %v", err) + } + bodyStr := string(body) + // AC-2: the body carries the embedded protocol tokens. + for _, want := range []string{"### Stage Report format", "## Stage Report:", "- DONE:", "- SKIPPED:", "- FAILED:", "### Summary"} { + if !strings.Contains(bodyStr, want) { + t.Fatalf("non-self-describing dispatch body missing embedded protocol token %q:\n%s", want, bodyStr) + } + } + // AC-3 tautology closure: the checklist (stdin) must not name the ensign + // skill path, the stage-report heading, or the DONE/Summary structure — + // the body embed is the worker's only format source. + for _, banned := range []string{"ensign/SKILL.md", "## Stage Report:", "- DONE:", "- SKIPPED:", "- FAILED:", "### Summary", "Stage Report format"} { + if strings.Contains(string(stdin), banned) { + t.Fatalf("non-self-describing checklist smuggles format hint %q into the dispatch stdin:\n%s", banned, stdin) + } + } +} + +// writePiNonSelfDescribingSmokeWorkflow creates a split-root smoke workflow +// whose implementation stage-def names only the real work (append a marker +// line) — no "stage report" mention — so the worker's stage-report format +// source is the embedded dispatch body block, not the stage-def. +// +//spacedock:live-fixture id=pi/non-self-describing-smoke +func writePiNonSelfDescribingSmokeWorkflow(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"), piNonSelfDescribingSmokeReadme()) + entityPath = filepath.Join(stateRoot, "pi-nonsd-smoke", "index.md") + writeFile(t, entityPath, piNonSelfDescribingSmokeEntity()) + gitInit(t, workflowRoot) + gitInit(t, stateRoot) + return workflowRoot, stateRoot, entityPath +} + +func piNonSelfDescribingSmokeReadme() 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 Non-Self-Describing Smoke\n\n" + + "### implementation\n\n" + + "Append the live Pi smoke marker line `PI-NONSD-SMOKE-MARKER` as a standalone line to the entity file, then commit only the entity path in the state checkout.\n\n" + + "- **Outputs:** The marker line present in the entity file and a path-scoped state commit.\n\n" + + "### done\n\nTerminal state.\n" +} + +func piNonSelfDescribingSmokeEntity() string { + return "---\n" + + "id: pi-nonsd-smoke\n" + + "title: Pi Non-Self-Describing Smoke\n" + + "status: implementation\n" + + "completed:\n" + + "verdict:\n" + + "worktree:\n" + + "---\n" + + "# Pi Non-Self-Describing Smoke\n\n" + + "This entity is mutated only by the Pi subagent non-self-describing live smoke.\n" +} diff --git a/internal/ensigncycle/pi_nonself_describing_live_test.go b/internal/ensigncycle/pi_nonself_describing_live_test.go new file mode 100644 index 000000000..79008c941 --- /dev/null +++ b/internal/ensigncycle/pi_nonself_describing_live_test.go @@ -0,0 +1,170 @@ +//go:build live + +package ensigncycle + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestLivePiNonSelfDescribingDispatch (AC-1, AC-3) is the tautology-closing +// live lane: it dispatches a Pi worker through `dispatch build --host pi` with +// a checklist equal to a real entity's acceptance criteria — no "First read +// ensign/SKILL.md", no stage-report heading, no DONE/Summary structure — and +// asserts the worker still writes a complete `## Stage Report: implementation` +// (heading + `- DONE:` + `### Summary`) with a clean state-checkout commit. +// The worker's only format source is the embedded `### Stage Report format` +// block the build artifact now carries for host=pi. Reverting the AC-2 body +// embed makes this lane RED (the worker has no format source), while the +// self-describing TestLivePiFrontDoorSmoke stays green — proving this lane +// tests the real mode, not a fixture hint. +// +//spacedock:live-proof id=pi-non-self-describing-dispatch lane=pi-live +func TestLivePiNonSelfDescribingDispatch(t *testing.T) { + repo := repoRoot(t) + piSubagentsRoot := piSubagentsPackageRoot(t) + binary := piSpacedockBinary(t, repo) + workflowRoot, stateRoot, entityPath, artifactDir, env, model := newPiNonSelfDescribingSmokeFixture(t, "pi-nonsd-smoke", repo, piSubagentsRoot, binary) + + envelope := runPiNonSelfDescribingDispatchBuild(t, binary, workflowRoot, entityPath) + prompt := piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath, envelope) + runPiLiveCommand(t, artifactDir, workflowRoot, env, binary, + "pi", + prompt, + "--plugin-dir", repo, + "--", + "--print", + "--model", model, + "--session-dir", filepath.Join(artifactDir, "sessions"), + ) + assertPiNonSelfDescribingSmokeResult(t, stateRoot, entityPath, artifactDir) +} + +func newPiNonSelfDescribingSmokeFixture(t *testing.T, name, repo, piSubagentsRoot, binary string) (workflowRoot, stateRoot, entityPath, artifactDir string, env []string, model string) { + t.Helper() + piHome := t.TempDir() + sessionDir := t.TempDir() + cleanHome := t.TempDir() + decision := seedPiLiveAuth(t, piHome, os.Getenv("HOME"), os.Getenv("CODEX_AUTH_JSON"), os.Getenv("OPENAI_API_KEY"), os.Getenv("SPACEDOCK_PI_LIVE_REQUIRED")) + writeFile(t, filepath.Join(piHome, "settings.json"), fmt.Sprintf("{\"packages\":[%q]}\n", "file:"+repo)) + writePiSubagentsProjectArtifactDir(t, piHome) + workflowRoot, stateRoot, entityPath = writePiNonSelfDescribingSmokeWorkflow(t) + artifactDir = filepath.Join(piLiveArtifactDir(t, name), "run") + if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { + t.Fatal(err) + } + env = piLiveEnvForAuth(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot, os.Getenv("OPENAI_API_KEY"), decision.mode) + model = piLiveChildModel(decision) + return workflowRoot, stateRoot, entityPath, artifactDir, env, model +} + +// runPiNonSelfDescribingDispatchBuild assembles the initial-dispatch artifact +// for the non-self-describing smoke entity with a checklist equal to a real +// entity's acceptance criteria: no ensign skill path, no stage-report heading, +// no DONE/Summary structure. The worker's only stage-report format source is +// the embedded `### Stage Report format` block the build artifact carries for +// host=pi (AC-2). +func runPiNonSelfDescribingDispatchBuild(t *testing.T, binary, workflowRoot, entityPath string) piSmokeEnvelope { + t.Helper() + // A real-entity acceptance-criteria checklist: the work to do and the + // commit discipline, with zero format or skill-path hints. + checklist := []string{ + "- append the smoke marker line `PI-NONSD-SMOKE-MARKER` to the entity file", + "- commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-nonsd-smoke/index.md)", + } + stdin, err := json.Marshal(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": workflowRoot, + "stage": "implementation", + "checklist": checklist, + "bare_mode": true, + "host": "pi", + }) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "dispatch", "build", "--workflow-dir", workflowRoot) + cmd.Dir = workflowRoot + cmd.Stdin = strings.NewReader(string(stdin)) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("dispatch build --host pi failed: %v\nstderr:\n%s", err, stderr.String()) + } + out := stdout.Bytes() + var envelope piSmokeEnvelope + if err := json.Unmarshal(out, &envelope); err != nil { + t.Fatalf("dispatch build stdout is not the build envelope: %v\n%s\nstderr:\n%s", err, out, stderr.String()) + } + if envelope.Agent != "worker" || envelope.Skill != "ensign" { + t.Fatalf("pi build envelope = agent %q skill %q, want worker/ensign:\n%s", envelope.Agent, envelope.Skill, out) + } + if envelope.Prompt == "" || envelope.DispatchFile == "" { + t.Fatalf("pi build envelope missing prompt/dispatch_file_path:\n%s", out) + } + // Adversarial guard: confirm the dispatch body carries the embedded + // stage-report format block (AC-2) so the worker has a format source. + body, err := os.ReadFile(envelope.DispatchFile) + if err != nil { + t.Fatalf("read dispatch artifact: %v", err) + } + bodyStr := string(body) + for _, want := range []string{"### Stage Report format", "## Stage Report:", "- DONE:", "### Summary"} { + if !strings.Contains(bodyStr, want) { + t.Fatalf("non-self-describing dispatch body missing embedded protocol token %q:\n%s", want, bodyStr) + } + } + return envelope +} + +// piNonSelfDescribingSmokePrompt is the FO prompt for the non-self-describing +// lane. It forwards the dispatch artifact's spawn fields verbatim and, after +// the worker returns, verifies the entity carries a complete +// `## Stage Report: implementation` and the state git log has the worker +// commit. Unlike piLiveSmokePrompt it does NOT tell the FO to verify the +// smoke marker — the marker is the worker's real work, not the proof; the +// stage report (sourced from the embedded body block) is the proof. +func piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath string, envelope piSmokeEnvelope) string { + return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. + +An initial-dispatch artifact was assembled for the entity with `+"`spacedock dispatch build --host pi`"+`; forward it through pi-subagents exactly as emitted — this smoke exists to prove the build artifact's embedded stage-report format drives the worker's report even when the checklist does not name the format. + + agent: %[5]s + skill: %[6]s + task: %[7]s + +Use the pi-subagents subagent(...) tool exactly once with those fields verbatim (context must be "fresh", working directory %[2]s). Do not use or mention Claude Agent, SendMessage, TeamCreate, or TeamDelete tools. Do not paraphrase, re-order, or extend the task string. + +After subagent(...) returns, you as first officer must verify the entity file %[4]s contains a '## Stage Report: implementation' section with at least one '- DONE:' item and a '### Summary' subsection, and verify the state checkout %[3]s git log contains 'ensign: pi live smoke' over pi-nonsd-smoke/index.md. Exit successfully only after those durable checks pass; your final message names the agent and skill values you passed to subagent(...) and the child's run id. + +Reference paths: ensign contract at %[1]s/skills/ensign/SKILL.md; Pi ensign adapter at %[1]s/skills/ensign/references/pi-ensign-runtime.md (the worker's dispatch artifact already points at them).`, + repo, workflowRoot, stateRoot, entityPath, envelope.Agent, envelope.Skill, envelope.Prompt) +} + +func assertPiNonSelfDescribingSmokeResult(t *testing.T, stateRoot, entityPath, artifactDir string) { + t.Helper() + entity := readFile(t, entityPath) + // The complete stage report structure (heading + DONE + Summary) plus the + // durable git commit prove the spawned worker followed the embedded + // stage-report format block without the checklist naming it. + for _, want := range []string{"## Stage Report: implementation", "- DONE:", "### Summary"} { + if !strings.Contains(entity, want) { + t.Fatalf("entity missing %q after non-self-describing pi subagent smoke; artifacts in %s\n%s", want, artifactDir, entity) + } + } + log := git(t, stateRoot, "log", "--oneline", "--", "pi-nonsd-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-nonsd-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")) + } +}