Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions internal/contractlint/capability_binding_test.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
Expand All @@ -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 {
Expand Down
133 changes: 118 additions & 15 deletions internal/contractlint/fo_function_reference_invariant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"}},
Expand Down
144 changes: 144 additions & 0 deletions internal/contractlint/host_neutral_core_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
2 changes: 1 addition & 1 deletion internal/dispatch/testdata/golden/build-abs-worktree.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading