From 740d528fb73426bc75f72094c2abbf82b93cf5c8 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Thu, 27 Aug 2026 00:41:22 -0700 Subject: [PATCH 1/2] dispatch: remove stageReportFormatBlock embed and body-asserts fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the Pi-only stageReportFormatBlock() embed and its step-8a conditional from the dispatch body. The body no longer carries the stage-report protocol template; a stacked follow-up layer rewrites the Pi firstActionBlock to invoke the ensign skill instead. Remove the body-presence assertions that depended on the embed: - TestBuildPiArtifactCarriesStageReportProtocol (internal/dispatch) - TestPiNonSelfDescribingDispatchBuildBodyCarriesProtocol (internal/ensigncycle) Keep TestBuildPiFirstActionNarrowedToStageReportFormat (overclaim gone), the live lane (TestLivePiNonSelfDescribingDispatch), and the CI wiring. The live lane will fail in pi-live until the follow-up layer rewrites firstActionBlock to invoke the skill — that is the point (the lane proves the fix). --- internal/dispatch/build.go | 26 ------ .../build_stage_report_protocol_test.go | 79 +------------------ .../pi_nonself_describing_build_test.go | 72 ----------------- 3 files changed, 3 insertions(+), 174 deletions(-) diff --git a/internal/dispatch/build.go b/internal/dispatch/build.go index 5ec6bf8d7..4e3b721aa 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -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 @@ -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" + diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go index 7d3d10be8..5b7134e8f 100644 --- a/internal/dispatch/build_stage_report_protocol_test.go +++ b/internal/dispatch/build_stage_report_protocol_test.go @@ -1,5 +1,6 @@ -// 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. +// 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. package dispatch import ( @@ -9,80 +10,6 @@ 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 diff --git a/internal/ensigncycle/pi_nonself_describing_build_test.go b/internal/ensigncycle/pi_nonself_describing_build_test.go index 0ec846e5f..ca6c06c78 100644 --- a/internal/ensigncycle/pi_nonself_describing_build_test.go +++ b/internal/ensigncycle/pi_nonself_describing_build_test.go @@ -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 From 4690b53d5564e42126b17a37a800bd6dedd14e7e Mon Sep 17 00:00:00 2001 From: CL Kao Date: Thu, 27 Aug 2026 00:38:08 -0700 Subject: [PATCH 2/2] dispatch: Pi firstActionBlock loads ensign skill before dispatch file Rewrite the Pi firstActionBlock to direct the worker to load the ensign discipline BEFORE reading the dispatch file, mirroring Claude's Skill(skill="spacedock:ensign") and Codex's $spacedock:ensign bootstrap. The worker is told to run /skill:ensign (Pi's skill-invoke slash command) or fall back to reading skills/ensign/SKILL.md and its references/, then read the dispatch file for the stage-specific assignment. This drops the false claim that the dispatch file itself contains the ensign discipline entry points (the skill is in available_skills but the worker must be told to load it). Also revert the comment-only piSpawnSkill loader-version pin from the prior (wrong-scope) commit; piSpawnSkill="ensign" is unchanged. Add offline guard TestPiFirstActionInvokesEnsignSkill asserting the firstActionBlock contains a skill-load instruction (/skill:ensign or skills/ensign/SKILL.md), does not carry the false claim, and loads the skill before the read-dispatch-file instruction. Update two sibling tests whose body assertions referenced the old firstActionBlock phrase. --- internal/dispatch/build.go | 16 +++--- .../dispatch/build_json_ergonomics_test.go | 2 +- internal/dispatch/build_pi_host_test.go | 3 +- .../build_stage_report_protocol_test.go | 49 ++++++++++--------- 4 files changed, 40 insertions(+), 30 deletions(-) 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) } }