Skip to content

Commit 8be4cc4

Browse files
clkaoSpike Test
andauthored
dispatch: add --advance mode to dispatch build for reuse-advance messages (#464)
Advancing a reused worker was the last dispatch-shaped message the FO still hand-assembled: copying the next stage's full README subsection verbatim into a SendMessage. `--advance` shares build's machinery (flag/file plumbing, validation, split-root guidance, fetch-command emission, dispatch-file write) and swaps in an advance header, continue-on-entity line, next-stage completion-signal wording, and a spawn-field-free envelope, cutting the per-advance payload from O(stage-section) to a fixed ~O(pointer) size. Updates fo-dispatch-core.md, claude-fo-dispatch.md, ensign-shared-core.md, and codex-first-officer-runtime.md so the reuse-advance path routes through the helper, with the old hand-assembled template demoted to a break-glass fallback. Co-authored-by: Spike Test <spike@example.com>
1 parent f7cbe21 commit 8be4cc4

14 files changed

Lines changed: 1054 additions & 30 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// ABOUTME: AC-3 structural guard — the reuse-advance path routes through
2+
// ABOUTME: `dispatch build --advance`, not a hand-assembled verbatim-section message.
3+
package contractlint
4+
5+
import (
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
var (
12+
foDispatchCoreRel = filepath.Join("skills", "first-officer", "references", "fo-dispatch-core.md")
13+
claudeFODispatchRel = filepath.Join("skills", "first-officer", "references", "claude-fo-dispatch.md")
14+
ensignSharedCoreRel = filepath.Join("skills", "ensign", "references", "ensign-shared-core.md")
15+
)
16+
17+
// TestFODispatchCoreReuseNamesAdvanceHelper is AC-3's first anchor:
18+
// fo-dispatch-core.md's "If reuse" step routes through `«dispatch.build»`
19+
// `--advance` mode, and the retired claim that the reuse path bypasses the
20+
// helper entirely is gone (structural absence — a stale claim left in place
21+
// would contradict the routing this task ships).
22+
func TestFODispatchCoreReuseNamesAdvanceHelper(t *testing.T) {
23+
text := readRepoFile(t, foDispatchCoreRel)
24+
25+
section := markdownSectionFromText(t, text, "## Reuse and Fresh Dispatch")
26+
if !strings.Contains(section, "--advance") {
27+
t.Errorf("%s `## Reuse and Fresh Dispatch` does not name --advance — the reuse path must route through `dispatch build --advance`", foDispatchCoreRel)
28+
}
29+
if !strings.Contains(section, "«dispatch.build»") {
30+
t.Errorf("%s `## Reuse and Fresh Dispatch` does not name «dispatch.build» in its \"If reuse\" step", foDispatchCoreRel)
31+
}
32+
33+
retired := "does NOT route through `«dispatch.build»` — assemble the advancement message directly"
34+
if strings.Contains(text, retired) {
35+
t.Errorf("%s still carries the retired claim %q — the reuse path routes through the helper now", foDispatchCoreRel, retired)
36+
}
37+
retiredClosingLine := "the reuse-advance path assembles its message directly"
38+
if strings.Contains(text, retiredClosingLine) {
39+
t.Errorf("%s still carries the retired closing-line claim %q", foDispatchCoreRel, retiredClosingLine)
40+
}
41+
}
42+
43+
// TestClaudeFODispatchReuseAdvanceRoutesThroughHelper is AC-3's second anchor:
44+
// claude-fo-dispatch.md's reuse-advance handle is `SendMessage(to={handle},
45+
// message=output.prompt)` — the helper's emitted pointer, forwarded verbatim —
46+
// with the old hand-assembled verbatim-stage-section template demoted to a
47+
// break-glass fallback (retained, not deleted, but no longer the live path).
48+
func TestClaudeFODispatchReuseAdvanceRoutesThroughHelper(t *testing.T) {
49+
text := readRepoFile(t, claudeFODispatchRel)
50+
51+
if !strings.Contains(text, `SendMessage(to="{live worker handle from session roster}", message=output.prompt)`) {
52+
t.Errorf("%s missing the reuse-advance handle's live call shape: SendMessage(to={handle}, message=output.prompt)", claudeFODispatchRel)
53+
}
54+
55+
breakGlassHeading := "**Break-glass reuse-advance"
56+
breakGlassIdx := strings.Index(text, breakGlassHeading)
57+
if breakGlassIdx < 0 {
58+
t.Fatalf("%s missing the %q break-glass heading for the reuse-advance fallback", claudeFODispatchRel, breakGlassHeading)
59+
}
60+
61+
verbatimTemplateMarker := "[STAGE_DEFINITION — copy the full ### stage subsection from the README verbatim]"
62+
verbatimIdx := strings.Index(text, verbatimTemplateMarker)
63+
if verbatimIdx < 0 {
64+
t.Errorf("%s no longer carries the verbatim-section template at all — it must be RETAINED as the break-glass fallback, not deleted", claudeFODispatchRel)
65+
}
66+
if verbatimIdx >= 0 && verbatimIdx < breakGlassIdx {
67+
t.Errorf("%s carries the verbatim-section template BEFORE the break-glass heading — it must live under break-glass, not the live reuse-advance path", claudeFODispatchRel)
68+
}
69+
70+
// The reuse-advance handle paragraph (before break-glass) must be the one
71+
// naming the live call shape, not the verbatim template.
72+
liveSection := text[:breakGlassIdx]
73+
if !strings.Contains(liveSection, "message=output.prompt") {
74+
t.Errorf("%s live reuse-advance handle section (before break-glass) does not route through output.prompt", claudeFODispatchRel)
75+
}
76+
if strings.Contains(liveSection, verbatimTemplateMarker) {
77+
t.Errorf("%s live reuse-advance handle section (before break-glass) still inlines the verbatim-section template", claudeFODispatchRel)
78+
}
79+
}
80+
81+
// TestEnsignSharedCoreCarriesAdvanceBootstrapClause is AC-3's third anchor:
82+
// ensign-shared-core.md's DISPATCH_FILE Bootstrap section gains the mid-session
83+
// advance clause (Read the pointed file on an "Advancing to next stage:"
84+
// message) with the same DISPATCH_FILE_MISSING failure shape as the initial
85+
// bootstrap.
86+
func TestEnsignSharedCoreCarriesAdvanceBootstrapClause(t *testing.T) {
87+
text := readRepoFile(t, ensignSharedCoreRel)
88+
89+
section := markdownSectionFromText(t, text, "## DISPATCH_FILE Bootstrap")
90+
if !strings.Contains(section, "Advancing to next stage:") {
91+
t.Errorf("%s `## DISPATCH_FILE Bootstrap` does not name the mid-session advance pointer shape (\"Advancing to next stage:\")", ensignSharedCoreRel)
92+
}
93+
if !strings.Contains(section, "next-stage assignment") {
94+
t.Errorf("%s `## DISPATCH_FILE Bootstrap` does not name the next-stage assignment the advance pointer resolves to", ensignSharedCoreRel)
95+
}
96+
if strings.Count(section, "DISPATCH_FILE_MISSING") < 2 {
97+
t.Errorf("%s `## DISPATCH_FILE Bootstrap` must carry the DISPATCH_FILE_MISSING failure shape for BOTH the initial and advance bootstrap paths", ensignSharedCoreRel)
98+
}
99+
}

internal/dispatch/build.go

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ type buildOutput struct {
8383
RunInBackground *bool `json:"run_in_background,omitempty"`
8484
}
8585

86+
// buildAdvanceOutput is the stdout JSON envelope for `--advance` mode: a pointer
87+
// message for the reuse-advance handle, not a spawn envelope. It carries no
88+
// subagent_type/name/team_name/run_in_background — nothing is spawned, so those
89+
// spawn-only fields are absent from the type entirely (not merely omitempty).
90+
// model stays so the FO's reuse-condition-4 comparator can read
91+
// next_stage.effective_model from this output instead of a separate README read.
92+
type buildAdvanceOutput struct {
93+
SchemaVersion int `json:"schema_version"`
94+
Description string `json:"description"`
95+
FetchCommands []string `json:"fetch_commands"`
96+
DispatchFile string `json:"dispatch_file_path"`
97+
Prompt string `json:"prompt"`
98+
Model *string `json:"model"`
99+
}
100+
86101
// buildError prints `error: {msg}` to stderr and returns code (1 by default).
87102
func buildError(stderr io.Writer, code int, format string, a ...any) int {
88103
fmt.Fprintf(stderr, "error: "+format+"\n", a...)
@@ -168,6 +183,7 @@ func fieldsFromBuildFlags(opts buildOptions, stderr io.Writer) (map[string]json.
168183
"stage": rawJSON(opts.Stage),
169184
"checklist": rawJSON(checklist),
170185
"bare_mode": rawJSON(opts.BareMode),
186+
"advance": rawJSON(opts.Advance),
171187
}
172188
if opts.TeamName != "" {
173189
fields["team_name"] = rawJSON(opts.TeamName)
@@ -281,6 +297,13 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
281297
}
282298
bareMode := optBool(fields, "bare_mode")
283299
isFeedbackReflow := optBool(fields, "is_feedback_reflow")
300+
advance := optBool(fields, "advance")
301+
302+
// Rule 13: --advance excludes bare mode. A reuse advance presupposes an
303+
// addressable live worker to message; bare mode dispatches nothing addressable.
304+
if advance && bareMode {
305+
return buildError(stderr, 2, "--advance is incompatible with bare_mode (a reuse advance presupposes an addressable worker; bare mode has none)")
306+
}
284307

285308
// Merged mode is the Claude .178+ team shape: a non-bare claude dispatch with
286309
// no team_name. On .178+ TeamCreate/TeamDelete are gone, so team membership is
@@ -526,11 +549,16 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
526549
// --- Prompt assembly ---
527550
var parts []string
528551

529-
// 0. Operating-contract first-action directive.
530-
parts = append(parts, firstActionBlock(host))
531-
532-
// 1. Header.
533-
parts = append(parts, fmt.Sprintf("You are working on: %s\n\nStage: %s\n", entityTitle, stage))
552+
// 0-1. Operating-contract first-action directive + header. Advance mode
553+
// skips both: the reused worker already holds its operating contract from
554+
// initial dispatch, so the file opens with an advance header instead.
555+
if advance {
556+
parts = append(parts, fmt.Sprintf(
557+
"## Advancing to next stage: %s\n\nYou are continuing work on: %s\n", stage, entityTitle))
558+
} else {
559+
parts = append(parts, firstActionBlock(host))
560+
parts = append(parts, fmt.Sprintf("You are working on: %s\n\nStage: %s\n", entityTitle, stage))
561+
}
534562

535563
// 2. Stage definition — replaced by the show-stage-def fetch line. The native
536564
// fetch line targets `spacedock dispatch show-stage-def` so the dispatch path
@@ -573,14 +601,27 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
573601

574602
// 4. Entity-read instruction. Under split root the entity lives in the state
575603
// checkout; a non-split worktree stage rewrites the path into the worktree.
604+
// Advance mode replaces the "Read ... for the spec" wording with a
605+
// continue-on-entity instruction — the worker already knows the entity, it is
606+
// resuming work on it — using the identical path resolution as fresh dispatch.
576607
if worktreePath != "" && !splitRoot {
577608
entityRel := pyRelpath(entityPath, gitRoot)
578609
worktreeEntityPath = status.PyJoin(worktreePath, entityRel)
579-
parts = append(parts, fmt.Sprintf(
580-
"Read the entity file at %s for the full spec. It contains:\n", worktreeEntityPath))
610+
if advance {
611+
parts = append(parts, fmt.Sprintf(
612+
"Continue working on the entity at %s.\n", worktreeEntityPath))
613+
} else {
614+
parts = append(parts, fmt.Sprintf(
615+
"Read the entity file at %s for the full spec. It contains:\n", worktreeEntityPath))
616+
}
581617
} else {
582-
parts = append(parts, fmt.Sprintf(
583-
"Read the entity file at %s for the current spec.\n", entityPath))
618+
if advance {
619+
parts = append(parts, fmt.Sprintf(
620+
"Continue working on the entity at %s.\n", entityPath))
621+
} else {
622+
parts = append(parts, fmt.Sprintf(
623+
"Read the entity file at %s for the current spec.\n", entityPath))
624+
}
584625
}
585626

586627
// 6. Feedback context (conditional).
@@ -672,6 +713,12 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
672713
dispatchFileName = sessionToken + "-" + derivedName
673714
}
674715
}
716+
if advance {
717+
// -advance suffix so an advance file can never alias a fresh-dispatch file
718+
// for the same slug+stage: a fresh dispatch after a failed advance would
719+
// otherwise collide with the stale advance body at the bare derivedName path.
720+
dispatchFileName += "-advance"
721+
}
675722
if len(dispatchFileName) > dispatchFileNameMaxLen {
676723
return buildError(stderr, 1,
677724
"dispatch filename '%s' exceeds %d characters", dispatchFileName, dispatchFileNameMaxLen)
@@ -686,7 +733,39 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
686733
return 1
687734
}
688735

689-
prompt := dispatchPointerPrompt(host, dispatchFilePath)
736+
var prompt string
737+
if advance {
738+
prompt = dispatchAdvancePointerPrompt(stage, dispatchFilePath)
739+
} else {
740+
prompt = dispatchPointerPrompt(host, dispatchFilePath)
741+
}
742+
743+
// Foot-gun guard: a non-bare claude dispatch that passes team_name selects the
744+
// legacy TeamCreate-registry envelope just assembled above (team_name present,
745+
// run_in_background absent) rather than the auto-team default — the teamName != ""
746+
// complement of mergedMode, the same three signals, no new detection and no
747+
// probe. The advisory fires here, after every error guard, so it warns only when
748+
// the legacy envelope is actually being emitted; a build that errors out (no
749+
// envelope) stays advisory-free. Stderr-only: the envelope above is untouched.
750+
// The text lives in the Claude seam beside BareModeAdvisory.
751+
if !bareMode && host == "claude" && teamName != "" {
752+
claudeteam.LegacyTeamNameAdvisory(stderr)
753+
}
754+
755+
if advance {
756+
// Nothing is spawned, so the envelope carries no subagent_type/name/
757+
// team_name/run_in_background — only the pointer message and the fields
758+
// the FO's reuse-condition-4 comparator still needs (model).
759+
outAdvance := buildAdvanceOutput{
760+
SchemaVersion: schemaVersion,
761+
Description: fmt.Sprintf("%s: %s", entityTitle, stage),
762+
FetchCommands: fetchCommands,
763+
DispatchFile: dispatchFilePath,
764+
Prompt: prompt,
765+
Model: effectiveModel,
766+
}
767+
return emitBuildJSON(stdout, outAdvance)
768+
}
690769

691770
out := buildOutput{
692771
SchemaVersion: schemaVersion,
@@ -711,18 +790,6 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m
711790
out.RunInBackground = &runInBackground
712791
}
713792

714-
// Foot-gun guard: a non-bare claude dispatch that passes team_name selects the
715-
// legacy TeamCreate-registry envelope just assembled above (team_name present,
716-
// run_in_background absent) rather than the auto-team default — the teamName != ""
717-
// complement of mergedMode, the same three signals, no new detection and no
718-
// probe. The advisory fires here, after every error guard, so it warns only when
719-
// the legacy envelope is actually being emitted; a build that errors out (no
720-
// envelope) stays advisory-free. Stderr-only: the envelope above is untouched.
721-
// The text lives in the Claude seam beside BareModeAdvisory.
722-
if !bareMode && host == "claude" && teamName != "" {
723-
claudeteam.LegacyTeamNameAdvisory(stderr)
724-
}
725-
726793
return emitBuildJSON(stdout, out)
727794
}
728795

@@ -887,6 +954,18 @@ func dispatchPointerPrompt(host, dispatchFilePath string) string {
887954
dispatchFilePath)
888955
}
889956

957+
// dispatchAdvancePointerPrompt is the reuse-advance pointer message sent to a
958+
// live worker in place of the hand-assembled verbatim-stage-section template.
959+
// Unlike dispatchPointerPrompt, the wording is host-uniform: a reused worker
960+
// already holds its operating contract from initial dispatch (Claude's
961+
// Skill(...) invocation included), so no host branches on a skill-wrapper
962+
// clause here.
963+
func dispatchAdvancePointerPrompt(stage, dispatchFilePath string) string {
964+
return fmt.Sprintf(
965+
"Advancing to next stage: %s.\n\nRead %s and treat its content as your next-stage assignment.",
966+
stage, dispatchFilePath)
967+
}
968+
890969
// stateHasOrigin reports whether the state checkout has a named `origin` remote,
891970
// the named-remote question the split-root sync contract pushes/pulls against —
892971
// true iff `git remote get-url origin` exits 0. Network-free (unlike ls-remote)
@@ -951,7 +1030,8 @@ func stateCommitGuidance(stateCheckout, entityPath, stateBranch string, hasOrigi
9511030
// emitBuildJSON writes out as two-space-indented JSON with a trailing newline,
9521031
// matching Python json.dumps(indent=2) followed by print() byte-for-byte,
9531032
// including its ensure_ascii escaping of any non-ASCII entity title / prompt.
954-
func emitBuildJSON(stdout io.Writer, out buildOutput) int {
1033+
// out is buildOutput for a spawn envelope or buildAdvanceOutput for --advance.
1034+
func emitBuildJSON(stdout io.Writer, out any) int {
9551035
return claudeteam.EmitPythonJSON(stdout, out)
9561036
}
9571037

@@ -994,6 +1074,9 @@ func emitBuildSchema(stdout io.Writer) int {
9941074
"is_feedback_reflow": map[string]any{
9951075
"type": "boolean",
9961076
},
1077+
"advance": map[string]any{
1078+
"type": "boolean",
1079+
},
9971080
"host": map[string]any{
9981081
"type": "string",
9991082
"enum": []string{"claude", "codex", "pi"},

0 commit comments

Comments
 (0)