diff --git a/README.md b/README.md index 75e84f2..6332cc1 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,21 @@ repository. Generated adapters never use a global `yskill` from `PATH`. ```bash # TypeScript -npm install --save-exact @operatorstack/yield@0.1.23 --registry=https://get.operatorstack.systems/npm/ +npm install --save-exact @operatorstack/yield@0.1.26 --registry=https://get.operatorstack.systems/npm/ npm exec -- yskill --version # Python, after creating and activating .venv -python -m pip install yieldskill==0.1.23 --index-url https://get.operatorstack.systems/pip/simple/ +python -m pip install yieldskill==0.1.26 --index-url https://get.operatorstack.systems/pip/simple/ python -m yieldskill --version # Go, from the repository root mkdir -p .yield/bin GOBIN="$PWD/.yield/bin" GOPROXY=https://get.operatorstack.systems/go,direct \ - go install github.com/operatorstack/yield/cmd/yskill@v0.1.23 + go install github.com/operatorstack/yield/cmd/yskill@v0.1.26 .yield/bin/yskill --version # Rust, from the repository root -cargo install yieldskill@0.1.23 --root .yield \ +cargo install yieldskill@0.1.26 --root .yield \ --index sparse+https://get.operatorstack.systems/cargo/index/ --locked .yield/bin/yskill --version ``` diff --git a/UPSTREAM.json b/UPSTREAM.json index 0b3612c..d581533 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -2,18 +2,18 @@ "files": { ".gitignore": "803c5f79d6da7f2c5a1dc0ce27c53b8e5c059309b165782831c4c472af058a4c", "LICENSE": "fff261ce507eabd57666c283a621f33e183a3aedebda04c4ecbc6309a62f5edf", - "README.md": "ad3d31327720cfaa382211fa13bc3df20134299d54222190307769890915ad23", - "cmd/yskill/agents.go": "3cf1309d3cebfe9a66b664d88992805326f813fa44b523e637afb86555ffd9ce", - "cmd/yskill/agents_test.go": "a167a0a6d59b17ff50a2e3aec010113961b085eb26d008ba4380127a1f801040", + "README.md": "c7af9266ee16bf4c315a3cabdb15f8f0d8b87501bce7ed3d184c53dfb144ddd7", + "cmd/yskill/agents.go": "8129c073b7b56ca7dcd50c5d90640ad80522c88822c629d1bdc79a386a31a8f4", + "cmd/yskill/agents_test.go": "d6202d208036be6b2b50f7d8f9c3a9b4c2b3eea9556477ef9ad4d56201c6b832", "cmd/yskill/main.go": "5eefea8fdd3e040a940bde80aac793fb97c61c18586bd993dab0395f57e5a81f", - "cmd/yskill/main_test.go": "12a73231e0723fd4e1cee432a08eaa11950401c68a6d59887bac382fb2e5a19a", + "cmd/yskill/main_test.go": "af22b208ffcb6f2ae692132fadd879218d51899c21285bc8e914face61a42ad9", "cmd/yskill/registry/README.md": "fab385921ee7972f76deb94a3b7ae41184735598ea4145c9d8ccdde2c2b876c4", "cmd/yskill/registry/VERCEL_SKILLS_LICENSE": "779258e329008bdb9330e6c1daad644ff867f164d11c4cc4404350479f3e92ee", "cmd/yskill/registry/agents.json": "6edfee31cdc0390516adbb107fc97fc3531eb02e1a28b9d62b5b86fdccaed7c1", - "cmd/yskill/scaffold.go": "fe79b27930673b2810f405474dae50ab494767e75e70821f21e153bd6972829f", + "cmd/yskill/scaffold.go": "404f8418a721b3e179fcd6d814640628a56930f54a9473a5f28dcae433e74ac7", "cmd/yskill/skillmeta.go": "12b0c25689bf825d676404af7b3fb74fb92db3d9e222d4e553d8800d86aea45e", "docs/README.md": "b4103bc38ca5b3b313d6cc7eae5f11276c03a8bd6123cfa101cb7372ff43fa72", - "docs/agent-setup.md": "0f73c8322f15a799beb9ad4a88079d8343254c18fe01c59634f4da036d4c5744", + "docs/agent-setup.md": "72d2f7af6ca93b3865c8d7095479320127adc5edfdcce940494adf28f3abe7fc", "docs/convert-existing-skill.md": "0dd538e908a1f2d3a1958e4b2cc1efede2a8e7a73341e32c1a75fec69a9acb8a", "docs/examples.md": "f6ef194f1d321692873b5cf35751f369d8215084c5a754b4a6725480ab9b2e37", "docs/locus-conformance.md": "a71fd5a72678dcad344afc71cd7a788090ef008a8e5e82d925da5a3db41af6a0", @@ -37,7 +37,7 @@ "docs/primitives/outcomes.md": "a584976fada6af3772894736e686f5deb466d3cc8fd1ecacf49f6b9042d6a232", "docs/primitives/require.md": "1ea8c3a9ea11aa61e2baee01cf83fca8c872e846a47d7ef230cf6b76d04c90d8", "docs/primitives/run-command.md": "af9bcc617f90fd2ee0086aeabb5c98854542efe81636a020699c2f688f8b3b8c", - "docs/quickstart.md": "98d1f4771d9ee9bab06a811aed8098531478d76b26291ddeb8e5ed443a7fc768", + "docs/quickstart.md": "92aad6226da57c89444499ea06f74125008fe486e8f35066b9f4c09597a7e5f6", "docs/reference/cli.md": "a36189229ebadf0c6b298f56b03013cb23720448bbe2f8c85297933111ef06ea", "docs/reference/execution-model.md": "58365a7870a081f4ed9e9ba144aae77aa5a1d8676d2bbf6979483be4a06eca80", "docs/reference/guarantees.md": "fce2006ad46a83c4ae34d221a83d68bc06a776e7c4f54d938bdcdac91977aa50", @@ -68,7 +68,7 @@ "evals/package.json": "84ac7bd7d1c0d1db2233dab54754296195b267e5e907d9f6c402d0d44d569adb", "evals/results/README.md": "65b74bfab83dfc51fc5b8a3a4824c37b722fb3a358a5dcfb217dc2af199f4801", "evals/results/latest-agent.json": "1484174819ae29bbcffeb615036fa165518669525e6d664123f6a38ca3614b69", - "evals/results/latest.json": "422c0fa860baa94ae336894c15c012beb04d78f46389dac9655a5a5bde4e670b", + "evals/results/latest.json": "54cbe6c70e57a38735ccbb60aaccbb23dad952b80cd852a3b3c1013baa207815", "evals/scripts/run.mjs": "17a62cd0f7fda73d84a9bfa21c9c3253ba52a4a23eece09523cfea244e3f9940", "evals/scripts/validate.mjs": "42d51ea75f9418e45dd1797d1fe02cbc046752d3e3c37d41bc8d3a47d5865d4d", "examples/convert-skill/SKILL.md": "e6376f34365d4ac030d316db55e91f0c606a501668099f4ecf1e27d43ec806a2", @@ -292,8 +292,11 @@ "packaging/assemble.mjs": "295e0fa5adc9898cf64ff554524329dedcfe43843804b260173f34a778f3e5ab", "packaging/cargo-index.mjs": "3c8845ab1b82a568c076229831580d8f3ad684dc6ccb74f4860d69637eb06735", "packaging/cargo-index.test.mjs": "62a986e7f98d83007bd4b0ca14ed0e613c8ec4e240656da5af23521d7b69653c", - "packaging/rust-launcher.rs": "d7a534609031397c816fb24702002c3c16be41cb1fedbd9c53b5915ba5f670ab", + "packaging/rust-launcher.rs": "cc71e9ab4e471d593f7eaf818a3b9f62b599bc2e846d9c632c8aae4da69c17e8", + "packaging/rust-launcher.test.mjs": "27c04d2aa6b98ef14b507264ce1d215aa6421a8f3ce4a64110f1347c9a9e60d5", "packaging/targets.mjs": "354e0700cf1c4f1a864e5eaa0fe951717559a381d50ad6e6c4f491449aeb526a", + "packaging/verify-registry-history.mjs": "ba43a9aeb6e80fe23ace41f23623f6d1526369e87489bd5b834991348ba80a6c", + "packaging/verify-registry-history.test.mjs": "c7a0b6a41aa8bdf9b2d2d486c40d14cc3224de7225dea3f6b48e66747e4496fa", "release-notes/2026-08-01-agent-workflow-evaluation.md": "61fe8b5f893d21dac9dba9fa759818637c81ce2cd844571274e50f763ac72d00", "release-notes/2026-08-01-docs-and-typescript-package.md": "93382375cb47187092aff9447b280853151c738b140879828c74576a109195b8", "release-notes/2026-08-01-evaluation-case-guides.md": "570bc996eb4d2892456a02d938fb6299d106c431bb842ce726db1df550f779d7", @@ -310,12 +313,13 @@ "release-notes/2026-08-02-go-sdk-pin-detection.md": "d835d0a9efebba16ab782a7b5e8730a873545b395e644b6e7b21cf93bed7d1a4", "release-notes/2026-08-02-skill-workflows.md": "8cd0cded1922387ef8e81b3e084c12e440f729aa778672f9ec91ffa153df5297", "release-notes/2026-08-02-v0-1-23-dx.md": "538aaf62beeb15e132cfde35f47217edce7d1e4ef5c4036fd00ed96f8362b2b7", - "sdk/python/README.md": "aeff405ce89928814e93704ad593f9ee976d400c227804343947f09ca8ad3062", + "release-notes/2026-08-02-v0-1-26-dx.md": "b3fafa640e8969351dfb4897686594b2144f3d3609f82dbee4871279ada54735", + "sdk/python/README.md": "c4f0fa62dc741d3f0fb6694308d9cdfafed7c66eab6b86d19fafbe6f38edbae4", "sdk/python/pyproject.toml": "0a36d0a22a29da6b8c42e3a5a91aed1b6f6d0813712a9e582ab08e8ce4290de6", - "sdk/python/test_cli.py": "ed1d17ffa4190dd3733cc3042cf49a66710ca5ff6303d1202330a2d53cd12fac", + "sdk/python/test_cli.py": "829778867cb18d1be6a8737cd9b9ef51077aad84f30d32bcd80fd83b2b1469a6", "sdk/python/yieldskill/__init__.py": "c43dbb2a25ed7e8537521561a16e2e5fc7921d60f913b22ac298255bd44fe60e", "sdk/python/yieldskill/__main__.py": "2f2978db2ba5bf8034466902e0c0f5fa61d0961b262d776598ba0d9bb47d6b62", - "sdk/python/yieldskill/_cli.py": "70efa4d505117bf83e0aeaf9dd729b62864cdb0ff360cfc24390932305093310", + "sdk/python/yieldskill/_cli.py": "3a3caa9008a4bf366bccbbea5d1c22ec86f7d48bc56cdf9c9e28e9ffa9e921a3", "sdk/rust/Cargo.toml": "da29a554401a316f6a0e7ba53d166f4c3741ac9aefe1e3f2d66af1bfa3a2d88a", "sdk/rust/src/lib.rs": "581882dca50cf4e27a8f2e05b013b2bc091802b6a20d89d58ffbcdf5c23372a7", "sdk/typescript/bin/runtime.mjs": "909da102e8ed8ccc917cd17d88acc65869a0a2bdb6f875f76d763383999a2b57", @@ -330,7 +334,7 @@ "generator": "operatorstack/yield:project", "schema_version": 1, "source": { - "commit": "2d0884460f31e85cda431e7f4e45c6184fcbda7d", + "commit": "ad137fc9d418a58336dfb6faaaccb47fbd8f018b", "path": "labs/22-yield", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index b60ef29..d58a759 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -517,6 +517,33 @@ func ensureContainedWrite(repoRoot, destination string) error { func commandExists(name string) bool { _, err := exec.LookPath(name); return err == nil } +var currentExecutable = os.Executable + +func repositoryRootFromLocalRuntime() (string, bool) { + executable := strings.TrimSpace(os.Getenv("YIELD_LAUNCHER_PATH")) + if executable == "" { + var err error + executable, err = currentExecutable() + if err != nil { + return "", false + } + } + executable, err := filepath.Abs(executable) + if err != nil { + return "", false + } + name := strings.ToLower(filepath.Base(executable)) + if name != "yskill" && name != "yskill.exe" { + return "", false + } + bin := filepath.Dir(executable) + state := filepath.Dir(bin) + if filepath.Base(bin) != "bin" || filepath.Base(state) != ".yield" { + return "", false + } + return filepath.Dir(state), true +} + func findRepoRoot(skillDir, explicit string) (string, error) { if explicit != "" { root, err := filepath.Abs(explicit) @@ -528,6 +555,9 @@ func findRepoRoot(skillDir, explicit string) (string, error) { } return filepath.Clean(root), nil } + if root, ok := repositoryRootFromLocalRuntime(); ok && within(root, skillDir) { + return filepath.Clean(root), nil + } for current := filepath.Clean(skillDir); ; current = filepath.Dir(current) { if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { return current, nil diff --git a/cmd/yskill/agents_test.go b/cmd/yskill/agents_test.go index d71323d..d8124ad 100644 --- a/cmd/yskill/agents_test.go +++ b/cmd/yskill/agents_test.go @@ -363,6 +363,41 @@ func TestRepositoryRuntimeLauncherUsesNativeWindowsPath(t *testing.T) { } } +func TestRepositoryRootIsInferredFromLocalRuntimeOutsideGit(t *testing.T) { + repo := t.TempDir() + skill := filepath.Join(repo, "skills", "review") + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review a change before it is merged.\n---\n") + previousExecutable := currentExecutable + currentExecutable = func() (string, error) { return localRuntimePath(repo), nil } + t.Cleanup(func() { currentExecutable = previousExecutable }) + + got, err := findRepoRoot(skill, "") + if err != nil { + t.Fatal(err) + } + if got != repo { + t.Fatalf("repository root = %q, want %q", got, repo) + } +} + +func TestRepositoryRootUsesTheRustWrapperPath(t *testing.T) { + repo := t.TempDir() + skill := filepath.Join(repo, "skills", "review") + writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review a change before it is merged.\n---\n") + t.Setenv("YIELD_LAUNCHER_PATH", localRuntimePath(repo)) + previousExecutable := currentExecutable + currentExecutable = func() (string, error) { return filepath.Join(t.TempDir(), "runtime-cache", "yskill"), nil } + t.Cleanup(func() { currentExecutable = previousExecutable }) + + got, err := findRepoRoot(skill, "") + if err != nil { + t.Fatal(err) + } + if got != repo { + t.Fatalf("repository root = %q, want %q", got, repo) + } +} + func TestRepositoryRuntimeRejectsMissingAndWrongVersions(t *testing.T) { repo := t.TempDir() path := localRuntimePath(repo) diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index e1bed9f..4bbf1be 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "runtime/debug" "strings" "testing" @@ -262,6 +263,41 @@ func TestGoScaffoldCanResolveItsPinnedModuleOnFirstRun(t *testing.T) { } } +func TestLocalGoAndRustScaffoldsKeepTheInvokedRuntime(t *testing.T) { + previousVersion := version + previousTidyGoModule := tidyGoModule + previousExecutable := currentExecutable + version = "0.1.23" + tidyGoModule = func(string) error { return nil } + t.Cleanup(func() { + version = previousVersion + tidyGoModule = previousTidyGoModule + currentExecutable = previousExecutable + }) + + for _, language := range []string{"go", "rust"} { + t.Run(language, func(t *testing.T) { + repo := t.TempDir() + currentExecutable = func() (string, error) { return localRuntimePath(repo), nil } + dir := filepath.Join(repo, "skills", "safe-change") + if err := scaffoldSkill(dir, language, "", "Run safety checks before applying a change."); err != nil { + t.Fatal(err) + } + skill := readTestFile(t, filepath.Join(dir, "SKILL.md")) + launcher := repositoryRuntimeLauncher(filepath.Join(".yield", "bin", filepath.Base(localRuntimePath(repo))), runtime.GOOS) + workflow := shellQuote("skills/safe-change") + for _, command := range []string{ + launcher + " run " + workflow, + launcher + " respond --value --skill " + workflow, + } { + if !strings.Contains(skill, command) { + t.Fatalf("SKILL.md does not preserve local runtime command %q:\n%s", command, skill) + } + } + }) + } +} + func TestPythonScaffoldUsesRelocatableInterpreter(t *testing.T) { previousVersion := version version = "0.1.9" diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index 483cb93..fdbde06 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strings" ) @@ -37,6 +38,36 @@ func packageVersion() string { return "0.0.0" } +func scaffoldCommand(language, dir string) (launcher, workflow string) { + launcher = map[string]string{ + "typescript": "npm exec -- yskill", + "python": "python -m yieldskill", + "go": "yskill", + "rust": "yskill", + }[language] + workflow = "." + if language != "go" && language != "rust" { + return launcher, workflow + } + root, ok := repositoryRootFromLocalRuntime() + if !ok { + return launcher, workflow + } + absDir, err := filepath.Abs(dir) + if err != nil || !within(root, absDir) { + return launcher, workflow + } + runtimeRel, err := filepath.Rel(root, localRuntimePath(root)) + if err != nil { + return launcher, workflow + } + skillRel, err := filepath.Rel(root, absDir) + if err != nil { + return launcher, workflow + } + return repositoryRuntimeLauncher(runtimeRel, runtime.GOOS), shellQuote(filepath.ToSlash(skillRel)) +} + func scaffoldSkill(dir, language, sdkPath, description string) error { language = strings.ToLower(strings.TrimSpace(language)) if language != "typescript" && language != "python" && language != "go" && language != "rust" { @@ -69,14 +100,9 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { return os.WriteFile(path, []byte(content), 0o644) } - launcher := map[string]string{ - "typescript": "npm exec -- yskill", - "python": "python -m yieldskill", - "go": "yskill", - "rust": "yskill", - }[language] + launcher, workflow := scaffoldCommand(language, dir) files := scaffoldFiles(name, language, sdkPath) - files["SKILL.md"] = fmt.Sprintf(skillMD, name, yamlString(strings.TrimSpace(description)), launcher, launcher) + files["SKILL.md"] = fmt.Sprintf(skillMD, name, yamlString(strings.TrimSpace(description)), launcher, workflow, launcher, workflow) files["fixtures/responses.json"] = "{\n \"confirm-start\": {\"value\": \"yes\"}\n}\n" files["fixtures/test.json"] = "{\n \"version\": 1,\n \"setup\": [],\n \"after_response\": {},\n \"teardown\": []\n}\n" keys := make([]string, 0, len(files)) @@ -99,8 +125,8 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { } fmt.Printf("init: %s skill %q scaffolded in %s\n", language, name, 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)) + fmt.Printf("test: %s doctor %s --test\n", launcher, workflow) + fmt.Printf("then: %s register %s\n", launcher, workflow) return nil } @@ -151,11 +177,11 @@ description: %s Run: - %s run . + %s run %s Follow each returned operation exactly. Answer it directly: - %s respond --value --skill . + %s respond --value --skill %s Do not skip an operation or invent a response. ` diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 06bffab..8413a72 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -86,6 +86,8 @@ 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. +Workflow-only `doctor` works without `.git`. A Go or Rust runtime under +`.yield/bin` also identifies the project root for `init`, `doctor`, and +registration. For other non-Git layouts, pass `--root` so Yield knows where +agent adapters belong. ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 8309ccb..697d896 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,7 @@ You need Node.js 24 or newer. mkdir yield-example cd yield-example npm init -y -npm install --save-exact @operatorstack/yield@0.1.23 \ +npm install --save-exact @operatorstack/yield@0.1.26 \ --registry=https://get.operatorstack.systems/npm/ npm exec -- yskill --version ``` diff --git a/evals/results/latest.json b/evals/results/latest.json index 4774d4a..df3310f 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-02T14:55:09.652Z", - "source_digest": "0c8520275d4bd2d0e607c6dbcb0ebf7696106451f2a7fd2acb71ea2c1ed16286", + "generated_at": "2026-08-02T16:31:06.974Z", + "source_digest": "7e61b35562440679e70f58f61a73d06e13d6b823b72b36498a48a9156775dc20", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/packaging/rust-launcher.rs b/packaging/rust-launcher.rs index 0118136..549e0cc 100644 --- a/packaging/rust-launcher.rs +++ b/packaging/rust-launcher.rs @@ -47,6 +47,7 @@ fn main() { let mut command = Command::new(path); command.args(args); if std::env::var_os("YIELD_LANGUAGE").is_none() { command.env("YIELD_LANGUAGE", "rust"); } + if let Ok(launcher) = std::env::current_exe() { command.env("YIELD_LAUNCHER_PATH", launcher); } #[cfg(unix)] { use std::os::unix::process::CommandExt; let error = command.exec(); diff --git a/packaging/rust-launcher.test.mjs b/packaging/rust-launcher.test.mjs new file mode 100644 index 0000000..87611b5 --- /dev/null +++ b/packaging/rust-launcher.test.mjs @@ -0,0 +1,9 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +test("Rust wrapper passes its repository-local path to the runtime", async () => { + const source = await readFile(new URL("./rust-launcher.rs", import.meta.url), "utf8"); + assert.match(source, /current_exe\(\)/); + assert.match(source, /YIELD_LAUNCHER_PATH/); +}); diff --git a/packaging/verify-registry-history.mjs b/packaging/verify-registry-history.mjs new file mode 100644 index 0000000..13646de --- /dev/null +++ b/packaging/verify-registry-history.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import process from "node:process"; +import { npmPackage, rustPackage, targets } from "./targets.mjs"; + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; + } + const versions = (values.versions ?? "").split(",").filter(Boolean); + if (!versions.length || versions.some((version) => !/^\d+\.\d+\.\d+$/.test(version))) { + throw new Error("--versions must be a comma-separated semver list"); + } + return { + versions: [...new Set(versions)], + base: (values.base ?? "https://get.operatorstack.systems").replace(/\/$/, ""), + }; +} + +async function fetchText(fetchImpl, url) { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return response.text(); +} + +async function fetchJSON(fetchImpl, url) { + return JSON.parse(await fetchText(fetchImpl, url)); +} + +function npmPath(name) { + return name.replace("/", "%2F"); +} + +function cargoPath(name) { + const lower = name.toLowerCase(); + if (lower.length === 1) return `1/${lower}`; + if (lower.length === 2) return `2/${lower}`; + if (lower.length === 3) return `3/${lower[0]}/${lower}`; + return `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}`; +} + +function cargoVersions(text, expectedName) { + const versions = new Set(); + for (const line of text.split("\n").filter(Boolean)) { + const record = JSON.parse(line); + if (record.name !== expectedName) throw new Error(`Cargo index for ${expectedName} contains ${record.name}`); + if (/^\d+\.\d+\.\d+$/.test(record.vers)) versions.add(record.vers); + } + return versions; +} + +export async function verifyRegistryHistory({ versions, base, fetchImpl = fetch }) { + const missing = []; + const npmNames = ["@operatorstack/yield", ...targets.map(npmPackage)]; + for (const name of npmNames) { + const packument = await fetchJSON(fetchImpl, `${base}/npm/${npmPath(name)}`); + const available = new Set(Object.keys(packument.versions ?? {})); + for (const version of versions) { + if (!available.has(version)) missing.push(`npm ${name}@${version}`); + } + } + + const pythonIndex = await fetchText(fetchImpl, `${base}/pip/simple/yieldskill/`); + for (const version of versions) { + for (const target of targets) { + const filename = `yieldskill-${version}-py3-none-${target.pythonTag}.whl`; + if (!pythonIndex.includes(filename)) missing.push(`Python ${filename}`); + } + } + + const goVersions = new Set((await fetchText(fetchImpl, `${base}/go/github.com/operatorstack/yield/@v/list`)).split(/\s+/).filter(Boolean)); + for (const version of versions) { + if (!goVersions.has(`v${version}`)) missing.push(`Go github.com/operatorstack/yield@v${version}`); + } + + const rustNames = ["yieldskill", ...targets.map(rustPackage)]; + for (const name of rustNames) { + const available = cargoVersions(await fetchText(fetchImpl, `${base}/cargo/index/${cargoPath(name)}`), name); + for (const version of versions) { + if (!available.has(version)) missing.push(`Cargo ${name}@${version}`); + } + } + + if (missing.length) { + throw new Error(`registry history is incomplete:\n - ${missing.join("\n - ")}`); + } + return { versions, languages: ["typescript", "python", "go", "rust"], targets: targets.map((target) => target.id) }; +} + +if (process.argv[1] && import.meta.filename === process.argv[1]) { + verifyRegistryHistory({ ...parseArgs(process.argv.slice(2)) }) + .then((result) => console.log(`verified ${result.versions.length} release versions across 4 SDKs and ${result.targets.length} targets`)) + .catch((error) => { console.error(`verify-registry-history: ${error.message}`); process.exit(1); }); +} diff --git a/packaging/verify-registry-history.test.mjs b/packaging/verify-registry-history.test.mjs new file mode 100644 index 0000000..d310c2b --- /dev/null +++ b/packaging/verify-registry-history.test.mjs @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { npmPackage, rustPackage, targets } from "./targets.mjs"; +import { verifyRegistryHistory } from "./verify-registry-history.mjs"; + +const versions = ["0.1.23", "0.1.24"]; + +function response(body, status = 200) { + return { ok: status >= 200 && status < 300, status, text: async () => body }; +} + +function registry({ omit = "" } = {}) { + return async (url) => { + if (url.includes("/npm/")) { + const name = decodeURIComponent(url.split("/npm/")[1]); + const present = Object.fromEntries(versions.filter((version) => `${name}@${version}` !== omit).map((version) => [version, {}])); + return response(JSON.stringify({ name, versions: present })); + } + if (url.includes("/pip/simple/yieldskill/")) { + const files = versions.flatMap((version) => targets.map((target) => `yieldskill-${version}-py3-none-${target.pythonTag}.whl`)); + return response(files.filter((file) => `Python ${file}` !== omit).join("\n")); + } + if (url.includes("/go/github.com/operatorstack/yield/@v/list")) { + return response(versions.filter((version) => `Go github.com/operatorstack/yield@v${version}` !== omit).map((version) => `v${version}`).join("\n")); + } + if (url.includes("/cargo/index/")) { + const name = url.split("/").at(-1); + const records = versions.filter((version) => `Cargo ${name}@${version}` !== omit).map((vers) => JSON.stringify({ name, vers })); + return response(records.join("\n") + "\n"); + } + return response("not found", 404); + }; +} + +test("verifies every SDK, platform package, wheel, and runtime crate", async () => { + const result = await verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry() }); + assert.deepEqual(result.versions, versions); + assert.deepEqual(result.languages, ["typescript", "python", "go", "rust"]); + assert.equal(result.targets.length, 6); +}); + +test("reports a missing historical version instead of accepting latest", async () => { + const missing = `Cargo ${rustPackage(targets[0])}@${versions[0]}`; + await assert.rejects( + verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry({ omit: missing }) }), + new RegExp(missing), + ); +}); + +test("reports missing platform packages, not only public package names", async () => { + const missing = `${npmPackage(targets[1])}@${versions[1]}`; + await assert.rejects( + verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry({ omit: missing }) }), + new RegExp(missing.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); +}); diff --git a/release-notes/2026-08-02-v0-1-26-dx.md b/release-notes/2026-08-02-v0-1-26-dx.md new file mode 100644 index 0000000..78aebc3 --- /dev/null +++ b/release-notes/2026-08-02-v0-1-26-dx.md @@ -0,0 +1,10 @@ +# Package history and clean local execution + +- Preserve the selected Python virtual environment for `RunCommand`, including + environment-local console scripts. +- Keep Go and Rust scaffold instructions on the repository-local runtime that + created them, including projects without Git metadata. +- Verify every published TypeScript, Python, Go, and Rust version remains in + the public package indexes before a release can be promoted. +- Test the current and previous exact package versions on all supported + operating-system and architecture targets. diff --git a/sdk/python/README.md b/sdk/python/README.md index 1182df5..60d89ac 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -10,7 +10,7 @@ Create a virtual environment before installation: # macOS and Linux python3 -m venv .venv source .venv/bin/activate -python -m pip install yieldskill==0.1.23 \ +python -m pip install yieldskill==0.1.26 \ --index-url https://get.operatorstack.systems/pip/simple/ ``` @@ -18,7 +18,7 @@ python -m pip install yieldskill==0.1.23 \ # Windows PowerShell py -m venv .venv .\.venv\Scripts\Activate.ps1 -python -m pip install yieldskill==0.1.23 ` +python -m pip install yieldskill==0.1.26 ` --index-url https://get.operatorstack.systems/pip/simple/ ``` diff --git a/sdk/python/test_cli.py b/sdk/python/test_cli.py index df2d87a..9dde47f 100644 --- a/sdk/python/test_cli.py +++ b/sdk/python/test_cli.py @@ -1,8 +1,10 @@ from __future__ import annotations import os +import subprocess import tempfile import unittest +import venv from pathlib import Path from unittest import mock @@ -28,7 +30,7 @@ def test_unix_replaces_process_and_forwards_arguments(self) -> None: self.assertEqual(call.args[:2], (str(binary), [str(binary), "test", "skill"])) self.assertEqual(call.args[2]["YIELD_LANGUAGE"], "python") self.assertEqual(call.args[2]["YIELD_PYTHON"], os.sys.executable) - self.assertEqual(call.args[2]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).resolve().parent)) + self.assertEqual(call.args[2]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).absolute().parent)) def test_windows_preserves_exit_code_and_arguments(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -44,7 +46,7 @@ def test_windows_preserves_exit_code_and_arguments(self) -> None: self.assertFalse(call.kwargs["check"]) self.assertEqual(call.kwargs["env"]["YIELD_LANGUAGE"], "python") self.assertEqual(call.kwargs["env"]["YIELD_PYTHON"], os.sys.executable) - self.assertEqual(call.kwargs["env"]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).resolve().parent)) + self.assertEqual(call.kwargs["env"]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).absolute().parent)) def test_selected_virtual_environment_is_first_on_path(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -60,7 +62,55 @@ def test_selected_virtual_environment_is_first_on_path(self) -> None: _cli.run([], "linux") environment = execute.call_args.args[2] self.assertEqual(environment["YIELD_PYTHON"], str(python)) - self.assertEqual(environment["PATH"], f"{python.resolve().parent}{os.pathsep}/usr/bin") + self.assertEqual(environment["PATH"], f"{python.absolute().parent}{os.pathsep}/usr/bin") + + def test_virtual_environment_symlink_keeps_its_lexical_bin_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / "yskill" + binary.touch() + system = Path(directory) / "system" / "python" + system.parent.mkdir(parents=True) + system.touch() + python = Path(directory) / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.symlink_to(system) + with mock.patch.object(_cli, "runtime_path", return_value=binary): + with mock.patch.dict(os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True): + with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with self.assertRaisesRegex(RuntimeError, "exec"): + _cli.run([], "linux") + environment = execute.call_args.args[2] + self.assertEqual(environment["PATH"].split(os.pathsep)[0], str(python.parent)) + + @unittest.skipIf(os.name == "nt", "Unix execve integration test") + def test_unactivated_virtual_environment_commands_reach_the_runtime(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + environment = root / ".venv" + venv.EnvBuilder(with_pip=False).create(environment) + python = environment / "bin" / "python" + tool = environment / "bin" / "yield-venv-tool" + tool.write_text("#!/bin/sh\necho venv-tool-ready\n") + tool.chmod(0o755) + runtime = root / "runtime" + runtime.write_text("#!/bin/sh\npython --version >/dev/null && yield-venv-tool\n") + runtime.chmod(0o755) + script = ( + "from yieldskill import _cli; " + f"_cli.runtime_path=lambda platform=None: __import__('pathlib').Path({str(runtime)!r}); " + "_cli.run([], 'linux')" + ) + inherited = {**os.environ, "PATH": "/usr/bin:/bin", "YIELD_PYTHON": str(python)} + result = subprocess.run( + [str(python), "-c", script], + cwd=Path(__file__).parent, + env=inherited, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("venv-tool-ready", result.stdout) if __name__ == "__main__": diff --git a/sdk/python/yieldskill/_cli.py b/sdk/python/yieldskill/_cli.py index 714a042..75d2086 100644 --- a/sdk/python/yieldskill/_cli.py +++ b/sdk/python/yieldskill/_cli.py @@ -27,8 +27,11 @@ def run(argv: Sequence[str] | None = None, platform: str | None = None) -> int | args = [str(runtime_path(platform)), *(argv if argv is not None else sys.argv[1:])] selected = platform or sys.platform python = os.environ.get("YIELD_PYTHON", sys.executable) - resolved_python = shutil.which(python) or python - python_bin = str(Path(resolved_python).resolve().parent) + selected_python = shutil.which(python) or python + # Keep the path used to enter the virtual environment. Resolving this + # symlink would replace .venv/bin with the system interpreter directory, + # hiding Python and environment-local console scripts from RunCommand. + python_bin = str(Path(selected_python).absolute().parent) inherited_path = os.environ.get("PATH", "") environment = { **os.environ,