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
2 changes: 1 addition & 1 deletion docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ An unsandboxed bootstrap launch carries no safehouse isolation, so per-action pe

Both take `--host claude|codex|pi` (default `claude`). When `doctor` reports the plugin is out of date, refresh it with `spacedock install`. When the plugin is still contract-compatible but a newer one is available, `doctor` and the front-door launch print an opt-in upgrade hint (`run spacedock install --host <host> to refresh`); the hint never blocks the launch. See [Install Spacedock](../get-started/install.md) for the full setup path.

The launcher and plugin are a version-gated bundle. During a gate lifecycle, the real `gate prepare` invocation is the capability check: a nonzero result halts before presentation or later state effects. Refresh the installed bundle or build the current checkout and select that executable with `SPACEDOCK_BIN`; do not scrape help output or hand-edit gate frontmatter as a fallback. Relative selected-source and room inputs resolve from the launch working directory.
The launcher and plugin are a version-gated bundle. During a gate lifecycle, the real `gate prepare` invocation is the capability check: a nonzero result halts before presentation or later state effects. Refresh the installed bundle or build the current checkout and select that executable with `SPACEDOCK_BIN`; do not scrape help output or hand-edit gate frontmatter as a fallback. Selected-source inputs may be absolute, launch-working-directory-relative, or state-checkout-relative. A relative spelling that names different existing paths under the launch directory and state checkout is refused; use an absolute path. Relative room inputs continue to resolve from the launch working directory.

## Workflow

Expand Down
1 change: 1 addition & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ func newGateCommand(dir string, stdout, stderr io.Writer) *cobra.Command {
}
if args[0] == "prepare" {
prepareInput.WorkflowDir = definitionDir
prepareInput.LaunchDir = dir
result, err := gates.Prepare(path, prepareInput)
if err != nil {
fmt.Fprintln(stderr, "Error:", err)
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,39 @@ func TestGatePrepareCLIPassesStateRelativeArtifactWithoutCwdJoin(t *testing.T) {
}
}

func TestGatePrepareCLIResolvesLaunchRelativeSelectedSources(t *testing.T) {
for _, flag := range []string{"--artifact", "--reference"} {
t.Run(flag, func(t *testing.T) {
workflow, state, artifact := gatePrepareCLIFixture(t)
selected := filepath.Join(state, "selected", "review.md")
if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, selected, "# Selected review\n")
git(t, state, "add", "selected")
git(t, state, "commit", "-q", "-m", "selected source")

launchDir := filepath.Dir(filepath.Dir(workflow))
relative := filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "review.md"))
args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact,
"--summary", "launch-relative source", "--workflow-dir", workflow}
if flag == "--artifact" {
args[6] = relative
} else {
args = append(args, "--reference", relative)
}
var out, errOut bytes.Buffer
code := run(context.Background(), args, nil, launchDir, nil, &out, &errOut, &status.NativeRunner{}, nil)
if code != 0 || errOut.Len() != 0 || !strings.Contains(out.String(), "state=open") {
t.Fatalf("prepare exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
if _, err := os.Stat(filepath.Join(state, "docs")); !os.IsNotExist(err) {
t.Fatalf("prepare created doubled path: %v", err)
}
})
}
}

func TestGatePrepareCLIRejectsSummaryCardinalityAndEncodingBeforeMutation(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down
1 change: 1 addition & 0 deletions internal/ensigncycle/shared_fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func writePreGateWorkflow(t *testing.T, root string) recordedGateFixture {
t.Helper()
fixture := writePreparedRecordedGateFixtureAt(t, root)
writeFile(t, filepath.Join(fixture.root, "README.md"), strings.Replace(strings.Replace(recordedGateReadme(), " - name: implementation\n initial: true\n", " - name: queued\n initial: true\n - name: implementation\n", 1), "### validation", "### implementation\n\nAppend exactly one `## Stage Report: implementation`, then return completion.\n\n### validation", 1))
gitCommitPathScoped(t, fixture.root, "README.md", "queue coherent workflow definition")
writeFile(t, fixture.entity, strings.Replace(strings.Split(recordedGateEntity(), "\n## Stage Report: validation\n")[0]+"\n", "status: validation", "status: queued", 1))
writeFile(t, fixture.references[0], "# Entity snapshot\n\nThe retained package is ready for implementation.\n")
git(t, fixture.stateRoot, "add", "--", "recorded-gate-task/index.md", "recorded-gate-task/selected/entity-snapshot.md")
Expand Down
87 changes: 52 additions & 35 deletions internal/gates/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package gates
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"unicode/utf8"

"github.com/spacedock-dev/spacedock/internal/gitsource"
Expand All @@ -34,9 +36,11 @@ const archivedBriefingLocator = "briefing.json"
var preparedLocators = []string{preparedBriefingLocator, legacyPreparedLocator}

var prepareWriteBinding = writeDocument
var selectedSourceLstat = os.Lstat

type PrepareInput struct {
WorkflowDir string
LaunchDir string
Question string
Artifact string
Summary string
Expand Down Expand Up @@ -97,7 +101,7 @@ func Prepare(entityPath string, input PrepareInput) (PrepareResult, error) {
normalized := make([]string, 0, len(paths))
seen := map[string]bool{}
for i, selected := range paths {
path, err := resolveSelectedSource(selected, entityRoot, i == 0)
path, err := resolveSelectedSource(selected, input.LaunchDir, entityRoot, i == 0)
if err != nil {
return PrepareResult{}, fmt.Errorf("resolve selected source: %w", err)
}
Expand Down Expand Up @@ -845,49 +849,62 @@ func entityResolveRoot(workflowDir string) (string, error) {
return filepath.Join(workflowDir, cleaned), nil
}

// resolveSelectedSource makes a selected source path absolute. Relative paths
// resolve against the entity root (the state-checkout root in split-root, the
// workflow dir in single-root); absolute paths pass through cleaned. This is
// the single resolution site — the CLI passes relative paths through unchanged.
//
// A relative path that already carries the state-checkout basename (e.g.
// ".spacedock-state/auto-continue-task/index.md" in a split-root workflow where
// entityRoot is "<workflow>/.spacedock-state") is workflow-rooted, not
// entity-rooted: joining it under entityRoot would double the basename. Resolve
// it against the workflow directory (entityRoot's parent) instead.
//
// The artifact resolves strictly against the entity root — a wrong-root
// artifact is rejected (TestPrepareWrongRootRelativeArtifactFails). A
// reference may legitimately live at the workflow root (e.g.
// recorder-contract.md alongside a split-root state checkout), so references
// fall back to the workflow directory (entityRoot's parent) when the entity-root
// join does not exist. A genuinely missing file reports the entity-root seek
// path so the error shape is preserved.
func resolveSelectedSource(selected, entityRoot string, isArtifact bool) (string, error) {
// resolveSelectedSource selects one cleaned lexical interpretation. Presence
// chooses a candidate; gitsource.Inspect remains the only source validator.
func resolveSelectedSource(selected, launchDir, entityRoot string, isArtifact bool) (string, error) {
if filepath.IsAbs(selected) {
return filepath.Clean(selected), nil
}
selected = filepath.Clean(selected)
flag := "--reference"
if isArtifact {
flag = "--artifact"
}
var candidates []string
add := func(root string) error {
path, err := filepath.Abs(filepath.Join(root, selected))
if err != nil {
return err
}
path = filepath.Clean(path)
for _, candidate := range candidates {
if candidate == path {
return nil
}
}
candidates = append(candidates, path)
return nil
}
if err := add(entityRoot); err != nil {
return "", fmt.Errorf("%s %q: resolve state-relative path: %w", flag, selected, err)
}
if strings.TrimSpace(launchDir) != "" {
if err := add(launchDir); err != nil {
return "", fmt.Errorf("%s %q: resolve launch-relative path: %w", flag, selected, err)
}
}
base := filepath.Base(entityRoot)
// A path carrying the state-checkout basename is workflow-rooted; resolve
// against the workflow directory so the basename is not doubled. This
// applies to both artifact and reference.
if base != "." && strings.HasPrefix(selected, base+string(filepath.Separator)) {
return filepath.Clean(filepath.Join(filepath.Dir(entityRoot), selected)), nil
if !isArtifact || (base != "." && strings.HasPrefix(selected, base+string(filepath.Separator))) {
if err := add(filepath.Dir(entityRoot)); err != nil {
return "", fmt.Errorf("%s %q: resolve workflow-relative path: %w", flag, selected, err)
}
}
entityJoin := filepath.Clean(filepath.Join(entityRoot, selected))
if isArtifact {
return entityJoin, nil

var present []string
for _, candidate := range candidates {
if _, err := selectedSourceLstat(candidate); err == nil {
present = append(present, candidate)
} else if !errors.Is(err, syscall.ENOENT) {
return "", fmt.Errorf("%s %q: inspect candidate %s: %w", flag, selected, candidate, err)
}
}
// Reference: fall back to the workflow root if the entity-root join is absent.
if info, err := os.Lstat(entityJoin); err == nil && info.Mode().IsRegular() {
return entityJoin, nil
if len(present) == 1 {
return present[0], nil
}
workflowJoin := filepath.Clean(filepath.Join(filepath.Dir(entityRoot), selected))
if info, err := os.Lstat(workflowJoin); err == nil && info.Mode().IsRegular() {
return workflowJoin, nil
if len(present) > 1 {
return "", fmt.Errorf("%s %q resolves to different paths: %s; use an absolute path", flag, selected, strings.Join(present, ", "))
}
return entityJoin, nil // not found; report the entity-root seek path
return "", fmt.Errorf("%s %q was not found; attempted paths: %s; use an absolute, launch-cwd-relative, or state-relative path", flag, selected, strings.Join(candidates, ", "))
}

func isMarkdownPath(path string) bool {
Expand Down
136 changes: 134 additions & 2 deletions internal/gates/prepare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"

"github.com/spacedock-dev/spacedock/internal/testgit"
Expand Down Expand Up @@ -1189,15 +1190,146 @@ func TestPrepareWrongRootRelativeArtifactFails(t *testing.T) {
}
_, err := Prepare(entity, PrepareInput{
WorkflowDir: workflow,
LaunchDir: "",
Question: "Advance?",
Artifact: "gate-review.md",
Summary: "wrong root",
})
if err == nil {
t.Fatal("wrong-root relative artifact succeeded; resolution reverted to the workflow root")
}
if !strings.Contains(err.Error(), "no such file or directory") {
t.Fatalf("wrong-root error=%v want no such file or directory", err)
if !strings.Contains(err.Error(), resolved) || !strings.Contains(err.Error(), "was not found") {
t.Fatalf("wrong-root error=%v want state-root absence", err)
}
}

func TestPrepareSelectedSourceFormsShareImmutableBinding(t *testing.T) {
for _, flag := range []string{"--artifact", "--reference"} {
t.Run(flag, func(t *testing.T) {
workflow, state, entity, artifact, _ := prepareFixture(t, "flat")
selected := filepath.Join(state, "selected", "review.md")
if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(selected, []byte("# Review\n"), 0o644); err != nil {
t.Fatal(err)
}
prepareGitRun(t, state, "add", "selected")
prepareGitRun(t, state, "commit", "-q", "-m", "selected source")
launch := filepath.Dir(filepath.Dir(workflow))
forms := []string{selected, filepath.Join("selected", "review.md"), filepath.Join("docs", "dev", ".state", "selected", "review.md")}
var first PrepareResult
for i, form := range forms {
input := PrepareInput{WorkflowDir: workflow, LaunchDir: launch, Question: "Advance?", Artifact: artifact, Summary: "same source"}
if flag == "--artifact" {
input.Artifact = form
} else {
input.References = []string{form}
}
result, err := Prepare(entity, input)
if err != nil {
t.Fatalf("form %q: %v", form, err)
}
if i == 0 {
first = result
} else if result != first {
t.Fatalf("form %q binding=%#v want %#v", form, result, first)
}
}
})
}
}

func TestResolveSelectedSourceRefusesAmbiguityAbsenceAndProbeErrors(t *testing.T) {
workflow, state, _, _, _ := prepareFixture(t, "flat")
launch := filepath.Dir(filepath.Dir(workflow))
rel := filepath.Join("shared", "review.md")
for _, root := range []string{state, launch} {
if err := os.MkdirAll(filepath.Join(root, "shared"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, rel), []byte(root), 0o644); err != nil {
t.Fatal(err)
}
}
for _, artifact := range []bool{true, false} {
_, err := resolveSelectedSource(rel, launch, state, artifact)
flag := "--reference"
if artifact {
flag = "--artifact"
}
if err == nil || !strings.Contains(err.Error(), flag) || !strings.Contains(err.Error(), filepath.Join(state, rel)) ||
!strings.Contains(err.Error(), filepath.Join(launch, rel)) || !strings.Contains(err.Error(), "use an absolute path") {
t.Fatalf("ambiguity error=%v", err)
}
_, err = resolveSelectedSource("missing.md", launch, state, artifact)
if err == nil || !strings.Contains(err.Error(), "attempted paths") || !strings.Contains(err.Error(), "launch-cwd-relative") {
t.Fatalf("absence error=%v", err)
}
}
if err := os.Remove(filepath.Join(launch, rel)); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(state, rel), filepath.Join(launch, rel)); err != nil {
t.Fatal(err)
}
if _, err := resolveSelectedSource(rel, launch, state, true); err == nil || !strings.Contains(err.Error(), "different paths") {
t.Fatalf("lexical alias was collapsed: %v", err)
}

original := selectedSourceLstat
defer func() { selectedSourceLstat = original }()
probes := 0
selectedSourceLstat = func(path string) (os.FileInfo, error) {
probes++
return nil, &os.PathError{Op: "lstat", Path: path, Err: syscall.EACCES}
}
_, err := resolveSelectedSource("denied.md", launch, state, true)
if err == nil || probes != 1 || !strings.Contains(err.Error(), "--artifact") || !strings.Contains(err.Error(), filepath.Join(state, "denied.md")) || !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("non-ENOENT error=%v probes=%d", err, probes)
}
}

func TestPrepareSelectedSourceGuardsRemainByteClean(t *testing.T) {
workflow, state, entity, artifact, _ := prepareFixture(t, "flat")
directory := filepath.Join(state, "directory.md")
if err := os.Mkdir(directory, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(state, "link.md")
if err := os.Symlink(artifact, link); err != nil {
t.Fatal(err)
}
unreadable := filepath.Join(state, "unreadable.md")
if err := os.WriteFile(unreadable, []byte("# Unreadable\n"), 0o644); err != nil {
t.Fatal(err)
}
prepareGitRun(t, state, "add", "unreadable.md")
prepareGitRun(t, state, "commit", "-q", "-m", "unreadable source")
if err := os.Chmod(unreadable, 0); err != nil {
t.Fatal(err)
}
defer os.Chmod(unreadable, 0o644)
foreign := t.TempDir()
testgit.InitRepo(t, foreign, "-q")
if err := os.WriteFile(filepath.Join(foreign, "foreign.md"), []byte("# Foreign\n"), 0o644); err != nil {
t.Fatal(err)
}
prepareGitRun(t, foreign, "add", ".")
prepareGitRun(t, foreign, "commit", "-q", "-m", "foreign")
before, _ := os.ReadFile(entity)
for _, tc := range []struct{ selected, launch, want string }{
{"directory.md", "", "non-symlink regular file"},
{"link.md", "", "non-symlink regular file"},
{"unreadable.md", "", "read selected source"},
{"foreign.md", foreign, "not owned by a workflow Git root"},
} {
statusBefore := prepareGitOutput(t, state, "status", "--porcelain")
_, err := Prepare(entity, PrepareInput{WorkflowDir: workflow, LaunchDir: tc.launch, Question: "Advance?", Artifact: tc.selected, Summary: "guard"})
after, _ := os.ReadFile(entity)
if err == nil || !strings.Contains(err.Error(), tc.want) || !bytes.Equal(before, after) || prepareGitOutput(t, state, "status", "--porcelain") != statusBefore {
t.Fatalf("selected=%q error=%v changed bytes or status", tc.selected, err)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion skills/fo-gate-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The binary owns preparation, withdrawal, recording, and one-use consume; this sk

**Boot.** `status --boot --identify --json` `ready_gates` gives `definition_dir`, `entity_dir`, slug/stage/readiness. Engage slug via `status --read <slug> --json`, never search. `needs-preparation`: review report, or at an `initial: true` stage the committed seed itself; `awaiting-captain`: open; `withdrawn-awaiting-prepare`: successor; approved: unblocked; malformed/ambiguous: fail closed.

**Prepare.** Resolve `${SPACEDOCK_BIN:-spacedock}`; keep supplied paths. Else, per distinct applicable retained absolute R=`definition_dir`/`entity_dir`, resolve intended root-relative P; state R uses the engaged task directory, never `.`. Run: `git -C "<R>" ls-tree -r --name-only HEAD -- "<P>" | awk 'tolower($0)~/\.(md|markdown)$/{print}'`. `<R>/<P>`: shell-quoted values. Read/use once; summarize. No harness logs/history/status/help probes. Supply judgment/cwd paths; no binary JSON/ids/digests/Git locators/room coords.
**Prepare.** Resolve `${SPACEDOCK_BIN:-spacedock}`; keep paths. For each retained absolute R (`definition_dir`, `entity_dir`), derive relative P; state R is the engaged task directory, never `.`. Run `git -C "<R>" ls-tree -r --name-only HEAD -- "<P>" | awk 'tolower($0)~/\.(md|markdown)$/{print}'`, quoted. Read once; summarize. No logs/history/status/help probes. Supply absolute, launch-cwd-relative, or state-relative judgment paths; use absolute before the one prepare if a spelling names different cwd/state paths. No binary JSON/IDs/digests/Git locators/room coordinates.

Run this sequence once and in this order:

Expand Down