Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion internal/cli/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>, -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(<absolute>) hands Node a script
// path fnm never tears down. On any miss/failure argv[0] stays "pi" (current
Expand Down
70 changes: 70 additions & 0 deletions internal/cli/pi_frontdoor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>, -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)
}
})
}
}
16 changes: 10 additions & 6 deletions internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
2 changes: 1 addition & 1 deletion internal/dispatch/build_json_ergonomics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/dispatch/build_pi_host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
} {
Expand Down
49 changes: 27 additions & 22 deletions internal/dispatch/build_stage_report_protocol_test.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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"
Expand All @@ -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)
}
}