From 0b41577cd91e94737e2b192480bea7a6cb6eea6b Mon Sep 17 00:00:00 2001 From: CL Kao Date: Tue, 21 Jul 2026 21:39:21 +0800 Subject: [PATCH] =?UTF-8?q?Core=20contract=20claims=20bind=20to=20=C2=ABfn?= =?UTF-8?q?=C2=BB=20per=20adapter:=20containment=20lint=20+=20per-host=20b?= =?UTF-8?q?udget=20+=20relocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three deliverables (j8s4): 1. Containment lint (AC-1): internal/contractlint/host_neutral_core_test.go asserts the five shared/host-neutral contract files carry no host name (claude|codex|pi|opus|sonnet|haiku|fable, word-bounded) and no host tool token (Claude vocabulary + the existing Codex/Pi vocabularies), with a discriminator control. Bare `acceptance` excluded with grounds (core workflow vocabulary; the Pi field token stays adapter-contained). 2. Per-host budget (AC-5): the sum-of-13 foPromptMetrics ratchet is replaced by per-host load sets (shared cores + host adapters + host-reachable trigger skills) with per-host baseline constants, so a one-host regression reds its own ratchet; the checkpoint logs all three loads and the mutable-address lint keeps the 13-file union (sync-checked). 3. Relocation + reconciliation (AC-2/AC-3): the six per-host arrow lines in fo-dispatch-core.md become kind-only runtime-binding pointers (runtimeBoundCapabilities +6); non-redundant residue moves to the adapters (Pi: interrupt-steering, cwd-on-every-call, advisory detail, model-space ownership; Claude: async binding, completion-signal tag, staleness caveat, ensign background-task surface + failure channel). The Fan-out checkpoint gains the host-neutral authoring rule binding a fan-out's shape to this host's «async-dispatch»/«addressable-worker» bindings. Two core/binding conflicts (wait_agent foreground-vs-idle, roster-reconcile ABSENT-vs-list_agents) dissolve by adapter ownership; the g6 idempotency conflict stays owned by g6. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../contractlint/capability_binding_test.go | 19 ++- .../fo_function_reference_invariant_test.go | 133 ++++++++++++++-- .../contractlint/host_neutral_core_test.go | 144 ++++++++++++++++++ internal/dispatch/build.go | 2 +- .../testdata/golden/build-abs-worktree.txt | 2 +- ...ild-crossproduct-feedback+scope+reflow.txt | 2 +- ...ssproduct-single+flat+nonworktree+bare.txt | 2 +- ...ssproduct-single+flat+nonworktree+team.txt | 2 +- ...crossproduct-single+flat+worktree+team.txt | 2 +- ...ossproduct-single+folder+worktree+team.txt | 2 +- ...-crossproduct-split+flat+worktree+team.txt | 2 +- ...sproduct-split+folder+nonworktree+team.txt | 2 +- ...rossproduct-split+folder+worktree+team.txt | 2 +- .../testdata/golden/build-html-escape.txt | 2 +- .../dispatch/testdata/golden/build-mods.txt | 2 +- .../testdata/golden/build-nonascii-title.txt | 2 +- .../testdata/golden/build-space-path.txt | 2 +- .../references/claude-ensign-runtime.md | 6 + .../ensign/references/ensign-shared-core.md | 16 +- .../claude-first-officer-runtime.md | 2 +- .../references/claude-fo-dispatch.md | 4 +- .../references/first-officer-shared-core.md | 9 +- .../references/fo-dispatch-core.md | 30 ++-- .../references/pi-first-officer-runtime.md | 8 +- skills/fo-dispatch-recovery/SKILL.md | 2 +- 25 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 internal/contractlint/host_neutral_core_test.go diff --git a/internal/contractlint/capability_binding_test.go b/internal/contractlint/capability_binding_test.go index b0ecd0031..12b67e619 100644 --- a/internal/contractlint/capability_binding_test.go +++ b/internal/contractlint/capability_binding_test.go @@ -1,6 +1,6 @@ // ABOUTME: AC-1 — binds the capability «fn» layer of the host-neutral dispatch core // ABOUTME: across two divergeable surfaces: «fn» DEFINITIONS (## «name») and body CALLS -// ABOUTME: («name»), plus legacy per-host → coverage where the core still owns it. +// ABOUTME: («name»), holding every runtime-bound block to the host-neutral arrow policy. package contractlint import ( @@ -17,9 +17,9 @@ func dispatchCorePath(t *testing.T) string { return filepath.Join(skillsRoot(t), "first-officer", "references", "fo-dispatch-core.md") } -// capabilityHosts: legacy core → lines still present before the runtime-binding-block -// migration MUST name all three. New runtime-bound capabilities are host-neutral here and -// are bound from runtime adapter `## Runtime implementation` blocks instead. +// capabilityHosts: the runtime hosts the adapters bind. A legacy core → line that +// names hosts MUST name all three; runtime-bound capabilities are host-neutral in the +// core and are bound from runtime adapter `## Runtime implementation` blocks instead. var capabilityHosts = []string{"Claude", "Codex", "Pi"} // fnHeadingRe: a capability «fn» DEFINITION heading `## «name»:` (the colon distinguishes @@ -42,7 +42,16 @@ var ( // runtimeBoundCapabilities are the capabilities whose per-host binding is delegated to the // runtime adapters' `## Runtime implementation` blocks via a kind-only `→ **runtime-binding**` // arrow in the core; they carry no per-host `→` coverage in fo-dispatch-core.md itself. -var runtimeBoundCapabilities = []string{"worker.spawn", "worker.shutdown"} +var runtimeBoundCapabilities = []string{ + "worker.spawn", + "worker.shutdown", + "addressable-worker", + "async-dispatch", + "worker-identity", + "completion-signal", + "context-budget", + "roster-reconcile", +} func isRuntimeBoundCapability(name string) bool { for _, c := range runtimeBoundCapabilities { diff --git a/internal/contractlint/fo_function_reference_invariant_test.go b/internal/contractlint/fo_function_reference_invariant_test.go index bb4982743..2f310177e 100644 --- a/internal/contractlint/fo_function_reference_invariant_test.go +++ b/internal/contractlint/fo_function_reference_invariant_test.go @@ -7,13 +7,15 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "testing" ) -const foFunctionReferenceBaselineBytes = 123323 - +// foFunctionReferencePaths is the 13-file union the mutable-address lint scans. The +// BUDGET is not this union: no single FO ever loads all three host adapters, so the +// byte ratchet below is per-host over foSharedLoadPaths + foHostLoadPaths. var foFunctionReferencePaths = []string{ "skills/first-officer/SKILL.md", "skills/first-officer/references/first-officer-shared-core.md", @@ -30,6 +32,47 @@ var foFunctionReferencePaths = []string{ "skills/fo-dispatch-recovery/SKILL.md", } +// foSharedLoadPaths is the load every host's FO pulls: the skill entry, the +// boot-resident shared core, the three deferred cores, and the trigger skills +// reachable on every host (present-gate, feedback-rejection-flow). +var foSharedLoadPaths = []string{ + "skills/first-officer/SKILL.md", + "skills/first-officer/references/first-officer-shared-core.md", + "skills/first-officer/references/fo-dispatch-core.md", + "skills/first-officer/references/fo-merge-core.md", + "skills/first-officer/references/fo-write-core.md", + "skills/present-gate/SKILL.md", + "skills/feedback-rejection-flow/SKILL.md", +} + +// foHostLoadPaths adds each host's adapter file(s) and the trigger skills reachable +// only on that host (using-legacy-claude-team and fo-dispatch-recovery are named +// only from the Claude dispatch module). load(H) = foSharedLoadPaths + this. +var foHostLoadPaths = map[string][]string{ + "claude": { + "skills/first-officer/references/claude-first-officer-runtime.md", + "skills/first-officer/references/claude-fo-dispatch.md", + "skills/using-legacy-claude-team/SKILL.md", + "skills/fo-dispatch-recovery/SKILL.md", + }, + "codex": { + "skills/first-officer/references/codex-first-officer-runtime.md", + }, + "pi": { + "skills/first-officer/references/pi-first-officer-runtime.md", + }, +} + +// foHostLoadBaselineBytes ratchets each host's real session load against its own +// measured baseline — per-host, not max-only, so a one-host regression cannot hide +// under another host's headroom. Growing a host's load past its constant is a +// deliberate re-baseline edit here, with the growth justified in the change. +var foHostLoadBaselineBytes = map[string]int{ + "claude": 111183, + "codex": 74608, + "pi": 70725, +} + var mutableProcedureAddress = regexp.MustCompile(`(?i)(?:\bsteps?[- ]\d+(?:\.\d+)?(?:\s*(?:-|–|to)\s*\d+(?:\.\d+)?)?|\breuse[- ]conditions?[- ]?\d+|\btiers?[- ]\d+|\btiers?\s+\d+(?:\s+and\s+\d+)?|\bentry-point principle\s+\d+|\b(?:signals?|items?)\s*\(\d+(?:\s*,\s*\d+)*(?:\s*,?\s*or\s*\d+)?\s+above\))`) type foAddressMatch struct { @@ -48,14 +91,32 @@ func mutableFOAddresses(path, body string) []foAddressMatch { return out } -func foPromptMetrics(t *testing.T) (addresses int, bytes int) { +// foHostLoadBytes measures one host's real FO session load: the shared load plus +// that host's adapter files and host-only trigger skills. +func foHostLoadBytes(t *testing.T, host string) int { t.Helper() - for _, rel := range foFunctionReferencePaths { - body := readRepoFile(t, filepath.FromSlash(rel)) - addresses += len(mutableFOAddresses(rel, body)) - bytes += len([]byte(body)) + total := 0 + for _, rel := range append(append([]string{}, foSharedLoadPaths...), foHostLoadPaths[host]...) { + total += len([]byte(readRepoFile(t, filepath.FromSlash(rel)))) } - return addresses, bytes + return total +} + +// hostBudgetViolations compares each host's measured load to its ratchet constant. +// The real ratchet and its discriminator control both drive this one function. +func hostBudgetViolations(loads, baselines map[string]int) []string { + hosts := make([]string, 0, len(baselines)) + for host := range baselines { + hosts = append(hosts, host) + } + sort.Strings(hosts) + var out []string + for _, host := range hosts { + if loads[host] > baselines[host] { + out = append(out, fmt.Sprintf("%s FO session load = %d bytes, above its ratchet baseline %d — a %s-load regression; shrink the load or deliberately re-baseline", host, loads[host], baselines[host], host)) + } + } + return out } func TestFOFunctionReferenceInvariant(t *testing.T) { @@ -101,16 +162,58 @@ func TestFOFunctionReferenceClassifierDiscriminates(t *testing.T) { } } -func TestFOFunctionPromptSurfaceShrinks(t *testing.T) { - _, got := foPromptMetrics(t) - if got >= foFunctionReferenceBaselineBytes { - t.Fatalf("FO prompt surface = %d bytes, want strictly below post-#531 baseline %d", got, foFunctionReferenceBaselineBytes) +// TestFOHostPromptLoadRatchet (per-host budget): each host's measured session load +// stays at or below its own baseline constant. A regression in a host-specific file +// reds only that host's ratchet — the discrimination a summed budget cannot see. +func TestFOHostPromptLoadRatchet(t *testing.T) { + loads := map[string]int{} + for host := range foHostLoadBaselineBytes { + loads[host] = foHostLoadBytes(t, host) + } + for _, msg := range hostBudgetViolations(loads, foHostLoadBaselineBytes) { + t.Error(msg) + } +} + +// TestFOHostPromptLoadRatchetDiscriminates is the non-vacuity control: at-baseline +// loads pass; a one-byte regression in a single host's load reds exactly that +// host's ratchet while the others stay green. +func TestFOHostPromptLoadRatchetDiscriminates(t *testing.T) { + baselines := map[string]int{"claude": 100, "codex": 90, "pi": 80} + if v := hostBudgetViolations(map[string]int{"claude": 100, "codex": 90, "pi": 80}, baselines); len(v) != 0 { + t.Fatalf("control: at-baseline loads were wrongly flagged: %v", v) + } + v := hostBudgetViolations(map[string]int{"claude": 100, "codex": 91, "pi": 80}, baselines) + if len(v) != 1 || !strings.Contains(v[0], "codex") { + t.Fatalf("control: a codex-only one-byte regression must red exactly the codex ratchet, got: %v", v) + } +} + +// TestFOHostLoadSetsCoverAddressLintUnion keeps the two scopes in sync: the union of +// the shared load and every host's load equals the 13-file set the mutable-address +// lint scans, so neither list can drop or gain a file without the other noticing. +func TestFOHostLoadSetsCoverAddressLintUnion(t *testing.T) { + union := map[string]bool{} + for _, rel := range foSharedLoadPaths { + union[rel] = true + } + for _, paths := range foHostLoadPaths { + for _, rel := range paths { + union[rel] = true + } + } + if !setEqual(union, toSet(foFunctionReferencePaths)) { + t.Fatalf("per-host load sets and the address-lint union diverged:\n loads: %v\n address: %v", sortedSet(union), sortedSet(toSet(foFunctionReferencePaths))) } } func TestFOFunctionReferenceCheckpointMetrics(t *testing.T) { - addresses, bytes := foPromptMetrics(t) - t.Logf("FO_FUNCTION_METRICS addresses=%d bytes=%d", addresses, bytes) + addresses := 0 + for _, rel := range foFunctionReferencePaths { + addresses += len(mutableFOAddresses(rel, readRepoFile(t, filepath.FromSlash(rel)))) + } + t.Logf("FO_FUNCTION_METRICS addresses=%d claude_bytes=%d codex_bytes=%d pi_bytes=%d", + addresses, foHostLoadBytes(t, "claude"), foHostLoadBytes(t, "codex"), foHostLoadBytes(t, "pi")) } func TestFirstOfficerReferenceTopology(t *testing.T) { @@ -253,7 +356,7 @@ func TestFOFunctionNormalizationPreservationSuite(t *testing.T) { {"startup-interaction", "skills/first-officer/references/first-officer-shared-core.md", []string{"## «interaction.boundary»(): route interactive and headless launch behavior", "## Startup"}, []string{"Interactive", "Headless", "given the conn", "STOP"}}, {"dispatch-checklist", "skills/first-officer/references/fo-dispatch-core.md", []string{"## «dispatch.checklist»(entity, stage): assemble dispatch linchpins", "## Dispatch"}, []string{"≤3", "Outputs:", "acceptance criteria", "not stage actions"}}, {"reuse", "skills/first-officer/references/fo-dispatch-core.md", []string{"## Reuse and Fresh Dispatch"}, []string{"«context-budget»()", "«addressable-worker»", "fresh: true", "«reuse.model-match»"}}, - {"completion", "skills/first-officer/references/fo-dispatch-core.md", []string{"## «completion-signal»: the signals that trigger the completion-verify path"}, []string{"Claude", "Codex", "Pi"}}, + {"completion", "skills/first-officer/references/fo-dispatch-core.md", []string{"## «completion-signal»: the signals that trigger the completion-verify path"}, []string{"runtime-binding", "stage report"}}, {"terminal-teardown", "skills/first-officer/references/fo-merge-core.md", []string{"## «merge.guard»(slug): auto-arm → block-on-open-PR → finalize-on-merge-sentinel, then archive"}, []string{"teardown", "best-effort", "drop them from session memory"}}, {"legacy-recovery", "skills/using-legacy-claude-team/SKILL.md", []string{"## «legacy-team.recover»(): recover a desynchronized legacy team", "## Team Creation"}, []string{"Fresh-suffixed TeamCreate", "Degraded Mode", "Surface to captain"}}, {"hooks", "skills/first-officer/references/first-officer-shared-core.md", []string{"## «hooks.run»(point): run registered lifecycle hooks", "## Mod Hook Convention"}, []string{"startup", "idle", "merge", "alphabetically"}}, diff --git a/internal/contractlint/host_neutral_core_test.go b/internal/contractlint/host_neutral_core_test.go new file mode 100644 index 000000000..a9ae898a6 --- /dev/null +++ b/internal/contractlint/host_neutral_core_test.go @@ -0,0 +1,144 @@ +// ABOUTME: Containment lint — the five shared/host-neutral contract files name no +// ABOUTME: runtime host (word or model name) and no host-specific tool token; adapters own those. +package contractlint + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// hostNeutralSharedFiles are the shared contract files every host loads. A host name +// or host tool token here reads to the other two hosts as a universal claim their +// runtime cannot satisfy — the class both field incidents (standing-teammate +// idempotency, bare-mode streaming fan-out) belong to. Host-specific coverage lives +// in the runtime adapters' binding sections instead. +var hostNeutralSharedFiles = []string{ + "skills/first-officer/references/first-officer-shared-core.md", + "skills/first-officer/references/fo-dispatch-core.md", + "skills/first-officer/references/fo-write-core.md", + "skills/first-officer/references/fo-merge-core.md", + "skills/ensign/references/ensign-shared-core.md", +} + +// hostNeutralWordRe matches a host name or a host model name as a whole word, +// case-insensitive — the same shape as hostWordRe (capability_binding_test.go), +// widened with the Claude model enum, and derived from capabilityHosts so a new +// host is covered without a second edit. Word boundaries keep `pi` from firing on +// lookalikes (`api`, `spike`, `pickup`) — the discriminator control proves it. +var hostNeutralWordRe = regexp.MustCompile(`(?i)\b(` + + strings.Join(append(append([]string{}, capabilityHosts...), "Opus", "Sonnet", "Haiku", "Fable"), "|") + `)\b`) + +// claudeHostToolTokens are the Claude-host tool tokens banned from the shared files; +// the Codex and Pi vocabularies ride codexToolTokens / piContainmentTokens +// (runtime_binding_block_test.go), so one token list per host exists in one place. +// +// Excluded from the token set, with grounds: generic harness verbs every host +// exposes (`Bash`, `Read`, `Grep`, `Write`, `Edit`, `Skill`) and spacedock's own +// cross-host dispatch-prompt convention (`Skill(skill="spacedock:ensign")`, emitted +// by internal/dispatch/build.go for every host). +var claudeHostToolTokens = []string{ + "Agent(", + "SendMessage", + "TeamCreate", + "TeamDelete", + "task_notification", + "run_in_background", + "subagent_type", + "ToolSearch", + "BashOutput", +} + +// hostNeutralToolTokens is the union of all three hosts' tool-token vocabularies, +// deduplicated (codexToolTokens also carries the Claude SendMessage ban). The bare +// Pi token `acceptance` is excluded here with grounds: in the shared files it is +// core workflow vocabulary ("acceptance criteria"), not the `subagent(... +// acceptance: ...)` transport field; the field token stays contained to the Pi +// adapter's binding sections by TestRuntimeToolTokensStayInBindingSections. +func hostNeutralToolTokens(t *testing.T) []string { + t.Helper() + seen := map[string]bool{"acceptance": true} + var out []string + for _, tok := range append(append(append([]string{}, claudeHostToolTokens...), codexToolTokens...), piContainmentTokens(t)...) { + if !seen[tok] { + seen[tok] = true + out = append(out, tok) + } + } + return out +} + +// sharedCoreHostViolations is the single scanner the real check and its discriminator +// control both drive: per line, a host/model word (word-bounded, case-insensitive) +// or a host tool token is a violation. Existence-fact containment — it asserts where +// a name may appear, never what a host or tool DOES. +func sharedCoreHostViolations(body string, tokens []string) []string { + var out []string + for i, line := range strings.Split(body, "\n") { + if m := hostNeutralWordRe.FindString(line); m != "" { + out = append(out, fmt.Sprintf("line %d names host %q outside a runtime adapter: %q", i+1, m, strings.TrimSpace(line))) + } + for range toolTokenContainmentViolations(line, tokens) { + out = append(out, fmt.Sprintf("line %d carries a host tool token outside a runtime adapter: %q", i+1, strings.TrimSpace(line))) + } + } + return out +} + +// TestSharedContractFilesStayHostNeutral (AC-1) is the containment invariant: the +// five shared files carry no host name and no host tool token. Green requires the +// legacy per-host `→` coverage to have RELOCATED into the adapters — a suppression +// that deleted meaning instead would strand each host's binding, which the adapter +// set-equality and preservation checks red on independently. +func TestSharedContractFilesStayHostNeutral(t *testing.T) { + tokens := hostNeutralToolTokens(t) + if len(tokens) == 0 { + t.Fatal("no host tool tokens enumerated — the containment check would pass vacuously") + } + for _, rel := range hostNeutralSharedFiles { + body := readRepoFile(t, filepath.FromSlash(rel)) + if len(body) == 0 { + t.Fatalf("%s read empty — the containment check would pass vacuously", rel) + } + for _, msg := range sharedCoreHostViolations(body, tokens) { + t.Errorf("%s %s", rel, msg) + } + } +} + +// TestSharedContractHostNeutralGuardDiscriminates is the non-vacuity control: it +// drives the same sharedCoreHostViolations the real check uses. Capability-speak and +// `pi`-lookalike words PASS; a planted host word (each host, plus a model name and a +// prose-case variant) and a planted tool token from each host's vocabulary RED. +func TestSharedContractHostNeutralGuardDiscriminates(t *testing.T) { + tokens := hostNeutralToolTokens(t) + pass := []struct{ why, body string }{ + {"capability-speak prose", "Await the worker result per `«async-dispatch»`; completion is recognized via `«completion-signal»`.\n"}, + {"pi-lookalike words", "the api surface, a spike, and a pickup of the worktree\n"}, + {"acceptance-criteria domain language", "entity-level acceptance criteria this stage naturally advances\n"}, + {"kind-only runtime-binding arrow", "- → **runtime-binding**: bound in the host adapter's `## Runtime implementation`\n"}, + } + for _, c := range pass { + if v := sharedCoreHostViolations(c.body, tokens); len(v) != 0 { + t.Fatalf("control: the %s was wrongly flagged: %v", c.why, v) + } + } + red := []struct{ why, body string }{ + {"planted Claude host word", "on Claude, call the spawn tool directly\n"}, + {"planted Codex host word", "Codex blocks until the worker returns\n"}, + {"planted Pi host word", "poll the run id on Pi\n"}, + {"planted lower-case host word", "the claude enum owns the model space\n"}, + {"planted model name", "prefer opus for gate stages\n"}, + {"planted Claude tool token", "spawn via Agent(name=…) and wait\n"}, + {"planted Claude polling token", "wait via BashOutput polling\n"}, + {"planted Codex tool token", "call spawn_agent(task_name,message) for every ready entity\n"}, + {"planted Pi substrate token", "initial creation maps to member_spawn\n"}, + } + for _, c := range red { + if v := sharedCoreHostViolations(c.body, tokens); len(v) == 0 { + t.Fatalf("control: the %s was not flagged — the containment guard stopped biting", c.why) + } + } +} diff --git a/internal/dispatch/build.go b/internal/dispatch/build.go index 2f87ff88c..27e31eb6f 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -904,7 +904,7 @@ func firstActionBlock(host string) string { "\n" + " Skill(skill=\"spacedock:ensign\")\n" + "\n" + - "This loads the shared ensign discipline (stage-report format, BashOutput " + + "This loads the shared ensign discipline (stage-report format, background-task " + "polling, worktree ownership, completion signal protocol). The call is safe " + "to call more than once; if the agent-definition preload ever starts " + "working, calling it again is a no-op (the skill content is re-loaded but " + diff --git a/internal/dispatch/testdata/golden/build-abs-worktree.txt b/internal/dispatch/testdata/golden/build-abs-worktree.txt index 864eef639..dab088662 100644 --- a/internal/dispatch/testdata/golden/build-abs-worktree.txt +++ b/internal/dispatch/testdata/golden/build-abs-worktree.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-feedback+scope+reflow.txt b/internal/dispatch/testdata/golden/build-crossproduct-feedback+scope+reflow.txt index 15ada8696..e4103cdbf 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-feedback+scope+reflow.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-feedback+scope+reflow.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+bare.txt b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+bare.txt index 1569bd001..1e3527688 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+bare.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+bare.txt @@ -23,7 +23,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+team.txt index 100a31511..3de51424e 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+nonworktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+worktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+worktree+team.txt index 112594284..58e767637 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-single+flat+worktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-single+flat+worktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-single+folder+worktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-single+folder+worktree+team.txt index 0ccafde00..6d90c2edf 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-single+folder+worktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-single+folder+worktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-split+flat+worktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-split+flat+worktree+team.txt index c203933a1..66b36622f 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-split+flat+worktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-split+flat+worktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-split+folder+nonworktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-split+folder+nonworktree+team.txt index fe9c5b982..14e9bf875 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-split+folder+nonworktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-split+folder+nonworktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-crossproduct-split+folder+worktree+team.txt b/internal/dispatch/testdata/golden/build-crossproduct-split+folder+worktree+team.txt index 28d53ca65..411959d9c 100644 --- a/internal/dispatch/testdata/golden/build-crossproduct-split+folder+worktree+team.txt +++ b/internal/dispatch/testdata/golden/build-crossproduct-split+folder+worktree+team.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-html-escape.txt b/internal/dispatch/testdata/golden/build-html-escape.txt index bf6cd12c7..3812a212f 100644 --- a/internal/dispatch/testdata/golden/build-html-escape.txt +++ b/internal/dispatch/testdata/golden/build-html-escape.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Compare a < b && c > d in diff --git a/internal/dispatch/testdata/golden/build-mods.txt b/internal/dispatch/testdata/golden/build-mods.txt index 53d13a889..19c31971c 100644 --- a/internal/dispatch/testdata/golden/build-mods.txt +++ b/internal/dispatch/testdata/golden/build-mods.txt @@ -26,7 +26,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/internal/dispatch/testdata/golden/build-nonascii-title.txt b/internal/dispatch/testdata/golden/build-nonascii-title.txt index aadcb436e..3bac7f26a 100644 --- a/internal/dispatch/testdata/golden/build-nonascii-title.txt +++ b/internal/dispatch/testdata/golden/build-nonascii-title.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Segregate Claude — generic split diff --git a/internal/dispatch/testdata/golden/build-space-path.txt b/internal/dispatch/testdata/golden/build-space-path.txt index 4a585ed96..8b7ecda80 100644 --- a/internal/dispatch/testdata/golden/build-space-path.txt +++ b/internal/dispatch/testdata/golden/build-space-path.txt @@ -25,7 +25,7 @@ Before anything else, invoke your operating contract: Skill(skill="spacedock:ensign") -This loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. +This loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). The call is safe to call more than once; if the agent-definition preload ever starts working, calling it again is a no-op (the skill content is re-loaded but has no behavioral effect). Do not paraphrase; call the tool. You are working on: Thing diff --git a/skills/ensign/references/claude-ensign-runtime.md b/skills/ensign/references/claude-ensign-runtime.md index 9603faeb3..bc4795207 100644 --- a/skills/ensign/references/claude-ensign-runtime.md +++ b/skills/ensign/references/claude-ensign-runtime.md @@ -24,6 +24,12 @@ SendMessage(to="team-lead", message="Done: {entity title} completed {stage}. Rep The entity file is the artifact. Do not include the checklist or summary in the message. Plain text only. Never send JSON. +This SendMessage route is the completion-signal channel the shared core's failure signals use too — e.g. `SendMessage(to="team-lead", message="DISPATCH_FILE_MISSING: {path} - {error}")`. + +## Background Task Surface + +The shared core's Background Bash Discipline binds here to the background Bash tools: launch with `Bash(run_in_background: true)`, capture the returned `bash_id`, and poll `BashOutput(bash_id=...)`, reading `status`. On `"completed"`, read the final output and proceed. + ## Feedback Interaction For feedback stages, the FO may keep a prior-stage agent alive for messaging. If the reviewer finds issues, the FO routes fixes through a fresh dispatch — the ensign does not directly message other agents. diff --git a/skills/ensign/references/ensign-shared-core.md b/skills/ensign/references/ensign-shared-core.md index 683ad9031..1e3dbe1f9 100644 --- a/skills/ensign/references/ensign-shared-core.md +++ b/skills/ensign/references/ensign-shared-core.md @@ -53,12 +53,12 @@ When the workflow is split-root (README declares `state:` checkout, e.g. `state: ## Background Bash Discipline -When you launch `Bash(run_in_background: true)`, wait via `BashOutput` polling, not a blocking `sleep`: +When you launch a long-running command in the background, wait by polling, not a blocking `sleep` (your runtime adapter binds the concrete launch and polling calls): -1. Capture the returned `bash_id`. +1. Capture the background task's handle when you launch it. 2. Sleep briefly between polls — ~30s default; longer for many-minute tasks, shorter for sub-minute ones. -3. Call `BashOutput(bash_id=...)` and read `status`. -4. On `"completed"`, read the final output and proceed. +3. Poll the task's status through the runtime's background-task read. +4. On completion, read the final output and proceed. 5. Otherwise repeat. Cap total wait at the task's budgeted timeout; on cap, report the timeout instead of waiting indefinitely. Never wait on a background task with `sleep N && tail …`. A blocking sleep sized for the worst case wastes wallclock when the task finishes early and blocks incoming messages until it returns. Polling avoids both. @@ -98,15 +98,15 @@ When done, send a minimal completion signal that points the first officer back t ## DISPATCH_FILE Bootstrap -The FO dispatches an ensign with a tiny ~175-char `Agent(prompt=...)` of the shape: +The FO dispatches an ensign with a tiny ~175-char dispatch prompt of the shape: Skill(skill="spacedock:ensign"); then Read /tmp/spacedock-dispatch/{name}.md and treat its content as your assignment. When your initial prompt matches this pattern (the `Skill(...)` invocation followed by `Read /tmp/spacedock-dispatch/...`), your first action MUST be `Read /tmp/spacedock-dispatch/{name}.md` and treat the file's content as your inline assignment. Then proceed with the rest of the operating contract. -If the Read fails (missing, unreadable, empty), do NOT proceed with empty context. Send `SendMessage(to="team-lead", message="DISPATCH_FILE_MISSING: {path} - {error}")` and stop. +If the Read fails (missing, unreadable, empty), do NOT proceed with empty context. Send `DISPATCH_FILE_MISSING: {path} - {error}` to the first officer through your runtime adapter's completion-signal channel and stop. -**Advance bootstrap.** This covers the initial prompt only. When a mid-session message instead matches `Advancing to next stage: {stage}.` followed by `Read /tmp/spacedock-dispatch/{name}.md and treat its content as your next-stage assignment.`, Read that file and treat its content as your next-stage assignment (the fetch-commands bootstrap above applies to it identically). On Read failure, send `SendMessage(to="team-lead", message="DISPATCH_FILE_MISSING: {path} - {error}")` and stop — the same failure shape as the initial bootstrap. +**Advance bootstrap.** This covers the initial prompt only. When a mid-session message instead matches `Advancing to next stage: {stage}.` followed by `Read /tmp/spacedock-dispatch/{name}.md and treat its content as your next-stage assignment.`, Read that file and treat its content as your next-stage assignment (the fetch-commands bootstrap above applies to it identically). On Read failure, send `DISPATCH_FILE_MISSING: {path} - {error}` through the same completion-signal channel and stop — the same failure shape as the initial bootstrap. ## Fetch-on-Demand Bootstrap @@ -117,6 +117,6 @@ The FO's dispatch may carry a `### Fetch commands` section near the top of your 3. Concatenate stdouts; treat the result as inlined into your prompt at the `### Fetch commands` position. 4. Proceed with the rest of your assignment. -If a fetch command exits non-zero, report the failure to the FO via your runtime's teammate-message channel (see your runtime adapter's `## Completion Signal`). Include command, exit code, and stderr — do not silently proceed. A missing or unreadable stage definition is a dispatch-shape failure the FO must surface to the captain. +If a fetch command exits non-zero, report the failure to the FO via your runtime adapter's completion-signal channel. Include command, exit code, and stderr — do not silently proceed. A missing or unreadable stage definition is a dispatch-shape failure the FO must surface to the captain. If the prompt has no `### Fetch commands` block, skip this step; the rest of the prompt is self-contained. diff --git a/skills/first-officer/references/claude-first-officer-runtime.md b/skills/first-officer/references/claude-first-officer-runtime.md index 3a98d9917..b404e3bce 100644 --- a/skills/first-officer/references/claude-first-officer-runtime.md +++ b/skills/first-officer/references/claude-first-officer-runtime.md @@ -28,7 +28,7 @@ For the dispatch-idle and idle-hallucination guardrails, see `## Awaiting Comple ## Entity-Body Inspection -See `## Probe and Ideation Discipline` in the shared core — its Grep-over-Read rule and Read-then-Bash staleness-echo guidance are already Claude-qualified. +See `## Probe and Ideation Discipline` in the shared core for the Grep-over-Read rule; ToolSearch is this host's tool-discovery surface. The Claude read-then-mutate caveat: a `Read` followed by a Bash mutation of the same file (including `status --set`) triggers the file-staleness safety net, echoing the file back as cache-write tokens. Grep does not participate. Trust `status --set` stdout (`field: old -> new`, `field: old -> ` for clear-to-empty, `field: -> {timestamp}` for bare-timestamp auto-fill) to narrate mutations without re-reading. ## Filing New Entities diff --git a/skills/first-officer/references/claude-fo-dispatch.md b/skills/first-officer/references/claude-fo-dispatch.md index aa512f9e6..4bbcb1d29 100644 --- a/skills/first-officer/references/claude-fo-dispatch.md +++ b/skills/first-officer/references/claude-fo-dispatch.md @@ -14,7 +14,7 @@ The startup boot read is an FO-internal read; consume it as JSON: `status --boot ## Spawn Call (Agent) -The spawn call (fo-dispatch-core.md `## Dispatch Adapter`) is the Agent tool. **Use Agent() for initial dispatch** — SendMessage is only for advancing a reused agent to its next stage. **NEVER use `subagent_type="first-officer"`** — that clones yourself instead of dispatching a worker. +The spawn call (fo-dispatch-core.md `## Dispatch Adapter`) is the Agent tool. **Use Agent() for initial dispatch** — SendMessage is only for advancing a reused agent to its next stage. **NEVER use `subagent_type="first-officer"`** — that clones yourself instead of dispatching a worker. `«async-dispatch»` binds ASYNC here: `Agent(name=…, run_in_background=true)` returns immediately; single-entity/bare mode uses the blocking call and dispatches one entity at a time. **Declare a scripted fan-out before launching it.** When agents are queued by a plan or script rather than by one `Agent()` call you make now — a workflow harness, a batch spawn, a per-finding verifier lane — state the expected worker count, the tolerance, and why that spend is economically reasonable BEFORE launch. A running script reaches no Nth spawn for the fan-out checkpoint to stop at. @@ -55,7 +55,7 @@ The Claude realization of the core's standing-injection call (fo-dispatch-core.m After dispatching an ensign (or routing work to a kept-alive ensign), you are waiting for that ensign's completion signal. Until that signal arrives, take NO action that affects the ensign's lifecycle. -**A completion signal is one of these three things, and nothing else:** +**A completion signal (`«completion-signal»`, DUAL-bound on this host) is one of these three things, and nothing else:** 1. An inbox-delivered user-role message from the ensign whose text begins with `Done:` (per the ensign runtime's completion contract). 2. A `system` entry with `subtype: task_notification` and `status: completed` whose `tool_use_id` matches the ensign's `Agent(...)` dispatch id. diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 553c78448..1d79e472f 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -7,7 +7,7 @@ Shared first-officer semantics — the boot-resident core. The active runtime ad **Launcher command invariant:** Resolve ONE launcher at the version gate — `SPACEDOCK_BIN` when set/executable, else `spacedock` on `$PATH` — report its path/version, and use THAT launcher (written `${SPACEDOCK_BIN:-spacedock}`) for every later Spacedock helper call. Never drift to a bare `spacedock` mid-session — a different `$PATH` binary shifts the command surface. Bare `spacedock` is fine only for naming a command (prose, `→` binding lines), the fallback probe, and diagnostic/install hints — never a post-gate helper invocation. 1. **Binary version gate.** Before discovery or boot read, run `${SPACEDOCK_BIN:-spacedock} --version` and parse line 1: `spacedock `. These skills require binary minor 0.25 (same major.minor; patch and prerelease skew are fine). Abort by class: - - **Binary absent or non-executable** — `${SPACEDOCK_BIN:-spacedock} --version` is not found or line 1 is not `spacedock `. If `SPACEDOCK_BIN` is unusable, retry once with bare `spacedock` on `$PATH`; if still absent, ABORT and tell the operator `spacedock` is not on PATH. Install hint: `brew install spacedock-dev/homebrew-tap/spacedock`, or source build `go build -o spacedock ./cmd/spacedock`. Do NOT run `spacedock doctor` — the binary is missing. Once spacedock is on PATH, launch with `spacedock claude` to start your first officer. + - **Binary absent or non-executable** — `${SPACEDOCK_BIN:-spacedock} --version` is not found or line 1 is not `spacedock `. If `SPACEDOCK_BIN` is unusable, retry once with bare `spacedock` on `$PATH`; if still absent, ABORT and tell the operator `spacedock` is not on PATH. Install hint: `brew install spacedock-dev/homebrew-tap/spacedock`, or source build `go build -o spacedock ./cmd/spacedock`. Do NOT run `spacedock doctor` — the binary is missing. Once spacedock is on PATH, start your first officer with the host's spacedock launcher command. - **Binary present but wrong version** — the version's major.minor is below the required minor (binary too old) or above it (these skills are too old — update the plugin), or the version token carries no major.minor at all (`dev` — an integer-era source build; rebuild it). ABORT with the mismatch message and run `${SPACEDOCK_BIN:-spacedock} doctor` for the per-class remedy. In every class, do NOT proceed to discovery or `--boot`. @@ -46,7 +46,7 @@ A greet-and-stop boot loads NONE of these — it composes its summary from `«st - `references/fo-dispatch-core.md` — read before the first worker dispatch, before invoking `«dispatch.next-action»()`, or before mutating dispatch state. `«dispatch.build»` output is not a dispatch: forward every ready entity's artifact to `«worker.spawn»`; never author its stage report or claim completion without the worker's `«completion-signal»`. - `{first_officer_base}/references/fo-write-core.md` — read in its own completed host event immediately before the first FO-authored mutation. The read activates `«write.classify»`; no FO-owned file, state, process-doc, archive, or mutation command may precede it. - `{first_officer_base}/references/fo-merge-core.md` — read in its own completed host event at the first terminal boundary, or when `«engage»` begins recovery for `mod-block=merge:*`, before a terminal status transition, merge hook/guard, archive, shutdown, or other merge-owned action. -- `Skill(skill="spacedock:fo-dispatch-recovery")` — dispatch failure recovery (Degraded Mode, break-glass manual dispatch, budget-fail/dead-ensign handling); named at its triggers inside the Claude dispatch module — no boot and no happy-path dispatch loads it. +- `Skill(skill="spacedock:fo-dispatch-recovery")` — dispatch failure recovery (Degraded Mode, break-glass manual dispatch, budget-fail/dead-ensign handling); named at its triggers inside the host dispatch module — no boot and no happy-path dispatch loads it. These two reads use only the retained loader-supplied `{first_officer_base}` plus their literal suffixes above; cwd, wrapper-skill discovery, alternate paths, retries at another root, and filesystem search are forbidden. @@ -197,6 +197,5 @@ Building a new STANDING check or enforcement process — a lint, a review gate, ## Probe and Ideation Discipline - When a dispatched-ideation design rests on an unverified mechanism (format round-trip, runtime handoff, a tool actually supporting a flag), exercise the riskiest path end-to-end first — the smallest run that would invalidate the work if it broke. Evidence goes in the entity body; "no spike needed" is recorded with proven mechanisms. The integration-level analog of the AC rule: arrive at the gate with the riskiest claim demonstrated, not asserted. -- When checking whether tool X supports Y, read X's schema via ToolSearch before grepping for callers — usage presence is not existence evidence. -- Prefer Grep over Read for targeted entity-body inspection; Read whole only when you need the full text. Anchor on a heading or field name (`## Stage Report`, `### Feedback Cycles`, a frontmatter field): `grep -n` gives the heading line (the section offset) and the next heading bounds its span, so the follow-up `Read(offset, limit)` is section-scoped. `status --read --json` is the fence-safe fallback when markdown-like fenced content makes grep over-count headings. -- On Claude Code, a `Read` followed by a Bash mutation of the same file (including `status --set`) triggers the file-staleness safety net, echoing the file back as cache-write tokens. Grep does not participate. Trust `status --set` stdout (`field: old -> new`, `field: old -> ` for clear-to-empty, `field: -> {timestamp}` for bare-timestamp auto-fill) to narrate mutations without re-reading. +- When checking whether tool X supports Y, read X's schema via the host's tool-discovery surface before grepping for callers — usage presence is not existence evidence. +- Prefer Grep over Read for targeted entity-body inspection; Read whole only when you need the full text. Anchor on a heading or field name (`## Stage Report`, `### Feedback Cycles`, a frontmatter field): `grep -n` gives the heading line (the section offset) and the next heading bounds its span, so the follow-up `Read(offset, limit)` is section-scoped. `status --read --json` is the fence-safe fallback when markdown-like fenced content makes grep over-count headings. The runtime adapter carries any host-specific read-then-mutate caveats. diff --git a/skills/first-officer/references/fo-dispatch-core.md b/skills/first-officer/references/fo-dispatch-core.md index 53caa1a3d..07ac491ef 100644 --- a/skills/first-officer/references/fo-dispatch-core.md +++ b/skills/first-officer/references/fo-dispatch-core.md @@ -1,6 +1,6 @@ # First Officer Dispatch Core -The per-entity dispatch procedure, worker resolution, dispatch-adapter assembly, the reuse contract, worktree ownership, and the event-loop skeleton. Host-specific parts ride the `→` lines of the capability `«fn»`s below. +The per-entity dispatch procedure, worker resolution, dispatch-adapter assembly, the reuse contract, worktree ownership, and the event-loop skeleton. Host-specific parts are bound in the runtime adapters' `## Runtime implementation` blocks. ## Dispatch @@ -80,7 +80,7 @@ Split worker identity into: ## Dispatch Adapter -Runtime adapters bind the capability `«fn»`s below in their `## Runtime implementation` blocks; the `→` lines here carry legacy host coverage this core still owns. `«addressable-worker»` is the organizing capability: its presence makes a worker reusable. `«worker.spawn»` handles initial dispatch; the reuse-advance handle only advances a reused agent. +Runtime adapters bind the capability `«fn»`s below in their `## Runtime implementation` blocks. `«addressable-worker»` is the organizing capability: its presence makes a worker reusable. `«worker.spawn»` handles initial dispatch; the reuse-advance handle only advances a reused agent. ## «worker.spawn»: create the initial worker from a `«dispatch.build»` artifact @@ -90,22 +90,26 @@ The runtime adapter binds the spawn call, helper-field mapping, model/null handl ## «addressable-worker»: address a still-running worker and hear from it mid-run -- **block:** ABSENT → the `«addressable-worker»` reuse condition fails; fresh one-shot only (return value is the sole completion signal; no mid-run steering, no reusable handle). When PRESENT, `«async-dispatch»` must be async — a blocking FO cannot answer a mid-run escalation within the worker's timeout window. -- → **Claude:** PRESENT when team-mode is enabled (boot-probed), else ABSENT (fresh one-shot, no reuse). · **Codex:** PRESENT — mailbox back-channel. · **Pi:** PRESENT — `contact_supervisor`/`intercom` back-channel. Each host's worker↔FO routes, probe, and ABSENT teardown are its adapter's `## Runtime implementation` binding. +- **block:** ABSENT → the `«addressable-worker»` reuse condition fails; fresh one-shot only (return value is the sole completion signal; no mid-run steering, no reusable handle). When PRESENT, `«async-dispatch»` must be async — a blocking FO cannot answer a mid-run escalation within the worker's timeout window. Each host's worker↔FO routes, presence probe, and ABSENT teardown are its adapter's binding. +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «async-dispatch»: dispatch a worker without blocking the FO event loop -- → **Claude:** ASYNC — `Agent(name=…, run_in_background=true)` returns immediately; single-entity/bare mode blocks until completion. · **Codex:** ASYNC — `spawn_agent` returns a handle; `wait_agent` is the explicit foreground wait. · **Pi:** ASYNC — `subagent(... async: true)` returns a run id; poll `subagent({action:"status", id})`; `subagent({action:"interrupt", id})` steers; pass `cwd: ` on every call so the ensign's working dir is the repo. +Resolved per adapter as ASYNC (the spawn returns immediately) or BLOCKING (the dispatch call returns only at completion); a host may bind different modes for different dispatch surfaces. + +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «worker-identity»: the spawn-time metadata that keeps a worker addressable and reuse-comparable -Records worker label, substrate, run/session handle, worker address, entity slug, stage, state, completion epoch, stamped model, and the host canonical model space used by `«reuse.model-match»`. Each host's model-resolution stamps the model, including the `«dispatch.build»` null case. +Records worker label, substrate, run/session handle, worker address, entity slug, stage, state, completion epoch, stamped model, and the host canonical model space used by `«reuse.model-match»`. Each host's model-resolution stamps the model, including the `«dispatch.build»` null case; each host's handle/address shape and canonical model space are its adapter's binding. -- → **Claude:** canonical model space = the Claude enum in `claude-fo-dispatch.md`. · **Codex:** the thread's model. · **Pi:** the Pi model space, OWNED BY `pi-dispatch-model-stamping`. Each host's handle/address shape and null-model stamping are its adapter's `## Runtime implementation` binding. +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «completion-signal»: the signals that trigger the completion-verify path -- → **Claude:** DUAL — a `Done:` inbox message, a `task_notification`, or captain shutdown. · **Codex:** single observable signal — the async final-status notification in the FO mailbox. · **Pi:** PRIMARY (subagent return, `status: completed`) + optional advisory (a non-blocking heads-up via raw `intercom send` before return; `contact_supervisor` carries no completion reason). +The adapter enumerates which observations count as a completion signal on its host; nothing outside that enumeration completes a worker, and the entity-file stage report remains the verify gate in every case. + +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «worker.shutdown»: cooperatively close a terminal or superseded worker @@ -116,11 +120,11 @@ Runs at terminal, supersede, or fresh-dispatch cleanup boundaries after any requ ## «context-budget»: probe whether a completed worker is still under context budget for reuse - **block:** an over-budget or unavailable reading forces fresh dispatch (fail-safe). ABSENT satisfies the `«context-budget»()` reuse condition. -- → **Claude:** PRESENT — `spacedock dispatch context-budget --name {name}`. · **Codex:** ABSENT. · **Pi:** ABSENT. +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «roster-reconcile»: sweep the host roster for drift before dispatching -- → **Claude:** PRESENT — `spacedock dispatch reconcile` (drift classes and per-class remedy are in the adapter's named binding). · **Codex:** ABSENT. · **Pi:** ABSENT. +- → **runtime-binding**: bound in the host adapter's `## Runtime implementation` ## «dispatch.build»(): assemble the initial-dispatch artifact the spawn call consumes @@ -140,8 +144,8 @@ The ONLY initial-dispatch path: route input through `spacedock dispatch build`, [--feedback-reflow] \ [--advance] ``` - `host` derives from the runtime (`--host` is for tests/cross-host tooling only). `--bare-mode` reads from live team state, never inferred from the stage. Add `--feedback-reflow` only when routing a rejection back to its `feedback-to` target stage. Add `--advance` when advancing a reused live worker instead of spawning one: the emitted envelope carries no `subagent_type`/`name`/`team_name`/`run_in_background` (nothing is spawned) and `prompt` is the reuse-advance pointer message, forwarded to the reuse-advance handle instead of `«worker.spawn»`; `--advance` is incompatible with `--bare-mode`. -- **done-when:** on exit 0, `«worker.spawn»` is called with `subagent_type`/`name`/`description`/`model`/`prompt` (plus any host-scoped fields the adapter declares) forwarded unchanged. `description` is REQUIRED. `prompt` is the ~175-char file-pointer the ensign Reads on first action — do not strip or rewrite it. Null `model` is `«worker-identity»`'s per-host case, not a core omit-on-null. + `host` derives from the runtime (`--host` is for tests/cross-host tooling only). `--bare-mode` reads from live team state, never inferred from the stage. Add `--feedback-reflow` only when routing a rejection back to its `feedback-to` target stage. Add `--advance` when advancing a reused live worker instead of spawning one: the emitted envelope carries no spawn/transport fields (nothing is spawned; the adapter enumerates them) and `prompt` is the reuse-advance pointer message, forwarded to the reuse-advance handle instead of `«worker.spawn»`; `--advance` is incompatible with `--bare-mode`. +- **done-when:** on exit 0, `«worker.spawn»` is called with every helper-emitted field — the spawn/transport fields the adapter enumerates plus `description`/`model`/`prompt` — forwarded unchanged. `description` is REQUIRED. `prompt` is the ~175-char file-pointer the ensign Reads on first action — do not strip or rewrite it. Null `model` is `«worker-identity»`'s per-host case, not a core omit-on-null. - **block:** on non-zero exit (or missing binary) ONLY — read stderr, report the helper failure to the captain, then use the adapter's Break-Glass Manual Dispatch template (stage definition inlined verbatim; conditional `model` slot per `«worker-identity»`'s canonical model space). A zero-exit run is never a break-glass trigger. - → **shipped**: `` `spacedock dispatch build` `` — invoke it directly per the effect above. @@ -167,7 +171,7 @@ When PRESENT, invoke `«roster-reconcile»()` before the inbound-message drain. **Consent stop before dispatching new standing enforcement.** A ready action whose deliverable is a NEW STANDING check or enforcement process the FO ORIGINATED — a lint, a review gate, a CI lane, a recurring validation step — is the last resort of the boot-resident ordering, never obvious reversible work. Consent is already given for a deliverable the captain commissioned, for running a check that already exists, and for writing a test for the behavior in hand; none of those stop. Otherwise do not dispatch: hold the entity ready-but-undispatched, surface it as an unmet clarification, and carry `awaiting-consent: {slug}` as the iteration's stop reason — headless EXITs with it, interactive waits. The license hangs off the captain wanting it, never an inference that it would help; it bites hardest in non-dev workflows, where every check is new process. -**Fan-out checkpoint.** A SINGLE investigation that will dispatch more than one worker (a flake chase, a review-rework, a refactor sweep) declares BEFORE its first spawn how many WORKERS it expects, the tolerance, and why that spend is economically reasonable — dispatched workers are the counted unit, whether they file entities or open PRs. The checkpoint fires when the next spawn would take the running count past expected-plus-tolerance: stop, surface the count against the declaration, and let the captain re-cap rather than spawning again. It binds equally to a plan that commits the fan-out in one act — a workflow script or batch spawn declares the same numbers before launch, since a running script reaches no later moment to catch. Keep-moving speeds independent, already-scoped work; it does not authorize an unbounded spawn chain off one thread. Judgment against a declared number, not a counter binary. Collapse demonstrably-identical findings in a barrier stage BEFORE the per-finding verifier spawn — never spend a verifier per duplicate. Where `«async-dispatch»` is async, a per-member verify that fires as reviews land forfeits that barrier; batch, dedupe, then fan out. +**Fan-out checkpoint.** A SINGLE investigation that will dispatch more than one worker (a flake chase, a review-rework, a refactor sweep) declares BEFORE its first spawn how many WORKERS it expects, the tolerance, and why that spend is economically reasonable — dispatched workers are the counted unit, whether they file entities or open PRs. The checkpoint fires when the next spawn would take the running count past expected-plus-tolerance: stop, surface the count against the declaration, and let the captain re-cap rather than spawning again. It binds equally to a plan that commits the fan-out in one act — a workflow script or batch spawn declares the same numbers before launch, since a running script reaches no later moment to catch. Keep-moving speeds independent, already-scoped work; it does not authorize an unbounded spawn chain off one thread. Judgment against a declared number, not a counter binary. Collapse demonstrably-identical findings in a barrier stage BEFORE the per-finding verifier spawn — never spend a verifier per duplicate. Where `«async-dispatch»` is async, a per-member verify that fires as reviews land forfeits that barrier; batch, dedupe, then fan out. Author the fan-out's shape against this host's `«async-dispatch»`/`«addressable-worker»` bindings — a blocking or ABSENT binding makes a streaming per-member shape unexecutable; plan batch review instead. **A second verifier attacks an unowned claim; it never re-runs an owned check.** "Independent adversarial verification" does not justify a second agent to re-run a green check or second-opinion a deterministic fact a shipped check owns — run that check instead. It DOES justify one to attack a claim no check owns AND that no direct read settles — a judgment, a runtime behavior, a fact not visible in the source; adversarial skepticism and a mandated detached audit are that falsifiable exercise. When a read or diff settles the claim, that read is the exercise, and a second agent re-reading the source is the redundancy this refuses. And N agents reaching the same answer is one confirmation observed N times, not N independent confidences — agreement raises cost, not confidence. diff --git a/skills/first-officer/references/pi-first-officer-runtime.md b/skills/first-officer/references/pi-first-officer-runtime.md index cee3d2e10..5dc44a956 100644 --- a/skills/first-officer/references/pi-first-officer-runtime.md +++ b/skills/first-officer/references/pi-first-officer-runtime.md @@ -5,10 +5,10 @@ This file defines how the shared first-officer core executes on Pi. The shared c ## Runtime implementation - `«worker.spawn»`: Use `spacedock dispatch build` with `host: "pi"` and forward the emitted assignment source of truth to the worker. For `pi-subagents`, call `subagent(...)` with explicit `context: "fresh"` and `cwd: `; add only Pi transport metadata such as optional human-facing phase or label values. For `pi-agent-teams`, map initial creation to `member_spawn` or `delegate`. For standing injection, `pi-subagents` is no-op and `pi-agent-teams` may map to `member_spawn`. -- `«addressable-worker»`: PRESENT. Fresh redispatch remains the default first Pi slice; normal follow-up and retry dispatches are fresh assignment cycles, not context resumes. Reuse-advance over a kept-alive handle is deferred; a non-fresh resume is only an explicit manual/debug exception tied to durable metadata and the full `«worker-identity»` schema. On `pi-agent-teams`, follow-up steering maps to `message_dm`. -- `«async-dispatch»`: `pi-subagents` runs `subagent(... async: true)` and polls the run id; `pi-agent-teams` maps async lifecycle to its team/member task substrate. do not use the `subagent(... acceptance: ...)` contract; Spacedock acceptance requirements stay in the dispatch content and are verified by state/report evidence. -- `«worker-identity»`: Record worker address, substrate, run/session handle, entity slug, stage, epoch, completion state, and stamped model. When the helper emits null model, stamp the parent's live model via `intercom({action:"list"})`; stage-declared model overrides are passed unchanged. Pi's model-space binding is provider/model strings, including provider-qualified and `~`-prefixed Pi-valid model names. -- `«completion-signal»`: For `pi-subagents`, the primary completion signal is the child return/status; optional advisory is only a heads-up. file verification remains the completion gate: the FO reads the entity file and verifies the stage report before advancing state. For `pi-agent-teams`, task/member completion is likewise verified against the entity file. +- `«addressable-worker»`: PRESENT — the `contact_supervisor`/`intercom` back-channel. Fresh redispatch remains the default first Pi slice; normal follow-up and retry dispatches are fresh assignment cycles, not context resumes. Reuse-advance over a kept-alive handle is deferred; a non-fresh resume is only an explicit manual/debug exception tied to durable metadata and the full `«worker-identity»` schema. On `pi-agent-teams`, follow-up steering maps to `message_dm`. +- `«async-dispatch»`: ASYNC — `pi-subagents` runs `subagent(... async: true)` and returns a run id; poll `subagent({action:"status", id})`; `subagent({action:"interrupt", id})` steers; pass `cwd: ` on every call so the ensign's working dir is the repo. `pi-agent-teams` maps async lifecycle to its team/member task substrate. do not use the `subagent(... acceptance: ...)` contract; Spacedock acceptance requirements stay in the dispatch content and are verified by state/report evidence. +- `«worker-identity»`: Record worker address, substrate, run/session handle, entity slug, stage, epoch, completion state, and stamped model. When the helper emits null model, stamp the parent's live model via `intercom({action:"list"})`; stage-declared model overrides are passed unchanged. Pi's model-space binding is provider/model strings, including provider-qualified and `~`-prefixed Pi-valid model names; the Pi model space is OWNED BY `pi-dispatch-model-stamping`. +- `«completion-signal»`: For `pi-subagents`, the PRIMARY completion signal is the child return/status (`status: completed`); an optional advisory is only a non-blocking heads-up via raw `intercom send` before return (`contact_supervisor` carries no completion reason). file verification remains the completion gate: the FO reads the entity file and verifies the stage report before advancing state. For `pi-agent-teams`, task/member completion is likewise verified against the entity file. - `«worker.shutdown»`: For `pi-subagents`, a completed child invocation needs no mailbox shutdown; mark the worker complete/closed in first-officer memory. For `pi-agent-teams`, map teardown to `member_shutdown` or `team_done` according to the active adapter lifecycle. - `«context-budget»`: ABSENT; its reuse condition is satisfied for Pi. - `«roster-reconcile»`: ABSENT; Pi relies on durable entity state and adapter-held worker identity, not a shared roster sweep. diff --git a/skills/fo-dispatch-recovery/SKILL.md b/skills/fo-dispatch-recovery/SKILL.md index e417a10a5..c5cb4521b 100644 --- a/skills/fo-dispatch-recovery/SKILL.md +++ b/skills/fo-dispatch-recovery/SKILL.md @@ -50,7 +50,7 @@ Agent( name="{worker_key}-{slug}-{stage}", // if this exceeds 64 chars, cap it the way `spacedock dispatch build` does: keep the {worker_key} prefix and -{stage} suffix and, on id-style: sd-b32, replace the slug with a fixed-length prefix of the entity id (id-less slug workflows truncate the slug head instead) run_in_background=true, // named background worker, no team_name model="{effective_model}", - prompt="## First action\n\nBefore anything else, invoke your operating contract:\n\n Skill(skill=\"spacedock:ensign\")\n\nThis loads the shared ensign discipline (stage-report format, BashOutput polling, worktree ownership, completion signal protocol). Do not paraphrase; call the tool.\n\nYou are working on: {entity title}\n\nStage: {stage}\n\n### Stage definition:\n\n{copy stage subsection from README verbatim}\n\nRead the entity file at {entity_file_path}.\n\n### Completion checklist\n\n{numbered checklist}\n\n### Summary\n{brief description of what was accomplished}\n\n### Stage report\n\nAppend a Stage Report section at the end of the entity file (per the shared-core Stage Report Protocol). Use the title `Stage Report: {stage}`. Account for every checklist item above with a `- DONE:` / `- SKIPPED:` / `- FAILED:` entry. Use the checklist item text verbatim when possible.\n\n### Completion Signal\n\nSendMessage(to=\"team-lead\", message=\"Done: {entity title} completed {stage}. Report written to {entity_file_path}.\")" + prompt="## First action\n\nBefore anything else, invoke your operating contract:\n\n Skill(skill=\"spacedock:ensign\")\n\nThis loads the shared ensign discipline (stage-report format, background-task polling, worktree ownership, completion signal protocol). Do not paraphrase; call the tool.\n\nYou are working on: {entity title}\n\nStage: {stage}\n\n### Stage definition:\n\n{copy stage subsection from README verbatim}\n\nRead the entity file at {entity_file_path}.\n\n### Completion checklist\n\n{numbered checklist}\n\n### Summary\n{brief description of what was accomplished}\n\n### Stage report\n\nAppend a Stage Report section at the end of the entity file (per the shared-core Stage Report Protocol). Use the title `Stage Report: {stage}`. Account for every checklist item above with a `- DONE:` / `- SKIPPED:` / `- FAILED:` entry. Use the checklist item text verbatim when possible.\n\n### Completion Signal\n\nSendMessage(to=\"team-lead\", message=\"Done: {entity title} completed {stage}. Report written to {entity_file_path}.\")" ) ``` This is the concrete Claude form of fo-dispatch-core.md's Break-Glass template; the contract (what it omits, the conditional `model=` slot, "use only when the helper is unavailable") is stated there. The canonical enum the conditional slot draws from is the resident `## Context Budget` section of `claude-fo-dispatch.md` (already loaded alongside this skill at the first dispatch).