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
42 changes: 10 additions & 32 deletions internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,15 +673,6 @@ 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 @@ -890,23 +881,6 @@ 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 @@ -922,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
118 changes: 25 additions & 93 deletions internal/dispatch/build_stage_report_protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,85 +9,7 @@ import (
"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) {
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 @@ -114,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)
}
}
72 changes: 0 additions & 72 deletions internal/ensigncycle/pi_nonself_describing_build_test.go
Original file line number Diff line number Diff line change
@@ -1,82 +1,10 @@
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
Expand Down