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 4e3b721aa..96eaeb022 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -896,13 +896,17 @@ func firstActionBlock(host string) string { if host == "pi" { return "## First action\n" + "\n" + - "Read this dispatch file directly and treat its content as your operating contract and assignment.\n" + + "Before anything else, load the ensign discipline: run `/skill:ensign` " + + "(Pi's skill-invoke slash command), or if that is unavailable, read " + + "`skills/ensign/SKILL.md` and its `references/` directly. This loads the " + + "shared ensign discipline (stage-report format, polling, worktree " + + "ownership, completion signal protocol).\n" + "\n" + - "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" + "Then read this dispatch file and treat its content as your " + + "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" + diff --git a/internal/dispatch/build_json_ergonomics_test.go b/internal/dispatch/build_json_ergonomics_test.go index 586f7cf2e..ee4d04997 100644 --- a/internal/dispatch/build_json_ergonomics_test.go +++ b/internal/dispatch/build_json_ergonomics_test.go @@ -240,7 +240,7 @@ func assertPiBuildOutput(t *testing.T, stdout string) { t.Fatalf("derived Pi prompt should be the read-dispatch-file form: %q", out.Prompt) } body := readDispatchBody(t, out.DispatchFilePath) - for _, want := range []string{"Read this dispatch file directly", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { + for _, want := range []string{"read this dispatch file", "/skill:ensign", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { if !strings.Contains(body, want) { t.Fatalf("derived Pi dispatch body missing %q:\n%s", want, body) } diff --git a/internal/dispatch/build_pi_host_test.go b/internal/dispatch/build_pi_host_test.go index 30f686fa6..0fc0e5d98 100644 --- a/internal/dispatch/build_pi_host_test.go +++ b/internal/dispatch/build_pi_host_test.go @@ -61,7 +61,8 @@ func TestBuildPiHostPromptShape(t *testing.T) { } } for _, want := range []string{ - "Read this dispatch file directly", + "read this dispatch file", + "/skill:ensign", "Pi subagent completion result", "Do not emit Claude team-tool calls", } { diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go index 5b7134e8f..40a607ac2 100644 --- a/internal/dispatch/build_stage_report_protocol_test.go +++ b/internal/dispatch/build_stage_report_protocol_test.go @@ -1,6 +1,5 @@ -// ABOUTME: AC-1 — the Pi First-action block no longer overclaims the full -// ABOUTME: ensign discipline; the stage-report format is attributed to the body -// ABOUTME: and the rest to the ensign skill. +// 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 ( @@ -10,11 +9,7 @@ import ( "testing" ) -// 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) { +func TestPiFirstActionInvokesEnsignSkill(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) worktreeRel := ".worktrees/spacedock-ensign-first-action" @@ -41,23 +36,33 @@ func TestBuildPiFirstActionNarrowedToStageReportFormat(t *testing.T) { } body := readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) - // The overclaim is gone. - for _, overclaim := range []string{ + // The false claim that the dispatch file itself carries the ensign + // discipline entry points must be gone. + for _, banned := 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) + if strings.Contains(body, banned) { + t.Fatalf("pi First-action still carries false claim %q:\n%s", banned, 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) - } + // The worker must be told to load the ensign skill before reading the + // dispatch file. + hasSkillLoad := strings.Contains(body, "/skill:ensign") || + strings.Contains(body, "skills/ensign/SKILL.md") + if !hasSkillLoad { + t.Fatalf("pi First-action missing ensign skill-load instruction (/skill:ensign or skills/ensign/SKILL.md):\n%s", body) + } + // The skill-load must come before the instruction to read the dispatch + // file, mirroring Claude and Codex. + skillIdx := strings.Index(body, "/skill:ensign") + if skillIdx < 0 { + skillIdx = strings.Index(body, "skills/ensign/SKILL.md") + } + readIdx := strings.Index(body, "read this dispatch file") + if readIdx < 0 { + t.Fatalf("pi First-action missing 'read this dispatch file' instruction:\n%s", body) + } + if skillIdx >= readIdx { + t.Fatalf("pi First-action: ensign skill-load must precede 'read this dispatch file' (skillIdx=%d readIdx=%d):\n%s", skillIdx, readIdx, body) } }