diff --git a/internal/svglide/run.go b/internal/svglide/run.go
index 95b24241a..a73e47ec0 100644
--- a/internal/svglide/run.go
+++ b/internal/svglide/run.go
@@ -109,12 +109,12 @@ func NewRun(cfg NewRunConfig) Run {
func DefaultStages() []Stage {
return []Stage{
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
- {Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
+ {Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"},
- {Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
+ {Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"},
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"},
- {Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides"}, Receipt: "receipts/svg_author.json"},
- {Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
+ {Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
+ {Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
}
}
diff --git a/internal/svglide/run_test.go b/internal/svglide/run_test.go
index 598874921..d5924652b 100644
--- a/internal/svglide/run_test.go
+++ b/internal/svglide/run_test.go
@@ -40,6 +40,36 @@ func TestDefaultStagesAreOrdered(t *testing.T) {
}
}
+func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
+ stages := DefaultStages()
+ svgAuthor := mustStage(t, stages, StageSVGAuthor)
+ if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
+ t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
+ }
+ repair := mustStage(t, stages, StageValidatePreviewRepair)
+ if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
+ t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
+ }
+}
+
+func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
+ stages := DefaultStages()
+ research := mustStage(t, stages, StageResearch)
+ want := []string{"request/request.json", "request/source_manifest.json"}
+ if !reflect.DeepEqual(research.Inputs, want) {
+ t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
+ }
+}
+
+func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
+ stages := DefaultStages()
+ outline := mustStage(t, stages, StageOutline)
+ want := []string{"brief/design_brief.json", "brief/visual_system.json"}
+ if !reflect.DeepEqual(outline.Inputs, want) {
+ t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
+ }
+}
+
func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
run := NewRun(NewRunConfig{
@@ -111,3 +141,14 @@ func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
}
}
+
+func mustStage(t *testing.T, stages []Stage, name string) Stage {
+ t.Helper()
+ for _, stage := range stages {
+ if stage.Name == name {
+ return stage
+ }
+ }
+ t.Fatalf("missing stage %q", name)
+ return Stage{}
+}
diff --git a/internal/svglide/status.go b/internal/svglide/status.go
new file mode 100644
index 000000000..085ebe974
--- /dev/null
+++ b/internal/svglide/status.go
@@ -0,0 +1,391 @@
+package svglide
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "path/filepath"
+ "strings"
+
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+type StatusReport struct {
+ CurrentStage string `json:"current_stage"`
+ MissingInputs []string `json:"missing_inputs"`
+ MissingOutputs []string `json:"missing_outputs"`
+ NextCommand string `json:"next_command"`
+}
+
+type NextTaskReport struct {
+ Stage string `json:"stage"`
+ PromptPath string `json:"prompt_path"`
+ Inputs []string `json:"inputs"`
+ Outputs []string `json:"outputs"`
+}
+
+func ReadRun(root string) (Run, error) {
+ safeRoot, err := validate.SafeInputPath(root)
+ if err != nil {
+ return Run{}, err
+ }
+ return readRunFile(safeRoot)
+}
+
+func InspectStatus(root string) (StatusReport, error) {
+ safeRoot, run, err := readRun(root)
+ if err != nil {
+ return StatusReport{}, err
+ }
+ stage, err := currentStage(run)
+ if err != nil {
+ return StatusReport{}, err
+ }
+ missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
+ if err != nil {
+ return StatusReport{}, err
+ }
+ missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
+ if err != nil {
+ return StatusReport{}, err
+ }
+ return StatusReport{
+ CurrentStage: stage.Name,
+ MissingInputs: missingInputs,
+ MissingOutputs: missingOutputs,
+ NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)),
+ }, nil
+}
+
+func NextTask(root string) (NextTaskReport, error) {
+ safeRoot, run, err := readRun(root)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ stage, err := currentStage(run)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ if len(missingInputs) > 0 {
+ return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
+ }
+ promptPath, err := promptPathForStage(stage.Name)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ inputs, err := validateRunPaths(safeRoot, stage.Inputs)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ outputs, err := validateRunPaths(safeRoot, stage.Outputs)
+ if err != nil {
+ return NextTaskReport{}, err
+ }
+ return NextTaskReport{
+ Stage: stage.Name,
+ PromptPath: promptPath,
+ Inputs: inputs,
+ Outputs: outputs,
+ }, nil
+}
+
+func readRun(root string) (string, Run, error) {
+ safeRoot, err := validate.SafeInputPath(root)
+ if err != nil {
+ return "", Run{}, err
+ }
+ run, err := readRunFile(safeRoot)
+ if err != nil {
+ return "", Run{}, err
+ }
+ return safeRoot, run, nil
+}
+
+func readRunFile(safeRoot string) (Run, error) {
+ raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
+ if err != nil {
+ return Run{}, err
+ }
+ var run Run
+ if err := json.Unmarshal(raw, &run); err != nil {
+ return Run{}, fmt.Errorf("read run.json: %w", err)
+ }
+ return run, nil
+}
+
+func currentStage(run Run) (Stage, error) {
+ for _, stage := range run.Stages {
+ if stage.Name == run.CurrentStage {
+ return stage, nil
+ }
+ }
+ return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
+}
+
+func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
+ var missing []string
+ for _, rel := range rels {
+ if hasGlobMeta(rel) {
+ exists, err := runGlobExists(safeRoot, rel)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ missing = append(missing, rel)
+ }
+ continue
+ }
+ exists, err := runRegularFileExists(safeRoot, rel)
+ if err != nil {
+ return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
+ }
+ if !exists {
+ missing = append(missing, rel)
+ }
+ }
+ return missing, nil
+}
+
+func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
+ paths := make([]string, 0, len(rels))
+ for _, rel := range rels {
+ if hasGlobMeta(rel) {
+ if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
+ return nil, err
+ }
+ } else {
+ if _, err := safeRunPath(safeRoot, rel); err != nil {
+ return nil, err
+ }
+ }
+ paths = append(paths, rel)
+ }
+ return paths, nil
+}
+
+func runGlobExists(safeRoot, rel string) (bool, error) {
+ dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
+ if err != nil {
+ return false, err
+ }
+ dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
+ if err != nil {
+ return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
+ }
+ if !exists {
+ return false, nil
+ }
+ entries, err := vfs.ReadDir(dirPath)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return false, nil
+ }
+ return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
+ }
+ for _, entry := range entries {
+ matched, err := filepath.Match(pattern, entry.Name())
+ if err != nil {
+ return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
+ }
+ if !matched {
+ continue
+ }
+ matchRel := filepath.Join(dirRel, entry.Name())
+ exists, err := runRegularFileExists(safeRoot, matchRel)
+ if err != nil {
+ return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
+ }
+ if exists {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
+ if strings.TrimSpace(rel) == "" {
+ return "", "", "", fmt.Errorf("run path must not be empty")
+ }
+ if isAbsoluteRunPath(rel) {
+ return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
+ }
+ cleanRel := filepath.Clean(rel)
+ dirRel, pattern := filepath.Split(cleanRel)
+ dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
+ if pattern == "" {
+ return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
+ }
+ if _, err := filepath.Match(pattern, ""); err != nil {
+ return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
+ }
+ if dirRel == "" {
+ dirRel = "."
+ }
+ if hasGlobMeta(dirRel) {
+ return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
+ }
+ dirPath, err := safeRunPath(safeRoot, dirRel)
+ if err != nil {
+ return "", "", "", err
+ }
+ return dirRel, pattern, dirPath, nil
+}
+
+func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
+ info, path, exists, err := lstatRunPath(safeRoot, rel)
+ if err != nil {
+ return path, false, err
+ }
+ if !exists {
+ return path, false, nil
+ }
+ if !info.IsDir() {
+ return path, false, fmt.Errorf("run path %q is not a directory", rel)
+ }
+ return path, true, nil
+}
+
+func runRegularFileExists(safeRoot, rel string) (bool, error) {
+ info, _, exists, err := lstatRunPath(safeRoot, rel)
+ if err != nil {
+ return false, err
+ }
+ if !exists {
+ return false, nil
+ }
+ return info.Mode().IsRegular(), nil
+}
+
+func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
+ path, err := safeRunPath(safeRoot, rel)
+ if err != nil {
+ return nil, "", false, err
+ }
+ cleanRel := filepath.Clean(rel)
+ if cleanRel == "." {
+ info, err := vfs.Lstat(path)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, path, false, nil
+ }
+ return nil, path, false, err
+ }
+ if info.Mode()&fs.ModeSymlink != 0 {
+ return nil, path, false, nil
+ }
+ return info, path, true, nil
+ }
+ parts := strings.Split(cleanRel, string(filepath.Separator))
+ cur := safeRoot
+ var info fs.FileInfo
+ for i, part := range parts {
+ if part == "" || part == "." {
+ continue
+ }
+ cur = filepath.Join(cur, part)
+ info, err = vfs.Lstat(cur)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, path, false, nil
+ }
+ return nil, path, false, err
+ }
+ if info.Mode()&fs.ModeSymlink != 0 {
+ return nil, path, false, nil
+ }
+ if i < len(parts)-1 && !info.IsDir() {
+ return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
+ }
+ }
+ if info == nil {
+ return nil, path, false, nil
+ }
+ return info, path, true, nil
+}
+
+func hasGlobMeta(path string) bool {
+ return strings.ContainsAny(path, "*?[")
+}
+
+func safeRunPath(safeRoot, rel string) (string, error) {
+ if strings.TrimSpace(rel) == "" {
+ return "", fmt.Errorf("run path must not be empty")
+ }
+ if isAbsoluteRunPath(rel) {
+ return "", fmt.Errorf("run path %q must be relative to run root", rel)
+ }
+ cleanRel := filepath.Clean(rel)
+ path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
+ rootRel, err := filepath.Rel(safeRoot, path)
+ if err != nil {
+ return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
+ }
+ if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
+ return "", fmt.Errorf("run path %q escapes run root", rel)
+ }
+ return path, nil
+}
+
+func isAbsoluteRunPath(path string) bool {
+ path = strings.TrimSpace(path)
+ if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
+ return true
+ }
+ if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
+ drive := path[0]
+ return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
+ }
+ return false
+}
+
+func shellQuote(value string) string {
+ if value == "" {
+ return "''"
+ }
+ if isShellBareword(value) {
+ return value
+ }
+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
+}
+
+func isShellBareword(value string) bool {
+ for _, r := range value {
+ if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
+ continue
+ }
+ if strings.ContainsRune("_@%+=:,./-", r) {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func promptPathForStage(stage string) (string, error) {
+ switch stage {
+ case StageRequest:
+ return "prompts/01_request.task.md", nil
+ case StageResearch:
+ return "prompts/02_research.task.md", nil
+ case StageDesignBrief:
+ return "prompts/03_design_brief.task.md", nil
+ case StageOutline:
+ return "prompts/04_outline.task.md", nil
+ case StageSlideContent:
+ return "prompts/05_slide_content.task.md", nil
+ case StageAssets:
+ return "prompts/06_assets.task.md", nil
+ case StageSVGAuthor:
+ return "prompts/07_svg_author.task.md", nil
+ case StageValidatePreviewRepair:
+ return "prompts/08_repair.task.md", nil
+ default:
+ return "", fmt.Errorf("stage %q has no prompt mapping", stage)
+ }
+}
diff --git a/internal/svglide/status_test.go b/internal/svglide/status_test.go
new file mode 100644
index 000000000..e5dd7beb6
--- /dev/null
+++ b/internal/svglide/status_test.go
@@ -0,0 +1,449 @@
+package svglide
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestStatusReportsMissingOutputs(t *testing.T) {
+ initStatusTestRun(t)
+ if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if status.CurrentStage != StageRequest {
+ t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
+ }
+ if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
+ t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
+ }
+ if len(status.MissingInputs) != 0 {
+ t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
+ }
+ if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
+ t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
+ }
+}
+
+func TestStatusQuotesNextCommandRunPath(t *testing.T) {
+ tests := []struct {
+ root string
+ want string
+ }{
+ {
+ root: "demo dir",
+ want: "lark-cli slides +create-svglide --action next --run 'demo dir'",
+ },
+ {
+ root: "demo' dir",
+ want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'",
+ },
+ {
+ root: "demo trail ",
+ want: "lark-cli slides +create-svglide --action next --run 'demo trail '",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.root, func(t *testing.T) {
+ cwd := initStatusTestRunAt(t, tt.root)
+
+ status, err := InspectStatus(tt.root)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if status.NextCommand != tt.want {
+ t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
+ }
+ if strings.Contains(status.NextCommand, cwd) {
+ t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
+ }
+ })
+ }
+}
+
+func TestNextReturnsCurrentTaskPrompt(t *testing.T) {
+ initStatusTestRun(t)
+
+ next, err := NextTask("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if next.Stage != StageRequest {
+ t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
+ }
+ if next.PromptPath != "prompts/01_request.task.md" {
+ t.Fatalf("PromptPath = %q, want prompts/01_request.task.md", next.PromptPath)
+ }
+ if filepath.IsAbs(next.PromptPath) {
+ t.Fatalf("PromptPath = %q, want relative path", next.PromptPath)
+ }
+ if _, err := os.Stat(filepath.Join("demo", next.PromptPath)); err != nil {
+ t.Fatalf("missing prompt %s: %v", next.PromptPath, err)
+ }
+ if len(next.Inputs) != 0 {
+ t.Fatalf("Inputs = %v, want empty", next.Inputs)
+ }
+ if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
+ t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
+ }
+}
+
+func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
+ t.Chdir(t.TempDir())
+
+ if _, err := InspectStatus("../escape"); err == nil {
+ t.Fatal("expected unsafe run path refusal")
+ }
+}
+
+func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
+ cwd := initStatusTestRun(t)
+
+ run, err := ReadRun("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if run.Title != "Demo" || run.CurrentStage != StageRequest {
+ t.Fatalf("unexpected run: %+v", run)
+ }
+
+ if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
+ t.Fatal("expected absolute run path refusal")
+ }
+}
+
+func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
+ writeStatusTestRunFile(t, run)
+
+ if _, err := InspectStatus("demo"); err == nil {
+ t.Fatal("expected escaping stage output path refusal")
+ }
+}
+
+func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
+ initStatusTestRun(t)
+ if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := InspectStatus("demo"); err == nil {
+ t.Fatal("expected stat error when output parent is a file")
+ }
+}
+
+func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
+ initStatusTestRun(t)
+ path := filepath.Join("demo", "request", "source_manifest.json")
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
+ t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
+ }
+}
+
+func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
+ writeStatusTestRunFile(t, run)
+
+ if _, err := NextTask("demo"); err == nil {
+ t.Fatal("expected escaping stage output path refusal")
+ }
+}
+
+func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ run.CurrentStage = StageDesignBrief
+ writeStatusTestRunFile(t, run)
+
+ if _, err := NextTask("demo"); err == nil {
+ t.Fatal("expected missing current stage inputs to reject next task")
+ }
+}
+
+func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
+ initStatusTestRun(t)
+ if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
+ t.Fatal(err)
+ }
+ run := readStatusTestRunFile(t)
+ run.CurrentStage = StageResearch
+ writeStatusTestRunFile(t, run)
+
+ if _, err := NextTask("demo"); err == nil {
+ t.Fatal("expected missing research source manifest to reject next task")
+ }
+}
+
+func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
+ initStatusTestRun(t)
+ if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run := readStatusTestRunFile(t)
+ run.CurrentStage = StageOutline
+ writeStatusTestRunFile(t, run)
+
+ if _, err := NextTask("demo"); err == nil {
+ t.Fatal("expected missing outline visual system to reject next task")
+ }
+}
+
+func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ run.CurrentStage = StageSVGAuthor
+ setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
+ writeStatusTestRunFile(t, run)
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
+ }
+
+ if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ status, err = InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if slices.Contains(status.MissingOutputs, "slides/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
+ cwd := initStatusTestRun(t)
+ setCurrentStageForStatusTest(t, StageSVGAuthor)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
+ writeStatusTestRunFile(t, run)
+ outside := filepath.Join(filepath.Dir(cwd), "outside")
+ if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
+ cwd := initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
+ writeStatusTestRunFile(t, run)
+ outside := filepath.Join(filepath.Dir(cwd), "outside")
+ if err := os.MkdirAll(outside, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "link/request.json") {
+ t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
+ cwd := initStatusTestRun(t)
+ setCurrentStageForStatusTest(t, StageSVGAuthor)
+ if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
+ t.Fatal(err)
+ }
+ outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
+ if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
+ initStatusTestRun(t)
+ setCurrentStageForStatusTest(t, StageSVGAuthor)
+ if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
+ cwd := initStatusTestRun(t)
+ setCurrentStageForStatusTest(t, StageSVGAuthor)
+ outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
+ if err := os.WriteFile(outside, []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
+ t.Fatal(err)
+ }
+
+ status, err := InspectStatus("demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
+ t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
+ }
+}
+
+func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
+ writeStatusTestRunFile(t, run)
+
+ if _, err := InspectStatus("demo"); err == nil {
+ t.Fatal("expected invalid glob pattern error")
+ }
+}
+
+func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
+ initStatusTestRun(t)
+ run := readStatusTestRunFile(t)
+ setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
+ writeStatusTestRunFile(t, run)
+
+ if _, err := NextTask("demo"); err == nil {
+ t.Fatal("expected invalid glob pattern error")
+ }
+}
+
+func initStatusTestRun(t *testing.T) string {
+ return initStatusTestRunAt(t, "demo")
+}
+
+func initStatusTestRunAt(t *testing.T, root string) string {
+ t.Helper()
+ cwd := t.TempDir()
+ t.Chdir(cwd)
+ if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ initRoot := root
+ if trimmed := strings.TrimSpace(root); trimmed != root {
+ initRoot = trimmed
+ }
+ if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
+ t.Fatal(err)
+ }
+ if initRoot != root {
+ if err := os.Rename(initRoot, root); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return cwd
+}
+
+func readStatusTestRunFile(t *testing.T) Run {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var run Run
+ if err := json.Unmarshal(raw, &run); err != nil {
+ t.Fatal(err)
+ }
+ return run
+}
+
+func writeStatusTestRunFile(t *testing.T, run Run) {
+ t.Helper()
+ raw, err := json.MarshalIndent(run, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw = append(raw, '\n')
+ if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
+ t.Helper()
+ for i := range run.Stages {
+ if run.Stages[i].Name == stageName {
+ run.Stages[i].Outputs = outputs
+ return
+ }
+ }
+ t.Fatalf("missing stage %q", stageName)
+}
+
+func setCurrentStageForStatusTest(t *testing.T, stageName string) {
+ t.Helper()
+ run := readStatusTestRunFile(t)
+ run.CurrentStage = stageName
+ writeStatusTestRunFile(t, run)
+}