diff --git a/UPSTREAM.json b/UPSTREAM.json index 71cffa7..7f28224 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -3,17 +3,17 @@ ".gitignore": "803c5f79d6da7f2c5a1dc0ce27c53b8e5c059309b165782831c4c472af058a4c", "LICENSE": "fff261ce507eabd57666c283a621f33e183a3aedebda04c4ecbc6309a62f5edf", "README.md": "da05508b2bae74f3cea3b88945a0346b8f454739a134ba412603fab42530f099", - "cmd/yskill/agents.go": "850e443e0c8279bdc00d9ae39ae729b77db9f222e014e0c7d92f86a9e109eeeb", - "cmd/yskill/agents_test.go": "ea9b229ed35adaeacce59412058500db72ef598fbf0f1ff311fde117ccee6278", - "cmd/yskill/main.go": "fd1fa92a5c30444e6ef09ad8bffcd115c274b2789ed9247d3c2a3781aeefb5e8", - "cmd/yskill/main_test.go": "5da95687ce60f71600530444798c3d290ce033d55466cbcc2e7ec9429da4ec31", + "cmd/yskill/agents.go": "386b04a9eefd3ee4d0e30dc1398abefd1b3d8f3fb129bb3fc389794a062967a1", + "cmd/yskill/agents_test.go": "144d91a1b0dff4eb1d523c49a8e9f4e300a1864c493835c549cd16c6b3be8caa", + "cmd/yskill/main.go": "3e516a829e39d79ced5c7048ef899c5110491db074996742e4a75e490bd185b7", + "cmd/yskill/main_test.go": "c0eb629add322c920834af069d22698466884333bde3b6467219230211cb065f", "cmd/yskill/registry/README.md": "fab385921ee7972f76deb94a3b7ae41184735598ea4145c9d8ccdde2c2b876c4", "cmd/yskill/registry/VERCEL_SKILLS_LICENSE": "779258e329008bdb9330e6c1daad644ff867f164d11c4cc4404350479f3e92ee", "cmd/yskill/registry/agents.json": "6edfee31cdc0390516adbb107fc97fc3531eb02e1a28b9d62b5b86fdccaed7c1", - "cmd/yskill/scaffold.go": "648b772f6c4926d5052245cbd1dc84e713c99210f3436ef4488e83e8e233854b", + "cmd/yskill/scaffold.go": "3841fb9c769b0be024355cdf4063c068b4cc8dd918bc2bbef10a7ff7bcbf786e", "cmd/yskill/skillmeta.go": "12b0c25689bf825d676404af7b3fb74fb92db3d9e222d4e553d8800d86aea45e", "docs/README.md": "63bfd77584e84be09fe55bfcf2e1792d67d9690f9ebf302e6756a77c4c0cfbf5", - "docs/agent-setup.md": "40c5adacbb22fd8d58c37300c68225c1442f579c58614742cefb7da995e40f2d", + "docs/agent-setup.md": "2c0899154fcc17e8144e2e056daf0bb1437d3089f4064b478f9f242ff7920ef9", "docs/convert-existing-skill.md": "0dd538e908a1f2d3a1958e4b2cc1efede2a8e7a73341e32c1a75fec69a9acb8a", "docs/examples.md": "3c19bacae7ec7b31bf93417e61d01cd1872f26228fedf0ed6decfce7c41ed274", "docs/locus-conformance.md": "a71fd5a72678dcad344afc71cd7a788090ef008a8e5e82d925da5a3db41af6a0", @@ -33,14 +33,14 @@ "docs/locus/yield-protocol.json": "82c3490000357d66bc1f35623176a9b0279800340684047d46696d2eb43677d2", "docs/primitives/README.md": "4181e5a4115fff00edef8b0266aa1ecb1bd3443acd0e0272246e650efd13acc9", "docs/primitives/agent-task.md": "89ec69ec7d83c4b78b8d71ee877dfc2b5cabb4fe5bee3f2ee1a524da40349b2d", - "docs/primitives/ask-user.md": "6f12772749efb485b0479cdb17d6aa1dd3394e4a5500278bd80f653bb910e638", + "docs/primitives/ask-user.md": "e76e59b5cb9105d1c55edb4d250162aeb4afe3aa424aee305fff476fc49fdf6e", "docs/primitives/outcomes.md": "a584976fada6af3772894736e686f5deb466d3cc8fd1ecacf49f6b9042d6a232", "docs/primitives/require.md": "1ea8c3a9ea11aa61e2baee01cf83fca8c872e846a47d7ef230cf6b76d04c90d8", "docs/primitives/run-command.md": "af9bcc617f90fd2ee0086aeabb5c98854542efe81636a020699c2f688f8b3b8c", - "docs/quickstart.md": "5bc7febcdb6493d34ac7c16f76f63e4ca3304fdeead256a2038d73f0fd906c7b", - "docs/reference/cli.md": "aa409436156bad1a7691bef47e191e09a083d533ff8893068d07df28ce68b30b", + "docs/quickstart.md": "4f76f73d9b622eecbeed04300a5ad1b8c4f7b611e7ed72ed6b58eff1a44142b1", + "docs/reference/cli.md": "f3469fadc7a461745008fa792515eb5df771084ed175a17868d8fdcecbc9dd5d", "docs/reference/execution-model.md": "b221051da602953ebd9e1cc5e7f848de8f59721b36a1793211e6374745f74507", - "docs/reference/guarantees.md": "07df3251a34345c4673e826ca19ae8a8d2539432bbc7848d1f0e0eb8aa68c808", + "docs/reference/guarantees.md": "fce2006ad46a83c4ae34d221a83d68bc06a776e7c4f54d938bdcdac91977aa50", "docs/reference/sdk-parity.md": "b8c3d6d6efad246b9212db760bb72cba7182b04da321cfd667783000ae75f733", "docs/tutorials/README.md": "d574e869782a1a5e39113b275744cb8cd74b7f63f69c8e30520559d32c21f8d1", "docs/tutorials/approval.md": "d0144288e12488627fce085d3053b1c9b79cc1dac2d45157c214062de3375a9c", @@ -66,9 +66,9 @@ "evals/package.json": "84ac7bd7d1c0d1db2233dab54754296195b267e5e907d9f6c402d0d44d569adb", "evals/results/README.md": "65b74bfab83dfc51fc5b8a3a4824c37b722fb3a358a5dcfb217dc2af199f4801", "evals/results/latest-agent.json": "1484174819ae29bbcffeb615036fa165518669525e6d664123f6a38ca3614b69", - "evals/results/latest.json": "fa1fc16ecd5983b5d78427a4854a8e2d27bb896df919f5dda502461237e63cd0", - "evals/scripts/run.mjs": "119248cd4d383326d396f4459df28c51ce7c31088d97639851f2f5ab4c329892", - "evals/scripts/validate.mjs": "7ac2a3239cd1b52a604c8a8a52a1af4ac55c4007ac2751d4fbfe20d0e1f568dc", + "evals/results/latest.json": "80281e8fc80cc39e03168ff6052986a6c896a06ef776318cbb832996d7386b0d", + "evals/scripts/run.mjs": "17a62cd0f7fda73d84a9bfa21c9c3253ba52a4a23eece09523cfea244e3f9940", + "evals/scripts/validate.mjs": "42d51ea75f9418e45dd1797d1fe02cbc046752d3e3c37d41bc8d3a47d5865d4d", "examples/convert-skill/SKILL.md": "e6376f34365d4ac030d316db55e91f0c606a501668099f4ecf1e27d43ec806a2", "examples/convert-skill/fixtures/responses.json": "5b5f27b0ae5962360ac7b0e779992f430c655f352a38da37debb3505c32cd60a", "examples/convert-skill/main.go": "eb987ed1ce9139e6d0d40a2fae20c532c2fba3b27357e31555db2f0f52db4e39", @@ -257,9 +257,9 @@ "examples/release-checklist/fixtures/responses.json": "9545c4795a5f0676e4b84303cc76911a701f1add94c3034f3c9a0695b2ec5a22", "examples/release-checklist/main.ts": "c9a5c695835b0527378e1fe3aa69619b94ac7994c6456e1dec1ff0e5c5a74cec", "examples/release-checklist/skill.json": "854fcb4bfd3254208233baf11d2762fddd4064ef55d641916a8b02f52612d18d", - "go.mod": "14c29b33635fdb90f3415474a3c13b08a53316d53d7d06fceb8a255e106080aa", - "go.sum": "248b7f2f978a9b04be0ce4d395789211c06c7d3007c6b75f273d22d7fcc72885", - "internal/conformance/conformance_test.go": "cf66fc5bfb0770a69294d17e8d9ee9e643b098f6d805773d6464256e39006db5", + "go.mod": "8e0a98ce35395a77a04190655556f3f9c19e13dcad31ed061d114435ad5e38f3", + "go.sum": "fc6d8a3de7b2f1ef51759974e48940328d8c10951833bcf4d6309daba3110b98", + "internal/conformance/conformance_test.go": "8dd828b3df9a4b0f6cd47a16db40619346a1476c1a85ee62950689d32e892603", "internal/conformance/testdata/skill-go/main.go": "b50d0ae8f978ce9a83f9cec6d3a939b544d8361c519cdf8dd994ddf5adf99ec0", "internal/conformance/testdata/skill-py/main.py": "f34b3315491d0725bce17e8d77cd0b8eb218d2896b51d8c10a660dc658f1cc24", "internal/conformance/testdata/skill-py/skill.json": "5207b98487b29a9914f3621d40daf8da3d1626a90a8fc0173ca0c7ee114b9764", @@ -269,12 +269,12 @@ "internal/conformance/testdata/skill-rs/src/main.rs": "b3101d7c1f7835cb0b22a2be8cfbfb1d8aee8a05e11b6dd11ffcabeeb3b8ccac", "internal/conformance/testdata/skill-ts/main.ts": "621549e23e3f2d2fc07d6796a9b27676509e013c41517c2ea86bd41431dca28b", "internal/conformance/testdata/skill-ts/skill.json": "90fe9e0312e05651ed176c27bfdc73dd7fb9989be9dd024fd46444b402750528", - "internal/engine/engine.go": "6e76c5eedfa9e918af38568630293cf4f980bf034d1406b9c8c20b62545ab30d", - "internal/engine/engine_test.go": "95b07c7ff4d3b80428ff50e48cd96f7bb32083a7f3efc7ad0d70da103175501e", + "internal/engine/engine.go": "bcb7de4f68fd822b7c7a08c3e30b4feb51ad8d99f10f16c4c0afa165cb6c5905", + "internal/engine/engine_test.go": "45c4f37d8adf71fcd273eda6fcafc3a4404d4bd4781d110fa78d6907dd9b6590", "internal/engine/testdata/skill-basic/main.go": "b2c0a732a29db321f3cfd5bf4a890893cfd06904ad159aaccf5c852286f2cd46", "internal/engine/testdata/skill-envbranch/main.go": "7a41ffab373e7acc7754abb4c3c251a7f2c3ca594051a1b0a4860c2592f23b32", "internal/engine/testdata/skill-reqfail/main.go": "163ad3c842b8a8f88e843e408b3ef701cea82933c8d48dd948b914c3f7e97f7b", - "internal/guard/guard.go": "d670d77469468b1e4c42a2de0d6e66f9a624f2fe0240259b4d7c13c22d32247c", + "internal/guard/guard.go": "2858211dc9f1c89b6b94510a110093aa316f7e3ed8752987718fad7bc67f9074", "internal/guard/guard_test.go": "8e0ed25898e4c9892fe1f6fd0bdc3bdfae9ff9382c9ae436a99bdc2b90855dcf", "internal/protocol/ir_test.go": "b8c0166fe5b4f5ab8b699df31368b37680a313b9da2dfdd25a7a46db8a23ca1a", "internal/protocol/protocol.go": "27624f58e8194c3d08a2f94aacf3faedcc7bf7ae32adbccb197a9d2b0d9f2b2f", @@ -304,26 +304,27 @@ "release-notes/2026-08-01-one-package-per-language.md": "6d0d55b4fdcef20332e98c34c1c42c8564aeeeac84647e9f76005b6842d0c91c", "release-notes/2026-08-01-remove-stray-analysis-traces.md": "0567f78ee97ffd23b3f26b5c39606e9ff6659c50a3fdef04ed9b3aa86cfa99af", "release-notes/2026-08-02-cross-agent-registration.md": "8f4296fae36468bda08370bad8d13b7fc1e893b682f8c73015311ab01ace853f", + "release-notes/2026-08-02-dx-hardening.md": "801d85ad760545140415000a95cbd6a681c32462f0578c1ac1ce802893bee303", "sdk/python/README.md": "415fde10711137e0baf30874844a0482cebbf60fb59cb751eba56dedfebb0ce8", "sdk/python/pyproject.toml": "142249ed41c8ea58560e2b54400a2a778d2ac4b327112b2909ded69d9bd99bdb", "sdk/python/test_cli.py": "b7a2fe9a72299aa86f6012129e00f9c2dc0f6e5c29241373fb956321fae5f0b4", - "sdk/python/yieldskill/__init__.py": "7e47b47b8a038640a06f596ab043a9e4519c4b5a02d12ea4e6c2ed398026a739", + "sdk/python/yieldskill/__init__.py": "c43dbb2a25ed7e8537521561a16e2e5fc7921d60f913b22ac298255bd44fe60e", "sdk/python/yieldskill/__main__.py": "2f2978db2ba5bf8034466902e0c0f5fa61d0961b262d776598ba0d9bb47d6b62", "sdk/python/yieldskill/_cli.py": "ab8978c5c053ba3007ba38ae22038a869cb0a5222762edd0fbea749623024b66", "sdk/rust/Cargo.toml": "39c19c2b7800c9b9d1f7387548f56ad3feb8c7712255e101d967bab82466fe8b", - "sdk/rust/src/lib.rs": "0c0d85d55f81e289f83588030f69e67d6d9baead9b05aa5bb1aca74c9450b851", + "sdk/rust/src/lib.rs": "581882dca50cf4e27a8f2e05b013b2bc091802b6a20d89d58ffbcdf5c23372a7", "sdk/typescript/bin/runtime.mjs": "909da102e8ed8ccc917cd17d88acc65869a0a2bdb6f875f76d763383999a2b57", "sdk/typescript/bin/runtime.test.mjs": "db9c2dc1054d7f03139d69d3ffdb1e6fe5b65a71f4bdcca50fdefb2562831a2b", "sdk/typescript/bin/yskill.mjs": "4ac8b13afc26fdc5b9192e6443dc121b83c7abfb9a4b80768f8344b8faaa4197", "sdk/typescript/package.json": "8a2b618ac94a00ef8a85ce2e97112b97db8a90505539b97f6b4afd4d383d6df5", "sdk/typescript/scripts/build.mjs": "1ba086bbdddb61226f3b6363e1fda0ffdd129ce90ea9da28ba71ecfb0f478e6d", - "sdk/typescript/src/index.ts": "59e7ce9f0e5443a1294c327c71b2d19dfd45fa78761430a835dc12a98885c8b9", - "sdk/yield/yield.go": "5250ded7e18be107b1ccbd5a46ab41f7baeeedbfe5b88a11b5a860b05b11625a" + "sdk/typescript/src/index.ts": "bc91d43b8f6698a3139fd482077942a22beab950db74164d94422ecb38631a0b", + "sdk/yield/yield.go": "c9b4c7ab9a36b28f383e141fc084bd8b3b693c63c5a22722d8ae7ef3253f76ff" }, "generator": "operatorstack/yield:project", "schema_version": 1, "source": { - "commit": "34cac6048389a19a16c4fed1e1f0fdec6e67505f", + "commit": "6d6739a7d41f276c2ee7619c588443a7f319b4a5", "path": "labs/22-yield", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index 62c0278..f44aefd 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -132,6 +132,162 @@ func cmdRegister(args []string) error { return nil } +type adapterPlan struct { + path, sourceRel, content, status string + agentIDs []string +} + +func cmdRegisterAll(args []string) error { + fs := flag.NewFlagSet("register-all", flag.ContinueOnError) + var agents agentListFlag + fs.Var(&agents, "agent", "agent id, comma-separated ids, or auto") + root := fs.String("root", "", "repository root (detected from .git by default)") + dryRun := fs.Bool("dry-run", false, "print the synchronization plan without writing") + prune := fs.Bool("prune", false, "remove obsolete generated adapters owned by this workflow directory") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("register-all takes exactly one skills directory") + } + parent, err := filepath.Abs(fs.Arg(0)) + if err != nil { + return err + } + parent, err = filepath.EvalSymlinks(parent) + if err != nil { + return err + } + entries, err := os.ReadDir(parent) + if err != nil { + return err + } + var skills []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(parent, entry.Name()) + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil { + skills = append(skills, dir) + } + } + sort.Strings(skills) + if len(skills) == 0 { + return fmt.Errorf("no workflows found directly under %s", parent) + } + plansByPath := map[string]*adapterPlan{} + names := map[string]string{} + var repoRoot, parentRel string + var selected []agentConfig + for _, skill := range skills { + skillDir, resolvedRoot, sourceRel, metadata, manifest, _, selectedAgents, inputErr := registrationInputs(skill, *root, agents) + if inputErr != nil { + return inputErr + } + if previous := names[metadata.Name]; previous != "" { + return fmt.Errorf("agent-facing name %q is used by both %s and %s; names must be unique before registration", metadata.Name, previous, skillDir) + } + names[metadata.Name] = skillDir + if repoRoot == "" { + repoRoot, selected = resolvedRoot, selectedAgents + parentRel, _ = filepath.Rel(repoRoot, parent) + } + digest, digestErr := protocol.DigestSkillDir(skillDir) + if digestErr != nil { + return digestErr + } + launcher, launcherErr := launcherFor(manifest.Language, skillDir, repoRoot) + if launcherErr != nil { + return launcherErr + } + content := renderAdapter(metadata, sourceRel, digest, launcher) + for _, agent := range selectedAgents { + path := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), metadata.Name, "SKILL.md") + plan := plansByPath[path] + if plan == nil { + status, statusErr := generatedAdapterStatus(path, sourceRel, content) + if statusErr != nil { + return statusErr + } + plan = &adapterPlan{path: path, sourceRel: sourceRel, content: content, status: status} + plansByPath[path] = plan + } + plan.agentIDs = append(plan.agentIDs, agent.ID) + } + } + paths := make([]string, 0, len(plansByPath)) + for path := range plansByPath { + paths = append(paths, path) + } + sort.Strings(paths) + var conflicts []string + for _, path := range paths { + plan := plansByPath[path] + if plan.status == "conflict" { + existing := "user-owned adapter" + if b, err := os.ReadFile(path); err == nil { + if source := generatedSource(string(b)); source != "" { + existing = source + } + } + conflicts = append(conflicts, fmt.Sprintf("%s and %s at %s", existing, plan.sourceRel, path)) + } + } + if len(conflicts) > 0 { + return fmt.Errorf("refusing bulk registration; resolve every agent-facing name collision before writing:\n - %s", strings.Join(conflicts, "\n - ")) + } + for _, path := range paths { + plan := plansByPath[path] + sort.Strings(plan.agentIDs) + fmt.Printf("%-9s %-24s %s\n", plan.status+":", strings.Join(plan.agentIDs, ","), filepath.ToSlash(path)) + if !*dryRun && plan.status != "unchanged" { + if _, err := writeGeneratedAdapter(path, plan.sourceRel, plan.content); err != nil { + return err + } + } + } + if *prune { + prefix := filepath.ToSlash(filepath.Clean(parentRel)) + "/" + for _, agent := range selected { + base := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir)) + children, _ := os.ReadDir(base) + for _, child := range children { + path := filepath.Join(base, child.Name(), "SKILL.md") + b, readErr := os.ReadFile(path) + if readErr != nil { + continue + } + source := generatedSource(string(b)) + if !strings.HasPrefix(source, prefix) || plansByPath[path] != nil { + continue + } + fmt.Printf("removed: %-24s %s\n", agent.ID, filepath.ToSlash(path)) + if !*dryRun { + if err := os.Remove(path); err != nil { + return err + } + _ = os.Remove(filepath.Dir(path)) // Keep the directory if it contains user files. + } + } + } + } + return nil +} + +func generatedSource(text string) string { + start := strings.Index(text, generatedAdapterPrefix) + if start < 0 { + return "" + } + value := text[start+len(generatedAdapterPrefix):] + end := strings.Index(value, ";") + if end < 0 { + return "" + } + return value[:end] +} + func reloadHint(agentIDs string) string { if strings.Contains(agentIDs, ",") { return "start a new session in each selected agent" @@ -180,7 +336,7 @@ func registerSkill(skillArg, rootArg string, requested []string) ([]registration if err := ensureContainedWrite(repoRoot, path); err != nil { return nil, err } - if err := writeGeneratedAdapter(path, sourceRel, content); err != nil { + if _, err := writeGeneratedAdapter(path, sourceRel, content); err != nil { return nil, err } rel, _ := filepath.Rel(repoRoot, path) @@ -447,47 +603,65 @@ Read its SKILL.md, then run from the repository root: %s run %s -Follow each returned operation exactly. Resume after each response: + Follow each returned operation exactly. Answer each operation directly: - %s resume --response response.json --skill %s + %s respond --value --skill %s + + For structured agent results, use --result-json instead of --value. Do not skip an operation or invent its response. `, metadata.Name, yamlString(metadata.Description), generatedAdapterPrefix, sourceRel, digest, runtimeVersion(), "`"+sourceRel+"`", launcher, path, launcher, path) } -func writeGeneratedAdapter(path, sourceRel, content string) error { +func generatedAdapterStatus(path, sourceRel, content string) (string, error) { if existing, err := os.ReadFile(path); err == nil { marker := generatedAdapterPrefix + sourceRel + ";" if !strings.Contains(string(existing), marker) { - return fmt.Errorf("refusing to overwrite user-owned or differently sourced adapter %s", path) + return "conflict", nil } if string(existing) == content { - return nil + return "unchanged", nil } } else if !errors.Is(err, fs.ErrNotExist) { - return err + return "", err + } else { + return "added", nil + } + return "updated", nil +} + +func writeGeneratedAdapter(path, sourceRel, content string) (string, error) { + status, err := generatedAdapterStatus(path, sourceRel, content) + if err != nil { + return "", err + } + if status == "conflict" { + return status, fmt.Errorf("refusing to overwrite user-owned or differently sourced adapter %s", path) + } + if status == "unchanged" { + return status, nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err + return "", err } tmp, err := os.CreateTemp(filepath.Dir(path), ".yskill-adapter-*") if err != nil { - return err + return "", err } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.WriteString(content); err != nil { tmp.Close() - return err + return "", err } if err := tmp.Chmod(0o644); err != nil { tmp.Close() - return err + return "", err } if err := tmp.Close(); err != nil { - return err + return "", err } - return os.Rename(tmpName, path) + return status, os.Rename(tmpName, path) } func cmdDoctor(args []string) error { @@ -502,37 +676,108 @@ func cmdDoctor(args []string) error { if fs.NArg() != 1 { return fmt.Errorf("doctor takes exactly one skill directory") } - skillDir, repoRoot, sourceRel, _, manifest, _, selected, err := registrationInputs(fs.Arg(0), *root, agents) + skillDir, err := filepath.Abs(fs.Arg(0)) if err != nil { return err } - if _, err := launcherFor(manifest.Language, skillDir, repoRoot); err != nil { + skillDir, err = filepath.EvalSymlinks(skillDir) + if err != nil { + return err + } + if _, err := readSkillMetadata(skillDir); err != nil { + return err + } + manifest, err := readSkillManifest(skillDir) + if err != nil { + return err + } + packageBoundary, boundaryErr := findRepoRoot(skillDir, *root) + if boundaryErr != nil { + if len(agents) > 0 || *root != "" { + return boundaryErr + } + volume := filepath.VolumeName(skillDir) + packageBoundary = volume + string(filepath.Separator) + } else if packageBoundary, err = filepath.EvalSymlinks(packageBoundary); err != nil { + return err + } + if _, err := launcherFor(manifest.Language, skillDir, packageBoundary); err != nil { + return err + } + if err := languageDiagnostics(manifest.Language, skillDir); err != nil { + return err + } + if *runTest { + if err := cmdTest([]string{skillDir}); err != nil { + return err + } + } + fmt.Printf("ok: workflow %s\n", filepath.ToSlash(skillDir)) + if len(agents) == 0 { + fmt.Printf("doctor: %s workflow is ready\n", filepath.Base(skillDir)) + return nil + } + _, repoRoot, sourceRel, metadata, _, _, selected, err := registrationInputs(skillDir, *root, agents) + if err != nil { return err } digest, err := protocol.DigestSkillDir(skillDir) if err != nil { return err } + var problems []string for _, agent := range selected { - path := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), filepath.Base(skillDir), "SKILL.md") + path := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), metadata.Name, "SKILL.md") if err := ensureContainedWrite(repoRoot, path); err != nil { - return err + problems = append(problems, fmt.Sprintf("%s: %v", agent.ID, err)) + continue } - b, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("%s adapter missing at %s; run yskill register", agent.ID, path) + b, readErr := os.ReadFile(path) + if readErr != nil { + problems = append(problems, fmt.Sprintf("%s: adapter missing at %s", agent.ID, path)) + continue } text := string(b) if !strings.Contains(text, generatedAdapterPrefix+sourceRel+";") || !strings.Contains(text, "digest: "+digest+";") { - return fmt.Errorf("%s adapter is stale or points elsewhere; run yskill register", agent.ID) + problems = append(problems, fmt.Sprintf("%s: adapter is stale or points elsewhere", agent.ID)) + continue } fmt.Printf("ok: %-22s %s\n", agent.ID, filepath.ToSlash(path)) } - if *runTest { - if err := cmdTest([]string{skillDir}); err != nil { - return err - } + if len(problems) > 0 { + return fmt.Errorf("adapter problems:\n - %s\nrun yskill register to update them", strings.Join(problems, "\n - ")) } fmt.Printf("doctor: %s is ready for %d agent(s)\n", filepath.Base(skillDir), len(selected)) return nil } + +func languageDiagnostics(language, skillDir string) error { + switch language { + case "go": + if !commandExists("go") { + return fmt.Errorf("Go workflow needs the go command") + } + fmt.Printf("validate: (cd %s && go test ./... && go vet ./...)\n", shellQuote(filepath.ToSlash(skillDir))) + case "rust": + if !commandExists("cargo") { + return fmt.Errorf("Rust workflow needs the cargo command") + } + if _, err := os.Stat(filepath.Join(skillDir, ".cargo", "config.toml")); err != nil { + return fmt.Errorf("Rust workflow needs .cargo/config.toml in the workflow directory: %w", err) + } + var missing []string + if !commandExists("rustfmt") { + missing = append(missing, "rustfmt") + } + if !commandExists("cargo-clippy") { + missing = append(missing, "clippy") + } + if len(missing) > 0 { + fmt.Printf("optional: rustup component add %s\n", strings.Join(missing, " ")) + } + fmt.Printf("validate: (cd %s && cargo fmt --check && cargo check && cargo clippy -- -D warnings)\n", shellQuote(filepath.ToSlash(skillDir))) + case "python": + fmt.Printf("validate: (cd %s && python -m compileall -q .)\n", shellQuote(filepath.ToSlash(skillDir))) + } + return nil +} diff --git a/cmd/yskill/agents_test.go b/cmd/yskill/agents_test.go index e04bba1..1715282 100644 --- a/cmd/yskill/agents_test.go +++ b/cmd/yskill/agents_test.go @@ -200,6 +200,100 @@ func TestDoctorDetectsCurrentAndStaleAdapters(t *testing.T) { } } +func TestDoctorWithoutAgentChecksWorkflowOnly(t *testing.T) { + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n") + skill := createTypeScriptSkill(t, repo, "review") + if err := cmdDoctor([]string{skill, "--root", repo}); err != nil { + t.Fatalf("workflow-only doctor required an adapter: %v", err) + } +} + +func TestDoctorWorkflowOnlyDoesNotRequireRepository(t *testing.T) { + root := t.TempDir() + skill := createTypeScriptSkill(t, root, "review") + if err := cmdDoctor([]string{skill}); err != nil { + t.Fatalf("workflow-only doctor required a repository: %v", err) + } +} + +func TestRegisterAllPreflightsAndWritesEveryWorkflow(t *testing.T) { + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n") + writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`) + for _, name := range []string{"review", "release"} { + skill := filepath.Join(repo, "skills", name) + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n") + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") + } + if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex", "--dry-run"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repo, ".agents", "skills", "review", "SKILL.md")); !os.IsNotExist(err) { + t.Fatal("dry-run wrote an adapter") + } + if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex"}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"review", "release"} { + if _, err := os.Stat(filepath.Join(repo, ".agents", "skills", name, "SKILL.md")); err != nil { + t.Fatal(err) + } + } +} + +func TestRegisterAllPruneRemovesOnlyOwnedAdapterFile(t *testing.T) { + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n") + writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`) + for _, name := range []string{"review", "release"} { + skill := filepath.Join(repo, "skills", name) + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n") + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") + } + if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex"}); err != nil { + t.Fatal(err) + } + releaseAdapter := filepath.Join(repo, ".agents", "skills", "release") + writeTestFile(t, filepath.Join(releaseAdapter, "notes.txt"), "keep me\n") + if err := os.RemoveAll(filepath.Join(repo, "skills", "release")); err != nil { + t.Fatal(err) + } + if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex", "--prune"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(releaseAdapter, "SKILL.md")); !os.IsNotExist(err) { + t.Fatalf("obsolete generated adapter remains: %v", err) + } + if got := readTestFile(t, filepath.Join(releaseAdapter, "notes.txt")); got != "keep me\n" { + t.Fatalf("prune changed sibling file: %q", got) + } +} + +func TestRegisterAllRefusesCrossDirectoryNameCollisionBeforeWrites(t *testing.T) { + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n") + writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`) + makeSkill := func(parent string) string { + skill := filepath.Join(repo, parent, "review") + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review the project when a user asks for a check.\n---\n") + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") + return skill + } + first := makeSkill("typescript-skills") + second := makeSkill("python-skills") + if _, err := registerSkill(first, repo, []string{"codex"}); err != nil { + t.Fatal(err) + } + err := cmdRegisterAll([]string{filepath.Dir(second), "--root", repo, "--agent", "codex"}) + if err == nil || !strings.Contains(err.Error(), "name collision") || !strings.Contains(err.Error(), "typescript-skills/review") || !strings.Contains(err.Error(), "python-skills/review") { + t.Fatalf("cross-directory collision = %v", err) + } +} + func TestShellQuoteEscapesSingleQuote(t *testing.T) { if got := shellQuote("skills/team's-review"); got != `'skills/team'"'"'s-review'` { t.Fatalf("shellQuote = %q", got) diff --git a/cmd/yskill/main.go b/cmd/yskill/main.go index 989d378..5e9e7ff 100644 --- a/cmd/yskill/main.go +++ b/cmd/yskill/main.go @@ -4,17 +4,24 @@ package main import ( + "bytes" "encoding/json" + "errors" "flag" "fmt" + "io" "os" + "os/exec" "path/filepath" "runtime" "runtime/debug" + "sort" "strings" + "time" "github.com/operatorstack/yield/internal/engine" "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" ) const usage = `yskill — turn SKILL.md workflows into resumable programs @@ -24,15 +31,21 @@ Usage: [--language typescript|python|go|rust] [--description text] yskill register expose one workflow to coding agents [--agent cursor,codex,...|auto] [--root repo] + yskill register-all expose every immediate workflow + [--agent cursor,codex,...|auto] [--root repo] [--dry-run] [--prune] yskill agents list supported coding agents and paths yskill doctor check package, workflow, and adapters [--agent cursor,codex,...|auto] [--root repo] [--test] yskill run [--input file] start a run; prints the first operation envelope yskill resume --response file feed a response; prints the next operation [--skill dir] [--accept-new-digest] + yskill respond --value text answer the pending question directly + [--result-json json|-] [--skill dir] yskill inspect [--skill dir] print the run's event log + yskill prune --older-than 720h remove old terminal runs + [--keep-last n] [--dry-run] yskill replay [--skill dir] re-derive the run from its log; verify determinism - yskill test run the skill against fixtures/responses.json + yskill test [--keep-run] run the skill against fixtures/responses.json yskill version print the runtime version and target ` @@ -61,6 +74,8 @@ func main() { err = cmdInit(os.Args[2:]) case "register": err = cmdRegister(os.Args[2:]) + case "register-all": + err = cmdRegisterAll(os.Args[2:]) case "agents": err = cmdAgents(os.Args[2:]) case "doctor": @@ -69,8 +84,12 @@ func main() { err = cmdRun(os.Args[2:]) case "resume": err = cmdResume(os.Args[2:]) + case "respond": + err = cmdRespond(os.Args[2:]) case "inspect": err = cmdInspect(os.Args[2:]) + case "prune": + err = cmdPrune(os.Args[2:]) case "replay": err = cmdReplay(os.Args[2:]) case "test": @@ -143,6 +162,51 @@ func cmdResume(args []string) error { return printProgress(p) } +func cmdRespond(args []string) error { + fs := flag.NewFlagSet("respond", flag.ExitOnError) + value := fs.String("value", "", "answer value for an ask_user operation") + resultJSON := fs.String("result-json", "", "JSON result, or - to read JSON from stdin") + skillDir := fs.String("skill", ".", "skill directory the run belongs to") + if err := parseOnePositional(fs, args); err != nil { + return err + } + seen := map[string]bool{} + fs.Visit(func(f *flag.Flag) { seen[f.Name] = true }) + if fs.NArg() != 1 || seen["value"] == seen["result-json"] { + return fmt.Errorf("respond takes one run id and exactly one of --value or --result-json") + } + e, err := engine.New(*skillDir) + if err != nil { + return err + } + var result json.RawMessage + if seen["value"] { + result, err = json.Marshal(map[string]string{"value": *value}) + } else if *resultJSON == "-" { + result, err = io.ReadAll(os.Stdin) + } else { + result = json.RawMessage(*resultJSON) + } + if err != nil { + return err + } + if !json.Valid(result) { + return fmt.Errorf("result is not valid JSON") + } + pending, pendingErr := e.Pending(fs.Arg(0)) + if pendingErr == nil && seen["value"] && pending.Request.Kind != protocol.OpAskUser { + return fmt.Errorf("--value is only valid for ask_user; use --result-json for %s", pending.Request.Kind) + } + if pendingErr != nil && !strings.Contains(pendingErr.Error(), "has no pending operation") && !strings.Contains(pendingErr.Error(), "terminal state") { + return pendingErr + } + p, err := e.Respond(fs.Arg(0), result) + if err != nil { + return err + } + return printProgress(p) +} + func cmdInspect(args []string) error { fs := flag.NewFlagSet("inspect", flag.ExitOnError) skillDir := fs.String("skill", ".", "skill directory the run belongs to") @@ -173,6 +237,70 @@ func cmdInspect(args []string) error { return nil } +func cmdPrune(args []string) error { + fs := flag.NewFlagSet("prune", flag.ExitOnError) + olderThan := fs.Duration("older-than", 0, "minimum terminal-run age, for example 24h or 720h") + keepLast := fs.Int("keep-last", 0, "always keep this many newest terminal runs") + dryRun := fs.Bool("dry-run", false, "print runs without deleting them") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if fs.NArg() != 1 || *olderThan <= 0 || *keepLast < 0 { + return fmt.Errorf("prune takes one skill directory, --older-than greater than zero, and a nonnegative --keep-last") + } + e, err := engine.New(fs.Arg(0)) + if err != nil { + return err + } + ids, err := e.ListRuns() + if err != nil { + return err + } + type candidate struct { + id string + mod time.Time + } + var terminal []candidate + for _, id := range ids { + log, openErr := e.Log(id) + if openErr != nil { + return openErr + } + closed := false + for _, event := range log.Events() { + if event.Type == runlog.RunCompleted || event.Type == runlog.RunBlocked || event.Type == runlog.RunRefused { + closed = true + } + } + if !closed { + continue + } + info, statErr := os.Stat(log.Path) + if statErr != nil { + return statErr + } + terminal = append(terminal, candidate{id: id, mod: info.ModTime()}) + } + sort.Slice(terminal, func(i, j int) bool { return terminal[i].mod.After(terminal[j].mod) }) + cutoff := time.Now().Add(-*olderThan) + removed := 0 + for index, run := range terminal { + if index < *keepLast || !run.mod.Before(cutoff) { + continue + } + fmt.Printf("prune: %s\n", run.id) + if !*dryRun { + if err := os.Remove(filepath.Join(e.RunsDir, run.id+".jsonl")); err != nil { + return err + } + _ = os.Remove(filepath.Join(e.RunsDir, run.id+".lock")) + } + removed++ + } + fmt.Printf("prune: %d terminal run(s) selected\n", removed) + return nil +} + func cmdReplay(args []string) error { fs := flag.NewFlagSet("replay", flag.ExitOnError) skillDir := fs.String("skill", ".", "skill directory the run belongs to") @@ -197,8 +325,9 @@ func cmdReplay(args []string) error { // cmdTest drives a skill against fixtures/responses.json: an ordered map // of request id -> scripted result. run_command operations execute for // real; everything else is answered from the script. -func cmdTest(args []string) error { +func cmdTest(args []string) (retErr error) { fs := flag.NewFlagSet("test", flag.ExitOnError) + keepRun := fs.Bool("keep-run", false, "keep the fixture run under the workflow's .yield directory") if err := parseOnePositional(fs, args); err != nil { return err } @@ -214,7 +343,29 @@ func cmdTest(args []string) error { if err := json.Unmarshal(b, &script); err != nil { return fmt.Errorf("fixtures/responses.json does not decode: %w", err) } - e, err := engine.New(dir) + fixture, err := readFixtureConfig(dir) + if err != nil { + return err + } + defer func() { + if err := runFixtureCommands(dir, fixture.Teardown, nil); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("fixture teardown: %w", err)) + } + }() + if err := runFixtureCommands(dir, fixture.Setup, nil); err != nil { + return fmt.Errorf("fixture setup: %w", err) + } + var e *engine.Engine + if *keepRun { + e, err = engine.New(dir) + } else { + var runs string + runs, err = os.MkdirTemp("", "yield-test-runs-*") + if err == nil { + defer os.RemoveAll(runs) + e, err = engine.NewWithRunsDir(dir, runs) + } + } if err != nil { return err } @@ -223,7 +374,8 @@ func cmdTest(args []string) error { return err } for p.Terminal == nil { - result, ok := script[p.Envelope.Request.ID] + requestID := p.Envelope.Request.ID + result, ok := script[requestID] if !ok { return fmt.Errorf("no scripted response for request %q (sequence %d)", p.Envelope.Request.ID, p.Envelope.Sequence) } @@ -234,6 +386,12 @@ func cmdTest(args []string) error { if err != nil { return err } + if err := protocol.ValidateResult(p.Envelope.Request.OutputSchema, result); err != nil { + return fmt.Errorf("fixture response for %q: %w", requestID, err) + } + if err := runFixtureCommands(dir, fixture.AfterResponse[requestID], result); err != nil { + return fmt.Errorf("fixture effect for %q: %w", requestID, err) + } if p, err = e.Resume(p.RunID, resp, false); err != nil { return err } @@ -245,6 +403,57 @@ func cmdTest(args []string) error { return nil } +type fixtureConfig struct { + Version int `json:"version"` + Setup [][]string `json:"setup"` + AfterResponse map[string][][]string `json:"after_response"` + Teardown [][]string `json:"teardown"` +} + +func readFixtureConfig(dir string) (fixtureConfig, error) { + path := filepath.Join(dir, "fixtures", "test.json") + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return fixtureConfig{Version: 1}, nil + } + if err != nil { + return fixtureConfig{}, err + } + var config fixtureConfig + decoder := json.NewDecoder(bytes.NewReader(b)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return fixtureConfig{}, fmt.Errorf("fixtures/test.json does not decode: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fixtureConfig{}, fmt.Errorf("fixtures/test.json must contain one JSON object") + } + if config.Version != 1 { + return fixtureConfig{}, fmt.Errorf("fixtures/test.json version must be 1") + } + return config, nil +} + +func runFixtureCommands(dir string, commands [][]string, input json.RawMessage) error { + for index, argv := range commands { + if len(argv) == 0 { + return fmt.Errorf("command %d is empty", index+1) + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "YIELD_FIXTURE=1") + if len(input) > 0 { + cmd.Stdin = bytes.NewReader(input) + } + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Run(); err != nil { + return fmt.Errorf("%q failed: %w: %s", argv, err, strings.TrimSpace(output.String())) + } + } + return nil +} + // parseOnePositional accepts the documented command shape where the target // comes first and flags follow it. The standard flag package stops parsing at // the first positional argument, so move that one target behind the flags. @@ -263,7 +472,7 @@ func printProgress(p *engine.Progress) error { fmt.Printf("reason: %s\n", p.Terminal.Reason) } if len(p.Terminal.Result) > 0 { - fmt.Printf("result: %s\n", compact(p.Terminal.Result)) + fmt.Printf("result: %s\n", p.Terminal.Result) } return nil } diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index f4cc6ae..03fcba3 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -1,14 +1,90 @@ package main import ( + "encoding/json" "flag" + "io" "os" "path/filepath" "runtime/debug" "strings" "testing" + "time" + + "github.com/operatorstack/yield/internal/engine" + "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" ) +func TestPrintProgressKeepsCompleteStructuredResult(t *testing.T) { + result := `{"summary":"` + strings.Repeat("x", 300) + `","count":42}` + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + previous := os.Stdout + os.Stdout = write + t.Cleanup(func() { os.Stdout = previous }) + callErr := printProgress(&engine.Progress{RunID: "run_test", Terminal: &protocol.TerminalOutcome{ + Status: protocol.StatusCompleted, Result: json.RawMessage(result), + }}) + if err := write.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = previous + output, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + if callErr != nil { + t.Fatal(callErr) + } + if !strings.Contains(string(output), result) { + t.Fatalf("terminal result was truncated: %s", output) + } +} + +func TestFixtureCommandReceivesResponseOnStdin(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "effect.json") + input := []byte(`{"value":"approved"}`) + command := []string{os.Args[0], "-test.run=TestFixtureCommandHelper", "--", output} + if err := runFixtureCommands(dir, [][]string{command}, input); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(got) != string(input) { + t.Fatalf("fixture stdin = %q, want %q", got, input) + } +} + +func TestFixtureCommandHelper(t *testing.T) { + if os.Getenv("YIELD_FIXTURE") != "1" { + return + } + separator := -1 + for index, arg := range os.Args { + if arg == "--" { + separator = index + break + } + } + if separator < 0 || separator+1 >= len(os.Args) { + os.Exit(2) + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + os.Exit(3) + } + if err := os.WriteFile(os.Args[separator+1], input, 0o600); err != nil { + os.Exit(4) + } + os.Exit(0) +} + func TestRuntimeVersionUsesGoModuleVersion(t *testing.T) { previousVersion := version previousReadBuildInfo := readBuildInfo @@ -25,6 +101,44 @@ func TestRuntimeVersionUsesGoModuleVersion(t *testing.T) { } } +func TestPruneRemovesOnlyOldTerminalRuns(t *testing.T) { + skill := t.TempDir() + runs := filepath.Join(skill, ".yield", "runs") + if err := os.MkdirAll(runs, 0o755); err != nil { + t.Fatal(err) + } + makeRun := func(id string, closed bool) string { + log, err := runlog.Create(runs, id) + if err != nil { + t.Fatal(err) + } + if _, err := log.Append(runlog.RunStarted, map[string]any{"run_id": id}); err != nil { + t.Fatal(err) + } + if closed { + if _, err := log.Append(runlog.RunCompleted, map[string]any{"result": "ok"}); err != nil { + t.Fatal(err) + } + } + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(log.Path, old, old); err != nil { + t.Fatal(err) + } + return log.Path + } + closed := makeRun("run_closed", true) + active := makeRun("run_active", false) + if err := cmdPrune([]string{skill, "--older-than", "24h"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(closed); !os.IsNotExist(err) { + t.Fatalf("terminal run was not pruned: %v", err) + } + if _, err := os.Stat(active); err != nil { + t.Fatalf("active run was pruned: %v", err) + } +} + func TestParseOnePositionalAllowsDocumentedFlagOrder(t *testing.T) { fs := flag.NewFlagSet("resume", flag.ContinueOnError) response := fs.String("response", "", "response file") @@ -98,6 +212,16 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { if !strings.Contains(skill, tt.command) { t.Fatalf("SKILL.md does not contain %q:\n%s", tt.command, skill) } + entrypoint := map[string]string{ + "typescript": "main.ts", + "python": "main.py", + "go": "main.go", + "rust": "src/main.rs", + }[tt.language] + program := readTestFile(t, filepath.Join(dir, filepath.FromSlash(entrypoint))) + if !strings.Contains(program, "replace the starter workflow and fixture before testing") { + t.Fatalf("%s starter can pass without implementation:\n%s", entrypoint, program) + } var manifest string switch tt.language { case "typescript": @@ -138,7 +262,7 @@ func TestGoScaffoldCanResolveItsPinnedModuleOnFirstRun(t *testing.T) { } } -func TestPythonScaffoldUsesInvokingInterpreter(t *testing.T) { +func TestPythonScaffoldUsesRelocatableInterpreter(t *testing.T) { previousVersion := version version = "0.1.9" t.Cleanup(func() { version = previousVersion }) @@ -148,8 +272,8 @@ func TestPythonScaffoldUsesInvokingInterpreter(t *testing.T) { t.Fatal(err) } skill := readTestFile(t, filepath.Join(dir, "skill.json")) - if !strings.Contains(skill, `"/opt/yield/.venv/bin/python"`) { - t.Fatalf("skill.json does not use the invoking interpreter: %s", skill) + if !strings.Contains(skill, `"run":["python","main.py"]`) || strings.Contains(skill, `/opt/yield`) { + t.Fatalf("skill.json is not relocatable: %s", skill) } } diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index b28735e..03f3202 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -97,8 +97,9 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { return fmt.Errorf("validate SKILL.md: %w", err) } fmt.Printf("init: %s skill %q scaffolded in %s\n", language, name, dir) - fmt.Printf("next: %s register %s\n", launcher, shellQuote(dir)) - fmt.Printf("check: %s doctor %s --test\n", launcher, shellQuote(dir)) + fmt.Println("next: replace the starter program and fixtures with the described workflow") + fmt.Printf("test: %s doctor %s --test\n", launcher, shellQuote(dir)) + fmt.Printf("then: %s register %s\n", launcher, shellQuote(dir)) return nil } @@ -117,14 +118,10 @@ func scaffoldFiles(name, language, sdkPath string) map[string]string { "skill.json": "{\"version\":1,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n", } case "python": - python := strings.TrimSpace(os.Getenv("YIELD_PYTHON")) - if python == "" { - python = "python" - } return map[string]string{ "main.py": mainPython, "requirements.txt": fmt.Sprintf("--index-url https://get.operatorstack.systems/pip/simple/\nyieldskill==%s\n", v), - "skill.json": fmt.Sprintf("{\"version\":1,\"language\":\"python\",\"run\":[%q,\"main.py\"]}\n", python), + "skill.json": "{\"version\":1,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n", } case "rust": return map[string]string{ @@ -155,9 +152,9 @@ Run: %s run . -Follow each returned operation exactly. Resume after each response: +Follow each returned operation exactly. Answer it directly: - %s resume --response response.json --skill . + %s respond --value --skill . Do not skip an operation or invent a response. ` @@ -172,7 +169,7 @@ func main() { if answer != "yes" { return yield.Outcome{}, ctx.Refused("user declined to start") } - return ctx.Complete(map[string]string{"status": "ready"}) + return yield.Outcome{}, ctx.Blocked("replace the starter workflow and fixture before testing") }) } ` @@ -184,7 +181,7 @@ defineSkill((ctx) => { { value: "yes" }, { value: "no" } ]) if (answer !== "yes") ctx.refused("user declined to start") - return { status: "ready" } + ctx.blocked("replace the starter workflow and fixture before testing") }) ` @@ -194,7 +191,7 @@ def program(ctx): answer = ctx.ask_user("confirm-start", "Ready to start?", options=[{"value": "yes"}, {"value": "no"}]) if answer != "yes": ctx.refused("user declined to start") - return {"status": "ready"} + ctx.blocked("replace the starter workflow and fixture before testing") define_skill(program) ` @@ -207,7 +204,7 @@ fn program(ctx: &mut Context) -> SkillResult { if answer != "yes" { return Err(ctx.refused("user declined to start")); } - Ok(json!({"status": "ready"})) + Err(ctx.blocked("replace the starter workflow and fixture before testing")) } fn main() { define_skill(program); } diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 3d3ffe6..5393050 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -13,8 +13,15 @@ yskill register skills/review # Or choose agents explicitly yskill register skills/review --agent cursor,codex,claude-code -# Check the package and adapters; add --test to run fixture responses +# Check the workflow itself +yskill doctor skills/review --test + +# Check selected adapters too yskill doctor skills/review --agent cursor,codex,claude-code --test + +# Register a complete workflow directory in one pass +yskill register-all skills --agent cursor,codex,claude-code --dry-run +yskill register-all skills --agent cursor,codex,claude-code --prune ``` Use the launcher installed by the selected language package: @@ -34,7 +41,8 @@ end to end by Yield. Generated adapters are safe to commit. Regenerate them after changing the canonical workflow. Yield refuses to overwrite a user-owned skill with the -same name. +same name. Names must also be unique across languages because coding agents use +one project-level skill namespace. ## Copy this to your agent @@ -49,13 +57,28 @@ Set up a Yield workflow named [skill-name] in skills/[skill-name]. 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. Keep the workflow beside the project's language dependencies. -5. Run yskill register for the coding agent you are currently using. -6. Use the launcher from the installed language package for every yskill +4. Replace the starter program and fixture with the requested workflow. The + starter is intentionally blocked and must not pass tests unchanged. +5. Run yskill doctor with --test before registration. +6. Keep the workflow beside the project's language dependencies. +7. Run yskill register for the coding agent you are currently using. +8. Use the launcher from the installed language package for every yskill command: npm exec -- yskill, python -m yieldskill, or yskill. -7. Run yskill doctor with --test. -8. Report the commands, generated adapter path, and every changed file. +9. Run yskill doctor with --agent and --test. +10. Report the commands, generated adapter path, and every changed file. Do not move the workflow into an agent discovery directory and do not copy its dependencies into an adapter. + +## Questions and agent results + +Yield emits a typed operation. The coding agent may show that operation using +its native question UI. Yield does not render the UI. After collecting an +answer, the adapter uses `yskill respond`; it does not create `response.json`. + +Use `--value` for a person’s answer and `--result-json` for structured agent +work. The file-based `resume --response` command remains available for CI. + +Workflow-only `doctor` works without `.git`. For registration in such a +directory, pass `--root` so Yield knows where agent adapters belong. ``` diff --git a/docs/primitives/ask-user.md b/docs/primitives/ask-user.md index 44c6534..a90d4f6 100644 --- a/docs/primitives/ask-user.md +++ b/docs/primitives/ask-user.md @@ -16,6 +16,10 @@ The coding agent asks through its normal interface. Yield records the answer and replays it when the program starts again. The run can wait on disk between the question and the answer. +When options are present, Yield accepts only one of their declared values. A +host may show the options using its native question UI. Yield emits the typed +question but does not render that UI. + Use a closed list of options when only specific values are valid. Use a free answer when the person needs to provide a path, identifier, or explanation. diff --git a/docs/quickstart.md b/docs/quickstart.md index 03c29e5..b231629 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -80,7 +80,30 @@ 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. Register it with coding agents +## 4. Prove the workflow locally + +Replace `skills/review/fixtures/responses.json`: + +```json +{ + "review": { + "critical": 0, + "summary": "No critical findings in the fixture run." + } +} +``` + +Run the workflow check: + +```bash +npm exec -- yskill doctor skills/review --test +``` + +`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. + +## 5. Register it with coding agents ```bash # Detect installed verified agents @@ -102,31 +125,13 @@ Yield keeps one workflow and writes only generated adapters: Start a new agent session after registration, then invoke `/review` or ask for the task described by the skill. -## 5. Prove the workflow locally - -Replace `skills/review/fixtures/responses.json`: - -```json -{ - "review": { - "critical": 0, - "summary": "No critical findings in the fixture run." - } -} -``` - -Run the complete setup check: +Check the generated adapters: ```bash npm exec -- yskill doctor skills/review \ - --agent cursor,codex,claude-code \ - --test + --agent cursor,codex,claude-code ``` -`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. - ## Run an existing workflow Initialization is only for creating or wrapping a workflow. For an existing @@ -139,8 +144,8 @@ npm exec -- yskill run skills/review ``` The agent reads the generated adapter, starts the canonical workflow, performs -each yielded operation, and resumes the saved run. If the session closes, the -run remains on disk. +each yielded operation, and answers with `yskill respond`. If the session +closes, the run remains on disk. Next: [set up coding agents](agent-setup.md), [understand each primitive](primitives/README.md), or follow the [complete review diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5f4239d..16e2ab3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -47,9 +47,10 @@ yskill doctor [--agent cursor,codex,...|auto] [--root repository] [--test] ``` -Checks the manifest, package launcher, portable metadata, adapter ownership, -source path, and source digest. `--test` also runs the workflow against -`fixtures/responses.json`. +Checks the canonical workflow and package launcher. `--test` also runs the +workflow against `fixtures/responses.json` without leaving a run journal. +Adapter checks run only when `--agent` is supplied, and all adapter problems +are reported together. ## `version` @@ -79,6 +80,33 @@ Validates one response and prints the next operation or terminal outcome. `--accept-new-digest` explicitly rebinds a saved run after intentional skill source changes; do not use it to hide accidental drift. +`resume` is the file-based interface for CI and audit tooling. For normal +agent use, prefer `respond`. + +## `respond` + +```bash +yskill respond --value [--skill directory] +yskill respond --result-json '' [--skill directory] +yskill respond --result-json - [--skill directory] +``` + +Reads the pending operation, builds the response envelope, validates the +result, and advances the run as one locked transition. `--value` answers an +`ask_user` operation. `--result-json` supplies a structured agent result; `-` +reads JSON from standard input. Completed results are printed in full. + +## `register-all` + +```bash +yskill register-all --agent cursor,codex + [--root repository] [--dry-run] [--prune] +``` + +Registers every immediate workflow in one directory. It checks all names and +destinations before writing. `--prune` removes only obsolete adapters generated +from that workflow directory. Agent-facing names must be unique. + ## `inspect` ```bash @@ -100,9 +128,34 @@ to the same frontier. Operation drift fails loudly. ## `test` ```bash -yskill test +yskill test [--keep-run] ``` Uses `fixtures/responses.json` for `ask_user` and `agent_task` operations. `run_command` operations still execute for real. The command succeeds only when -the program reaches `completed`. +the program reaches `completed`. Test journals are temporary unless +`--keep-run` is supplied. Optional `fixtures/test.json` setup, per-response, +and teardown commands use argv arrays and never run through a shell. + +```json +{ + "version": 1, + "setup": [["node", "fixtures/setup.mjs"]], + "after_response": { + "approve": [["node", "fixtures/apply-approval.mjs"]] + }, + "teardown": [["node", "fixtures/teardown.mjs"]] +} +``` + +Each `after_response` command receives that fixture response as JSON on +standard input. Hooks run only during `yskill test`. + +## `prune` + +```bash +yskill prune --older-than 720h + [--keep-last 10] [--dry-run] +``` + +Removes old terminal runs. Active runs are never selected. diff --git a/docs/reference/guarantees.md b/docs/reference/guarantees.md index 7f27a74..98fe170 100644 --- a/docs/reference/guarantees.md +++ b/docs/reference/guarantees.md @@ -5,8 +5,10 @@ - operation order expressed by the program; - typed request and response envelopes; - persistent append-only run state; +- one locked response transition per run, including safe exact retries; - per-step digest checks during replay; - rejection of stale, duplicate, wrong-run, and schema-invalid responses; +- rejection of undeclared `AskUser` option values; - real command execution by the Yield CLI; - requirements that prevent later completion after failure; - recorded completed, blocked, and refused outcomes. diff --git a/evals/results/latest.json b/evals/results/latest.json index d02e533..eec14ef 100644 --- a/evals/results/latest.json +++ b/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, - "methodology_version": "1.0", - "generated_at": "2026-08-02T00:47:04.974Z", - "source_digest": "760c09c309aac72fc15ff1b6b35663717b32a96917bca1be0c61e8536354467a", + "methodology_version": "1.1", + "generated_at": "2026-08-02T09:47:39.937Z", + "source_digest": "5a52fa0145a400283ee3140563124c2e7c758cfbcce8b20f7efcfa2e7c3c8cd4", "status": "passed", "workflow_conformance": { "passed": 40, @@ -258,35 +258,61 @@ ] }, "runtime_invariants": { - "passed": 5, - "total": 5, + "passed": 8, + "total": 8, "cases": [ { "id": "resume-complete", + "package": "./internal/engine", "test": "TestEndToEndRunResumeComplete", "assertion": "a recorded response advances the run to completion", "status": "passed" }, + { + "id": "response-lock", + "package": "./internal/engine", + "test": "TestConcurrentIdenticalResumeCommitsOnce", + "assertion": "concurrent identical responses create one completion event", + "status": "passed" + }, + { + "id": "response-recovery", + "package": "./internal/engine", + "test": "TestRespondRecoveryRejectsDifferentCommittedContent", + "assertion": "an exact committed response recovers and different content is refused", + "status": "passed" + }, + { + "id": "ask-user-options", + "package": "./internal/conformance", + "test": "TestGuardRefusals", + "assertion": "every SDK rejects an answer outside the declared options", + "status": "passed" + }, { "id": "deterministic-replay", + "package": "./internal/engine", "test": "TestReplayIsDeterministic", "assertion": "the saved log returns to the same next step", "status": "passed" }, { "id": "replay-divergence", + "package": "./internal/engine", "test": "TestReplayDivergenceFailsLoudly", "assertion": "changed behavior stops replay instead of reusing the wrong result", "status": "passed" }, { "id": "requirement-block", + "package": "./internal/engine", "test": "TestFailedRequirementBlocksRun", "assertion": "a failed rule ends the run as blocked", "status": "passed" }, { "id": "source-change", + "package": "./internal/engine", "test": "TestDigestMismatchRefusedThenMigrates", "assertion": "changed source is refused until the user accepts the change", "status": "passed" diff --git a/evals/scripts/run.mjs b/evals/scripts/run.mjs index 9ad0f74..636030b 100644 --- a/evals/scripts/run.mjs +++ b/evals/scripts/run.mjs @@ -10,11 +10,14 @@ const yieldRoot = resolve(evalRoot, "..") const libraryRoot = join(yieldRoot, "examples/library") const languages = ["typescript", "python", "go", "rust"] const runtimeCases = [ - ["resume-complete", "TestEndToEndRunResumeComplete", "a recorded response advances the run to completion"], - ["deterministic-replay", "TestReplayIsDeterministic", "the saved log returns to the same next step"], - ["replay-divergence", "TestReplayDivergenceFailsLoudly", "changed behavior stops replay instead of reusing the wrong result"], - ["requirement-block", "TestFailedRequirementBlocksRun", "a failed rule ends the run as blocked"], - ["source-change", "TestDigestMismatchRefusedThenMigrates", "changed source is refused until the user accepts the change"], + ["resume-complete", "./internal/engine", "TestEndToEndRunResumeComplete", "a recorded response advances the run to completion"], + ["response-lock", "./internal/engine", "TestConcurrentIdenticalResumeCommitsOnce", "concurrent identical responses create one completion event"], + ["response-recovery", "./internal/engine", "TestRespondRecoveryRejectsDifferentCommittedContent", "an exact committed response recovers and different content is refused"], + ["ask-user-options", "./internal/conformance", "TestGuardRefusals", "every SDK rejects an answer outside the declared options"], + ["deterministic-replay", "./internal/engine", "TestReplayIsDeterministic", "the saved log returns to the same next step"], + ["replay-divergence", "./internal/engine", "TestReplayDivergenceFailsLoudly", "changed behavior stops replay instead of reusing the wrong result"], + ["requirement-block", "./internal/engine", "TestFailedRequirementBlocksRun", "a failed rule ends the run as blocked"], + ["source-change", "./internal/engine", "TestDigestMismatchRefusedThenMigrates", "changed source is refused until the user accepts the change"], ] const excludedDirectories = new Set([ ".git", ".yield", "node_modules", "runs", "raw", "artifacts", @@ -73,9 +76,9 @@ async function workflowCases(yskill) { } function evaluateRuntime() { - return runtimeCases.map(([id, test, assertion]) => { - execute("go", ["test", "./internal/engine", "-run", `^${test}$`, "-count=1"]) - return { id, test, assertion, status: "passed" } + return runtimeCases.map(([id, packagePath, test, assertion]) => { + execute("go", ["test", packagePath, "-run", `^${test}$`, "-count=1"]) + return { id, package: packagePath, test, assertion, status: "passed" } }) } @@ -88,7 +91,7 @@ async function evaluate() { const invariants = evaluateRuntime() return { schema_version: 2, - methodology_version: "1.0", + methodology_version: "1.1", generated_at: new Date().toISOString(), source_digest: await sourceDigest(), status: "passed", diff --git a/evals/scripts/validate.mjs b/evals/scripts/validate.mjs index 2ff0a75..4a951bf 100644 --- a/evals/scripts/validate.mjs +++ b/evals/scripts/validate.mjs @@ -7,7 +7,7 @@ const result = JSON.parse(await readFile(join(root, "results/latest.json"), "utf const fail = (message) => { throw new Error(message) } if (result.schema_version !== 2) fail("unsupported result schema") -if (result.methodology_version !== "1.0") fail("unsupported methodology") +if (result.methodology_version !== "1.1") fail("unsupported methodology") if (!/^[0-9a-f]{64}$/.test(result.source_digest)) fail("source hash is invalid") if (result.status !== "passed") fail("published result is not passing") @@ -21,7 +21,7 @@ if (workflows.total !== 40 || workflows.passed !== workflows.total) { if (workflows.cases.length !== workflows.total) fail("workflow case list is incomplete") const runtime = result.runtime_invariants -if (runtime.total !== 5 || runtime.passed !== runtime.total) { +if (runtime.total !== 8 || runtime.passed !== runtime.total) { fail("not every runtime check passed") } if (runtime.cases.length !== runtime.total) fail("runtime case list is incomplete") diff --git a/go.mod b/go.mod index 88e2725..6e483b4 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,14 @@ module github.com/operatorstack/yield go 1.26.5 require ( + github.com/gofrs/flock v0.13.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/text v0.14.0 // indirect +require ( + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum index 7e38d45..54eb1a8 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,28 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/conformance/conformance_test.go b/internal/conformance/conformance_test.go index 4926a03..a4bcec1 100644 --- a/internal/conformance/conformance_test.go +++ b/internal/conformance/conformance_test.go @@ -330,6 +330,9 @@ func TestGuardRefusals(t *testing.T) { if _, err := respond(t, e, p, `{"wrong":"shape"}`); err == nil || !strings.Contains(err.Error(), "schema-invalid") { t.Fatalf("schema-invalid result must be refused, got %v", err) } + if _, err := respond(t, e, p, `{"value":"not-a-declared-option"}`); err == nil || !strings.Contains(err.Error(), "schema-invalid") { + t.Fatalf("unknown ask_user option must be refused, got %v", err) + } p2, err := respond(t, e, p, `{"value":"yes"}`) if err != nil { t.Fatal(err) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index cbd7735..e2f0dd7 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/gofrs/flock" "github.com/operatorstack/yield/internal/guard" "github.com/operatorstack/yield/internal/protocol" "github.com/operatorstack/yield/internal/runlog" @@ -44,6 +45,23 @@ func New(skillDir string) (*Engine, error) { return &Engine{SkillDir: abs, RunsDir: runs, Stderr: os.Stderr}, nil } +// NewWithRunsDir creates an engine whose durable run state is stored outside +// the workflow directory. Tests use this to avoid leaving local state behind. +func NewWithRunsDir(skillDir, runsDir string) (*Engine, error) { + abs, err := filepath.Abs(skillDir) + if err != nil { + return nil, err + } + runs, err := filepath.Abs(runsDir) + if err != nil { + return nil, err + } + if err := os.MkdirAll(runs, 0o755); err != nil { + return nil, err + } + return &Engine{SkillDir: abs, RunsDir: runs, Stderr: os.Stderr}, nil +} + // Advance's result: either the next operation for the agent, or a // terminal status. type Progress struct { @@ -79,6 +97,100 @@ func (e *Engine) StartRun(input json.RawMessage) (*Progress, error) { // digest (the migrate_digest mechanism; divergence detection remains the // safety net). func (e *Engine) Resume(runID string, respBytes []byte, migrate bool) (*Progress, error) { + return e.withRunLock(runID, func() (*Progress, error) { + return e.resumeLocked(runID, respBytes, migrate, nil) + }) +} + +// Pending returns the current unanswered operation without changing the run. +func (e *Engine) Pending(runID string) (*protocol.RequestEnvelope, error) { + l, err := runlog.Open(e.RunsDir, runID) + if err != nil { + return nil, err + } + s, err := guard.Reconstruct(l) + if err != nil { + return nil, err + } + if s.Closed { + return nil, fmt.Errorf("run %s already reached a terminal state", runID) + } + if s.Pending == nil { + return nil, fmt.Errorf("run %s has no pending operation", runID) + } + copy := *s.Pending + return ©, nil +} + +// Continue recovers a run after a response was committed but the process +// stopped before the next frontier was recorded. +func (e *Engine) Continue(runID string) (*Progress, error) { + return e.withRunLock(runID, func() (*Progress, error) { + l, err := runlog.Open(e.RunsDir, runID) + if err != nil { + return nil, err + } + s, err := guard.Reconstruct(l) + if err != nil { + return nil, err + } + if s.Closed { + return e.replayFromLog(l, runID) + } + if s.Pending != nil { + return &Progress{RunID: runID, Envelope: s.Pending}, nil + } + return e.advance(l, runID) + }) +} + +// Respond binds a bare result to the frontier observed when the call began. +// The frontier is checked again under the run lock before the result is used. +func (e *Engine) Respond(runID string, result json.RawMessage) (*Progress, error) { + pending, err := e.Pending(runID) + if err != nil { + if !strings.Contains(err.Error(), "has no pending operation") && !strings.Contains(err.Error(), "terminal state") { + return nil, err + } + return e.withRunLock(runID, func() (*Progress, error) { + l, openErr := runlog.Open(e.RunsDir, runID) + if openErr != nil { + return nil, openErr + } + s, reconstructErr := guard.Reconstruct(l) + if reconstructErr != nil { + return nil, reconstructErr + } + latest := 0 + for sequence := range s.Completed { + if sequence > latest { + latest = sequence + } + } + if latest == 0 || s.Completed[latest] != protocol.DigestBytes(result) { + return nil, fmt.Errorf("run %s has no pending operation; the last response was already committed with different content", runID) + } + return e.progressAfterCommit(l, runID, s) + }) + } + return e.RespondAt(runID, pending, result) +} + +// RespondAt binds a result only if expected is still the current frontier. +func (e *Engine) RespondAt(runID string, expected *protocol.RequestEnvelope, result json.RawMessage) (*Progress, error) { + resp, err := json.Marshal(protocol.ResponseEnvelope{ + RunID: runID, Sequence: expected.Sequence, RequestID: expected.Request.ID, + Status: "completed", Result: result, + }) + if err != nil { + return nil, err + } + return e.withRunLock(runID, func() (*Progress, error) { + return e.resumeLocked(runID, resp, false, expected) + }) +} + +func (e *Engine) resumeLocked(runID string, respBytes []byte, migrate bool, expected *protocol.RequestEnvelope) (*Progress, error) { l, err := runlog.Open(e.RunsDir, runID) if err != nil { return nil, err @@ -87,6 +199,10 @@ func (e *Engine) Resume(runID string, respBytes []byte, migrate bool) (*Progress if err != nil { return nil, err } + var resp protocol.ResponseEnvelope + if err := json.Unmarshal(respBytes, &resp); err != nil { + return nil, fmt.Errorf("response does not decode: %w", err) + } current, err := protocol.DigestSkillDir(e.SkillDir) if err != nil { return nil, err @@ -99,9 +215,16 @@ func (e *Engine) Resume(runID string, respBytes []byte, migrate bool) (*Progress return nil, err } } - var resp protocol.ResponseEnvelope - if err := json.Unmarshal(respBytes, &resp); err != nil { - return nil, fmt.Errorf("response does not decode: %w", err) + if expected != nil && (s.Pending == nil || s.Pending.Sequence != expected.Sequence || protocol.RequestDigest(s.Pending.Request) != protocol.RequestDigest(expected.Request)) { + if digest, ok := s.Completed[resp.Sequence]; ok && s.CompletedRequest[resp.Sequence] == resp.RequestID && digest == protocol.DigestBytes(resp.Result) { + return e.progressAfterCommit(l, runID, s) + } + return nil, fmt.Errorf("pending operation changed while waiting for run lock; inspect the run and answer the current operation") + } + if digest, ok := s.Completed[resp.Sequence]; ok && s.CompletedRequest[resp.Sequence] == resp.RequestID { + if digest == protocol.DigestBytes(resp.Result) { + return e.progressAfterCommit(l, runID, s) + } } if err := guard.CheckResponse(s, resp); err != nil { return nil, e.rejected(l, err) @@ -112,6 +235,26 @@ func (e *Engine) Resume(runID string, respBytes []byte, migrate bool) (*Progress return e.advance(l, runID) } +func (e *Engine) progressAfterCommit(l *runlog.Log, runID string, s *guard.RunState) (*Progress, error) { + if s.Closed { + return e.replayFromLog(l, runID) + } + if s.Pending != nil { + return &Progress{RunID: runID, Envelope: s.Pending}, nil + } + return e.advance(l, runID) +} + +func (e *Engine) withRunLock(runID string, fn func() (*Progress, error)) (*Progress, error) { + path := filepath.Join(e.RunsDir, runID+".lock") + lock := flock.New(path) + if err := lock.Lock(); err != nil { + return nil, fmt.Errorf("lock run %s: %w", runID, err) + } + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + return fn() +} + // Replay re-executes the program against the full journal and verifies it // reproduces the run's recorded frontier — the determinism check. func (e *Engine) Replay(runID string) (*Progress, error) { @@ -119,6 +262,10 @@ func (e *Engine) Replay(runID string) (*Progress, error) { if err != nil { return nil, err } + return e.replayFromLog(l, runID) +} + +func (e *Engine) replayFromLog(l *runlog.Log, runID string) (*Progress, error) { s, err := guard.Reconstruct(l) if err != nil { return nil, err @@ -298,7 +445,8 @@ func runnerCommand(skillDir string) ([]string, error) { manifest := filepath.Join(skillDir, "skill.json") if b, err := os.ReadFile(manifest); err == nil { var m struct { - Run []string `json:"run"` + Language string `json:"language"` + Run []string `json:"run"` } if err := json.Unmarshal(b, &m); err != nil { return nil, fmt.Errorf("skill.json does not decode: %w", err) @@ -306,6 +454,11 @@ func runnerCommand(skillDir string) ([]string, error) { if len(m.Run) == 0 { return nil, fmt.Errorf("skill.json must declare a non-empty run command") } + if m.Language == "python" && m.Run[0] == "python" { + if python := strings.TrimSpace(os.Getenv("YIELD_PYTHON")); python != "" { + m.Run[0] = python + } + } return m.Run, nil } if _, err := os.Stat(filepath.Join(skillDir, "main.go")); err == nil { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index d5e2e09..3d1eead 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1,10 +1,13 @@ package engine import ( + "bytes" "encoding/json" "os" + "os/exec" "path/filepath" "strings" + "sync" "testing" "github.com/operatorstack/yield/internal/guard" @@ -23,6 +26,202 @@ func testEngine(t *testing.T, skill string) *Engine { return &Engine{SkillDir: abs, RunsDir: t.TempDir(), Stderr: os.Stderr} } +func TestConcurrentIdenticalResumeCommitsOnce(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + response, err := json.Marshal(protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: json.RawMessage(`{"value":"preserve"}`), + }) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + errs := make(chan error, 2) + for range 2 { + wg.Add(1) + go func() { defer wg.Done(); _, callErr := e.Resume(p.RunID, response, false); errs <- callErr }() + } + wg.Wait() + close(errs) + for callErr := range errs { + if callErr != nil { + t.Fatalf("identical concurrent retry failed: %v", callErr) + } + } + log, err := e.Log(p.RunID) + if err != nil { + t.Fatal(err) + } + completed := 0 + for _, event := range log.Events() { + if event.Type == runlog.OperationCompleted { + var data struct { + RequestID string `json:"request_id"` + } + _ = event.Decode(&data) + if data.RequestID == "confirm-scope" { + completed++ + } + } + } + if completed != 1 { + t.Fatalf("confirm-scope completion events = %d, want 1", completed) + } +} + +func TestConcurrentResumeProcessesCommitOnce(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + response, err := json.Marshal(protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: json.RawMessage(`{"value":"preserve"}`), + }) + if err != nil { + t.Fatal(err) + } + responsePath := filepath.Join(t.TempDir(), "response.json") + if err := os.WriteFile(responsePath, response, 0o600); err != nil { + t.Fatal(err) + } + commands := make([]*exec.Cmd, 2) + outputs := make([]bytes.Buffer, 2) + for index := range commands { + commands[index] = exec.Command(os.Args[0], "-test.run=^TestResumeProcessHelper$", "--", e.SkillDir, e.RunsDir, p.RunID, responsePath) + commands[index].Env = append(os.Environ(), "YIELD_RESUME_HELPER=1") + commands[index].Stdout = &outputs[index] + commands[index].Stderr = &outputs[index] + if err := commands[index].Start(); err != nil { + t.Fatal(err) + } + } + for index, command := range commands { + if err := command.Wait(); err != nil { + t.Fatalf("resume process failed: %v: %s", err, outputs[index].String()) + } + } + l, err := e.Log(p.RunID) + if err != nil { + t.Fatal(err) + } + completed := 0 + for _, event := range l.Events() { + if event.Type == runlog.OperationCompleted { + var data struct { + RequestID string `json:"request_id"` + } + _ = event.Decode(&data) + if data.RequestID == "confirm-scope" { + completed++ + } + } + } + if completed != 1 { + t.Fatalf("cross-process completion events = %d, want 1", completed) + } +} + +func TestResumeProcessHelper(t *testing.T) { + if os.Getenv("YIELD_RESUME_HELPER") != "1" { + return + } + separator := -1 + for index, arg := range os.Args { + if arg == "--" { + separator = index + break + } + } + if separator < 0 || separator+4 >= len(os.Args) { + os.Exit(2) + } + response, err := os.ReadFile(os.Args[separator+4]) + if err != nil { + os.Exit(3) + } + e := &Engine{SkillDir: os.Args[separator+1], RunsDir: os.Args[separator+2], Stderr: os.Stderr} + if _, err := e.Resume(os.Args[separator+3], response, false); err != nil { + os.Exit(4) + } + os.Exit(0) +} + +func TestRespondBuildsEnvelopeFromPendingFrontier(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + p, err = e.Respond(p.RunID, json.RawMessage(`{"value":"preserve"}`)) + if err != nil { + t.Fatal(err) + } + if p.Envelope == nil || p.Envelope.Request.ID != "summarize" { + t.Fatalf("direct response did not reach the next frontier: %+v", p) + } +} + +func TestContinueAdvancesAfterCommittedResponse(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + l, err := e.Log(p.RunID) + if err != nil { + t.Fatal(err) + } + result := json.RawMessage(`{"value":"preserve"}`) + if err := e.acceptResponse(l, p.Envelope, protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: result, + }); err != nil { + t.Fatal(err) + } + p, err = e.Continue(p.RunID) + if err != nil { + t.Fatal(err) + } + if p.Envelope == nil || p.Envelope.Request.ID != "summarize" { + t.Fatalf("continue did not reconstruct the next frontier: %+v", p) + } +} + +func TestRespondRecoveryRejectsDifferentCommittedContent(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + l, err := e.Log(p.RunID) + if err != nil { + t.Fatal(err) + } + committed := json.RawMessage(`{"value":"preserve"}`) + if err := e.acceptResponse(l, p.Envelope, protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: committed, + }); err != nil { + t.Fatal(err) + } + if _, err := e.Respond(p.RunID, json.RawMessage(`{"value":"replace"}`)); err == nil || !strings.Contains(err.Error(), "different content") { + t.Fatalf("different recovery response = %v", err) + } + p, err = e.Respond(p.RunID, committed) + if err != nil { + t.Fatal(err) + } + if p.Envelope == nil || p.Envelope.Request.ID != "summarize" { + t.Fatalf("exact recovery did not reach the next frontier: %+v", p) + } +} + func respond(t *testing.T, e *Engine, p *Progress, result string, migrate bool) (*Progress, error) { t.Helper() b, err := json.Marshal(protocol.ResponseEnvelope{ diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 0cf301a..2a1372e 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -41,20 +41,21 @@ func reject(reason RejectReason, format string, args ...any) *Rejection { // RunState is the guard-relevant projection of a run log. type RunState struct { - RunID string - BoundDigest string - Skill protocol.SkillRef - Pending *protocol.RequestEnvelope // unanswered operation, if any - Completed map[int]string // sequence -> result digest - Closed bool // a terminal run.* event exists - ReqFailed bool // a requirement.failed event exists - Diverged bool + RunID string + BoundDigest string + Skill protocol.SkillRef + Pending *protocol.RequestEnvelope // unanswered operation, if any + Completed map[int]string // sequence -> result digest + CompletedRequest map[int]string // sequence -> request id + Closed bool // a terminal run.* event exists + ReqFailed bool // a requirement.failed event exists + Diverged bool } // Reconstruct folds a run log into its guard state. The log is the only // source of truth; nothing else is consulted. func Reconstruct(l *runlog.Log) (*RunState, error) { - s := &RunState{Completed: map[int]string{}} + s := &RunState{Completed: map[int]string{}, CompletedRequest: map[int]string{}} for _, e := range l.Events() { switch e.Type { case runlog.RunStarted: @@ -77,12 +78,14 @@ func Reconstruct(l *runlog.Log) (*RunState, error) { case runlog.OperationCompleted: var d struct { Sequence int `json:"sequence"` + RequestID string `json:"request_id"` ResultDigest string `json:"result_digest"` } if err := e.Decode(&d); err != nil { return nil, err } s.Completed[d.Sequence] = d.ResultDigest + s.CompletedRequest[d.Sequence] = d.RequestID if s.Pending != nil && s.Pending.Sequence == d.Sequence { s.Pending = nil } diff --git a/release-notes/2026-08-02-dx-hardening.md b/release-notes/2026-08-02-dx-hardening.md new file mode 100644 index 0000000..168bfd7 --- /dev/null +++ b/release-notes/2026-08-02-dx-hardening.md @@ -0,0 +1,14 @@ +# Safer responses and simpler workflow setup + +- Add `yskill respond` so agents can answer a pending step without building a + response file by hand. +- Serialize responses per run across processes, recover exact retries, and + refuse conflicting content. +- Enforce declared `AskUser` options in the TypeScript, Python, Go, and Rust + SDKs. +- Let `yskill doctor` check a workflow without requiring agent adapters, and + keep fixture test runs temporary by default. +- Add bulk adapter registration, safe synchronization, terminal-run pruning, + relocatable Python workflows, and deterministic fixture effects. +- Verify 40 workflow cases and eight runtime checks, including concurrent + response admission and recovery. diff --git a/sdk/python/yieldskill/__init__.py b/sdk/python/yieldskill/__init__.py index ddf21ef..6a3dcb6 100644 --- a/sdk/python/yieldskill/__init__.py +++ b/sdk/python/yieldskill/__init__.py @@ -99,8 +99,10 @@ def __init__(self, journal: dict): def ask_user(self, id: str, question: str, options: Optional[list] = None) -> str: payload: dict = {"question": question} + value_schema: dict = {"type": "string"} if options: payload["options"] = options + value_schema["enum"] = [option["value"] for option in options] resp = self._step( { "id": id, @@ -109,7 +111,8 @@ def ask_user(self, id: str, question: str, options: Optional[list] = None) -> st "output_schema": { "type": "object", "required": ["value"], - "properties": {"value": {"type": "string"}}, + "additionalProperties": False, + "properties": {"value": value_schema}, }, } ) diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 9a2a69b..e3ed93d 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -198,6 +198,11 @@ impl Context { .collect(), ); } + let mut value_schema = json!({ "type": "string" }); + if !options.is_empty() { + value_schema["enum"] = + Value::Array(options.iter().map(|(value, _)| json!(value)).collect()); + } let resp = self.step(Request { id: id.to_string(), kind: "ask_user".to_string(), @@ -205,10 +210,14 @@ impl Context { output_schema: Some(json!({ "type": "object", "required": ["value"], - "properties": { "value": { "type": "string" } } + "additionalProperties": false, + "properties": { "value": value_schema } })), }); - resp.result["value"].as_str().unwrap_or_default().to_string() + resp.result["value"] + .as_str() + .unwrap_or_default() + .to_string() } /// Delegate reasoning to the model. `schema` (JSON Schema) is enforced @@ -344,7 +353,9 @@ pub fn define_skill(program: fn(&mut Context) -> SkillResult) -> ! { let path = match std::env::var("YIELD_JOURNAL") { Ok(p) => p, Err(_) => { - eprintln!("yield: YIELD_JOURNAL is not set; this program is run by yskill, not directly"); + eprintln!( + "yield: YIELD_JOURNAL is not set; this program is run by yskill, not directly" + ); exit(2); } }; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 2aa89fd..a4fe5a8 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -151,6 +151,8 @@ export class Context { /** Yield a question asked through the host's normal interface. */ askUser(id: string, question: string, options?: Option[]): string { + const valueSchema: Record = { type: "string" }; + if (options?.length) valueSchema.enum = options.map((option) => option.value); const resp = this.step({ id, kind: "ask_user", @@ -158,7 +160,8 @@ export class Context { output_schema: { type: "object", required: ["value"], - properties: { value: { type: "string" } }, + additionalProperties: false, + properties: { value: valueSchema }, }, }); return (resp.result as { value: string }).value; diff --git a/sdk/yield/yield.go b/sdk/yield/yield.go index a970517..51081a7 100644 --- a/sdk/yield/yield.go +++ b/sdk/yield/yield.go @@ -61,9 +61,22 @@ func (c *Context) Refused(reason string) error { return &RefusedError{Reason: re // interface and returns the selected value on resume. func (c *Context) AskUser(id, question string, options ...protocol.Option) string { payload := mustJSON(protocol.AskUserPayload{Question: question, Options: options}) + valueSchema := map[string]any{"type": "string"} + if len(options) > 0 { + values := make([]string, 0, len(options)) + for _, option := range options { + values = append(values, option.Value) + } + valueSchema["enum"] = values + } + schema := mustJSON(map[string]any{ + "type": "object", "required": []string{"value"}, + "additionalProperties": false, + "properties": map[string]any{"value": valueSchema}, + }) resp := c.step(protocol.Request{ ID: id, Kind: protocol.OpAskUser, Payload: payload, - OutputSchema: json.RawMessage(`{"type":"object","required":["value"],"properties":{"value":{"type":"string"}}}`), + OutputSchema: schema, }) var r protocol.AskUserResult mustDecode(resp.Result, &r)