Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
6137c3f046 chore: bump version to 0.8.14 2026-05-26 13:30:49 +00:00
40 changed files with 300 additions and 1462 deletions

View File

@@ -381,26 +381,25 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
## Branch Naming Convention
Branches follow one of two patterns depending on whether an issue exists:
All branches **must** follow this pattern:
```
<type>/<number>-<short-slug> # when an issue is created first
<type>/<short-slug> # when no issue exists (PR-only changes)
<type>/<number>-<short-slug>
```
When an issue exists, include its number immediately after the prefixthis is what makes branches traceable. For small or self-contained changes that go straight to a PR without a tracking issue, omit the number.
Where `<number>` is either an issue number or a PR numberwhichever is created first.
| Prefix | When to use | Example |
|---|---|---|
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention`, `docs/update-landing-stats` |
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention` |
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
**Rules:**
1. Include the issue number when one exists — this is what makes branches traceable
1. Always include the issue or PR number immediately after the prefix — this is what makes branches traceable
2. Use kebab-case for the slug
3. Keep the slug short — enough to identify the work without looking up the issue

View File

@@ -2,34 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.8.16] - 2026-05-27
### Changed
- docs: update landing page stats and branch naming convention (#2727)
- feat(workflows): expose {{ context.run_id }} template variable (#2664)
- fix: resolve __SPECKIT_COMMAND_*__ refs in preset skill rendering (#2717) (#2718)
- Add Workflow Preset to community catalog (#2725)
- fix: paths-only skips branch validation, setup-plan preserves existing plan (#2672)
- docs: fix broken pipx homepage URLs to point to pipx.pypa.io (#2670)
- Update Architecture Guard extension to v1.8.9 (#2723)
- Re-validate spec quality checklist after clarify updates spec (#2715)
- chore: release 0.8.15, begin 0.8.16.dev0 development (#2722)
## [0.8.15] - 2026-05-27
### Changed
- Update Fiction Book Writing preset to v1.8.1 (#2714)
- chore: update memorylint and superb to 1.4.0 (#2690)
- fix: promote post-execution hook dispatch to H2 with directive language (#2713)
- Add Token Budget extension to community catalog (#2712)
- fix: create skills directory on demand during extension/preset install (#2711)
- fix: PS 5.1 compat — replace non-ASCII chars in shipped PowerShell scripts (#2709)
- docs: update security-governance preset to v0.3.0 (#2676)
- Update README.md (#2675)
- chore: release 0.8.14, begin 0.8.15.dev0 development (#2706)
## [0.8.14] - 2026-05-26
### Changed

View File

@@ -35,7 +35,7 @@
- [🔧 Prerequisites](#-prerequisites)
- [📖 Learn More](#-learn-more)
- [📋 Detailed Process](#-detailed-process)
- [💬 Support](#-support)
- [ Support](#-support)
- [🙏 Acknowledgements](#-acknowledgements)
- [📄 License](#-license)
@@ -281,7 +281,7 @@ Our research and experimentation focus on:
- **Linux/macOS/Windows**
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
@@ -581,7 +581,7 @@ Once the implementation is complete, test the application and resolve any runtim
---
## 💬 Support
## Support
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.

View File

@@ -27,7 +27,7 @@ The following community-contributed extensions are available in [`catalog.commun
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
@@ -116,7 +116,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |

View File

@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
@@ -23,10 +23,9 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 23 templates, 7 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).

View File

@@ -43,7 +43,7 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm
### Make it your own
<span class="pillar-stat">105 community extensions</span> (60+ authors), <span class="pillar-stat">22 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
Including entirely different SDD processes:
@@ -82,7 +82,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
<div class="stats-grid">
<div class="stat-item">
<span class="stat-number">106K+</span>
<span class="stat-number">96K+</span>
<span class="stat-label">GitHub stars</span>
</div>
<div class="stat-item">
@@ -94,11 +94,11 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
<span class="stat-label">Integrations</span>
</div>
<div class="stat-item">
<span class="stat-number">105</span>
<span class="stat-number">91</span>
<span class="stat-label">Extensions</span>
</div>
<div class="stat-item">
<span class="stat-number">22</span>
<span class="stat-number">18</span>
<span class="stat-label">Presets</span>
</div>
<div class="stat-item">
@@ -150,5 +150,3 @@ specify init my-project --integration copilot
Ready to start? Follow the [Quick Start Guide](quickstart.md).
</div>
<p class="text-end small text-body-secondary">Last updated: May 27, 2026</p>

View File

@@ -1,6 +1,6 @@
# Installing with pipx
[pipx](https://pipx.pypa.io/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
[pipx](https://pypa.github.io/pipx/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
## Install Specify CLI

View File

@@ -4,7 +4,7 @@
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-26T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -240,10 +240,10 @@
"architecture-guard": {
"name": "Architecture Guard",
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
"author": "DyanGalih",
"version": "1.8.9",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip",
"version": "1.8.4",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
@@ -258,18 +258,17 @@
},
"tags": [
"architecture",
"spec-kit",
"review",
"refactor",
"workflow",
"governance",
"guardrails"
"drift-detection",
"refactor",
"monolithic",
"microservices"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-27T00:00:00Z"
"updated_at": "2026-05-11T14:58:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -1647,8 +1646,8 @@
"id": "memorylint",
"description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.",
"author": "RbBtSn0w",
"version": "1.4.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.4.0/memorylint.zip",
"version": "1.3.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip",
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint",
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md",
@@ -1658,8 +1657,8 @@
"speckit_version": ">=0.5.1"
},
"provides": {
"commands": 2,
"hooks": 3
"commands": 1,
"hooks": 1
},
"tags": [
"memory",
@@ -1672,7 +1671,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-05-24T01:06:49Z"
"updated_at": "2026-04-16T13:10:26Z"
},
"multi-model-review": {
"name": "Multi-Model Review",
@@ -2895,8 +2894,8 @@
"id": "superb",
"description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.",
"author": "rbbtsn0w",
"version": "1.4.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.4.0/superpowers-bridge.zip",
"version": "1.3.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip",
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions",
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md",
@@ -2914,7 +2913,7 @@
},
"provides": {
"commands": 8,
"hooks": 3
"hooks": 4
},
"tags": [
"methodology",
@@ -2931,7 +2930,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-30T00:00:00Z",
"updated_at": "2026-05-24T01:07:34Z"
"updated_at": "2026-04-16T14:08:23Z"
},
"superpowers-bridge": {
"name": "Superpowers Bridge",
@@ -3161,48 +3160,6 @@
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z"
},
"token-budget": {
"name": "Token Budget",
"id": "token-budget",
"description": "Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage.",
"author": "Tine Kondo",
"version": "1.0.1",
"download_url": "https://github.com/tinesoft/spec-kit-token-budget/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/tinesoft/spec-kit-token-budget",
"homepage": "https://github.com/tinesoft/spec-kit-token-budget",
"documentation": "https://github.com/tinesoft/spec-kit-token-budget/blob/main/README.md",
"changelog": "https://github.com/tinesoft/spec-kit-token-budget/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "python3",
"required": false
},
{
"name": "rtk",
"required": false
}
]
},
"provides": {
"commands": 4,
"hooks": 6
},
"tags": [
"tokens",
"budget",
"context",
"efficiency",
"cost-optimization"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-05-26T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",

View File

@@ -35,7 +35,7 @@ Replace the script to add project-specific Git initialization steps:
## Output
On success:
- `[OK] Git repository initialized`
- ` Git repository initialized`
## Graceful Degradation

View File

@@ -115,7 +115,7 @@ if (Test-Path $configFile) {
}
}
} else {
# No config file -- auto-commit disabled by default
# No config file auto-commit disabled by default
exit 0
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
# Extracted from scripts/powershell/common.ps1 contains only git-specific
# branch validation and detection logic.
function Test-HasGit {

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env pwsh
# Git extension: initialize-repo.ps1
# Initialize a Git repository with an initial commit.
# Customizable -- replace this script to add .gitignore templates,
# Customizable replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
$ErrorActionPreference = 'Stop'
@@ -66,4 +66,4 @@ try {
exit 1
}
Write-Host "[OK] Git repository initialized"
Write-Host " Git repository initialized"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-05T10:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -222,11 +222,11 @@
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.8.1",
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"version": "1.7.0",
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
@@ -234,8 +234,8 @@
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 25,
"commands": 33,
"templates": 22,
"commands": 27,
"scripts": 2
},
"tags": [
@@ -254,7 +254,7 @@
"language-support"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-05-24T08:00:00Z"
"updated_at": "2026-04-27T08:00:00Z"
},
"game-narrative-writing": {
"name": "Game Narrative Writing",
@@ -472,11 +472,11 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"version": "0.3.0",
"description": "Adds memory-safe-language preference, secure code generation, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
"version": "0.2.0",
"description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.3.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
"license": "MIT",
@@ -491,20 +491,11 @@
"security",
"governance",
"msl",
"ssdf",
"asvs",
"supply-chain",
"sbom",
"ai-sbom",
"vex",
"slsa",
"cwe-top-25",
"g7",
"bsi",
"cra"
"supply-chain"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-05-22T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",
@@ -581,34 +572,6 @@
"clarify",
"interactive"
]
},
"workflow-preset": {
"name": "Workflow Preset",
"id": "workflow-preset",
"version": "1.2.0",
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
"author": "bigsmartben",
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/archive/refs/tags/v1.2.0.zip",
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"templates": 23,
"commands": 7
},
"tags": [
"behavior",
"bdd",
"planning",
"implementation",
"handoff"
],
"created_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-27T00:00:00Z"
}
}
}

View File

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

View File

@@ -78,12 +78,13 @@ done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
# Get feature paths and validate branch
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# If paths-only mode, output paths and exit (no validation)
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
if $PATHS_ONLY; then
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
@@ -111,9 +112,6 @@ if $PATHS_ONLY; then
exit 0
fi
# Validate branch name
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2

View File

@@ -40,31 +40,15 @@ fi
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"
# Copy plan template if plan doesn't already exist
if [[ -f "$IMPL_PLAN" ]]; then
if $JSON_MODE; then
echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2
else
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
fi
# Copy plan template if it exists
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN"
else
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
if $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN" >&2
else
echo "Copied plan template to $IMPL_PLAN"
fi
else
if $JSON_MODE; then
echo "Warning: Plan template not found" >&2
else
echo "Warning: Plan template not found"
fi
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
echo "Warning: Plan template not found"
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
# Output results

View File

@@ -56,10 +56,14 @@ EXAMPLES:
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
# If paths-only mode, output paths and exit (no validation)
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
if ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
@@ -81,11 +85,6 @@ if ($PathsOnly) {
exit 0
}
# Validate branch name
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"

View File

@@ -336,10 +336,10 @@ function Get-FeaturePathsEnv {
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output " [OK] $Description"
Write-Output " $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
Write-Output " $Description"
return $false
}
}
@@ -347,10 +347,10 @@ function Test-FileExists {
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
Write-Output " [OK] $Description"
Write-Output " $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
Write-Output " $Description"
return $false
}
}
@@ -591,7 +591,7 @@ except Exception:
if ($layerPaths.Count -eq 0) { return $null }
# If the top (highest-priority) layer is replace, it wins entirely --
# If the top (highest-priority) layer is replace, it wins entirely
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)

View File

@@ -312,7 +312,7 @@ if (-not $DryRun) {
if ($AllowExistingBranch) {
# If we're already on the branch, continue without another checkout.
if ($currentBranch -eq $branchName) {
# Already on the target branch -- nothing to do
# Already on the target branch nothing to do
} else {
# Otherwise switch to the existing branch instead of failing.
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String

View File

@@ -33,25 +33,17 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if plan doesn't already exist
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
if ($Json) {
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
} else {
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
}
# Copy plan template if it exists, otherwise note it or create empty file
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
} else {
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
} else {
Write-Warning "Plan template not found"
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
Write-Warning "Plan template not found"
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
# Output results

View File

@@ -317,57 +317,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
return project_path / ".agents" / "skills"
def resolve_active_skills_dir(project_root: Path) -> Path | None:
"""Return the active skills directory, creating it on demand when enabled.
Reads ``.specify/init-options.json`` to determine whether skills are
enabled and which agent was selected. When ``ai_skills`` is true the
directory is created safely (symlink/containment checks); when false
only Kimi's native-skills fallback is honoured (directory must already
exist).
Returns:
The skills directory ``Path``, or ``None`` if skills are not active.
Raises:
ValueError: If the resolved skills path escapes the project root,
a parent component is a symlink, or a path component exists
but is not a directory.
OSError: If the directory cannot be created (e.g. permission denied).
"""
from .shared_infra import _ensure_safe_shared_directory
opts = load_init_options(project_root)
if not isinstance(opts, dict):
opts = {}
agent = opts.get("ai")
if not isinstance(agent, str) or not agent:
return None
ai_skills_enabled = bool(opts.get("ai_skills"))
if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = _get_skills_dir(project_root, agent)
if not ai_skills_enabled:
# Kimi native-skills fallback: use the directory only if it exists.
if not skills_dir.is_dir():
return None
_ensure_safe_shared_directory(
project_root, skills_dir,
create=False, context="agent skills directory",
)
return skills_dir
# ai_skills is explicitly enabled — create the directory safely.
_ensure_safe_shared_directory(
project_root, skills_dir, context="agent skills directory",
)
return skills_dir
def _cli_error_detail(exc: BaseException) -> str:
"""Return a compact one-line exception detail for CLI output."""
detail = str(exc).replace("\n", " ").strip()

View File

@@ -801,24 +801,38 @@ class ExtensionManager:
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for extension skill registration.
Delegates to :func:`resolve_active_skills_dir` which reads
init-options, applies the Kimi native-skills fallback, and
safely creates the directory when ``ai_skills`` is enabled.
Reads ``.specify/init-options.json`` to determine whether skills
are enabled and which agent was selected, then delegates to
the module-level ``_get_skills_dir()`` helper for the concrete path.
Returns ``None`` (instead of raising) when the directory cannot
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
``.kimi/skills`` exists, extension installs should still propagate
command skills even when ``ai_skills`` is false.
Returns:
The skills directory ``Path``, or ``None`` if skills were not
enabled and no native-skills fallback applies.
"""
from . import resolve_active_skills_dir, _print_cli_warning
try:
return resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve", "skills directory", None, exc,
continuing="Continuing without skill registration.",
)
from . import load_init_options, _get_skills_dir as resolve_skills_dir
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
opts = {}
agent = opts.get("ai")
if not isinstance(agent, str) or not agent:
return None
ai_skills_enabled = bool(opts.get("ai_skills"))
if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = resolve_skills_dir(self.project_root, agent)
if not skills_dir.is_dir():
return None
return skills_dir
def _register_extension_skills(
self,
manifest: ExtensionManifest,

View File

@@ -28,7 +28,6 @@ from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
def _substitute_core_template(
@@ -1059,9 +1058,6 @@ class PresetManager:
body = registrar.resolve_skill_placeholders(
selected_ai, fm, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
)
fm_data = registrar.build_skill_frontmatter(
selected_ai if isinstance(selected_ai, str) else "",
skill_name, desc,
@@ -1101,24 +1097,37 @@ class PresetManager:
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for preset skill overrides.
Delegates to :func:`resolve_active_skills_dir` which reads
init-options, applies the Kimi native-skills fallback, and
safely creates the directory when ``ai_skills`` is enabled.
Reads ``.specify/init-options.json`` to determine whether skills
are enabled and which agent was selected, then delegates to
the module-level ``_get_skills_dir()`` helper for the concrete path.
Returns ``None`` (instead of raising) when the directory cannot
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
``.kimi/skills`` exists, presets should still propagate command
overrides to skills even when ``ai_skills`` is false.
Returns:
The skills directory ``Path``, or ``None`` if skills were not
enabled and no native-skills fallback applies.
"""
from . import resolve_active_skills_dir, _print_cli_warning
try:
return resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve", "skills directory", None, exc,
continuing="Continuing without skill registration.",
)
from . import load_init_options, _get_skills_dir
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
opts = {}
agent = opts.get("ai")
if not isinstance(agent, str) or not agent:
return None
ai_skills_enabled = bool(opts.get("ai_skills"))
if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = _get_skills_dir(self.project_root, agent)
if not skills_dir.is_dir():
return None
return skills_dir
@staticmethod
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
"""Return the modern and legacy skill directory names for a command."""
@@ -1138,23 +1147,6 @@ class PresetManager:
title_name = title_name[len("speckit."):]
return title_name.replace(".", " ").replace("-", " ").title()
@staticmethod
def _resolve_skill_command_refs(
body: str, registrar: "CommandRegistrar", selected_ai: str
) -> str:
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
Looks up the agent's invoke separator and rewrites each
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
applies via ``CommandRegistrar.register_commands()``.
"""
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
"invoke_separator", "."
)
return IntegrationBase.resolve_command_refs(body, separator)
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"""Index extension-backed skill restore data by skill directory name."""
from .extensions import ExtensionManifest, ValidationError
@@ -1331,7 +1323,6 @@ class PresetManager:
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(body, registrar, selected_ai)
for target_skill_name in target_skill_names:
skill_subdir = skills_dir / target_skill_name
@@ -1424,9 +1415,6 @@ class PresetManager:
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
)
original_desc = frontmatter.get("description", "")
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
@@ -1464,9 +1452,6 @@ class PresetManager:
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
)
command_name = extension_restore["command_name"]
title_name = self._skill_title_from_command(command_name)

View File

@@ -88,13 +88,7 @@ def _shared_relative_path(project_path: Path, dest: Path) -> Path:
return rel
def _ensure_safe_shared_directory(
project_path: Path,
directory: Path,
*,
create: bool = True,
context: str = "shared infrastructure directory",
) -> None:
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
"""Create a shared infra directory without following symlinked parents."""
root = project_path.resolve()
rel = _shared_relative_path(project_path, directory)
@@ -104,24 +98,24 @@ def _ensure_safe_shared_directory(
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"{context.capitalize()} path is not a directory: {label}")
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
continue
if not create:
raise ValueError(f"{context.capitalize()} does not exist: {label}")
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
current.mkdir()
if current.is_symlink():
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:

View File

@@ -102,15 +102,6 @@ def _build_namespace(context: Any) -> dict[str, Any]:
ns["item"] = context.item
if hasattr(context, "fan_in"):
ns["fan_in"] = context.fan_in or {}
# Engine-managed runtime metadata. Always present (even outside a
# run) so templates referencing it never error: `run_id` falls back
# to an empty string when no run is active (dry-run, validation,
# ad-hoc evaluator usage). The value is the same one Spec Kit
# prints as `Run ID:` at the end of `workflow run` — auto-generated
# 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}
return ns

View File

@@ -197,24 +197,13 @@ Execution steps:
7. Write the updated spec back to `FEATURE_SPEC`.
8. **Re-validate Spec Quality Checklist** (if it exists):
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
- If it does NOT exist, skip this step silently.
- If it exists:
1. Read the checklist file.
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
5. For each checkbox item, update only if the checked/unchecked state actually changes:
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
- **Newly passing**: items that changed from unchecked to checked.
- **Regressions**: items that changed from checked to unchecked.
- **Still unchecked**: items that remain unchecked.
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
8. Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
- Suggested next command.
Behavior rules:
@@ -228,27 +217,17 @@ Behavior rules:
Context for prioritization: {ARGS}
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
## Post-Execution Checks
**Check for extension hooks (after clarification)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_clarify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
@@ -260,21 +239,12 @@ Check if `.specify/extensions.yml` exists in the project root.
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
## Completion Report
Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
- Suggested next command.
## Done When
- [ ] Spec ambiguities identified and clarifications integrated into spec file
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -168,49 +168,35 @@ You **MUST** consider the user input before proceeding (if not empty).
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
## Mandatory Post-Execution Hooks
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**You MUST complete this section before reporting completion to the user.**
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_implement` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report final status with summary of completed work.
## Done When
- [ ] All tasks in tasks.md completed and marked `[X]`
- [ ] Implementation validated against specification, plan, and test coverage
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with summary of completed work
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -70,42 +70,36 @@ You **MUST** consider the user input before proceeding (if not empty).
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
**You MUST complete this section before reporting completion to the user.**
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_plan` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Phases
@@ -156,9 +150,3 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
- ERROR on gate failures or unresolved clarifications
## Done When
- [ ] Plan workflow executed and design artifacts generated
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with branch, plan path, and generated artifacts

View File

@@ -183,7 +183,7 @@ Given that feature description, do this:
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
- **If all items pass**: Mark checklist complete and proceed to step 8
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
@@ -228,46 +228,40 @@ Given that feature description, do this:
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
## Mandatory Post-Execution Hooks
8. **Report completion** to the user with:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
**You MUST complete this section before reporting completion to the user.**
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_specify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report completion to the user with:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
@@ -331,9 +325,3 @@ Success criteria must be:
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)
## Done When
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with feature directory, spec file path, and checklist results

View File

@@ -89,48 +89,42 @@ You **MUST** consider the user input before proceeding (if not empty).
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
## Mandatory Post-Execution Hooks
5. **Report**: Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
**You MUST complete this section before reporting completion to the user.**
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
Context for task generation: {ARGS}
@@ -207,9 +201,3 @@ Every task MUST strictly follow this format:
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns
## Done When
- [ ] tasks.md generated with all phases, task IDs, and file paths
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with task count, story breakdown, and MVP scope

View File

@@ -335,11 +335,10 @@ class TestInitIntegrationFlag:
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
plain = strip_ansi(captured.out)
assert "already exist and were not updated" in plain
assert "specify init --here --force" in plain
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(plain.split())
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):

View File

@@ -7,7 +7,6 @@ import pytest
from typer.testing import CliRunner
from specify_cli import app
from tests.conftest import strip_ansi
runner = CliRunner()
@@ -50,8 +49,7 @@ def _write_invalid_manifest(project, key):
def _integration_list_row_cells(output: str, key: str) -> list[str]:
plain = strip_ansi(output)
row = next(line for line in plain.splitlines() if line.startswith(f"{key}"))
row = next(line for line in output.splitlines() if line.startswith(f"{key}"))
return [cell.strip() for cell in row.split("")[1:-1]]
@@ -162,9 +160,8 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
plain = strip_ansi(result.output)
assert "already installed" in plain
normalized = " ".join(plain.split())
assert "already installed" in result.output
normalized = " ".join(result.output.split())
assert "specify integration upgrade copilot" in normalized
assert "already the default integration" in normalized
assert "No files were changed" in normalized
@@ -200,10 +197,9 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "Default integration: copilot" in plain
normalized = " ".join(plain.split())
assert "Installed integrations: copilot" in result.output
assert "Default integration: copilot" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
@@ -313,10 +309,9 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "multi-install safe" in plain
normalized = " ".join(plain.split())
assert "Installed integrations: copilot" in result.output
assert "multi-install safe" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized

View File

@@ -1,205 +0,0 @@
"""Tests for check-prerequisites --paths-only skipping branch validation (#2653)."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(CHECK_PREREQS_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ── Bash tests ────────────────────────────────────────────────────────────
@requires_bash
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must return paths without branch validation (main branch)."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "REPO_ROOT" in data
assert "BRANCH" in data
assert "FEATURE_DIR" in data
@requires_bash
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must also work on a properly named spec branch."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo,
check=True,
)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "FEATURE_DIR" in data
assert "001-my-feature" in data.get("BRANCH", "")
@requires_bash
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only without --json must return text paths on a non-spec branch."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert "REPO_ROOT:" in result.stdout
assert "FEATURE_DIR:" in result.stdout
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, branch validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr
# ── PowerShell tests ──────────────────────────────────────────────────────
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must return paths without branch validation (main branch)."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "REPO_ROOT" in data
assert "BRANCH" in data
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must also work on a properly named spec branch."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo,
check=True,
)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, branch validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr

View File

@@ -173,32 +173,24 @@ class TestExtensionManagerGetSkillsDir:
assert result == skills_dir
def test_returns_none_when_no_ai_skills(self, no_skills_project):
"""Should return None when ai_skills is false and not create the dir."""
"""Should return None when ai_skills is false."""
manager = ExtensionManager(no_skills_project)
result = manager._get_skills_dir()
assert result is None
# Ensure the directory was NOT created on disk
from specify_cli import _get_skills_dir as resolve_skills_dir
skills_path = resolve_skills_dir(no_skills_project, "claude")
assert not skills_path.exists()
def test_returns_none_when_no_init_options(self, project_dir):
"""Should return None when init-options.json is missing and not create any dir."""
"""Should return None when init-options.json is missing."""
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
assert result is None
# No agent skills directory should have been created
assert not (project_dir / ".claude" / "skills").exists()
assert not (project_dir / ".agents" / "skills").exists()
def test_creates_skills_dir_on_demand(self, project_dir):
"""Should create skills dir when ai_skills is enabled but dir is missing."""
def test_returns_none_when_skills_dir_missing(self, project_dir):
"""Should return None when skills dir doesn't exist on disk."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
# Don't create the skills directory — _get_skills_dir should do it
# Don't create the skills directory
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
assert result is not None
assert result.is_dir()
assert result is None
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
"""Kimi should still use its native skills dir when ai_skills is false."""
@@ -468,38 +460,6 @@ class TestExtensionSkillRegistration:
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
@pytest.mark.parametrize("ai", ["claude", "codex"])
def test_skills_registered_when_dir_missing(self, project_dir, temp_dir, ai):
"""Extension add should create skills dir on demand and register skills.
Regression test for https://github.com/github/spec-kit/issues/2682:
when an extension is installed before the agent skills directory exists,
skills must still be materialized (the directory is created on demand).
"""
_create_init_options(project_dir, ai=ai, ai_skills=True)
# Deliberately do NOT create the skills directory
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
# Skills dir should have been created automatically
from specify_cli import _get_skills_dir as resolve_skills_dir
skills_dir = resolve_skills_dir(project_dir, ai)
assert skills_dir.is_dir()
# SKILL.md files should exist
assert (skills_dir / "speckit-early-ext-hello" / "SKILL.md").exists()
assert (skills_dir / "speckit-early-ext-world" / "SKILL.md").exists()
# Registry should record them
metadata = manager.registry.get(manifest.id)
assert len(metadata["registered_skills"]) == 2
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
# ===== Extension Skill Unregistration Tests =====

View File

@@ -2346,154 +2346,6 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_register_skills_resolves_command_refs(self, project_dir, temp_dir):
"""Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717).
``_register_skills()`` previously ran only ``resolve_skill_placeholders()``,
so command cross-references leaked into SKILL.md as raw placeholders
instead of rendering as ``/speckit-<cmd>`` like the command layer.
"""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
preset_dir = self._create_command_preset(
temp_dir,
"cmdref-install",
"speckit.specify",
"Override specify",
"Run `__SPECKIT_COMMAND_SPECIFY__` then `__SPECKIT_COMMAND_PLAN__`.\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked into SKILL.md"
# Claude's invoke_separator is "-", so tokens render as /speckit-<cmd>.
assert "/speckit-specify" in content
assert "/speckit-plan" in content
def test_restore_skill_resolves_command_refs(self, project_dir, temp_dir):
"""Skill restore on preset removal must also resolve command tokens (issue #2717)."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text(
"---\ndescription: Core specify\n---\n\n"
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
)
preset_dir = self._create_command_preset(
temp_dir,
"cmdref-restore",
"speckit.specify",
"Override specify",
"Override body\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
manager.remove("cmdref-restore")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore"
assert "/speckit-plan" in content
def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir):
"""Reconcile's project-override restore must resolve command tokens (issue #2717).
When a preset that overrode a command is removed and a project override
becomes the winning layer, ``_reconcile_skills`` rewrites the skill from
the override body — which must also render ``__SPECKIT_COMMAND_*__`` tokens.
"""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
# Project override wins once the preset is removed; its body carries a
# command cross-reference token. No core template exists for "specify",
# so the skill is restored exclusively via the reconcile override branch.
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
(overrides_dir / "speckit.specify.md").write_text(
"---\ndescription: Override specify\n---\n\n"
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
)
preset_dir = self._create_command_preset(
temp_dir,
"cmdref-reconcile",
"speckit.specify",
"Preset specify",
"Preset body\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
manager.remove("cmdref-reconcile")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "override:speckit.specify" in content, "skill should be restored from the project override"
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on reconcile"
assert "/speckit-plan" in content
def test_extension_restore_resolves_command_refs(self, project_dir, temp_dir):
"""Extension-backed skill restore must resolve command tokens (issue #2717).
When a preset override is removed and the skill is restored from an
extension command body, ``__SPECKIT_COMMAND_*__`` tokens in that body
must render as slash-command invocations like the core-template path.
"""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = self._create_command_preset(
temp_dir,
"cmdref-ext-restore",
"speckit.fakeext.cmd",
"Override fakeext cmd",
"Override body\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
manager.remove("cmdref-ext-restore")
content = (skills_dir / "speckit-fakeext-cmd" / "SKILL.md").read_text()
assert "source: extension:fakeext" in content, "skill should be restored from the extension"
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on extension restore"
assert "/speckit-plan" in content
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
self._write_init_options(project_dir, ai="claude")

View File

@@ -1,54 +0,0 @@
"""Regression tests for PowerShell 5.1 compatibility (GitHub issue #2680).
PowerShell 5.1 (built-in on Windows) defaults to the system's legacy encoding
when reading .ps1 files. Non-ASCII characters in UTF-8-encoded scripts cause
parse errors because multi-byte sequences are misinterpreted as individual bytes.
These tests ensure that all shipped .ps1 files remain ASCII-only so they work
on both PowerShell 5.1 and 7+.
"""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
# All directories that contain shipped PowerShell scripts.
_PS1_DIRS = [
REPO_ROOT / "scripts" / "powershell",
REPO_ROOT / "extensions" / "git" / "scripts" / "powershell",
]
def _collect_ps1_files():
"""Yield all .ps1 files under the known script directories."""
for d in _PS1_DIRS:
if d.is_dir():
yield from sorted(d.rglob("*.ps1"))
_PS1_FILES = list(_collect_ps1_files())
@pytest.mark.parametrize("ps1_file", _PS1_FILES, ids=lambda p: str(p.relative_to(REPO_ROOT)))
def test_ps1_file_is_ascii_only(ps1_file: Path):
"""Every .ps1 file must contain only ASCII characters (PS 5.1 compat)."""
content = ps1_file.read_bytes()
non_ascii = [
(i + 1, byte)
for i, byte in enumerate(content)
if byte > 127
]
assert not non_ascii, (
f"{ps1_file.relative_to(REPO_ROOT)} contains non-ASCII bytes "
f"(PowerShell 5.1 incompatible): "
f"first at byte offset {non_ascii[0][0]} (0x{non_ascii[0][1]:02x})"
)
def test_ps1_files_discovered():
"""Sanity check: at least the known script files are found."""
names = {p.name for p in _PS1_FILES}
assert "common.ps1" in names
assert "initialize-repo.ps1" in names

View File

@@ -1,216 +0,0 @@
"""Tests for setup-plan preserving existing plan.md (#2653)."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_PLAN_SH, d / "setup-plan.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_PLAN_PS, d / "setup-plan.ps1")
def _minimal_templates(repo: Path) -> None:
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def plan_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo,
check=True,
)
(repo / ".specify").mkdir()
_minimal_templates(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ── Bash tests ────────────────────────────────────────────────────────────
@requires_bash
def test_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
"""First run must create plan.md from the template."""
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
plan_path = Path(data["IMPL_PLAN"])
assert plan_path.is_file()
# Template content should be present
content = plan_path.read_text(encoding="utf-8")
assert len(content) > 0
@requires_bash
def test_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
"""Rerun must not overwrite an existing plan.md."""
feat = plan_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n"
(feat / "plan.md").write_text(existing_content, encoding="utf-8")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
# Plan must be unchanged
assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content
@requires_bash
def test_setup_plan_skip_message_on_stderr_in_json_mode(plan_repo: Path) -> None:
"""In --json mode, status messages must go to stderr, not stdout."""
feat = plan_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# existing\n", encoding="utf-8")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
# stdout must be valid JSON (no status messages mixed in)
data = json.loads(result.stdout)
assert "IMPL_PLAN" in data
# The skip message should be on stderr
assert "already exists" in result.stderr
@requires_bash
def test_setup_plan_json_parseable_on_first_run(plan_repo: Path) -> None:
"""In --json mode, first-run stdout must be parseable JSON (no status on stdout)."""
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "IMPL_PLAN" in data
assert "Copied plan template" in result.stderr
# ── PowerShell tests ──────────────────────────────────────────────────────
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
"""First run must create plan.md from the template."""
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
plan_path = Path(data["IMPL_PLAN"])
assert plan_path.is_file()
content = plan_path.read_text(encoding="utf-8")
assert len(content) > 0
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
"""Rerun must not overwrite an existing plan.md."""
feat = plan_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n"
(feat / "plan.md").write_text(existing_content, encoding="utf-8")
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content
# stdout must be valid JSON (no status messages mixed in)
data = json.loads(result.stdout)
assert "IMPL_PLAN" in data
# The skip message should be on stderr
assert "already exists" in result.stderr

View File

@@ -333,44 +333,6 @@ class TestExpressions:
result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx)
assert result == "a.md"
def test_context_run_id_resolves(self):
"""``{{ context.run_id }}`` resolves to ``StepContext.run_id``.
Locks the contract from issue #2590: workflow templates can
reference the engine-assigned run id for telemetry, artifact
metadata, or per-run scratch isolation.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(run_id="a1b2c3d4")
assert evaluate_expression("{{ context.run_id }}", ctx) == "a1b2c3d4"
def test_context_run_id_defaults_to_empty_when_unset(self):
"""``{{ context.run_id }}`` resolves to ``""`` when no run is
active (dry-run, validation, ad-hoc evaluator usage) rather
than raising — workflows referencing the variable never error
outside a run context.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# No run_id set on the context.
ctx = StepContext()
assert evaluate_expression("{{ context.run_id }}", ctx) == ""
def test_context_run_id_string_interpolation(self):
"""Run id interpolates inside a larger template string — the
common pattern for stamping shell commands and artifact paths
with the run id.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(run_id="deadbeef")
result = evaluate_expression("RUN_ID={{ context.run_id }}", ctx)
assert result == "RUN_ID=deadbeef"
# ===== Integration Dispatch Tests =====
@@ -2192,147 +2154,6 @@ steps:
assert "retry-loop:step-b:2" in state.step_results
# ===== context.run_id Tests =====
#
# End-to-end coverage for the `{{ context.run_id }}` template
# variable introduced in issue #2590. Locks resolution inside the
# three step types the acceptance criteria called out — shell `run:`,
# command `input.args:`, and switch `expression:` — plus the
# "workflow doesn't reference it" backward-compat path.
class TestContextRunId:
"""End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
def test_shell_run_resolves_run_id(self, project_dir):
"""`run: "echo {{ context.run_id }}"` substitutes the
engine-assigned run id into the spawned shell, and the
same value appears on `state.run_id`.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "stamp-run-id"
name: "Stamp Run Id"
version: "1.0.0"
steps:
- id: stamp
type: shell
run: "echo RUN_ID={{ context.run_id }}"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition, run_id="abc12345")
assert state.run_id == "abc12345"
stdout = state.step_results["stamp"]["output"]["stdout"]
assert stdout.strip() == "RUN_ID=abc12345"
def test_command_input_args_resolves_run_id(self, project_dir):
"""`input.args: "{{ context.run_id }}"` is resolved by
`CommandStep` and recorded in step output, even when CLI
dispatch is unavailable (no integration installed). Covers
the artifact-metadata use case from the issue.
"""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "command-stamp"
name: "Command Stamp"
version: "1.0.0"
integration: claude
steps:
- id: tag-artifact
command: speckit.specify
input:
args: "{{ context.run_id }}"
""")
engine = WorkflowEngine(project_dir)
with patch(
"specify_cli.workflows.steps.command.shutil.which",
return_value=None,
):
state = engine.execute(definition, run_id="cafef00d")
# Even when dispatch fails (no CLI), the resolved input is
# recorded so downstream observers see the run id in artifact
# metadata.
assert state.step_results["tag-artifact"]["output"]["input"]["args"] == "cafef00d"
def test_switch_expression_matches_on_run_id(self, project_dir):
"""`switch` over `{{ context.run_id }}` matches against case
keys, and the nested branch can ALSO reference
`{{ context.run_id }}`. Demonstrates the run id is a
first-class value in the expression engine (not just a
string-interpolation token) AND that it propagates into
nested step execution via the recursive `_execute_steps`
traversal.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "switch-on-run-id"
name: "Switch On Run Id"
version: "1.0.0"
steps:
- id: route
type: switch
expression: "{{ context.run_id }}"
cases:
target-run:
- id: matched-branch
type: shell
run: "echo nested-run-id={{ context.run_id }}"
default:
- id: default-branch
type: shell
run: "echo defaulted"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition, run_id="target-run")
assert state.status == RunStatus.COMPLETED
assert state.step_results["route"]["output"]["matched_case"] == "target-run"
assert "matched-branch" in state.step_results
assert "default-branch" not in state.step_results
# The nested branch sees the same run id — propagation through
# recursive `_execute_steps` is intact.
nested_stdout = state.step_results["matched-branch"]["output"]["stdout"]
assert nested_stdout.strip() == "nested-run-id=target-run"
def test_workflow_without_context_reference_unchanged(self, project_dir):
"""Workflows that do not reference `{{ context.run_id }}`
continue to run exactly as before. Locks the byte-equivalent
default required by the issue's acceptance criteria.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "no-context-ref"
name: "No Context Ref"
version: "1.0.0"
steps:
- id: only-step
type: shell
run: "echo hello"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello"
# ===== State Persistence Tests =====
class TestRunState:

View File

@@ -239,33 +239,6 @@ message: "{{ status | default('pending') }}"
Supported filters: `default`, `join`, `contains`, `map`.
### Runtime Context
`{{ context.* }}` exposes engine-managed runtime metadata for the
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. |
```yaml
# Stamp telemetry events with the run id for cross-system join.
- id: emit-event
type: shell
run: 'echo "{\"run_id\":\"{{ context.run_id }}\",\"event\":\"started\"}" >> events.jsonl'
# Per-run scratch directory.
- id: prep-scratch
type: shell
run: 'mkdir -p /tmp/run-{{ context.run_id }}'
# Pass run id into a command for artifact metadata.
- id: tag-artifact
command: speckit.specify
input:
args: "{{ context.run_id }}"
```
## Input Types
Workflow inputs are type-checked and coerced from CLI string values: