diff --git a/labs/22-yield/distribution/release-notes/2026-08-02-dx-hardening.md b/labs/22-yield/distribution/release-notes/2026-08-02-dx-hardening.md index 168bfd78..458dfcff 100644 --- a/labs/22-yield/distribution/release-notes/2026-08-02-dx-hardening.md +++ b/labs/22-yield/distribution/release-notes/2026-08-02-dx-hardening.md @@ -12,3 +12,10 @@ relocatable Python workflows, and deterministic fixture effects. - Verify 40 workflow cases and eight runtime checks, including concurrent response admission and recovery. +- Add the public Go `yield.Option` type so external modules can declare closed + `AskUser` choices without importing an internal package. +- Keep Go and Rust runtimes under `.yield/bin`, bind adapters to that path, and + report SDK, runtime, and adapter version mismatches together. +- Preserve a selected Python environment for commands, add deterministic + fixture hooks to new workflows, and make bulk dry-run output describe only + future changes. diff --git a/labs/22-yield/public-readme/README.md b/labs/22-yield/public-readme/README.md index 01ee39a1..75e84f2c 100644 --- a/labs/22-yield/public-readme/README.md +++ b/labs/22-yield/public-readme/README.md @@ -24,29 +24,35 @@ normal code owns the repeatable control flow. ## Install -Choose one language package. It includes the SDK and the matching `yskill` -runtime. +Choose one language package. TypeScript and Python include a package-local +runtime. Go and Rust install the matching runtime under `.yield/bin` in the +repository. Generated adapters never use a global `yskill` from `PATH`. ```bash # TypeScript -npm install @operatorstack/yield --registry=https://get.operatorstack.systems/npm/ +npm install --save-exact @operatorstack/yield@0.1.23 --registry=https://get.operatorstack.systems/npm/ npm exec -- yskill --version -# Python -python -m pip install yieldskill --index-url https://get.operatorstack.systems/pip/simple/ +# Python, after creating and activating .venv +python -m pip install yieldskill==0.1.23 --index-url https://get.operatorstack.systems/pip/simple/ python -m yieldskill --version -# Go -GOPROXY=https://get.operatorstack.systems/go,direct \ - go install github.com/operatorstack/yield/cmd/yskill@latest -yskill --version +# Go, from the repository root +mkdir -p .yield/bin +GOBIN="$PWD/.yield/bin" GOPROXY=https://get.operatorstack.systems/go,direct \ + go install github.com/operatorstack/yield/cmd/yskill@v0.1.23 +.yield/bin/yskill --version -# Rust -cargo install yieldskill \ +# Rust, from the repository root +cargo install yieldskill@0.1.23 --root .yield \ --index sparse+https://get.operatorstack.systems/cargo/index/ --locked -yskill --version +.yield/bin/yskill --version ``` +Yield creates `.yield/.gitignore` when it registers a Go or Rust workflow, so +the local runtime and run state stay out of Git. +On Windows, run the local binary as `.\.yield\bin\yskill.exe`. + ## Create and register a skill workflow Keep the canonical workflow beside the language dependencies it uses. Yield writes @@ -139,6 +145,8 @@ the documentation for your job: - [examples](docs/examples.md) — working programs in all four languages; - [coding-agent setup](docs/agent-setup.md) — register one skill workflow with the agents used by the project; +- [test workflow effects](docs/testing-fixtures.md) — deterministic fixture + setup, response effects, standard-input JSON, and cleanup; - [evaluations](evals/README.md) — first-party workflow conformance and runtime invariant results, including the exact claim boundary; - [convert an existing skill](docs/convert-existing-skill.md) — move diff --git a/labs/22-yield/yield/cmd/yskill/agents.go b/labs/22-yield/yield/cmd/yskill/agents.go index f44aefd1..8f3c0fb4 100644 --- a/labs/22-yield/yield/cmd/yskill/agents.go +++ b/labs/22-yield/yield/cmd/yskill/agents.go @@ -10,6 +10,8 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "runtime" "sort" "strings" @@ -180,6 +182,7 @@ func cmdRegisterAll(args []string) error { names := map[string]string{} var repoRoot, parentRel string var selected []agentConfig + usesLocalRuntime := false for _, skill := range skills { skillDir, resolvedRoot, sourceRel, metadata, manifest, _, selectedAgents, inputErr := registrationInputs(skill, *root, agents) if inputErr != nil { @@ -193,6 +196,7 @@ func cmdRegisterAll(args []string) error { repoRoot, selected = resolvedRoot, selectedAgents parentRel, _ = filepath.Rel(repoRoot, parent) } + usesLocalRuntime = usesLocalRuntime || manifest.Language == "go" || manifest.Language == "rust" digest, digestErr := protocol.DigestSkillDir(skillDir) if digestErr != nil { return digestErr @@ -237,10 +241,19 @@ func cmdRegisterAll(args []string) error { if len(conflicts) > 0 { return fmt.Errorf("refusing bulk registration; resolve every agent-facing name collision before writing:\n - %s", strings.Join(conflicts, "\n - ")) } + if usesLocalRuntime && !*dryRun { + if err := ensureLocalStateIgnored(repoRoot); err != nil { + return err + } + } for _, path := range paths { plan := plansByPath[path] sort.Strings(plan.agentIDs) - fmt.Printf("%-9s %-24s %s\n", plan.status+":", strings.Join(plan.agentIDs, ","), filepath.ToSlash(path)) + status := plan.status + if *dryRun { + status = map[string]string{"added": "would add", "updated": "would update", "unchanged": "unchanged"}[status] + } + fmt.Printf("%-13s %-24s %s\n", status+":", strings.Join(plan.agentIDs, ","), filepath.ToSlash(path)) if !*dryRun && plan.status != "unchanged" { if _, err := writeGeneratedAdapter(path, plan.sourceRel, plan.content); err != nil { return err @@ -262,7 +275,11 @@ func cmdRegisterAll(args []string) error { if !strings.HasPrefix(source, prefix) || plansByPath[path] != nil { continue } - fmt.Printf("removed: %-24s %s\n", agent.ID, filepath.ToSlash(path)) + status := "removed" + if *dryRun { + status = "would remove" + } + fmt.Printf("%-13s %-24s %s\n", status+":", agent.ID, filepath.ToSlash(path)) if !*dryRun { if err := os.Remove(path); err != nil { return err @@ -272,6 +289,9 @@ func cmdRegisterAll(args []string) error { } } } + if *dryRun { + fmt.Println("dry-run: no files written") + } return nil } @@ -320,6 +340,11 @@ func registerSkill(skillArg, rootArg string, requested []string) ([]registration if err != nil { return nil, err } + if manifest.Language == "go" || manifest.Language == "rust" { + if err := ensureLocalStateIgnored(repoRoot); err != nil { + return nil, err + } + } content := renderAdapter(metadata, sourceRel, digest, launcher) byDestination := map[string][]string{} for _, agent := range selected { @@ -379,6 +404,9 @@ func registrationInputs(skillArg, rootArg string, requested []string) (string, s if err != nil { return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err } + if err := verifyWorkflowSDKVersion(manifest, skillDir, repoRoot, runtimeVersion()); err != nil { + return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err + } registry, err := loadAgentRegistry() if err != nil { return "", "", "", skillMetadata{}, skillManifest{}, registry, nil, err @@ -539,12 +567,149 @@ func launcherFor(language, skillDir, repoRoot string) (string, error) { } return "python -m yieldskill", nil case "go", "rust": - return "yskill", nil + path := localRuntimePath(repoRoot) + if err := verifyLocalRuntime(path, runtimeVersion(), language); err != nil { + return "", err + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return "", err + } + return repositoryRuntimeLauncher(rel, runtime.GOOS), nil default: return "", fmt.Errorf("unsupported workflow language %q", language) } } +func repositoryRuntimeLauncher(relative, goos string) string { + if goos == "windows" { + return `.\` + strings.ReplaceAll(filepath.ToSlash(relative), "/", `\`) + } + return filepath.ToSlash(relative) +} + +var inspectRuntimeVersion = func(path string) (string, error) { + out, err := exec.Command(path, "version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("run %s version: %w: %s", path, err, strings.TrimSpace(string(out))) + } + fields := strings.Fields(string(out)) + if len(fields) < 2 || fields[0] != "yskill" { + return "", fmt.Errorf("%s returned an invalid version line: %q", path, strings.TrimSpace(string(out))) + } + return strings.TrimPrefix(fields[1], "v"), nil +} + +func localRuntimePath(repoRoot string) string { + name := "yskill" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(repoRoot, ".yield", "bin", name) +} + +func ensureLocalStateIgnored(repoRoot string) error { + dir := filepath.Join(repoRoot, ".yield") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.WriteFile(path, []byte("*\n"), 0o644) +} + +func verifyLocalRuntime(path, expected, language string) error { + repair := localRuntimeInstallCommand(language, expected) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s workflow needs Yield %s at %s; repair: %s", language, expected, filepath.ToSlash(path), repair) + } + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("repository-local Yield runtime is not a regular file: %s", filepath.ToSlash(path)) + } + got, err := inspectRuntimeVersion(path) + if err != nil { + return fmt.Errorf("repository-local Yield runtime is unusable: %w", err) + } + if got != expected { + return fmt.Errorf("repository-local Yield runtime version is %s, but this workflow needs %s; repair: %s", got, expected, repair) + } + return nil +} + +func localRuntimeInstallCommand(language, expected string) string { + switch language { + case "go": + if runtime.GOOS == "windows" { + return fmt.Sprintf(`New-Item -ItemType Directory -Force .yield\bin | Out-Null; $env:GOBIN="$PWD\.yield\bin"; $env:GOPROXY="https://get.operatorstack.systems/go,direct"; go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) + } + return fmt.Sprintf(`mkdir -p .yield/bin && GOBIN="$PWD/.yield/bin" GOPROXY=https://get.operatorstack.systems/go,direct go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) + case "rust": + return fmt.Sprintf(`cargo install yieldskill@%s --root .yield --index sparse+https://get.operatorstack.systems/cargo/index/ --locked`, expected) + default: + return "install the matching Yield package" + } +} + +var pinnedVersionPatterns = map[string]*regexp.Regexp{ + "python": regexp.MustCompile(`(?m)^yieldskill==([^\s]+)$`), + "go": regexp.MustCompile(`(?m)^\s*github\.com/operatorstack/yield\s+v([^\s]+)`), + "rust": regexp.MustCompile(`(?m)yieldskill\s*=\s*\{[^\n]*version\s*=\s*"=([^"]+)"`), +} + +func verifyWorkflowSDKVersion(manifest skillManifest, skillDir, repoRoot, expected string) error { + if expected == "dev" { + return nil + } + var declared string + if manifest.Language == "typescript" { + root, err := findTypeScriptPackageRoot(skillDir, repoRoot) + if err != nil { + return err + } + b, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return err + } + var packageJSON struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(b, &packageJSON); err != nil { + return fmt.Errorf("package.json does not decode: %w", err) + } + declared = packageJSON.Dependencies["@operatorstack/yield"] + if declared == "" { + declared = packageJSON.DevDependencies["@operatorstack/yield"] + } + } else { + file := map[string]string{"python": "requirements.txt", "go": "go.mod", "rust": "Cargo.toml"}[manifest.Language] + b, err := os.ReadFile(filepath.Join(skillDir, file)) + if err != nil { + return fmt.Errorf("read %s SDK version: %w", file, err) + } + match := pinnedVersionPatterns[manifest.Language].FindStringSubmatch(string(b)) + if len(match) == 2 { + declared = match[1] + } + } + declared = strings.TrimPrefix(strings.TrimSpace(declared), "v") + if declared == "" { + return fmt.Errorf("%s workflow must pin the Yield SDK to %s", manifest.Language, expected) + } + if declared != expected { + return fmt.Errorf("%s workflow pins Yield SDK %s, but the runtime is %s", manifest.Language, declared, expected) + } + return nil +} + func findTypeScriptPackageRoot(skillDir, repoRoot string) (string, error) { var declared string for current := filepath.Clean(skillDir); within(repoRoot, current); current = filepath.Dir(current) { @@ -684,7 +849,8 @@ func cmdDoctor(args []string) error { if err != nil { return err } - if _, err := readSkillMetadata(skillDir); err != nil { + metadata, err := readSkillMetadata(skillDir) + if err != nil { return err } manifest, err := readSkillManifest(skillDir) @@ -693,6 +859,9 @@ func cmdDoctor(args []string) error { } packageBoundary, boundaryErr := findRepoRoot(skillDir, *root) if boundaryErr != nil { + if manifest.Language == "go" || manifest.Language == "rust" { + return fmt.Errorf("%s workflow needs a repository root for .yield/bin; pass --root: %w", manifest.Language, boundaryErr) + } if len(agents) > 0 || *root != "" { return boundaryErr } @@ -701,23 +870,43 @@ func cmdDoctor(args []string) error { } else if packageBoundary, err = filepath.EvalSymlinks(packageBoundary); err != nil { return err } + var problems []string + if err := verifyWorkflowSDKVersion(manifest, skillDir, packageBoundary, runtimeVersion()); err != nil { + problems = append(problems, "SDK: "+err.Error()) + } if _, err := launcherFor(manifest.Language, skillDir, packageBoundary); err != nil { - return err + problems = append(problems, "runtime: "+err.Error()) } if err := languageDiagnostics(manifest.Language, skillDir); err != nil { - return err + problems = append(problems, "language: "+err.Error()) } - if *runTest { + if *runTest && len(problems) == 0 { if err := cmdTest([]string{skillDir}); err != nil { return err } } - fmt.Printf("ok: workflow %s\n", filepath.ToSlash(skillDir)) + if len(problems) == 0 { + fmt.Printf("ok: workflow %s\n", filepath.ToSlash(skillDir)) + } if len(agents) == 0 { + if len(problems) > 0 { + return fmt.Errorf("doctor found problems:\n - %s", strings.Join(problems, "\n - ")) + } fmt.Printf("doctor: %s workflow is ready\n", filepath.Base(skillDir)) return nil } - _, repoRoot, sourceRel, metadata, _, _, selected, err := registrationInputs(skillDir, *root, agents) + repoRoot := packageBoundary + sourceRel, err := filepath.Rel(repoRoot, skillDir) + if err != nil || sourceRel == ".." || strings.HasPrefix(sourceRel, ".."+string(filepath.Separator)) { + problems = append(problems, "workflow is outside the repository root") + sourceRel = "" + } + sourceRel = filepath.ToSlash(sourceRel) + registry, err := loadAgentRegistry() + if err != nil { + return err + } + selected, err := selectAgents(registry, agents, repoRoot) if err != nil { return err } @@ -725,7 +914,6 @@ func cmdDoctor(args []string) error { if err != nil { return err } - var problems []string for _, agent := range selected { path := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), metadata.Name, "SKILL.md") if err := ensureContainedWrite(repoRoot, path); err != nil { @@ -738,14 +926,14 @@ func cmdDoctor(args []string) error { continue } text := string(b) - if !strings.Contains(text, generatedAdapterPrefix+sourceRel+";") || !strings.Contains(text, "digest: "+digest+";") { + if !strings.Contains(text, generatedAdapterPrefix+sourceRel+";") || !strings.Contains(text, "digest: "+digest+";") || !strings.Contains(text, "version: "+runtimeVersion()+" -->") { problems = append(problems, fmt.Sprintf("%s: adapter is stale or points elsewhere", agent.ID)) continue } fmt.Printf("ok: %-22s %s\n", agent.ID, filepath.ToSlash(path)) } if len(problems) > 0 { - return fmt.Errorf("adapter problems:\n - %s\nrun yskill register to update them", strings.Join(problems, "\n - ")) + return fmt.Errorf("doctor found problems:\n - %s\nrun yskill register to update generated adapters after fixing version errors", strings.Join(problems, "\n - ")) } fmt.Printf("doctor: %s is ready for %d agent(s)\n", filepath.Base(skillDir), len(selected)) return nil diff --git a/labs/22-yield/yield/cmd/yskill/agents_test.go b/labs/22-yield/yield/cmd/yskill/agents_test.go index 17152827..7907965a 100644 --- a/labs/22-yield/yield/cmd/yskill/agents_test.go +++ b/labs/22-yield/yield/cmd/yskill/agents_test.go @@ -1,6 +1,7 @@ package main import ( + "io" "os" "path/filepath" "runtime" @@ -227,8 +228,13 @@ func TestRegisterAllPreflightsAndWritesEveryWorkflow(t *testing.T) { writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") } - if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex", "--dry-run"}); err != nil { - t.Fatal(err) + output := captureStdout(t, func() { + if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex", "--dry-run"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(output, "would add:") || !strings.Contains(output, "dry-run: no files written") || strings.Contains(output, "updated:") { + t.Fatalf("dry-run output is ambiguous:\n%s", output) } if _, err := os.Stat(filepath.Join(repo, ".agents", "skills", "review", "SKILL.md")); !os.IsNotExist(err) { t.Fatal("dry-run wrote an adapter") @@ -314,6 +320,120 @@ func TestPythonLauncherUsesRepositoryVirtualEnvironment(t *testing.T) { } } +func TestGoAndRustLaunchersUseMatchingRepositoryRuntime(t *testing.T) { + repo := t.TempDir() + path := localRuntimePath(repo) + writeTestFile(t, path, "runtime") + oldGlobal := filepath.Join(t.TempDir(), "bin") + writeTestFile(t, filepath.Join(oldGlobal, "yskill"), "old global runtime") + t.Setenv("PATH", oldGlobal) + previousVersion := version + previousInspect := inspectRuntimeVersion + version = "0.1.23" + inspectRuntimeVersion = func(got string) (string, error) { + if got != path { + t.Fatalf("inspected runtime = %q, want %q", got, path) + } + return "0.1.23", nil + } + t.Cleanup(func() { + version = previousVersion + inspectRuntimeVersion = previousInspect + }) + for _, language := range []string{"go", "rust"} { + got, err := launcherFor(language, filepath.Join(repo, "skills", "review"), repo) + if err != nil { + t.Fatal(err) + } + relative := filepath.Join(".yield", "bin", filepath.Base(path)) + want := repositoryRuntimeLauncher(relative, runtime.GOOS) + if got != want { + t.Fatalf("%s launcher = %q, want %q", language, got, want) + } + } +} + +func TestRepositoryRuntimeLauncherUsesNativeWindowsPath(t *testing.T) { + relative := filepath.Join(".yield", "bin", "yskill.exe") + if got := repositoryRuntimeLauncher(relative, "windows"); got != `.\.yield\bin\yskill.exe` { + t.Fatalf("Windows launcher = %q", got) + } + if got := repositoryRuntimeLauncher(filepath.Join(".yield", "bin", "yskill"), "linux"); got != ".yield/bin/yskill" { + t.Fatalf("Unix launcher = %q", got) + } +} + +func TestRepositoryRuntimeRejectsMissingAndWrongVersions(t *testing.T) { + repo := t.TempDir() + path := localRuntimePath(repo) + repair := localRuntimeInstallCommand("go", "0.1.23") + if err := verifyLocalRuntime(path, "0.1.23", "go"); err == nil || !strings.Contains(err.Error(), repair) { + t.Fatalf("missing runtime error = %v", err) + } + writeTestFile(t, path, "runtime") + previousInspect := inspectRuntimeVersion + inspectRuntimeVersion = func(string) (string, error) { return "0.1.22", nil } + t.Cleanup(func() { inspectRuntimeVersion = previousInspect }) + if err := verifyLocalRuntime(path, "0.1.23", "go"); err == nil || !strings.Contains(err.Error(), "version is 0.1.22") { + t.Fatalf("wrong runtime error = %v", err) + } +} + +func TestLocalStateIgnoreFileCoversRuntimeAndRuns(t *testing.T) { + repo := t.TempDir() + if err := ensureLocalStateIgnored(repo); err != nil { + t.Fatal(err) + } + if got := readTestFile(t, filepath.Join(repo, ".yield", ".gitignore")); got != "*\n" { + t.Fatalf(".yield/.gitignore = %q", got) + } +} + +func TestWorkflowSDKVersionMustMatchRuntime(t *testing.T) { + repo := t.TempDir() + skill := createTypeScriptSkill(t, repo, "review") + manifest, err := readSkillManifest(skill) + if err != nil { + t.Fatal(err) + } + if err := verifyWorkflowSDKVersion(manifest, skill, repo, "0.1.23"); err == nil || !strings.Contains(err.Error(), "pins Yield SDK 0.1.17") { + t.Fatalf("SDK mismatch error = %v", err) + } + writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.23"}}`) + if err := verifyWorkflowSDKVersion(manifest, skill, repo, "0.1.23"); err != nil { + t.Fatal(err) + } +} + +func TestDoctorReportsSDKRuntimeAndAdapterVersionProblemsTogether(t *testing.T) { + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, ".git", "keep"), "") + skill := filepath.Join(repo, "skills", "review") + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review code before it is shipped.\n---\n") + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"go","run":["go","run","."]}`) + writeTestFile(t, filepath.Join(skill, "go.mod"), "module review\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v0.1.22\n") + writeTestFile(t, filepath.Join(skill, "main.go"), "package main\nfunc main() {}\n") + writeTestFile(t, localRuntimePath(repo), "runtime") + writeTestFile(t, filepath.Join(repo, ".cursor", "skills", "review", "SKILL.md"), "\n") + previousVersion := version + previousInspect := inspectRuntimeVersion + version = "0.1.23" + inspectRuntimeVersion = func(string) (string, error) { return "0.1.22", nil } + t.Cleanup(func() { + version = previousVersion + inspectRuntimeVersion = previousInspect + }) + err := cmdDoctor([]string{skill, "--root", repo, "--agent", "cursor"}) + if err == nil { + t.Fatal("doctor accepted three version mismatches") + } + for _, want := range []string{"SDK:", "runtime:", "cursor: adapter is stale"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("doctor error does not contain %q:\n%v", want, err) + } + } +} + func createTypeScriptSkill(t *testing.T, repo, name string) string { t.Helper() writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.17"}}`) @@ -333,3 +453,23 @@ func writeTestFile(t *testing.T, path, content string) { t.Fatal(err) } } + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + previous := os.Stdout + os.Stdout = write + fn() + if err := write.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = previous + b, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/labs/22-yield/yield/cmd/yskill/main_test.go b/labs/22-yield/yield/cmd/yskill/main_test.go index 03fcba30..e1bed9fb 100644 --- a/labs/22-yield/yield/cmd/yskill/main_test.go +++ b/labs/22-yield/yield/cmd/yskill/main_test.go @@ -196,7 +196,7 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { if err := scaffoldSkill(dir, tt.language, "", "Run the test workflow when checking Yield setup."); err != nil { t.Fatal(err) } - for _, rel := range append(tt.files, "SKILL.md", "fixtures/responses.json") { + for _, rel := range append(tt.files, "SKILL.md", "fixtures/responses.json", "fixtures/test.json") { if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { t.Fatalf("%s: %v", rel, err) } diff --git a/labs/22-yield/yield/cmd/yskill/scaffold.go b/labs/22-yield/yield/cmd/yskill/scaffold.go index 03f32026..483cb932 100644 --- a/labs/22-yield/yield/cmd/yskill/scaffold.go +++ b/labs/22-yield/yield/cmd/yskill/scaffold.go @@ -78,6 +78,7 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { files := scaffoldFiles(name, language, sdkPath) files["SKILL.md"] = fmt.Sprintf(skillMD, name, yamlString(strings.TrimSpace(description)), launcher, launcher) files["fixtures/responses.json"] = "{\n \"confirm-start\": {\"value\": \"yes\"}\n}\n" + files["fixtures/test.json"] = "{\n \"version\": 1,\n \"setup\": [],\n \"after_response\": {},\n \"teardown\": []\n}\n" keys := make([]string, 0, len(files)) for key := range files { keys = append(keys, key) diff --git a/labs/22-yield/yield/docs/agent-setup.md b/labs/22-yield/yield/docs/agent-setup.md index 12c54ac3..06bffab6 100644 --- a/labs/22-yield/yield/docs/agent-setup.md +++ b/labs/22-yield/yield/docs/agent-setup.md @@ -30,8 +30,14 @@ Use the launcher installed by the selected language package: |---|---| | TypeScript | `npm exec -- yskill` | | Python | `python -m yieldskill` | -| Go | `yskill` | -| Rust | `yskill` | +| Go | `.yield/bin/yskill` | +| Rust | `.yield/bin/yskill` | + +Go and Rust keep one version-locked runtime in `.yield/bin` at the repository +root. Registration checks that runtime, the workflow SDK, and the generated +adapter all use the same Yield version. It refuses a missing or mismatched +runtime and prints the exact repair command. A global `yskill` is not used. +Windows adapters use `.\.yield\bin\yskill.exe`. Run `yskill agents` to see every supported ID and project directory. Cursor, Codex, and Claude Code are verified. Other entries use paths from a pinned @@ -64,7 +70,7 @@ Set up a Yield skill workflow named [skill-name] in skills/[skill-name]. 6. Keep the canonical workflow beside the project's language dependencies. 7. Run yskill register for the coding agent you are currently using. 8. Use the launcher from the installed language package for every yskill - command: npm exec -- yskill, python -m yieldskill, or yskill. + command: npm exec -- yskill, python -m yieldskill, or .yield/bin/yskill. 9. Run yskill doctor with --agent and --test. 10. Report the commands, generated adapter path, and every changed file. diff --git a/labs/22-yield/yield/docs/quickstart.md b/labs/22-yield/yield/docs/quickstart.md index f2556852..8309ccb9 100644 --- a/labs/22-yield/yield/docs/quickstart.md +++ b/labs/22-yield/yield/docs/quickstart.md @@ -12,7 +12,7 @@ You need Node.js 24 or newer. mkdir yield-example cd yield-example npm init -y -npm install @operatorstack/yield \ +npm install --save-exact @operatorstack/yield@0.1.23 \ --registry=https://get.operatorstack.systems/npm/ npm exec -- yskill --version ``` diff --git a/labs/22-yield/yield/docs/reference/cli.md b/labs/22-yield/yield/docs/reference/cli.md index 48aa360a..aacd8094 100644 --- a/labs/22-yield/yield/docs/reference/cli.md +++ b/labs/22-yield/yield/docs/reference/cli.md @@ -152,6 +152,11 @@ and teardown commands use argv arrays and never run through a shell. Each `after_response` command receives that fixture response as JSON on standard input. Hooks run only during `yskill test`. +`setup` runs before the first workflow step. `after_response` runs after the +named fixture response is accepted. `teardown` always runs after success or +failure. Every hook receives `YIELD_FIXTURE=1`. This keeps test-only effects +out of live workflows. + ## `prune` ```bash diff --git a/labs/22-yield/yield/docs/testing-fixtures.md b/labs/22-yield/yield/docs/testing-fixtures.md new file mode 100644 index 00000000..f8f4c170 --- /dev/null +++ b/labs/22-yield/yield/docs/testing-fixtures.md @@ -0,0 +1,26 @@ +# Test workflow effects + +`yskill test` runs commands for real. Fixture files provide only the agent and +person responses that the workflow would normally wait for. + +Use `fixtures/responses.json` for saved answers. Add `fixtures/test.json` when +a response must also create a deterministic test-only effect: + +```json +{ + "version": 1, + "setup": [["node", "fixtures/setup.mjs"]], + "after_response": { + "approve": [["node", "fixtures/apply-approval.mjs"]] + }, + "teardown": [["node", "fixtures/teardown.mjs"]] +} +``` + +The runtime passes each `after_response` command that response as JSON on +standard input. Commands use argument arrays and never a shell. `setup` runs +first. `teardown` runs after success or failure. Every hook receives +`YIELD_FIXTURE=1` so test behavior stays separate from live runs. + +Keep hooks small and repeatable. They should prepare or clean fixture state, +not replace the workflow behavior being tested. diff --git a/labs/22-yield/yield/evals/results/latest.json b/labs/22-yield/yield/evals/results/latest.json index 8d6c3ebf..97483e11 100644 --- a/labs/22-yield/yield/evals/results/latest.json +++ b/labs/22-yield/yield/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, "methodology_version": "1.1", - "generated_at": "2026-08-02T10:42:46.872Z", - "source_digest": "1819f3be5d90558331e24832f6b30294aa5b3a9b6b07771e3fd2db8e407bbf25", + "generated_at": "2026-08-02T14:30:09.305Z", + "source_digest": "6a2ad2cc8d59a7226c966bdf71015a3d959a7f57ecf29d661d195c2f8ea79604", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/labs/22-yield/yield/examples/convert-skill/main.go b/labs/22-yield/yield/examples/convert-skill/main.go index 665f0734..2870b033 100644 --- a/labs/22-yield/yield/examples/convert-skill/main.go +++ b/labs/22-yield/yield/examples/convert-skill/main.go @@ -15,7 +15,6 @@ import ( "fmt" "strings" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) @@ -68,10 +67,10 @@ func main() { json.RawMessage(flowSchema)) lang := ctx.AskUser("pick-language", "Target language for the generated program?", - protocol.Option{Value: "go", Label: "Go"}, - protocol.Option{Value: "typescript", Label: "TypeScript"}, - protocol.Option{Value: "python", Label: "Python"}, - protocol.Option{Value: "rust", Label: "Rust"}) + yield.Option{Value: "go", Label: "Go"}, + yield.Option{Value: "typescript", Label: "TypeScript"}, + yield.Option{Value: "python", Label: "Python"}, + yield.Option{Value: "rust", Label: "Rust"}) dest := ctx.AskUser("dest-path", "Directory to write the converted skill into?") diff --git a/labs/22-yield/yield/examples/library/go/src/migrate-database/main.go b/labs/22-yield/yield/examples/library/go/src/migrate-database/main.go index ee14012b..78b3d3b8 100644 --- a/labs/22-yield/yield/examples/library/go/src/migrate-database/main.go +++ b/labs/22-yield/yield/examples/library/go/src/migrate-database/main.go @@ -4,7 +4,6 @@ package main import ( "encoding/json" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) @@ -36,8 +35,8 @@ func main() { approval := ctx.AskUser( "approve-migration", "Apply the reviewed database migration?", - protocol.Option{Value: "continue", Label: "Continue"}, - protocol.Option{Value: "stop", Label: "Stop"}, + yield.Option{Value: "continue", Label: "Continue"}, + yield.Option{Value: "stop", Label: "Stop"}, ) if approval != "continue" { return yield.Outcome{}, ctx.Refused("the operator declined to continue") diff --git a/labs/22-yield/yield/examples/library/go/src/publish-ios/main.go b/labs/22-yield/yield/examples/library/go/src/publish-ios/main.go index 321410a4..d66fdc5e 100644 --- a/labs/22-yield/yield/examples/library/go/src/publish-ios/main.go +++ b/labs/22-yield/yield/examples/library/go/src/publish-ios/main.go @@ -4,7 +4,6 @@ package main import ( "encoding/json" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) @@ -36,8 +35,8 @@ func main() { approval := ctx.AskUser( "approve-ios-upload", "Upload this iOS build to App Store Connect?", - protocol.Option{Value: "continue", Label: "Continue"}, - protocol.Option{Value: "stop", Label: "Stop"}, + yield.Option{Value: "continue", Label: "Continue"}, + yield.Option{Value: "stop", Label: "Stop"}, ) if approval != "continue" { return yield.Outcome{}, ctx.Refused("the operator declined to continue") diff --git a/labs/22-yield/yield/examples/library/go/src/release-package/main.go b/labs/22-yield/yield/examples/library/go/src/release-package/main.go index b433da87..53e19000 100644 --- a/labs/22-yield/yield/examples/library/go/src/release-package/main.go +++ b/labs/22-yield/yield/examples/library/go/src/release-package/main.go @@ -4,7 +4,6 @@ package main import ( "encoding/json" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) @@ -36,8 +35,8 @@ func main() { approval := ctx.AskUser( "approve-publish", "Publish this package release?", - protocol.Option{Value: "continue", Label: "Continue"}, - protocol.Option{Value: "stop", Label: "Stop"}, + yield.Option{Value: "continue", Label: "Continue"}, + yield.Option{Value: "stop", Label: "Stop"}, ) if approval != "continue" { return yield.Outcome{}, ctx.Refused("the operator declined to continue") diff --git a/labs/22-yield/yield/examples/library/go/src/upgrade-dependency/main.go b/labs/22-yield/yield/examples/library/go/src/upgrade-dependency/main.go index 06a43f76..05fbeb3d 100644 --- a/labs/22-yield/yield/examples/library/go/src/upgrade-dependency/main.go +++ b/labs/22-yield/yield/examples/library/go/src/upgrade-dependency/main.go @@ -4,7 +4,6 @@ package main import ( "encoding/json" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) @@ -36,8 +35,8 @@ func main() { approval := ctx.AskUser( "approve-upgrade", "Apply the reviewed dependency upgrade?", - protocol.Option{Value: "continue", Label: "Continue"}, - protocol.Option{Value: "stop", Label: "Stop"}, + yield.Option{Value: "continue", Label: "Continue"}, + yield.Option{Value: "stop", Label: "Stop"}, ) if approval != "continue" { return yield.Outcome{}, ctx.Refused("the operator declined to continue") diff --git a/labs/22-yield/yield/examples/library/scripts/generate.mjs b/labs/22-yield/yield/examples/library/scripts/generate.mjs index c5ff7e9e..7939ed5e 100644 --- a/labs/22-yield/yield/examples/library/scripts/generate.mjs +++ b/labs/22-yield/yield/examples/library/scripts/generate.mjs @@ -310,8 +310,8 @@ function addOptionalGoSteps(body, pattern) { "approval := ctx.AskUser(", " " + quoted(pattern.approvalId) + ",", " " + quoted(pattern.approvalQuestion) + ",", - " protocol.Option{Value: \"continue\", Label: \"Continue\"},", - " protocol.Option{Value: \"stop\", Label: \"Stop\"},", + " yield.Option{Value: \"continue\", Label: \"Continue\"},", + " yield.Option{Value: \"stop\", Label: \"Stop\"},", ")", "if approval != \"continue\" {", " return yield.Outcome{}, ctx.Refused(\"the operator declined to continue\")", @@ -361,7 +361,6 @@ function renderGo(pattern) { "import (", " \"encoding/json\"", "", - ...(pattern.approvalId ? [" \"github.com/operatorstack/yield/internal/protocol\""] : []), " \"github.com/operatorstack/yield/sdk/yield\"", ")", "", diff --git a/labs/22-yield/yield/internal/conformance/testdata/skill-go/main.go b/labs/22-yield/yield/internal/conformance/testdata/skill-go/main.go index bf7c1fc3..18f16761 100644 --- a/labs/22-yield/yield/internal/conformance/testdata/skill-go/main.go +++ b/labs/22-yield/yield/internal/conformance/testdata/skill-go/main.go @@ -5,14 +5,13 @@ package main import ( "encoding/json" - "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/sdk/yield" ) func main() { yield.Main(func(ctx *yield.Context) (yield.Outcome, error) { proceed := ctx.AskUser("q1-proceed", "Proceed with the conformance run?", - protocol.Option{Value: "yes", Label: "Yes"}, protocol.Option{Value: "no", Label: "No"}) + yield.Option{Value: "yes", Label: "Yes"}, yield.Option{Value: "no", Label: "No"}) if proceed == "no" { return yield.Outcome{}, ctx.Refused("operator declined") } diff --git a/labs/22-yield/yield/sdk/python/README.md b/labs/22-yield/yield/sdk/python/README.md index f4e9b669..1182df5f 100644 --- a/labs/22-yield/yield/sdk/python/README.md +++ b/labs/22-yield/yield/sdk/python/README.md @@ -4,6 +4,27 @@ The Python implementation of the yield.v1 SDK execution contract (see `ir/README.md`). The import name is `yieldskill` because `yield` is a Python keyword. +Create a virtual environment before installation: + +```bash +# macOS and Linux +python3 -m venv .venv +source .venv/bin/activate +python -m pip install yieldskill==0.1.23 \ + --index-url https://get.operatorstack.systems/pip/simple/ +``` + +```powershell +# Windows PowerShell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install yieldskill==0.1.23 ` + --index-url https://get.operatorstack.systems/pip/simple/ +``` + +The launcher preserves the selected Python environment for `RunCommand`, even +when an adapter starts that interpreter without activating the environment. + ```python from yieldskill import define_skill diff --git a/labs/22-yield/yield/sdk/python/test_cli.py b/labs/22-yield/yield/sdk/python/test_cli.py index e414e76c..df2d87a4 100644 --- a/labs/22-yield/yield/sdk/python/test_cli.py +++ b/labs/22-yield/yield/sdk/python/test_cli.py @@ -28,6 +28,7 @@ def test_unix_replaces_process_and_forwards_arguments(self) -> None: self.assertEqual(call.args[:2], (str(binary), [str(binary), "test", "skill"])) self.assertEqual(call.args[2]["YIELD_LANGUAGE"], "python") self.assertEqual(call.args[2]["YIELD_PYTHON"], os.sys.executable) + self.assertEqual(call.args[2]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).resolve().parent)) def test_windows_preserves_exit_code_and_arguments(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -43,6 +44,23 @@ def test_windows_preserves_exit_code_and_arguments(self) -> None: self.assertFalse(call.kwargs["check"]) self.assertEqual(call.kwargs["env"]["YIELD_LANGUAGE"], "python") self.assertEqual(call.kwargs["env"]["YIELD_PYTHON"], os.sys.executable) + self.assertEqual(call.kwargs["env"]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).resolve().parent)) + + def test_selected_virtual_environment_is_first_on_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / "yskill" + binary.touch() + python = Path(directory) / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() + with mock.patch.object(_cli, "runtime_path", return_value=binary): + with mock.patch.dict(os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True): + with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with self.assertRaisesRegex(RuntimeError, "exec"): + _cli.run([], "linux") + environment = execute.call_args.args[2] + self.assertEqual(environment["YIELD_PYTHON"], str(python)) + self.assertEqual(environment["PATH"], f"{python.resolve().parent}{os.pathsep}/usr/bin") if __name__ == "__main__": diff --git a/labs/22-yield/yield/sdk/python/yieldskill/_cli.py b/labs/22-yield/yield/sdk/python/yieldskill/_cli.py index ba81ca98..714a0428 100644 --- a/labs/22-yield/yield/sdk/python/yieldskill/_cli.py +++ b/labs/22-yield/yield/sdk/python/yieldskill/_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shutil import subprocess import sys from pathlib import Path @@ -25,10 +26,15 @@ def run(argv: Sequence[str] | None = None, platform: str | None = None) -> int | """Replace this process on Unix; preserve the child exit code on Windows.""" args = [str(runtime_path(platform)), *(argv if argv is not None else sys.argv[1:])] selected = platform or sys.platform + python = os.environ.get("YIELD_PYTHON", sys.executable) + resolved_python = shutil.which(python) or python + python_bin = str(Path(resolved_python).resolve().parent) + inherited_path = os.environ.get("PATH", "") environment = { **os.environ, "YIELD_LANGUAGE": os.environ.get("YIELD_LANGUAGE", "python"), - "YIELD_PYTHON": os.environ.get("YIELD_PYTHON", sys.executable), + "YIELD_PYTHON": python, + "PATH": python_bin + (os.pathsep + inherited_path if inherited_path else ""), } if selected == "win32": return subprocess.run(args, check=False, env=environment).returncode diff --git a/labs/22-yield/yield/sdk/yield/public_api_test.go b/labs/22-yield/yield/sdk/yield/public_api_test.go new file mode 100644 index 00000000..125d2745 --- /dev/null +++ b/labs/22-yield/yield/sdk/yield/public_api_test.go @@ -0,0 +1,46 @@ +package yield_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestPublicAskUserOptionCompilesFromExternalModule(t *testing.T) { + _, current, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate SDK source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")) + dir := t.TempDir() + goMod := "module example.com/yield-consumer\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v0.0.0\nreplace github.com/operatorstack/yield => " + filepath.ToSlash(root) + "\n" + main := `package main + +import yield "github.com/operatorstack/yield/sdk/yield" + +func choose(ctx *yield.Context) string { + return ctx.AskUser("approve", "Continue?", yield.Option{Value: "yes", Label: "Yes"}) +} +` + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(main), 0o644); err != nil { + t.Fatal(err) + } + tidy := exec.Command("go", "mod", "tidy") + tidy.Dir = dir + tidy.Env = append(os.Environ(), "GOWORK=off") + if output, err := tidy.CombinedOutput(); err != nil { + t.Fatalf("external Go module cannot resolve Yield dependencies: %v\n%s", err, output) + } + + command := exec.Command("go", "test", "./...") + command.Dir = dir + command.Env = append(os.Environ(), "GOWORK=off") + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("external Go module cannot use yield.Option: %v\n%s", err, out) + } +} diff --git a/labs/22-yield/yield/sdk/yield/yield.go b/labs/22-yield/yield/sdk/yield/yield.go index 51081a79..f0fead76 100644 --- a/labs/22-yield/yield/sdk/yield/yield.go +++ b/labs/22-yield/yield/sdk/yield/yield.go @@ -35,6 +35,12 @@ type Outcome struct { Result any } +// Option is one allowed answer to an AskUser question. +type Option struct { + Value string + Label string +} + // Complete finishes the run with a result; evidence is the requirement // trail accumulated via Require. func (c *Context) Complete(result any) (Outcome, error) { @@ -59,8 +65,12 @@ func (c *Context) Refused(reason string) error { return &RefusedError{Reason: re // AskUser yields a question to be asked through the host's normal // interface and returns the selected value on resume. -func (c *Context) AskUser(id, question string, options ...protocol.Option) string { - payload := mustJSON(protocol.AskUserPayload{Question: question, Options: options}) +func (c *Context) AskUser(id, question string, options ...Option) string { + protocolOptions := make([]protocol.Option, 0, len(options)) + for _, option := range options { + protocolOptions = append(protocolOptions, protocol.Option{Value: option.Value, Label: option.Label}) + } + payload := mustJSON(protocol.AskUserPayload{Question: question, Options: protocolOptions}) valueSchema := map[string]any{"type": "string"} if len(options) > 0 { values := make([]string, 0, len(options))