Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions scripts/bash/check-prerequisites.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1

# If paths-only mode, output paths and exit (support JSON + paths-only combined)
if $PATHS_ONLY; then
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s","SPECS_DIR":"%s"}\n' \
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" "$(json_escape "$SPECS_DIR")"
else
echo "REPO_ROOT: $REPO_ROOT"
echo "BRANCH: $CURRENT_BRANCH"
echo "FEATURE_DIR: $FEATURE_DIR"
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "TASKS: $TASKS"
echo "SPECS_DIR: $SPECS_DIR"
fi
exit 0
fi
Expand Down Expand Up @@ -148,7 +151,9 @@ if $JSON_MODE; then
json_docs="[${json_docs%,}]"
fi

printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
# Note: $json_docs is not escaped because it is already a pre-formatted JSON array
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"SPECS_DIR":"%s"}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$SPECS_DIR")"
else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
Expand Down
47 changes: 44 additions & 3 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
#!/usr/bin/env bash
# Common functions and variables for all scripts

# Escape a string for safe inclusion in JSON (handles all JSON-required escapes)
json_escape() {
local str="$1"
# Escape backslashes first, then quotes, then control characters
str="${str//\\/\\\\}"
str="${str//\"/\\\"}"
str="${str//$'\n'/\\n}"
str="${str//$'\r'/\\r}"
str="${str//$'\t'/\\t}"
str="${str//$'\b'/\\b}"
str="${str//$'\f'/\\f}"
# Remove any remaining control characters (U+0000-U+001F) that are
# not covered above, since they are invalid unescaped in JSON strings.
str="$(printf '%s' "$str" | tr -d '\000-\006\016-\037')"
printf '%s' "$str"
}

# Get repository root, with fallback for non-git repositories
get_repo_root() {
if git rev-parse --show-toplevel >/dev/null 2>&1; then
Expand All @@ -12,6 +29,24 @@ get_repo_root() {
fi
}

# Get specs directory, with support for external location via SPECIFY_SPECS_DIR
get_specs_dir() {
local repo_root="${1:-$(get_repo_root)}"
local specs_dir

if [[ -n "${SPECIFY_SPECS_DIR:-}" ]]; then
specs_dir="$SPECIFY_SPECS_DIR"
# Resolve relative paths against repo root
if [[ "$specs_dir" != /* ]]; then
specs_dir="$repo_root/$specs_dir"
fi
Comment on lines +37 to +42
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPECIFY_SPECS_DIR is user-controlled and can now influence values emitted by get_feature_paths (which are later consumed via eval $(get_feature_paths) in multiple scripts). Because get_feature_paths wraps values in single quotes, a SPECIFY_SPECS_DIR containing a ' can break out of quoting and lead to command injection. Consider switching away from eval-based exports, or ensure values are safely shell-escaped (e.g., escape single quotes) before being embedded in the get_feature_paths output.

Copilot uses AI. Check for mistakes.
else
specs_dir="$repo_root/specs"
fi

echo "$specs_dir"
}
Comment on lines +33 to +48
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_specs_dir is used as though it can fail (SPECS_DIR="$(get_specs_dir ...)" || exit 1), but the function always exits 0 and just echoes a path. Either remove the || exit 1 patterns, or add validation so get_specs_dir returns non-zero for invalid configurations (e.g., empty repo_root, path exists but is not a directory, or directory cannot be created when required).

Copilot uses AI. Check for mistakes.

# Get current branch, with fallback for non-git repositories
get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
Expand All @@ -28,7 +63,7 @@ get_current_branch() {

# For non-git repos, try to find the latest feature directory
local repo_root=$(get_repo_root)
local specs_dir="$repo_root/specs"
local specs_dir="$(get_specs_dir "$repo_root")"

if [[ -d "$specs_dir" ]]; then
local latest_feature=""
Expand Down Expand Up @@ -66,6 +101,12 @@ check_feature_branch() {
local branch="$1"
local has_git_repo="$2"

# When SPECIFY_SPECS_DIR is set (e.g., worktree mode), skip branch naming
# validation since the branch/worktree may not follow the NNN- convention.
if [[ -n "${SPECIFY_SPECS_DIR:-}" ]]; then
return 0
fi

# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
Expand All @@ -81,14 +122,14 @@ check_feature_branch() {
return 0
}

get_feature_dir() { echo "$1/specs/$2"; }
get_feature_dir() { echo "$(get_specs_dir "$1")/$2"; }

# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name="$2"
local specs_dir="$repo_root/specs"
local specs_dir="$(get_specs_dir "$repo_root")"

# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
Expand Down
36 changes: 31 additions & 5 deletions scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ clean_branch_name() {
# were initialised with --no-git.
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source shared functions (get_specs_dir, json_escape, etc.)
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

if git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
HAS_GIT=true
Expand All @@ -174,9 +178,19 @@ fi

cd "$REPO_ROOT"

SPECS_DIR="$REPO_ROOT/specs"
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
mkdir -p "$SPECS_DIR"

# Scaffold _shared directory with README if it doesn't exist yet
SHARED_DIR="$SPECS_DIR/_shared"
if [ ! -d "$SHARED_DIR" ]; then
mkdir -p "$SHARED_DIR"
SHARED_README="$REPO_ROOT/.specify/templates/_shared/README.md"
if [ -f "$SHARED_README" ]; then
cp "$SHARED_README" "$SHARED_DIR/README.md"
fi
fi

# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
Expand Down Expand Up @@ -234,13 +248,22 @@ else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi

# When SPECIFY_SPECS_DIR is set, we assume an external specs directory (e.g., a
# git worktree dedicated to this feature). Skip branch creation and git fetch
# since the caller controls the branch lifecycle.
WORKTREE_MODE=false
if [ -n "${SPECIFY_SPECS_DIR:-}" ]; then
WORKTREE_MODE=true
fi

# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$HAS_GIT" = true ]; then
if [ "$HAS_GIT" = true ] && [ "$WORKTREE_MODE" = false ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
# Fall back to local directory check (also used in worktree mode to
# avoid running git fetch --all --prune against the parent repo)
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
Expand Down Expand Up @@ -271,7 +294,9 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi

if [ "$HAS_GIT" = true ]; then
if [ "$WORKTREE_MODE" = true ]; then
>&2 echo "[specify] Worktree mode: skipping branch creation (SPECIFY_SPECS_DIR is set)"
elif [ "$HAS_GIT" = true ]; then
git checkout -b "$BRANCH_NAME"
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
Expand All @@ -288,7 +313,8 @@ if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"
export SPECIFY_FEATURE="$BRANCH_NAME"

if $JSON_MODE; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","SPECS_DIR":"%s","WORKTREE_MODE":%s}\n' \
"$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$(json_escape "$SPECS_DIR")" "$WORKTREE_MODE"
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
Expand Down
9 changes: 8 additions & 1 deletion scripts/powershell/check-prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI

# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
if ($PathsOnly) {
$specsDir = Get-SpecsDir -RepoRoot $paths.REPO_ROOT
if (-not $specsDir) { exit 1 }
Comment thread
alanmeadows marked this conversation as resolved.
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
Expand All @@ -73,6 +75,7 @@ if ($PathsOnly) {
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
SPECS_DIR = $specsDir
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Expand All @@ -81,6 +84,7 @@ if ($PathsOnly) {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
Write-Output "SPECS_DIR: $specsDir"
}
exit 0
}
Expand Down Expand Up @@ -127,9 +131,12 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
# Output results
if ($Json) {
# JSON output
$specsDir = Get-SpecsDir -RepoRoot $paths.REPO_ROOT
if (-not $specsDir) { exit 1 }
Comment thread
alanmeadows marked this conversation as resolved.
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
AVAILABLE_DOCS = $docs
SPECS_DIR = $specsDir
} | ConvertTo-Json -Compress
} else {
# Text output
Expand Down
25 changes: 23 additions & 2 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ function Get-RepoRoot {
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
}

# Get specs directory, with support for external location via SPECIFY_SPECS_DIR
function Get-SpecsDir {
param([string]$RepoRoot = (Get-RepoRoot))

if ($env:SPECIFY_SPECS_DIR) {
$specsDir = $env:SPECIFY_SPECS_DIR
# Resolve relative paths against repo root
if (-not [System.IO.Path]::IsPathRooted($specsDir)) {
$specsDir = Join-Path $RepoRoot $specsDir
}
return $specsDir
}
return Join-Path $RepoRoot "specs"
Comment thread
alanmeadows marked this conversation as resolved.
Comment on lines +22 to +30
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get-SpecsDir always returns a (possibly relative-resolved) string, so callers' if (-not $specsDir) { exit 1 } checks can never trigger. Either implement real validation here (and return $null for invalid values like an existing non-directory path), or remove the dead checks and adjust the error messaging accordingly.

Suggested change
if ($env:SPECIFY_SPECS_DIR) {
$specsDir = $env:SPECIFY_SPECS_DIR
# Resolve relative paths against repo root
if (-not [System.IO.Path]::IsPathRooted($specsDir)) {
$specsDir = Join-Path $RepoRoot $specsDir
}
return $specsDir
}
return Join-Path $RepoRoot "specs"
$specsDir = $null
if ($env:SPECIFY_SPECS_DIR) {
$specsDir = $env:SPECIFY_SPECS_DIR
# Resolve relative paths against repo root
if (-not [System.IO.Path]::IsPathRooted($specsDir)) {
$specsDir = Join-Path $RepoRoot $specsDir
}
} else {
$specsDir = Join-Path $RepoRoot "specs"
}
# Validate that, if the path exists, it is a directory
if (Test-Path $specsDir) {
if (-not (Test-Path $specsDir -PathType Container)) {
Write-Error "Invalid specs directory path '$specsDir': path exists but is not a directory."
return $null
}
}
return $specsDir

Copilot uses AI. Check for mistakes.
}

function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
Expand All @@ -33,7 +48,7 @@ function Get-CurrentBranch {

# For non-git repos, try to find the latest feature directory
$repoRoot = Get-RepoRoot
$specsDir = Join-Path $repoRoot "specs"
$specsDir = Get-SpecsDir -RepoRoot $repoRoot

if (Test-Path $specsDir) {
$latestFeature = ""
Expand Down Expand Up @@ -73,6 +88,12 @@ function Test-FeatureBranch {
[bool]$HasGit = $true
)

# When SPECIFY_SPECS_DIR is set (e.g., worktree mode), skip branch naming
# validation since the branch/worktree may not follow the NNN- convention.
if ($env:SPECIFY_SPECS_DIR) {
return $true
}

# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
Expand All @@ -89,7 +110,7 @@ function Test-FeatureBranch {

function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
Join-Path (Get-SpecsDir -RepoRoot $RepoRoot) $Branch
}

function Get-FeaturePathsEnv {
Expand Down
32 changes: 28 additions & 4 deletions scripts/powershell/create-new-feature.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,28 @@ try {

Set-Location $repoRoot

$specsDir = Join-Path $repoRoot 'specs'
# When SPECIFY_SPECS_DIR is set, we assume an external specs directory (e.g., a
# git worktree dedicated to this feature). Skip branch creation and git fetch
# since the caller controls the branch lifecycle.
$worktreeMode = [bool]$env:SPECIFY_SPECS_DIR

$specsDir = Get-SpecsDir -RepoRoot $repoRoot
if (-not $specsDir) {
Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red
exit 1
}
Comment thread
alanmeadows marked this conversation as resolved.
Comment on lines +161 to +164
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (-not $specsDir) branch is currently unreachable because Get-SpecsDir always returns a non-empty string. This makes the new "Invalid SPECIFY_SPECS_DIR configuration" error message misleading. Either add validation/$null return behavior to Get-SpecsDir, or remove this conditional and rely on New-Item/Resolve-Path failures for error handling.

Suggested change
if (-not $specsDir) {
Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red
exit 1
}

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +164
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (-not $specsDir) guard is currently unreachable because Get-SpecsDir (in common.ps1) always returns a non-empty string (either SPECIFY_SPECS_DIR resolved or the default Join-Path $RepoRoot 'specs'). Either remove this check, or add real validation to Get-SpecsDir (e.g., return $null when the path is invalid/uncreatable) so the error message can actually trigger.

Suggested change
if (-not $specsDir) {
Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red
exit 1
}

Copilot uses AI. Check for mistakes.
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null

Comment thread
alanmeadows marked this conversation as resolved.
# Scaffold _shared directory with README if it doesn't exist yet
$sharedDir = Join-Path $specsDir '_shared'
if (-not (Test-Path $sharedDir)) {
New-Item -ItemType Directory -Path $sharedDir -Force | Out-Null
$sharedReadme = Join-Path $repoRoot '.specify/templates/_shared/README.md'
if (Test-Path $sharedReadme) {
Copy-Item $sharedReadme (Join-Path $sharedDir 'README.md') -Force
}
}

# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
Expand Down Expand Up @@ -208,11 +227,12 @@ if ($ShortName) {

# Determine branch number
if ($Number -eq 0) {
if ($hasGit) {
if ($hasGit -and -not $worktreeMode) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
# Fall back to local directory check (also used in worktree mode to
# avoid running git fetch --all --prune against the parent repo)
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
Expand Down Expand Up @@ -241,7 +261,9 @@ if ($branchName.Length -gt $maxBranchLength) {
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}

if ($hasGit) {
if ($worktreeMode) {
Write-Warning "[specify] Worktree mode: skipping branch creation (SPECIFY_SPECS_DIR is set)"
} elseif ($hasGit) {
try {
git checkout -b $branchName | Out-Null
} catch {
Expand Down Expand Up @@ -271,6 +293,8 @@ if ($Json) {
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
SPECS_DIR = $specsDir
WORKTREE_MODE = $worktreeMode
}
$obj | ConvertTo-Json -Compress
} else {
Expand Down
39 changes: 39 additions & 0 deletions templates/_shared/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Shared Context

This directory holds project-wide standards and conventions that apply across
all feature specs. Every `.md` file placed here is automatically loaded as
read-only context by the following Spec Kit commands:

- **specify** -- informs spec structure and alignment with project standards
- **clarify** -- validates spec alignment and informs ambiguity detection
- **plan** -- guides technical decisions in implementation plans
- **tasks** -- informs task structure and sequencing
- **implement** -- guides implementation decisions and coding patterns
- **checklist** -- incorporates project-wide requirements into validation
- **analyze** -- uses standards as additional validation criteria

## What to put here

Create `.md` files for any project-wide standards you want consistently applied:

- **Architecture decisions** -- system boundaries, data flow, service topology
- **API conventions** -- endpoint naming, versioning, request/response patterns
- **Coding standards** -- naming, formatting, error handling, logging patterns
- **Security requirements** -- authentication, authorization, input validation
- **Accessibility standards** -- WCAG compliance, ARIA patterns
- **Testing conventions** -- coverage expectations, mocking strategies, test naming

## Example

```
specs/_shared/
api-conventions.md
coding-standards.md
security-requirements.md
```
Comment on lines +28 to +33
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example path uses specs/_shared/, but with SPECIFY_SPECS_DIR the shared directory is conceptually SPECS_DIR/_shared/ (which may not be under specs/). Consider updating the example to avoid implying it must live in specs/.

Copilot uses AI. Check for mistakes.

## Notes

- Files are **read-only** -- Spec Kit commands never modify shared context.
- Only `.md` files are loaded; other file types are ignored.
- This directory is optional. If absent, commands proceed without shared context.
Loading
Loading