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
2 changes: 1 addition & 1 deletion .github/workflows/runtime-live-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions docs/runtime-live-ci-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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)
}
})
}
}
31 changes: 29 additions & 2 deletions internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" +
Expand All @@ -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"
}
Expand Down
136 changes: 136 additions & 0 deletions internal/dispatch/build_stage_report_protocol_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading