Skip to content
Open
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
71 changes: 71 additions & 0 deletions internal/ensigncycle/boot_discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// ABOUTME: AC-1 offline proof — every shared-scenario fixture README carries the
// ABOUTME: commissioned-by marker discovery requires; the zero-discovery fixture stays markerless.
package ensigncycle

import (
"os"
"path/filepath"
"testing"

"github.com/spacedock-dev/spacedock/internal/status"
)

// fixtureDiscoveryReadmes names every live-runner fixture README whose scenario
// expects a discoverable workflow — the Spike promoted to a repo test. Before the
// commissioned-by marker landed on all of them, the FO's nondeterministic choice
// between `--boot --identify` (gated on this marker) and `--workflow-dir .`
// (marker-blind) could false-negative a scenario whose fixture lacked it; only
// filingReadme and smallestMechanismReadme carried it from the start. The
// zero-discovery fixture (TestLiveZeroDiscoverReportsAndStops) is the sole,
// deliberate exception — its SUBJECT is non-discovery — and is proved the other way
// in TestZeroDiscoverFixtureStaysUndiscoverable below, not listed here.
func fixtureDiscoveryReadmes() map[string]func() string {
return map[string]func() string{
"gate-guardrail": gateReadme,
"rejection-flow": rejectionReadme,
"feedback-3-cycle-escalation": escalationReadme,
"merge-hook-guardrail": mergeHookGuardReadme,
"filing": filingReadme,
"shallow-boot": shallowBootReadme,
"self-evidence-merge-triage": mergeTriageReadme,
"smallest-sufficient-mechanism": smallestMechanismReadme,
"keep-moving-posture": keepMovingReadme,
"gate-stop": gateStopReadme,
}
}

// TestSharedScenarioFixturesAreDiscoverable is AC-1's CLI/deterministic proof: from
// every fixture root, discovery (the predicate `--boot --identify` resolves
// through) finds the workflow — so the FO's nondeterministic choice between
// `--boot --identify` and `--workflow-dir .` can no longer produce a
// scenario-failing false-negative. status.DiscoverWorkflowDir is the same
// predicate both discoverWorkflows (handlers.go) and `--boot --identify` use, so
// this is a same-mechanism CLI-level assertion, not a proxy.
func TestSharedScenarioFixturesAreDiscoverable(t *testing.T) {
for name, readme := range fixtureDiscoveryReadmes() {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(readme()), 0o644); err != nil {
t.Fatal(err)
}
if _, found := status.DiscoverWorkflowDir(root); !found {
t.Fatalf("fixture %q's README is not discoverable (missing `commissioned-by: spacedock@` marker) — the FO's boot-path choice between --boot --identify and --workflow-dir . can false-negative this scenario", name)
}
})
}
}

// TestZeroDiscoverFixtureStaysUndiscoverable guards Fix B's over-application: the
// zero-discovery scenario's SUBJECT is non-discovery (TestLiveZeroDiscoverReportsAndStops
// proves an FO report-and-stops on a genuinely empty root), so its root must stay
// undiscoverable — gaining a marker here would silently disable the very scenario
// it is meant to exercise.
func TestZeroDiscoverFixtureStaysUndiscoverable(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".gitkeep"), nil, 0o644); err != nil {
t.Fatal(err)
}
if _, found := status.DiscoverWorkflowDir(root); found {
t.Fatal("zero-discovery fixture root is discoverable — it must stay markerless so TestLiveZeroDiscoverReportsAndStops still exercises a real zero-discover boot")
}
}
21 changes: 21 additions & 0 deletions internal/ensigncycle/boot_preamble_classify_impl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// ABOUTME: Pure classifier folding the wrong-root and broad-search boot detectors
// ABOUTME: into one ordered check claudeLiveRunner.run consults on its failure path.
package ensigncycle

// classifyBootPreambleFailure runs the boot-preamble detectors over a captured
// stream in priority order — wrong-root wander first (the most specific, earliest
// diagnosis: the FO operated outside the fixture entirely), then a broad-search
// filesystem sweep (the FO stayed on the fixture but hunted the filesystem for a
// workflow or a contract file instead of proceeding on what it already read) — and
// returns the first legible diagnosis it finds. A nil return means neither preamble
// class fired, so the caller's normal stall/assertion path applies unchanged.
//
// Extracted as a pure function (stream + workflowRoot in, error out) so the
// classification claudeLiveRunner.run performs on every launch is unit-testable
// offline, with no subprocess and no model spend.
func classifyBootPreambleFailure(stream, workflowRoot string) error {
if wrongRoot := detectWrongRootBoot(stream, workflowRoot); wrongRoot != nil {
return wrongRoot
}
return detectBroadSearchAtBoot(stream, workflowRoot)
}
69 changes: 69 additions & 0 deletions internal/ensigncycle/boot_preamble_classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// ABOUTME: AC-2 offline proof — a contract-file find-hunt classifies through the
// ABOUTME: runner's failure classification as a distinct broad-search preamble diagnosis.
package ensigncycle

import (
"strings"
"testing"
)

// TestClassifyBootPreambleFailureBroadSearch is AC-2's offline classification
// proof: a captured boot stream carrying a contract-file `find /` hunt (the
// ledger's instance 7/8/11/16 shape — after resolving its workflow the FO greps
// the filesystem for a contract reference file instead of proceeding on the
// fixture/plugin content already read) classifies through
// classifyBootPreambleFailure as a distinct broad-search preamble diagnosis
// naming the sweep — never silence (which would leave the caller's opaque stall
// path or a downstream scenario assertion to misdiagnose it).
func TestClassifyBootPreambleFailureBroadSearch(t *testing.T) {
const fixtureRoot = "/tmp/TestLiveClaudeSharedScenariosself-evidence-merge-triage1234567890/001"
stream := strings.Join([]string{
streamLine(`spacedock status --boot --workflow-dir ` + fixtureRoot),
streamLine(`find / -maxdepth 6 -iname "fo-merge-core.md"`),
}, "\n")

err := classifyBootPreambleFailure(stream, fixtureRoot)
if err == nil {
t.Fatal("classifier passed a contract-file find-hunt boot — want a distinct broad-search preamble diagnosis")
}
if !strings.Contains(err.Error(), "FO broad-searched the filesystem at boot") {
t.Errorf("diagnosis must name the broad-search preamble class, got: %v", err)
}
}

// TestClassifyBootPreambleFailureWrongRootTakesPriority proves wrong-root wander
// is classified first, ahead of broad-search: a stream carrying both a wrong-root
// cd AND a subsequent find-hunt returns the wrong-root diagnosis — the earliest,
// most specific signature of the two.
func TestClassifyBootPreambleFailureWrongRootTakesPriority(t *testing.T) {
const fixtureRoot = "/tmp/TestLiveClaudeSharedScenariosfiling1234567890/001"
const realRepo = "/home/runner/work/spacedock/spacedock"
stream := strings.Join([]string{
streamLine(`cd ` + realRepo + ` && spacedock status --discover`),
streamLine(`find / -maxdepth 6 -iname "fo-merge-core.md"`),
}, "\n")

err := classifyBootPreambleFailure(stream, fixtureRoot)
if err == nil {
t.Fatal("classifier passed a wrong-root + find-hunt boot — want the wrong-root diagnosis")
}
if !strings.Contains(err.Error(), "FO booted the wrong root") {
t.Errorf("wrong-root wander must classify first, got: %v", err)
}
}

// TestClassifyBootPreambleFailureCleanBootPasses proves a clean, on-fixture boot
// with no wander and no sweep classifies as nil, leaving the caller's normal
// stall/assertion path intact — the classifier must not false-red an ordinary run.
func TestClassifyBootPreambleFailureCleanBootPasses(t *testing.T) {
const fixtureRoot = "/tmp/TestLiveClaudeSharedScenariosgate-guardrail1234567890/001"
stream := strings.Join([]string{
streamLine(`spacedock --version`),
streamLine(`spacedock status --boot --workflow-dir ` + fixtureRoot),
`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"` + fixtureRoot + `/README.md"}}]}}`,
}, "\n")

if err := classifyBootPreambleFailure(stream, fixtureRoot); err != nil {
t.Errorf("classifier red a clean, on-fixture boot: %v", err)
}
}
55 changes: 29 additions & 26 deletions internal/ensigncycle/broad_search_detect_impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,38 @@ import (
"strings"
)

// detectBroadSearchAtBoot scans a captured FO boot stream for the lean-boot
// violation the captain observed (2026-06-14): after `spacedock status --discover`
// returned zero workflows, an FO ran a broad `find`/`grep -r`/`ls -R` filesystem
// sweep to hunt a workflow down instead of obeying the contract's terminal zero
// branch (Startup step 2: zero → report no workflow found and STOP). A broad sweep
// at boot is both a discipline violation (the zero branch is report-and-stop) and a
// cost/latency regression — the opposite of lean boot.
// detectBroadSearchAtBoot scans a captured FO boot stream for a broad filesystem
// sweep at boot: a `find`/`grep -r`/`ls -R` (or a recursive Glob/repo-wide Grep)
// hunting a workflow or a contract reference file across the project root instead
// of proceeding on the fixture/plugin content already in hand. Two boot shapes
// trigger it: (1) after `spacedock status --discover` returns zero workflows, the
// contract's terminal zero branch is report-and-stop, not a sweep to hunt one down
// (the captain observed this 2026-06-14); (2) after a normal boot resolves its
// workflow, the FO goes looking for a contract reference file (e.g. a skill's
// cross-referenced doc) that lives only in the --plugin-dir checkout, not the
// fixture — also a sweep, not a scoped read. A broad sweep at boot is both a
// discipline violation and a cost/latency regression — the opposite of lean boot.
//
// Like detectWrongRootBoot it is model-agnostic (it reads the tool-call stream, not
// any model phrasing) and pure (stream + fixtureRoot in, error out), with its own
// offline test. It iterates ALL tool_use blocks of each entry (not just the first),
// so a sweep cannot evade it by riding as a second block of a multi-tool turn. The
// reddable sweep signatures, all observable in the boot stream:
// It is model-agnostic (it reads the tool-call stream, not any model phrasing) and
// pure (stream + fixtureRoot in, error out), with its own offline test. It iterates
// ALL tool_use blocks of each entry (not just the first), so a sweep cannot evade
// it by riding as a second block of a multi-tool turn. The reddable sweep
// signatures, all observable in the boot stream:
//
// - a `Bash` command invoking find / grep -r / rg / fd / ls -R whose target is the
// project root or a broad ancestor (not a scoped path under a resolved workflow),
// - a `Glob` tool_use with a recursive workflow-hunting pattern (e.g. **/README.md),
// - a `Glob` tool_use with a recursive hunting pattern (e.g. **/README.md),
// - a `Grep` tool_use whose path is the project root / unset (a repo-wide search).
//
// A correct zero-discover boot touches none of these: it runs --version,
// git rev-parse, status --discover (zero), then reports no-workflow-found and stops.
// A boot MAY also run a plain, non-recursive `ls` (e.g. `ls -la` of its own cwd, or
// `ls {root}`) while orienting before it reports — the captain's decision (2026-07-02)
// is that flat `ls` is not a hunt, so the detector never reds it. The detector passes
// all of that and reds only the genuine sweep. It guards STRICTLY the zero-discover
// branch's substituted sweep — a scoped search under an already-resolved workflow dir
// is legitimate and must pass. The banned axis is recursion/hunting, not the `ls`
// binary or the root path (keyed on recursion for `ls`; on the target being the
// root / an unscoped path for find/rg/fd/grep -r/Glob/Grep).
// A correct boot touches none of these. A boot MAY also run a plain, non-recursive
// `ls` (e.g. `ls -la` of its own cwd, or `ls {root}`) while orienting — the
// captain's decision (2026-07-02) is that flat `ls` is not a hunt, so the detector
// never reds it. The detector passes all of that and reds only the genuine sweep.
// It guards STRICTLY a substituted sweep — a scoped search under an
// already-resolved workflow dir is legitimate and must pass. The banned axis is
// recursion/hunting, not the `ls` binary or the root path (keyed on recursion for
// `ls`; on the target being the root / an unscoped path for
// find/rg/fd/grep -r/Glob/Grep).
func detectBroadSearchAtBoot(stream, fixtureRoot string) error {
clean := filepath.Clean(fixtureRoot)
for _, line := range strings.Split(stream, "\n") {
Expand All @@ -53,17 +56,17 @@ func detectBroadSearchAtBoot(stream, fixtureRoot string) error {
switch b.Name {
case "Bash":
if sig, ok := broadSweepCommand(b.Input.Command, clean); ok {
return fmt.Errorf("FO broad-searched the filesystem at boot: command %q runs %s over the project root %q — after a zero `status --discover` the Startup zero branch is report-and-stop, not a filesystem sweep to hunt a workflow",
return fmt.Errorf("FO broad-searched the filesystem at boot: command %q runs %s over the project root %q — a boot-preamble filesystem sweep, not the scenario's own assertion",
strings.TrimSpace(b.Input.Command), sig, clean)
}
case "Glob":
if recursiveHuntPattern(b.Input.Pattern) {
return fmt.Errorf("FO broad-searched the filesystem at boot: a recursive Glob %q hunts a workflow project-wide — after a zero `status --discover` the Startup zero branch is report-and-stop",
return fmt.Errorf("FO broad-searched the filesystem at boot: a recursive Glob %q hunts the project root — a boot-preamble filesystem sweep, not the scenario's own assertion",
b.Input.Pattern)
}
case "Grep":
if repoWideGrep(b.Input.Path, clean) {
return fmt.Errorf("FO broad-searched the filesystem at boot: a repo-wide Grep for %q (path %q) hunts a workflow project-wide — after a zero `status --discover` the Startup zero branch is report-and-stop",
return fmt.Errorf("FO broad-searched the filesystem at boot: a repo-wide Grep for %q (path %q) hunts the project root — a boot-preamble filesystem sweep, not the scenario's own assertion",
b.Input.Pattern, b.Input.Path)
}
}
Expand Down
Loading
Loading