From d17ba34ed3edc448977c9648e1a6aca61768af8e Mon Sep 17 00:00:00 2001 From: chelsealong Date: Fri, 14 Aug 2026 22:11:07 +0000 Subject: [PATCH] feat(scripts): add SPECIFY_NO_PERSIST env var to suppress feature.json writes (#4128) Multi-agent setups running several Spec Kit script invocations concurrently against the same checkout each set their own SPECIFY_FEATURE_DIRECTORY. Every invocation that omits --no-persist (e.g. setup-plan, setup-tasks) still writes that value to the shared .specify/feature.json, so agents can clobber each other's pinned feature directory. SPECIFY_NO_PERSIST=1|true is the environment-level equivalent of --no-persist, letting an orchestrator suppress that write across every call in the process tree without patching each call site. Assisted-by: Claude Code (model: claude-sonnet-5, autonomous) --- docs/reference/core.md | 1 + scripts/bash/common.sh | 7 ++ scripts/powershell/common.ps1 | 8 ++- scripts/python/common.py | 6 ++ tests/test_specify_no_persist.py | 114 +++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/test_specify_no_persist.py diff --git a/docs/reference/core.md b/docs/reference/core.md index fdf0b80e7f..06f2b75bb4 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -54,6 +54,7 @@ specify init my-project --integration copilot --preset compliance | `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). | | `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | +| `SPECIFY_NO_PERSIST` | Set to `1` or `true` to stop every core script from writing `.specify/feature.json`, even when it would otherwise persist `SPECIFY_FEATURE_DIRECTORY` on read. Useful when multiple agents run concurrently against the same checkout, each with its own `SPECIFY_FEATURE_DIRECTORY`: without it, each invocation's persist step can overwrite another agent's pinned feature directory. | > **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 33f90b8dbb..58d90363b1 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -169,6 +169,13 @@ get_feature_paths() { no_persist=true shift fi + # SPECIFY_NO_PERSIST is the environment-level equivalent of --no-persist, + # letting an orchestrator (multi-agent runner, CI matrix) guarantee that no + # script invocation in the process tree writes .specify/feature.json, even + # scripts that don't pass --no-persist themselves (#4128). + if [[ "${SPECIFY_NO_PERSIST:-}" == "1" || "${SPECIFY_NO_PERSIST:-}" == "true" ]]; then + no_persist=true + fi # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in # get_repo_root propagates as a hard error instead of being masked by `local`. diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 585e884702..f1f3a6f179 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -166,6 +166,12 @@ function Get-FeaturePathsEnv { [switch]$ReturnNullOnError ) + # SPECIFY_NO_PERSIST is the environment-level equivalent of -NoPersist, + # letting an orchestrator (multi-agent runner, CI matrix) guarantee that no + # script invocation in the process tree writes .specify/feature.json, even + # scripts that don't pass -NoPersist themselves (#4128). + $noPersist = [bool]$NoPersist -or $env:SPECIFY_NO_PERSIST -eq '1' -or $env:SPECIFY_NO_PERSIST -eq 'true' + $repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError if (-not $repoRoot) { return $null } $currentBranch = Get-CurrentBranch @@ -183,7 +189,7 @@ function Get-FeaturePathsEnv { } # Persist to feature.json so future sessions without the env var still # work - unless the caller opted out for read-only resolution (#3025). - if (-not $NoPersist) { + if (-not $noPersist) { Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY } } elseif (Test-Path $featureJson) { diff --git a/scripts/python/common.py b/scripts/python/common.py index db958dc1cb..28722bbd49 100644 --- a/scripts/python/common.py +++ b/scripts/python/common.py @@ -139,6 +139,12 @@ def get_feature_paths( repo_root = get_repo_root(script_file) current_branch = get_current_branch() + # SPECIFY_NO_PERSIST is the environment-level equivalent of no_persist=True, + # letting an orchestrator (multi-agent runner, CI matrix) guarantee that no + # script invocation in the process tree writes .specify/feature.json, even + # scripts that don't pass no_persist themselves (#4128). + no_persist = no_persist or os.environ.get("SPECIFY_NO_PERSIST", "") in ("1", "true") + feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "") if feature_dir_raw: feature_dir = Path(feature_dir_raw) diff --git a/tests/test_specify_no_persist.py b/tests/test_specify_no_persist.py new file mode 100644 index 0000000000..ac608704d7 --- /dev/null +++ b/tests/test_specify_no_persist.py @@ -0,0 +1,114 @@ +"""Tests for SPECIFY_NO_PERSIST, the env-level equivalent of --no-persist (#4128). + +Scripts like setup-plan/setup-tasks call get_feature_paths() without +--no-persist, so every invocation with SPECIFY_FEATURE_DIRECTORY set +overwrites .specify/feature.json. In multi-agent setups where several +processes each set their own SPECIFY_FEATURE_DIRECTORY, this creates a +write-write race on the shared file. SPECIFY_NO_PERSIST lets an orchestrator +suppress that write across every script invocation without having to patch +each call site. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash +from tests.parity_helpers import ( + HAS_POWERSHELL, + PROJECT_ROOT, + bash_cmd, + clean_env, + install_scripts, + make_repo, + ps_cmd, + py_cmd, + run, +) + +SCRIPT = "setup-plan" +PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md" + + +def _setup_repo(tmp_path: Path, name: str = "proj") -> Path: + repo = make_repo(tmp_path, name) + install_scripts(repo, SCRIPT) + templates = repo / ".specify" / "templates" + templates.mkdir(parents=True, exist_ok=True) + shutil.copy(PLAN_TEMPLATE, templates / "plan-template.md") + return repo + + +def _feature_json(repo: Path) -> dict | None: + fj = repo / ".specify" / "feature.json" + if not fj.is_file(): + return None + return json.loads(fj.read_text(encoding="utf-8")) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + return _setup_repo(tmp_path) + + +@requires_bash +def test_bash_persists_by_default(repo: Path) -> None: + (repo / "specs" / "001-a").mkdir(parents=True) + env = clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a" + result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) == {"feature_directory": "specs/001-a"} + + +@requires_bash +def test_bash_specify_no_persist_suppresses_write(repo: Path) -> None: + (repo / "specs" / "001-a").mkdir(parents=True) + env = clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a" + env["SPECIFY_NO_PERSIST"] = "1" + result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) is None + + +@requires_bash +def test_bash_specify_no_persist_does_not_clobber_existing_pin(repo: Path) -> None: + """A second agent's SPECIFY_FEATURE_DIRECTORY must not overwrite the + first agent's persisted feature.json when SPECIFY_NO_PERSIST is set.""" + (repo / "specs" / "001-a").mkdir(parents=True) + (repo / "specs" / "002-b").mkdir(parents=True) + env = clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a" + result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) == {"feature_directory": "specs/001-a"} + + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-b" + env["SPECIFY_NO_PERSIST"] = "1" + result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) == {"feature_directory": "specs/001-a"} + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") +def test_ps_specify_no_persist_suppresses_write(repo: Path) -> None: + (repo / "specs" / "001-a").mkdir(parents=True) + env = clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a" + env["SPECIFY_NO_PERSIST"] = "true" + result = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) is None + + +def test_py_specify_no_persist_suppresses_write(repo: Path) -> None: + (repo / "specs" / "001-a").mkdir(parents=True) + env = clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a" + env["SPECIFY_NO_PERSIST"] = "1" + result = run(py_cmd(repo, SCRIPT, "--json"), repo, env) + assert result.returncode == 0, result.stderr + assert _feature_json(repo) is None