diff --git a/README.md b/README.md index 2ed4295..38202b4 100644 --- a/README.md +++ b/README.md @@ -240,14 +240,19 @@ helper: | Language | Command | | ---------- | ------------------------------------------------------------------------------------------------------------------- | | TypeScript | `npm exec -- yskill helper install --language typescript` | -| Python | `uvx --from yieldskill yskill helper install --language python` | +| Python | `python -m yieldskill helper install --language python` | | Rust | `cargo install yieldskill --root .yield --locked`, then `.yield/bin/yskill helper install --root . --language rust` | | Go | `go run github.com/operatorstack/yield/cmd/yskill@latest helper install --root . --language go` | +The installer first prints one ordered plan with resolved paths, dependency +preparation, workflow testing, registration for the selected agents, and final +adapter verification. Review it before answering +`Apply this helper install plan? [y/N]`. + The helper is installed as `skills/yield-workflow-builder`. It can explain, -create, convert, check, repair, upgrade, and register skill workflows. It shows -the relevant primitive, exact files, and commands before any mutation and asks -for approval. Restart your coding agent after installation. +create, convert, check, repair, upgrade, and register skill workflows. Before a +mutation it shows the summary, relevant primitives, exact files, and exact +commands, then asks for approval. Restart your coding agent after installation. `yskill bootstrap` and `npm create @operatorstack/yield@latest` remain compatibility aliases for `yskill helper install`. diff --git a/cmd/yskill/bootstrap.go b/cmd/yskill/bootstrap.go index a8f82f9..ac2341a 100644 --- a/cmd/yskill/bootstrap.go +++ b/cmd/yskill/bootstrap.go @@ -27,16 +27,29 @@ type bootstrapProfile struct { } type bootstrapPlan struct { - Root string - Language string - SkillDir string - Profile bootstrapProfile - Agents []agentConfig - Files map[string]string - Adapters []string - Dependency string + Root string + Language string + SkillDir string + Profile bootstrapProfile + Agents []agentConfig + Files map[string]string + Adapters []string } +type bootstrapOperation struct { + kind string + dir string + name string + args []string + agents []string +} + +const ( + bootstrapOperationCommand = "command" + bootstrapOperationDoctor = "doctor" + bootstrapOperationRegister = "register" +) + var bootstrapInput io.Reader = os.Stdin var bootstrapCommand = func(dir, name string, args ...string) error { cmd := exec.Command(name, args...) @@ -83,22 +96,8 @@ func cmdBootstrap(args []string) error { 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, nil); 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) - } - if err := bootstrapDoctor(plan.SkillDir, plan.Root, ids); err != nil { - return fmt.Errorf("verify workflow builder adapters: %w", err) + if err := runBootstrapOperations(plan, bootstrapOperations(plan)); err != nil { + return err } fmt.Println("helper: Yield developer helper is ready") fmt.Println("next: restart your coding agent, then ask it to create or convert a skill workflow") @@ -149,7 +148,7 @@ func makeBootstrapPlan(rootArg, language string, requested []string) (bootstrapP 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) + files, _, err := renderBootstrapSkill(language, profile) if err != nil { return bootstrapPlan{}, err } @@ -186,7 +185,7 @@ func makeBootstrapPlan(rootArg, language string, requested []string) (bootstrapP return bootstrapPlan{}, err } } - return bootstrapPlan{Root: root, Language: language, SkillDir: skillDir, Profile: profile, Agents: agents, Files: files, Adapters: adapters, Dependency: dependency}, nil + return bootstrapPlan{Root: root, Language: language, SkillDir: skillDir, Profile: profile, Agents: agents, Files: files, Adapters: adapters}, nil } func preflightBootstrapPath(root, path string, ownedSkill bool) error { @@ -246,15 +245,90 @@ func printBootstrapPlan(plan bootstrapPlan) { rel, _ := filepath.Rel(plan.Root, path) fmt.Printf(" write %s\n", filepath.ToSlash(rel)) } - if plan.Dependency != "" { - fmt.Printf(" run %s\n", plan.Dependency) + for _, operation := range bootstrapOperations(plan) { + fmt.Printf(" run %s\n", renderBootstrapOperation(plan, operation)) + } +} + +func bootstrapOperations(plan bootstrapPlan) []bootstrapOperation { + ids := make([]string, 0, len(plan.Agents)) + for _, agent := range plan.Agents { + ids = append(ids, agent.ID) + } + operations := make([]bootstrapOperation, 0, 4) + switch plan.Language { + case "typescript": + operations = append(operations, bootstrapOperation{kind: bootstrapOperationCommand, dir: plan.SkillDir, name: "npm", args: []string{"install", "--ignore-scripts", "--no-audit", "--no-fund"}}) + case "go": + operations = append(operations, bootstrapOperation{kind: bootstrapOperationCommand, dir: plan.SkillDir, name: "go", args: []string{"mod", "tidy"}}) + } + operations = append(operations, + bootstrapOperation{kind: bootstrapOperationDoctor}, + bootstrapOperation{kind: bootstrapOperationRegister, agents: append([]string(nil), ids...)}, + bootstrapOperation{kind: bootstrapOperationDoctor, agents: append([]string(nil), ids...)}, + ) + return operations +} + +func renderBootstrapOperation(plan bootstrapPlan, operation bootstrapOperation) string { + if operation.kind == bootstrapOperationCommand { + parts := []string{"cd", shellQuoteForPlatform(operation.dir, runtime.GOOS), "&&", operation.name} + for _, arg := range operation.args { + parts = append(parts, shellQuoteForPlatform(arg, runtime.GOOS)) + } + return strings.Join(parts, " ") } launcher := "yskill" if plan.Language == "go" || plan.Language == "rust" { - launcher = repositoryRuntimeLauncher(filepath.ToSlash(filepath.Join(".yield", "bin", "yskill")), runtime.GOOS) + launcher = repositoryRuntimeLauncher(filepath.Join(".yield", "bin", "yskill"), runtime.GOOS) + } + args := []string{launcher} + switch operation.kind { + case bootstrapOperationDoctor: + args = append(args, "doctor", plan.SkillDir, "--root", plan.Root) + for _, agent := range operation.agents { + args = append(args, "--agent", agent) + } + args = append(args, "--test") + case bootstrapOperationRegister: + args = append(args, "register", plan.SkillDir, "--root", plan.Root) + for _, agent := range operation.agents { + args = append(args, "--agent", agent) + } + } + for index := 1; index < len(args); index++ { + args[index] = shellQuoteForPlatform(args[index], runtime.GOOS) + } + return strings.Join(args, " ") +} + +func runBootstrapOperations(plan bootstrapPlan, operations []bootstrapOperation) error { + for _, operation := range operations { + switch operation.kind { + case bootstrapOperationCommand: + if err := bootstrapCommand(operation.dir, operation.name, operation.args...); err != nil { + return fmt.Errorf("prepare workflow-builder dependencies: %w", err) + } + case bootstrapOperationDoctor: + if err := bootstrapDoctor(plan.SkillDir, plan.Root, operation.agents); err != nil { + if len(operation.agents) == 0 { + return fmt.Errorf("verify workflow builder: %w", err) + } + return fmt.Errorf("verify workflow builder adapters: %w", err) + } + case bootstrapOperationRegister: + registrations, err := registerSkill(plan.SkillDir, plan.Root, operation.agents) + 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) + } + default: + return fmt.Errorf("unknown helper install operation %q", operation.kind) + } } - fmt.Printf(" run %s doctor skills/yield-workflow-builder --root . --test\n", launcher) - fmt.Printf(" run %s register skills/yield-workflow-builder --root .\n", launcher) + return nil } func detectBootstrapLanguage(root string) (string, error) { @@ -286,7 +360,7 @@ func detectBootstrapLanguage(root string) (string, error) { } func confirmBootstrap(input io.Reader) bool { - fmt.Print("Apply this bootstrap plan? [y/N] ") + fmt.Print("Apply this helper install plan? [y/N] ") scanner := bufio.NewScanner(input) if !scanner.Scan() { return false @@ -327,16 +401,6 @@ func applyBootstrapPlan(plan bootstrapPlan) error { 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 } diff --git a/cmd/yskill/bootstrap_templates.go b/cmd/yskill/bootstrap_templates.go index e28c477..9d84e3b 100644 --- a/cmd/yskill/bootstrap_templates.go +++ b/cmd/yskill/bootstrap_templates.go @@ -151,6 +151,7 @@ const quote = (v:string) => "'" + v.replaceAll("'", "'\\''") + "'"; const inside = (p:string) => p === root || p.startsWith(root+sep); const safe = (p:string, destination=false) => !!p && !isAbsolute(p) && !normalize(p).split(sep).includes("..") && normalize(p).startsWith("skills"+sep) && inside(realpathSync(destination ? dirname(resolve(root,p)) : resolve(root,p))); const filesStayInside = (files:string[], target:string) => files.every((file) => normalize(file) === normalize(target) || normalize(file).startsWith(normalize(target)+sep)); +const renderPlan = (plan:Plan) => plan.summary+"\n\nPrimitives:\n- "+plan.primitives.join("\n- ")+"\n\nFiles:\n- "+plan.files.join("\n- ")+"\n\nCommands:\n- "+plan.commands.join("\n- ")+"\n\nApply this plan?"; defineSkill((ctx) => { const mode = ctx.askUser("select-mode", "What do you want to do with Yield?", [ @@ -161,7 +162,7 @@ defineSkill((ctx) => { const guide = ctx.agentTask("teach-yield", "Teach the smallest relevant Yield concept for the user's request. Explain what remains in SKILL.md, what moves into code, fixtures, doctor, and registration. Give manual steps before mentioning this helper. Do not edit files or run commands.", {mode}, guideSchema); return {mode,changed:false,guide}; } - const spec = ctx.agentTask("collect-specification", "Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.", {mode}, specSchema); + const spec = ctx.agentTask("collect-specification", "Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. source_path must be the source skill directory under skills/, not the SKILL.md file. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.", {mode}, specSchema); const creates = mode === "create" || mode === "convert"; const target = creates ? spec.destination : spec.target_path; if (creates) { @@ -193,7 +194,7 @@ defineSkill((ctx) => { if (creates) flow = ctx.agentTask("extract-flow", "Extract or design the minimal workflow. Keep model judgment in agent_task. Put order, branches, commands, approvals, evidence, and finish rules in code.", {mode,spec,source,projection}, flowSchema); else inspection = ctx.runCommand("inspect-workflow", fixture?"printf fixture-inspect-ok":"cd ../.. && "+config.launcher+" doctor "+quote(target)+" --root .", 120); const plan = ctx.agentTask("teach-and-plan", "Teach the relevant Yield primitives, then return the exact files and commands proposed for this operation. For register, explain generated adapters. For repair, use the doctor evidence. For upgrade, target the installed Yield version and include dependency and lockfile changes. Do not edit files or run commands.", {mode,spec,target,flow,inspection,yield_version:config.yield_version}, planSchema); - const approval = ctx.askUser("approve-change", plan.summary+" Apply this plan?", [{value:"apply",label:"Apply"},{value:"stop",label:"Stop"}]); + const approval = ctx.askUser("approve-change", renderPlan(plan), [{value:"apply",label:"Apply"},{value:"stop",label:"Stop"}]); if (approval !== "apply") ctx.refused("the developer declined the proposed Yield changes"); const agentFlag = config.agents.join(","); const verify = fixture ? "printf fixture-ok" : "cd ../.. && "+config.launcher+" doctor "+quote(target)+" --root . --test"; @@ -248,6 +249,8 @@ def quote(value): return "'" + value.replace("'", "'\\''") + "'" def files_stay_inside(files, target): base = Path(target) return all(Path(file) == base or base in Path(file).parents for file in files) +def render_plan(plan): + return plan["summary"] + "\n\nPrimitives:\n- " + "\n- ".join(plan["primitives"]) + "\n\nFiles:\n- " + "\n- ".join(plan["files"]) + "\n\nCommands:\n- " + "\n- ".join(plan["commands"]) + "\n\nApply this plan?" def receipt(result): return {"exit_code":result.exit_code,"stdout":result.stdout,"stderr":result.stderr} def program(ctx): @@ -256,7 +259,7 @@ def program(ctx): if mode == "learn": guide = ctx.agent_task("teach-yield", "Teach the smallest relevant Yield concept for the user's request. Explain what remains in SKILL.md, what moves into code, fixtures, doctor, and registration. Give manual steps before mentioning this helper. Do not edit files or run commands.", context={"mode":mode}, schema=GUIDE_SCHEMA) return {"mode":mode,"changed":False,"guide":guide} - spec = ctx.agent_task("collect-specification", "Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.", context={"mode":mode}, schema=SPEC_SCHEMA) + spec = ctx.agent_task("collect-specification", "Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. source_path must be the source skill directory under skills/, not the SKILL.md file. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.", context={"mode":mode}, schema=SPEC_SCHEMA) creates = mode in ["create","convert"] target = spec["destination"] if creates else spec["target_path"] if creates: @@ -284,7 +287,7 @@ def program(ctx): inspection_result = None if creates else ctx.run_command("inspect-workflow", "printf fixture-inspect-ok" if fixture else "cd ../.. && " + CONFIG["launcher"] + " doctor " + quote(target) + " --root .", timeout_seconds=120) inspection = None if inspection_result is None else receipt(inspection_result) plan = ctx.agent_task("teach-and-plan", "Teach the relevant Yield primitives, then return the exact files and commands proposed. For register, explain adapters. For repair, use doctor evidence. For upgrade, target the installed Yield version and include dependency and lockfile changes. Do not edit files or run commands.", context={"mode":mode,"spec":spec,"target":target,"flow":flow,"inspection":inspection,"yield_version":CONFIG["yield_version"]}, schema=PLAN_SCHEMA) - approval = ctx.ask_user("approve-change", plan["summary"] + " Apply this plan?", options=[{"value":"apply","label":"Apply"},{"value":"stop","label":"Stop"}]) + approval = ctx.ask_user("approve-change", render_plan(plan), options=[{"value":"apply","label":"Apply"},{"value":"stop","label":"Stop"}]) if approval != "apply": ctx.refused("the developer declined the proposed Yield changes") agents = ",".join(CONFIG["agents"]) verify = "printf fixture-ok" if fixture else "cd ../.. && " + CONFIG["launcher"] + " doctor " + quote(target) + " --root . --test" @@ -327,7 +330,7 @@ import ( type specification struct { Name string ` + "`json:\"name\"`" + `; Description string ` + "`json:\"description\"`" + `; Language string ` + "`json:\"language\"`" + `; Destination string ` + "`json:\"destination\"`" + `; SourcePath string ` + "`json:\"source_path\"`" + `; TargetPath string ` + "`json:\"target_path\"`" + `; RequestedVersion string ` + "`json:\"requested_version\"`" + ` } type fileResult struct { Files []string ` + "`json:\"files\"`" + ` } type projectionResult struct { Ready bool ` + "`json:\"ready\"`" + `; Unresolved []string ` + "`json:\"unresolved\"`" + ` } -type planResult struct { Summary string ` + "`json:\"summary\"`" + ` } +type planResult struct { Summary string ` + "`json:\"summary\"`" + `; Primitives []string ` + "`json:\"primitives\"`" + `; Files []string ` + "`json:\"files\"`" + `; Commands []string ` + "`json:\"commands\"`" + ` } type config struct { Launcher string ` + "`json:\"launcher\"`" + `; Agents []string ` + "`json:\"agents\"`" + `; YieldVersion string ` + "`json:\"yield_version\"`" + ` } const helperPath = "skills/yield-workflow-builder" const specSchema = ` + "`" + `{"type":"object","required":["name","description","language","destination","source_path","target_path","requested_version"],"additionalProperties":false,"properties":{"name":{"type":"string"},"description":{"type":"string"},"language":{"enum":["typescript","python","go","rust"]},"destination":{"type":"string"},"source_path":{"type":"string"},"target_path":{"type":"string"},"requested_version":{"type":"string"}}}` + "`" + ` @@ -339,11 +342,12 @@ const guideSchema = ` + "`" + `{"type":"object","required":["summary","primitive func quote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } func safe(root,value string,destination bool) bool { if value==""||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&&(canonical==root||strings.HasPrefix(canonical+string(filepath.Separator),root+string(filepath.Separator))) } func filesStayInside(files []string,target string) bool { base:=filepath.Clean(target);for _,file:=range files{clean:=filepath.Clean(file);if clean!=base&&!strings.HasPrefix(clean,base+string(filepath.Separator)){return false}};return true } +func renderPlan(plan planResult) string { return plan.Summary+"\n\nPrimitives:\n- "+strings.Join(plan.Primitives,"\n- ")+"\n\nFiles:\n- "+strings.Join(plan.Files,"\n- ")+"\n\nCommands:\n- "+strings.Join(plan.Commands,"\n- ")+"\n\nApply this plan?" } 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","What do you want to do with Yield?",yield.Option{Value:"learn",Label:"Learn"},yield.Option{Value:"create",Label:"Create"},yield.Option{Value:"convert",Label:"Convert"},yield.Option{Value:"check",Label:"Check"},yield.Option{Value:"repair",Label:"Repair"},yield.Option{Value:"upgrade",Label:"Upgrade"},yield.Option{Value:"register",Label:"Register"}) if mode=="learn" { guide:=ctx.AgentTask("teach-yield","Teach the smallest relevant Yield concept for the user's request. Explain what remains in SKILL.md, what moves into code, fixtures, doctor, and registration. Give manual steps before mentioning this helper. Do not edit files or run commands.",map[string]any{"mode":mode},json.RawMessage(guideSchema));return ctx.Complete(map[string]any{"mode":mode,"changed":false,"guide":json.RawMessage(guide)}) } - raw:=ctx.AgentTask("collect-specification","Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. 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} + raw:=ctx.AgentTask("collect-specification","Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. source_path must be the source skill directory under skills/, not the SKILL.md file. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. 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} creates:=mode=="create"||mode=="convert";target:=spec.TargetPath;if creates{target=spec.Destination;if !regexp.MustCompile(` + "`" + `^[a-z0-9]+(?:-[a-z0-9]+)*$` + "`" + `).MatchString(spec.Name)||spec.Description==""||!safe(root,target,true)||target==helperPath{return yield.Outcome{},ctx.Blocked("the destination must be a new named path under skills/")};available:=ctx.RunCommand("check-destination","cd ../.. && test ! -e "+quote(target),30);if available.ExitCode!=0{return yield.Outcome{},ctx.Blocked("the destination must be a new path under skills/")}}else if !safe(root,target,false){return yield.Outcome{},ctx.Blocked("the target workflow must be an existing path under skills/")} if mode=="upgrade"&&target==helperPath{return yield.Outcome{},ctx.Blocked("the helper cannot upgrade itself during an active run; exit and run yskill helper install")} @@ -352,7 +356,7 @@ func main() { yield.Main(func(ctx *yield.Context) (yield.Outcome,error) { source:="";var projection json.RawMessage;if mode=="convert"{if !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);projection=ctx.AgentTask("project-semantics","Map every source clause exactly once using Pi(S)={(c,d,T,r)|c in clauses(S)}. YAML frontmatter is metadata, not a clause. Each top-level bullet is exactly one clause, including a compound sentence. For prose without bullets, treat each paragraph as one clause. Use disposition control, guidance, both, or excluded. Control needs a reachable code destination. Guidance needs a reachable skill or agent_task destination. Both needs both kinds. Excluded needs no destination and a non-empty reason. Name concrete destinations that the writer can create. Use the target language's canonical entrypoint for code: main.ts, main.py, main.go, or src/main.rs. Report uncertainty in unresolved and set ready false. Do not write files.",map[string]any{"source":source,"spec":spec},json.RawMessage(projectionSchema));var projected projectionResult;if err=json.Unmarshal(projection,&projected);err!=nil{return yield.Outcome{},err};if !projected.Ready||len(projected.Unresolved)>0{return yield.Outcome{},ctx.Blocked("the semantic projection has unresolved source clauses")}} var flow json.RawMessage;var inspection any;if creates{flow=ctx.AgentTask("extract-flow","Extract or design the minimal workflow. Keep model judgment in agent_task. Put order, branches, commands, approvals, evidence, and finish rules in code.",map[string]any{"mode":mode,"spec":spec,"source":source,"projection":projection},json.RawMessage(flowSchema))}else{command:="cd ../.. && "+cfg.Launcher+" doctor "+quote(target)+" --root .";if fixture{command="printf fixture-inspect-ok"};observed:=ctx.RunCommand("inspect-workflow",command,120);inspection=map[string]any{"exit_code":observed.ExitCode,"stdout":observed.Stdout,"stderr":observed.Stderr}} planRaw:=ctx.AgentTask("teach-and-plan","Teach the relevant Yield primitives, then return the exact files and commands proposed. For register, explain adapters. For repair, use doctor evidence. For upgrade, target the installed Yield version and include dependency and lockfile changes. Do not edit files or run commands.",map[string]any{"mode":mode,"spec":spec,"target":target,"flow":flow,"inspection":inspection,"yield_version":cfg.YieldVersion},json.RawMessage(planSchema));var plan planResult;if err=json.Unmarshal(planRaw,&plan);err!=nil{return yield.Outcome{},err} - if ctx.AskUser("approve-change",plan.Summary+" Apply this plan?",yield.Option{Value:"apply",Label:"Apply"},yield.Option{Value:"stop",Label:"Stop"})!="apply"{return yield.Outcome{},ctx.Refused("the developer declined the proposed Yield changes")} + if ctx.AskUser("approve-change",renderPlan(plan),yield.Option{Value:"apply",Label:"Apply"},yield.Option{Value:"stop",Label:"Stop"})!="apply"{return yield.Outcome{},ctx.Refused("the developer declined the proposed Yield changes")} agents:=strings.Join(cfg.Agents,",");verify:="printf fixture-ok";register:="printf fixture-register-ok";verifyAdapters:="printf fixture-adapters-ok";if !fixture{verify="cd ../.. && "+cfg.Launcher+" doctor "+quote(target)+" --root . --test";register="cd ../.. && "+cfg.Launcher+" register "+quote(target)+" --root . --agent "+quote(agents);verifyAdapters="cd ../.. && "+cfg.Launcher+" doctor "+quote(target)+" --root . --agent "+quote(agents)+" --test"} if mode=="register"{checked:=ctx.RunCommand("verify-before-register",verify,600);ctx.Require(checked.ExitCode==0,"the workflow passes its fixture run",map[string]any{"exit_code":checked.ExitCode});registered:=ctx.RunCommand("register-workflow",register,120);ctx.Require(registered.ExitCode==0,"the workflow is registered",map[string]any{"exit_code":registered.ExitCode});adapters:=ctx.RunCommand("verify-adapters",verifyAdapters,600);ctx.Require(adapters.ExitCode==0,"the generated adapters pass verification",map[string]any{"exit_code":adapters.ExitCode});return ctx.Complete(map[string]any{"mode":mode,"target":target,"changed":true,"verified":true})} task:="write-workflow";prompt:="Create the complete Yield workflow at the destination. Follow every semantic disposition. Write the program, SKILL.md, exact-version dependencies, skill.json, and self-contained fixtures. Keep useful model-facing guidance in SKILL.md; remove only duplicated sequencing. Do not edit outside the destination.";if !creates{task=mode+"-workflow";prompt="Apply only the approved operation to the target. For upgrade, update its manifest and language dependency to the supplied exact Yield version. Do not edit outside the target. Return every changed file."} @@ -377,11 +381,12 @@ fn safe(root:&Path,value:&str,destination:bool)->bool{let p=Path::new(value);if fn valid_name(value:&str)->bool{!value.is_empty()&&value.split('-').all(|part|!part.is_empty()&&part.bytes().all(|b|b.is_ascii_lowercase()||b.is_ascii_digit()))} fn quote(value:&str)->String{format!("'{}'",value.replace('\'',"'\\''"))} fn files_stay_inside(value:&Value,target:&str)->bool{value["files"].as_array().map(|files|files.iter().all(|file|file.as_str().map(|path|{let p=Path::new(path);p==Path::new(target)||p.starts_with(Path::new(target))}).unwrap_or(false))).unwrap_or(false)} +fn render_plan(plan:&Value)->String{let values=|key:&str|plan[key].as_array().unwrap().iter().map(|value|value.as_str().unwrap()).collect::>().join("\n- ");format!("{}\n\nPrimitives:\n- {}\n\nFiles:\n- {}\n\nCommands:\n- {}\n\nApply this plan?",plan["summary"].as_str().unwrap_or("Apply the proposed Yield changes."),values("primitives"),values("files"),values("commands"))} 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","What do you want to do with Yield?",&[("learn","Learn"),("create","Create"),("convert","Convert"),("check","Check"),("repair","Repair"),("upgrade","Upgrade"),("register","Register")]); if mode=="learn"{let guide=ctx.agent_task("teach-yield","Teach the smallest relevant Yield concept for the user's request. Explain what remains in SKILL.md, what moves into code, fixtures, doctor, and registration. Give manual steps before mentioning this helper. Do not edit files or run commands.",Some(json!({"mode":mode})),Some(serde_json::from_str(GUIDE_SCHEMA).unwrap()));return Ok(json!({"mode":mode,"changed":false,"guide":guide}))} - let spec=ctx.agent_task("collect-specification","Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.",Some(json!({"mode":mode})),Some(serde_json::from_str(SPEC_SCHEMA).unwrap())); + let spec=ctx.agent_task("collect-specification","Use the user's current request. For create or convert, return a safe kebab-case name, description, target language, new destination under skills/, and source_path for convert. source_path must be the source skill directory under skills/, not the SKILL.md file. For check, repair, upgrade, or register, return target_path for one existing workflow under skills/. Set requested_version only when the user explicitly requests one. Use empty strings for fields that do not apply. Do not write files.",Some(json!({"mode":mode})),Some(serde_json::from_str(SPEC_SCHEMA).unwrap())); let root=PathBuf::from("../..").canonicalize().expect("repository root must be readable");let creates=mode=="create"||mode=="convert";let target=if creates{spec["destination"].as_str().unwrap_or("")}else{spec["target_path"].as_str().unwrap_or("")}; if creates{if !valid_name(spec["name"].as_str().unwrap_or(""))||spec["description"].as_str().unwrap_or("").is_empty()||!safe(&root,target,true)||target==HELPER_PATH{return Err(ctx.blocked("the destination must be a new named path under skills/"))}let available=ctx.run_command("check-destination",&format!("cd ../.. && test ! -e {}",quote(target)),30);if available.exit_code!=0{return Err(ctx.blocked("the destination must be a new path under skills/"))}}else if !safe(&root,target,false){return Err(ctx.blocked("the target workflow must be an existing path under skills/"))} if mode=="upgrade"&&target==HELPER_PATH{return Err(ctx.blocked("the helper cannot upgrade itself during an active run; exit and run yskill helper install"))} @@ -391,7 +396,7 @@ fn program(ctx:&mut Context)->SkillResult{ let flow=if creates{ctx.agent_task("extract-flow","Extract or design the minimal workflow. Keep model judgment in agent_task. Put order, branches, commands, approvals, evidence, and finish rules in code.",Some(json!({"mode":mode,"spec":spec,"source":source,"projection":projection})),Some(serde_json::from_str(FLOW_SCHEMA).unwrap()))}else{Value::Null}; let inspection=if creates{Value::Null}else{let command=if fixture{"printf fixture-inspect-ok".to_string()}else{format!("cd ../.. && {} doctor {} --root .",launcher,quote(target))};let observed=ctx.run_command("inspect-workflow",&command,120);json!({"exit_code":observed.exit_code,"stdout":observed.stdout,"stderr":observed.stderr})}; let plan=ctx.agent_task("teach-and-plan","Teach the relevant Yield primitives, then return the exact files and commands proposed. For register, explain adapters. For repair, use doctor evidence. For upgrade, target the installed Yield version and include dependency and lockfile changes. Do not edit files or run commands.",Some(json!({"mode":mode,"spec":spec,"target":target,"flow":flow,"inspection":inspection,"yield_version":cfg["yield_version"]})),Some(serde_json::from_str(PLAN_SCHEMA).unwrap())); - let approval=ctx.ask_user("approve-change",&format!("{} Apply this plan?",plan["summary"].as_str().unwrap_or("Apply the proposed Yield changes.")),&[("apply","Apply"),("stop","Stop")]);if approval!="apply"{return Err(ctx.refused("the developer declined the proposed Yield changes"))} + let approval=ctx.ask_user("approve-change",&render_plan(&plan),&[("apply","Apply"),("stop","Stop")]);if approval!="apply"{return Err(ctx.refused("the developer declined the proposed Yield changes"))} let agents=cfg["agents"].as_array().unwrap().iter().filter_map(|v|v.as_str()).collect::>().join(",");let verify=if fixture{"printf fixture-ok".to_string()}else{format!("cd ../.. && {} doctor {} --root . --test",launcher,quote(target))};let register=if fixture{"printf fixture-register-ok".to_string()}else{format!("cd ../.. && {} register {} --root . --agent {}",launcher,quote(target),quote(&agents))};let verify_adapters=if fixture{"printf fixture-adapters-ok".to_string()}else{format!("cd ../.. && {} doctor {} --root . --agent {} --test",launcher,quote(target),quote(&agents))}; if mode=="register"{let checked=ctx.run_command("verify-before-register",&verify,600);ctx.require(checked.exit_code==0,"the workflow passes its fixture run",Some(&json!({"exit_code":checked.exit_code})));let registered=ctx.run_command("register-workflow",®ister,120);ctx.require(registered.exit_code==0,"the workflow is registered",Some(&json!({"exit_code":registered.exit_code})));let adapters=ctx.run_command("verify-adapters",&verify_adapters,600);ctx.require(adapters.exit_code==0,"the generated adapters pass verification",Some(&json!({"exit_code":adapters.exit_code})));return Ok(json!({"mode":mode,"target":target,"changed":true,"verified":true}))} let task=if creates{"write-workflow"}else if mode=="repair"{"repair-workflow"}else{"upgrade-workflow"};let prompt=if creates{"Create the complete Yield workflow at the destination. Follow every semantic disposition. Write the program, SKILL.md, exact-version dependencies, skill.json, and self-contained fixtures. Keep useful model-facing guidance in SKILL.md; remove only duplicated sequencing. Do not edit outside the destination."}else{"Apply only the approved operation to the target. For upgrade, update its manifest and language dependency to the supplied exact Yield version. Do not edit outside the target. Return every changed file."};let mut written=ctx.agent_task(task,prompt,Some(json!({"mode":mode,"spec":spec,"target":target,"source":source,"projection":projection,"flow":flow,"plan":plan,"yield_version":cfg["yield_version"]})),Some(serde_json::from_str(FILES_SCHEMA).unwrap()));if !files_stay_inside(&written,target){return Err(ctx.blocked("the reported changes escape the approved workflow directory"))} diff --git a/cmd/yskill/bootstrap_test.go b/cmd/yskill/bootstrap_test.go index e00f4d2..8896971 100644 --- a/cmd/yskill/bootstrap_test.go +++ b/cmd/yskill/bootstrap_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/operatorstack/yield/internal/protocol" ) func withBootstrapTestState(t *testing.T) { @@ -31,9 +33,35 @@ func withBootstrapTestState(t *testing.T) { func TestBootstrapDryRunDoesNotWrite(t *testing.T) { withBootstrapTestState(t) root := t.TempDir() - if err := cmdBootstrap([]string{"--root", root, "--language", "typescript", "--agent", "codex", "--dry-run"}); err != nil { + var runErr error + output := captureStdout(t, func() { + runErr = cmdBootstrap([]string{"--root", root, "--language", "typescript", "--agent", "codex", "--dry-run"}) + }) + if runErr != nil { + t.Fatal(runErr) + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { t.Fatal(err) } + for _, required := range []string{ + "helper install plan: language=typescript root=" + resolvedRoot, + filepath.Join(resolvedRoot, "skills", bootstrapSkillName), + "npm 'install' '--ignore-scripts' '--no-audit' '--no-fund'", + "yskill 'register'", + "'--agent' 'codex'", + "helper: dry run complete; no files changed", + } { + if !strings.Contains(output, required) { + t.Errorf("dry-run output is missing %q:\n%s", required, output) + } + } + if strings.Count(output, "yskill 'doctor'") != 2 || strings.Index(output, "yskill 'register'") > strings.LastIndex(output, "yskill 'doctor'") { + t.Errorf("dry-run operations do not match execution order:\n%s", output) + } + if strings.Contains(output, "Apply this bootstrap plan") { + t.Errorf("dry-run uses obsolete bootstrap wording:\n%s", output) + } if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) { t.Fatalf("dry run wrote skills directory: %v", err) } @@ -57,8 +85,18 @@ 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) + var runErr error + output := captureStdout(t, func() { + runErr = cmdBootstrap([]string{"--root", root, "--language", "python", "--agent", "codex"}) + }) + if runErr != nil { + t.Fatal(runErr) + } + if !strings.Contains(output, "Apply this helper install plan? [y/N]") || strings.Contains(output, "Apply this bootstrap plan") { + t.Fatalf("cancellation prompt is not helper-specific:\n%s", output) + } + if !strings.Contains(output, "helper: cancelled; no files changed") { + t.Fatalf("cancellation result is missing:\n%s", output) } if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) { t.Fatalf("cancelled bootstrap wrote skills directory: %v", err) @@ -134,7 +172,7 @@ func TestBootstrapRefusesAdapterSymlinkEscape(t *testing.T) { func TestBuilderTemplatesExposeEquivalentOperations(t *testing.T) { profile := bootstrapProfile{YieldVersion: "1.2.3", Agents: []string{"codex"}} - want := []string{"learn", "create", "convert", "check", "repair", "upgrade", "register", "select-mode", "teach-yield", "collect-specification", "check-destination", "project-semantics", "extract-flow", "teach-and-plan", "approve-change", "write-workflow", "verify-workflow", "repair-workflow-", "register-workflow", "verify-adapters", "yskill helper install"} + want := []string{"learn", "create", "convert", "check", "repair", "upgrade", "register", "select-mode", "teach-yield", "collect-specification", "source skill directory under skills/, not the SKILL.md file", "check-destination", "project-semantics", "extract-flow", "teach-and-plan", "approve-change", "write-workflow", "verify-workflow", "repair-workflow-", "register-workflow", "verify-adapters", "yskill helper install"} projectionContract := []string{"source_clause", "disposition", "destinations", "reason", "control", "guidance", "both", "excluded", "ready", "unresolved"} repairLimit := map[string]string{"typescript": "attempt<=2", "python": "range(1, 3)", "go": "attempt<=2", "rust": "1..=2"} for _, language := range []string{"typescript", "python", "go", "rust"} { @@ -396,7 +434,20 @@ func TestBuilderModeFixturesAcrossLanguages(t *testing.T) { t.Fatalf("%s %s fixture did not complete: %v", language, mode, err) } } - writeBuilderResponses(t, dir, builderModeResponses(t, "repair", "stop")) + declinedResponses := builderModeResponses(t, "repair", "stop") + question := builderApprovalQuestion(t, dir, declinedResponses) + for _, expected := range []string{ + "Apply the fixture plan.", + "Primitives:\n- Require binds completion to evidence.", + "Files:\n- skills/yield-workflow-builder-fixture/SKILL.md", + "Commands:\n- yskill doctor --test", + "Apply this plan?", + } { + if !strings.Contains(question, expected) { + t.Errorf("%s approval question is missing %q:\n%s", language, expected, question) + } + } + writeBuilderResponses(t, dir, declinedResponses) if err := cmdDoctor([]string{dir, "--root", root, "--test"}); err == nil || !strings.Contains(err.Error(), "terminal status refused") { t.Fatalf("%s declined mutation returned %v", language, err) } @@ -493,6 +544,48 @@ func marshalBuilderResponses(t *testing.T, responses map[string]any) string { return string(b) + "\n" } +func builderApprovalQuestion(t *testing.T, dir, responses string) string { + t.Helper() + var script map[string]json.RawMessage + if err := json.Unmarshal([]byte(responses), &script); err != nil { + t.Fatal(err) + } + e, err := newEngineWithRunsDir(dir, t.TempDir()) + if err != nil { + t.Fatal(err) + } + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + for p.Terminal == nil { + if p.Envelope.Request.ID == "approve-change" { + var payload protocol.AskUserPayload + if err := json.Unmarshal(p.Envelope.Request.Payload, &payload); err != nil { + t.Fatal(err) + } + return payload.Question + } + result, ok := script[p.Envelope.Request.ID] + if !ok { + t.Fatalf("no scripted response before approval for %q", p.Envelope.Request.ID) + } + response, err := json.Marshal(protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: result, + }) + if err != nil { + t.Fatal(err) + } + p, err = e.Resume(p.RunID, response, false) + if err != nil { + t.Fatal(err) + } + } + t.Fatal("workflow completed before requesting mutation approval") + return "" +} + func runTestCommand(t *testing.T, dir, name string, args ...string) { t.Helper() if _, err := exec.LookPath(name); err != nil { diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 6ecd3d0..010852d 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -79,7 +79,7 @@ manual workflow, install the guided helper explicitly from the repository root: npm exec -- yskill helper install --language typescript # Python -uvx --from yieldskill yskill helper install --language python +python -m yieldskill helper install --language python # Rust cargo install yieldskill --root .yield --locked diff --git a/docs/quickstart.md b/docs/quickstart.md index b159de2..fa125e0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -74,7 +74,7 @@ After learning the manual flow, install guided assistance explicitly: npm exec -- yskill helper install --language typescript # Python -uvx --from yieldskill yskill helper install --language python +python -m yieldskill helper install --language python # Rust .yield/bin/yskill helper install --root . --language rust diff --git a/evals/results/latest-conversion.json b/evals/results/latest-conversion.json index 2d338ab..c34e049 100644 --- a/evals/results/latest-conversion.json +++ b/evals/results/latest-conversion.json @@ -1,8 +1,8 @@ { "schema_version": 1, "methodology_version": "semantic-disposition-v1", - "generated_at": "2026-08-09T11:11:00.278Z", - "source_hash": "e5f48a27c174816531e8dbb36d6987f54fe091a071ce5b5adf9fe19d747232e1", + "generated_at": "2026-08-09T13:48:44.777Z", + "source_hash": "c38823839d4b4aa8981d2e97bd21ead0da360906ad1f0290e03744cdf3df5a82", "fixture_source_hash": "9ab03fffe6716da8298b461f79c9eebae7c7bb01151328686ec51ed9c0b77fe5", "status": "passed", "model": { @@ -13,10 +13,10 @@ }, "sessions": 2, "token_usage": { - "input_tokens": 648932, - "cached_input_tokens": 588148, - "output_tokens": 9945, - "reasoning_output_tokens": 2646 + "input_tokens": 936729, + "cached_input_tokens": 877552, + "output_tokens": 13860, + "reasoning_output_tokens": 4234 }, "clause_counts": { "total": 4, diff --git a/evals/results/latest.json b/evals/results/latest.json index 40838d1..986f527 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-09T11:08:29.697Z", - "source_digest": "b9a11df3a7a86c95932dd5e54fad84e897e99f4058f1ca03109ce7ae269e5545", + "generated_at": "2026-08-09T13:40:24.575Z", + "source_digest": "9a3f7e52d3321fc41bd5792f185db8d9aac235f4031cc1d0651bb60e8de076c1", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index 3bbb3a0..e963e9b 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -372,7 +372,7 @@ test("README and quickstart use the public documentation and package registries" ) const helperCommands = [ "npm exec -- yskill helper install --language typescript", - "uvx --from yieldskill yskill helper install --language python", + "python -m yieldskill helper install --language python", ".yield/bin/yskill helper install --root . --language rust", "go run github.com/operatorstack/yield/cmd/yskill@latest helper install --root . --language go", ] @@ -391,6 +391,9 @@ test("README and quickstart use the public documentation and package registries" ) } assert.match(readme, /Package installation does not create skills or coding-agent adapters/) + assert.match(readme, /one ordered plan with resolved paths, dependency\s+preparation/) + assert.match(readme, /Apply this helper install plan\? \[y\/N\]/) + assert.match(readme, /summary, relevant primitives, exact files, and exact\s+commands/) assert.match( quickstart, /Package installation alone never\s+creates a skill or coding-agent adapter/, diff --git a/sdk/python/README.md b/sdk/python/README.md index 35698aa..ebd22ea 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -229,7 +229,7 @@ Installing `yieldskill` does not create skills or coding-agent adapters. After learning the manual workflow above, install guided assistance explicitly: ```bash -uvx --from yieldskill yskill helper install --language python +python -m yieldskill helper install --language python ``` Review the plan and restart the coding agent after installation. The helper