mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904bd3f99f |
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/bug-assess.lock.yml
generated
vendored
8
.github/workflows/bug-assess.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1277,7 +1277,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/release-trigger.yml
vendored
2
.github/workflows/release-trigger.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -423,37 +423,15 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
|
||||
---
|
||||
|
||||
## Agent Disclosure for PRs, Comments, and Commits
|
||||
|
||||
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
|
||||
|
||||
### Commits
|
||||
|
||||
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
|
||||
|
||||
```
|
||||
Assisted-by: GitHub Copilot (model: <name-if-known>, autonomous)
|
||||
```
|
||||
|
||||
Use `supervised` instead of `autonomous` only when a human actually authored or line-by-line reviewed the change before it was committed.
|
||||
- **Never push solo-authored commits that hide agent authorship behind the operator's git identity.** If an agent generated the change, the trailer must say so even when the commit is attributed to a human account.
|
||||
- Preserve any tool-generated `Co-authored-by:` trailers (e.g. Copilot Autofix) — do not strip them to make a commit look hand-written.
|
||||
|
||||
### Comments
|
||||
## Responding to PR Review Comments
|
||||
|
||||
- If you are an agent working on behalf of a human, **disclose your identity in your PR comment** — name the agent (and model, if applicable) and the human you are acting for (e.g., "Posted on behalf of @user by GitHub Copilot (model: <name-if-known>)").
|
||||
- **Re-state agent identity in each review-round summary comment.** A prior PR-body disclosure does not cover later comments or commits.
|
||||
- Post **one** top-level summary comment per review round listing what changed and the commit SHA. Do not reply on every individual comment.
|
||||
- Reply inline only when context is needed (disagreement, deferral, non-obvious fix). Keep it to a sentence or two.
|
||||
- **Never click "Resolve conversation"** — that belongs to the reviewer or PR author.
|
||||
- No emoji, no celebratory framing, no checklist mirroring the reviewer's items, no restating what the reviewer wrote.
|
||||
- Re-request review once per round (when all feedback is addressed), not after every intermediate push.
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- **Do not** reply "Done" or push a "fix" within seconds/minutes of a review event without disclosing that the response or commit was agent-generated. Speed of turnaround is not a substitute for attestation — a near-instant tested code change is itself a signal of automation and must be disclosed as such.
|
||||
- **Do not** claim "reviewed, tested, and understood by me" for commits that were authored and pushed automatically in response to a review trigger. If the loop is automated, disclose it as automated.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,23 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.11.3] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: strengthen agent disclosure to cover commits and per-round comments (#3071)
|
||||
- fix: isolate per-extension failures so one bad extension can't drop the rest (#2951)
|
||||
- fix(taskstoissues): skip tasks that already have a GitHub issue (#2992)
|
||||
- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
|
||||
- Update Multi-Model Review extension to v0.1.2 (#3066)
|
||||
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#3064)
|
||||
- feat(claude): run /analyze in a forked subagent (#2511)
|
||||
- fix: count worktree branches in git extension numbering (#3054)
|
||||
- Add Token Economy extension to community catalog (#3049)
|
||||
- chore: release 0.11.2, begin 0.11.3.dev0 development (#3059)
|
||||
|
||||
- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
|
||||
|
||||
## [0.11.2] - 2026-06-18
|
||||
|
||||
### Changed
|
||||
@@ -1840,3 +1823,4 @@
|
||||
### Changed
|
||||
|
||||
- Update release.yml
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| 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) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
|
||||
@@ -50,12 +50,8 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. |
|
||||
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
|
||||
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
|
||||
|
||||
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
|
||||
|
||||
## Check Installed Tools
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-18T00:00:00Z",
|
||||
"updated_at": "2026-06-17T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -2063,8 +2063,8 @@
|
||||
"id": "multi-model-review",
|
||||
"description": "Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review.",
|
||||
"author": "formin",
|
||||
"version": "0.1.2",
|
||||
"download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.2.zip",
|
||||
"version": "0.1.1",
|
||||
"download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.1.zip",
|
||||
"repository": "https://github.com/formin/multi-model-review",
|
||||
"homepage": "https://github.com/formin/multi-model-review",
|
||||
"documentation": "https://github.com/formin/multi-model-review/blob/main/README.md",
|
||||
@@ -2108,7 +2108,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-04T02:51:52Z",
|
||||
"updated_at": "2026-06-18T00:00:00Z"
|
||||
"updated_at": "2026-06-09T00:00:00Z"
|
||||
},
|
||||
"multi-sites": {
|
||||
"name": "Multi-Sites Spec Kit",
|
||||
@@ -3798,46 +3798,6 @@
|
||||
"created_at": "2026-05-26T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
},
|
||||
"token-economy": {
|
||||
"name": "Token Economy",
|
||||
"id": "token-economy",
|
||||
"description": "Token routing, measured savings, and context audit workflows.",
|
||||
"author": "formin",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/formin/spec-kit-token-economy/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/formin/spec-kit-token-economy",
|
||||
"homepage": "https://github.com/formin/spec-kit-token-economy",
|
||||
"documentation": "https://github.com/formin/spec-kit-token-economy/blob/main/README.md",
|
||||
"changelog": "https://github.com/formin/spec-kit-token-economy/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0",
|
||||
"tools": [
|
||||
{ "name": "rtk", "required": false },
|
||||
{ "name": "headroom", "required": false },
|
||||
{ "name": "token-router", "required": false },
|
||||
{ "name": "ollama", "required": false },
|
||||
{ "name": "python", "version": ">=3.10", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"tokens",
|
||||
"routing",
|
||||
"reporting",
|
||||
"context"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-17T00:00:00Z",
|
||||
"updated_at": "2026-06-17T00:00:00Z"
|
||||
},
|
||||
"trace": {
|
||||
"name": "Spec Trace",
|
||||
"id": "trace",
|
||||
|
||||
@@ -127,7 +127,7 @@ get_highest_from_specs() {
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
@@ -235,19 +235,9 @@ if [ "$_common_loaded" != "true" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
|
||||
# minimal git-common.sh was loaded, or an older core common.sh without the
|
||||
# resolver was loaded, refuse rather than silently falling back to the wrong root.
|
||||
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
|
||||
echo "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.sh with resolve_specify_init_dir), which were not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve repository root. When the core scripts are present, get_repo_root
|
||||
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
|
||||
# CI use) and hard-fails on an invalid value with no silent fallback.
|
||||
# Resolve repository root
|
||||
if type get_repo_root >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(get_repo_root) || exit 1
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
elif [ -n "$_PROJECT_ROOT" ]; then
|
||||
|
||||
@@ -88,7 +88,7 @@ function Get-HighestNumberFromBranches {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
@@ -197,16 +197,7 @@ if (-not $commonLoaded) {
|
||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
||||
}
|
||||
|
||||
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
|
||||
# minimal git-common.ps1 was loaded, or an older core common.ps1 without the
|
||||
# resolver was loaded, refuse rather than silently falling back to the wrong root.
|
||||
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
|
||||
throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found."
|
||||
}
|
||||
|
||||
# Resolve repository root. When the core scripts are present, Get-RepoRoot
|
||||
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
|
||||
# CI use) and hard-fails on an invalid value with no silent fallback.
|
||||
# Resolve repository root
|
||||
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
||||
$repoRoot = Get-RepoRoot
|
||||
} elseif ($projectRoot) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.11.3"
|
||||
version = "0.11.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -24,42 +24,9 @@ find_specify_root() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
||||
# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
|
||||
# project root, or prints an error and returns 1. Strict by design: the path
|
||||
# must exist and contain .specify/, with no silent fallback to cwd or the
|
||||
# script-location default (which would silently write to the wrong project).
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
resolve_specify_init_dir() {
|
||||
local init_root
|
||||
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
|
||||
# CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
|
||||
# (which would also echo to stdout and corrupt the captured path).
|
||||
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
|
||||
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$init_root/.specify" ]]; then
|
||||
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$init_root"
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# Explicit project override wins (see resolve_specify_init_dir).
|
||||
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
|
||||
resolve_specify_init_dir
|
||||
return
|
||||
fi
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
if specify_root=$(find_specify_root); then
|
||||
@@ -152,12 +119,8 @@ _persist_feature_json() {
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
|
||||
# get_repo_root propagates as a hard error instead of being masked by `local`.
|
||||
local repo_root
|
||||
repo_root=$(get_repo_root) || return 1
|
||||
local current_branch
|
||||
current_branch=$(get_current_branch)
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
|
||||
@@ -123,7 +123,7 @@ clean_branch_name() {
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root) || exit 1
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
|
||||
@@ -24,51 +24,9 @@ function Find-SpecifyRoot {
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
||||
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1. Strict by design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
$initDir = Join-Path (Get-Location).Path $initDir
|
||||
}
|
||||
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
|
||||
# Resolve-Path also succeeds for files, so check the resolved path is a
|
||||
# directory; otherwise a file value would slip through to the less accurate
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
# the returned root matches the bash resolver, whose `cd && pwd` never yields
|
||||
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
|
||||
# that already has no trailing separator.
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
if ($specifyRoot) {
|
||||
|
||||
@@ -1716,73 +1716,37 @@ class ExtensionManager:
|
||||
continue
|
||||
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
# Isolate per-extension failures: one extension that fails to
|
||||
# register (e.g. an OSError writing a command file) must not abort
|
||||
# registration of the remaining enabled extensions for this agent.
|
||||
try:
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
if agent_config and not skills_mode_active:
|
||||
registered = registrar.register_commands_for_agent(
|
||||
agent_name, manifest, ext_dir, self.project_root
|
||||
)
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
registered_commands = {}
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
if registered:
|
||||
new_registered[agent_name] = registered
|
||||
else:
|
||||
# Registration returned empty list (e.g., corrupted
|
||||
# manifest pointing at missing command files). Clear
|
||||
# stale entry so later cleanup doesn't try to remove
|
||||
# files that were never written.
|
||||
new_registered.pop(agent_name, None)
|
||||
if new_registered != registered_commands:
|
||||
updates["registered_commands"] = new_registered
|
||||
|
||||
try:
|
||||
registered_skills = self._register_extension_skills(manifest, ext_dir)
|
||||
except Exception as skills_err:
|
||||
# Skills are a companion artifact. If command registration
|
||||
# already succeeded, still persist it so later cleanup can
|
||||
# find those command files.
|
||||
from . import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"register extension skills for",
|
||||
"extension",
|
||||
ext_id,
|
||||
skills_err,
|
||||
continuing=(
|
||||
"Continuing with available registration results for this "
|
||||
"extension and the remaining extensions."
|
||||
),
|
||||
)
|
||||
else:
|
||||
if registered_skills:
|
||||
existing_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
|
||||
updates["registered_skills"] = merged_skills
|
||||
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
except Exception as ext_err:
|
||||
# Best-effort per extension: warn and move on so a single bad
|
||||
# extension cannot silently drop the others. See #2950.
|
||||
from . import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"register extension artifacts for",
|
||||
"extension",
|
||||
ext_id,
|
||||
ext_err,
|
||||
continuing="Continuing with the remaining extensions.",
|
||||
if agent_config and not skills_mode_active:
|
||||
registered = registrar.register_commands_for_agent(
|
||||
agent_name, manifest, ext_dir, self.project_root
|
||||
)
|
||||
continue
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
registered_commands = {}
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
if registered:
|
||||
new_registered[agent_name] = registered
|
||||
else:
|
||||
# Registration returned empty list (e.g., corrupted
|
||||
# manifest pointing at missing command files). Clear
|
||||
# stale entry so later cleanup doesn't try to remove
|
||||
# files that were never written.
|
||||
new_registered.pop(agent_name, None)
|
||||
if new_registered != registered_commands:
|
||||
updates["registered_commands"] = new_registered
|
||||
|
||||
registered_skills = self._register_extension_skills(manifest, ext_dir)
|
||||
if registered_skills:
|
||||
existing_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
|
||||
updates["registered_skills"] = merged_skills
|
||||
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
"""List all installed extensions with metadata.
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
from ..._utils import dump_frontmatter
|
||||
|
||||
# Mapping of command template stem → argument-hint text shown inline
|
||||
@@ -21,15 +23,6 @@ ARGUMENT_HINTS: dict[str, str] = {
|
||||
"taskstoissues": "Optional filter or label for GitHub issues",
|
||||
}
|
||||
|
||||
# Per-command frontmatter overrides for skills that should run in a forked
|
||||
# subagent context. Read-only analysis commands are good candidates: the
|
||||
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
|
||||
# so isolating them keeps the main conversation context clean.
|
||||
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
|
||||
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
|
||||
"analyze": {"context": "fork", "agent": "general-purpose"},
|
||||
}
|
||||
|
||||
|
||||
class ClaudeIntegration(SkillsIntegration):
|
||||
"""Integration for Claude Code skills."""
|
||||
@@ -155,47 +148,50 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _skill_stem_from_content(content: str) -> str | None:
|
||||
"""Derive the command stem (e.g. ``analyze``) from a skill's frontmatter.
|
||||
|
||||
Reads the ``name:`` field of the first frontmatter block and strips
|
||||
the ``speckit-`` prefix. Returns ``None`` when no name is present.
|
||||
"""
|
||||
dash_count = 0
|
||||
for line in content.splitlines():
|
||||
stripped = line.rstrip("\r\n")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith("name:"):
|
||||
name = stripped[len("name:"):].strip().strip('"').strip("'")
|
||||
if name.startswith("speckit-"):
|
||||
return name[len("speckit-"):]
|
||||
return name or None
|
||||
return None
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Claude-specific frontmatter flags, hook notes, and any
|
||||
per-command frontmatter.
|
||||
|
||||
Applied by every skill-generation path (setup, presets, extensions),
|
||||
so command-specific frontmatter (argument-hint, fork context) stays
|
||||
consistent however the SKILL.md was produced.
|
||||
"""
|
||||
"""Inject Claude-specific frontmatter flags and hook notes."""
|
||||
updated = super().post_process_skill_content(content)
|
||||
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
return updated
|
||||
|
||||
stem = self._skill_stem_from_content(updated)
|
||||
if stem:
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills, then inject argument-hints."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
# Only touch SKILL.md files under the skills directory
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = content
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
stem = skill_dir_name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
hint = ARGUMENT_HINTS.get(stem, "")
|
||||
if hint:
|
||||
updated = self.inject_argument_hint(updated, hint)
|
||||
fork_config = FORK_CONTEXT_COMMANDS.get(stem)
|
||||
if fork_config:
|
||||
for key, value in fork_config.items():
|
||||
updated = self._inject_frontmatter_flag(updated, key, value)
|
||||
return updated
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_write']
|
||||
tools: ['github/github-mcp-server/issue_write']
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
@@ -62,10 +62,7 @@ git config --get remote.origin.url
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by three digits, e.g. `T001`). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3}\b` (word boundaries so tokens like `ST001` or `T0010` are not matched by mistake; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
|
||||
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
|
||||
- Only create issues for tasks that do not yet have a matching issue.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
@@ -89,17 +89,6 @@ def _write_config(project: Path, content: str) -> Path:
|
||||
return config_path
|
||||
|
||||
|
||||
def _add_sibling_worktree(project: Path, path: Path, branch: str) -> None:
|
||||
"""Add a sibling worktree so `git branch -a` marks it with `+`."""
|
||||
subprocess.run(
|
||||
["git", "worktree", "add", "-q", "-b", branch, str(path), "HEAD"],
|
||||
cwd=project,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
# Git identity env vars for CI runners without global git config
|
||||
_GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "Test User",
|
||||
@@ -323,40 +312,6 @@ class TestCreateFeatureBash:
|
||||
data = json.loads(result.stdout)
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||
project = _setup_project(tmp_path / "project")
|
||||
_add_sibling_worktree(project, tmp_path / "sibling-worktree", "007-worktree-feature")
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_dry_run_preserves_literal_plus_branch_prefix(self, tmp_path: Path):
|
||||
"""A literal leading plus in a branch name is not a git worktree marker."""
|
||||
project = _setup_project(tmp_path)
|
||||
subprocess.run(
|
||||
["git", "branch", "+007-plus-prefix"],
|
||||
cwd=project,
|
||||
check=True,
|
||||
)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "001-next"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
@@ -382,36 +337,6 @@ class TestCreateFeatureBash:
|
||||
assert data.get("DRY_RUN") is True
|
||||
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
|
||||
|
||||
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
|
||||
"""With no core scripts (only git-common.sh loaded), a set SPECIFY_INIT_DIR
|
||||
hard-errors instead of silently falling back to the walk-up project root."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
# Simulate a no-core install: drop core common.sh so only git-common.sh loads.
|
||||
(project / "scripts" / "bash" / "common.sh").unlink()
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "x", "X feature",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(project)},
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
|
||||
"""With an older core common.sh, a set SPECIFY_INIT_DIR must hard-error
|
||||
instead of calling the stale get_repo_root that ignores the override."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "bash" / "common.sh").write_text(
|
||||
"#!/usr/bin/env bash\nget_repo_root() { pwd; }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "x", "X feature",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(tmp_path / "missing")},
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
@@ -426,21 +351,6 @@ class TestCreateFeaturePowerShell:
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||
project = _setup_project(tmp_path / "project")
|
||||
_add_sibling_worktree(project, tmp_path / "sibling-worktree", "007-worktree-feature")
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature-branch.ps1 creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
@@ -467,43 +377,6 @@ class TestCreateFeaturePowerShell:
|
||||
assert "BRANCH_NAME" in data
|
||||
assert "FEATURE_NUM" in data
|
||||
|
||||
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
|
||||
"""With no core scripts (only git-common.ps1 loaded), a set SPECIFY_INIT_DIR
|
||||
hard-errors instead of silently falling back to the walk-up project root."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "powershell" / "common.ps1").unlink()
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(project)}
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
|
||||
"""With an older core common.ps1, a set SPECIFY_INIT_DIR must hard-error
|
||||
instead of calling the stale Get-RepoRoot that ignores the override."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "powershell" / "common.ps1").write_text(
|
||||
"function Get-RepoRoot { return (Get-Location).Path }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(tmp_path / "missing")}
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
|
||||
from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS
|
||||
from specify_cli.integrations.claude import ARGUMENT_HINTS
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
@@ -536,102 +536,6 @@ class TestClaudeDisableModelInvocation:
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeForkContext:
|
||||
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
|
||||
|
||||
def test_analyze_skill_runs_in_forked_subagent(self, tmp_path):
|
||||
"""speckit-analyze must opt into context: fork + agent."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
assert analyze_skill.exists()
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("context") == "fork"
|
||||
assert parsed.get("agent") == "general-purpose"
|
||||
|
||||
def test_other_skills_do_not_fork(self, tmp_path):
|
||||
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
if stem in FORK_CONTEXT_COMMANDS:
|
||||
continue
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert "context" not in parsed, (
|
||||
f"{f.parent.name}: must not have context frontmatter"
|
||||
)
|
||||
assert "agent" not in parsed, (
|
||||
f"{f.parent.name}: must not have agent frontmatter"
|
||||
)
|
||||
|
||||
def test_fork_flags_inside_frontmatter(self, tmp_path):
|
||||
"""context/agent must appear in the frontmatter, not in the body."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
frontmatter = parts[1]
|
||||
body = parts[2]
|
||||
assert "context: fork" in frontmatter
|
||||
assert "agent: general-purpose" in frontmatter
|
||||
assert "context: fork" not in body
|
||||
assert "agent: general-purpose" not in body
|
||||
|
||||
def test_fork_injection_idempotent(self, tmp_path):
|
||||
"""Re-running setup must not duplicate the fork frontmatter keys."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
assert content.count("context: fork") == 1
|
||||
assert content.count("agent: general-purpose") == 1
|
||||
|
||||
def test_fork_context_injected_via_post_process(self):
|
||||
"""Preset/extension generators call post_process_skill_content directly,
|
||||
bypassing setup(); fork context must be injected there too."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert parsed.get("context") == "fork"
|
||||
assert parsed.get("agent") == "general-purpose"
|
||||
assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"]
|
||||
|
||||
def test_post_process_no_fork_for_other_skills(self):
|
||||
"""Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
|
||||
def test_post_process_fork_idempotent(self):
|
||||
"""Re-running post_process must not duplicate fork frontmatter keys."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
|
||||
once = i.post_process_skill_content(content)
|
||||
twice = i.post_process_skill_content(once)
|
||||
assert once == twice
|
||||
assert twice.count("context: fork") == 1
|
||||
assert twice.count("agent: general-purpose") == 1
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
|
||||
|
||||
|
||||
@@ -1036,93 +1036,6 @@ class TestExtensionSkillRegistration:
|
||||
assert metadata["registered_skills"] == []
|
||||
assert (project_dir / ".github" / "agents").is_dir()
|
||||
|
||||
def test_one_failing_extension_does_not_abort_the_rest(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""A single failing extension must not block registration of the others.
|
||||
|
||||
Regression for #2950: ``register_enabled_extensions_for_agent`` iterates
|
||||
enabled extensions; before the per-extension isolation, the first one
|
||||
that raised (e.g. an OSError writing a command file) aborted the loop and
|
||||
the exception propagated, so every later extension was silently skipped.
|
||||
"""
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Two enabled extensions; the first one iterated ("aaa-fail") will raise.
|
||||
manager.install_from_directory(
|
||||
_create_extension_dir(temp_dir, ext_id="aaa-fail"), "0.1.0",
|
||||
register_commands=False,
|
||||
)
|
||||
manager.install_from_directory(
|
||||
_create_extension_dir(temp_dir, ext_id="bbb-ok"), "0.1.0",
|
||||
register_commands=False,
|
||||
)
|
||||
|
||||
original = CommandRegistrar.register_commands_for_agent
|
||||
|
||||
def flaky(self, agent_name, manifest, ext_dir, project_root, link_outputs=False):
|
||||
if manifest.id == "aaa-fail":
|
||||
raise OSError("simulated command-file write failure")
|
||||
return original(
|
||||
self, agent_name, manifest, ext_dir, project_root,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(CommandRegistrar, "register_commands_for_agent", flaky)
|
||||
|
||||
# Must not propagate, despite the first extension failing.
|
||||
manager.register_enabled_extensions_for_agent("claude")
|
||||
|
||||
# The healthy extension was still registered for the agent...
|
||||
ok_meta = manager.registry.get("bbb-ok")
|
||||
assert "claude" in ok_meta["registered_commands"], (
|
||||
"a later extension must still register after an earlier one fails (#2950)"
|
||||
)
|
||||
# ...and the failing one was not.
|
||||
fail_meta = manager.registry.get("aaa-fail")
|
||||
assert "claude" not in fail_meta.get("registered_commands", {})
|
||||
|
||||
def test_skill_registration_failure_preserves_registered_commands(
|
||||
self, project_dir, temp_dir, monkeypatch, capsys
|
||||
):
|
||||
"""Persist successful command registration even if skills fail.
|
||||
|
||||
If command files are written but skill generation raises, the command
|
||||
registry must still be updated so later unregister/cleanup can find the
|
||||
command files.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
_create_extension_dir(temp_dir, ext_id="skill-fail"), "0.1.0",
|
||||
register_commands=False,
|
||||
)
|
||||
|
||||
def fail_skills(self, manifest, ext_dir, link_outputs=False):
|
||||
raise OSError("simulated skill directory failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "_register_extension_skills", fail_skills
|
||||
)
|
||||
|
||||
manager.register_enabled_extensions_for_agent("claude")
|
||||
|
||||
metadata = manager.registry.get("skill-fail")
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"claude": [
|
||||
"speckit.skill-fail.hello",
|
||||
"speckit.skill-fail.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "register extension skills for extension 'skill-fail'" in captured.out
|
||||
assert "Continuing with available registration results" in captured.out
|
||||
|
||||
def test_existing_agent_command_path_file_is_not_detected(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
"""Tests for the SPECIFY_INIT_DIR project-root override.
|
||||
|
||||
SPECIFY_INIT_DIR lets a non-interactive / CI caller target a member project from
|
||||
outside its directory (e.g. a monorepo root) without `cd`. It names the project
|
||||
root — the directory *containing* `.specify/` — and is strict: it must exist and
|
||||
contain `.specify/`, otherwise the resolver hard-errors with no silent fallback to
|
||||
cwd or the git toplevel.
|
||||
|
||||
See proposals/monorepo-support and github/spec-kit discussion #2834.
|
||||
"""
|
||||
|
||||
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"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
GIT_CREATE_FEATURE_SH = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
)
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
|
||||
(SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, …) cannot leak into the
|
||||
subprocess and make these resolution tests flaky."""
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _make_project(root: Path, name: str) -> Path:
|
||||
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
|
||||
proj = root / name
|
||||
(proj / ".specify").mkdir(parents=True)
|
||||
return proj
|
||||
|
||||
|
||||
def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""Source the real common.sh and run a function, from a given cwd/env."""
|
||||
return subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && {func_call}'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""Dot-source the real common.ps1 and run PowerShell, from a given cwd/env."""
|
||||
return subprocess.run(
|
||||
[_PS_EXE, "-NoProfile", "-Command", f'. "{COMMON_PS}"; {script}'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _feature_dir_line(stdout: str) -> str | None:
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
return line.split("=", 1)[1].strip("'\"")
|
||||
return None
|
||||
|
||||
|
||||
def _bash_path(path: Path) -> str:
|
||||
"""Return the path format emitted by Bash `pwd`.
|
||||
|
||||
Git-for-Windows Bash reports absolute paths as /c/... while pathlib reports
|
||||
them as C:\\..., so Bash stdout comparisons need an expected value in Bash's
|
||||
own path shape.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return str(path)
|
||||
|
||||
resolved = path.resolve()
|
||||
path_str = str(resolved).replace("\\", "/")
|
||||
if resolved.drive.endswith(":"):
|
||||
return f"/{resolved.drive[0].lower()}{path_str[len(resolved.drive):]}"
|
||||
return path_str
|
||||
|
||||
|
||||
requires_pwsh = pytest.mark.skipif(
|
||||
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
|
||||
)
|
||||
|
||||
|
||||
# ── Bash: positive cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_valid_path_resolves_from_outside(tmp_path: Path) -> None:
|
||||
"""P1: a valid project path resolves correctly when run from elsewhere."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
|
||||
"""P2: a relative SPECIFY_INIT_DIR is resolved against the current directory."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_trailing_slash_tolerated(tmp_path: Path) -> None:
|
||||
"""P3: a trailing slash is collapsed by normalization."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_precedence_over_cwd_project(tmp_path: Path) -> None:
|
||||
"""P4: feature resolution happens inside the *target* project, not cwd.
|
||||
|
||||
cwd is itself a valid Spec Kit project; SPECIFY_INIT_DIR must redirect
|
||||
resolution to the target project, so a relative SPECIFY_FEATURE_DIRECTORY
|
||||
normalizes under the target root, not cwd.
|
||||
"""
|
||||
cwd_proj = _make_project(tmp_path, "cwd_proj")
|
||||
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
|
||||
web = _make_project(tmp_path, "web")
|
||||
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=cwd_proj, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "001-demo")
|
||||
assert _bash_path(cwd_proj) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_composes_with_feature_directory_override(tmp_path: Path) -> None:
|
||||
"""P5: SPECIFY_INIT_DIR (project axis) composes with SPECIFY_FEATURE_DIRECTORY
|
||||
(feature axis); a relative feature dir normalizes under the *target* root."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "003-x")
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_composes_with_target_feature_json(tmp_path: Path) -> None:
|
||||
"""P6: the target project's .specify/feature.json is honored."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
(web / ".specify" / "feature.json").write_text(
|
||||
'{"feature_directory": "specs/004-fj"}'
|
||||
)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "004-fj")
|
||||
|
||||
|
||||
# ── Bash: negative / contract cases ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_unset_preserves_cwd_walk(tmp_path: Path) -> None:
|
||||
"""N1: with SPECIFY_INIT_DIR unset, resolution walks up from cwd as before."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
result = _bash("get_repo_root", cwd=sub, env=_clean_env())
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_empty_string_treated_as_unset(tmp_path: Path) -> None:
|
||||
"""N2: an empty SPECIFY_INIT_DIR behaves as unset (not as ".").
|
||||
|
||||
Run from a deep subdirectory so the two interpretations diverge:
|
||||
empty-as-unset walks up to the project root; empty-as-"." would resolve to
|
||||
the cwd (which has no .specify/) and error. Asserting the walk-up result
|
||||
genuinely guards against a regression to "." semantics.
|
||||
"""
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
|
||||
result = _bash("get_repo_root", cwd=sub, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_invalid_init_dir_fails_feature_paths_chain(tmp_path: Path) -> None:
|
||||
"""N5: an invalid SPECIFY_INIT_DIR hard-fails the load-bearing call site
|
||||
(get_feature_paths), not just get_repo_root — this is what the decl/assign
|
||||
split guards against (a `local x=$(get_repo_root)` would mask the failure
|
||||
and emit a FEATURE_DIR under the wrong root). SPECIFY_FEATURE_DIRECTORY is
|
||||
set so a feature dir *is* resolvable — only the propagation stops a
|
||||
wrong-root FEATURE_DIR, so a revert to the masked form fails this test."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(missing),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-x",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert "FEATURE_DIR=" not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N3: a non-existent path hard-errors — even from inside a valid project,
|
||||
proving there is no silent fallback to the cwd walk-up or git root."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N4: a path that exists but lacks .specify/ hard-errors, no fallback."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "not a Spec Kit project" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_file_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N4b: a path that exists but is a file (not a directory) hard-errors with
|
||||
the existing-directory message, with no fallback."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
# ── Bash: bundled Git extension entrypoint ──────────────────────────────────
|
||||
|
||||
|
||||
def _bash_git_create(
|
||||
args: list[str], cwd: Path, env: dict[str, str]
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run the bundled git extension's create-new-feature-branch.sh (the real
|
||||
/speckit.specify before_specify entrypoint)."""
|
||||
return subprocess.run(
|
||||
["bash", str(GIT_CREATE_FEATURE_SH), *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(stdout: str) -> dict | None:
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
return None
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_git_ext_create_feature_numbers_from_target(tmp_path: Path) -> None:
|
||||
"""P8: the git extension's feature creation numbers from the SPECIFY_INIT_DIR
|
||||
project, not the cwd project."""
|
||||
(tmp_path / "specs" / "008-cwd").mkdir(parents=True) # cwd project's specs
|
||||
web = _make_project(tmp_path, "web")
|
||||
(web / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(web / ".specify" / "templates" / "spec-template.md").write_text("# Spec: [FEATURE]\n")
|
||||
(web / "specs" / "005-existing").mkdir(parents=True)
|
||||
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash_git_create(["--json", "next thing"], cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = _json_line(result.stdout)
|
||||
assert data is not None and data["FEATURE_NUM"] == "006" # 005 in web → 006, not 009
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_git_ext_create_feature_invalid_init_dir_errors(tmp_path: Path) -> None:
|
||||
"""N7: the git extension hard-errors on an invalid SPECIFY_INIT_DIR with no
|
||||
fallback to the cwd/git-toplevel project."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
(web / "specs" / "001-cwd").mkdir(parents=True)
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _bash_git_create(["--json", "x"], cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _json_line(result.stdout) is None
|
||||
|
||||
|
||||
# ── PowerShell mirror (skipped only when no PowerShell is installed; the CI
|
||||
# ubuntu/windows runners ship pwsh, so these DO run there) ─────────────────
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_valid_path_resolves_from_outside(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_trailing_slash_tolerated(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_unset_preserves_cwd_walk(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
result = _ps("Get-RepoRoot", cwd=sub, env=_clean_env())
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_precedence_over_cwd_project(tmp_path: Path) -> None:
|
||||
cwd_proj = _make_project(tmp_path, "cwd_proj")
|
||||
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
|
||||
}
|
||||
result = _ps(
|
||||
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
|
||||
cwd=cwd_proj,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# PowerShell Join-Path keeps the embedded "/" of the relative feature dir
|
||||
# while pathlib uses the platform separator; compare separator-insensitively
|
||||
# so the Windows CI runner (where pwsh runs) matches.
|
||||
feature_dir = _feature_dir_line(result.stdout)
|
||||
assert feature_dir is not None, result.stdout
|
||||
assert feature_dir.replace("\\", "/") == (web / "specs" / "001-demo").as_posix()
|
||||
assert str(cwd_proj) not in result.stdout
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
|
||||
}
|
||||
result = _ps(
|
||||
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
|
||||
cwd=tmp_path,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# Separator-insensitive: PowerShell Join-Path keeps the embedded "/".
|
||||
feature_dir = _feature_dir_line(result.stdout)
|
||||
assert feature_dir is not None, result.stdout
|
||||
assert feature_dir.replace("\\", "/") == (web / "specs" / "003-x").as_posix()
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
|
||||
result = _ps("Get-RepoRoot", cwd=sub, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "not a Spec Kit project" in result.stderr
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""A file path resolves via Resolve-Path but is not a directory; the resolver
|
||||
must reject it with the existing-directory message, not not-a-project."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
Reference in New Issue
Block a user