From 6ab17b82d69769125a014a9fdcdfb298d1dc2f45 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 10:27:21 +0100 Subject: [PATCH 1/3] Add agent-first Yield bootstrap --- .changeset/agent-first-bootstrap.md | 5 + .github/workflows/npm-publish.yml | 23 ++ .github/workflows/release-finalize.yml | 1 + README.md | 29 +- cmd/yskill/agents.go | 12 + cmd/yskill/bootstrap.go | 375 ++++++++++++++++++ cmd/yskill/bootstrap_templates.go | 266 +++++++++++++ cmd/yskill/bootstrap_test.go | 289 ++++++++++++++ cmd/yskill/main.go | 5 + cmd/yskill/scaffold.go | 5 + docs/README.md | 12 +- docs/agent-setup.md | 47 ++- docs/convert-existing-skill.md | 74 ++-- docs/quickstart.md | 181 ++------- docs/reference/cli.md | 31 ++ docs/skill-workflows.md | 8 + packaging/assemble.mjs | 9 + packaging/assemble.test.mjs | 14 +- packaging/create-yield.test.mjs | 32 ++ packaging/create-yield/README.md | 9 + packaging/create-yield/bin/create-yield.mjs | 18 + packaging/create-yield/package.json | 35 ++ packaging/verify-registry-history.mjs | 2 +- scripts/check-release-control.mjs | 4 + scripts/readme.test.mjs | 16 +- sdk/python/README.md | 11 +- sdk/rust/README.md | 12 +- sdk/yield/README.md | 11 +- .../release-yield/src/release-controller.mjs | 1 + skills/release-yield/src/workflow.test.mjs | 4 +- 30 files changed, 1306 insertions(+), 235 deletions(-) create mode 100644 .changeset/agent-first-bootstrap.md create mode 100644 cmd/yskill/bootstrap.go create mode 100644 cmd/yskill/bootstrap_templates.go create mode 100644 cmd/yskill/bootstrap_test.go create mode 100644 packaging/create-yield.test.mjs create mode 100644 packaging/create-yield/README.md create mode 100644 packaging/create-yield/bin/create-yield.mjs create mode 100644 packaging/create-yield/package.json diff --git a/.changeset/agent-first-bootstrap.md b/.changeset/agent-first-bootstrap.md new file mode 100644 index 0000000..ed6437e --- /dev/null +++ b/.changeset/agent-first-bootstrap.md @@ -0,0 +1,5 @@ +--- +"@operatorstack/yield": minor +--- + +Add the agent-first bootstrap command, the tested workflow builder for all four SDKs, and the `@operatorstack/create-yield` initializer. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 56c3bdf..4f97aae 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -206,6 +206,18 @@ jobs: else (cd dist/release-unit/npm/yield && npm publish --tag "$DIST_TAG") fi + - name: Publish npm initializer + env: + DIST_TAG: ${{ needs.resolve.outputs.dist_tag }} + VERSION: ${{ needs.resolve.outputs.version }} + shell: bash + run: | + set -euo pipefail + if npm view "@operatorstack/create-yield@${VERSION}" version >/dev/null 2>&1; then + echo "@operatorstack/create-yield@${VERSION} already exists" + else + (cd dist/release-unit/npm/create-yield && npm publish --tag "$DIST_TAG") + fi - name: Verify complete npm release unit env: VERSION: ${{ needs.resolve.outputs.version }} @@ -220,6 +232,17 @@ jobs: sleep 10 done done + - name: Smoke test npm initializer + env: + VERSION: ${{ needs.resolve.outputs.version }} + shell: bash + run: | + set -euo pipefail + root="$RUNNER_TEMP/create-yield-smoke" + mkdir -p "$root" + npm create "@operatorstack/yield@${VERSION}" -- \ + --root "$root" --agent codex --dry-run + test ! -e "$root/skills" pypi: needs: [resolve, build] diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 09da042..99b23db 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -102,6 +102,7 @@ jobs: --name "crates-${version}-${SOURCE_SHA}" --dir "$RUNNER_TEMP/crates-receipt" for package in \ @operatorstack/yield \ + @operatorstack/create-yield \ @operatorstack/yield-darwin-amd64 @operatorstack/yield-darwin-arm64 \ @operatorstack/yield-linux-amd64 @operatorstack/yield-linux-arm64 \ @operatorstack/yield-windows-amd64 @operatorstack/yield-windows-arm64; do diff --git a/README.md b/README.md index b8c72a9..5cb2e17 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,31 @@ discover it. Verified with Cursor, Codex, and Claude Code. Registry-backed project paths are available for 73 more coding agents. +## Start with your coding agent + +Run the command for your project: + +| Language | Command | +|---|---| +| TypeScript | `npm create @operatorstack/yield@latest` | +| Python | `uvx --from yieldskill yskill bootstrap --language python` | +| Rust | `cargo install yieldskill --locked`, then `yskill bootstrap --language rust` | +| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go` | + +Yield detects the repository, language, and installed coding agents. It shows +every proposed file and dependency change. It asks before it writes. It then +installs, tests, and registers the `yield-workflow-builder` skill workflow. + +Restart your coding agent. Then ask: + +```text +Use Yield to turn my release skill into a tested workflow. +``` + +The builder can create a skill workflow from a description. It can also +convert an existing `SKILL.md`. Yield does not use install hooks to change the +repository. + ## Move repeated instructions into code A release skill often starts as prose: @@ -127,7 +152,7 @@ Every newly published canary runs the same contract tests in an isolated CI lane. Stable release execution remains pinned to an exact public version. -## Use Yield in five steps +## Advanced: build manually ### 1. Install Yield @@ -139,7 +164,7 @@ npm exec -- yskill --version ``` [Public npm releases](https://www.npmjs.com/package/@operatorstack/yield) -use trusted publishing. The SDK package and all six runtime packages include +use trusted publishing. The initializer, SDK, and six runtime packages include SLSA v1 provenance. ### 2. Create the workflow diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index 339805b..d4ae0f0 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -576,6 +576,18 @@ func within(parent, child string) bool { } func launcherFor(language, skillDir, repoRoot string) (string, error) { + if language == "python" { + profile, err := readBootstrapProfile(repoRoot) + if err != nil { + return "", err + } + if profile.LauncherProfile == "python-uvx" { + if profile.YieldVersion == "" { + return "", fmt.Errorf("bootstrap profile is missing yield_version") + } + return fmt.Sprintf("uvx --from 'yieldskill==%s' yskill", shellQuoteValue(profile.YieldVersion)), nil + } + } switch language { case "typescript": packageRoot, err := findTypeScriptPackageRoot(skillDir, repoRoot) diff --git a/cmd/yskill/bootstrap.go b/cmd/yskill/bootstrap.go new file mode 100644 index 0000000..6aadd0a --- /dev/null +++ b/cmd/yskill/bootstrap.go @@ -0,0 +1,375 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +const bootstrapSkillName = "yield-workflow-builder" + +type bootstrapProfile struct { + Version int `json:"version"` + YieldVersion string `json:"yield_version"` + Language string `json:"language"` + LauncherProfile string `json:"launcher_profile"` + Agents []string `json:"agents"` +} + +type bootstrapPlan struct { + Root string + Language string + SkillDir string + Profile bootstrapProfile + Agents []agentConfig + Files map[string]string + Adapters []string + Dependency string +} + +var bootstrapInput io.Reader = os.Stdin +var bootstrapCommand = func(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} +var bootstrapDoctor = func(skillDir, root string, agents []string) error { + args := []string{skillDir, "--root", root, "--test"} + for _, agent := range agents { + args = append(args, "--agent", agent) + } + return cmdDoctor(args) +} + +func cmdBootstrap(args []string) error { + fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError) + language := fs.String("language", "", "workflow-builder language (detected by default)") + root := fs.String("root", "", "repository root") + dryRun := fs.Bool("dry-run", false, "print the plan without writing files") + yes := fs.Bool("yes", false, "apply the printed plan without prompting") + var requested agentListFlag + fs.Var(&requested, "agent", "agent id, comma-separated ids, or auto") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("bootstrap takes no positional arguments") + } + plan, err := makeBootstrapPlan(*root, *language, requested) + if err != nil { + return err + } + printBootstrapPlan(plan) + if *dryRun { + fmt.Println("bootstrap: dry run complete; no files changed") + return nil + } + if !*yes && !confirmBootstrap(bootstrapInput) { + fmt.Println("bootstrap: cancelled; no files changed") + return nil + } + if err := applyBootstrapPlan(plan); err != nil { + return err + } + ids := make([]string, 0, len(plan.Agents)) + for _, agent := range plan.Agents { + ids = append(ids, agent.ID) + } + if err := bootstrapDoctor(plan.SkillDir, plan.Root, ids); err != nil { + return fmt.Errorf("verify workflow builder: %w", err) + } + registrations, err := registerSkill(plan.SkillDir, plan.Root, ids) + if err != nil { + return fmt.Errorf("register workflow builder: %w", err) + } + for _, item := range registrations { + fmt.Printf("registered: %-22s %s\n", item.AgentID, item.Path) + } + fmt.Println("bootstrap: workflow builder is ready") + fmt.Println("next: restart your coding agent, then say: Use Yield to turn my release skill into a tested workflow.") + return nil +} + +func makeBootstrapPlan(rootArg, language string, requested []string) (bootstrapPlan, error) { + language = strings.ToLower(strings.TrimSpace(language)) + cwd, err := os.Getwd() + if err != nil { + return bootstrapPlan{}, err + } + root, err := findRepoRoot(cwd, rootArg) + if err != nil { + return bootstrapPlan{}, err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return bootstrapPlan{}, fmt.Errorf("resolve repository root: %w", err) + } + if language == "" { + language, err = detectBootstrapLanguage(root) + if err != nil { + return bootstrapPlan{}, err + } + } + if language != "typescript" && language != "python" && language != "go" && language != "rust" { + return bootstrapPlan{}, fmt.Errorf("unsupported language %q; choose typescript, python, go, or rust", language) + } + registry, err := loadAgentRegistry() + if err != nil { + return bootstrapPlan{}, err + } + agents, err := selectAgents(registry, requested, root) + if err != nil { + return bootstrapPlan{}, err + } + ids := make([]string, 0, len(agents)) + adapters := make([]string, 0, len(agents)) + for _, agent := range agents { + ids = append(ids, agent.ID) + adapters = append(adapters, filepath.Join(root, filepath.FromSlash(agent.ProjectDir), bootstrapSkillName, "SKILL.md")) + } + profile := bootstrapProfile{ + Version: 1, YieldVersion: packageVersion(), Language: language, Agents: ids, + LauncherProfile: map[string]string{"typescript": "typescript-npm", "python": "python-uvx", "go": "repository-runtime", "rust": "repository-runtime"}[language], + } + files, dependency, err := renderBootstrapSkill(language, profile) + if err != nil { + return bootstrapPlan{}, err + } + skillDir := filepath.Join(root, "skills", bootstrapSkillName) + ownedSkill := false + if info, statErr := os.Stat(skillDir); statErr == nil { + if !info.IsDir() { + return bootstrapPlan{}, fmt.Errorf("workflow-builder path is not a directory: %s", skillDir) + } + b, readErr := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if readErr != nil || !strings.Contains(string(b), "generated-by: yskill-bootstrap") { + return bootstrapPlan{}, fmt.Errorf("refusing to overwrite user-owned directory %s", skillDir) + } + var existing struct { + Language string `json:"language"` + } + if config, readErr := os.ReadFile(filepath.Join(skillDir, "builder.json")); readErr != nil || json.Unmarshal(config, &existing) != nil || existing.Language == "" { + return bootstrapPlan{}, fmt.Errorf("refusing to update workflow builder with missing or invalid ownership metadata: %s", skillDir) + } + if existing.Language != language { + return bootstrapPlan{}, fmt.Errorf("workflow builder already uses %s; remove the generated builder before selecting %s", existing.Language, language) + } + ownedSkill = true + } else if !errors.Is(statErr, fs.ErrNotExist) { + return bootstrapPlan{}, statErr + } + for rel := range files { + if err := preflightBootstrapPath(root, filepath.Join(skillDir, filepath.FromSlash(rel)), ownedSkill); err != nil { + return bootstrapPlan{}, err + } + } + for _, path := range adapters { + if err := preflightBootstrapAdapter(root, path); err != nil { + return bootstrapPlan{}, err + } + } + return bootstrapPlan{Root: root, Language: language, SkillDir: skillDir, Profile: profile, Agents: agents, Files: files, Adapters: adapters, Dependency: dependency}, nil +} + +func preflightBootstrapPath(root, path string, ownedSkill bool) error { + if !within(root, path) { + return fmt.Errorf("bootstrap path escapes repository: %s", path) + } + if err := ensureContainedWrite(root, path); err != nil { + return err + } + if _, err := os.ReadFile(path); err == nil { + if !ownedSkill { + return fmt.Errorf("refusing to overwrite user-owned file %s", path) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +func preflightBootstrapAdapter(root, path string) error { + if err := ensureContainedWrite(root, path); err != nil { + return err + } + if b, err := os.ReadFile(path); err == nil { + if !strings.Contains(string(b), generatedAdapterPrefix+"skills/"+bootstrapSkillName+";") { + return fmt.Errorf("refusing to overwrite user-owned agent skill %s", path) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +func printBootstrapPlan(plan bootstrapPlan) { + fmt.Printf("bootstrap plan: language=%s root=%s\n", plan.Language, plan.Root) + paths := make([]string, 0, len(plan.Files)+len(plan.Adapters)+1) + for rel := range plan.Files { + paths = append(paths, filepath.Join(plan.SkillDir, filepath.FromSlash(rel))) + } + if plan.Language == "typescript" { + paths = append(paths, filepath.Join(plan.SkillDir, "package-lock.json")) + } + if plan.Language == "go" { + paths = append(paths, filepath.Join(plan.SkillDir, "go.sum")) + } + if plan.Language == "rust" { + paths = append(paths, filepath.Join(plan.SkillDir, "Cargo.lock")) + } + paths = append(paths, filepath.Join(plan.Root, ".yield", "bootstrap.json")) + paths = append(paths, filepath.Join(plan.Root, ".yield", ".gitignore")) + if plan.Language == "go" || plan.Language == "rust" { + paths = append(paths, localRuntimePath(plan.Root)) + } + paths = append(paths, plan.Adapters...) + sort.Strings(paths) + for _, path := range paths { + rel, _ := filepath.Rel(plan.Root, path) + fmt.Printf(" write %s\n", filepath.ToSlash(rel)) + } + if plan.Dependency != "" { + fmt.Printf(" run %s\n", plan.Dependency) + } + fmt.Println(" run yskill doctor skills/yield-workflow-builder --test") + fmt.Println(" run yskill register skills/yield-workflow-builder") +} + +func detectBootstrapLanguage(root string) (string, error) { + candidates := []struct { + language string + files []string + }{ + {language: "typescript", files: []string{"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"}}, + {language: "python", files: []string{"pyproject.toml", "requirements.txt", "uv.lock"}}, + {language: "rust", files: []string{"Cargo.toml", "Cargo.lock"}}, + {language: "go", files: []string{"go.mod", "go.work"}}, + } + var found []string + for _, candidate := range candidates { + for _, name := range candidate.files { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + found = append(found, candidate.language) + break + } + } + } + if len(found) == 1 { + return found[0], nil + } + if len(found) == 0 { + return "", fmt.Errorf("cannot detect a project language; pass --language typescript, python, rust, or go") + } + return "", fmt.Errorf("multiple project languages detected (%s); pass --language explicitly", strings.Join(found, ", ")) +} + +func confirmBootstrap(input io.Reader) bool { + fmt.Print("Apply this bootstrap plan? [y/N] ") + scanner := bufio.NewScanner(input) + if !scanner.Scan() { + return false + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + return answer == "y" || answer == "yes" +} + +func applyBootstrapPlan(plan bootstrapPlan) error { + if err := ensureLocalStateIgnored(plan.Root); err != nil { + return err + } + profileBytes, err := json.MarshalIndent(plan.Profile, "", " ") + if err != nil { + return err + } + if err := writeBootstrapFile(filepath.Join(plan.Root, ".yield", "bootstrap.json"), string(profileBytes)+"\n"); err != nil { + return err + } + keys := make([]string, 0, len(plan.Files)) + for rel := range plan.Files { + keys = append(keys, rel) + } + sort.Strings(keys) + for _, rel := range keys { + if err := writeBootstrapFile(filepath.Join(plan.SkillDir, filepath.FromSlash(rel)), plan.Files[rel]); err != nil { + return err + } + } + if plan.Language == "go" || plan.Language == "rust" { + if err := pinRuntimeAtRoot(plan.Root); err != nil { + return err + } + } + switch plan.Language { + case "typescript": + if err := bootstrapCommand(plan.SkillDir, "npm", "install", "--ignore-scripts", "--no-audit", "--no-fund"); err != nil { + return fmt.Errorf("install workflow-builder dependencies: %w", err) + } + case "go": + if err := bootstrapCommand(plan.SkillDir, "go", "mod", "tidy"); err != nil { + return fmt.Errorf("prepare workflow-builder dependencies: %w", err) + } + } + return nil +} + +func writeBootstrapFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".yield-bootstrap-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err := temporary.WriteString(content); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err == nil { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.Rename(temporaryPath, path) +} + +func readBootstrapProfile(repoRoot string) (bootstrapProfile, error) { + path := filepath.Join(repoRoot, ".yield", "bootstrap.json") + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return bootstrapProfile{}, nil + } + if err != nil { + return bootstrapProfile{}, err + } + var profile bootstrapProfile + decoder := json.NewDecoder(strings.NewReader(string(b))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&profile); err != nil { + return bootstrapProfile{}, fmt.Errorf("bootstrap profile does not decode: %w", err) + } + if profile.Version != 1 { + return bootstrapProfile{}, fmt.Errorf("bootstrap profile version must be 1") + } + return profile, nil +} + +func shellQuoteValue(value string) string { + return strings.ReplaceAll(value, "'", "'\\''") +} diff --git a/cmd/yskill/bootstrap_templates.go b/cmd/yskill/bootstrap_templates.go new file mode 100644 index 0000000..56cfd20 --- /dev/null +++ b/cmd/yskill/bootstrap_templates.go @@ -0,0 +1,266 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +func renderBootstrapSkill(language string, profile bootstrapProfile) (map[string]string, string, error) { + version := profile.YieldVersion + agents := strings.Join(profile.Agents, ",") + launcher := map[string]string{ + "typescript": "npm --prefix 'skills/yield-workflow-builder' exec -- yskill", + "python": fmt.Sprintf("uvx --from 'yieldskill==%s' yskill", shellQuoteValue(version)), + "go": ".yield/bin/yskill", + "rust": ".yield/bin/yskill", + }[language] + config, err := json.MarshalIndent(map[string]any{ + "version": 1, "yield_version": version, "language": language, + "launcher": launcher, "agents": profile.Agents, + }, "", " ") + if err != nil { + return nil, "", err + } + files := map[string]string{ + "SKILL.md": fmt.Sprintf(`--- +name: yield-workflow-builder +description: Create a tested Yield skill workflow from a description, or convert an existing SKILL.md into one. +--- + + + +Run from the repository root: + + %s run 'skills/yield-workflow-builder' + +Follow each returned operation exactly. Answer each operation directly: + + %s respond --value --skill 'skills/yield-workflow-builder' + +Use --result-json for structured agent work. When an operation asks you to +write or repair files, edit the repository before returning the JSON result. +Do not skip an operation or invent its response. +`, version, launcher, launcher), + "builder.json": string(config) + "\n", + "fixtures/responses.json": bootstrapFixtureResponses, + "fixtures/test.json": bootstrapFixtureConfig, + } + dependency := "" + switch language { + case "typescript": + files["main.ts"] = bootstrapTypeScript + files["package.json"] = fmt.Sprintf("{\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": { \"@operatorstack/yield\": \"%s\" }\n}\n", version) + files["skill.json"] = "{\"version\":1,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n" + dependency = "npm install --ignore-scripts --no-audit --no-fund (inside skills/yield-workflow-builder)" + case "python": + files["main.py"] = bootstrapPython + files["requirements.txt"] = fmt.Sprintf("yieldskill==%s\n", version) + files["skill.json"] = "{\"version\":1,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n" + case "go": + files["main.go"] = bootstrapGo + files["go.mod"] = fmt.Sprintf("module yield-workflow-builder\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v%s\n", version) + files["skill.json"] = "{\"version\":1,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n" + dependency = "go mod tidy (inside skills/yield-workflow-builder)" + case "rust": + files["src/main.rs"] = bootstrapRust + files["Cargo.toml"] = fmt.Sprintf("[package]\nname = \"yield-workflow-builder\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\" }\nserde_json = \"1\"\n", version) + files["skill.json"] = "{\"version\":1,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\"]}\n" + default: + return nil, "", fmt.Errorf("unsupported language %q", language) + } + for path, content := range files { + files[path] = strings.ReplaceAll(content, "{{AGENTS}}", agents) + } + return files, dependency, nil +} + +const bootstrapFixtureResponses = `{ + "select-mode": {"value": "create"}, + "collect-specification": { + "name": "yield-workflow-builder-fixture", + "description": "Create a harmless fixture workflow.", + "language": "go", + "destination": "skills/yield-workflow-builder-fixture", + "source_path": "" + }, + "extract-flow": { + "summary": "Ask for confirmation and complete.", + "steps": [{"id":"confirm","kind":"ask_user","description":"Ask for confirmation."}] + }, + "write-workflow": {"files":["skills/yield-workflow-builder-fixture/SKILL.md","skills/yield-workflow-builder-fixture/main.go"]} +} +` + +const bootstrapFixtureConfig = `{ + "version": 1, + "setup": [], + "after_response": {}, + "teardown": [] +} +` + +const bootstrapTypeScript = `import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, normalize, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineSkill } from "@operatorstack/yield"; + +type Specification = { name: string; description: string; language: "typescript"|"python"|"go"|"rust"; destination: string; source_path?: string }; +type Files = { files: string[] }; +const specSchema = {type:"object",required:["name","description","language","destination","source_path"],additionalProperties:false,properties:{name:{type:"string",pattern:"^[a-z0-9]+(?:-[a-z0-9]+)*$"},description:{type:"string",minLength:1},language:{enum:["typescript","python","go","rust"]},destination:{type:"string",minLength:1},source_path:{type:"string"}}}; +const flowSchema = {type:"object",required:["summary","steps"],additionalProperties:false,properties:{summary:{type:"string",minLength:1},steps:{type:"array",minItems:1,items:{type:"object",required:["id","kind","description"],additionalProperties:false,properties:{id:{type:"string",minLength:1},kind:{enum:["ask_user","agent_task","run_command","branch","require"]},description:{type:"string",minLength:1}}}}}}; +const filesSchema = {type:"object",required:["files"],additionalProperties:false,properties:{files:{type:"array",minItems:2,items:{type:"string",minLength:1}}}}; +const config = JSON.parse(readFileSync(new URL("./builder.json", import.meta.url), "utf8")) as {launcher:string;agents:string[]}; +const root = realpathSync(fileURLToPath(new URL("../../", import.meta.url))); +const quote = (v:string) => "'" + v.replaceAll("'", "'\\''") + "'"; +const inside = (p:string) => p === root || p.startsWith(root+sep); +const safe = (p:string, destination=false) => !isAbsolute(p) && !normalize(p).split(sep).includes("..") && normalize(p).startsWith("skills"+sep) && inside(realpathSync(destination ? dirname(resolve(root,p)) : resolve(root,p))); + +defineSkill((ctx) => { + const mode = ctx.askUser("select-mode", "Create a workflow from a description, or convert an existing SKILL.md?", [{value:"create",label:"Create"},{value:"convert",label:"Convert"}]); + const spec = ctx.agentTask("collect-specification", "Use the user's current request. Return a safe kebab-case name, a short description, the target language, a new destination under skills/, and source_path for convert mode. Do not write files.", {mode}, specSchema); + if (!safe(spec.destination, true) || spec.destination === "skills/yield-workflow-builder" || existsSync(resolve(root,spec.destination))) ctx.blocked("the destination must be a new path under skills/"); + let source = ""; + if (mode === "convert") { + if (!spec.source_path || !safe(spec.source_path)) ctx.blocked("the source skill must be inside the repository"); + const sourceFile = resolve(root,spec.source_path,"SKILL.md"); + if (!existsSync(sourceFile)) ctx.blocked("the source SKILL.md does not exist"); + source = readFileSync(sourceFile, "utf8"); + } + const flow = ctx.agentTask("extract-flow", "Extract or design the minimal workflow control flow. Keep model judgment in agent_task operations. Put order, branches, commands, approvals, evidence requirements, and finish rules in code.", {mode,spec,source}, flowSchema); + let written = ctx.agentTask("write-workflow", "Create the complete Yield skill workflow at the destination. Write the language program, thin SKILL.md, exact-version dependencies, skill.json when required, and self-contained fixtures. Do not edit files outside the destination. Return every file written.", {mode,spec,flow}, filesSchema); + const fixture = spec.destination === "skills/yield-workflow-builder-fixture"; + const base = fixture ? "printf fixture-ok" : "cd ../.. && "+config.launcher+" doctor "+quote(spec.destination)+" --test"; + let checked = ctx.runCommand("verify-generated", base, 600); + for (let attempt=1; checked.exit_code!==0 && attempt<=2; attempt++) { + written = ctx.agentTask("repair-generated-"+attempt, "The generated workflow failed verification. Fix only the destination files and return every changed file.", {spec,stdout:checked.stdout,stderr:checked.stderr}, filesSchema); + checked = ctx.runCommand("verify-generated-retry-"+attempt, base, 600); + } + if (checked.exit_code!==0) ctx.blocked("the generated workflow still fails after two repair attempts"); + ctx.require(checked.exit_code===0, "the generated workflow passes its fixture run", {exit_code:checked.exit_code}); + const agentFlag = config.agents.join(","); + const register = fixture ? "printf fixture-register-ok" : "cd ../.. && "+config.launcher+" register "+quote(spec.destination)+" --agent "+quote(agentFlag); + const registered = ctx.runCommand("register-generated", register, 120); + ctx.require(registered.exit_code===0, "the generated workflow is registered for the selected coding agents", {exit_code:registered.exit_code}); + const verifyAdapters = fixture ? "printf fixture-adapters-ok" : "cd ../.. && "+config.launcher+" doctor "+quote(spec.destination)+" --agent "+quote(agentFlag)+" --test"; + const adapters = ctx.runCommand("verify-adapters", verifyAdapters, 600); + ctx.require(adapters.exit_code===0, "the generated coding-agent adapters pass verification", {exit_code:adapters.exit_code}); + return {mode,language:spec.language,destination:spec.destination,files:written.files,verified:true}; +}); +` + +const bootstrapPython = `from pathlib import Path +import json +import os +from yieldskill import define_skill + +ROOT = Path(__file__).resolve().parents[2].resolve() +CONFIG = json.loads((Path(__file__).parent / "builder.json").read_text()) +SPEC_SCHEMA = {"type":"object","required":["name","description","language","destination","source_path"],"additionalProperties":False,"properties":{"name":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"description":{"type":"string","minLength":1},"language":{"enum":["typescript","python","go","rust"]},"destination":{"type":"string","minLength":1},"source_path":{"type":"string"}}} +FLOW_SCHEMA = {"type":"object","required":["summary","steps"],"additionalProperties":False,"properties":{"summary":{"type":"string","minLength":1},"steps":{"type":"array","minItems":1,"items":{"type":"object","required":["id","kind","description"],"additionalProperties":False,"properties":{"id":{"type":"string","minLength":1},"kind":{"enum":["ask_user","agent_task","run_command","branch","require"]},"description":{"type":"string","minLength":1}}}}}} +FILES_SCHEMA = {"type":"object","required":["files"],"additionalProperties":False,"properties":{"files":{"type":"array","minItems":2,"items":{"type":"string","minLength":1}}}} + +def safe(path, destination=False): + candidate = Path(path) + if candidate.is_absolute() or ".." in candidate.parts or len(candidate.parts) <= 1 or candidate.parts[0] != "skills": return False + resolved = ((ROOT / candidate).parent if destination else (ROOT / candidate)).resolve() + return resolved == ROOT or ROOT in resolved.parents + +def quote(value): + return "'" + value.replace("'", "'\\''") + "'" + +def program(ctx): + mode = ctx.ask_user("select-mode", "Create a workflow from a description, or convert an existing SKILL.md?", options=[{"value":"create","label":"Create"},{"value":"convert","label":"Convert"}]) + spec = ctx.agent_task("collect-specification", "Use the user's current request. Return a safe kebab-case name, a short description, the target language, a new destination under skills/, and source_path for convert mode. Do not write files.", context={"mode":mode}, schema=SPEC_SCHEMA) + destination = ROOT / spec["destination"] + if not safe(spec["destination"], True) or spec["destination"] == "skills/yield-workflow-builder" or destination.exists(): + ctx.blocked("the destination must be a new path under skills/") + source = "" + if mode == "convert": + if not spec.get("source_path") or not safe(spec["source_path"]): ctx.blocked("the source skill must be inside the repository") + source_file = ROOT / spec["source_path"] / "SKILL.md" + if not source_file.is_file(): ctx.blocked("the source SKILL.md does not exist") + source = source_file.read_text() + flow = ctx.agent_task("extract-flow", "Extract or design the minimal workflow control flow. Keep model judgment in agent_task operations. Put order, branches, commands, approvals, evidence requirements, and finish rules in code.", context={"mode":mode,"spec":spec,"source":source}, schema=FLOW_SCHEMA) + written = ctx.agent_task("write-workflow", "Create the complete Yield skill workflow at the destination. Write the language program, thin SKILL.md, exact-version dependencies, skill.json when required, and self-contained fixtures. Do not edit files outside the destination. Return every file written.", context={"mode":mode,"spec":spec,"flow":flow}, schema=FILES_SCHEMA) + fixture = spec["destination"] == "skills/yield-workflow-builder-fixture" + base = "printf fixture-ok" if fixture else "cd ../.. && " + CONFIG["launcher"] + " doctor " + quote(spec["destination"]) + " --test" + checked = ctx.run_command("verify-generated", base, timeout_seconds=600) + for attempt in range(1, 3): + if checked.exit_code == 0: break + written = ctx.agent_task(f"repair-generated-{attempt}", "The generated workflow failed verification. Fix only the destination files and return every changed file.", context={"spec":spec,"stdout":checked.stdout,"stderr":checked.stderr}, schema=FILES_SCHEMA) + checked = ctx.run_command(f"verify-generated-retry-{attempt}", base, timeout_seconds=600) + if checked.exit_code != 0: ctx.blocked("the generated workflow still fails after two repair attempts") + ctx.require(checked.exit_code == 0, "the generated workflow passes its fixture run", {"exit_code":checked.exit_code}) + agent_flag = ",".join(CONFIG["agents"]) + register = "printf fixture-register-ok" if fixture else "cd ../.. && " + CONFIG["launcher"] + " register " + quote(spec["destination"]) + " --agent " + quote(agent_flag) + registered = ctx.run_command("register-generated", register, timeout_seconds=120) + ctx.require(registered.exit_code == 0, "the generated workflow is registered for the selected coding agents", {"exit_code":registered.exit_code}) + verify = "printf fixture-adapters-ok" if fixture else "cd ../.. && " + CONFIG["launcher"] + " doctor " + quote(spec["destination"]) + " --agent " + quote(agent_flag) + " --test" + adapters = ctx.run_command("verify-adapters", verify, timeout_seconds=600) + ctx.require(adapters.exit_code == 0, "the generated coding-agent adapters pass verification", {"exit_code":adapters.exit_code}) + return {"mode":mode,"language":spec["language"],"destination":spec["destination"],"files":written["files"],"verified":True} + +define_skill(program) +` + +const bootstrapGo = `package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "github.com/operatorstack/yield/sdk/yield" +) +type specification struct { Name string ` + "`json:\"name\"`" + `; Description string ` + "`json:\"description\"`" + `; Language string ` + "`json:\"language\"`" + `; Destination string ` + "`json:\"destination\"`" + `; SourcePath string ` + "`json:\"source_path\"`" + ` } +type fileResult struct { Files []string ` + "`json:\"files\"`" + ` } +type config struct { Launcher string ` + "`json:\"launcher\"`" + `; Agents []string ` + "`json:\"agents\"`" + ` } +const specSchema = ` + "`" + `{"type":"object","required":["name","description","language","destination","source_path"],"additionalProperties":false,"properties":{"name":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"description":{"type":"string","minLength":1},"language":{"enum":["typescript","python","go","rust"]},"destination":{"type":"string","minLength":1},"source_path":{"type":"string"}}}` + "`" + ` +const flowSchema = ` + "`" + `{"type":"object","required":["summary","steps"],"additionalProperties":false,"properties":{"summary":{"type":"string","minLength":1},"steps":{"type":"array","minItems":1,"items":{"type":"object","required":["id","kind","description"],"additionalProperties":false,"properties":{"id":{"type":"string","minLength":1},"kind":{"enum":["ask_user","agent_task","run_command","branch","require"]},"description":{"type":"string","minLength":1}}}}}}` + "`" + ` +const filesSchema = ` + "`" + `{"type":"object","required":["files"],"additionalProperties":false,"properties":{"files":{"type":"array","minItems":2,"items":{"type":"string","minLength":1}}}}` + "`" + ` +func quote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } +func safe(root,value string,destination bool) bool { if filepath.IsAbs(value) { return false }; clean:=filepath.Clean(value); parts:=strings.Split(filepath.ToSlash(clean), "/"); if len(parts)<=1||parts[0]!="skills"||strings.Contains(filepath.ToSlash(clean), "../"){return false};probe:=filepath.Join(root,clean);if destination{probe=filepath.Dir(probe)};canonical,err:=filepath.EvalSymlinks(probe);return err==nil&&strings.HasPrefix(canonical+string(filepath.Separator),root+string(filepath.Separator)) } +func main() { yield.Main(func(ctx *yield.Context) (yield.Outcome,error) { + rawConfig,err:=os.ReadFile("builder.json"); if err!=nil{return yield.Outcome{},err}; var cfg config; if err=json.Unmarshal(rawConfig,&cfg);err!=nil{return yield.Outcome{},err} + mode:=ctx.AskUser("select-mode","Create a workflow from a description, or convert an existing SKILL.md?",yield.Option{Value:"create",Label:"Create"},yield.Option{Value:"convert",Label:"Convert"}) + raw:=ctx.AgentTask("collect-specification","Use the user's current request. Return a safe kebab-case name, a short description, the target language, a new destination under skills/, and source_path for convert mode. Do not write files.",map[string]any{"mode":mode},json.RawMessage(specSchema)); var spec specification; if err=json.Unmarshal(raw,&spec);err!=nil{return yield.Outcome{},err} + root,err:=filepath.Abs(filepath.Join("..","..")); if err!=nil{return yield.Outcome{},err};root,err=filepath.EvalSymlinks(root);if err!=nil{return yield.Outcome{},err}; destination:=filepath.Join(root,spec.Destination); if !safe(root,spec.Destination,true)||spec.Destination=="skills/yield-workflow-builder" { return yield.Outcome{},ctx.Blocked("the destination must be a new path under skills/") }; if _,err=os.Stat(destination);err==nil{return yield.Outcome{},ctx.Blocked("the destination must be a new path under skills/")}else if !os.IsNotExist(err){return yield.Outcome{},err} + source:=""; if mode=="convert" { if spec.SourcePath==""||!safe(root,spec.SourcePath,false){return yield.Outcome{},ctx.Blocked("the source skill must be inside the repository")}; b,readErr:=os.ReadFile(filepath.Join(root,spec.SourcePath,"SKILL.md"));if readErr!=nil{return yield.Outcome{},ctx.Blocked("the source SKILL.md does not exist")};source=string(b) } + flow:=ctx.AgentTask("extract-flow","Extract or design the minimal workflow control flow. Keep model judgment in agent_task operations. Put order, branches, commands, approvals, evidence requirements, and finish rules in code.",map[string]any{"mode":mode,"spec":spec,"source":source},json.RawMessage(flowSchema)) + writtenRaw:=ctx.AgentTask("write-workflow","Create the complete Yield skill workflow at the destination. Write the language program, thin SKILL.md, exact-version dependencies, skill.json when required, and self-contained fixtures. Do not edit files outside the destination. Return every file written.",map[string]any{"mode":mode,"spec":spec,"flow":json.RawMessage(flow)},json.RawMessage(filesSchema));var written fileResult;_ = json.Unmarshal(writtenRaw,&written) + fixture:=spec.Destination=="skills/yield-workflow-builder-fixture"; command:="printf fixture-ok";if !fixture{command="cd ../.. && "+cfg.Launcher+" doctor "+quote(spec.Destination)+" --test"};checked:=ctx.RunCommand("verify-generated",command,600) + for attempt:=1;checked.ExitCode!=0&&attempt<=2;attempt++{ repair:=ctx.AgentTask(fmt.Sprintf("repair-generated-%d",attempt),"The generated workflow failed verification. Fix only the destination files and return every changed file.",map[string]any{"spec":spec,"stdout":checked.Stdout,"stderr":checked.Stderr},json.RawMessage(filesSchema));_ = json.Unmarshal(repair,&written);checked=ctx.RunCommand(fmt.Sprintf("verify-generated-retry-%d",attempt),command,600) } + if checked.ExitCode!=0{return yield.Outcome{},ctx.Blocked("the generated workflow still fails after two repair attempts")};ctx.Require(checked.ExitCode==0,"the generated workflow passes its fixture run",map[string]any{"exit_code":checked.ExitCode}) + agentFlag:=strings.Join(cfg.Agents,",");register:="printf fixture-register-ok";if !fixture{register="cd ../.. && "+cfg.Launcher+" register "+quote(spec.Destination)+" --agent "+quote(agentFlag)};registered:=ctx.RunCommand("register-generated",register,120);ctx.Require(registered.ExitCode==0,"the generated workflow is registered for the selected coding agents",map[string]any{"exit_code":registered.ExitCode}) + verify:="printf fixture-adapters-ok";if !fixture{verify="cd ../.. && "+cfg.Launcher+" doctor "+quote(spec.Destination)+" --agent "+quote(agentFlag)+" --test"};adapters:=ctx.RunCommand("verify-adapters",verify,600);ctx.Require(adapters.ExitCode==0,"the generated coding-agent adapters pass verification",map[string]any{"exit_code":adapters.ExitCode}) + return ctx.Complete(map[string]any{"mode":mode,"language":spec.Language,"destination":spec.Destination,"files":written.Files,"verified":true}) +}) } +` + +const bootstrapRust = `use serde_json::{json, Value}; +use std::{fs, path::{Path, PathBuf}}; +use yieldskill::{define_skill, Context, SkillResult}; +const SPEC_SCHEMA:&str=r#"{"type":"object","required":["name","description","language","destination","source_path"],"additionalProperties":false,"properties":{"name":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"description":{"type":"string","minLength":1},"language":{"enum":["typescript","python","go","rust"]},"destination":{"type":"string","minLength":1},"source_path":{"type":"string"}}}"#; +const FLOW_SCHEMA:&str=r#"{"type":"object","required":["summary","steps"],"additionalProperties":false,"properties":{"summary":{"type":"string","minLength":1},"steps":{"type":"array","minItems":1,"items":{"type":"object","required":["id","kind","description"],"additionalProperties":false,"properties":{"id":{"type":"string","minLength":1},"kind":{"enum":["ask_user","agent_task","run_command","branch","require"]},"description":{"type":"string","minLength":1}}}}}}"#; +const FILES_SCHEMA:&str=r#"{"type":"object","required":["files"],"additionalProperties":false,"properties":{"files":{"type":"array","minItems":2,"items":{"type":"string","minLength":1}}}}"#; +fn safe(root:&Path,value:&str,destination:bool)->bool{let p=Path::new(value);if p.is_absolute()||p.components().any(|c|matches!(c,std::path::Component::ParentDir))||p.components().next().map(|c|c.as_os_str()!="skills").unwrap_or(true)||p.components().count()<=1{return false}let joined=root.join(p);let probe=if destination{joined.parent().unwrap_or(root)}else{joined.as_path()};probe.canonicalize().map(|resolved|resolved.starts_with(root)).unwrap_or(false)} +fn quote(value:&str)->String{format!("'{}'",value.replace('\'',"'\\''"))} +fn program(ctx:&mut Context)->SkillResult{ + let cfg:Value=serde_json::from_slice(&fs::read("builder.json").expect("builder.json must be readable")).expect("builder.json must be valid JSON"); + let mode=ctx.ask_user("select-mode","Create a workflow from a description, or convert an existing SKILL.md?",&[("create","Create"),("convert","Convert")]); + let spec=ctx.agent_task("collect-specification","Use the user's current request. Return a safe kebab-case name, a short description, the target language, a new destination under skills/, and source_path for convert mode. Do not write files.",Some(json!({"mode":mode})),Some(serde_json::from_str(SPEC_SCHEMA).unwrap())); + let destination=spec["destination"].as_str().unwrap_or("");let root=PathBuf::from("../..").canonicalize().expect("repository root must be readable");if !safe(&root,destination,true)||destination=="skills/yield-workflow-builder"||root.join(destination).exists(){return Err(ctx.blocked("the destination must be a new path under skills/"))} + let mut source=String::new();if mode=="convert"{let source_path=spec["source_path"].as_str().unwrap_or("");if !safe(&root,source_path,false){return Err(ctx.blocked("the source skill must be inside the repository"))};source=fs::read_to_string(root.join(source_path).join("SKILL.md")).map_err(|_|ctx.blocked("the source SKILL.md does not exist"))?;} + let flow=ctx.agent_task("extract-flow","Extract or design the minimal workflow control flow. Keep model judgment in agent_task operations. Put order, branches, commands, approvals, evidence requirements, and finish rules in code.",Some(json!({"mode":mode,"spec":spec,"source":source})),Some(serde_json::from_str(FLOW_SCHEMA).unwrap())); + let mut written=ctx.agent_task("write-workflow","Create the complete Yield skill workflow at the destination. Write the language program, thin SKILL.md, exact-version dependencies, skill.json when required, and self-contained fixtures. Do not edit files outside the destination. Return every file written.",Some(json!({"mode":mode,"spec":spec,"flow":flow})),Some(serde_json::from_str(FILES_SCHEMA).unwrap())); + let fixture=destination=="skills/yield-workflow-builder-fixture";let launcher=cfg["launcher"].as_str().unwrap();let command=if fixture{"printf fixture-ok".to_string()}else{format!("cd ../.. && {} doctor {} --test",launcher,quote(destination))};let mut checked=ctx.run_command("verify-generated",&command,600); + for attempt in 1..=2{if checked.exit_code==0{break}written=ctx.agent_task(&format!("repair-generated-{attempt}"),"The generated workflow failed verification. Fix only the destination files and return every changed file.",Some(json!({"spec":spec,"stdout":checked.stdout,"stderr":checked.stderr})),Some(serde_json::from_str(FILES_SCHEMA).unwrap()));checked=ctx.run_command(&format!("verify-generated-retry-{attempt}"),&command,600)} + if checked.exit_code!=0{return Err(ctx.blocked("the generated workflow still fails after two repair attempts"))}ctx.require(true,"the generated workflow passes its fixture run",Some(&json!({"exit_code":checked.exit_code}))); + let agents=cfg["agents"].as_array().unwrap().iter().filter_map(|v|v.as_str()).collect::>().join(",");let register=if fixture{"printf fixture-register-ok".to_string()}else{format!("cd ../.. && {} register {} --agent {}",launcher,quote(destination),quote(&agents))};let registered=ctx.run_command("register-generated",®ister,120);ctx.require(registered.exit_code==0,"the generated workflow is registered for the selected coding agents",Some(&json!({"exit_code":registered.exit_code}))); + let verify=if fixture{"printf fixture-adapters-ok".to_string()}else{format!("cd ../.. && {} doctor {} --agent {} --test",launcher,quote(destination),quote(&agents))};let adapters=ctx.run_command("verify-adapters",&verify,600);ctx.require(adapters.exit_code==0,"the generated coding-agent adapters pass verification",Some(&json!({"exit_code":adapters.exit_code}))); + Ok(json!({"mode":mode,"language":spec["language"],"destination":destination,"files":written["files"],"verified":true})) +} +fn main(){define_skill(program);} +` diff --git a/cmd/yskill/bootstrap_test.go b/cmd/yskill/bootstrap_test.go new file mode 100644 index 0000000..0ee96df --- /dev/null +++ b/cmd/yskill/bootstrap_test.go @@ -0,0 +1,289 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func withBootstrapTestState(t *testing.T) { + t.Helper() + oldVersion := version + oldInput := bootstrapInput + oldCommand := bootstrapCommand + oldDoctor := bootstrapDoctor + version = "1.2.3" + bootstrapInput = strings.NewReader("yes\n") + bootstrapCommand = func(string, string, ...string) error { return nil } + bootstrapDoctor = func(string, string, []string) error { return nil } + t.Cleanup(func() { + version = oldVersion + bootstrapInput = oldInput + bootstrapCommand = oldCommand + bootstrapDoctor = oldDoctor + }) +} + +func TestBootstrapDryRunDoesNotWrite(t *testing.T) { + withBootstrapTestState(t) + root := t.TempDir() + if err := cmdBootstrap([]string{"--root", root, "--language", "typescript", "--agent", "codex", "--dry-run"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) { + t.Fatalf("dry run wrote skills directory: %v", err) + } +} + +func TestBootstrapCancellationDoesNotWrite(t *testing.T) { + withBootstrapTestState(t) + bootstrapInput = bytes.NewBufferString("no\n") + root := t.TempDir() + if err := cmdBootstrap([]string{"--root", root, "--language", "python", "--agent", "codex"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) { + t.Fatalf("cancelled bootstrap wrote skills directory: %v", err) + } +} + +func TestBootstrapWritesBuilderProfileAndAdapter(t *testing.T) { + withBootstrapTestState(t) + root := t.TempDir() + if err := cmdBootstrap([]string{"--root", root, "--language", "python", "--agent", "codex", "--yes"}); err != nil { + t.Fatal(err) + } + for _, path := range []string{ + "skills/yield-workflow-builder/SKILL.md", + "skills/yield-workflow-builder/main.py", + ".yield/bootstrap.json", + ".agents/skills/yield-workflow-builder/SKILL.md", + } { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(path))); err != nil { + t.Fatalf("missing %s: %v", path, err) + } + } + adapter, _ := os.ReadFile(filepath.Join(root, ".agents/skills/yield-workflow-builder/SKILL.md")) + if !strings.Contains(string(adapter), "uvx --from 'yieldskill==1.2.3' yskill") { + t.Fatalf("adapter does not use the pinned uvx launcher:\n%s", adapter) + } + if err := cmdBootstrap([]string{"--root", root, "--language", "python", "--agent", "codex", "--yes"}); err != nil { + t.Fatalf("idempotent bootstrap failed: %v", err) + } +} + +func TestBootstrapRefusesForeignSkillAndAdapter(t *testing.T) { + withBootstrapTestState(t) + for _, collision := range []string{ + "skills/yield-workflow-builder/SKILL.md", + ".agents/skills/yield-workflow-builder/SKILL.md", + } { + root := t.TempDir() + path := filepath.Join(root, filepath.FromSlash(collision)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("user owned\n"), 0o644); err != nil { + t.Fatal(err) + } + err := cmdBootstrap([]string{"--root", root, "--language", "typescript", "--agent", "codex", "--yes"}) + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite user-owned") { + t.Fatalf("collision %s returned %v", collision, err) + } + } +} + +func TestBootstrapRefusesAdapterSymlinkEscape(t *testing.T) { + withBootstrapTestState(t) + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, ".agents")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + err := cmdBootstrap([]string{"--root", root, "--language", "typescript", "--agent", "codex", "--yes"}) + if err == nil || !strings.Contains(err.Error(), "outside repository") { + t.Fatalf("symlink escape returned %v", err) + } +} + +func TestBuilderTemplatesExposeEquivalentOperations(t *testing.T) { + profile := bootstrapProfile{YieldVersion: "1.2.3", Agents: []string{"codex"}} + want := []string{"select-mode", "collect-specification", "extract-flow", "write-workflow", "verify-generated", "repair-generated-", "register-generated", "verify-adapters"} + for _, language := range []string{"typescript", "python", "go", "rust"} { + files, _, err := renderBootstrapSkill(language, profile) + if err != nil { + t.Fatal(err) + } + var program string + for _, path := range []string{"main.ts", "main.py", "main.go", "src/main.rs"} { + if files[path] != "" { + program = files[path] + } + } + for _, operation := range want { + if !strings.Contains(program, operation) { + t.Errorf("%s builder is missing operation %s", language, operation) + } + } + } +} + +func TestBootstrapDetectsOneLanguageAndRejectsAmbiguity(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "pyproject.toml"), []byte("[project]\n"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := detectBootstrapLanguage(root); err != nil || got != "python" { + t.Fatalf("detected %q, %v", got, err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/test\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := detectBootstrapLanguage(root); err == nil || !strings.Contains(err.Error(), "multiple project languages") { + t.Fatalf("ambiguous detection returned %v", err) + } +} + +func TestBuilderTemplatesCompile(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + profile := bootstrapProfile{YieldVersion: "0.0.0", Agents: []string{"codex"}} + tests := []struct { + language string + command string + args []string + }{ + {language: "typescript", command: "node", args: []string{"--check", "main.ts"}}, + {language: "python", command: "python3", args: []string{"-m", "py_compile", "main.py"}}, + {language: "go", command: "go", args: []string{"test", "./..."}}, + {language: "rust", command: "cargo", args: []string{"check", "--quiet"}}, + } + for _, test := range tests { + t.Run(test.language, func(t *testing.T) { + if _, err := exec.LookPath(test.command); err != nil { + t.Skipf("%s is unavailable", test.command) + } + dir := t.TempDir() + files, _, err := renderBootstrapSkill(test.language, profile) + if err != nil { + t.Fatal(err) + } + if test.language == "go" { + files["go.mod"] += "\nreplace github.com/operatorstack/yield => " + filepath.ToSlash(repoRoot) + "\n" + } + if test.language == "rust" { + files["Cargo.toml"] = strings.Replace(files["Cargo.toml"], `yieldskill = { version = "=0.0.0" }`, `yieldskill = { path = "`+filepath.ToSlash(filepath.Join(repoRoot, "sdk", "rust"))+`" }`, 1) + } + for path, content := range files { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if test.language == "go" { + command := exec.Command("go", "mod", "tidy") + command.Dir = dir + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("prepare go template: %v\n%s", err, output) + } + } + command := exec.Command(test.command, test.args...) + command.Dir = dir + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s template does not compile: %v\n%s", test.language, err, output) + } + }) + } +} + +func TestBuilderFixturesReachCompletedAcrossLanguages(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + profile := bootstrapProfile{YieldVersion: "0.0.0", Agents: []string{"codex"}} + for _, language := range []string{"typescript", "python", "go", "rust"} { + t.Run(language, func(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "skills", bootstrapSkillName) + files, _, err := renderBootstrapSkill(language, profile) + if err != nil { + t.Fatal(err) + } + if language == "go" { + files["go.mod"] += "\nreplace github.com/operatorstack/yield => " + filepath.ToSlash(repoRoot) + "\n" + } + if language == "rust" { + files["Cargo.toml"] = strings.Replace(files["Cargo.toml"], `yieldskill = { version = "=0.0.0" }`, `yieldskill = { path = "`+filepath.ToSlash(filepath.Join(repoRoot, "sdk", "rust"))+`" }`, 1) + } + for path, content := range files { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + switch language { + case "typescript": + runTestCommand(t, filepath.Join(repoRoot, "sdk", "typescript"), "npm", "run", "build") + module := filepath.Join(dir, "node_modules", "@operatorstack", "yield") + if err := os.MkdirAll(filepath.Dir(module), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(repoRoot, "sdk", "typescript"), module); err != nil { + t.Fatal(err) + } + case "python": + old := os.Getenv("PYTHONPATH") + t.Setenv("PYTHONPATH", filepath.Join(repoRoot, "sdk", "python")+string(os.PathListSeparator)+old) + t.Setenv("PYTHONDONTWRITEBYTECODE", "1") + python3, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is unavailable") + } + bin := filepath.Join(root, ".yield", "test-bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(python3, filepath.Join(bin, "python")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + case "go": + runTestCommand(t, dir, "go", "mod", "tidy") + } + if language == "go" || language == "rust" { + runtimePath := filepath.Join(root, ".yield", "bin", "yskill") + if err := os.MkdirAll(filepath.Dir(runtimePath), 0o755); err != nil { + t.Fatal(err) + } + runTestCommand(t, repoRoot, "go", "build", "-o", runtimePath, "./cmd/yskill") + } + if err := cmdDoctor([]string{dir, "--root", root, "--test"}); err != nil { + t.Fatalf("%s builder fixture did not complete: %v", language, err) + } + }) + } +} + +func runTestCommand(t *testing.T, dir, name string, args ...string) { + t.Helper() + if _, err := exec.LookPath(name); err != nil { + t.Skipf("%s is unavailable", name) + } + command := exec.Command(name, args...) + command.Dir = dir + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, output) + } +} diff --git a/cmd/yskill/main.go b/cmd/yskill/main.go index e10b845..4e3191d 100644 --- a/cmd/yskill/main.go +++ b/cmd/yskill/main.go @@ -27,6 +27,9 @@ import ( const usage = `yskill — run and resume skill workflows Usage: + yskill bootstrap install the governed workflow builder + [--language typescript|python|go|rust] [--agent cursor,codex,...|auto] + [--root repo] [--dry-run] [--yes] yskill init scaffold a skill workflow (or wrap an existing prose skill) [--language typescript|python|go|rust] [--description text] yskill register expose one skill workflow to coding agents @@ -70,6 +73,8 @@ func main() { } var err error switch os.Args[1] { + case "bootstrap": + err = cmdBootstrap(os.Args[2:]) case "init": err = cmdInit(os.Args[2:]) case "register": diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index d7f0aaf..32560e3 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -158,6 +158,11 @@ func pinCurrentRuntime(dir string) error { if err != nil { return nil } + return pinRuntimeAtRoot(root) +} + +func pinRuntimeAtRoot(root string) error { + var err error source := strings.TrimSpace(os.Getenv("YIELD_LAUNCHER_PATH")) if source == "" { source, err = currentExecutable() diff --git a/docs/README.md b/docs/README.md index 0a3a4fa..8c49ac3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,12 +23,12 @@ and start it. ## Start here -1. [Read the public guide](https://yield.operatorstack.systems/docs/) — the - quickest path from installation to a running workflow. +1. [Bootstrap the workflow builder](quickstart.md) — install, test, and + register it for your coding agent. 2. [Understand skill workflows](skill-workflows.md) — the canonical workflow, generated adapter, and execution boundary. -3. [Build and run your first skill workflow](quickstart.md) — a TypeScript - workflow you can test in about ten minutes. +3. [Read the public guide](https://yield.operatorstack.systems/docs/) — the + complete guide for the current release. 4. [Register it with your coding agents](agent-setup.md) — keep one workflow and generate the small discovery adapters each agent needs. 5. [Learn the primitives](primitives/README.md) — commands, model work, human @@ -37,8 +37,8 @@ and start it. environment repair, bounded debugging, and migration. 7. [Browse the examples](examples.md) — working skill workflows in Go, TypeScript, Python, and Rust. -8. [Convert an existing prose skill](convert-existing-skill.md) — use Yield's - verified converter after you understand one ordinary workflow. +8. [Convert an existing prose skill](convert-existing-skill.md) — use the + workflow builder to convert an existing `SKILL.md`. ## The split to remember diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 9d27bb5..c8099f9 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -69,31 +69,34 @@ Use the review skill to check the current branch. The host owns how the request is presented. The generated adapter starts the canonical workflow under `skills/review`; it does not contain a second copy. -## Copy this to your agent +## Set up the workflow builder -Replace the bracketed values, then paste this into the coding agent already -open in the project: +Run the native bootstrap command from the repository root: + +```bash +# TypeScript +npm create @operatorstack/yield@latest + +# Python +uvx --from yieldskill yskill bootstrap --language python + +# Rust +cargo install yieldskill --locked +yskill bootstrap --language rust + +# Go +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go +``` + +Bootstrap shows every proposed change. Confirm the plan. Restart the coding +agent after registration. Then ask: ```text -Set up a Yield skill workflow named [skill-name] in skills/[skill-name]. - -1. Detect whether this project uses TypeScript, Python, Go, or Rust. -2. Install that language's Yield package using the project's existing package - manager. Do not install a second global runtime. -3. Run yskill init with the detected language and this description: - [what the workflow does and when it should run] -4. Replace the starter program and fixture with the requested skill workflow. The - starter is intentionally blocked and must not pass tests unchanged. -5. Run yskill doctor with --test before registration. -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 .yield/bin/yskill. -9. Run yskill doctor with --agent and --test. -10. Report the commands, generated adapter path, and every changed file. - -Do not move the skill workflow into an agent discovery directory and do not copy its -dependencies into an adapter. +Use Yield to turn my release skill into a tested workflow. +``` + +The builder collects the specification, writes the skill workflow, runs its +fixture, allows two repair attempts, registers adapters, and verifies them. ## Questions and agent results diff --git a/docs/convert-existing-skill.md b/docs/convert-existing-skill.md index 47fa094..8c3c1ef 100644 --- a/docs/convert-existing-skill.md +++ b/docs/convert-existing-skill.md @@ -1,64 +1,38 @@ -# Convert an existing prose skill +# Convert an existing skill -Use conversion after you have run one ordinary Yield workflow. Conversion is a -separate job from the runtime itself: the converter helps extract and encode a -policy; Yield then executes and verifies the resulting program. +The workflow builder can convert an existing `SKILL.md` into a tested skill +workflow. The source and destination must stay inside the repository. -The repository includes [`examples/convert-skill`](../examples/convert-skill/), -a converter that is itself a Yield skill. +## 1. Install the builder -## What moves, and what stays - -Keep these in the thin `SKILL.md`: - -- the goal and when the skill should be used; -- domain context the model needs; -- judgment criteria and useful examples; -- the instruction to start and resume the Yield program. - -Move these into the program: - -- required order; -- branches and retry limits; -- commands whose output must be observed; -- approval points; -- claims required for completion; -- `Blocked` and `Refused` outcomes. - -## Run the converter - -From a checkout of the public repository: +Run the bootstrap command for the project language. For example: ```bash -go build -o /tmp/yskill ./cmd/yskill -cd examples/convert-skill -YSKILL=/tmp/yskill /tmp/yskill run . +npm create @operatorstack/yield@latest ``` -The workflow asks for: +See the [quickstart](quickstart.md) for Python, Rust, and Go commands. -1. the source directory containing `SKILL.md`; -2. a target language; -3. a destination directory. +## 2. Restart the coding agent -The coding agent extracts the implicit flow and writes the generated files. The -converter then runs the generated skill's own fixtures under `yskill test`. It -allows two bounded repair attempts and returns `Blocked` if the fixture run -still fails. +Restart the session after bootstrap registers the adapter. -## What “verified” means here +## 3. Request the conversion -The converter verifies that the generated program executes its declared fixture -path. It does **not** prove that the extracted policy is behaviorally equivalent -to every reading of the original prose. +Tell the agent which skill to convert and what must remain true: + +```text +Use Yield to convert skills/release/SKILL.md into a tested skill workflow. +Keep the approval before publish. Require registry verification before completion. +``` -Review the conversion as a policy change: +The builder reads the source, extracts its control flow, and writes a new +destination. It refuses an existing destination. It never overwrites the +source. -- map every load-bearing source instruction to code, retained model judgment, - or an explicit exclusion; -- add a positive fixture and at least one negative or refusal path; -- test bypass attempts and failure states; -- replay a completed run to check determinism; -- keep performance or token-reduction claims separate from runtime correctness. +The builder runs the generated fixture. It allows two repair attempts. It then +registers and verifies the selected coding-agent adapters. It reports success +only after these checks pass. -The converter cannot report success before the generated fixture run passes. +Review the generated program. A passing fixture proves the tested path. It +does not prove that every sentence in the prose skill has the same behavior. diff --git a/docs/quickstart.md b/docs/quickstart.md index f8c1d26..b297b82 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,170 +1,61 @@ # Create your first skill workflow -This tutorial turns a repeated review checklist into a small TypeScript -program: run a real check, ask the coding agent to review the branch, stop on -critical findings, and save the structured result. +Bootstrap installs a tested workflow builder for your coding agent. Run one +command from the repository root. -You need Node.js 24 or newer. +## 1. Run bootstrap -## 1. Install Yield +Choose the command for the project language: ```bash -mkdir yield-example -cd yield-example -npm init -y -npm install --save-exact @operatorstack/yield -npm exec -- yskill --version -``` - -The package includes the TypeScript SDK and its matching Yield runtime. - -## 2. Initialize the skill workflow - -```bash -npm exec -- yskill init skills/review \ - --language typescript \ - --description "Check and review the current branch before it is shipped." -``` - -The canonical workflow stays under `skills/review`, inside the same dependency tree as -`@operatorstack/yield`. The generated `skill.json` records the language and -program entry point: - -```json -{"version":1,"language":"typescript","run":["node","main.ts"]} -``` +# TypeScript +npm create @operatorstack/yield@latest -The starter is intentionally incomplete. It cannot pass `doctor --test` until -you replace its program and fixture with the behavior described by the skill. - -## 3. Implement the workflow and fixture - -Replace `skills/review/main.ts`: - -```ts -import { defineSkill } from "@operatorstack/yield"; - -type Review = { - critical: number; - summary: string; -}; - -defineSkill((ctx) => { - const check = ctx.runCommand("check", "npm run check", 60); - ctx.require(check.exit_code === 0, "the code check passes", check); - - const review = ctx.agentTask( - "review", - "Review the current branch. Find correctness, security, and data-loss risks.", - undefined, - { - type: "object", - required: ["critical", "summary"], - properties: { - critical: { type: "number" }, - summary: { type: "string" }, - }, - }, - ); - - ctx.require(review.critical === 0, "no critical findings remain", review); - return review; -}); -``` +# Python +uvx --from yieldskill yskill bootstrap --language python -Add the real project check to the root `package.json`: +# Rust +cargo install yieldskill --locked +yskill bootstrap --language rust -```json -{"scripts":{"check":"node --check skills/review/main.ts"}} +# Go +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go ``` -The generated `skills/review/SKILL.md` remains short. It tells the agent when -to use the workflow and how to follow the yielded operations; the program owns -the order and finish rule. - -## 4. Test the skill workflow +Bootstrap detects installed Codex, Claude Code, and Cursor project adapters. +Use `--agent codex,claude-code,cursor` to select them explicitly. -Replace `skills/review/fixtures/responses.json`: +## 2. Review the plan -```json -{ - "review": { - "critical": 0, - "summary": "No critical findings in the fixture run." - } -} -``` +Yield prints every file, dependency, and command that it will change. Confirm +the plan to continue. Use `--dry-run` to stop after the plan. Use `--yes` only +when another trusted process already approved the changes. -Run the workflow check: +Yield writes the builder under `skills/yield-workflow-builder`. It stores local +bootstrap state under ignored `.yield/`. It does not use an install hook. -```bash -npm exec -- yskill doctor skills/review --test -``` +## 3. Restart the coding agent -`run_command` operations execute for real. The fixture supplies only model and -user responses. A successful result ends with `reached completed` and a doctor -summary. +Restart the coding-agent session after registration. This lets the agent find +the new adapter. -## 5. Generate coding-agent adapters - -```bash -# Detect installed verified agents -npm exec -- yskill register skills/review - -# Or select them explicitly -npm exec -- yskill register skills/review \ - --agent cursor,codex,claude-code -``` +## 4. Ask for the skill workflow -Yield keeps one canonical skill workflow and writes only generated adapters: +Use a plain-language request: ```text -.cursor/skills/review/SKILL.md # Cursor -.agents/skills/review/SKILL.md # Codex -.claude/skills/review/SKILL.md # Claude Code +Use Yield to turn my release skill into a tested workflow. ``` -Check the generated adapters: +The builder can start from a description. It can also convert an existing +`SKILL.md`. It writes the workflow, runs `doctor --test`, allows two repair +attempts, registers adapters, and verifies them. -```bash -npm exec -- yskill doctor skills/review \ - --agent cursor,codex,claude-code -``` - -## 6. Run the skill - -Start a new coding-agent session so it discovers the generated adapter. Where -slash skills are supported, run: - -```text -/review -``` - -Otherwise, ask the agent in plain language: - -```text -Use the review skill to check the current branch. -``` - -The agent starts the canonical workflow in `skills/review` and follows each -operation until the run completes, blocks, or is refused. - -## Run an existing workflow - -Initialization is only for creating or wrapping a workflow. For an existing -workflow, install the matching language package, register it for the agents in -the project, then run it directly when needed: - -```bash -npm exec -- yskill register skills/review --agent cursor -npm exec -- yskill run skills/review -``` +The workflow remains under `skills/`. Generated agent adapters contain only +the commands that start and resume it. -The agent reads the generated adapter, starts the canonical skill workflow, performs -each yielded operation, and answers with `yskill respond`. If the session -closes, the run remains on disk. +## Advanced: build manually -Next: [understand skill workflows](skill-workflows.md), [set up coding -agents](agent-setup.md), [understand each -primitive](primitives/README.md), or follow the [complete review -tutorial](tutorials/code-review.md). +Use [`yskill init`](reference/cli.md#init) when you want to write the program +and fixtures yourself. See the [primitive guides](primitives/README.md) and +[working examples](examples.md). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aacd809..ed21173 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3,6 +3,37 @@ `yskill` runs and resumes skill workflows. It owns run logs, validates responses, executes commands, and starts the skill workflow. It comes with each language package. +## `bootstrap` + +```bash +yskill bootstrap + [--language typescript|python|rust|go] + [--agent auto|cursor,codex,claude-code] + [--root repository] + [--dry-run] + [--yes] +``` + +Detects the repository, language, and installed coding agents. It shows every +proposed change before it writes. It asks for confirmation unless `--yes` is +set. It installs and tests `skills/yield-workflow-builder`, registers the +selected adapters, and verifies them. + +Bootstrap stores local state under ignored `.yield/`. It refuses paths outside +the repository, symlink escapes, existing destinations, and user-owned adapter +files. Use `--root` for a directory that is not a Git repository. + +Bootstrap can change only these repository locations: + +- `.yield/.gitignore` and `.yield/bootstrap.json` +- `.yield/bin/yskill` for Go and Rust +- `skills/yield-workflow-builder/` and its language dependency lockfile +- selected generated agent adapter paths + +The TypeScript dependency install also creates ignored `node_modules/` content. +Bootstrap runs only the dependency preparation command shown in the plan, +`doctor --test`, and adapter registration. It does not use install hooks. + ## `init` ```bash diff --git a/docs/skill-workflows.md b/docs/skill-workflows.md index 58fcaf1..ac6f81b 100644 --- a/docs/skill-workflows.md +++ b/docs/skill-workflows.md @@ -41,5 +41,13 @@ Yield does not replace skills. It gives repeatable skill behavior an executable boundary that can be tested, paused, resumed, and exposed to more than one coding agent. +## Build one with a coding agent + +`yskill bootstrap` installs the `yield-workflow-builder` skill workflow. The +builder accepts a description or an existing `SKILL.md`. It extracts the +control flow, writes one language implementation, runs its fixture, repairs at +most twice, and verifies the generated adapters. It refuses success when any +verification step is missing. + Next: [create your first skill workflow](quickstart.md) or [register an existing one](agent-setup.md). diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index c1b7721..2cb36ab 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -54,6 +54,7 @@ async function validateBinaries(directory) { async function assembleNpm({ version, binaries, output }) { const npm = join(output, "npm"); const main = join(npm, "yield"); + const initializer = join(npm, "create-yield"); await cp(join(root, "sdk/typescript"), main, { recursive: true, filter: (source) => !source.includes("node_modules") && !source.includes("/dist") }); await mkdir(join(main, "assets"), { recursive: true }); const [readme] = await Promise.all([ @@ -73,6 +74,14 @@ async function assembleNpm({ version, binaries, output }) { packageJson.files = [...new Set([...(packageJson.files ?? []), "assets"])]; await writeFile(join(main, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + await cp(join(root, "packaging/create-yield"), initializer, { recursive: true }); + await cp(join(root, "LICENSE"), join(initializer, "LICENSE")); + await chmod(join(initializer, "bin/create-yield.mjs"), 0o755); + const initializerPackage = await json(join(initializer, "package.json")); + initializerPackage.version = version; + initializerPackage.dependencies["@operatorstack/yield"] = version; + await writeFile(join(initializer, "package.json"), `${JSON.stringify(initializerPackage, null, 2)}\n`); + for (const target of targets) { const directory = join(npm, target.id); const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs index e4daa6a..5428e7d 100644 --- a/packaging/assemble.test.mjs +++ b/packaging/assemble.test.mjs @@ -17,7 +17,7 @@ test("accepts stable and exact Yield canary versions", () => { assert.equal(isPackageVersion("v1.2.3"), false); }); -test("assembles one public npm package and six matching npm and Python runtimes", async (t) => { +test("assembles two public npm packages and six matching npm and Python runtimes", async (t) => { const root = await mkdtemp(join(tmpdir(), "yield-assemble-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -31,6 +31,7 @@ test("assembles one public npm package and six matching npm and Python runtimes" await assemble({ version: "1.2.3", binaries, output }); const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); const main = await readJson(join(output, "npm/yield/package.json")); + const initializer = await readJson(join(output, "npm/create-yield/package.json")); assert.equal(main.name, "@operatorstack/yield"); assert.equal(main.version, "1.2.3"); @@ -44,6 +45,17 @@ test("assembles one public npm package and six matching npm and Python runtimes" main.optionalDependencies, Object.fromEntries(targets.map((target) => [npmPackage(target), "1.2.3"])), ); + assert.equal(initializer.name, "@operatorstack/create-yield"); + assert.equal(initializer.version, "1.2.3"); + assert.equal(initializer.dependencies["@operatorstack/yield"], "1.2.3"); + assert.equal(initializer.publishConfig.provenance, true); + assert.match(await readFile(join(output, "npm/create-yield/LICENSE"), "utf8"), /MIT License/); + assert.match(await readFile(join(output, "npm/create-yield/bin/create-yield.mjs"), "utf8"), /bootstrap/); + const initializerPack = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: join(output, "npm/create-yield"), + encoding: "utf8", + })); + assert.equal(initializerPack[0].files.find((file) => file.path === "bin/create-yield.mjs").mode, 0o755); const assembledReadme = await readFile(join(output, "npm/yield/README.md"), "utf8"); const repositoryReadme = await readFile(join(import.meta.dirname, "../README.md"), "utf8"); assert.match(repositoryReadme, /pypi\.org\/project\/yieldskill/); diff --git a/packaging/create-yield.test.mjs b/packaging/create-yield.test.mjs new file mode 100644 index 0000000..1b5c4e6 --- /dev/null +++ b/packaging/create-yield.test.mjs @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +test("npm initializer forwards bootstrap and user arguments to the matching CLI", async (t) => { + const root = await mkdtemp(join(tmpdir(), "create-yield-")); + t.after(() => rm(root, { recursive: true, force: true })); + const initializer = join(root, "node_modules/@operatorstack/create-yield"); + const sdk = join(root, "node_modules/@operatorstack/yield"); + await mkdir(join(initializer, "bin"), { recursive: true }); + await mkdir(join(sdk, "dist"), { recursive: true }); + await mkdir(join(sdk, "bin"), { recursive: true }); + await cp(join(import.meta.dirname, "create-yield/bin/create-yield.mjs"), join(initializer, "bin/create-yield.mjs")); + await writeFile(join(sdk, "package.json"), JSON.stringify({ + name: "@operatorstack/yield", + type: "module", + exports: { ".": "./dist/index.js" }, + })); + await writeFile(join(sdk, "dist/index.js"), "export {};\n"); + await writeFile(join(sdk, "bin/yskill.mjs"), "import { writeFileSync } from 'node:fs'; writeFileSync(process.env.RECEIPT, JSON.stringify(process.argv.slice(2)));\n"); + const receipt = join(root, "receipt.json"); + execFileSync(process.execPath, [join(initializer, "bin/create-yield.mjs"), "--root", root, "--dry-run"], { + cwd: root, + env: { ...process.env, RECEIPT: receipt }, + }); + assert.deepEqual(JSON.parse(await readFile(receipt, "utf8")), [ + "bootstrap", "--language", "typescript", "--root", root, "--dry-run", + ]); +}); diff --git a/packaging/create-yield/README.md b/packaging/create-yield/README.md new file mode 100644 index 0000000..09a9095 --- /dev/null +++ b/packaging/create-yield/README.md @@ -0,0 +1,9 @@ +# Create Yield + +Create and register the Yield workflow builder in a repository: + +```sh +npm create @operatorstack/yield@latest +``` + +The command shows every proposed change. It asks for confirmation before it writes files. diff --git a/packaging/create-yield/bin/create-yield.mjs b/packaging/create-yield/bin/create-yield.mjs new file mode 100644 index 0000000..c58799e --- /dev/null +++ b/packaging/create-yield/bin/create-yield.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; + +const require = createRequire(import.meta.url); +const sdkEntry = require.resolve("@operatorstack/yield"); +const cli = resolve(dirname(sdkEntry), "../bin/yskill.mjs"); +const result = spawnSync(process.execPath, [cli, "bootstrap", "--language", "typescript", ...process.argv.slice(2)], { + stdio: "inherit", +}); + +if (result.error) { + console.error(`create-yield: ${result.error.message}`); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/packaging/create-yield/package.json b/packaging/create-yield/package.json new file mode 100644 index 0000000..a7ce3db --- /dev/null +++ b/packaging/create-yield/package.json @@ -0,0 +1,35 @@ +{ + "name": "@operatorstack/create-yield", + "version": "0.0.0", + "description": "Create a tested Yield skill workflow for your coding agent.", + "license": "MIT", + "type": "module", + "bin": { + "create-yield": "bin/create-yield.mjs" + }, + "files": [ + "bin", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=23.6" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/operatorstack/yield.git", + "directory": "packaging/create-yield" + }, + "homepage": "https://yield.operatorstack.systems/", + "bugs": { + "url": "https://github.com/operatorstack/yield/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@operatorstack/yield": "0.0.0" + } +} diff --git a/packaging/verify-registry-history.mjs b/packaging/verify-registry-history.mjs index 13646de..48ef79c 100644 --- a/packaging/verify-registry-history.mjs +++ b/packaging/verify-registry-history.mjs @@ -51,7 +51,7 @@ function cargoVersions(text, expectedName) { export async function verifyRegistryHistory({ versions, base, fetchImpl = fetch }) { const missing = []; - const npmNames = ["@operatorstack/yield", ...targets.map(npmPackage)]; + const npmNames = ["@operatorstack/yield", "@operatorstack/create-yield", ...targets.map(npmPackage)]; for (const name of npmNames) { const packument = await fetchJSON(fetchImpl, `${base}/npm/${npmPath(name)}`); const available = new Set(Object.keys(packument.versions ?? {})); diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 3daf363..0cb351b 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -81,6 +81,10 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". const pythonWheelStep = publisher.jobs?.build?.steps?.find((step) => step.name === "Build Python wheels"); expect(pythonWheelStep?.if === "needs.resolve.outputs.channel == 'stable'", "PyPI wheels must be built only for stable PEP 440 versions"); expect(raw["npm-publish.yml"].indexOf("Publish platform runtimes") < raw["npm-publish.yml"].indexOf("Publish SDK and CLI"), "runtime packages must publish before the SDK package"); + expect(raw["npm-publish.yml"].includes("@operatorstack/create-yield@${VERSION}"), "npm trusted publishing must include the initializer package"); + expect(raw["npm-publish.yml"].indexOf("Publish SDK and CLI") < raw["npm-publish.yml"].indexOf("Publish npm initializer"), "the SDK package must publish before the initializer"); + expect(raw["npm-publish.yml"].indexOf("Publish npm initializer") < raw["npm-publish.yml"].indexOf("Verify complete npm release unit"), "the initializer must publish before release-unit verification"); + expect(raw["npm-publish.yml"].includes('npm create "@operatorstack/yield@${VERSION}"'), "the exact published initializer must pass a dry-run smoke test"); expect(raw["npm-publish.yml"].includes("pypa/gh-action-pypi-publish@"), "PyPI publishing must use the trusted-publishing action"); expect(raw["npm-publish.yml"].includes("rust-lang/crates-io-auth-action@"), "crates.io publishing must use the trusted-publishing action"); expect(raw["npm-publish.yml"].includes("chmod 0644 dist/packages/rust/runtime/*/runtime/*"), "Rust archives must normalize embedded runtime modes before artifact transport"); diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index 59f4552..c1d3f78 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -284,10 +284,20 @@ test("README and quickstart use the public documentation and package registries" assert.doesNotMatch(rustReadme, /get\.operatorstack\.systems\/cargo/); assert.match(goReadme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/); assert.match(docsIndex, /\[public documentation\]\(https:\/\/yield\.operatorstack\.systems\/docs\/\)/); - assert.match(quickstart, /npm install --save-exact @operatorstack\/yield/); + const commands = [ + "npm create @operatorstack/yield@latest", + "uvx --from yieldskill yskill bootstrap --language python", + "yskill bootstrap --language rust", + "go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go", + ]; + for (const command of commands) { + assert.ok(readme.includes(command), `README is missing ${command}`); + assert.ok(quickstart.includes(command), `quickstart is missing ${command}`); + assert.ok(agentSetup.includes(command), `agent setup is missing ${command}`); + } assert.doesNotMatch(quickstart, /get\.operatorstack\.systems\/npm|@operatorstack\/yield@0\./); - assert.match(quickstart, /^## 6\. Run the skill$/m); - assert.match(quickstart, /^\/review$/m); + assert.match(quickstart, /Use Yield to turn my release skill into a tested workflow\./); + assert.match(quickstart, /^## Advanced: build manually$/m); assert.match(agentSetup, /^## Run the registered skill$/m); }); diff --git a/sdk/python/README.md b/sdk/python/README.md index 7bbe555..3ed7659 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -29,7 +29,16 @@ The package name and import name are both `yieldskill`. Python reserves `yield` as a keyword. -## Build a Python skill in five steps +## Start with your coding agent + +```bash +uvx --from yieldskill yskill bootstrap --language python +``` + +Review and confirm the plan. Restart your coding agent. Then ask it to create +or convert a skill workflow. + +## Advanced: build manually ### 1. Install Yield diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 89c9924..bc45dfc 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -30,7 +30,17 @@ The crate and library names are both `yieldskill`. The installed command is `yskill`. -## Build a Rust skill in five steps +## Start with your coding agent + +```bash +cargo install yieldskill --locked +yskill bootstrap --language rust +``` + +Review and confirm the plan. Restart your coding agent. Then ask it to create +or convert a skill workflow. + +## Advanced: build manually ### 1. Install Yield diff --git a/sdk/yield/README.md b/sdk/yield/README.md index 3946705..e7ad389 100644 --- a/sdk/yield/README.md +++ b/sdk/yield/README.md @@ -29,7 +29,16 @@ The Go module is `github.com/operatorstack/yield`. Import the SDK as `github.com/operatorstack/yield/sdk/yield`. The installed command is `yskill`. -## Build a Go skill in five steps +## Start with your coding agent + +```bash +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go +``` + +Review and confirm the plan. Restart your coding agent. Then ask it to create +or convert a skill workflow. + +## Advanced: build manually ### 1. Install Yield diff --git a/skills/release-yield/src/release-controller.mjs b/skills/release-yield/src/release-controller.mjs index 22ebdec..45c1d1b 100644 --- a/skills/release-yield/src/release-controller.mjs +++ b/skills/release-yield/src/release-controller.mjs @@ -11,6 +11,7 @@ const bumps = new Set(["auto", "patch", "minor", "major"]); const active = new Set(["queued", "in_progress", "pending", "waiting", "requested"]); const npmPackages = [ "@operatorstack/yield", + "@operatorstack/create-yield", "@operatorstack/yield-darwin-amd64", "@operatorstack/yield-darwin-arm64", "@operatorstack/yield-linux-amd64", diff --git a/skills/release-yield/src/workflow.test.mjs b/skills/release-yield/src/workflow.test.mjs index b7c767c..88c302e 100644 --- a/skills/release-yield/src/workflow.test.mjs +++ b/skills/release-yield/src/workflow.test.mjs @@ -25,7 +25,7 @@ function successReceipts(overrides = {}) { "wait-finalizer": { status: "ok", run_id: "13", run_url: "https://example.test/13" }, "verify-public-release": { status: "ok", - targets: { npm: 7, pypi: { project: "yieldskill", wheels: 6 }, crates: 7, go: "github.com/operatorstack/yield" }, + targets: { npm: 8, pypi: { project: "yieldskill", wheels: 6 }, crates: 7, go: "github.com/operatorstack/yield" }, }, ...overrides, }; @@ -69,7 +69,7 @@ test("enforces dry run, immutable authorization, protected publication, and veri ]); assert.equal(result.version, "1.2.3"); assert.equal(result.source_sha, sha); - assert.equal(result.verified.npm, 7); + assert.equal(result.verified.npm, 8); }); test("stops before live dispatch when authorization is declined", () => { From 6ab3c5456899ef4568709c5bd3841f38f6ea50a0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 10:41:30 +0100 Subject: [PATCH 2/3] Fix bootstrap CI contracts --- .agents/skills/release-yield/SKILL.md | 2 +- .claude/skills/release-yield/SKILL.md | 2 +- .cursor/skills/release-yield/SKILL.md | 2 +- cmd/yskill/bootstrap_test.go | 4 ++-- evals/results/latest.json | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index 3726695..9e98efa 100644 --- a/.agents/skills/release-yield/SKILL.md +++ b/.agents/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.claude/skills/release-yield/SKILL.md b/.claude/skills/release-yield/SKILL.md index 3726695..9e98efa 100644 --- a/.claude/skills/release-yield/SKILL.md +++ b/.claude/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.cursor/skills/release-yield/SKILL.md b/.cursor/skills/release-yield/SKILL.md index 3726695..9e98efa 100644 --- a/.cursor/skills/release-yield/SKILL.md +++ b/.cursor/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/cmd/yskill/bootstrap_test.go b/cmd/yskill/bootstrap_test.go index 0ee96df..20bdcf6 100644 --- a/cmd/yskill/bootstrap_test.go +++ b/cmd/yskill/bootstrap_test.go @@ -263,11 +263,11 @@ func TestBuilderFixturesReachCompletedAcrossLanguages(t *testing.T) { runTestCommand(t, dir, "go", "mod", "tidy") } if language == "go" || language == "rust" { - runtimePath := filepath.Join(root, ".yield", "bin", "yskill") + runtimePath := localRuntimePath(root) if err := os.MkdirAll(filepath.Dir(runtimePath), 0o755); err != nil { t.Fatal(err) } - runTestCommand(t, repoRoot, "go", "build", "-o", runtimePath, "./cmd/yskill") + runTestCommand(t, repoRoot, "go", "build", "-ldflags", "-X main.version=dev", "-o", runtimePath, "./cmd/yskill") } if err := cmdDoctor([]string{dir, "--root", root, "--test"}); err != nil { t.Fatalf("%s builder fixture did not complete: %v", language, err) diff --git a/evals/results/latest.json b/evals/results/latest.json index e03bef0..16d8190 100644 --- a/evals/results/latest.json +++ b/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, "methodology_version": "1.1", - "generated_at": "2026-08-08T01:20:38.016Z", - "source_digest": "eb20ae102ae5de056d91ad3a9b8cbfc77284185050c3e3639d93c111b78ed6db", + "generated_at": "2026-08-08T09:40:27.593Z", + "source_digest": "6823344415b26868fdab9b4b4d7e9e0ab406e30720a08cbdadc2a29080e3b96a", "status": "passed", "workflow_conformance": { "passed": 40, From 9cf93c6650deb373e2494ac28fa651e20bc04699 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 10:47:37 +0100 Subject: [PATCH 3/3] Bind fixture runtime to SDK version --- cmd/yskill/bootstrap_test.go | 9 ++++++--- evals/results/latest.json | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/yskill/bootstrap_test.go b/cmd/yskill/bootstrap_test.go index 20bdcf6..ca40370 100644 --- a/cmd/yskill/bootstrap_test.go +++ b/cmd/yskill/bootstrap_test.go @@ -205,11 +205,14 @@ func TestBuilderTemplatesCompile(t *testing.T) { } func TestBuilderFixturesReachCompletedAcrossLanguages(t *testing.T) { + oldVersion := version + version = "0.1.0" + t.Cleanup(func() { version = oldVersion }) repoRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatal(err) } - profile := bootstrapProfile{YieldVersion: "0.0.0", Agents: []string{"codex"}} + profile := bootstrapProfile{YieldVersion: "0.1.0", Agents: []string{"codex"}} for _, language := range []string{"typescript", "python", "go", "rust"} { t.Run(language, func(t *testing.T) { root := t.TempDir() @@ -222,7 +225,7 @@ func TestBuilderFixturesReachCompletedAcrossLanguages(t *testing.T) { files["go.mod"] += "\nreplace github.com/operatorstack/yield => " + filepath.ToSlash(repoRoot) + "\n" } if language == "rust" { - files["Cargo.toml"] = strings.Replace(files["Cargo.toml"], `yieldskill = { version = "=0.0.0" }`, `yieldskill = { path = "`+filepath.ToSlash(filepath.Join(repoRoot, "sdk", "rust"))+`" }`, 1) + files["Cargo.toml"] = strings.Replace(files["Cargo.toml"], `yieldskill = { version = "=0.1.0" }`, `yieldskill = { version = "=0.1.0", path = "`+filepath.ToSlash(filepath.Join(repoRoot, "sdk", "rust"))+`" }`, 1) } for path, content := range files { full := filepath.Join(dir, filepath.FromSlash(path)) @@ -267,7 +270,7 @@ func TestBuilderFixturesReachCompletedAcrossLanguages(t *testing.T) { if err := os.MkdirAll(filepath.Dir(runtimePath), 0o755); err != nil { t.Fatal(err) } - runTestCommand(t, repoRoot, "go", "build", "-ldflags", "-X main.version=dev", "-o", runtimePath, "./cmd/yskill") + runTestCommand(t, repoRoot, "go", "build", "-ldflags", "-X main.version=0.1.0", "-o", runtimePath, "./cmd/yskill") } if err := cmdDoctor([]string{dir, "--root", root, "--test"}); err != nil { t.Fatalf("%s builder fixture did not complete: %v", language, err) diff --git a/evals/results/latest.json b/evals/results/latest.json index 16d8190..9efc657 100644 --- a/evals/results/latest.json +++ b/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, "methodology_version": "1.1", - "generated_at": "2026-08-08T09:40:27.593Z", - "source_digest": "6823344415b26868fdab9b4b4d7e9e0ab406e30720a08cbdadc2a29080e3b96a", + "generated_at": "2026-08-08T09:46:54.610Z", + "source_digest": "e2d5965b634d1c9f5936988771bc62386cac97f5af9ce3119d2876390cff6a8d", "status": "passed", "workflow_conformance": { "passed": 40,