Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
7cd8953696 chore: bump version to 0.12.16 2026-07-15 14:25:26 +00:00
Noor ul ain
c1722a425e fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
The `map`, `join`, and `contains` expression filters assumed their
argument was a string. A non-string argument — an authoring mistake such
as `| map(5)`, `| join(5)`, or `| contains(5)` — reached an operation
that only strings support and raised a cryptic exception that escaped the
evaluator entirely:

  * `map(5)`      -> `attr.split(".")`  -> AttributeError
  * `join(5)`     -> `separator.join(...)` -> AttributeError
  * `contains(5)` on a string value -> `x in str` -> TypeError

The engine wraps neither expression evaluation nor `step_impl.execute()`
in a try/except, so each of these took down the whole run with a message
that names none of the real problem.

Validate the argument type up front and raise a `ValueError` naming the
filter and the offending type instead, mirroring the strict argument
handling already in `from_json`. `contains` guards only the string-value
branch: for a list value, membership of any element type is legitimate
(`5 in [1, 2, 5]`), so that branch is intentionally left unguarded.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:29:44 -05:00
Roland Huss
6688b447b7 feat(workflows): expose workflow source directory to steps (#3469)
* feat(workflows): expose workflow source directory to steps (#3467)

Propagate WorkflowDefinition.source_path to steps via
{{ context.workflow_dir }} in template expressions and
SPECKIT_WORKFLOW_DIR env var for shell steps. The original
source directory is persisted in state.json so resume
restores the correct value instead of the run-directory copy path.

Closes #3467

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#2)

Applied fixes from bot review comments:
- Comment #3563319058: prevent stale SPECKIT_WORKFLOW_DIR leak from parent env
- Comment #3563319094: use cross-platform Python one-liner instead of printenv
- Comment #3563319103: add monkeypatch.delenv for deterministic env var test
- Comment #3563319116: same env leak fix as #3563319058

Assisted-By: 🤖 Claude Code

* fix: use YAML single-quotes and forward-slash paths for Windows CI (#2)

sys.executable on Windows returns backslash paths (D:\a\...) which YAML
double-quoted strings interpret as escape sequences. Switch to
single-quoted YAML strings and normalize paths with replace("\\", "/").

Assisted-By: 🤖 Claude Code

* fix: resolve workflow_dir to absolute path and add installed-by-ID test (#3469)

Applied fixes from bot review comments:
- Comment #3563382853: resolve source_path before taking parent to ensure absolute paths
- Comment #3563382864: add test for installed-by-ID workflow_dir semantics

Assisted-By: 🤖 Claude Code

* docs: document context.workflow_dir and SPECKIT_WORKFLOW_DIR

Add reference documentation for the new workflow_dir runtime value in
both workflows/README.md and docs/reference/workflows.md so workflow
authors can discover the feature and its semantics.

Assisted-By: 🤖 Claude Code

* fix: clarify installed workflow_dir is an absolute path (#3469)

The documentation for context.workflow_dir described the installed-by-ID
case as ".specify/workflows/<id>/" which appears relative, contradicting
the "resolved absolute path" semantics. Clarified that it is the absolute
path to the installation directory.

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#3469)

Applied fixes from bot review comments:
- Comment #3580005128: Quote sys.executable in shell step env var test
- Comment #3580005174: Quote sys.executable in no-env-var test

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#3469)

Applied fixes from bot review comments:
- Comment #3587146944: Quote interpolated workflow_dir path in example

Assisted-By: 🤖 Claude Code
2026-07-15 08:09:35 -05:00
Ali jawwad
fb076a38b8 fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
_run_fan_out coerces max_concurrency with int() inside except (TypeError,
ValueError). int(float('inf')) raises OverflowError, which is not in that tuple,
so a YAML 'max_concurrency: .inf' crashed the whole run with an uncaught
OverflowError instead of the documented 'cannot be coerced -> sequential'
fallback. Add OverflowError to the except tuple (nan already coerced via
ValueError).

Extends the existing invalid-value parametrization with float('inf')/nan (fails
before on inf: OverflowError; passes after: sequential, all items in order).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:06:03 -05:00
github-actions[bot]
1e84ee2713 Update Coding Standards Drift Control extension to v0.4.0 (#3540)
Update coding-standards-drift-control extension submitted by @benizzio:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes #3534

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-15 07:29:35 -05:00
Ben Buttigieg
353851e966 fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
* fix(presets): seed constitution from preset constitution-template (#3272)

The constitution is the only template materialized to a live file
(.specify/memory/constitution.md) rather than resolved on demand, yet
ensure_constitution_from_template hardcoded a copy from the core template
and ignored PresetResolver. Combined with init seeding the constitution
before preset installation, a preset's constitution-template (e.g.
strategy: replace with a ratified constitution) could never go live.

Changes:
- ensure_constitution_from_template now resolves constitution-template
  through PresetResolver, so a preset/override/extension wins and core is
  the fallback.
- init seeds the constitution after preset installation so init --preset
  uses the resolved stack.
- install_from_directory re-seeds memory/constitution.md from the resolved
  preset template, guarded to only act when the memory file is missing or
  still contains generic placeholder tokens — authored constitutions are
  never overwritten. Covers preset add and install_from_zip.
- Tests for preset seeding, placeholder re-seed, authored-constitution
  preservation, override resolution, and resolver-aware init seeding.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): compose constitution-template when seeding memory

Take on review feedback from Copilot and gglachant:
- constitution seeding previously copied the top layer file path verbatim
  even when the winning layer used a composing strategy
  (prepend/append/wrap), which could leave {CORE_TEMPLATE} unresolved.
- both seeding paths now inspect resolver layers and only copy verbatim for
  replace; non-replace strategies materialize composed content via
  PresetResolver.resolve_content().
- add regression tests for wrap strategy composition in both
  PresetManager seeding and ensure_constitution_from_template.
- add a drift-guard test pinning _CONSTITUTION_PLACEHOLDER_TOKENS to the
  placeholders in templates/constitution-template.md.

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(presets): unify constitution template materialization

Address latest Copilot feedback on the constitution seeding path:
- moved resolver/layer I/O behind the existing-memory fast path in init
- corrected tracker output for composed materialization
- deduplicated materialization logic shared by init and preset install seeding
  into presets._materialize_constitution_template()

Behavior is unchanged for replace strategies (copy verbatim) and remains
composed for prepend/append/wrap via resolve_content().

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(init): restore shutil import

The constitution materialization refactor removed the module import, but init
still uses shutil.rmtree when cleaning up a failed new-project initialization.
Restore the import so the required ruff check passes.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): harden constitution materialization

Address the outstanding review batch for preset constitution seeding:
- use checked atomic writes and reject symlinked memory paths
- replace placeholder heuristics with hash/source provenance
- rematerialize unchanged generated constitutions by resolver priority
- preserve authored or edited constitutions, including placeholder mentions
- warn non-fatally when post-install materialization cannot complete
- retain exact core-template comparison for legacy projects without provenance

Add focused provenance, priority, symlink, and failure-path coverage, and
update integration inventories for the generated provenance sidecar.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): reconcile constitution after removal

When the removed preset supplied constitution-template, rematerialize the
winning remaining resolver layer only if provenance proves the live file is
still generated and unchanged. Preserve edited constitutions and report
post-removal reconciliation failures as non-fatal warnings.

Add coverage for restoring the core layer, falling back from a removed
higher-priority preset, and preserving edited generated content.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): tighten legacy constitution provenance

Trust only the immutable bundled/source constitution template when migrating
legacy projects without provenance. Do not infer core provenance from mutable
project templates or preset source labels, including IDs beginning with core.

Also detect convention-based constitution-template files before preset removal
so unchanged generated constitutions reconcile to the next resolver layer.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): preserve files with invalid provenance

Use immutable-core legacy migration only when the provenance sidecar is absent.
If a sidecar is malformed or its hash does not match the live constitution,
treat the file as edited and preserve it.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): reconcile constitution on stack changes

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): guard constitution reconciliation edges

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-15 11:42:50 +01:00
Manfred Riem
ad601e5d52 docs: add PyPI as second supported install route (#3425) (#3516)
* docs: add PyPI as second supported install route (#3425)

The specify-cli package is now officially published to PyPI via the
publish-pypi.yml trusted-publishing workflow. Document PyPI as a
supported install route alongside the GitHub source install:

- Revise the outdated "not affiliated" warning in installation.md to
  reflect that specify-cli on PyPI is an official, maintained channel.
- Add an "Install from PyPI" section and list PyPI under alternative
  package managers.
- Add a dedicated docs/install/pypi.md guide (install, pin version,
  verify, upgrade, uninstall).
- Add the PyPI guide to the docs TOC.
- Mention the PyPI route in the README quick start.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: refine PyPI install guidance from review (#3516)

Address review feedback for the PyPI install documentation:

- Reword the verification guidance so `specify version` is described as a
  local version/runtime check rather than proof of package provenance.
- Clarify that upgrading a pinned `uv tool` install to the newest PyPI
  release requires an unpinned reinstall command.
- Note that `specify self upgrade` rebuilds `uv tool` and `pipx`
  installs from the GitHub source release URL rather than preserving a
  PyPI-based installation.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: clarify PyPI verification and upgrade guidance

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: point PyPI provenance check to source metadata

Address review feedback: version/list commands do not reveal install
provenance. Direct readers to the source metadata their package manager
records (pipx list --json, PEP 610 direct_url.json) to confirm whether an
install came from PyPI or a Git URL, and note pip show cannot see
uv/pipx-managed environments.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-14 16:05:15 -05:00
Noor ul ain
77ebd5fcea fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
`WhileStep.validate()` and `DoWhileStep.validate()` already reject a
non-list `steps` body, but the engine's `execute()` path does not
auto-validate (see `WorkflowEngine.load_workflow`, whose docstring notes
the definition is "not yet validated"). On an unvalidated run the body
is returned as `next_steps`, and the engine feeds it straight into
`_execute_steps`, which iterates it as step mappings. A non-list `steps`
— a single mapping or scalar authoring mistake — was iterated
element-wise (a dict yields its string keys, a str its characters) and
raised `AttributeError` on `.get()`, taking down the whole run; the
engine invokes `step_impl.execute()` with no surrounding try/except.

Guard both `execute` paths to return a FAILED StepResult naming the type
error instead, mirroring the if/switch non-list-branch and fan-out
non-list `items` handling. The do-while body always dispatches on the
first call, so its guard is unconditional; the while body only
dispatches when the condition is truthy, so its guard fires only then —
a false condition leaves a non-list `steps` benign and the step
completes, unchanged. The condition/expression is still evaluated first,
so its result is surfaced in the step output for downstream context.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:16:36 -05:00
github-actions[bot]
faeb956664 Add PatchWarden Evidence Pack extension to community catalog (#3514)
Add patchwarden-evidence extension submitted by @jiezeng2004-design to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3512

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-14 10:08:42 -05:00
Marsel Safin
91839fba50 feat(extensions): port git extension scripts to Python (#3400)
* feat(extensions): port git extension scripts to Python

Ports git-common, initialize-repo, auto-commit, and
create-new-feature-branch to extensions/git/scripts/python/,
mirroring the bash/PowerShell twins. Parity tests run each bash
script and its Python twin in identical projects and compare
output, exit codes, and resulting git state.

Fixes #3282

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: match bash error message for whitespace-only descriptions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Handle unreadable git-config.yml and assert stderr parity

An unreadable config file raised OSError with a full traceback from
_parse_auto_commit_config. Treat it like a missing config: auto-commit
stays disabled. Covered by a chmod-000 test (skipped on non-POSIX and
as root).

_assert_parity now also compares stderr so warning or usage-text
regressions between the bash and Python twins fail the suite. All
existing parity tests pass with the stricter assertion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(extensions/git): pass script path to core.get_repo_root for cwd-outside-repo callers

Without script_file, core.get_repo_root() falls back to Path.cwd() when
SPECIFY_INIT_DIR is unset and no .specify root is found upward — the
bash twin instead falls back to the script's install location
(.specify/scripts/...). Pass script_file so both twins resolve the
same repo_root; TypeError fallback keeps older cores working.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: exercise SPECIFY_INIT_DIR from outside the project

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(extensions/git): handle UnicodeDecodeError and USER/USERNAME fallback

- Catch (OSError, UnicodeDecodeError) when reading git-config.yml in
  create_new_feature_branch.py, initialize_repo.py, and auto_commit.py
  so invalid UTF-8 config falls back to defaults instead of crashing
  with a traceback.
- Fall back to USERNAME (then "unknown") when USER is unset when
  deriving the branch author token, matching the PowerShell twin's
  Windows-friendly fallback chain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(extensions/git): platform-aware persist hint and stronger SPECIFY_INIT_DIR test

- Add a shared _persist_hint() helper in create_new_feature_branch.py
  and use it for both the JSON-mode stderr hint and the human-readable
  stdout hint, so there is a single place emitting the SPECIFY_FEATURE
  persistence guidance. On Windows (os.name == "nt") it prints
  PowerShell $env:VAR = "..." syntax; elsewhere it keeps the existing
  POSIX export VAR=... syntax (parity with the bash twin).
- Rework test_specify_init_dir_resolves_target_project so SPECIFY_INIT_DIR
  is the only thing that can produce the observed result: the script now
  runs from a separate host_proj (no existing specs, so script/cwd-based
  discovery would yield 001) while SPECIFY_INIT_DIR points at a different
  target_proj that already has an existing spec (007-existing, so the
  override must yield 008). The old version pointed SPECIFY_INIT_DIR at
  the same project the script was installed in, so it passed even if the
  env var were ignored.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(extensions): tolerate missing Git executable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(extensions): quote PowerShell persist hint

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(git): match bash persist hint escaping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(git): ignore unterminated config record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(git): handle Windows persist hint parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(init): install Python shared scripts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(git): normalize Windows persistence hints

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-14 09:56:10 -05:00
Manfred Riem
ab82571999 chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
* chore: bump version to 0.12.15

* chore: begin 0.12.16.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 09:50:31 -05:00
34 changed files with 3325 additions and 35 deletions

View File

@@ -2,6 +2,36 @@
<!-- insert new changelog below this comment -->
## [0.12.16] - 2026-07-15
### Changed
- fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
- feat(workflows): expose workflow source directory to steps (#3469)
- fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
- Update Coding Standards Drift Control extension to v0.4.0 (#3540)
- fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
- docs: add PyPI as second supported install route (#3425) (#3516)
- fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
- Add PatchWarden Evidence Pack extension to community catalog (#3514)
- feat(extensions): port git extension scripts to Python (#3400)
- chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [0.12.14] - 2026-07-13
### Changed

View File

@@ -51,6 +51,12 @@ Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
```
Prefer installing from PyPI? The `specify-cli` package is also published there:
```bash
uv tool install specify-cli
```
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
### 2. Initialize a project

View File

@@ -91,6 +91,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |

83
docs/install/pypi.md Normal file
View File

@@ -0,0 +1,83 @@
# Installing from PyPI
Spec Kit is published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), maintained by the Spec Kit maintainers. Installing from PyPI is the second supported install route alongside installing from the [GitHub source](../installation.md#install-from-source--persistent-installation-recommended). Use whichever fits your workflow — both provide the same `specify` CLI.
> [!NOTE]
> The PyPI release version tracks the GitHub release tags (for example, PyPI `0.12.11` corresponds to the `v0.12.11` tag). `specify version` is only a local version/runtime sanity check — it reports the installed version but not where the `specify` executable came from, so it cannot distinguish a PyPI install from a Git install. To confirm the install source, inspect the source metadata your package manager records: `pipx list --json` reports the exact install specification for each tool, and for uv/pip installs you can check the package's [PEP 610](https://peps.python.org/pep-0610/) `direct_url.json` inside its `*.dist-info` directory (a Git or URL install records the repository/archive URL there, while a plain PyPI index install does not create that file). Note that `pip show specify-cli` only prints package metadata and will not see uv/pipx-managed environments from the host interpreter.
## Install Specify CLI
Use whichever Python tool you already have:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
### Install a specific release
Pin an exact version for reproducible installs (check [PyPI](https://pypi.org/project/specify-cli/#history) or [Releases](https://github.com/github/spec-kit/releases) for available versions):
```bash
# Using uv
uv tool install specify-cli==0.12.11
# Or using pipx
pipx install specify-cli==0.12.11
# Or using pip
pip install specify-cli==0.12.11
```
## Verify
```bash
specify version
```
## Initialize a project
```bash
specify init <PROJECT_NAME> --integration copilot
```
## Upgrade
Upgrade by reinstalling the package through the same tool you used for the original install. If you originally pinned a version, note that `uv tool upgrade` preserves that pin; to move to the newest PyPI release, use an unpinned install command so you do not keep the existing version pin:
```bash
# Using uv
uv tool install --force specify-cli
# Or using pipx
pipx install --force specify-cli
# Or using pip
pip install --upgrade specify-cli
```
> [!NOTE]
> `specify self upgrade` currently rebuilds `uv tool` and `pipx` installs from the GitHub source release URL rather than preserving a PyPI-based installation. If you want to stay on the PyPI route, use the package-manager commands above. A plain `pip install specify-cli` is treated as an unmanaged install — upgrade it with `pip install --upgrade specify-cli`. See the [Upgrade Guide](../upgrade.md) for details.
## Uninstall
```bash
# Using uv
uv tool uninstall specify-cli
# Or using pipx
pipx uninstall specify-cli
# Or using pip
pip uninstall specify-cli
```
## Next steps
Head to the [Quick Start](../quickstart.md) to initialize your first project.

View File

@@ -11,9 +11,14 @@
## Installation
> [!IMPORTANT]
> The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
> Spec Kit is distributed through two official channels, both published and maintained by the Spec Kit maintainers: the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository (source installs) and the [`specify-cli`](https://pypi.org/project/specify-cli/) package on [PyPI](https://pypi.org/project/specify-cli/). Either route is supported for normal installs — use the commands shown below. After installing, run `specify version` as a local version/runtime sanity check. It confirms that the `specify` command is available and reports its version, but it does not prove whether the executable came from PyPI or GitHub. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
### Persistent Installation (Recommended)
Spec Kit supports two install routes:
1. **Install from source (GitHub)** — the recommended route, pinned to a release tag.
2. **Install from PyPI** — install the published `specify-cli` package with your usual Python tooling.
### Install from Source — Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
@@ -30,12 +35,30 @@ Then initialize a project:
specify init <PROJECT_NAME> --integration copilot
```
### Install from PyPI
Spec Kit is also published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), so you can install it with your preferred Python package manager without referencing the Git URL:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
### One-time Usage
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
### Alternative Package Managers
- **PyPI** — see the [PyPI installation guide](install/pypi.md)
- **pipx** — see the [pipx installation guide](install/pipx.md)
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
@@ -81,13 +104,13 @@ specify init <project_name> --integration claude --ignore-agent-tools
## Verification
After installation, run the following command to confirm the correct version is installed:
After installation, run the following command as a local version/runtime check:
```bash
specify version
```
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
This confirms that the `specify` command is available and reporting the expected version. It does not prove whether that executable came from PyPI or GitHub.
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).

View File

@@ -305,6 +305,8 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
| `inputs.spec` | Workflow input values |
| `steps.specify.output.file` | Output from a previous step |
| `item` | Current item in a fan-out iteration |
| `context.run_id` | Current workflow run ID |
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
@@ -316,6 +318,14 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
| Variable | Description |
| -------- | ----------- |
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |
## Input Types
| Type | Coercion |

View File

@@ -13,6 +13,8 @@
href: upgrade.md
- name: Install uv
href: install/uv.md
- name: Install from PyPI
href: install/pypi.md
- name: Install with pipx
href: install/pipx.md
- name: One-time Usage (uvx)

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -809,8 +809,8 @@
"id": "coding-standards-drift-control",
"description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features",
"author": "Igor Benicio de Mesquita",
"version": "0.3.1",
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.3.1.zip",
"version": "0.4.0",
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.4.0.zip",
"repository": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"homepage": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"documentation": "https://github.com/benizzio/spec-kit-coding-standards-drift-control#readme",
@@ -835,7 +835,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-06-11T00:00:00Z"
"updated_at": "2026-07-15T00:00:00Z"
},
"companion": {
"name": "SpecKit Companion",
@@ -2677,6 +2677,46 @@
"created_at": "2026-04-24T14:00:00Z",
"updated_at": "2026-04-24T14:00:00Z"
},
"patchwarden-evidence": {
"name": "PatchWarden Evidence Pack",
"id": "patchwarden-evidence",
"description": "Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage.",
"author": "Zengjie",
"version": "1.0.1",
"download_url": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"homepage": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"documentation": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/README.md",
"changelog": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "patchwarden",
"version": ">=1.5.1",
"required": true
}
]
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"verification",
"evidence",
"traceability",
"security"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-14T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"plan-review-gate": {
"name": "Plan Review Gate",
"id": "plan-review-gate",

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Git extension: auto_commit.py
Automatically commit changes after a Spec Kit command completes.
Python port of ``auto-commit.sh`` / ``auto-commit.ps1``.
Checks per-command config keys in git-config.yml before committing.
Usage: auto_commit.py <event_name>
e.g.: auto_commit.py after_specify
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _value_after_colon(line: str) -> str:
return re.sub(r"^[^:]*:\s*", "", line)
def _strip_quotes(value: str) -> str:
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)
def _parse_auto_commit_config(
config_file: Path, event_name: str
) -> tuple[bool, str]:
"""Parse the auto_commit section for this event, mirroring the bash line parser.
Returns (enabled, commit_msg). Looks for auto_commit.<event_name>.enabled
and .message, with auto_commit.default as fallback.
"""
enabled = False
commit_msg = ""
default_enabled = False
in_auto_commit = False
in_event = False
try:
content = config_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Unreadable or non-UTF-8 config is treated like a missing one:
# auto-commit stays disabled instead of crashing with a traceback.
return False, ""
for record in content.splitlines(keepends=True):
if not record.endswith("\n"):
break
line = record[:-1]
if line.startswith("auto_commit:"):
in_auto_commit = True
in_event = False
continue
# Exit auto_commit section on next top-level key
if in_auto_commit and re.match(r"^[a-z]", line):
break
if not in_auto_commit:
continue
if re.match(r"^\s+default:\s", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
default_enabled = True
if re.match(rf"^\s+{re.escape(event_name)}:", line):
in_event = True
continue
if in_event:
# Exit on next sibling key (same indent level as event name)
if re.match(r"^\s{2}[a-z]", line) and not re.match(r"^\s{4}", line):
in_event = False
continue
if re.search(r"\s+enabled:", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
enabled = True
elif value == "false":
enabled = False
if re.search(r"\s+message:", line):
commit_msg = _strip_quotes(_value_after_colon(line))
# If event-specific key not found, use default — but only if the event
# section didn't exist at all (an explicit false must win).
if not enabled and default_enabled:
if not re.search(rf"^\s*{re.escape(event_name)}:", content, re.MULTILINE):
enabled = True
return enabled, commit_msg
def main(argv: list[str]) -> int:
event_name = argv[0] if argv else ""
if not event_name:
print(f"Usage: {Path(sys.argv[0]).name} <event_name>", file=sys.stderr)
return 1
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
if shutil.which("git") is None:
print("[specify] Warning: Git not found; skipped auto-commit", file=sys.stderr)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode != 0:
print(
"[specify] Warning: Not a Git repository; skipped auto-commit",
file=sys.stderr,
)
return 0
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
# No config file — auto-commit disabled by default
return 0
enabled, commit_msg = _parse_auto_commit_config(config_file, event_name)
if not enabled:
return 0
# Check if there are changes to commit
def _quiet(*args: str) -> bool:
return (
subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True
).returncode
== 0
)
untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=repo_root,
capture_output=True,
text=True,
).stdout.strip()
if _quiet("diff", "--quiet", "HEAD") and _quiet("diff", "--cached", "--quiet") and not untracked:
print(f"[specify] No changes to commit after {event_name}", file=sys.stderr)
return 0
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
command_name = re.sub(r"^(after_|before_)", "", event_name)
phase = "before" if event_name.startswith("before_") else "after"
if not commit_msg:
commit_msg = f"[Spec Kit] Auto-commit {phase} {command_name}"
steps = [
(["git", "add", "."], "git add"),
(["git", "commit", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print(f"[OK] Changes committed {phase} {command_name}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""Git extension: create_new_feature_branch.py
Creates a git feature branch only. The feature directory and spec file are
created by the core create-new-feature script. Python port of
``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
Loads the core Python helpers from the project's installed scripts when
available, falling back to the minimal git helpers next to this script.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
USAGE = (
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
"[--allow-existing-branch] [--short-name <name>] [--number N] "
"[--timestamp] <feature_description>"
)
HELP_TEXT = f"""{USAGE}
Options:
--json Output in JSON format
--dry-run Compute branch name without creating the branch
--allow-existing-branch Switch to branch if it already exists instead of failing
--short-name <name> Provide a custom short name (2-4 words) for the branch
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
Environment variables:
GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
Configuration:
branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
Examples:
create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
"""
STOP_WORDS = frozenset(
"i a an the to for of in on at by with from is are was were be been being "
"have has had do does did will would should could can may might must shall "
"this that these those my your our their want need add get set".split()
)
def _err(message: str) -> None:
print(message, file=sys.stderr)
def _persist_hint(var_name: str, value: str) -> str:
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
if os.name == "nt":
escaped_value = value.replace("'", "''")
return f"$env:{var_name} = '{escaped_value}'"
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
return f"export {var_name}={escaped_value}"
@dataclass
class Args:
json_mode: bool = False
dry_run: bool = False
allow_existing: bool = False
short_name: str = ""
branch_number: str = ""
use_timestamp: bool = False
description_parts: list[str] = field(default_factory=list)
def parse_args(argv: list[str]) -> Args:
args = Args()
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--json":
args.json_mode = True
elif arg == "--dry-run":
args.dry_run = True
elif arg == "--allow-existing-branch":
args.allow_existing = True
elif arg == "--short-name":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --short-name requires a value")
raise SystemExit(1)
i += 1
args.short_name = argv[i]
elif arg == "--number":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --number requires a value")
raise SystemExit(1)
i += 1
args.branch_number = argv[i]
if not re.fullmatch(r"[0-9]+", args.branch_number):
_err("Error: --number must be a non-negative integer")
raise SystemExit(1)
elif arg == "--timestamp":
args.use_timestamp = True
elif arg in ("--help", "-h"):
print(HELP_TEXT)
raise SystemExit(0)
else:
args.description_parts.append(arg)
i += 1
return args
# ── Core helpers loading ─────────────────────────────────────────────────────
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _load_core_common(project_root: Path | None):
"""Load the core common.py from the project's installed scripts.
Search locations in priority order, mirroring the bash script:
1. .specify/scripts/python/common.py (installed project)
2. scripts/python/common.py (source checkout fallback)
Returns the loaded module or None.
"""
if project_root is None:
return None
for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"):
candidate = project_root / relative
if candidate.is_file():
spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
return None
def _local_has_git(repo_root: Path) -> bool:
git_marker = repo_root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
return (
subprocess.run(
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
).returncode
== 0
)
# ── Numbering ────────────────────────────────────────────────────────────────
def get_highest_from_specs(specs_dir: Path) -> int:
highest = 0
if specs_dir.is_dir():
for entry in specs_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if re.match(r"^[0-9]{3,}-", name) and not re.match(
r"^[0-9]{8}-[0-9]{6}-", name
):
number = int(re.match(r"^[0-9]+", name).group(0))
highest = max(highest, number)
return highest
def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
"""Extract the highest sequential feature number from a list of ref names."""
highest = 0
for name in names:
if not name:
continue
if scope_prefix:
if not name.startswith(scope_prefix):
continue
name = name[len(scope_prefix) :]
name = name.rsplit("/", 1)[-1]
if (
re.match(r"^[0-9]{3,}-", name)
and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
):
match = re.match(r"^([0-9]{3,})-", name)
number = int(match.group(1)) if match else 0
highest = max(highest, number)
return highest
def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
if shutil.which("git") is None:
return []
env = {**os.environ, **(env_extra or {})}
result = subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
)
if result.returncode != 0:
return []
return result.stdout.splitlines()
def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
names = []
for line in _git_lines(repo_root, "branch", "-a"):
line = re.sub(r"^[+*]\s+", "", line)
line = line.lstrip()
line = re.sub(r"^remotes/[^/]*/", "", line)
names.append(line)
return _extract_highest_number(names, scope_prefix)
def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
"""Highest number from remote branches without fetching (side-effect-free)."""
highest = 0
for remote in _git_lines(repo_root, "remote"):
refs = _git_lines(
repo_root,
"ls-remote",
"--heads",
remote,
env_extra={"GIT_TERMINAL_PROMPT": "0"},
)
names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
highest = max(highest, _extract_highest_number(names, scope_prefix))
return highest
def check_existing_branches(
repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
) -> int:
"""Check existing branches and return the next available number."""
if skip_fetch:
highest_branch = max(
get_highest_from_remote_refs(repo_root, scope_prefix),
get_highest_from_branches(repo_root, scope_prefix),
)
else:
subprocess.run(
["git", "fetch", "--all", "--prune"],
cwd=repo_root,
capture_output=True,
text=True,
)
highest_branch = get_highest_from_branches(repo_root, scope_prefix)
return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
# ── Branch naming ────────────────────────────────────────────────────────────
def clean_branch_name(name: str) -> str:
name = re.sub(r"[^a-z0-9]", "-", name.lower())
name = re.sub(r"-+", "-", name)
return name.strip("-")
def generate_branch_name(description: str) -> str:
"""Generate a branch suffix from the description with stop word filtering."""
clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
meaningful_words = []
for word in clean_name.split():
if word in STOP_WORDS:
continue
if len(word) >= 3:
meaningful_words.append(word)
# Keep short words only when they appear uppercased in the original
# description (acronyms like "API" or "DB").
elif re.search(rf"\b{re.escape(word.upper())}\b", description):
meaningful_words.append(word)
if meaningful_words:
max_words = 4 if len(meaningful_words) == 4 else 3
return "-".join(meaningful_words[:max_words])
cleaned = clean_branch_name(description)
return "-".join([part for part in cleaned.split("-") if part][:3])
def branch_token(value: str, fallback: str) -> str:
cleaned = clean_branch_name(value)
return cleaned if cleaned else fallback
def get_author_token(repo_root: Path) -> str:
author = ""
if shutil.which("git") is not None:
lines = _git_lines(repo_root, "config", "user.name")
author = lines[0] if lines else ""
if not author:
lines = _git_lines(repo_root, "config", "user.email")
email = lines[0] if lines else ""
author = email.split("@")[0]
if not author:
author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
return branch_token(author, "unknown")
def get_app_token(repo_root: Path) -> str:
return branch_token(repo_root.name, "app")
def read_git_config_value(config_file: Path, key: str) -> str:
if not config_file.is_file():
return ""
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return ""
for line in lines:
if re.match(rf"^\s*{re.escape(key)}:", line):
value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
value = re.sub(r"\s+#.*$", "", value)
value = value.strip()
value = re.sub(r'^"|"$', "", value)
value = re.sub(r"^'|'$", "", value)
return value
return ""
def resolve_branch_template(config_file: Path) -> str:
template = read_git_config_value(config_file, "branch_template")
if template:
return template
prefix = read_git_config_value(config_file, "branch_prefix")
if not prefix:
return ""
if prefix.endswith("/"):
return f"{prefix}{{number}}-{{slug}}"
return f"{prefix}/{{number}}-{{slug}}"
def validate_branch_template(template: str) -> None:
if not template:
return
if "{number}" not in template:
_err(
"Error: branch_template must include the {number} token so generated "
"branches remain valid feature branches."
)
raise SystemExit(1)
slug_index = template.find("{slug}")
if slug_index != -1 and "{number}" in template[slug_index:]:
_err(
"Error: branch_template must not place {slug} before {number}; "
"use {slug} only in the final feature segment."
)
raise SystemExit(1)
feature_segment = template.rsplit("/", 1)[-1]
if not feature_segment.startswith("{number}-"):
_err(
"Error: branch_template must put {number}- at the start of the final "
"path segment so generated branches remain valid feature branches."
)
raise SystemExit(1)
def render_branch_template(
template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
) -> str:
rendered = template
rendered = rendered.replace("{author}", author_token)
rendered = rendered.replace("{app}", app_token)
rendered = rendered.replace("{number}", feature_num)
rendered = rendered.replace("{slug}", branch_suffix)
return rendered
def extract_feature_num_from_branch(branch_name: str) -> str:
feature_segment = branch_name.rsplit("/", 1)[-1]
match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
if match:
return match.group(0).rstrip("-")
match = re.match(r"^[0-9]+-", feature_segment)
if match:
return match.group(0).rstrip("-")
return branch_name
def _byte_length(value: str) -> int:
return len(value.encode("utf-8"))
# ── Main ─────────────────────────────────────────────────────────────────────
def main(argv: list[str]) -> int:
args = parse_args(argv)
feature_description = " ".join(args.description_parts)
if not feature_description:
_err(USAGE)
return 1
feature_description = feature_description.strip()
if not feature_description:
_err("Error: Feature description cannot be empty or contain only whitespace")
return 1
project_root = _find_project_root(SCRIPT_DIR)
core = _load_core_common(project_root)
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
# core helpers were not found, refuse rather than silently falling back to
# the wrong root.
if os.environ.get("SPECIFY_INIT_DIR") and (
core is None or not hasattr(core, "resolve_specify_init_dir")
):
_err(
"Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts "
"(common.py with resolve_specify_init_dir), which were not found."
)
return 1
if core is not None and hasattr(core, "get_repo_root"):
# Pass script path so cwd-outside-repo callers land on the same
# fallback the bash twin does. Older cores don't accept the kwarg —
# fall back to the no-arg call for compatibility.
try:
repo_root = core.get_repo_root(script_file=Path(__file__))
except TypeError:
repo_root = core.get_repo_root()
else:
toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
if toplevel:
repo_root = Path(toplevel[0])
elif project_root is not None:
repo_root = project_root
else:
_err("Error: Could not determine repository root.")
return 1
repo_root = Path(repo_root)
has_git_repo = _local_has_git(repo_root)
specs_dir = repo_root / "specs"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
author_token = get_author_token(repo_root)
app_token = get_app_token(repo_root)
branch_template = resolve_branch_template(config_file)
validate_branch_template(branch_template)
def build_branch_name(feature_num: str, branch_suffix: str) -> str:
if branch_template:
return render_branch_template(
branch_template, feature_num, branch_suffix, author_token, app_token
)
return f"{feature_num}-{branch_suffix}"
branch_number = args.branch_number
# Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
if env_branch_name:
branch_name = env_branch_name
feature_num = extract_feature_num_from_branch(branch_name)
branch_suffix = branch_name
else:
if args.short_name:
branch_suffix = clean_branch_name(args.short_name)
else:
branch_suffix = generate_branch_name(feature_description)
if args.use_timestamp and branch_number:
_err("[specify] Warning: --number is ignored when --timestamp is used")
branch_number = ""
if args.use_timestamp:
feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
branch_name = build_branch_name(feature_num, branch_suffix)
else:
scope_prefix = ""
if branch_template:
prefix_template = branch_template.split("{number}")[0]
scope_prefix = render_branch_template(
prefix_template, "", branch_suffix, author_token, app_token
)
if not branch_number:
if args.dry_run and has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, True, scope_prefix
)
elif args.dry_run:
branch_number = get_highest_from_specs(specs_dir) + 1
elif has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, False, scope_prefix
)
else:
branch_number = get_highest_from_specs(specs_dir) + 1
feature_num = f"{int(branch_number):03d}"
branch_name = build_branch_name(feature_num, branch_suffix)
branch_byte_len = _byte_length(branch_name)
if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
_err(
"Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
f"Provided value is {branch_byte_len} bytes."
)
return 1
if branch_byte_len > MAX_BRANCH_LENGTH:
original_branch_name = branch_name
truncated_suffix = branch_suffix
while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
truncated_suffix = truncated_suffix[:-1]
truncated_suffix = truncated_suffix.rstrip("-")
branch_name = build_branch_name(feature_num, truncated_suffix)
if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
_err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
return 1
_err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
_err(
f"[specify] Original: {original_branch_name} "
f"({_byte_length(original_branch_name)} bytes)"
)
_err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
if not args.dry_run:
if has_git_repo:
create = subprocess.run(
["git", "checkout", "-q", "-b", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if create.returncode != 0:
current_branch_lines = _git_lines(
repo_root, "rev-parse", "--abbrev-ref", "HEAD"
)
current_branch = current_branch_lines[0] if current_branch_lines else ""
branch_exists = bool(
_git_lines(repo_root, "branch", "--list", branch_name)
)
if branch_exists:
if args.allow_existing:
if current_branch != branch_name:
switch = subprocess.run(
["git", "checkout", "-q", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if switch.returncode != 0:
_err(
f"Error: Failed to switch to existing branch '{branch_name}'. "
"Please resolve any local changes or conflicts and try again."
)
if switch.stderr.strip():
_err(switch.stderr.strip())
return 1
elif args.use_timestamp:
_err(
f"Error: Branch '{branch_name}' already exists. Rerun to get "
"a new timestamp or use a different --short-name."
)
return 1
else:
_err(
f"Error: Branch '{branch_name}' already exists. Please use a "
"different feature name or specify a different number with --number."
)
return 1
else:
_err(f"Error: Failed to create git branch '{branch_name}'.")
if create.stderr.strip():
_err(create.stderr.strip())
else:
_err("Please check your git configuration and try again.")
return 1
else:
_err(
"[specify] Warning: Git repository not detected; skipped branch "
f"creation for {branch_name}"
)
_err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
if args.json_mode:
payload: dict[str, object] = {
"BRANCH_NAME": branch_name,
"FEATURE_NUM": feature_num,
}
if args.dry_run:
payload["DRY_RUN"] = True
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
else:
print(f"BRANCH_NAME: {branch_name}")
print(f"FEATURE_NUM: {feature_num}")
if not args.dry_run:
print(
"# To persist in your shell: "
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Git-specific common helpers for the git extension.
Python port of ``git-common.sh`` / ``git-common.ps1`` — contains only
git-specific branch validation and detection logic.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def has_git(repo_root: Path | None = None) -> bool:
"""Check if we have git available at the repo root."""
root = Path(repo_root) if repo_root is not None else Path.cwd()
git_marker = root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
)
return result.returncode == 0
def effective_branch_name(raw: str) -> str:
"""Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
Only when the full name is exactly two slash-free segments; otherwise
returns the raw name.
"""
match = re.fullmatch(r"([^/]+)/([^/]+)", raw)
if match:
return match.group(2)
return raw
def check_feature_branch(raw: str, has_git_repo: bool) -> bool:
"""Validate that a branch name matches the expected feature branch pattern.
Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*)
formats, either at the start of the branch or after path-style namespace
prefixes. Logic aligned with the bash/PowerShell twins.
"""
if not has_git_repo:
print(
"[specify] Warning: Git repository not detected; skipped branch validation",
file=sys.stderr,
)
return True
branch = effective_branch_name(raw)
feature_segment = branch.rsplit("/", 1)[-1]
# Accept sequential prefix (3+ digits) but exclude malformed timestamps:
# 7-or-8 digit date + 6-digit time with no trailing slug.
is_sequential = bool(
re.match(r"^[0-9]{3,}-", feature_segment)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", feature_segment)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment)
)
is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment))
if not is_sequential and not is_timestamp:
print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr)
print(
"Feature branches should be named like: 001-feature-name, "
"1234-feature-name, 20260319-143022-feature-name, or "
"<prefix>/001-feature-name",
file=sys.stderr,
)
return False
return True

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Git extension: initialize_repo.py
Initialize a Git repository with an initial commit.
Python port of ``initialize-repo.sh`` / ``initialize-repo.ps1``.
Customizable — replace this script to add .gitignore templates,
default branch config, git-flow, LFS, signing, etc.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _read_commit_message(repo_root: Path) -> str:
"""Read init_commit_message from git-config.yml, mirroring the bash sed pipeline."""
default = "[Spec Kit] Initial commit"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
return default
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return default
for line in lines:
if line.startswith("init_commit_message:"):
value = re.sub(r"^init_commit_message:\s*", "", line)
value = re.sub(r"^[\"']", "", value)
value = re.sub(r"[\"']*$", "", value)
if value:
return value
return default
def main() -> int:
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
commit_msg = _read_commit_message(repo_root)
if shutil.which("git") is None:
print(
"[specify] Warning: Git not found; skipped repository initialization",
file=sys.stderr,
)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode == 0:
print("[specify] Git repository already initialized; skipping", file=sys.stderr)
return 0
steps = [
(["git", "init", "-q"], "git init"),
(["git", "add", "."], "git add"),
(["git", "commit", "--allow-empty", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print("[OK] Git repository initialized", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.15.dev0"
version = "0.12.16"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -33,11 +33,17 @@ def _stdin_is_interactive() -> bool:
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
"""Copy constitution template to memory if it doesn't exist."""
"""Materialize the resolved constitution template to memory if missing.
Resolution walks the full priority stack (project overrides → installed
presets → extensions → core) via :class:`PresetResolver`, so a preset that
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
constitution) can seed the memory file. When nothing overrides it, the
resolver falls through to the core template.
"""
from ..presets import _materialize_constitution_template
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)
if memory_constitution.exists():
if tracker:
@@ -45,18 +51,21 @@ def ensure_constitution_from_template(
tracker.skip("constitution", "existing file preserved")
return
if not template_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
materialization = _materialize_constitution_template(
project_path, memory_constitution
)
if materialization is None:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
if materialization == "copied":
tracker.complete("constitution", "copied from template")
else:
tracker.complete("constitution", "composed from template")
else:
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
@@ -476,8 +485,6 @@ def register(app: typer.Typer) -> None:
"shared-infra", f"scripts ({selected_script}) + templates"
)
ensure_constitution_from_template(project_path, tracker=tracker)
try:
bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf:
@@ -605,6 +612,11 @@ def register(app: typer.Typer) -> None:
continuing="Continuing without the optional preset.",
)
# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.
ensure_constitution_from_template(project_path, tracker=tracker)
tracker.complete("final", "project ready")
except (typer.Exit, SystemExit):
raise

View File

@@ -31,7 +31,117 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter, version_satisfies
from ..shared_infra import verify_archive_sha256
from ..shared_infra import (
_ensure_safe_shared_destination,
_ensure_safe_shared_directory,
_write_shared_bytes,
_write_shared_text,
verify_archive_sha256,
)
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
resolver: "PresetResolver",
) -> bool:
"""Return whether the live constitution is an unchanged generated file."""
_ensure_safe_shared_destination(project_root, memory_constitution)
content = memory_constitution.read_bytes()
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_ensure_safe_shared_destination(project_root, provenance)
if provenance.exists():
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("sha256") == _content_sha256(content)
)
# Older projects have no provenance sidecar. Only the immutable bundled or
# source-checkout core template is safe to treat as generated.
core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
return core is not None and core.read_bytes() == content
def _constitution_provenance_matches_preset(
project_root: Path,
memory_constitution: Path,
pack_id: str,
pack_version: str,
) -> bool:
"""Return whether provenance identifies a preset as the materialized source."""
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
if not provenance.parent.exists():
return False
_ensure_safe_shared_destination(project_root, provenance)
if not provenance.exists():
return False
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("source") == f"{pack_id} v{pack_version}"
)
def _materialize_constitution_template(
project_root: Path,
memory_constitution: Path,
) -> str | None:
"""Materialize constitution-template content into memory/constitution.md.
Returns:
"copied" when the winning layer is ``replace`` and the source file is
copied verbatim; "composed" when a composing strategy is materialized
via ``resolve_content``; ``None`` when no constitution template resolves.
"""
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if not layers:
return None
top_layer = layers[0]
if top_layer["strategy"] == "replace":
content = top_layer["path"].read_bytes()
result = "copied"
else:
composed_content = resolver.resolve_content("constitution-template", "template")
if composed_content is None:
return None
content = composed_content.encode("utf-8")
result = "composed"
_ensure_safe_shared_directory(project_root, memory_constitution.parent)
_write_shared_bytes(project_root, memory_constitution, content)
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_write_shared_text(
project_root,
provenance,
json.dumps(
{
"sha256": _content_sha256(content),
"source": top_layer["source"],
},
indent=2,
)
+ "\n",
)
return result
def _substitute_core_template(
@@ -1629,8 +1739,73 @@ class PresetManager:
stacklevel=2,
)
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
"""
provides_constitution = any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
(preset_dir / relative_path).is_file()
for relative_path in (
"templates/constitution-template.md",
"constitution-template.md",
)
)
if not provides_constitution:
return
self.reconcile_constitution(
f"Failed to seed constitution from preset {manifest.id}",
create_if_missing=True,
)
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"{failure_context}: {exc}.",
stacklevel=2,
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
if not memory_constitution.exists() and not create_if_missing:
return
resolver = PresetResolver(self.project_root)
if memory_constitution.exists() and not _constitution_is_generated(
self.project_root, memory_constitution, resolver
):
return
_materialize_constitution_template(self.project_root, memory_constitution)
def install_from_zip(
self,
zip_path: Path,
@@ -1710,6 +1885,25 @@ class PresetManager:
# Also include aliases from the manifest as a safety net for registries
# populated by older versions that may not track aliases.
removed_cmd_names = set()
removed_constitution = any(
path.exists()
for path in (
pack_dir / "templates" / "constitution-template.md",
pack_dir / "constitution-template.md",
)
)
if metadata and isinstance(metadata.get("version"), str):
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
removed_constitution = removed_constitution or (
_constitution_provenance_matches_preset(
self.project_root,
memory_constitution,
pack_id,
metadata["version"],
)
)
for cmd_names in registered_commands.values():
removed_cmd_names.update(cmd_names)
manifest_path = pack_dir / "preset.yml"
@@ -1717,6 +1911,11 @@ class PresetManager:
try:
manifest = PresetManifest(manifest_path)
for tmpl in manifest.templates:
if (
tmpl.get("type") == "template"
and tmpl.get("name") == "constitution-template"
):
removed_constitution = True
if tmpl.get("type") == "command":
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
@@ -1763,6 +1962,18 @@ class PresetManager:
stacklevel=2,
)
if removed_constitution:
try:
self._reconcile_constitution()
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"Post-removal constitution reconciliation failed for {pack_id}: "
f"{exc}. The live constitution may be stale.",
stacklevel=2,
)
return True
def list_installed(self) -> List[Dict[str, Any]]:

View File

@@ -484,6 +484,9 @@ def preset_set_priority(
# Update priority
manager.registry.update(preset_id, {"priority": priority})
manager.reconcile_constitution(
f"Failed to reconcile constitution after changing priority for preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
@@ -517,6 +520,9 @@ def preset_enable(
# Enable the preset
manager.registry.update(preset_id, {"enabled": True})
manager.reconcile_constitution(
f"Failed to reconcile constitution after enabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
@@ -551,6 +557,9 @@ def preset_disable(
# Disable the preset
manager.registry.update(preset_id, {"enabled": False})
manager.reconcile_constitution(
f"Failed to reconcile constitution after disabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")

View File

@@ -74,6 +74,9 @@ class StepContext:
#: Current run ID.
run_id: str | None = None
#: Source directory of the workflow definition file.
workflow_dir: str | None = None
@dataclass
class StepResult:

View File

@@ -508,6 +508,7 @@ class RunState:
# append_log is never called while _lock is held, the two never nest.
self._log_lock = threading.Lock()
self.inputs: dict[str, Any] = {}
self.workflow_dir: str | None = None
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
@@ -562,6 +563,7 @@ class RunState:
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
"step_results": self.step_results,
"workflow_dir": self.workflow_dir,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@@ -654,6 +656,7 @@ class RunState:
state.current_step_index = state_data.get("current_step_index", 0)
state.current_step_id = state_data.get("current_step_id")
state.step_results = state_data.get("step_results", {})
state.workflow_dir = state_data.get("workflow_dir")
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
@@ -810,6 +813,12 @@ class WorkflowEngine:
# Resolve inputs
resolved_inputs = self._resolve_inputs(definition, inputs or {})
state.inputs = resolved_inputs
workflow_dir = (
str(definition.source_path.resolve().parent)
if definition.source_path is not None
else None
)
state.workflow_dir = workflow_dir
state.status = RunStatus.RUNNING
state.save()
@@ -820,6 +829,7 @@ class WorkflowEngine:
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
workflow_dir=workflow_dir,
)
# Execute steps
@@ -885,6 +895,7 @@ class WorkflowEngine:
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
workflow_dir=state.workflow_dir,
)
from . import STEP_REGISTRY
@@ -1197,9 +1208,9 @@ class WorkflowEngine:
already flipped), so the prefix never drops the actual halting item.
``max_concurrency`` is coerced with ``int()``; a value that cannot be
coerced (``None``, a non-numeric string, …) or that coerces to <= 1 runs
sequentially, while a numeric string like ``"4"`` or a float like ``4.0``
is honored.
coerced (``None``, a non-numeric string, ``.inf``/``.nan``, …) or that
coerces to <= 1 runs sequentially, while a numeric string like ``"4"`` or
a float like ``4.0`` is honored.
"""
if not items:
return []
@@ -1207,7 +1218,9 @@ class WorkflowEngine:
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
try:
workers = max(1, int(max_concurrency))
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a YAML ``max_concurrency: .inf``
# would otherwise crash the whole run instead of falling back.
workers = 1
# Never spin up more workers than there is work — bounds a user-controlled
# max_concurrency from over-allocating threads.

View File

@@ -35,14 +35,38 @@ def _filter_default(value: Any, default_value: Any = "") -> Any:
def _filter_join(value: Any, separator: str = ", ") -> str:
"""Join a list into a string with *separator*."""
"""Join a list into a string with *separator*.
Raises ``ValueError`` when *separator* is not a string. Without the guard a
non-string separator (an authoring mistake like ``| join(5)``) reaches
``str.join`` and raises a cryptic ``AttributeError: 'int' object has no
attribute 'join'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(separator, str):
raise ValueError(
f"join: expected a string separator, got {type(separator).__name__}"
)
if isinstance(value, list):
return separator.join(str(v) for v in value)
return str(value)
def _filter_map(value: Any, attr: str) -> list[Any]:
"""Map a list of dicts to a specific attribute."""
"""Map a list of dicts to a specific attribute.
Raises ``ValueError`` when *attr* is not a string. Without the guard a
non-string attribute (an authoring mistake like ``| map(5)``) reaches
``attr.split(".")`` and raises a cryptic ``AttributeError: 'int' object has
no attribute 'split'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(attr, str):
raise ValueError(
f"map: expected a string attribute name, got {type(attr).__name__}"
)
if isinstance(value, list):
result = []
for item in value:
@@ -63,9 +87,25 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
return []
def _filter_contains(value: Any, substring: str) -> bool:
"""Check if a string or list contains *substring*."""
def _filter_contains(value: Any, substring: Any) -> bool:
"""Check if a string or list contains *substring*.
For a string *value*, *substring* must itself be a string: ``x in y`` on a
string requires a string left operand, so a non-string argument (an
authoring mistake like ``| contains(5)``) would otherwise raise a cryptic
``TypeError`` that escapes the evaluator and crashes the whole run, since
the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Raise a ``ValueError`` naming the problem instead, mirroring the
strict argument handling in ``from_json``. For a list *value*, membership of
any element type is legitimate (``5 in [1, 2, 5]``), so that branch is left
unguarded.
"""
if isinstance(value, str):
if not isinstance(substring, str):
raise ValueError(
"contains: expected a string argument when the value is a "
f"string, got {type(substring).__name__}"
)
return substring in value
if isinstance(value, list):
return substring in value
@@ -142,7 +182,8 @@ def _build_namespace(context: Any) -> dict[str, Any]:
# runs use an 8-character uuid4 hex; operator-supplied ids may be
# any alphanumeric string with hyphens or underscores.
run_id = getattr(context, "run_id", None) or ""
ns["context"] = {"run_id": run_id}
workflow_dir = getattr(context, "workflow_dir", None) or ""
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
return ns

View File

@@ -27,6 +27,30 @@ class DoWhileStep(StepBase):
nested_steps = config.get("steps", [])
condition = config.get("condition", "false")
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (a single mapping or scalar authoring mistake)
# would otherwise be iterated element-wise — a dict yields its string
# keys, a str its characters — and crash the whole run with
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The body always runs on the
# first call, so unlike the while step this guard is unconditional.
if not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition": condition,
"max_iterations": max_iterations,
"loop_type": "do-while",
},
error=(
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
f"a list of steps, got {type(nested_steps).__name__}."
),
)
# Always execute body at least once; the engine layer evaluates
# `condition` after each iteration to decide whether to loop.
return StepResult(

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import math
import os
import subprocess
from typing import Any
@@ -40,6 +41,13 @@ class ShellStep(StepBase):
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
env = {**os.environ}
if context.workflow_dir:
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
else:
env.pop("SPECKIT_WORKFLOW_DIR", None)
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -51,6 +59,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
env=env,
timeout=timeout,
)
output = {

View File

@@ -26,6 +26,32 @@ class WhileStep(StepBase):
nested_steps = config.get("steps", [])
result = evaluate_condition(condition, context)
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (a single mapping or scalar authoring mistake)
# would otherwise be iterated element-wise — a dict yields its string
# keys, a str its characters — and crash the whole run with
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The guard fires only when the
# body would actually be dispatched (condition truthy). The condition is
# still evaluated first, so its result is surfaced for downstream context.
if result and not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition_result": True,
"max_iterations": max_iterations,
"loop_type": "while",
},
error=(
f"While step {config.get('id', '?')!r}: 'steps' must be a "
f"list of steps, got {type(nested_steps).__name__}."
),
)
if result:
return StepResult(
status=StepStatus.COMPLETED,

View File

@@ -0,0 +1,647 @@
"""
Parity tests for the Python port of the git extension scripts (extensions/git/scripts/python/).
Each test runs the bash script and its Python twin in identical twin projects
and asserts matching output, exit codes, and resulting git state.
"""
import json
import os
import re
import runpy
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.extensions.git.test_git_extension import (
_GIT_ENV,
_init_git,
_run_bash,
_setup_project,
_write_config,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_PY = PROJECT_ROOT / "extensions" / "git" / "scripts" / "python"
CORE_COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
PY_SCRIPTS = {
"create-new-feature-branch": "create_new_feature_branch.py",
"initialize-repo": "initialize_repo.py",
"auto-commit": "auto_commit.py",
}
def _setup_py_project(tmp_path: Path, *, git: bool = True) -> Path:
"""Twin of _setup_project that also installs the Python scripts."""
project = _setup_project(tmp_path, git=git)
py_core = project / ".specify" / "scripts" / "python"
py_core.mkdir(parents=True, exist_ok=True)
shutil.copy(CORE_COMMON_PY, py_core / "common.py")
ext_py = project / ".specify" / "extensions" / "git" / "scripts" / "python"
ext_py.mkdir(parents=True, exist_ok=True)
for f in EXT_PY.iterdir():
if f.suffix == ".py":
shutil.copy(f, ext_py / f.name)
return project
def _run_py(
script_name: str,
cwd: Path,
*args: str,
env_extra: dict | None = None,
run_cwd: Path | None = None,
) -> subprocess.CompletedProcess:
"""Run an extension Python script.
``run_cwd`` overrides the working directory while the script path is
still resolved against ``cwd``, for tests that invoke a project's script
from outside that project.
"""
script = (
cwd / ".specify" / "extensions" / "git" / "scripts" / "python" / PY_SCRIPTS[script_name]
)
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
return subprocess.run(
[sys.executable, str(script), *args],
cwd=run_cwd or cwd,
capture_output=True,
text=True,
env=env,
)
def _twin_projects(tmp_path: Path, *, git: bool = True) -> tuple[Path, Path]:
"""Two identically named projects so {app} tokens match."""
bash_proj = _setup_py_project(tmp_path / "bash" / "proj", git=git)
py_proj = _setup_py_project(tmp_path / "py" / "proj", git=git)
return bash_proj, py_proj
def _assert_parity(
bash_result: subprocess.CompletedProcess,
py_result: subprocess.CompletedProcess,
*,
stdout: bool = True,
stderr: bool = True,
) -> None:
assert py_result.returncode == bash_result.returncode, (
f"exit codes diverge: bash={bash_result.returncode} py={py_result.returncode}\n"
f"bash stderr: {bash_result.stderr}\npy stderr: {py_result.stderr}"
)
if stdout:
assert py_result.stdout == bash_result.stdout
if stderr:
py_stderr = py_result.stderr
bash_stderr = bash_result.stderr
if os.name == "nt":
py_stderr = _without_persist_hint(py_stderr)
bash_stderr = _without_persist_hint(bash_stderr)
assert py_stderr == bash_stderr
def _without_persist_hint(stderr: str) -> str:
return "".join(
line
for line in stderr.splitlines(keepends=True)
if not line.startswith("# To persist: ")
)
@requires_bash
class TestCreateFeatureBranchParity:
def test_sequential_branch_json(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "Add user authentication")
p = _run_py("create-new-feature-branch", py_proj, "--json", "Add user authentication")
_assert_parity(b, p)
data = json.loads(p.stdout)
assert data == {"BRANCH_NAME": "001-user-authentication", "FEATURE_NUM": "001"}
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=py_proj,
capture_output=True,
text=True,
).stdout.strip()
assert branch == "001-user-authentication"
def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
description = "I want to add DB caching for the API layer"
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", description)
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description)
_assert_parity(b, p)
def test_short_name_cleaning(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
# Single separator runs only: the bash twin's collapse step
# (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally.
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
)
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth"
def test_numbering_from_specs_and_branches(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
(proj / "specs" / "007-existing").mkdir(parents=True)
(proj / "specs" / "20260101-120000-timestamped").mkdir(parents=True)
subprocess.run(["git", "branch", "012-in-branch"], cwd=proj, check=True)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next feature")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "013"
def test_explicit_number(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--number", "42", "some feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--number", "42", "some feature")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "042"
def test_timestamp_mode_format(self, tmp_path: Path):
_, py_proj = _twin_projects(tmp_path)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--timestamp", "--short-name", "user-auth", "desc",
)
assert p.returncode == 0
data = json.loads(p.stdout)
assert re.fullmatch(r"[0-9]{8}-[0-9]{6}", data["FEATURE_NUM"])
assert data["BRANCH_NAME"] == f"{data['FEATURE_NUM']}-user-auth"
def test_timestamp_with_number_warns(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
)
assert p.returncode == b.returncode == 0
warning = "[specify] Warning: --number is ignored when --timestamp is used"
assert warning in b.stderr
assert warning in p.stderr
def test_branch_template_author_app(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "test-user/proj/001-new-payment-flow"
def test_branch_prefix_shorthand(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "branch_prefix: feat\n")
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "feat/001-new-payment-flow"
def test_template_scopes_existing_branch_numbers(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, 'branch_template: "{author}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "test-user/008-scoped"], cwd=proj, check=True)
subprocess.run(["git", "branch", "other-user/030-unscoped"], cwd=proj, check=True)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next thing")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next thing")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "009"
@pytest.mark.parametrize(
"template",
[
'branch_template: "feat/{slug}"\n',
'branch_template: "{slug}/{number}-x"\n',
'branch_template: "{number}/{slug}-x"\n',
],
)
def test_invalid_template_rejected(self, tmp_path: Path, template: str):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, template)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "desc word")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "desc word")
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
def test_git_branch_name_override(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
env = {"GIT_BRANCH_NAME": "team/042-exact-name"}
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env)
p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env)
_assert_parity(b, p)
assert json.loads(p.stdout) == {"BRANCH_NAME": "team/042-exact-name", "FEATURE_NUM": "042"}
def test_git_branch_name_override_persist_hint_matches_bash(
self, tmp_path: Path
):
bash_proj, py_proj = _twin_projects(tmp_path)
env = {"GIT_BRANCH_NAME": "feature/$value's-\"quoted\""}
b = _run_bash(
"create-new-feature-branch.sh",
bash_proj,
"--json",
"desc word",
env_extra=env,
)
p = _run_py(
"create-new-feature-branch",
py_proj,
"--json",
"desc word",
env_extra=env,
)
_assert_parity(b, p)
def test_long_branch_name_truncated_to_244_bytes(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
long_name = "-".join(["word"] * 60)
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--short-name", long_name, "desc",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--short-name", long_name, "desc",
)
_assert_parity(b, p)
assert len(json.loads(p.stdout)["BRANCH_NAME"].encode()) <= 244
def test_existing_branch_errors_without_flag(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
args = ("--json", "--number", "1", "--short-name", "user-auth", "desc")
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
p = _run_py("create-new-feature-branch", py_proj, *args)
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
def test_existing_branch_switches_with_allow_flag(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
args = ("--json", "--number", "1", "--short-name", "user-auth", "--allow-existing-branch", "desc")
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
p = _run_py("create-new-feature-branch", py_proj, *args)
_assert_parity(b, p)
for proj in (bash_proj, py_proj):
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert branch == "001-user-auth"
def test_no_git_graceful_degradation(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "offline feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "offline feature")
_assert_parity(b, p)
assert "skipped branch creation" in p.stderr
def test_missing_git_executable_gracefully_degrades(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setenv("PATH", "")
assert module["_git_lines"](tmp_path, "status") == []
def test_windows_persist_hint_quotes_branch_name(
self, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setattr(module["os"], "name", "nt")
result = module["_persist_hint"](
"GIT_BRANCH_NAME", "feature/$value's-\"quoted\""
)
assert (
result
== "$env:GIT_BRANCH_NAME = 'feature/$value''s-\"quoted\"'"
)
def test_shell_specific_persist_hint_can_be_ignored_for_parity(self):
bash_stderr = (
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
windows_stderr = (
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
assert _without_persist_hint(bash_stderr) == _without_persist_hint(
windows_stderr
)
def test_assert_parity_ignores_windows_persist_hint(
self, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(os, "name", "nt")
bash_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
)
py_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
)
_assert_parity(bash_result, py_result)
def test_empty_description_errors(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", " ")
p = _run_py("create-new-feature-branch", py_proj, "--json", " ")
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
assert "cannot be empty or contain only whitespace" in p.stderr
def test_specify_init_dir_resolves_target_project(self, tmp_path: Path):
# The script is installed under host_proj, so script_file-based
# discovery (and cwd-based discovery, since we run from elsewhere)
# would resolve host_proj, not target_proj. host_proj has no specs
# (next number 001); target_proj already has 007-existing (next
# number 008). Only honoring SPECIFY_INIT_DIR produces 008, so this
# proves the env var -- not script location or cwd -- controls
# resolution.
host_proj = _setup_py_project(tmp_path / "host")
target_proj = _setup_py_project(tmp_path / "target")
(target_proj / "specs" / "007-existing").mkdir(parents=True)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
p = _run_py(
"create-new-feature-branch", host_proj,
"--json", "--dry-run", "init dir feature",
env_extra={"SPECIFY_INIT_DIR": str(target_proj)},
run_cwd=elsewhere,
)
assert p.returncode == 0
assert json.loads(p.stdout)["FEATURE_NUM"] == "008"
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
_, py_proj = _twin_projects(tmp_path)
(
py_proj / ".specify" / "scripts" / "python" / "common.py"
).unlink()
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "desc word",
env_extra={"SPECIFY_INIT_DIR": str(py_proj)},
)
assert p.returncode == 1
assert "SPECIFY_INIT_DIR requires updated Spec Kit core scripts" in p.stderr
@requires_bash
class TestInitializeRepoParity:
def test_initializes_repo_with_default_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
for proj in (bash_proj, py_proj):
message = subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert message == "[Spec Kit] Initial commit"
def test_custom_commit_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
for proj in (bash_proj, py_proj):
_write_config(proj, 'init_commit_message: "Custom initial commit"\n')
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
for proj in (bash_proj, py_proj):
message = subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert message == "Custom initial commit"
def test_skips_existing_repo(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert "already initialized" in p.stderr
@requires_bash
class TestAutoCommitParity:
def _dirty(self, proj: Path) -> None:
(proj / "change.txt").write_text("dirty\n", encoding="utf-8")
def _last_message(self, proj: Path) -> str:
return subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
def test_disabled_by_default(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n after_specify:\n enabled: false\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_ignores_unterminated_final_config_line(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = "auto_commit:\n after_specify:\n enabled: true"
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_enabled_per_command_with_custom_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "spec done"\n'
)
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n default: true\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_plan")
p = _run_py("auto-commit", py_proj, "after_plan")
_assert_parity(b, p)
expected = "[Spec Kit] Auto-commit after plan"
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
def test_explicit_false_beats_default_true(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = "auto_commit:\n default: true\n after_specify:\n enabled: false\n"
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_before_event_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n before_plan:\n enabled: true\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "before_plan")
p = _run_py("auto-commit", py_proj, "before_plan")
_assert_parity(b, p)
expected = "[Spec Kit] Auto-commit before plan"
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
def test_no_changes_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n after_specify:\n enabled: true\n")
subprocess.run(["git", "add", "-A"], cwd=proj, check=True)
subprocess.run(
["git", "commit", "-q", "-m", "clean"],
cwd=proj,
check=True,
env={**os.environ, **_GIT_ENV},
)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert "No changes to commit" in p.stderr
def test_no_config_file_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions")
def test_unreadable_config_skips_auto_commit(self, tmp_path: Path):
"""An unreadable config behaves like a missing one: no traceback, no commit."""
if os.geteuid() == 0:
pytest.skip("root bypasses file permissions")
proj = _setup_py_project(tmp_path / "proj")
config = _write_config(
proj, "auto_commit:\n after_specify:\n enabled: true\n"
)
self._dirty(proj)
config.chmod(0o000)
try:
p = _run_py("auto-commit", proj, "after_specify")
finally:
config.chmod(0o644)
assert p.returncode == 0
assert "Traceback" not in p.stderr
assert self._last_message(proj) == "seed"
def test_missing_event_argument_errors(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("auto-commit.sh", bash_proj)
p = _run_py("auto-commit", py_proj)
assert b.returncode == p.returncode == 1
def test_not_a_repo_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert "Not a Git repository" in p.stderr
class TestGitCommonPython:
"""Unit tests for git_common.py (imported directly)."""
@pytest.fixture()
def git_common(self):
sys.path.insert(0, str(EXT_PY))
try:
import git_common
yield git_common
finally:
sys.path.remove(str(EXT_PY))
sys.modules.pop("git_common", None)
def test_has_git(self, git_common, tmp_path: Path):
assert git_common.has_git(tmp_path) is False
_init_git(tmp_path)
assert git_common.has_git(tmp_path) is True
@pytest.mark.parametrize(
("branch", "expected"),
[
("001-feature-name", True),
("1234-feature-name", True),
("20260319-143022-feature-name", True),
("feat/004-name", True),
("main", False),
("2026031-143022", False),
("20260319-143022", False),
("2026031-143022-slug", False),
],
)
def test_check_feature_branch(self, git_common, branch: str, expected: bool):
assert git_common.check_feature_branch(branch, True) is expected
def test_check_feature_branch_no_git_warns_but_passes(self, git_common, capsys):
assert git_common.check_feature_branch("main", False) is True
assert "skipped branch validation" in capsys.readouterr().err

View File

@@ -314,6 +314,18 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_installs_python_scripts_for_py(self, tmp_path):
from specify_cli import _install_shared_infra
project = tmp_path / "python-scripts"
project.mkdir()
_install_shared_infra(project, "py")
assert (
project / ".specify" / "scripts" / "python" / "common.py"
).exists()
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
"""A managed script the core no longer ships (e.g. the legacy
update-agent-context.sh, superseded by the agent-context extension) is

View File

@@ -253,6 +253,7 @@ class MarkdownIntegrationTests:
"spec-template.md", "tasks-template.md"]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -399,6 +399,7 @@ class SkillsIntegrationTests:
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
]
# Script variant

View File

@@ -517,6 +517,7 @@ class TomlIntegrationTests:
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -431,6 +431,7 @@ class YamlIntegrationTests:
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -214,6 +214,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -252,6 +252,7 @@ class TestCopilotIntegration:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
@@ -313,6 +314,7 @@ class TestCopilotIntegration:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
@@ -724,6 +726,7 @@ class TestCopilotSkillsMode:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",

View File

@@ -286,6 +286,7 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
@@ -342,6 +343,7 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",

View File

@@ -2777,6 +2777,40 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
"""Create a preset whose constitution is found by convention, not its manifest."""
preset_dir = temp_dir / "convention-constitution"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Convention Constitution\n"
)
(preset_dir / "templates" / "spec-template.md").write_text("# Spec\n")
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "convention-constitution",
"name": "Convention Constitution",
"version": "1.0.0",
"description": "Convention-based constitution for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}
]
},
}
)
)
return preset_dir
class TestSelfTestPreset:
"""Tests using the self-test preset that ships with the repo.
@@ -2884,6 +2918,24 @@ class TestSelfTestPreset:
assert result is not None
assert result["source"] == "core"
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Core constitution-template\n"
def test_self_test_removal_preserves_edited_constitution(self, project_dir):
"""Removing a preset does not overwrite an edited generated constitution."""
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
edited = memory.read_text() + "\n## Authored amendment\n"
memory.write_text(edited)
manager.remove("self-test")
assert memory.read_text() == edited
def test_self_test_not_in_catalog(self):
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -2958,6 +3010,432 @@ class TestSelfTestPreset:
metadata = manager.registry.get("self-test")
assert metadata["registered_commands"] == {}
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
"""Installing a preset seeds memory/constitution.md from its template."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.exists(), "constitution.md was not seeded from the preset"
assert "preset:self-test" in memory.read_text(), (
"constitution.md was not seeded from the self-test preset template"
)
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
"""An unchanged core constitution is re-seeded from the preset template."""
resolver = PresetResolver(project_dir)
bundled_core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
assert bundled_core is not None
core = bundled_core.read_bytes()
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_bytes(core)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
content = memory.read_text()
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
assert "[PROJECT_NAME]" not in content
@pytest.mark.parametrize(
"provenance_content",
[
'{"sha256": "does-not-match", "source": "old-preset"}\n',
"{not valid json",
],
ids=["hash-mismatch", "malformed"],
)
def test_self_test_preserves_core_content_with_existing_invalid_provenance(
self, project_dir, provenance_content
):
"""A present invalid sidecar disables legacy core-template migration."""
resolver = PresetResolver(project_dir)
bundled_core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
assert bundled_core is not None
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_bytes(bundled_core.read_bytes())
(memory.parent / ".constitution-template.json").write_text(
provenance_content
)
original = memory.read_bytes()
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_bytes() == original
def test_self_test_preserves_mutable_project_core_copy(self, project_dir):
"""A project template copy does not establish generated provenance."""
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
project_template = (
project_dir / ".specify" / "templates" / "constitution-template.md"
)
project_template.write_text(authored)
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
assert not (memory.parent / ".constitution-template.json").exists()
def test_core_prefixed_preset_does_not_establish_generated_provenance(
self, project_dir, temp_dir
):
"""A preset ID beginning with core is not an immutable core source."""
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_text(authored)
preset_dir = temp_dir / "core-company"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(authored)
(preset_dir / "preset.yml").write_text(
yaml.safe_dump(
{
"schema_version": "1.0",
"preset": {
"id": "core-company",
"name": "Core Company",
"version": "1.0.0",
"description": "Company constitution preset",
"author": "Test Author",
"repository": "https://github.com/test/core-company",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"description": "Company constitution",
"replaces": "constitution-template",
}
]
},
}
)
)
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
assert memory.read_text() == authored
assert not (memory.parent / ".constitution-template.json").exists()
def test_self_test_preserves_authored_constitution_with_placeholder(
self, project_dir
):
"""A placeholder mention does not establish generated provenance."""
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
def test_self_test_preserves_authored_constitution(self, project_dir):
"""An authored (placeholder-free) constitution is never overwritten."""
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored, "authored constitution was overwritten"
def test_self_test_override_resolves_constitution_template(self, project_dir):
"""The preset override of constitution-template resolves to the preset file."""
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core constitution\n")
manager = PresetManager(project_dir)
install_self_test_preset(manager)
resolver = PresetResolver(project_dir)
result = resolver.resolve("constitution-template", "template")
assert result is not None
assert "preset:self-test" in result.read_text()
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
"""Seeding memory composes wrap constitution-template layers."""
templates_dir = project_dir / ".specify" / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
(templates_dir / "constitution-template.md").write_text(
"# Core Constitution\n\n## Core Principle\n"
)
preset_dir = temp_dir / "constitution-wrap"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "constitution-wrap",
"name": "Constitution Wrap",
"version": "1.0.0",
"description": "Wrap constitution template for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Wrapped constitution template",
}
]
},
}
)
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
memory = project_dir / ".specify" / "memory" / "constitution.md"
content = memory.read_text()
assert "{CORE_TEMPLATE}" not in content
assert "# Wrapper Constitution" in content
assert "## Core Principle" in content
def test_constitution_follows_priority_when_winning_preset_removed(
self, project_dir, temp_dir
):
"""An unchanged generated constitution follows priority and fallback layers."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
preset_dir = temp_dir / "higher-priority"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Higher Priority Constitution\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "higher-priority",
"name": "Higher Priority",
"version": "1.0.0",
"description": "Higher-priority constitution",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "replace",
"description": "Higher-priority constitution",
}
]
},
}
)
)
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Higher Priority Constitution\n"
manager.remove("higher-priority")
assert "preset:self-test" in memory.read_text()
def test_convention_constitution_removal_restores_remaining_layer(
self, project_dir, temp_dir
):
"""Removing a convention layer rematerializes the remaining resolver layer."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Convention Constitution\n"
manager.remove("convention-constitution")
assert "preset:self-test" in memory.read_text()
def test_convention_constitution_removal_preserves_edited_content(
self, project_dir, temp_dir
):
"""Removing a convention layer does not overwrite edited generated content."""
from specify_cli.commands.init import ensure_constitution_from_template
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5"
)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
edited = memory.read_text() + "\n## Authored amendment\n"
memory.write_text(edited)
manager.remove("convention-constitution")
assert memory.read_text() == edited
def test_custom_constitution_removal_recovers_with_invalid_manifest(
self, project_dir, temp_dir
):
"""Provenance triggers fallback when a custom-path manifest is invalid."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
preset_dir = temp_dir / "custom-constitution"
(preset_dir / "policy").mkdir(parents=True)
(preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n")
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "custom-constitution",
"name": "Custom Constitution",
"version": "1.0.0",
"description": "Custom-path constitution for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "policy/charter.md",
}
]
},
}
)
)
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Custom Constitution\n"
installed_manifest = (
project_dir
/ ".specify"
/ "presets"
/ "custom-constitution"
/ "preset.yml"
)
installed_manifest.write_text("invalid: [")
manager.remove("custom-constitution")
assert "preset:self-test" in memory.read_text()
def test_constitution_seed_rejects_symlinked_memory_directory(
self, project_dir, temp_dir
):
"""Preset installation cannot seed through a symlinked memory directory."""
outside = temp_dir / "outside"
outside.mkdir()
try:
(project_dir / ".specify" / "memory").symlink_to(
outside, target_is_directory=True
)
except OSError:
pytest.skip("symlinks are unavailable")
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
assert manager.registry.is_installed("self-test")
assert not (outside / "constitution.md").exists()
def test_constitution_seed_rejects_dangling_destination_symlink(
self, project_dir, temp_dir
):
"""Preset installation cannot seed through a dangling destination symlink."""
memory = project_dir / ".specify" / "memory"
memory.mkdir(parents=True)
outside = temp_dir / "outside-constitution.md"
try:
(memory / "constitution.md").symlink_to(outside)
except OSError:
pytest.skip("symlinks are unavailable")
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
assert manager.registry.is_installed("self-test")
assert not outside.exists()
def test_constitution_materialization_error_is_nonfatal(
self, project_dir, temp_dir
):
"""An invalid wrap warns without reporting an uninstalled preset."""
preset_dir = temp_dir / "invalid-wrap"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Missing core placeholder\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "invalid-wrap",
"name": "Invalid Wrap",
"version": "1.0.0",
"description": "Invalid wrapping constitution",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Invalid wrap",
}
]
},
}
)
)
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="Failed to seed constitution"):
manifest = manager.install_from_directory(preset_dir, "0.1.5")
assert manifest.id == "invalid-wrap"
assert manager.registry.is_installed("invalid-wrap")
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
"""Test that extension command overrides are skipped if the extension isn't installed."""
claude_dir = project_dir / ".claude" / "skills"
@@ -4308,6 +4786,31 @@ class TestPresetSetPriority:
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["priority"] == 5
def test_set_priority_reconciles_generated_constitution(
self, project_dir, temp_dir
):
"""Changing priority rematerializes an unchanged generated constitution."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=20
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert "preset:self-test" in memory.read_text()
with patch.object(Path, "cwd", return_value=project_dir):
result = CliRunner().invoke(
app,
["preset", "set-priority", "convention-constitution", "1"],
)
assert result.exit_code == 0, result.output
assert memory.read_text() == "# Convention Constitution\n"
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
"""Test set-priority with same value shows already set message."""
from typer.testing import CliRunner
@@ -4528,6 +5031,61 @@ class TestPresetEnableDisable:
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is True
def test_enable_disable_reconciles_generated_constitution(
self, project_dir, temp_dir
):
"""Enable and disable rematerialize the winning constitution layer."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Convention Constitution\n"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
disabled = runner.invoke(
app, ["preset", "disable", "convention-constitution"]
)
assert disabled.exit_code == 0, disabled.output
assert "preset:self-test" in memory.read_text()
with patch.object(Path, "cwd", return_value=project_dir):
enabled = runner.invoke(
app, ["preset", "enable", "convention-constitution"]
)
assert enabled.exit_code == 0, enabled.output
assert memory.read_text() == "# Convention Constitution\n"
def test_stack_changes_do_not_create_missing_constitution(
self, project_dir, pack_dir
):
"""Stack changes for non-providers do not seed a missing constitution."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")
memory = project_dir / ".specify" / "memory" / "constitution.md"
runner = CliRunner()
for args in (
["preset", "set-priority", "test-pack", "5"],
["preset", "disable", "test-pack"],
["preset", "enable", "test-pack"],
):
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, args)
assert result.exit_code == 0, result.output
assert not memory.exists()
def test_disable_already_disabled(self, project_dir, pack_dir):
"""Test disable on already disabled preset shows warning."""
from typer.testing import CliRunner
@@ -6678,3 +7236,130 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
# ===== ensure_constitution_from_template resolver-awareness =====
class TestEnsureConstitutionResolverAware:
"""`ensure_constitution_from_template` must resolve through PresetResolver.
The constitution is the only template materialized to a live file rather
than resolved on demand. These tests pin the regression from issue #3272:
a preset-provided ``constitution-template`` must seed memory, while the
core template is used when no preset overrides it.
"""
def _core_constitution(self, project_dir):
templates_dir = project_dir / ".specify" / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
(templates_dir / "constitution-template.md").write_text(
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
)
def _wrap_constitution_preset(self, temp_dir):
preset_dir = temp_dir / "ensure-wrap-preset"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "ensure-wrap",
"name": "Ensure Wrap",
"version": "1.0.0",
"description": "Wrap strategy for ensure() coverage",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Wrapped constitution",
}
]
},
}
)
)
return preset_dir
def test_seeds_from_core_when_no_preset(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.exists()
assert "[PROJECT_NAME]" in memory.read_text()
assert (memory.parent / ".constitution-template.json").exists()
def test_seeds_from_preset_when_installed(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
# Remove the memory file seeded during install to test ensure() in
# isolation; it must re-seed from the preset, not the core template.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
ensure_constitution_from_template(project_dir)
assert memory.exists()
content = memory.read_text()
assert "preset:self-test" in content
assert "[PROJECT_NAME]" not in content
def test_preserves_existing_memory(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\nAuthored.\n"
memory.write_text(authored)
ensure_constitution_from_template(project_dir)
assert memory.read_text() == authored
def test_preserves_edited_generated_memory(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
authored = memory.read_text() + "\nAuthored amendment.\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
manager = PresetManager(project_dir)
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
# Ensure we validate ensure() behavior directly.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
ensure_constitution_from_template(project_dir)
content = memory.read_text()
assert "{CORE_TEMPLATE}" not in content
assert "# Ensure Wrapper" in content
assert "[PROJECT_NAME]" in content

View File

@@ -586,6 +586,51 @@ class TestExpressions:
with pytest.raises(ValueError, match="unknown filter 'upper'"):
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
def test_filter_map_non_string_attr_raises(self):
# A non-string attribute (authoring mistake like `map(5)`) must raise a
# ValueError naming the problem, not leak the cryptic AttributeError
# from attr.split() that would escape the evaluator and crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"id": "a"}, {"id": "b"}]})
with pytest.raises(ValueError, match="map: expected a string attribute name"):
evaluate_expression("{{ inputs.rows | map(5) }}", ctx)
def test_filter_join_non_string_separator_raises(self):
# A non-string separator (authoring mistake like `join(5)`) must raise a
# ValueError, not leak the cryptic AttributeError from str.join.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"tags": ["a", "b"]})
with pytest.raises(ValueError, match="join: expected a string separator"):
evaluate_expression("{{ inputs.tags | join(5) }}", ctx)
def test_filter_contains_non_string_arg_on_string_raises(self):
# For a string value, `contains` requires a string argument: `x in y` on
# a string needs a string left operand. A non-string argument must raise
# a ValueError, not leak the cryptic TypeError that would crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"text": "hello"})
with pytest.raises(ValueError, match="contains: expected a string argument"):
evaluate_expression("{{ inputs.text | contains(5) }}", ctx)
def test_filter_contains_non_string_arg_on_list_ok(self):
# For a list value, membership of any element type is legitimate, so a
# non-string argument stays valid and is not rejected.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"nums": [1, 2, 5]})
assert evaluate_expression("{{ inputs.nums | contains(5) }}", ctx) is True
assert evaluate_expression("{{ inputs.nums | contains(9) }}", ctx) is False
def test_registered_filters_unaffected(self):
# Regression: all five registered filters keep working unchanged.
from specify_cli.workflows.expressions import evaluate_expression
@@ -2260,6 +2305,47 @@ class TestWhileStep:
assert result.output["condition_result"] is False
assert result.next_steps == []
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
"""A non-list ``steps`` reached at runtime must fail the step, not crash.
``validate`` rejects a non-list ``steps``, but the engine does not
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds
``next_steps`` straight into ``_execute_steps``, which iterates them as
step mappings. The while body only dispatches when the condition is
truthy, so a non-list ``steps`` reaches ``next_steps`` and would crash
the engine's step iteration on an unvalidated run. Mirrors the
if/switch/fan-out non-list handling.
"""
from specify_cli.workflows.steps.while_loop import WhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = WhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "retry", "condition": "true", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.FAILED
assert "'steps' must be a list of steps" in (result.error or "")
assert result.next_steps == []
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_ok_when_condition_false(self, bad_steps):
"""A false condition never dispatches the body, so a non-list ``steps``
stays benign — the step completes without touching ``next_steps``.
"""
from specify_cli.workflows.steps.while_loop import WhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = WhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "retry", "condition": "false", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.output["condition_result"] is False
assert result.next_steps == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.while_loop import WhileStep
@@ -2350,6 +2436,30 @@ class TestDoWhileStep:
assert result.next_steps == []
assert result.status.value == "completed"
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
"""A non-list ``steps`` must fail the step, not crash the run.
``validate`` rejects a non-list ``steps``, but the engine does not
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds
``next_steps`` straight into ``_execute_steps``, which iterates them as
step mappings. The do-while body always dispatches on the first call
regardless of condition, so a non-list ``steps`` always reaches
``next_steps`` and would crash the engine's step iteration on an
unvalidated run. Mirrors the if/switch/fan-out non-list handling.
"""
from specify_cli.workflows.steps.do_while import DoWhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = DoWhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "cycle", "condition": "false", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.FAILED
assert "'steps' must be a list of steps" in (result.error or "")
assert result.next_steps == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.do_while import DoWhileStep
@@ -2662,8 +2772,13 @@ class TestFanOutConcurrency:
results, _ = self._run(tmp_path, list(range(n)), n, on_item)
assert results == [{"seen": i} for i in range(n)]
@pytest.mark.parametrize("bad", [0, -1, None, "abc", 1.0])
@pytest.mark.parametrize(
"bad", [0, -1, None, "abc", 1.0, float("inf"), float("nan")]
)
def test_invalid_max_concurrency_coerces_to_sequential(self, tmp_path, bad):
# float("inf") -> int() raises OverflowError (not TypeError/ValueError);
# it must fall back to sequential like any other uncoercible value, not
# crash the run.
results, _ = self._run(tmp_path, list(range(4)), bad)
assert results == [{"seen": i} for i in range(4)]
@@ -4446,6 +4561,286 @@ steps:
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
# ===== context.workflow_dir Tests =====
class TestContextWorkflowDir:
"""Tests for `{{ context.workflow_dir }}` and `SPECKIT_WORKFLOW_DIR`."""
def test_context_workflow_dir_resolves(self):
"""``{{ context.workflow_dir }}`` resolves to ``StepContext.workflow_dir``."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/home/user/my-workflow")
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == "/home/user/my-workflow"
def test_context_workflow_dir_defaults_to_empty_when_unset(self):
"""``{{ context.workflow_dir }}`` resolves to ``""`` when no source
path is available (string-loaded workflows, dry-run).
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext()
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == ""
def test_context_workflow_dir_string_interpolation(self):
"""Workflow dir interpolates inside a larger template string."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/opt/workflows/setup")
result = evaluate_expression("cp {{ context.workflow_dir }}/config.yml .", ctx)
assert result == "cp /opt/workflows/setup/config.yml ."
def test_step_context_workflow_dir(self):
"""StepContext accepts and stores workflow_dir."""
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/some/path")
assert ctx.workflow_dir == "/some/path"
ctx_none = StepContext()
assert ctx_none.workflow_dir is None
def test_from_yaml_sets_workflow_dir(self, project_dir):
"""Workflow loaded from a YAML file has workflow_dir set to the
file's parent directory.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
wf_dir = project_dir / "my-workflows"
wf_dir.mkdir()
wf_file = wf_dir / "setup.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "from-yaml"
name: "From YAML"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
def test_from_string_has_empty_workflow_dir(self, project_dir):
"""String-loaded workflows have empty workflow_dir."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "from-string"
name: "From String"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == "DIR="
def test_shell_step_receives_speckit_workflow_dir_env_var(self, project_dir):
"""Shell steps receive SPECKIT_WORKFLOW_DIR in their environment."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import sys
wf_dir = project_dir / "wf"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
python = sys.executable.replace("\\", "/")
wf_file.write_text(f"""
schema_version: "1.0"
workflow:
id: "env-var-test"
name: "Env Var Test"
version: "1.0.0"
steps:
- id: print-env
type: shell
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["print-env"]["output"]["stdout"]
assert stdout.strip() == str(wf_dir.resolve())
def test_shell_step_no_env_var_when_workflow_dir_unset(self, project_dir, monkeypatch):
"""Shell steps do not set SPECKIT_WORKFLOW_DIR for string-loaded workflows."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import sys
monkeypatch.delenv("SPECKIT_WORKFLOW_DIR", raising=False)
python = sys.executable.replace("\\", "/")
definition = WorkflowDefinition.from_string(f"""
schema_version: "1.0"
workflow:
id: "no-env-var"
name: "No Env Var"
version: "1.0.0"
steps:
- id: check-env
type: shell
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-env"]["output"]["stdout"]
assert stdout.strip() == "UNSET"
def test_resume_preserves_original_workflow_dir(self, project_dir):
"""Resumed workflow uses the original source directory, not the
run-directory copy path.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
wf_dir = project_dir / "original-source"
wf_dir.mkdir()
wf_file = wf_dir / "resumable.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "resumable"
name: "Resumable"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
- id: after-gate
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
# Execute -- gate pauses the workflow
state = engine.execute(definition)
assert state.status == RunStatus.PAUSED
assert state.workflow_dir == str(wf_dir.resolve())
# Simulate gate approval by patching the gate step
from unittest.mock import patch
from specify_cli.workflows.base import StepResult
with patch(
"specify_cli.workflows.steps.gate.GateStep.execute",
return_value=StepResult(output={"approved": True}),
):
state = engine.resume(state.run_id)
assert state.status == RunStatus.COMPLETED
stdout = state.step_results["after-gate"]["output"]["stdout"]
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
def test_workflow_dir_persisted_in_state(self, project_dir):
"""workflow_dir is persisted in state.json and survives load/save."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, RunState
wf_dir = project_dir / "persist-test"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "persist-wfdir"
name: "Persist WfDir"
version: "1.0.0"
steps:
- id: noop
type: shell
run: "echo ok"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
# Reload state from disk and verify workflow_dir survived
loaded = RunState.load(state.run_id, project_dir)
assert loaded.workflow_dir == str(wf_dir.resolve())
def test_installed_workflow_has_workflow_dir(self, project_dir):
"""Installed-by-ID workflows get workflow_dir pointing to the
installation directory (.specify/workflows/<id>/).
"""
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows.base import RunStatus
wf_id = "installed-wfdir"
install_dir = project_dir / ".specify" / "workflows" / wf_id
install_dir.mkdir(parents=True)
(install_dir / "workflow.yml").write_text("""
schema_version: "1.0"
workflow:
id: "installed-wfdir"
name: "Installed WfDir"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
engine = WorkflowEngine(project_dir)
definition = engine.load_workflow(wf_id)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == f"DIR={install_dir.resolve()}"
def test_workflow_dir_is_resolved_to_absolute(self, project_dir):
"""workflow_dir is resolved to an absolute path even when the
source path is relative.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import os
wf_dir = project_dir / "rel-test"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "rel-path"
name: "Relative Path"
version: "1.0.0"
steps:
- id: check
type: shell
run: "echo ok"
""")
# Load via a relative path
saved_cwd = os.getcwd()
try:
os.chdir(project_dir)
rel_path = Path("rel-test/workflow.yml")
definition = WorkflowDefinition.from_yaml(rel_path)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
finally:
os.chdir(saved_cwd)
assert Path(state.workflow_dir).is_absolute()
assert state.workflow_dir == str(wf_dir.resolve())
# ===== continue_on_error Tests =====
#
# Locks the contract documented in workflows/README.md "Error Handling"

View File

@@ -348,6 +348,7 @@ current run:
| Variable | Description |
|----------|-------------|
| `context.run_id` | The current workflow run id (the same value Spec Kit prints as `Run ID:` at the end of `workflow run`). Auto-generated runs are 8-character hex from `uuid4`; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
| `context.workflow_dir` | The resolved absolute path to the directory containing the workflow source file. For file-loaded workflows this is the parent directory of the YAML file; for installed-by-ID workflows it is the absolute path to the installation directory (e.g. `<project>/.specify/workflows/<id>/`); for string-loaded workflows it is an empty string. On resume the original source directory is preserved from the first execution. |
```yaml
# Stamp telemetry events with the run id for cross-system join.
@@ -365,6 +366,11 @@ current run:
command: speckit.specify
input:
args: "{{ context.run_id }}"
# Reference a sibling file shipped alongside the workflow definition.
- id: apply-config
type: shell
run: 'cp "{{ context.workflow_dir }}/defaults.yml" ./config.yml'
```
## Input Types
@@ -445,6 +451,7 @@ specify workflow catalog remove <index>
| Variable | Description |
|----------|-------------|
| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
| `SPECKIT_WORKFLOW_DIR` | Set automatically for shell steps; contains the resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path (string-loaded workflows). |
## Configuration Files