From 30bf5c4bcce87cb5ca2642573e6025dff2f44080 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Sun, 15 Feb 2026 09:36:31 +0200 Subject: [PATCH 01/14] Add PROJECT_ACRONYM to branch naming and auto-fill constitution identity fields --- .gitignore | 4 + scripts/bash/common.sh | 41 ++- scripts/bash/create-new-feature.sh | 137 +++++++- scripts/powershell/common.ps1 | 11 +- scripts/powershell/create-new-feature.ps1 | 113 +++++- src/specify_cli/__init__.py | 411 ++++++++++++++++++++-- templates/commands/constitution.md | 6 + templates/commands/specify.md | 12 +- templates/constitution-template.md | 5 + 9 files changed, 670 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 1688c8299e..2a05cc4cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ docs/dev .specify/extensions/.cache/ .specify/extensions/.backup/ .specify/extensions/*/local-config.yml + + +.claude/* +.test/* diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..5cbf3af97f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -37,7 +37,10 @@ get_current_branch() { for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then local dirname=$(basename "$dir") - if [[ "$dirname" =~ ^([0-9]{3})- ]]; then + # Match both "001-name" and "ACR-001-name" directory patterns + local stripped_dirname + stripped_dirname=$(echo "$dirname" | sed 's/^[A-Z]\{2,5\}-//') + if [[ "$stripped_dirname" =~ ^([0-9]{3})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then @@ -72,16 +75,16 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + if [[ ! "$branch" =~ ^(feature/([A-Z]+-)?)?[0-9]{3}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name" >&2 + echo "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" >&2 return 1 fi return 0 } -get_feature_dir() { echo "$1/specs/$2"; } +get_feature_dir() { local dir="${2#feature/}"; echo "$1/specs/$dir"; } # 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) @@ -90,29 +93,41 @@ find_feature_dir_by_prefix() { local branch_name="$2" local specs_dir="$repo_root/specs" - # Extract numeric prefix from branch (e.g., "004" from "004-whatever") - if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then + # Strip feature/ prefix for directory lookup + local dir_name="${branch_name#feature/}" + + # Extract numeric prefix from branch (e.g., "004" from "004-whatever" or "URA-004-whatever") + local stripped_name + stripped_name=$(echo "$dir_name" | sed 's/^[A-Z]\{2,5\}-//') + if [[ ! "$stripped_name" =~ ^([0-9]{3})- ]]; then # If branch doesn't have numeric prefix, fall back to exact match - echo "$specs_dir/$branch_name" + echo "$specs_dir/$dir_name" return fi local prefix="${BASH_REMATCH[1]}" - # Search for directories in specs/ that start with this prefix + # Search for directories in specs/ that match this numeric prefix + # Match both "001-name" and "ACR-001-name" patterns local matches=() if [[ -d "$specs_dir" ]]; then - for dir in "$specs_dir"/"$prefix"-*; do + for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then - matches+=("$(basename "$dir")") + local base + base=$(basename "$dir") + local base_stripped + base_stripped=$(echo "$base" | sed 's/^[A-Z]\{2,5\}-//') + if [[ "$base_stripped" =~ ^${prefix}- ]]; then + matches+=("$base") + fi fi done fi # Handle results if [[ ${#matches[@]} -eq 0 ]]; then - # No match found - return the branch name path (will fail later with clear error) - echo "$specs_dir/$branch_name" + # No match found - return the dir name path (will fail later with clear error) + echo "$specs_dir/$dir_name" elif [[ ${#matches[@]} -eq 1 ]]; then # Exactly one match - perfect! echo "$specs_dir/${matches[0]}" @@ -120,7 +135,7 @@ find_feature_dir_by_prefix() { # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2 - echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + echo "$specs_dir/$dir_name" # Return something to avoid breaking the script fi } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c40cfd77f0..de23b70ba5 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -89,7 +89,10 @@ get_highest_from_specs() { for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + local stripped_dirname + stripped_dirname=$(echo "$dirname" | sed 's/^[A-Z]\{2,5\}-//') + number=$(echo "$stripped_dirname" | grep -o '^[0-9]\+' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -112,9 +115,15 @@ get_highest_from_branches() { # Clean branch name: remove leading markers and remote prefixes clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + # Strip feature/ prefix if present + clean_branch="${clean_branch#feature/}" + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + local stripped_branch + stripped_branch=$(echo "$clean_branch" | sed 's/^[A-Z]\{2,5\}-//') + # Extract feature number if branch matches pattern ###-* - if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then - number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + if echo "$stripped_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$stripped_branch" | grep -o '^[0-9]\{3\}' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -155,6 +164,60 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Function to extract project acronym from constitution.md +get_project_acronym() { + local repo_root="$1" + local constitution="$repo_root/.specify/memory/constitution.md" + + if [ ! -f "$constitution" ]; then + echo "" + return + fi + + # Try to extract project_acronym from YAML front matter + local acronym="" + if head -1 "$constitution" | grep -q '^---$'; then + acronym=$(awk '/^---$/{n++; next} n==1 && /^project_acronym:/{sub(/^project_acronym:[[:space:]]*/,""); gsub(/^["'"'"']|["'"'"']$/,""); print; exit}' "$constitution") + fi + + # Skip if placeholder or empty + if [ -n "$acronym" ] && [ "$acronym" != "[PROJECT_ACRONYM]" ]; then + echo "$acronym" + return + fi + + # Fallback: derive from H1 heading (e.g., "# Upwork Routine Automation Constitution") + local heading + heading=$(grep -m1 '^# ' "$constitution" | sed 's/^# //') + if [ -z "$heading" ]; then + echo "" + return + fi + + # Skip if heading is still a placeholder + if echo "$heading" | grep -q '\[PROJECT_NAME\]'; then + echo "" + return + fi + + # Remove trailing "Constitution" if present + heading=$(echo "$heading" | sed 's/[[:space:]]*Constitution[[:space:]]*$//') + + # Count words + local word_count + word_count=$(echo "$heading" | wc -w | tr -d ' ') + + if [ "$word_count" -eq 1 ]; then + # Single word: first 3 letters uppercased + echo "$heading" | tr '[:lower:]' '[:upper:]' | cut -c1-3 + elif [ "$word_count" -ge 2 ]; then + # Multiple words: first letter of each word + echo "$heading" | tr '[:lower:]' '[:upper:]' | sed 's/[[:space:]]\+/ /g' | sed 's/\([A-Z]\)[^ ]*/\1/g' | tr -d ' ' + else + echo "" + fi +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -248,24 +311,69 @@ fi # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") -BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + +# Get project acronym from constitution +PROJECT_ACRONYM=$(get_project_acronym "$REPO_ROOT") + +# If no acronym found, ask the user +if [ -z "$PROJECT_ACRONYM" ]; then + CONSTITUTION_FILE="$REPO_ROOT/.specify/memory/constitution.md" + >&2 echo "" + >&2 printf "[specify] Enter PROJECT_ACRONYM (2-5 uppercase letters, or press Enter to skip): " + read -r user_acronym || user_acronym="" + # Uppercase and trim + user_acronym=$(echo "$user_acronym" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') + if [[ "$user_acronym" =~ ^[A-Z]{2,5}$ ]]; then + PROJECT_ACRONYM="$user_acronym" + # Persist to constitution if file exists + if [ -f "$CONSTITUTION_FILE" ]; then + if head -1 "$CONSTITUTION_FILE" | grep -q '^---$'; then + if grep -q '^project_acronym:' "$CONSTITUTION_FILE"; then + sed -i.bak "s/^project_acronym:.*$/project_acronym: \"$PROJECT_ACRONYM\"/" "$CONSTITUTION_FILE" + rm -f "$CONSTITUTION_FILE.bak" + else + sed -i.bak "1a\\ +project_acronym: \"$PROJECT_ACRONYM\"" "$CONSTITUTION_FILE" + rm -f "$CONSTITUTION_FILE.bak" + fi + >&2 echo "[specify] Saved PROJECT_ACRONYM=$PROJECT_ACRONYM to constitution." + fi + fi + elif [ -n "$user_acronym" ]; then + >&2 echo "[specify] Invalid acronym (must be 2-5 uppercase letters). Skipping." + fi +fi + +if [ -n "$PROJECT_ACRONYM" ]; then + BRANCH_NAME="feature/${PROJECT_ACRONYM}-${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + BRANCH_NAME="feature/${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4)) - + # Calculate prefix length: "feature/" (8) + optional acronym + hyphen + feature number (3) + hyphen (1) + if [ -n "$PROJECT_ACRONYM" ]; then + PREFIX_LENGTH=$((8 + ${#PROJECT_ACRONYM} + 1 + 3 + 1)) + else + PREFIX_LENGTH=$((8 + 3 + 1)) + fi + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + # Truncate suffix at word boundary if possible TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) # Remove trailing hyphen if truncation created one TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" - + if [ -n "$PROJECT_ACRONYM" ]; then + BRANCH_NAME="feature/${PROJECT_ACRONYM}-${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + else + BRANCH_NAME="feature/${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + fi + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" @@ -277,7 +385,9 @@ else >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +# Strip feature/ prefix for spec directory name (avoids specs/feature/ nesting) +SPEC_DIR_NAME="${BRANCH_NAME#feature/}" +FEATURE_DIR="$SPECS_DIR/$SPEC_DIR_NAME" mkdir -p "$FEATURE_DIR" TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" @@ -288,10 +398,11 @@ 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","PROJECT_ACRONYM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$PROJECT_ACRONYM" else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" + echo "PROJECT_ACRONYM: $PROJECT_ACRONYM" echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..87a619d7c6 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -40,7 +40,9 @@ function Get-CurrentBranch { $highest = 0 Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { + # Match both "001-name" and "ACR-001-name" directory patterns + $dirName = $_.Name -replace '^[A-Z]{2,5}-', '' + if ($dirName -match '^(\d{3})-') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num @@ -79,9 +81,9 @@ function Test-FeatureBranch { return $true } - if ($Branch -notmatch '^[0-9]{3}-') { + if ($Branch -notmatch '^(feature/([A-Z]+-)?)?[0-9]{3}-') { Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" + Write-Output "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" return $false } return $true @@ -89,7 +91,8 @@ function Test-FeatureBranch { function Get-FeatureDir { param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" + $dir = $Branch -replace '^feature/', '' + Join-Path $RepoRoot "specs/$dir" } function Get-FeaturePathsEnv { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..77174cb13b 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -65,7 +65,9 @@ function Get-HighestNumberFromSpecs { $highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + $dirName = $_.Name -replace '^[A-Z]{2,5}-', '' + if ($dirName -match '^(\d+)') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num } } @@ -85,6 +87,11 @@ function Get-HighestNumberFromBranches { # Clean branch name: remove leading markers and remote prefixes $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + # Strip feature/ prefix if present + $cleanBranch = $cleanBranch -replace '^feature/', '' + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + $cleanBranch = $cleanBranch -replace '^[A-Z]{2,5}-', '' + # Extract feature number if branch matches pattern ###-* if ($cleanBranch -match '^(\d+)-') { $num = [int]$matches[1] @@ -126,9 +133,63 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } + +function Get-ProjectAcronym { + param([string]$RepoRoot) + + $constitution = Join-Path $RepoRoot '.specify/memory/constitution.md' + if (-not (Test-Path $constitution)) { + return '' + } + + $content = Get-Content $constitution -Raw + + # Try to extract project_acronym from YAML front matter + if ($content -match '(?ms)\A---\s*\n(.*?)\n---') { + $frontMatter = $matches[1] + if ($frontMatter -match 'project_acronym:\s*"?([^"\n]+)"?') { + $acronym = $matches[1].Trim().Trim('"').Trim("'") + if ($acronym -and $acronym -ne '[PROJECT_ACRONYM]') { + return $acronym + } + } + } + + # Fallback: derive from H1 heading + $lines = Get-Content $constitution + $heading = '' + foreach ($line in $lines) { + if ($line -match '^# (.+)') { + $heading = $matches[1].Trim() + break + } + } + + if (-not $heading) { + return '' + } + + # Remove trailing "Constitution" + $heading = $heading -replace '\s*Constitution\s*$', '' + + $words = ($heading.Trim() -split '\s+') | Where-Object { $_ } + + if ($words.Count -eq 1) { + # Single word: first 3 letters uppercased + $word = $words[0].ToUpper() + return $word.Substring(0, [Math]::Min(3, $word.Length)) + } elseif ($words.Count -ge 2) { + # Multiple words: first letter of each word + $acronym = ($words | ForEach-Object { $_[0] }) -join '' + return $acronym.ToUpper() + } + + return '' +} + $fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) if (-not $fallbackRoot) { Write-Error "Error: Could not determine repository root. Please run this script from within the repository." @@ -218,24 +279,40 @@ if ($Number -eq 0) { } $featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" + +# Get project acronym from constitution +$projectAcronym = Get-ProjectAcronym -RepoRoot $repoRoot + +if ($projectAcronym) { + $branchName = "feature/$projectAcronym-$featureNum-$branchSuffix" +} else { + $branchName = "feature/$featureNum-$branchSuffix" +} # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 - + # Calculate prefix length: "feature/" (8) + optional acronym + hyphen + feature number (3) + hyphen (1) + if ($projectAcronym) { + $prefixLength = 8 + $projectAcronym.Length + 1 + 3 + 1 + } else { + $prefixLength = 8 + 3 + 1 + } + $maxSuffixLength = $maxBranchLength - $prefixLength + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" - + if ($projectAcronym) { + $branchName = "feature/$projectAcronym-$featureNum-$truncatedSuffix" + } else { + $branchName = "feature/$featureNum-$truncatedSuffix" + } + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" @@ -251,25 +328,28 @@ if ($hasGit) { Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } -$featureDir = Join-Path $specsDir $branchName +# Strip feature/ prefix for spec directory name (avoids specs/feature/ nesting) +$specDirName = $branchName -replace '^feature/', '' +$featureDir = Join-Path $specsDir $specDirName New-Item -ItemType Directory -Path $featureDir -Force | Out-Null $template = Join-Path $repoRoot '.specify/templates/spec-template.md' $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null +if (Test-Path $template) { + Copy-Item $template $specFile -Force +} else { + New-Item -ItemType File -Path $specFile | Out-Null } # Set the SPECIFY_FEATURE environment variable for the current session $env:SPECIFY_FEATURE = $branchName if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum + PROJECT_ACRONYM = $projectAcronym HAS_GIT = $hasGit } $obj | ConvertTo-Json -Compress @@ -277,6 +357,7 @@ if ($Json) { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" + Write-Output "PROJECT_ACRONYM: $projectAcronym" Write-Output "HAS_GIT: $hasGit" Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 70c5bd27c5..ecadad0eeb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -234,6 +234,29 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) }, } +# Agent command config: maps agent -> (command_folder, file_extension, arg_token) +# Used by extract_template_from_local() to generate agent-specific command files. +AGENT_COMMAND_CONFIG = { + "claude": (".claude/commands", "md", "$ARGUMENTS"), + "gemini": (".gemini/commands", "toml", "{{args}}"), + "copilot": (".github/agents", "agent.md", "$ARGUMENTS"), + "cursor-agent": (".cursor/commands", "md", "$ARGUMENTS"), + "qwen": (".qwen/commands", "toml", "{{args}}"), + "opencode": (".opencode/command", "md", "$ARGUMENTS"), + "windsurf": (".windsurf/workflows", "md", "$ARGUMENTS"), + "codex": (".codex/prompts", "md", "$ARGUMENTS"), + "kilocode": (".kilocode/workflows", "md", "$ARGUMENTS"), + "auggie": (".augment/commands", "md", "$ARGUMENTS"), + "roo": (".roo/commands", "md", "$ARGUMENTS"), + "codebuddy": (".codebuddy/commands", "md", "$ARGUMENTS"), + "qoder": (".qoder/commands", "md", "$ARGUMENTS"), + "amp": (".agents/commands", "md", "$ARGUMENTS"), + "shai": (".shai/commands", "md", "$ARGUMENTS"), + "q": (".amazonq/prompts", "md", "$ARGUMENTS"), + "agy": (".agent/workflows", "md", "$ARGUMENTS"), + "bob": (".bob/commands", "md", "$ARGUMENTS"), +} + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -247,7 +270,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ """ -TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" +TAGLINE = "Forked GitHub Spec Kit - Spec-Driven Development Toolkit" class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. @@ -904,6 +927,256 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +import re as _re + +def _rewrite_paths(text: str) -> str: + """Rewrite bare memory/, scripts/, templates/ paths to .specify/ equivalents.""" + text = _re.sub(r'(/?)memory/', r'.specify/memory/', text) + text = _re.sub(r'(/?)scripts/', r'.specify/scripts/', text) + text = _re.sub(r'(/?)templates/', r'.specify/templates/', text) + text = text.replace('.specify.specify/', '.specify/') + return text + + +def _parse_command_template(template_path: Path, script_variant: str) -> dict: + """Parse a command template file's YAML frontmatter and body. + + Returns dict with keys: name, description, script_command, agent_script_command, body_raw. + """ + name = template_path.stem + content = template_path.read_text(encoding="utf-8").replace("\r", "") + + # Split frontmatter from body + parts = content.split("---", 2) + if len(parts) < 3: + # No proper frontmatter + return {"name": name, "description": "", "script_command": "", "agent_script_command": "", "body_raw": content} + + frontmatter = parts[1] + body_after_frontmatter = parts[2] + + # Extract description + description = "" + for line in frontmatter.splitlines(): + if line.startswith("description:"): + description = line.split(":", 1)[1].strip() + break + + # Extract script command from scripts: section + script_command = "" + in_scripts = False + for line in frontmatter.splitlines(): + if _re.match(r'^scripts:\s*$', line): + in_scripts = True + continue + if in_scripts: + stripped = line.lstrip() + if stripped.startswith(f"{script_variant}:"): + script_command = stripped.split(":", 1)[1].strip() + break + # If we hit a non-indented line that's a new top-level key, stop + if line and not line[0].isspace(): + in_scripts = False + + # Extract agent_script command from agent_scripts: section + agent_script_command = "" + in_agent_scripts = False + for line in frontmatter.splitlines(): + if _re.match(r'^agent_scripts:\s*$', line): + in_agent_scripts = True + continue + if in_agent_scripts: + stripped = line.lstrip() + if stripped.startswith(f"{script_variant}:"): + agent_script_command = stripped.split(":", 1)[1].strip() + break + if line and not line[0].isspace(): + in_agent_scripts = False + + # Remove scripts: and agent_scripts: sections from frontmatter + cleaned_fm_lines = [] + skip_section = False + for line in frontmatter.splitlines(): + if _re.match(r'^(scripts|agent_scripts):\s*$', line): + skip_section = True + continue + if skip_section: + if line and not line[0].isspace(): + # New top-level key, stop skipping + skip_section = False + cleaned_fm_lines.append(line) + # else: still in indented sub-keys, skip + continue + cleaned_fm_lines.append(line) + + cleaned_frontmatter = "\n".join(cleaned_fm_lines) + body_raw = f"---{cleaned_frontmatter}---{body_after_frontmatter}" + + return { + "name": name, + "description": description, + "script_command": script_command or f"(Missing script command for {script_variant})", + "agent_script_command": agent_script_command, + "body_raw": body_raw, + } + + +def _generate_command_file(parsed: dict, agent: str, ext: str, arg_format: str, output_dir: Path) -> None: + """Generate a single agent command file from a parsed command template.""" + body = parsed["body_raw"] + + # Replace {SCRIPT} placeholder + body = body.replace("{SCRIPT}", parsed["script_command"]) + + # Replace {AGENT_SCRIPT} placeholder + if parsed["agent_script_command"]: + body = body.replace("{AGENT_SCRIPT}", parsed["agent_script_command"]) + + # Replace {ARGS} and __AGENT__ + body = body.replace("{ARGS}", arg_format) + body = body.replace("__AGENT__", agent) + + # Rewrite paths + body = _rewrite_paths(body) + + output_dir.mkdir(parents=True, exist_ok=True) + name = parsed["name"] + + if ext == "toml": + body = body.replace("\\", "\\\\") + content = f'description = "{parsed["description"]}"\n\nprompt = """\n{body}\n"""\n' + (output_dir / f"speckit.{name}.{ext}").write_text(content, encoding="utf-8") + elif ext in ("md", "agent.md"): + (output_dir / f"speckit.{name}.{ext}").write_text(body, encoding="utf-8") + + +def extract_template_from_local( + project_path: Path, + ai_assistant: str, + script_type: str, + source_dir: Path, + is_current_dir: bool = False, + tracker: StepTracker | None = None, +) -> Path: + """Build a project from the local spec-kit source tree instead of downloading a release zip. + + Replicates the logic of create-release-packages.sh's build_variant() and generate_commands(). + """ + if tracker: + tracker.start("local-copy", "copying from local source") + + if not is_current_dir: + project_path.mkdir(parents=True, exist_ok=True) + + spec_dir = project_path / ".specify" + spec_dir.mkdir(parents=True, exist_ok=True) + + # (a) Copy templates (excluding commands/ and vscode-settings.json) + src_templates = source_dir / "templates" + if src_templates.is_dir(): + dest_templates = spec_dir / "templates" + dest_templates.mkdir(parents=True, exist_ok=True) + for item in src_templates.iterdir(): + if item.is_file() and item.name != "vscode-settings.json": + dest_file = dest_templates / item.name + if dest_file.exists() and is_current_dir: + pass # will overwrite + shutil.copy2(item, dest_file) + + # (b) Copy scripts (filtered by script_type) + src_scripts = source_dir / "scripts" + if src_scripts.is_dir(): + dest_scripts = spec_dir / "scripts" + dest_scripts.mkdir(parents=True, exist_ok=True) + if script_type == "sh": + src_bash = src_scripts / "bash" + if src_bash.is_dir(): + dest_bash = dest_scripts / "bash" + if dest_bash.exists(): + shutil.rmtree(dest_bash) + shutil.copytree(src_bash, dest_bash) + elif script_type == "ps": + src_ps = src_scripts / "powershell" + if src_ps.is_dir(): + dest_ps = dest_scripts / "powershell" + if dest_ps.exists(): + shutil.rmtree(dest_ps) + shutil.copytree(src_ps, dest_ps) + # Copy any top-level script files + for item in src_scripts.iterdir(): + if item.is_file(): + shutil.copy2(item, dest_scripts / item.name) + + # (c) Copy memory (if exists) + src_memory = source_dir / "memory" + if src_memory.is_dir(): + dest_memory = spec_dir / "memory" + if dest_memory.exists(): + # Merge: copy files that don't exist yet + for item in src_memory.rglob("*"): + if item.is_file(): + rel = item.relative_to(src_memory) + dest_file = dest_memory / rel + dest_file.parent.mkdir(parents=True, exist_ok=True) + if not dest_file.exists(): + shutil.copy2(item, dest_file) + else: + shutil.copytree(src_memory, dest_memory) + + if tracker: + tracker.complete("local-copy", "templates, scripts, memory") + + # (d) Generate agent command files + if tracker: + tracker.start("commands", "generating agent commands") + + commands_dir = source_dir / "templates" / "commands" + if commands_dir.is_dir() and ai_assistant in AGENT_COMMAND_CONFIG: + folder, ext, arg_format = AGENT_COMMAND_CONFIG[ai_assistant] + output_dir = project_path / folder + + for template_file in sorted(commands_dir.glob("*.md")): + parsed = _parse_command_template(template_file, script_type) + _generate_command_file(parsed, ai_assistant, ext, arg_format, output_dir) + + if tracker: + tracker.complete("commands", f"{ai_assistant} -> {folder}") + else: + if tracker: + tracker.complete("commands", "no command templates found") + + # (e) Copilot special handling + if ai_assistant == "copilot": + if tracker: + tracker.start("copilot-extras", "generating copilot prompts & vscode settings") + + # Generate .github/prompts/speckit.{name}.prompt.md files + agents_dir = project_path / ".github" / "agents" + prompts_dir = project_path / ".github" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + + for agent_file in sorted(agents_dir.glob("speckit.*.agent.md")): + basename = agent_file.name.replace(".agent.md", "") + prompt_file = prompts_dir / f"{basename}.prompt.md" + prompt_file.write_text(f"---\nagent: {basename}\n---\n", encoding="utf-8") + + # Copy vscode-settings.json -> .vscode/settings.json + vscode_settings_src = source_dir / "templates" / "vscode-settings.json" + if vscode_settings_src.exists(): + vscode_dir = project_path / ".vscode" + vscode_dir.mkdir(parents=True, exist_ok=True) + dest_settings = vscode_dir / "settings.json" + if dest_settings.exists(): + handle_vscode_settings(vscode_settings_src, dest_settings, Path("settings.json")) + else: + shutil.copy2(vscode_settings_src, dest_settings) + + if tracker: + tracker.complete("copilot-extras", "prompts + vscode settings") + + return project_path + + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": @@ -948,8 +1221,12 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = for f in failures: console.print(f" - {f}") -def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None: - """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization).""" +def ensure_constitution_from_template(project_path: Path, project_name: str, tracker: StepTracker | None = None) -> None: + """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization). + + Auto-fills identity fields (project name, acronym, version, dates) from the + project name so that only principle/section placeholders remain for the user. + """ memory_constitution = project_path / ".specify" / "memory" / "constitution.md" template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" @@ -971,6 +1248,21 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | try: memory_constitution.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(template_constitution, memory_constitution) + + # Auto-fill identity fields + title = project_name.replace("-", " ").replace("_", " ").title() + words = title.split() + acronym = "".join(w[0] for w in words).upper() if len(words) > 1 else title[:3].upper() + today = datetime.now().strftime("%Y-%m-%d") + + content = memory_constitution.read_text() + content = content.replace("[PROJECT_NAME]", title) + content = content.replace("[PROJECT_ACRONYM]", acronym) + content = content.replace("[CONSTITUTION_VERSION]", "1.0.0") + content = content.replace("[RATIFICATION_DATE]", today) + content = content.replace("[LAST_AMENDED_DATE]", today) + memory_constitution.write_text(content) + if tracker: tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") @@ -995,6 +1287,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + local: str = typer.Option(None, "--local", help="Use local spec-kit source directory instead of downloading from GitHub"), ): """ Initialize a new Specify project from the latest template. @@ -1019,6 +1312,7 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --ai claude --script sh --local /path/to/spec-kit # Use local fork """ show_banner() @@ -1132,6 +1426,18 @@ def init( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + # Validate --local path if provided + local_path = None + if local: + local_path = Path(local).resolve() + if not local_path.is_dir(): + console.print(f"[red]Error:[/red] Local source directory not found: {local_path}") + raise typer.Exit(1) + if not (local_path / "templates").is_dir() or not (local_path / "scripts").is_dir(): + console.print(f"[red]Error:[/red] Local source directory must contain 'templates/' and 'scripts/' subdirectories: {local_path}") + raise typer.Exit(1) + console.print(f"[cyan]Using local source:[/cyan] {local_path}") + tracker = StepTracker("Initialize Specify Project") sys._specify_tracker_active = True @@ -1142,19 +1448,34 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), - ("chmod", "Ensure scripts executable"), - ("constitution", "Constitution setup"), - ("cleanup", "Cleanup"), - ("git", "Initialize git repository"), - ("final", "Finalize") - ]: - tracker.add(key, label) + + if local_path: + for key, label in [ + ("local-copy", "Copy from local source"), + ("commands", "Generate agent commands"), + ("chmod", "Ensure scripts executable"), + ("constitution", "Constitution setup"), + ("git", "Initialize git repository"), + ("final", "Finalize") + ]: + tracker.add(key, label) + # Add copilot-extras step if copilot is selected + if selected_ai == "copilot": + tracker.add("copilot-extras", "Generate copilot prompts & vscode settings") + else: + for key, label in [ + ("fetch", "Fetch latest release"), + ("download", "Download template"), + ("extract", "Extract template"), + ("zip-list", "Archive contents"), + ("extracted-summary", "Extraction summary"), + ("chmod", "Ensure scripts executable"), + ("constitution", "Constitution setup"), + ("cleanup", "Cleanup"), + ("git", "Initialize git repository"), + ("final", "Finalize") + ]: + tracker.add(key, label) # Track git error message outside Live context so it persists git_error_message = None @@ -1162,15 +1483,18 @@ def init( with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) + if local_path: + extract_template_from_local(project_path, selected_ai, selected_script, local_path, is_current_dir=here, tracker=tracker) + else: + verify = not skip_tls + local_ssl_context = ssl_context if verify else False + local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) ensure_executable_scripts(project_path, tracker=tracker) - ensure_constitution_from_template(project_path, tracker=tracker) + ensure_constitution_from_template(project_path, project_name, tracker=tracker) if not no_git: tracker.start("git") @@ -1284,6 +1608,51 @@ def init( console.print() console.print(enhancements_panel) +@app.command("fork-init") +def fork_init( + project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), + ai_assistant: str = typer.Option("claude", "--ai", help="AI assistant to use"), + script_type: str = typer.Option("sh", "--script", help="Script type to use: sh or ps"), + ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools"), + no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), + here: bool = typer.Option(False, "--here", help="Initialize in current directory"), + force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here"), + debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output"), +): + """Initialize a project from the local spec-kit fork (no GitHub download). + + Shorthand for 'specify init --local '. The source path is + derived automatically from the editable install location. + + Examples: + specify fork-init my-project --ai claude + specify fork-init . --ai claude --script sh + specify fork-init --here --ai copilot + """ + # Derive the repo root from this file's location (editable install) + # __file__ = .../spec-kit/src/specify_cli/__init__.py → repo root is 3 levels up + repo_root = Path(__file__).resolve().parent.parent.parent + if not (repo_root / "templates").is_dir(): + console.print(f"[red]Error:[/red] Could not locate local spec-kit source tree (expected at {repo_root})") + console.print("[dim]fork-init only works with editable installs (uv tool install --editable)[/dim]") + raise typer.Exit(1) + + # Delegate to init with --local pre-filled + init( + project_name=project_name, + ai_assistant=ai_assistant, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + no_git=no_git, + here=here, + force=force, + skip_tls=False, + debug=debug, + github_token=None, + local=str(repo_root), + ) + + @app.command() def check(): """Check that all required tools are installed.""" diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 63d4f662ae..085901ad14 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -29,6 +29,12 @@ Follow this execution flow: 2. Collect/derive values for placeholders: - If user input (conversation) supplies a value, use it. - Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded). + - `PROJECT_ACRONYM` derivation rules: + - If the user explicitly provides an acronym, use it. + - Otherwise derive from `PROJECT_NAME`: take the first letter of each word, uppercased (e.g., "Upwork Routine Automation" → "URA"). + - If `PROJECT_NAME` is a single word, use the first 3 letters uppercased (e.g., "Taskify" → "TAS"). + - The acronym must be 2-5 uppercase characters (`[A-Z]{2,5}`). + - **IMPORTANT**: The YAML front matter `---` delimiters in the constitution file must be preserved exactly. The `project_name` and `project_acronym` fields live inside the YAML front matter block at the top of the file. - For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous. - `CONSTITUTION_VERSION` must increment according to semantic versioning rules: - MAJOR: Backward incompatible governance/principle removals or redefinitions. diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 3c952d683e..e87772a968 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -27,6 +27,12 @@ The text the user typed after `/speckit.specify` in the triggering message **is* Given that feature description, do this: +0. **Constitution pre-check**: + Before proceeding, check if `.specify/memory/constitution.md` has its identity fields populated: + - Read the YAML frontmatter and check that `project_name` and `project_acronym` are set to real values (not `[PROJECT_NAME]` / `[PROJECT_ACRONYM]` placeholders) + - If either field is still a placeholder, run the full `/speckit.constitution` flow first + - Once both fields are populated, continue with step 1 + 1. **Generate a concise short name** (2-4 words) for the branch: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature @@ -48,9 +54,9 @@ Given that feature description, do this: ``` b. Find the highest feature number across all sources for the short-name: - - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-$'` - - Local branches: `git branch | grep -E '^[* ]*[0-9]+-$'` - - Specs directories: Check for directories matching `specs/[0-9]+-` + - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/feature/([A-Z]+-)?[0-9]+-$'` + - Local branches: `git branch | grep -E '^[* ]*(feature/)?([A-Z]+-)?[0-9]+-$'` + - Specs directories: Check for directories matching `specs/([A-Z]+-)?[0-9]+-` c. Determine the next available number: - Extract all numbers from all three sources diff --git a/templates/constitution-template.md b/templates/constitution-template.md index a4670ff469..c6298bc2d0 100644 --- a/templates/constitution-template.md +++ b/templates/constitution-template.md @@ -1,3 +1,8 @@ +--- +project_name: "[PROJECT_NAME]" +project_acronym: "[PROJECT_ACRONYM]" +--- + # [PROJECT_NAME] Constitution From 07048868ae6db80fe055f74d752c349adcb08f9b Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Thu, 19 Feb 2026 19:47:03 +0200 Subject: [PATCH 02/14] Allow digits in project acronym for branch name validation --- scripts/bash/common.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5cbf3af97f..1e886fda13 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -75,7 +75,7 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^(feature/([A-Z]+-)?)?[0-9]{3}- ]]; then + if [[ ! "$branch" =~ ^(feature/([A-Z0-9]+-)?)?[0-9]{3}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 echo "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" >&2 return 1 From 0a9aadcaaa768cbdb5a558cc1c1def437abb7dd1 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Sun, 15 Feb 2026 09:36:31 +0200 Subject: [PATCH 03/14] Add PROJECT_ACRONYM to branch naming and auto-fill constitution identity fields --- .gitignore | 4 + scripts/bash/common.sh | 41 +- scripts/bash/create-new-feature.sh | 137 +++- scripts/powershell/common.ps1 | 11 +- scripts/powershell/create-new-feature.ps1 | 113 ++- src/specify_cli/__init__.py | 838 +++++++++++----------- templates/commands/constitution.md | 6 + templates/commands/specify.md | 12 +- templates/constitution-template.md | 5 + 9 files changed, 713 insertions(+), 454 deletions(-) diff --git a/.gitignore b/.gitignore index 1688c8299e..2a05cc4cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ docs/dev .specify/extensions/.cache/ .specify/extensions/.backup/ .specify/extensions/*/local-config.yml + + +.claude/* +.test/* diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..5cbf3af97f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -37,7 +37,10 @@ get_current_branch() { for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then local dirname=$(basename "$dir") - if [[ "$dirname" =~ ^([0-9]{3})- ]]; then + # Match both "001-name" and "ACR-001-name" directory patterns + local stripped_dirname + stripped_dirname=$(echo "$dirname" | sed 's/^[A-Z]\{2,5\}-//') + if [[ "$stripped_dirname" =~ ^([0-9]{3})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then @@ -72,16 +75,16 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + if [[ ! "$branch" =~ ^(feature/([A-Z]+-)?)?[0-9]{3}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name" >&2 + echo "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" >&2 return 1 fi return 0 } -get_feature_dir() { echo "$1/specs/$2"; } +get_feature_dir() { local dir="${2#feature/}"; echo "$1/specs/$dir"; } # 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) @@ -90,29 +93,41 @@ find_feature_dir_by_prefix() { local branch_name="$2" local specs_dir="$repo_root/specs" - # Extract numeric prefix from branch (e.g., "004" from "004-whatever") - if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then + # Strip feature/ prefix for directory lookup + local dir_name="${branch_name#feature/}" + + # Extract numeric prefix from branch (e.g., "004" from "004-whatever" or "URA-004-whatever") + local stripped_name + stripped_name=$(echo "$dir_name" | sed 's/^[A-Z]\{2,5\}-//') + if [[ ! "$stripped_name" =~ ^([0-9]{3})- ]]; then # If branch doesn't have numeric prefix, fall back to exact match - echo "$specs_dir/$branch_name" + echo "$specs_dir/$dir_name" return fi local prefix="${BASH_REMATCH[1]}" - # Search for directories in specs/ that start with this prefix + # Search for directories in specs/ that match this numeric prefix + # Match both "001-name" and "ACR-001-name" patterns local matches=() if [[ -d "$specs_dir" ]]; then - for dir in "$specs_dir"/"$prefix"-*; do + for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then - matches+=("$(basename "$dir")") + local base + base=$(basename "$dir") + local base_stripped + base_stripped=$(echo "$base" | sed 's/^[A-Z]\{2,5\}-//') + if [[ "$base_stripped" =~ ^${prefix}- ]]; then + matches+=("$base") + fi fi done fi # Handle results if [[ ${#matches[@]} -eq 0 ]]; then - # No match found - return the branch name path (will fail later with clear error) - echo "$specs_dir/$branch_name" + # No match found - return the dir name path (will fail later with clear error) + echo "$specs_dir/$dir_name" elif [[ ${#matches[@]} -eq 1 ]]; then # Exactly one match - perfect! echo "$specs_dir/${matches[0]}" @@ -120,7 +135,7 @@ find_feature_dir_by_prefix() { # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2 - echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + echo "$specs_dir/$dir_name" # Return something to avoid breaking the script fi } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54697024d6..4011637401 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -96,7 +96,10 @@ get_highest_from_specs() { for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + local stripped_dirname + stripped_dirname=$(echo "$dirname" | sed 's/^[A-Z]\{2,5\}-//') + number=$(echo "$stripped_dirname" | grep -o '^[0-9]\+' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -119,9 +122,15 @@ get_highest_from_branches() { # Clean branch name: remove leading markers and remote prefixes clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + # Strip feature/ prefix if present + clean_branch="${clean_branch#feature/}" + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + local stripped_branch + stripped_branch=$(echo "$clean_branch" | sed 's/^[A-Z]\{2,5\}-//') + # Extract feature number if branch matches pattern ###-* - if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then - number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + if echo "$stripped_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$stripped_branch" | grep -o '^[0-9]\{3\}' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -162,6 +171,60 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Function to extract project acronym from constitution.md +get_project_acronym() { + local repo_root="$1" + local constitution="$repo_root/.specify/memory/constitution.md" + + if [ ! -f "$constitution" ]; then + echo "" + return + fi + + # Try to extract project_acronym from YAML front matter + local acronym="" + if head -1 "$constitution" | grep -q '^---$'; then + acronym=$(awk '/^---$/{n++; next} n==1 && /^project_acronym:/{sub(/^project_acronym:[[:space:]]*/,""); gsub(/^["'"'"']|["'"'"']$/,""); print; exit}' "$constitution") + fi + + # Skip if placeholder or empty + if [ -n "$acronym" ] && [ "$acronym" != "[PROJECT_ACRONYM]" ]; then + echo "$acronym" + return + fi + + # Fallback: derive from H1 heading (e.g., "# Upwork Routine Automation Constitution") + local heading + heading=$(grep -m1 '^# ' "$constitution" | sed 's/^# //') + if [ -z "$heading" ]; then + echo "" + return + fi + + # Skip if heading is still a placeholder + if echo "$heading" | grep -q '\[PROJECT_NAME\]'; then + echo "" + return + fi + + # Remove trailing "Constitution" if present + heading=$(echo "$heading" | sed 's/[[:space:]]*Constitution[[:space:]]*$//') + + # Count words + local word_count + word_count=$(echo "$heading" | wc -w | tr -d ' ') + + if [ "$word_count" -eq 1 ]; then + # Single word: first 3 letters uppercased + echo "$heading" | tr '[:lower:]' '[:upper:]' | cut -c1-3 + elif [ "$word_count" -ge 2 ]; then + # Multiple words: first letter of each word + echo "$heading" | tr '[:lower:]' '[:upper:]' | sed 's/[[:space:]]\+/ /g' | sed 's/\([A-Z]\)[^ ]*/\1/g' | tr -d ' ' + else + echo "" + fi +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -255,24 +318,69 @@ fi # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") -BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + +# Get project acronym from constitution +PROJECT_ACRONYM=$(get_project_acronym "$REPO_ROOT") + +# If no acronym found, ask the user +if [ -z "$PROJECT_ACRONYM" ]; then + CONSTITUTION_FILE="$REPO_ROOT/.specify/memory/constitution.md" + >&2 echo "" + >&2 printf "[specify] Enter PROJECT_ACRONYM (2-5 uppercase letters, or press Enter to skip): " + read -r user_acronym || user_acronym="" + # Uppercase and trim + user_acronym=$(echo "$user_acronym" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') + if [[ "$user_acronym" =~ ^[A-Z]{2,5}$ ]]; then + PROJECT_ACRONYM="$user_acronym" + # Persist to constitution if file exists + if [ -f "$CONSTITUTION_FILE" ]; then + if head -1 "$CONSTITUTION_FILE" | grep -q '^---$'; then + if grep -q '^project_acronym:' "$CONSTITUTION_FILE"; then + sed -i.bak "s/^project_acronym:.*$/project_acronym: \"$PROJECT_ACRONYM\"/" "$CONSTITUTION_FILE" + rm -f "$CONSTITUTION_FILE.bak" + else + sed -i.bak "1a\\ +project_acronym: \"$PROJECT_ACRONYM\"" "$CONSTITUTION_FILE" + rm -f "$CONSTITUTION_FILE.bak" + fi + >&2 echo "[specify] Saved PROJECT_ACRONYM=$PROJECT_ACRONYM to constitution." + fi + fi + elif [ -n "$user_acronym" ]; then + >&2 echo "[specify] Invalid acronym (must be 2-5 uppercase letters). Skipping." + fi +fi + +if [ -n "$PROJECT_ACRONYM" ]; then + BRANCH_NAME="feature/${PROJECT_ACRONYM}-${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + BRANCH_NAME="feature/${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4)) - + # Calculate prefix length: "feature/" (8) + optional acronym + hyphen + feature number (3) + hyphen (1) + if [ -n "$PROJECT_ACRONYM" ]; then + PREFIX_LENGTH=$((8 + ${#PROJECT_ACRONYM} + 1 + 3 + 1)) + else + PREFIX_LENGTH=$((8 + 3 + 1)) + fi + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + # Truncate suffix at word boundary if possible TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) # Remove trailing hyphen if truncation created one TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" - + if [ -n "$PROJECT_ACRONYM" ]; then + BRANCH_NAME="feature/${PROJECT_ACRONYM}-${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + else + BRANCH_NAME="feature/${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + fi + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" @@ -293,7 +401,9 @@ else >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +# Strip feature/ prefix for spec directory name (avoids specs/feature/ nesting) +SPEC_DIR_NAME="${BRANCH_NAME#feature/}" +FEATURE_DIR="$SPECS_DIR/$SPEC_DIR_NAME" mkdir -p "$FEATURE_DIR" TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" @@ -304,10 +414,11 @@ 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","PROJECT_ACRONYM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$PROJECT_ACRONYM" else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" + echo "PROJECT_ACRONYM: $PROJECT_ACRONYM" echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..87a619d7c6 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -40,7 +40,9 @@ function Get-CurrentBranch { $highest = 0 Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { + # Match both "001-name" and "ACR-001-name" directory patterns + $dirName = $_.Name -replace '^[A-Z]{2,5}-', '' + if ($dirName -match '^(\d{3})-') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num @@ -79,9 +81,9 @@ function Test-FeatureBranch { return $true } - if ($Branch -notmatch '^[0-9]{3}-') { + if ($Branch -notmatch '^(feature/([A-Z]+-)?)?[0-9]{3}-') { Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" + Write-Output "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" return $false } return $true @@ -89,7 +91,8 @@ function Test-FeatureBranch { function Get-FeatureDir { param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" + $dir = $Branch -replace '^feature/', '' + Join-Path $RepoRoot "specs/$dir" } function Get-FeaturePathsEnv { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 8f88b6c568..a7f6a471c1 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -71,7 +71,9 @@ function Get-HighestNumberFromSpecs { $highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + $dirName = $_.Name -replace '^[A-Z]{2,5}-', '' + if ($dirName -match '^(\d+)') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num } } @@ -91,6 +93,11 @@ function Get-HighestNumberFromBranches { # Clean branch name: remove leading markers and remote prefixes $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + # Strip feature/ prefix if present + $cleanBranch = $cleanBranch -replace '^feature/', '' + # Strip optional acronym prefix (e.g., "URA-" from "URA-001-name") + $cleanBranch = $cleanBranch -replace '^[A-Z]{2,5}-', '' + # Extract feature number if branch matches pattern ###-* if ($cleanBranch -match '^(\d+)-') { $num = [int]$matches[1] @@ -132,9 +139,63 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } + +function Get-ProjectAcronym { + param([string]$RepoRoot) + + $constitution = Join-Path $RepoRoot '.specify/memory/constitution.md' + if (-not (Test-Path $constitution)) { + return '' + } + + $content = Get-Content $constitution -Raw + + # Try to extract project_acronym from YAML front matter + if ($content -match '(?ms)\A---\s*\n(.*?)\n---') { + $frontMatter = $matches[1] + if ($frontMatter -match 'project_acronym:\s*"?([^"\n]+)"?') { + $acronym = $matches[1].Trim().Trim('"').Trim("'") + if ($acronym -and $acronym -ne '[PROJECT_ACRONYM]') { + return $acronym + } + } + } + + # Fallback: derive from H1 heading + $lines = Get-Content $constitution + $heading = '' + foreach ($line in $lines) { + if ($line -match '^# (.+)') { + $heading = $matches[1].Trim() + break + } + } + + if (-not $heading) { + return '' + } + + # Remove trailing "Constitution" + $heading = $heading -replace '\s*Constitution\s*$', '' + + $words = ($heading.Trim() -split '\s+') | Where-Object { $_ } + + if ($words.Count -eq 1) { + # Single word: first 3 letters uppercased + $word = $words[0].ToUpper() + return $word.Substring(0, [Math]::Min(3, $word.Length)) + } elseif ($words.Count -ge 2) { + # Multiple words: first letter of each word + $acronym = ($words | ForEach-Object { $_[0] }) -join '' + return $acronym.ToUpper() + } + + return '' +} + $fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) if (-not $fallbackRoot) { Write-Error "Error: Could not determine repository root. Please run this script from within the repository." @@ -224,24 +285,40 @@ if ($Number -eq 0) { } $featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" + +# Get project acronym from constitution +$projectAcronym = Get-ProjectAcronym -RepoRoot $repoRoot + +if ($projectAcronym) { + $branchName = "feature/$projectAcronym-$featureNum-$branchSuffix" +} else { + $branchName = "feature/$featureNum-$branchSuffix" +} # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 - + # Calculate prefix length: "feature/" (8) + optional acronym + hyphen + feature number (3) + hyphen (1) + if ($projectAcronym) { + $prefixLength = 8 + $projectAcronym.Length + 1 + 3 + 1 + } else { + $prefixLength = 8 + 3 + 1 + } + $maxSuffixLength = $maxBranchLength - $prefixLength + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" - + if ($projectAcronym) { + $branchName = "feature/$projectAcronym-$featureNum-$truncatedSuffix" + } else { + $branchName = "feature/$featureNum-$truncatedSuffix" + } + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" @@ -273,25 +350,28 @@ if ($hasGit) { Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } -$featureDir = Join-Path $specsDir $branchName +# Strip feature/ prefix for spec directory name (avoids specs/feature/ nesting) +$specDirName = $branchName -replace '^feature/', '' +$featureDir = Join-Path $specsDir $specDirName New-Item -ItemType Directory -Path $featureDir -Force | Out-Null $template = Join-Path $repoRoot '.specify/templates/spec-template.md' $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null +if (Test-Path $template) { + Copy-Item $template $specFile -Force +} else { + New-Item -ItemType File -Path $specFile | Out-Null } # Set the SPECIFY_FEATURE environment variable for the current session $env:SPECIFY_FEATURE = $branchName if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum + PROJECT_ACRONYM = $projectAcronym HAS_GIT = $hasGit } $obj | ConvertTo-Json -Compress @@ -299,6 +379,7 @@ if ($Json) { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" + Write-Output "PROJECT_ACRONYM: $projectAcronym" Write-Output "HAS_GIT: $hasGit" Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ad84210135..ecadad0eeb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -32,7 +32,6 @@ import shutil import shlex import json -import yaml from pathlib import Path from typing import Optional, Tuple @@ -123,171 +122,141 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) return "\n".join(lines) -# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory +# Agent configuration with name, folder, install URL, and CLI tool requirement AGENT_CONFIG = { "copilot": { "name": "GitHub Copilot", "folder": ".github/", - "commands_subdir": "agents", # Special: uses agents/ not commands/ "install_url": None, # IDE-based, no CLI check needed "requires_cli": False, }, "claude": { "name": "Claude Code", "folder": ".claude/", - "commands_subdir": "commands", "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, }, "gemini": { "name": "Gemini CLI", "folder": ".gemini/", - "commands_subdir": "commands", "install_url": "https://github.com/google-gemini/gemini-cli", "requires_cli": True, }, "cursor-agent": { "name": "Cursor", "folder": ".cursor/", - "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, "qwen": { "name": "Qwen Code", "folder": ".qwen/", - "commands_subdir": "commands", "install_url": "https://github.com/QwenLM/qwen-code", "requires_cli": True, }, "opencode": { "name": "opencode", "folder": ".opencode/", - "commands_subdir": "command", # Special: singular 'command' not 'commands' "install_url": "https://opencode.ai", "requires_cli": True, }, "codex": { "name": "Codex CLI", "folder": ".codex/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ "install_url": "https://github.com/openai/codex", "requires_cli": True, }, "windsurf": { "name": "Windsurf", "folder": ".windsurf/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "kilocode": { "name": "Kilo Code", "folder": ".kilocode/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "auggie": { "name": "Auggie CLI", "folder": ".augment/", - "commands_subdir": "commands", "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", "requires_cli": True, }, "codebuddy": { "name": "CodeBuddy", "folder": ".codebuddy/", - "commands_subdir": "commands", "install_url": "https://www.codebuddy.ai/cli", "requires_cli": True, }, - "qodercli": { + "qoder": { "name": "Qoder CLI", "folder": ".qoder/", - "commands_subdir": "commands", "install_url": "https://qoder.com/cli", "requires_cli": True, }, "roo": { "name": "Roo Code", "folder": ".roo/", - "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, - "kiro-cli": { - "name": "Kiro CLI", - "folder": ".kiro/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ - "install_url": "https://kiro.dev/docs/cli/", + "q": { + "name": "Amazon Q Developer CLI", + "folder": ".amazonq/", + "install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/", "requires_cli": True, }, "amp": { "name": "Amp", "folder": ".agents/", - "commands_subdir": "commands", "install_url": "https://ampcode.com/manual#install", "requires_cli": True, }, "shai": { "name": "SHAI", "folder": ".shai/", - "commands_subdir": "commands", "install_url": "https://github.com/ovh/shai", "requires_cli": True, }, "agy": { "name": "Antigravity", "folder": ".agent/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "bob": { "name": "IBM Bob", "folder": ".bob/", - "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, - "generic": { - "name": "Generic (bring your own agent)", - "folder": None, # Set dynamically via --ai-commands-dir - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - }, } -AI_ASSISTANT_ALIASES = { - "kiro": "kiro-cli", +# Agent command config: maps agent -> (command_folder, file_extension, arg_token) +# Used by extract_template_from_local() to generate agent-specific command files. +AGENT_COMMAND_CONFIG = { + "claude": (".claude/commands", "md", "$ARGUMENTS"), + "gemini": (".gemini/commands", "toml", "{{args}}"), + "copilot": (".github/agents", "agent.md", "$ARGUMENTS"), + "cursor-agent": (".cursor/commands", "md", "$ARGUMENTS"), + "qwen": (".qwen/commands", "toml", "{{args}}"), + "opencode": (".opencode/command", "md", "$ARGUMENTS"), + "windsurf": (".windsurf/workflows", "md", "$ARGUMENTS"), + "codex": (".codex/prompts", "md", "$ARGUMENTS"), + "kilocode": (".kilocode/workflows", "md", "$ARGUMENTS"), + "auggie": (".augment/commands", "md", "$ARGUMENTS"), + "roo": (".roo/commands", "md", "$ARGUMENTS"), + "codebuddy": (".codebuddy/commands", "md", "$ARGUMENTS"), + "qoder": (".qoder/commands", "md", "$ARGUMENTS"), + "amp": (".agents/commands", "md", "$ARGUMENTS"), + "shai": (".shai/commands", "md", "$ARGUMENTS"), + "q": (".amazonq/prompts", "md", "$ARGUMENTS"), + "agy": (".agent/workflows", "md", "$ARGUMENTS"), + "bob": (".bob/commands", "md", "$ARGUMENTS"), } -def _build_ai_assistant_help() -> str: - """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" - - non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic") - base_help = ( - f"AI assistant to use: {', '.join(non_generic_agents)}, " - "or generic (requires --ai-commands-dir)." - ) - - if not AI_ASSISTANT_ALIASES: - return base_help - - alias_phrases = [] - for alias, target in sorted(AI_ASSISTANT_ALIASES.items()): - alias_phrases.append(f"'{alias}' as an alias for '{target}'") - - if len(alias_phrases) == 1: - aliases_text = alias_phrases[0] - else: - aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] - - return base_help + " Use " + aliases_text + "." -AI_ASSISTANT_HELP = _build_ai_assistant_help() - SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -301,7 +270,7 @@ def _build_ai_assistant_help() -> str: ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ """ -TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" +TAGLINE = "Forked GitHub Spec Kit - Spec-Driven Development Toolkit" class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. @@ -562,12 +531,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: tracker.complete(tool, "available") return True - if tool == "kiro-cli": - # Kiro currently supports both executable names. Prefer kiro-cli and - # accept kiro as a compatibility fallback. - found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None - else: - found = shutil.which(tool) is not None + found = shutil.which(tool) is not None if tracker: if found: @@ -728,7 +692,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri except ValueError as je: raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") except Exception as e: - console.print("[red]Error fetching release information[/red]") + console.print(f"[red]Error fetching release information[/red]") console.print(Panel(str(e), title="Fetch Error", border_style="red")) raise typer.Exit(1) @@ -758,7 +722,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri zip_path = download_dir / filename if verbose: - console.print("[cyan]Downloading template...[/cyan]") + console.print(f"[cyan]Downloading template...[/cyan]") try: with client.stream( @@ -797,7 +761,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) except Exception as e: - console.print("[red]Error downloading template[/red]") + console.print(f"[red]Error downloading template[/red]") detail = str(e) if zip_path.exists(): zip_path.unlink() @@ -881,7 +845,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print("[cyan]Found nested directory structure[/cyan]") + console.print(f"[cyan]Found nested directory structure[/cyan]") for item in source_dir.iterdir(): dest_path = project_path / item.name @@ -906,7 +870,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") shutil.copy2(item, dest_path) if verbose and not tracker: - console.print("[cyan]Template files merged into current directory[/cyan]") + console.print(f"[cyan]Template files merged into current directory[/cyan]") else: zip_ref.extractall(project_path) @@ -932,7 +896,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print("[cyan]Flattened nested directory structure[/cyan]") + console.print(f"[cyan]Flattened nested directory structure[/cyan]") except Exception as e: if tracker: @@ -963,6 +927,256 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +import re as _re + +def _rewrite_paths(text: str) -> str: + """Rewrite bare memory/, scripts/, templates/ paths to .specify/ equivalents.""" + text = _re.sub(r'(/?)memory/', r'.specify/memory/', text) + text = _re.sub(r'(/?)scripts/', r'.specify/scripts/', text) + text = _re.sub(r'(/?)templates/', r'.specify/templates/', text) + text = text.replace('.specify.specify/', '.specify/') + return text + + +def _parse_command_template(template_path: Path, script_variant: str) -> dict: + """Parse a command template file's YAML frontmatter and body. + + Returns dict with keys: name, description, script_command, agent_script_command, body_raw. + """ + name = template_path.stem + content = template_path.read_text(encoding="utf-8").replace("\r", "") + + # Split frontmatter from body + parts = content.split("---", 2) + if len(parts) < 3: + # No proper frontmatter + return {"name": name, "description": "", "script_command": "", "agent_script_command": "", "body_raw": content} + + frontmatter = parts[1] + body_after_frontmatter = parts[2] + + # Extract description + description = "" + for line in frontmatter.splitlines(): + if line.startswith("description:"): + description = line.split(":", 1)[1].strip() + break + + # Extract script command from scripts: section + script_command = "" + in_scripts = False + for line in frontmatter.splitlines(): + if _re.match(r'^scripts:\s*$', line): + in_scripts = True + continue + if in_scripts: + stripped = line.lstrip() + if stripped.startswith(f"{script_variant}:"): + script_command = stripped.split(":", 1)[1].strip() + break + # If we hit a non-indented line that's a new top-level key, stop + if line and not line[0].isspace(): + in_scripts = False + + # Extract agent_script command from agent_scripts: section + agent_script_command = "" + in_agent_scripts = False + for line in frontmatter.splitlines(): + if _re.match(r'^agent_scripts:\s*$', line): + in_agent_scripts = True + continue + if in_agent_scripts: + stripped = line.lstrip() + if stripped.startswith(f"{script_variant}:"): + agent_script_command = stripped.split(":", 1)[1].strip() + break + if line and not line[0].isspace(): + in_agent_scripts = False + + # Remove scripts: and agent_scripts: sections from frontmatter + cleaned_fm_lines = [] + skip_section = False + for line in frontmatter.splitlines(): + if _re.match(r'^(scripts|agent_scripts):\s*$', line): + skip_section = True + continue + if skip_section: + if line and not line[0].isspace(): + # New top-level key, stop skipping + skip_section = False + cleaned_fm_lines.append(line) + # else: still in indented sub-keys, skip + continue + cleaned_fm_lines.append(line) + + cleaned_frontmatter = "\n".join(cleaned_fm_lines) + body_raw = f"---{cleaned_frontmatter}---{body_after_frontmatter}" + + return { + "name": name, + "description": description, + "script_command": script_command or f"(Missing script command for {script_variant})", + "agent_script_command": agent_script_command, + "body_raw": body_raw, + } + + +def _generate_command_file(parsed: dict, agent: str, ext: str, arg_format: str, output_dir: Path) -> None: + """Generate a single agent command file from a parsed command template.""" + body = parsed["body_raw"] + + # Replace {SCRIPT} placeholder + body = body.replace("{SCRIPT}", parsed["script_command"]) + + # Replace {AGENT_SCRIPT} placeholder + if parsed["agent_script_command"]: + body = body.replace("{AGENT_SCRIPT}", parsed["agent_script_command"]) + + # Replace {ARGS} and __AGENT__ + body = body.replace("{ARGS}", arg_format) + body = body.replace("__AGENT__", agent) + + # Rewrite paths + body = _rewrite_paths(body) + + output_dir.mkdir(parents=True, exist_ok=True) + name = parsed["name"] + + if ext == "toml": + body = body.replace("\\", "\\\\") + content = f'description = "{parsed["description"]}"\n\nprompt = """\n{body}\n"""\n' + (output_dir / f"speckit.{name}.{ext}").write_text(content, encoding="utf-8") + elif ext in ("md", "agent.md"): + (output_dir / f"speckit.{name}.{ext}").write_text(body, encoding="utf-8") + + +def extract_template_from_local( + project_path: Path, + ai_assistant: str, + script_type: str, + source_dir: Path, + is_current_dir: bool = False, + tracker: StepTracker | None = None, +) -> Path: + """Build a project from the local spec-kit source tree instead of downloading a release zip. + + Replicates the logic of create-release-packages.sh's build_variant() and generate_commands(). + """ + if tracker: + tracker.start("local-copy", "copying from local source") + + if not is_current_dir: + project_path.mkdir(parents=True, exist_ok=True) + + spec_dir = project_path / ".specify" + spec_dir.mkdir(parents=True, exist_ok=True) + + # (a) Copy templates (excluding commands/ and vscode-settings.json) + src_templates = source_dir / "templates" + if src_templates.is_dir(): + dest_templates = spec_dir / "templates" + dest_templates.mkdir(parents=True, exist_ok=True) + for item in src_templates.iterdir(): + if item.is_file() and item.name != "vscode-settings.json": + dest_file = dest_templates / item.name + if dest_file.exists() and is_current_dir: + pass # will overwrite + shutil.copy2(item, dest_file) + + # (b) Copy scripts (filtered by script_type) + src_scripts = source_dir / "scripts" + if src_scripts.is_dir(): + dest_scripts = spec_dir / "scripts" + dest_scripts.mkdir(parents=True, exist_ok=True) + if script_type == "sh": + src_bash = src_scripts / "bash" + if src_bash.is_dir(): + dest_bash = dest_scripts / "bash" + if dest_bash.exists(): + shutil.rmtree(dest_bash) + shutil.copytree(src_bash, dest_bash) + elif script_type == "ps": + src_ps = src_scripts / "powershell" + if src_ps.is_dir(): + dest_ps = dest_scripts / "powershell" + if dest_ps.exists(): + shutil.rmtree(dest_ps) + shutil.copytree(src_ps, dest_ps) + # Copy any top-level script files + for item in src_scripts.iterdir(): + if item.is_file(): + shutil.copy2(item, dest_scripts / item.name) + + # (c) Copy memory (if exists) + src_memory = source_dir / "memory" + if src_memory.is_dir(): + dest_memory = spec_dir / "memory" + if dest_memory.exists(): + # Merge: copy files that don't exist yet + for item in src_memory.rglob("*"): + if item.is_file(): + rel = item.relative_to(src_memory) + dest_file = dest_memory / rel + dest_file.parent.mkdir(parents=True, exist_ok=True) + if not dest_file.exists(): + shutil.copy2(item, dest_file) + else: + shutil.copytree(src_memory, dest_memory) + + if tracker: + tracker.complete("local-copy", "templates, scripts, memory") + + # (d) Generate agent command files + if tracker: + tracker.start("commands", "generating agent commands") + + commands_dir = source_dir / "templates" / "commands" + if commands_dir.is_dir() and ai_assistant in AGENT_COMMAND_CONFIG: + folder, ext, arg_format = AGENT_COMMAND_CONFIG[ai_assistant] + output_dir = project_path / folder + + for template_file in sorted(commands_dir.glob("*.md")): + parsed = _parse_command_template(template_file, script_type) + _generate_command_file(parsed, ai_assistant, ext, arg_format, output_dir) + + if tracker: + tracker.complete("commands", f"{ai_assistant} -> {folder}") + else: + if tracker: + tracker.complete("commands", "no command templates found") + + # (e) Copilot special handling + if ai_assistant == "copilot": + if tracker: + tracker.start("copilot-extras", "generating copilot prompts & vscode settings") + + # Generate .github/prompts/speckit.{name}.prompt.md files + agents_dir = project_path / ".github" / "agents" + prompts_dir = project_path / ".github" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + + for agent_file in sorted(agents_dir.glob("speckit.*.agent.md")): + basename = agent_file.name.replace(".agent.md", "") + prompt_file = prompts_dir / f"{basename}.prompt.md" + prompt_file.write_text(f"---\nagent: {basename}\n---\n", encoding="utf-8") + + # Copy vscode-settings.json -> .vscode/settings.json + vscode_settings_src = source_dir / "templates" / "vscode-settings.json" + if vscode_settings_src.exists(): + vscode_dir = project_path / ".vscode" + vscode_dir.mkdir(parents=True, exist_ok=True) + dest_settings = vscode_dir / "settings.json" + if dest_settings.exists(): + handle_vscode_settings(vscode_settings_src, dest_settings, Path("settings.json")) + else: + shutil.copy2(vscode_settings_src, dest_settings) + + if tracker: + tracker.complete("copilot-extras", "prompts + vscode settings") + + return project_path + + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": @@ -982,17 +1196,13 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = continue except Exception: continue - st = script.stat() - mode = st.st_mode + st = script.stat(); mode = st.st_mode if mode & 0o111: continue new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 + if mode & 0o400: new_mode |= 0o100 + if mode & 0o040: new_mode |= 0o010 + if mode & 0o004: new_mode |= 0o001 if not (new_mode & 0o100): new_mode |= 0o100 os.chmod(script, new_mode) @@ -1011,8 +1221,12 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = for f in failures: console.print(f" - {f}") -def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None: - """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization).""" +def ensure_constitution_from_template(project_path: Path, project_name: str, tracker: StepTracker | None = None) -> None: + """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization). + + Auto-fills identity fields (project name, acronym, version, dates) from the + project name so that only principle/section placeholders remain for the user. + """ memory_constitution = project_path / ".specify" / "memory" / "constitution.md" template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" @@ -1034,11 +1248,26 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | try: memory_constitution.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(template_constitution, memory_constitution) + + # Auto-fill identity fields + title = project_name.replace("-", " ").replace("_", " ").title() + words = title.split() + acronym = "".join(w[0] for w in words).upper() if len(words) > 1 else title[:3].upper() + today = datetime.now().strftime("%Y-%m-%d") + + content = memory_constitution.read_text() + content = content.replace("[PROJECT_NAME]", title) + content = content.replace("[PROJECT_ACRONYM]", acronym) + content = content.replace("[CONSTITUTION_VERSION]", "1.0.0") + content = content.replace("[RATIFICATION_DATE]", today) + content = content.replace("[LAST_AMENDED_DATE]", today) + memory_constitution.write_text(content) + if tracker: tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") else: - console.print("[cyan]Initialized constitution from template[/cyan]") + console.print(f"[cyan]Initialized constitution from template[/cyan]") except Exception as e: if tracker: tracker.add("constitution", "Constitution setup") @@ -1046,209 +1275,10 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") -# Agent-specific skill directory overrides for agents whose skills directory -# doesn't follow the standard /skills/ pattern -AGENT_SKILLS_DIR_OVERRIDES = { - "codex": ".agents/skills", # Codex agent layout override -} - -# Default skills directory for agents not in AGENT_CONFIG -DEFAULT_SKILLS_DIR = ".agents/skills" - -# Enhanced descriptions for each spec-kit command skill -SKILL_DESCRIPTIONS = { - "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", - "plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.", - "tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.", - "implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.", - "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.", - "clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.", - "constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.", - "checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.", - "taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.", -} - - -def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: - """Resolve the agent-specific skills directory for the given AI assistant. - - Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to - ``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to - ``DEFAULT_SKILLS_DIR``. - """ - if selected_ai in AGENT_SKILLS_DIR_OVERRIDES: - return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai] - - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - if agent_folder: - return project_path / agent_folder.rstrip("/") / "skills" - - return project_path / DEFAULT_SKILLS_DIR - - -def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool: - """Install Prompt.MD files from templates/commands/ as agent skills. - - Skills are written to the agent-specific skills directory following the - `agentskills.io `_ specification. - Installation is additive — existing files are never removed and prompt - command files in the agent's commands directory are left untouched. - - Args: - project_path: Target project directory. - selected_ai: AI assistant key from ``AGENT_CONFIG``. - tracker: Optional progress tracker. - - Returns: - ``True`` if at least one skill was installed or all skills were - already present (idempotent re-run), ``False`` otherwise. - """ - # Locate command templates in the agent's extracted commands directory. - # download_and_extract_template() already placed the .md files here. - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - commands_subdir = agent_config.get("commands_subdir", "commands") - if agent_folder: - templates_dir = project_path / agent_folder.rstrip("/") / commands_subdir - else: - templates_dir = project_path / commands_subdir - - if not templates_dir.exists() or not any(templates_dir.glob("*.md")): - # Fallback: try the repo-relative path (for running from source checkout) - # This also covers agents whose extracted commands are in a different - # format (e.g. gemini uses .toml, not .md). - script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ - fallback_dir = script_dir / "templates" / "commands" - if fallback_dir.exists() and any(fallback_dir.glob("*.md")): - templates_dir = fallback_dir - - if not templates_dir.exists() or not any(templates_dir.glob("*.md")): - if tracker: - tracker.error("ai-skills", "command templates not found") - else: - console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") - return False - - command_files = sorted(templates_dir.glob("*.md")) - if not command_files: - if tracker: - tracker.skip("ai-skills", "no command templates found") - else: - console.print("[yellow]No command templates found to install[/yellow]") - return False - - # Resolve the correct skills directory for this agent - skills_dir = _get_skills_dir(project_path, selected_ai) - skills_dir.mkdir(parents=True, exist_ok=True) - - if tracker: - tracker.start("ai-skills") - - installed_count = 0 - skipped_count = 0 - for command_file in command_files: - try: - content = command_file.read_text(encoding="utf-8") - - # Parse YAML frontmatter - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - # File starts with --- but has no closing --- - console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") - frontmatter = {} - body = content - else: - frontmatter = {} - body = content - - command_name = command_file.stem - # Normalize: extracted commands may be named "speckit..md"; - # strip the "speckit." prefix so skill names stay clean and - # SKILL_DESCRIPTIONS lookups work. - if command_name.startswith("speckit."): - command_name = command_name[len("speckit."):] - skill_name = f"speckit-{command_name}" - - # Create skill directory (additive — never removes existing content) - skill_dir = skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - - # Select the best description available - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}") - - # Build SKILL.md following agentskills.io spec - # Use yaml.safe_dump to safely serialise the frontmatter and - # avoid YAML injection from descriptions containing colons, - # quotes, or newlines. - # Normalize source filename for metadata — strip speckit. prefix - # so it matches the canonical templates/commands/.md path. - source_name = command_file.name - if source_name.startswith("speckit."): - source_name = source_name[len("speckit."):] - - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"templates/commands/{source_name}", - }, - } - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {command_name.title()} Skill\n\n" - f"{body}\n" - ) - - skill_file = skill_dir / "SKILL.md" - if skill_file.exists(): - # Do not overwrite user-customized skills on re-runs - skipped_count += 1 - continue - skill_file.write_text(skill_content, encoding="utf-8") - installed_count += 1 - - except Exception as e: - console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]") - continue - - if tracker: - if installed_count > 0 and skipped_count > 0: - tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}") - elif installed_count > 0: - tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}") - elif skipped_count > 0: - tracker.complete("ai-skills", f"{skipped_count} skills already present") - else: - tracker.error("ai-skills", "no skills installed") - else: - if installed_count > 0: - console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/") - elif skipped_count > 0: - console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/") - else: - console.print("[yellow]No skills were installed[/yellow]") - - return installed_count > 0 or skipped_count > 0 - - @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), - ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), + ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, or qoder "), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), @@ -1257,7 +1287,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + local: str = typer.Option(None, "--local", help="Use local spec-kit source directory instead of downloading from GitHub"), ): """ Initialize a new Specify project from the latest template. @@ -1282,30 +1312,11 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --ai claude --ai-skills # Install agent skills - specify init --here --ai gemini --ai-skills - specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --ai claude --script sh --local /path/to/spec-kit # Use local fork """ show_banner() - # Detect when option values are likely misinterpreted flags (parameter ordering issue) - if ai_assistant and ai_assistant.startswith("--"): - console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") - console.print("[yellow]Example:[/yellow] specify init --ai claude --here") - console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") - raise typer.Exit(1) - - if ai_commands_dir and ai_commands_dir.startswith("--"): - console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") - console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") - raise typer.Exit(1) - - if ai_assistant: - ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) - if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -1318,11 +1329,6 @@ def init( console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) - if ai_skills and not ai_assistant: - console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") - console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") - raise typer.Exit(1) - if here: project_name = Path.cwd().name project_path = Path.cwd() @@ -1386,16 +1392,6 @@ def init( "copilot" ) - # Validate --ai-commands-dir usage - if selected_ai == "generic": - if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") - console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") - raise typer.Exit(1) - elif ai_commands_dir: - console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") - raise typer.Exit(1) - if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: @@ -1430,6 +1426,18 @@ def init( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + # Validate --local path if provided + local_path = None + if local: + local_path = Path(local).resolve() + if not local_path.is_dir(): + console.print(f"[red]Error:[/red] Local source directory not found: {local_path}") + raise typer.Exit(1) + if not (local_path / "templates").is_dir() or not (local_path / "scripts").is_dir(): + console.print(f"[red]Error:[/red] Local source directory must contain 'templates/' and 'scripts/' subdirectories: {local_path}") + raise typer.Exit(1) + console.print(f"[cyan]Using local source:[/cyan] {local_path}") + tracker = StepTracker("Initialize Specify Project") sys._specify_tracker_active = True @@ -1440,24 +1448,34 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), - ("chmod", "Ensure scripts executable"), - ("constitution", "Constitution setup"), - ]: - tracker.add(key, label) - if ai_skills: - tracker.add("ai-skills", "Install agent skills") - for key, label in [ - ("cleanup", "Cleanup"), - ("git", "Initialize git repository"), - ("final", "Finalize") - ]: - tracker.add(key, label) + + if local_path: + for key, label in [ + ("local-copy", "Copy from local source"), + ("commands", "Generate agent commands"), + ("chmod", "Ensure scripts executable"), + ("constitution", "Constitution setup"), + ("git", "Initialize git repository"), + ("final", "Finalize") + ]: + tracker.add(key, label) + # Add copilot-extras step if copilot is selected + if selected_ai == "copilot": + tracker.add("copilot-extras", "Generate copilot prompts & vscode settings") + else: + for key, label in [ + ("fetch", "Fetch latest release"), + ("download", "Download template"), + ("extract", "Extract template"), + ("zip-list", "Archive contents"), + ("extracted-summary", "Extraction summary"), + ("chmod", "Ensure scripts executable"), + ("constitution", "Constitution setup"), + ("cleanup", "Cleanup"), + ("git", "Initialize git repository"), + ("final", "Finalize") + ]: + tracker.add(key, label) # Track git error message outside Live context so it persists git_error_message = None @@ -1465,51 +1483,18 @@ def init( with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) - - # For generic agent, rename placeholder directory to user-specified path - if selected_ai == "generic" and ai_commands_dir: - placeholder_dir = project_path / ".speckit" / "commands" - target_dir = project_path / ai_commands_dir - if placeholder_dir.is_dir(): - target_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(placeholder_dir), str(target_dir)) - # Clean up empty .speckit dir if it's now empty - speckit_dir = project_path / ".speckit" - if speckit_dir.is_dir() and not any(speckit_dir.iterdir()): - speckit_dir.rmdir() + if local_path: + extract_template_from_local(project_path, selected_ai, selected_script, local_path, is_current_dir=here, tracker=tracker) + else: + verify = not skip_tls + local_ssl_context = ssl_context if verify else False + local_client = httpx.Client(verify=local_ssl_context) + + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) ensure_executable_scripts(project_path, tracker=tracker) - ensure_constitution_from_template(project_path, tracker=tracker) - - if ai_skills: - skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) - - # When --ai-skills is used on a NEW project and skills were - # successfully installed, remove the command files that the - # template archive just created. Skills replace commands, so - # keeping both would be confusing. For --here on an existing - # repo we leave pre-existing commands untouched to avoid a - # breaking change. We only delete AFTER skills succeed so the - # project always has at least one of {commands, skills}. - if skills_ok and not here: - agent_cfg = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - if cmds_dir.exists(): - try: - shutil.rmtree(cmds_dir) - except OSError: - # Best-effort cleanup: skills are already installed, - # so leaving stale commands is non-fatal. - console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") + ensure_constitution_from_template(project_path, project_name, tracker=tracker) if not no_git: tracker.start("git") @@ -1569,17 +1554,16 @@ def init( # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: - agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] - if agent_folder: - security_notice = Panel( - f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" - f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", - title="[yellow]Agent Folder Security[/yellow]", - border_style="yellow", - padding=(1, 2) - ) - console.print() - console.print(security_notice) + agent_folder = agent_config["folder"] + security_notice = Panel( + f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" + f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", + title="[yellow]Agent Folder Security[/yellow]", + border_style="yellow", + padding=(1, 2) + ) + console.print() + console.print(security_notice) steps_lines = [] if not here: @@ -1616,14 +1600,59 @@ def init( enhancement_lines = [ "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", "", - "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", - "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", + f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", + f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" ] enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) console.print() console.print(enhancements_panel) +@app.command("fork-init") +def fork_init( + project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), + ai_assistant: str = typer.Option("claude", "--ai", help="AI assistant to use"), + script_type: str = typer.Option("sh", "--script", help="Script type to use: sh or ps"), + ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools"), + no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), + here: bool = typer.Option(False, "--here", help="Initialize in current directory"), + force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here"), + debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output"), +): + """Initialize a project from the local spec-kit fork (no GitHub download). + + Shorthand for 'specify init --local '. The source path is + derived automatically from the editable install location. + + Examples: + specify fork-init my-project --ai claude + specify fork-init . --ai claude --script sh + specify fork-init --here --ai copilot + """ + # Derive the repo root from this file's location (editable install) + # __file__ = .../spec-kit/src/specify_cli/__init__.py → repo root is 3 levels up + repo_root = Path(__file__).resolve().parent.parent.parent + if not (repo_root / "templates").is_dir(): + console.print(f"[red]Error:[/red] Could not locate local spec-kit source tree (expected at {repo_root})") + console.print("[dim]fork-init only works with editable installs (uv tool install --editable)[/dim]") + raise typer.Exit(1) + + # Delegate to init with --local pre-filled + init( + project_name=project_name, + ai_assistant=ai_assistant, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + no_git=no_git, + here=here, + force=force, + skip_tls=False, + debug=debug, + github_token=None, + local=str(repo_root), + ) + + @app.command() def check(): """Check that all required tools are installed.""" @@ -1637,8 +1666,6 @@ def check(): agent_results = {} for agent_key, agent_config in AGENT_CONFIG.items(): - if agent_key == "generic": - continue # Generic is not a real agent to check agent_name = agent_config["name"] requires_cli = agent_config["requires_cli"] @@ -1653,10 +1680,10 @@ def check(): # Check VS Code variants (not in agent config) tracker.add("code", "Visual Studio Code") - check_tool("code", tracker=tracker) + code_ok = check_tool("code", tracker=tracker) tracker.add("code-insiders", "Visual Studio Code Insiders") - check_tool("code-insiders", tracker=tracker) + code_insiders_ok = check_tool("code-insiders", tracker=tracker) console.print(tracker.render()) @@ -1922,14 +1949,14 @@ def extension_add( if zip_path.exists(): zip_path.unlink() - console.print("\n[green]✓[/green] Extension installed successfully!") + console.print(f"\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") console.print(f" {manifest.description}") - console.print("\n[bold cyan]Provided commands:[/bold cyan]") + console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") - console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{manifest.id}/") except ValidationError as e: @@ -1979,11 +2006,11 @@ def extension_remove( # Confirm removal if not force: - console.print("\n[yellow]⚠ This will remove:[/yellow]") + console.print(f"\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") console.print(f" • Extension directory: .specify/extensions/{extension}/") if not keep_config: - console.print(" • Config files (will be backed up)") + console.print(f" • Config files (will be backed up)") console.print() confirm = typer.confirm("Continue?") @@ -2002,7 +2029,7 @@ def extension_remove( console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") console.print(f"\nTo reinstall: specify extension add {extension}") else: - console.print("[red]Error:[/red] Failed to remove extension") + console.print(f"[red]Error:[/red] Failed to remove extension") raise typer.Exit(1) @@ -2277,8 +2304,8 @@ def extension_update( # TODO: Implement download and reinstall from URL # For now, just show message console.print( - "[yellow]Note:[/yellow] Automatic update not yet implemented. " - "Please update manually:" + f"[yellow]Note:[/yellow] Automatic update not yet implemented. " + f"Please update manually:" ) console.print(f" specify extension remove {ext_id} --keep-config") console.print(f" specify extension add {ext_id}") @@ -2378,7 +2405,7 @@ def extension_disable( hook_executor.save_project_config(config) console.print(f"[green]✓[/green] Extension '{extension}' disabled") - console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"\nCommands will no longer be available. Hooks will not execute.") console.print(f"To re-enable: specify extension enable {extension}") @@ -2387,3 +2414,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 63d4f662ae..085901ad14 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -29,6 +29,12 @@ Follow this execution flow: 2. Collect/derive values for placeholders: - If user input (conversation) supplies a value, use it. - Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded). + - `PROJECT_ACRONYM` derivation rules: + - If the user explicitly provides an acronym, use it. + - Otherwise derive from `PROJECT_NAME`: take the first letter of each word, uppercased (e.g., "Upwork Routine Automation" → "URA"). + - If `PROJECT_NAME` is a single word, use the first 3 letters uppercased (e.g., "Taskify" → "TAS"). + - The acronym must be 2-5 uppercase characters (`[A-Z]{2,5}`). + - **IMPORTANT**: The YAML front matter `---` delimiters in the constitution file must be preserved exactly. The `project_name` and `project_acronym` fields live inside the YAML front matter block at the top of the file. - For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous. - `CONSTITUTION_VERSION` must increment according to semantic versioning rules: - MAJOR: Backward incompatible governance/principle removals or redefinitions. diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 5fd4489eee..1957de5bf6 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -27,6 +27,12 @@ The text the user typed after `/speckit.specify` in the triggering message **is* Given that feature description, do this: +0. **Constitution pre-check**: + Before proceeding, check if `.specify/memory/constitution.md` has its identity fields populated: + - Read the YAML frontmatter and check that `project_name` and `project_acronym` are set to real values (not `[PROJECT_NAME]` / `[PROJECT_ACRONYM]` placeholders) + - If either field is still a placeholder, run the full `/speckit.constitution` flow first + - Once both fields are populated, continue with step 1 + 1. **Generate a concise short name** (2-4 words) for the branch: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature @@ -48,9 +54,9 @@ Given that feature description, do this: ``` b. Find the highest feature number across all sources for the short-name: - - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-$'` - - Local branches: `git branch | grep -E '^[* ]*[0-9]+-$'` - - Specs directories: Check for directories matching `specs/[0-9]+-` + - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/feature/([A-Z]+-)?[0-9]+-$'` + - Local branches: `git branch | grep -E '^[* ]*(feature/)?([A-Z]+-)?[0-9]+-$'` + - Specs directories: Check for directories matching `specs/([A-Z]+-)?[0-9]+-` c. Determine the next available number: - Extract all numbers from all three sources diff --git a/templates/constitution-template.md b/templates/constitution-template.md index a4670ff469..c6298bc2d0 100644 --- a/templates/constitution-template.md +++ b/templates/constitution-template.md @@ -1,3 +1,8 @@ +--- +project_name: "[PROJECT_NAME]" +project_acronym: "[PROJECT_ACRONYM]" +--- + # [PROJECT_NAME] Constitution From 86d29a8f5ef3baff8655eac57b1c52a1ff812db6 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Thu, 19 Feb 2026 19:47:03 +0200 Subject: [PATCH 04/14] Allow digits in project acronym for branch name validation --- scripts/bash/common.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5cbf3af97f..1e886fda13 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -75,7 +75,7 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^(feature/([A-Z]+-)?)?[0-9]{3}- ]]; then + if [[ ! "$branch" =~ ^(feature/([A-Z0-9]+-)?)?[0-9]{3}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 echo "Feature branches should be named like: feature/001-feature-name or feature/URA-001-feature-name" >&2 return 1 From f725a2c0c80115a71a28ec56f36f4771f4e5bb3e Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Wed, 4 Mar 2026 09:35:12 +0200 Subject: [PATCH 05/14] Add opt-in --simple flag for Implementation Constraints Users can pass --simple/--simplify or say "make it simple" to /speckit.constitution or /speckit.specify to inject an immutable Implementation Constraints section into the constitution. Once added, the section persists through all future constitution edits. --- README.md | 4 ++-- templates/commands/constitution.md | 16 ++++++++++++++++ templates/commands/specify.md | 1 + 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5316c3a2a4..45857a953b 100644 --- a/README.md +++ b/README.md @@ -267,8 +267,8 @@ Essential commands for the Spec-Driven Development workflow: | Command | Description | | ----------------------- | ------------------------------------------------------------------------ | -| `/speckit.constitution` | Create or update project governing principles and development guidelines | -| `/speckit.specify` | Define what you want to build (requirements and user stories) | +| `/speckit.constitution` | Create or update project governing principles and development guidelines. Use `--simple` (or "make it simple") to add immutable Implementation Constraints that enforce minimal, simple code. Once added, the constraints persist through all future constitution edits. | +| `/speckit.specify` | Define what you want to build (requirements and user stories). Supports `--simple` flag — if the constitution doesn't have Implementation Constraints yet, adds them automatically before proceeding. | | `/speckit.plan` | Create technical implementation plans with your chosen tech stack | | `/speckit.tasks` | Generate actionable task lists for implementation | | `/speckit.implement` | Execute all tasks to build the feature according to the plan | diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 085901ad14..ddf30f0073 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -47,6 +47,21 @@ Follow this execution flow: - Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance. - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. - Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations. + - **Implementation Constraints (opt-in, then immutable)**: + - **Trigger**: If the user includes `--simple`, `--simplify`, or phrases like "make it simple", "simple solution", "keep it simple", "simplify" in their input, add the `## Implementation Constraints` section (see below) immediately before `## Governance`. + - **Persistence**: If the constitution already contains a `## Implementation Constraints` section with the `` comment, it MUST be preserved exactly as-is through any update. Never edit, reorder, remove, or rephrase any part of it. + - The section content to insert when triggered: + ``` + ## Implementation Constraints + + - Implement only what this spec explicitly describes. Nothing more. + - Do not infer or add implied features, edge case handling, or extensibility hooks. + - Prefer inline logic over abstraction unless reuse is explicitly required. + - Prefer a single function/module over a class hierarchy unless state management is required. + - Do not wrap simple logic in services, managers, or handlers unless the spec names them. + - If uncertain between two approaches, choose the one with fewer lines of code. + - Flag any decision that adds more than ~20% code beyond the minimal solution and ask before proceeding. + ``` 4. Consistency propagation checklist (convert prior checklist into active validations): - Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles. @@ -68,6 +83,7 @@ Follow this execution flow: - Version line matches report. - Dates ISO format YYYY-MM-DD. - Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate). + - If a `## Implementation Constraints` section exists, verify it is unchanged from the canonical version defined in step 3 above. 7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite). diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 1957de5bf6..35c5639823 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -31,6 +31,7 @@ Given that feature description, do this: Before proceeding, check if `.specify/memory/constitution.md` has its identity fields populated: - Read the YAML frontmatter and check that `project_name` and `project_acronym` are set to real values (not `[PROJECT_NAME]` / `[PROJECT_ACRONYM]` placeholders) - If either field is still a placeholder, run the full `/speckit.constitution` flow first + - **Simplicity flag**: If the user includes `--simple`, `--simplify`, or phrases like "make it simple", "simple solution", "keep it simple", "simplify" in their input, and the constitution does NOT already contain a `## Implementation Constraints` section with the `` comment, run the `/speckit.constitution --simple` flow to add it before continuing - Once both fields are populated, continue with step 1 1. **Generate a concise short name** (2-4 words) for the branch: From 70a9253269676bcc551c4d7405cab7eac9ee3417 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Fri, 27 Mar 2026 20:38:05 +0200 Subject: [PATCH 06/14] Add E2E test generator command and testing guide Introduce speckit.e2e command that generates Playwright E2E tests from feature spec acceptance criteria, runs them, and reports results. Include a reusable e2e-testing-guide for project-specific DOM patterns. --- memory/e2e-testing-guide.md | 96 +++++++++++++++++ templates/commands/e2e.md | 208 ++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 memory/e2e-testing-guide.md create mode 100644 templates/commands/e2e.md diff --git a/memory/e2e-testing-guide.md b/memory/e2e-testing-guide.md new file mode 100644 index 0000000000..d0a5d71fbc --- /dev/null +++ b/memory/e2e-testing-guide.md @@ -0,0 +1,96 @@ +# E2E Testing Guide + +Project-specific knowledge base for generating and running Playwright E2E tests. + +> **This is a template.** After `fork-init`, customize this file in `.specify/memory/e2e-testing-guide.md` with your project's specific DOM patterns, page objects, CSS values, and pitfalls. + +## App Structure + +| Component | Detail | +|-----------|--------| +| **Frontend** | Port ??? — `E2E_BASE_URL` env var | +| **API** | Port ??? | +| **Auth** | Describe auth mechanism (Keycloak, Auth0, etc.) | +| **Config** | Path to `.env.e2e` (credentials, test data) | +| **Test runner** | `npx playwright test tests/{file}.spec.js --project=e2e` | +| **Config file** | Path to `playwright.config.js` | + +### Environment Variables + +``` +E2E_USERNAME / E2E_PASSWORD — Auth credentials +E2E_BASE_URL — App base URL +E2E_PAUSE — Set to "1" for page.pause() in tests +# Add project-specific env vars here +``` + +## DOM Patterns + +Document selectors for common UI elements in your app: + +### Framework-Specific Selectors + + +### App-Specific Selectors + + +## Highlight / Focus CSS Values + +Document any CSS values used for focus/highlight states: + + + +## Page Object Inventory + +List all page objects with their methods: + + + +## Assertion Helpers + +List custom assertion functions: + + + +## Pitfalls & Lessons Learned + +Document timing issues, flaky patterns, and workarounds: + +### Timing + + +### Known Issues + + +### Test Conventions + diff --git a/templates/commands/e2e.md b/templates/commands/e2e.md new file mode 100644 index 0000000000..84333da064 --- /dev/null +++ b/templates/commands/e2e.md @@ -0,0 +1,208 @@ +--- +description: Generate and run Playwright E2E tests for the current feature based on spec.md acceptance criteria. +scripts: + sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +# speckit.e2e — E2E Test Generator & Runner + +You are an **E2E Test Engineer** specializing in Playwright tests. You generate targeted E2E tests from feature specifications and run them against the live app. + +## Prerequisites + +1. Run `{SCRIPT}` from repo root and parse JSON for `FEATURE_DIR` and `AVAILABLE_DOCS`. All paths must be absolute. + +Extract from `FEATURE_DIR`: +- **Feature short name**: last segment of path (e.g., `RSP-011-preview-deselect`) +- **Feature ID**: ticket prefix (e.g., `RSP-011`) + +2. Verify E2E infrastructure exists: +- Look for a Playwright config file (e.g., `playwright.config.js` or `playwright.config.ts`) +- Look for an `.env.e2e` or similar E2E env file +- If missing, warn the user and suggest setup steps + +3. Load the E2E knowledge base if it exists: +- Read `memory/e2e-testing-guide.md` for project-specific DOM patterns, page objects, CSS values, and pitfalls + +## Step 1: Load Feature Context + +Read these files to understand what was built: + +1. **`{FEATURE_DIR}/spec.md`** — Extract: + - All user stories (US1, US2, ...) + - All acceptance criteria (AC1, AC2, ...) per user story + - Edge cases and error scenarios + - Any test-specific notes + +2. **`{FEATURE_DIR}/plan.md`** — Extract: + - Changed files list (determines if feature touches frontend code) + - Component interactions and data flow + +3. **`{FEATURE_DIR}/tasks.md`** — Extract: + - What was implemented (completed tasks) + - Any known limitations or caveats + +**SKIP GATE**: If `plan.md` has NO frontend files (no files under typical frontend directories like `client/src/`, `src/`, `app/`, `pages/`, `components/`), output: +``` +SKIP: Backend-only feature — no E2E tests needed. +``` +And stop. + +## Step 2: Load E2E Knowledge Base + +Read ALL available E2E infrastructure files: + +1. **`memory/e2e-testing-guide.md`** — Project-specific DOM patterns, CSS values, pitfalls, conventions (if it exists) +2. **All page objects** in the E2E directory (e.g., `*.page.js`, `*.page.ts`) +3. **All helpers** (e.g., `helpers/*.js`, `helpers/*.ts`) +4. **All existing test files** (e.g., `*.spec.js`, `*.spec.ts`) — to understand patterns and avoid duplication + +## Step 3: Plan Test Scenarios + +For each acceptance criterion in `spec.md`, determine: + +1. **Test case name**: `US{N}-AC{N}: {description}` +2. **Required page object methods**: Which PO methods are needed? +3. **New PO methods needed?**: If existing POs don't cover the interaction, plan extensions +4. **Preconditions**: What state must the app be in? +5. **Skip conditions**: When should the test use `test.skip()`? + +Output the plan as a table: + +``` +| AC | Test Name | PO Methods Used | New Methods? | Preconditions | +|----|-----------|-----------------|--------------|---------------| +``` + +## Step 4: Extend Page Objects (if needed) + +If new PO methods are required: + +- **Prefer extending existing page objects** over creating new ones +- Add methods to the relevant PO file +- Follow the existing JSDoc/TSDoc + method naming conventions from the codebase +- Add appropriate wait times after interactions (follow patterns in existing POs) + +If an entirely new page is needed (new app section not covered by existing POs): +- Create a new page object file following the existing naming convention +- Follow the class-based or function-based pattern used by existing POs + +If new assertion helpers are needed: +- Add to existing helper files or create a new helper file following codebase conventions + +## Step 5: Generate Test File + +Create the test file in the project's E2E test directory, following the naming convention of existing tests. + +### Rules + +- **One test per acceptance criterion** — name matches `US{N}-AC{N}: {description}` +- **Edge cases** get separate tests: `US{N}-edge: {description}` +- **Use `test.skip(condition, 'reason')`** when preconditions can't be met +- **Add appropriate waits** after interactions (follow patterns from existing tests and the e2e-testing-guide) +- **Support debug pause**: `if (process.env.E2E_PAUSE) await page.pause();` at the end of key tests +- **Only import POs/helpers actually used** +- **Reuse existing helper functions** and patterns from other test files +- **Follow project conventions** for env vars, test structure, and setup/teardown + +## Step 6: Run Tests + +Execute the tests using the project's Playwright configuration: + +```bash +cd && npx playwright test --project= +``` + +Adapt the command based on the project's `playwright.config.js`/`playwright.config.ts`. + +### Interpret Results + +- **All pass**: Proceed to Step 8 +- **Failures**: Proceed to Step 7 + +## Step 7: Failure Loop (max 3 iterations) + +For each failure: + +1. **Read the error output** carefully — Playwright gives line numbers and expected/received values +2. **Check screenshots** if available in the test results directory +3. **Diagnose the root cause**: + - **Locator not found** → selector is wrong, element structure changed, or timing issue + - **Timeout** → element doesn't appear; check if the feature renders correctly, add more wait time + - **Assertion failed** → CSS value or element count is wrong; verify against actual DOM + - **Test infrastructure** → auth state expired, env vars missing, app not running + +4. **Fix the TEST code** (never fix app code in this skill): + - Update selectors to match actual DOM + - Add/increase wait times + - Fix assertion expectations + - Add `test.skip()` for infeasible preconditions + +5. **Re-run** the tests + +After 3 failed iterations, proceed to Step 8 with failures. + +## Step 8: Report + +### All Tests Pass + +``` +## E2E Tests: PASS + +| Test | Status | +|------|--------| +| US1-AC1: ... | PASS | +| US1-AC2: ... | PASS | +| ... | ... | + +### Files Created/Modified +- (created) +- (modified, if any) +- (modified, if any) +``` + +### Tests Still Failing After 3 Iterations + +Write `{FEATURE_DIR}/blockers.md`: + +```markdown +# E2E Test Blockers + +## Failing Tests +| Test | Error | Attempts | +|------|-------|----------| +| US1-AC2: ... | Timeout waiting for ... | 3 | + +## Root Cause Analysis +- [explanation of why the test can't pass] + +## Recommended Fix +- [what needs to change in the app or test infrastructure] +``` + +Then output: + +``` +## E2E Tests: BLOCKED + +N/M tests passing. Wrote blockers.md. Pipeline halted. +``` + +## Rules + +1. **Never modify application code** — only test files, page objects, and helpers +2. **Reuse existing infrastructure** — page objects, helpers, env vars, auth setup +3. **Extend, don't duplicate** — add methods to existing POs, don't create parallel POs +4. **Graceful degradation** — use `test.skip()` when preconditions aren't met +5. **Test what the spec says** — don't add extra tests beyond the acceptance criteria + edge cases +6. **Committed with feature** — test file is part of the feature deliverable +7. **Follow project conventions** — match the style, patterns, and structure of existing E2E tests From a3299d7b1ecdc4296ee3b16d12531b47b0152e4e Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Fri, 27 Mar 2026 23:16:16 +0200 Subject: [PATCH 07/14] Add autonomous pipeline infrastructure (#3) Autonomous clarification protocol, spec quality gate checklist, pipeline command template, and decisions log template. These enable fully autonomous specify-through-implement runs with convention-based quality gates that halt on unresolvable blockers. --- memory/autonomous-constitution.md | 81 +++++++++++++ memory/quality-gate.md | 35 ++++++ templates/commands/pipeline.md | 190 ++++++++++++++++++++++++++++++ templates/decisions-template.md | 17 +++ 4 files changed, 323 insertions(+) create mode 100644 memory/autonomous-constitution.md create mode 100644 memory/quality-gate.md create mode 100644 templates/commands/pipeline.md create mode 100644 templates/decisions-template.md diff --git a/memory/autonomous-constitution.md b/memory/autonomous-constitution.md new file mode 100644 index 0000000000..f7bf7f3962 --- /dev/null +++ b/memory/autonomous-constitution.md @@ -0,0 +1,81 @@ +## Autonomous Clarification Protocol + +After every `/speckit.specify` run, before proceeding to planning, the agent MUST: + +1. Re-read the generated `spec.md` in full. +2. Adopt the role of a skeptical Product Manager and identify all requirements that are: + - Ambiguous (could be interpreted in more than one way) + - Missing (implied by context but not stated) + - Contradictory (two requirements conflict with each other) +3. For each issue found, resolve it using the most conservative and safe interpretation. +4. Document every resolution in `.specify/specs//decisions.md` using this format: + + ``` + ### Decision: + **Issue:** + **Resolution:** + **Rationale:** + ``` + +5. Do NOT ask for human input. Do NOT pause. Resolve all issues autonomously and document them. +6. Only proceed to `/speckit.plan` once `decisions.md` has been written and all open issues are resolved. + +## Spec Quality Gate + +After writing `decisions.md` and before running `/speckit.plan`, the agent MUST run `/speckit.checklist` against the criteria in `.specify/memory/quality-gate.md`. + +For each checklist item: +- Mark PASS or FAIL with a one-line rationale. +- If any item FAILS: fix `spec.md` immediately, then re-run the checklist. +- Repeat until all items PASS. +- Do NOT proceed to planning with any failing items. +- Do NOT ask for human review. Fix and re-score autonomously. + +Record the final checklist scores in `.specify/specs//quality-report.md`. + +## Task Structural Validation + +After running `/speckit.tasks` and before running `/speckit.implement`, the agent MUST validate `tasks.md` against all of the following rules: + +**Coverage:** +- Every user story in `spec.md` maps to at least one task in `tasks.md`. +- Every acceptance criterion in `spec.md` is addressed by at least one task. + +**Structure:** +- Every task specifies at least one target file path. +- Every task has a clear, unambiguous success condition. +- No task contains open questions or unresolved references. + +**Ordering:** +- No task has a dependency on a task that appears after it in the list. +- Tasks marked `[P]` (parallel) do not share write targets with each other. + +**Autonomy check:** +- Read every task and ask: "Can this task be implemented without human input?" +- If any task requires human input to proceed, either resolve the blocker using available context or add it to a `blockers.md` file and halt (notify human). + +If any validation rule fails: fix `tasks.md` and re-validate. Do not proceed with a broken task list. + +## Pre-Flight Assertions Before Implementation + +Before executing `/speckit.implement`, assert ALL of the following. If any assertion fails, halt and report — do NOT proceed: + +- [ ] `constitution.md` exists and was referenced during spec and planning phases. +- [ ] `spec.md` exists and has no unchecked checklist items. +- [ ] `decisions.md` exists and documents all assumption resolutions. +- [ ] `quality-report.md` exists and shows all items PASSING. +- [ ] `tasks.md` passed all structural validations (coverage, structure, ordering, autonomy). +- [ ] No `blockers.md` exists with unresolved items. + +If all assertions pass: proceed with `/speckit.implement` without waiting for human confirmation. +If any assertion fails: create or update `blockers.md` with the specific failure, then halt and notify. + +## E2E Validation Protocol + +After `/speckit.implement` and before committing: + +1. Run `/speckit.e2e` to generate and run E2E tests from `spec.md` acceptance criteria. +2. Tests use existing project test infrastructure if available. +3. Reuse page objects and helpers — only extend, don't duplicate. +4. 3 fix-and-retry cycles max — then write `blockers.md` and halt. +5. Skip for backend-only features (no frontend files in `plan.md`). diff --git a/memory/quality-gate.md b/memory/quality-gate.md new file mode 100644 index 0000000000..8969f11dae --- /dev/null +++ b/memory/quality-gate.md @@ -0,0 +1,35 @@ +# Spec Quality Gate + +This checklist is used by the agent to self-score every specification before planning begins. +All items must PASS before proceeding. Fix and re-score if any item fails. + +## Completeness + +- [ ] Every feature mentioned in the prompt has at least one user story. +- [ ] Every user story has at least one measurable acceptance criterion (not vague like "works correctly"). +- [ ] All happy-path flows are fully described end-to-end. +- [ ] At least the most likely edge cases and error states are enumerated per user story. +- [ ] No user story uses "should" without an explicit rationale for why it is optional. + +## Clarity + +- [ ] No requirement can be interpreted in more than one way without an explicit decision documented in `decisions.md`. +- [ ] All referenced entities (users, roles, objects, states) are defined in the spec or in a glossary. +- [ ] No requirement references external documents or context that is not available in the repo. + +## Consistency + +- [ ] No two requirements contradict each other. +- [ ] Terminology is consistent throughout the spec (no synonyms for the same concept). +- [ ] User stories are consistent with the governing principles in `constitution.md`. + +## Implementability + +- [ ] Every acceptance criterion is verifiable by a test or a deterministic check. +- [ ] No acceptance criterion requires subjective human judgment to evaluate. +- [ ] The spec does not prescribe implementation details that belong in the plan, not the spec. + +## API & Integration (if applicable) + +- [ ] Every API interaction specifies both success responses and error states. +- [ ] No API contract leaves authentication, authorization, or rate limiting undefined if applicable. diff --git a/templates/commands/pipeline.md b/templates/commands/pipeline.md new file mode 100644 index 0000000000..3582496323 --- /dev/null +++ b/templates/commands/pipeline.md @@ -0,0 +1,190 @@ +--- +description: Run the full autonomous speckit pipeline — specify through implement with quality gates. +scripts: + sh: scripts/bash/check-prerequisites.sh --json --paths-only + ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly +--- + +## User Input + +```text +$ARGUMENTS +``` + +The user input is the **feature description** (required). Optional flags: +- `--commit` — after implementation, create a git commit, push, and open a PR + +Parse the arguments: extract the feature description text and whether `--commit` is present. + +## Goal + +Run the complete speckit pipeline autonomously — from specification through implementation — with convention-based quality gates. No human checkpoints. Halts only on unresolvable blockers (writes `blockers.md`). + +## Execution Steps + +### 1. Initialize + +Run `{SCRIPT}` once from repo root to get `REPO_ROOT`. + +Extract the feature description and `--commit` flag from user input. + +### 2. Specify + +Run `/speckit.specify {feature-description}`. + +This creates the feature branch, spec directory, and `spec.md`. + +### 3. Resolve Feature Directory + +Re-run `{SCRIPT}` (without `--require-tasks`) to get `FEATURE_DIR` now that the feature branch and spec exist. + +### 4. Convention Detection + +Check which autonomous infrastructure is available: + +``` +AUTONOMOUS_CONSTITUTION = exists(".specify/memory/autonomous-constitution.md") + — OR constitution.md contains "## Autonomous Clarification Protocol" +QUALITY_GATE = exists(".specify/memory/quality-gate.md") +DECISIONS_TEMPLATE = exists(".specify/templates/decisions-template.md") +PLAYWRIGHT_CONFIG = exists("playwright.config.js") OR exists("playwright.config.ts") +E2E_SKILL = /speckit.e2e is available as a skill +``` + +Each gate is independently enabled. Missing infrastructure means that gate is skipped (not an error). + +### 5. Self-Clarification Loop (if AUTONOMOUS_CONSTITUTION) + +Read `autonomous-constitution.md` (or the autonomous sections in `constitution.md`) for the clarification protocol. + +1. Re-read the generated `spec.md` in full. +2. Adopt the role of a skeptical Product Manager — identify ambiguous, missing, or contradictory requirements. +3. For each issue, resolve using the most conservative interpretation. +4. Write all resolutions to `{FEATURE_DIR}/decisions.md` (use `decisions-template.md` if available). +5. Do NOT ask for human input. Resolve autonomously. + +### 6. Quality Gate Loop (if QUALITY_GATE) + +Read `.specify/memory/quality-gate.md`. + +1. Score `spec.md` against every checklist item — mark PASS or FAIL with a one-line rationale. +2. For each FAIL: fix `spec.md` immediately. +3. Re-score until all items PASS. +4. Write final scores to `{FEATURE_DIR}/quality-report.md`. + +If after 3 full cycles any item still fails: write `{FEATURE_DIR}/blockers.md` and **halt**. + +### 7. Plan + +Run `/speckit.plan`. + +### 8. Tasks + +Run `/speckit.tasks`. + +### 9. Task Structural Validation (if AUTONOMOUS_CONSTITUTION) + +Read the Task Structural Validation section from `autonomous-constitution.md` (or `constitution.md`). + +Validate `tasks.md` against: + +**Coverage:** +- Every user story in `spec.md` maps to at least one task. +- Every acceptance criterion is addressed by at least one task. + +**Structure:** +- Every task specifies at least one target file path. +- Every task has a clear success condition. +- No open questions or unresolved references. + +**Ordering:** +- No dependency on a later task. +- Parallel tasks `[P]` don't share write targets. + +**Autonomy:** +- Every task can be implemented without human input. + +If validation fails: fix `tasks.md` and re-validate. If still failing after 3 cycles: write `{FEATURE_DIR}/blockers.md` and **halt**. + +### 10. Pre-Flight Assertions (if AUTONOMOUS_CONSTITUTION) + +Read the Pre-Flight Assertions section. Assert ALL of: + +- [ ] `constitution.md` exists +- [ ] `spec.md` exists and has no unchecked items +- [ ] `decisions.md` exists (if self-clarification was enabled) +- [ ] `quality-report.md` exists and all PASS (if quality gate was enabled) +- [ ] `tasks.md` passed structural validation (if task validation was enabled) +- [ ] No `blockers.md` with unresolved items + +If any fail: write `{FEATURE_DIR}/blockers.md` and **halt**. + +### 11. Implement + +Run `/speckit.implement`. + +### 12. E2E Tests (conditional) + +**Only if** ALL of these are true: +- PLAYWRIGHT_CONFIG exists +- E2E_SKILL is available +- `plan.md` references frontend files (e.g., files under `client/src/`, `src/pages/`, `src/components/`) + +Then run `/speckit.e2e`. + +If tests fail after 3 retry cycles: write `{FEATURE_DIR}/blockers.md` and **halt**. + +**Skip** if any condition is false (not an error — just skip silently). + +### 13. Commit and PR (if --commit flag) + +1. Use the `/commit` skill to create the git commit. Do NOT create commits manually. +2. Push the branch: `git push -u origin HEAD` +3. Create PR: `gh pr create --fill` + +If `/commit` skill is not available, fall back to manual `git add` + `git commit`. + +### 14. Report + +Display a summary of what was done: + +``` +## Pipeline Complete: {feature-description} + +### Steps Executed +- [x] Specify — spec.md created +- [x] Self-clarification — decisions.md ({N} decisions) [or: skipped — no autonomous constitution] +- [x] Quality gate — all items PASS [or: skipped — no quality-gate.md] +- [x] Plan — plan.md created +- [x] Tasks — tasks.md created ({N} tasks) +- [x] Task validation — all rules pass [or: skipped] +- [x] Pre-flight — all assertions pass [or: skipped] +- [x] Implement — all tasks executed +- [x] E2E — tests pass [or: skipped — no playwright config / backend-only] +- [x] Commit + PR — {PR_URL} [or: skipped — no --commit flag] + +### Artifacts +- Spec: {FEATURE_DIR}/spec.md +- Decisions: {FEATURE_DIR}/decisions.md +- Quality report: {FEATURE_DIR}/quality-report.md +- Plan: {FEATURE_DIR}/plan.md +- Tasks: {FEATURE_DIR}/tasks.md +``` + +## Halting Protocol + +At no point during steps 1-13 should the agent pause for human input. + +If an unresolvable blocker is hit at any step: +1. Write `{FEATURE_DIR}/blockers.md` with the specific failure and context. +2. Display the blocker to the user. +3. **Stop.** Do not continue to the next step. + +Never ask for human clarification mid-pipeline. Resolve autonomously or halt. + +## Operating Principles + +- **Convention over configuration** — the pipeline auto-detects available infrastructure. No flags needed to enable gates. +- **Graceful degradation** — missing infrastructure means the gate is skipped, not failed. A project with zero autonomous files still runs: specify → plan → tasks → implement. +- **Fail fast, fail loud** — blockers halt immediately with a clear report. No silent failures. +- **Idempotent gates** — each validation loop has a max retry count (3). Infinite loops are impossible. diff --git a/templates/decisions-template.md b/templates/decisions-template.md new file mode 100644 index 0000000000..3b108e2db2 --- /dev/null +++ b/templates/decisions-template.md @@ -0,0 +1,17 @@ +# Decisions Log — + +This file documents every assumption and resolution made during autonomous spec clarification. +It serves as the audit trail for decisions made without human input. + +## Format + +### Decision: +**Issue:** +**Resolution:** +**Rationale:** + +--- + +## Decisions + + From 383e0ddab84bf67d3b89f84f5e57ba19aa086bfb Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Sat, 28 Mar 2026 14:54:40 +0200 Subject: [PATCH 08/14] Fix fork-init crashes from missing symbols lost in upstream merge - Restore AI_ASSISTANT_ALIASES dict (NameError at module load) - Add missing ai_commands_dir param to init() and fork_init delegate - Move use_github assignment before if/else branch (UnboundLocalError when local_path is set) --- src/specify_cli/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7191c1cdb8..fcfe2fc9b0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -295,6 +295,10 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) }, } +AI_ASSISTANT_ALIASES = { + "kiro": "kiro-cli", +} + # Agent command config: maps agent -> (command_folder, file_extension, arg_token) # Used by extract_template_from_local() to generate agent-specific command files. AGENT_COMMAND_CONFIG = { @@ -2078,7 +2082,8 @@ def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, or qoder "), + ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), + ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), @@ -2316,6 +2321,8 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) + use_github = not offline and not local_path + if local_path: for key, label in [ ("local-copy", "Copy from local source"), @@ -2333,8 +2340,6 @@ def init( # Determine whether to use bundled assets or download from GitHub (default). _core = _locate_core_pack() - use_github = not offline and not local_path - if use_github and _core is not None: console.print( "[yellow]Note:[/yellow] Bundled assets are available in this install. " @@ -2687,6 +2692,7 @@ def fork_init( init( project_name=project_name, ai_assistant=ai_assistant, + ai_commands_dir=None, script_type=script_type, ignore_agent_tools=ignore_agent_tools, no_git=no_git, @@ -2696,6 +2702,10 @@ def fork_init( debug=debug, github_token=None, local=str(repo_root), + ai_skills=False, + offline=False, + preset=None, + branch_numbering=None, ) From 9d48734dd86bf4ec2ce66c5cc9bcbf8fbc852e8f Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Sat, 28 Mar 2026 16:02:05 +0200 Subject: [PATCH 09/14] Add --e2e flag to init and fork-init commands Scaffolds Playwright E2E testing infrastructure (playwright.config.ts, .env.e2e, e2e/tests/example.spec.ts, page-objects and helpers dirs) so the autonomous pipeline's Stage 12 works out of the box for frontend features. All files use skip-if-exists for safe re-init. --- src/specify_cli/__init__.py | 101 ++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index fcfe2fc9b0..0efa33304e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -26,6 +26,7 @@ """ import os +import textwrap import subprocess import sys import zipfile @@ -1732,6 +1733,94 @@ def ensure_constitution_from_template(project_path: Path, project_name: str, tra console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") +def ensure_e2e_setup(project_path: Path, tracker: StepTracker | None = None) -> None: + """Scaffold Playwright E2E testing infrastructure (all files skip-if-exists).""" + step_key = "e2e-setup" + + # 1. Copy e2e-testing-guide.md to .specify/memory/ + memory_guide = project_path / ".specify" / "memory" / "e2e-testing-guide.md" + template_guide = project_path / ".specify" / "templates" / "e2e-testing-guide.md" + # Also check the repo-level memory/ directory (for fork-init / local source) + repo_root = Path(__file__).resolve().parent.parent.parent + repo_guide = repo_root / "memory" / "e2e-testing-guide.md" + + if not memory_guide.exists(): + source_guide = template_guide if template_guide.exists() else (repo_guide if repo_guide.exists() else None) + if source_guide: + memory_guide.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_guide, memory_guide) + + # 2. Scaffold Playwright files (all skip-if-exists) + files: dict[str, str] = { + "playwright.config.ts": textwrap.dedent("""\ + import { defineConfig } from '@playwright/test'; + + export default defineConfig({ + testDir: './e2e/tests', + timeout: 30_000, + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [{ name: 'e2e', use: { browserName: 'chromium' } }], + }); + """), + ".env.e2e": textwrap.dedent("""\ + E2E_BASE_URL=http://localhost:3000 + E2E_USERNAME= + E2E_PASSWORD= + E2E_PAUSE= + """), + "e2e/tests/example.spec.ts": textwrap.dedent("""\ + import { test, expect } from '@playwright/test'; + + test('app loads successfully', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/.+/); + }); + """), + } + gitkeeps = [ + "e2e/pages/.gitkeep", + "e2e/helpers/.gitkeep", + ] + + created: list[str] = [] + skipped: list[str] = [] + + for rel_path, content in files.items(): + dest = project_path / rel_path + if dest.exists(): + skipped.append(rel_path) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content) + created.append(rel_path) + + for rel_path in gitkeeps: + dest = project_path / rel_path + if dest.exists(): + skipped.append(rel_path) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("") + created.append(rel_path) + + detail_parts: list[str] = [] + if created: + detail_parts.append(f"created {len(created)} files") + if skipped: + detail_parts.append(f"skipped {len(skipped)} existing") + detail = ", ".join(detail_parts) if detail_parts else "nothing to do" + + if tracker: + tracker.start(step_key) + tracker.complete(step_key, detail) + else: + console.print(f"[cyan]E2E setup:[/cyan] {detail}") + + INIT_OPTIONS_FILE = ".specify/init-options.json" @@ -2097,6 +2186,7 @@ def init( offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), + e2e: bool = typer.Option(False, "--e2e", help="Scaffold Playwright E2E testing infrastructure"), ): """ Initialize a new Specify project. @@ -2336,6 +2426,8 @@ def init( # Add copilot-extras step if copilot is selected if selected_ai == "copilot": tracker.add("copilot-extras", "Generate copilot prompts & vscode settings") + if e2e: + tracker.add("e2e-setup", "E2E testing setup") else: # Determine whether to use bundled assets or download from GitHub (default). _core = _locate_core_pack() @@ -2367,6 +2459,8 @@ def init( tracker.add(key, label) if ai_skills: tracker.add("ai-skills", "Install agent skills") + if e2e: + tracker.add("e2e-setup", "E2E testing setup") for key, label in [ ("cleanup", "Cleanup"), ("git", "Initialize git repository"), @@ -2430,6 +2524,9 @@ def init( ensure_constitution_from_template(project_path, project_name, tracker=tracker) + if e2e: + ensure_e2e_setup(project_path, tracker=tracker) + # Determine skills directory and migrate any legacy Kimi dotted skills. migrated_legacy_kimi_skills = 0 removed_legacy_kimi_skills = 0 @@ -2509,6 +2606,7 @@ def init( "ai_skills": ai_skills, "ai_commands_dir": ai_commands_dir, "branch_numbering": branch_numbering or "sequential", + "e2e": e2e, "here": here, "preset": preset, "offline": offline, @@ -2669,6 +2767,7 @@ def fork_init( here: bool = typer.Option(False, "--here", help="Initialize in current directory"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output"), + e2e: bool = typer.Option(False, "--e2e", help="Scaffold Playwright E2E testing infrastructure"), ): """Initialize a project from the local spec-kit fork (no GitHub download). @@ -2677,6 +2776,7 @@ def fork_init( Examples: specify fork-init my-project --ai claude + specify fork-init my-project --ai claude --e2e specify fork-init . --ai claude --script sh specify fork-init --here --ai copilot """ @@ -2706,6 +2806,7 @@ def fork_init( offline=False, preset=None, branch_numbering=None, + e2e=e2e, ) From c3644d38243f72c81083544fd3949f1c317e7506 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Sun, 5 Apr 2026 00:11:05 +0300 Subject: [PATCH 10/14] Add multi-agent consensus review to autonomous constitution Integrate stochastic multi-agent consensus into the spec review phase so ambiguity/gap detection is validated by multiple agents before decisions are finalized. Updates decision format to track consensus levels and adds consensus-report.md to quality gate. --- memory/autonomous-constitution.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/memory/autonomous-constitution.md b/memory/autonomous-constitution.md index f7bf7f3962..4f582ed604 100644 --- a/memory/autonomous-constitution.md +++ b/memory/autonomous-constitution.md @@ -3,22 +3,40 @@ After every `/speckit.specify` run, before proceeding to planning, the agent MUST: 1. Re-read the generated `spec.md` in full. -2. Adopt the role of a skeptical Product Manager and identify all requirements that are: +2. **Run multi-agent consensus review** by invoking `/stochastic-multi-agent-consensus` with the following prompt: + + ``` + Review this feature spec for a QR icebreaker bar product. Identify all requirements that are: - Ambiguous (could be interpreted in more than one way) - Missing (implied by context but not stated) - Contradictory (two requirements conflict with each other) -3. For each issue found, resolve it using the most conservative and safe interpretation. + + For each issue found, propose a resolution using the most conservative and safe interpretation. + + Spec: + + ``` + + Use these settings: + - **N**: 5 agents (sufficient for spec review, cost-effective) + - **model**: sonnet + - **output_format**: recommendation + +3. Parse the consensus report. For each **consensus item** (agreed by 3+/5 agents): adopt the resolution directly. For each **divergence** (split decision): use the most conservative interpretation. For **outliers**: include only if they identify a genuine gap. + 4. Document every resolution in `.specify/specs//decisions.md` using this format: ``` ### Decision: **Issue:** **Resolution:** - **Rationale:** + **Rationale:** + **Consensus:** (/5 agents) ``` -5. Do NOT ask for human input. Do NOT pause. Resolve all issues autonomously and document them. -6. Only proceed to `/speckit.plan` once `decisions.md` has been written and all open issues are resolved. +5. Save the raw consensus report to `.specify/specs//consensus-report.md`. +6. Do NOT ask for human input. Do NOT pause. Resolve all issues autonomously and document them. +7. Only proceed to `/speckit.plan` once `decisions.md` has been written and all open issues are resolved. ## Spec Quality Gate @@ -62,7 +80,8 @@ Before executing `/speckit.implement`, assert ALL of the following. If any asser - [ ] `constitution.md` exists and was referenced during spec and planning phases. - [ ] `spec.md` exists and has no unchecked checklist items. -- [ ] `decisions.md` exists and documents all assumption resolutions. +- [ ] `decisions.md` exists and documents all assumption resolutions (with consensus levels). +- [ ] `consensus-report.md` exists (multi-agent review was performed). - [ ] `quality-report.md` exists and shows all items PASSING. - [ ] `tasks.md` passed all structural validations (coverage, structure, ordering, autonomy). - [ ] No `blockers.md` exists with unresolved items. From 60607503ffb329773ba3bee0ed0791e9dd0042dc Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Mon, 27 Apr 2026 12:36:21 +0300 Subject: [PATCH 11/14] Add ticket ID detection to specify command branch creation When a feature description starts with a roadmap ticket ID (e.g., ALP-071), extract the numeric portion and pass --number to the branch script. This ensures branches use exact roadmap ticket numbers instead of auto-incremented sequential numbers. Ticket ID detection takes priority over branch_numbering mode. --- templates/commands/specify.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 95194ae5c1..318d31a288 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -82,17 +82,28 @@ Given that feature description, do this: 2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. + **Ticket ID detection (highest priority — overrides all numbering modes)**: Before checking `branch_numbering`, scan the feature description for a roadmap ticket ID: + - Pattern: description starts with `[A-Z]+-\d+` (e.g., `ALP-071`, `URA-023`, `PROJ-5`) + - If matched, extract the numeric portion and pass `--number ` to the script + - Examples: + - `ALP-071 Server-Side Chat History` → `--number 71` + - `URA-023 Add OAuth integration` → `--number 23` + - This ensures the branch uses the exact roadmap ticket number (e.g., `feature/ALP-071-server-side-chat-history`) + - When a ticket ID is detected, skip the branch numbering mode check below + + **Branch numbering mode** (used only when no ticket ID is detected): Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - If `"sequential"` or absent, do not add any extra flag (default behavior) - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` + - Bash (ticket ID): `{SCRIPT} --json --number 71 --short-name "server-side-chat-history" "ALP-071 Server-Side Chat History"` - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` + - PowerShell (ticket ID): `{SCRIPT} -Json -Number 71 -ShortName "server-side-chat-history" "ALP-071 Server-Side Chat History"` - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` **IMPORTANT**: - - Do NOT pass `--number` — the script determines the correct next number automatically + - Pass `--number` ONLY when a ticket ID is detected in the description (see above). Otherwise, do NOT pass `--number` — let the script auto-detect the next number. - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for From ff3d363a51d474f4a899b4f456ab75c458fdadd1 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Mon, 27 Apr 2026 13:28:44 +0300 Subject: [PATCH 12/14] Add pipeline intelligence and quality feedback loops - Scope routing (XS/S/M/L) with adaptive stage execution - Failure attribution in e2e with upstream round-trip routing - Per-task validation gate in implement (lint/typecheck/test) - Cost/risk gate for L-scope features - AI code review stage and pipeline metrics collection - Episodic memory (lessons.jsonl) query in specify and plan - Plan quality validation checklist with self-heal loop - Structural validation moved into tasks command - New review command template --- templates/commands/e2e.md | 40 ++++++--- templates/commands/implement.md | 26 ++++-- templates/commands/pipeline.md | 144 ++++++++++++++++++++++++------- templates/commands/plan.md | 30 ++++++- templates/commands/review.md | 148 ++++++++++++++++++++++++++++++++ templates/commands/specify.md | 12 +++ templates/commands/tasks.md | 32 ++++++- 7 files changed, 377 insertions(+), 55 deletions(-) create mode 100644 templates/commands/review.md diff --git a/templates/commands/e2e.md b/templates/commands/e2e.md index 84333da064..5eed92ece2 100644 --- a/templates/commands/e2e.md +++ b/templates/commands/e2e.md @@ -135,17 +135,23 @@ For each failure: 1. **Read the error output** carefully — Playwright gives line numbers and expected/received values 2. **Check screenshots** if available in the test results directory -3. **Diagnose the root cause**: - - **Locator not found** → selector is wrong, element structure changed, or timing issue - - **Timeout** → element doesn't appear; check if the feature renders correctly, add more wait time - - **Assertion failed** → CSS value or element count is wrong; verify against actual DOM - - **Test infrastructure** → auth state expired, env vars missing, app not running - -4. **Fix the TEST code** (never fix app code in this skill): - - Update selectors to match actual DOM - - Add/increase wait times - - Fix assertion expectations - - Add `test.skip()` for infeasible preconditions +3. **Classify the failure** (failure attribution — critical for pipeline routing): + + | `failure_class` | Symptoms | Action | + |-----------------|----------|--------| + | `spec-ambiguity` | Test expectation doesn't match what was built because spec was unclear | Write re-spec request → bounce to `/speckit.specify` | + | `plan-gap` | Feature is missing a component the test expects (e.g., no loading state) | Write re-plan request → bounce to `/speckit.plan` | + | `task-decomposition` | Component exists but was built wrong (task was underspecified) | Fix implementation directly | + | `implementation-bug` | Code bug — logic error, typo, wrong selector | Fix test or flag app bug | + | `infra` | Auth expired, env missing, app not running, Playwright config issue | Fix test infrastructure | + +4. **Route based on failure class**: + - **`spec-ambiguity` or `plan-gap`**: Write `{FEATURE_DIR}/blockers.md` with `failure_class` field and a `respec_request` or `replan_request` section. Do NOT patch the test — the problem is upstream. Cap at **one round-trip** per feature. If the same `failure_class` appears twice consecutively → escalate to human halt. + - **`task-decomposition` or `implementation-bug` or `infra`**: Fix the TEST code (never fix app code in this skill): + - Update selectors to match actual DOM + - Add/increase wait times + - Fix assertion expectations + - Add `test.skip()` for infeasible preconditions 5. **Re-run** the tests @@ -178,9 +184,15 @@ Write `{FEATURE_DIR}/blockers.md`: # E2E Test Blockers ## Failing Tests -| Test | Error | Attempts | -|------|-------|----------| -| US1-AC2: ... | Timeout waiting for ... | 3 | +| Test | Error | Failure Class | Attempts | +|------|-------|---------------|----------| +| US1-AC2: ... | Timeout waiting for ... | implementation-bug | 3 | + +## Failure Attribution +- failure_class: [spec-ambiguity | plan-gap | task-decomposition | implementation-bug | infra] +- upstream_cause: [if spec-ambiguity or plan-gap — what's missing/unclear in spec or plan] +- respec_request: [if spec-ambiguity — what needs clarification] +- replan_request: [if plan-gap — what component is missing] ## Root Cause Analysis - [explanation of why the test can't pass] diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 9a91d2dc4b..8ca54093e6 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -142,19 +142,35 @@ You **MUST** consider the user input before proceeding (if not empty). 6. Execute implementation following the task plan: - **Phase-by-phase execution**: Complete each phase before moving to the next - - **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together + - **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together - **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks - **File-based coordination**: Tasks affecting the same files must run sequentially - **Validation checkpoints**: Verify each phase completion before proceeding -7. Implementation execution rules: +7. **Per-Task Validation Gate** (runs after EVERY task, <10s): + + After completing each task, run a fast validation pass. Detect which tools are available and run what applies: + + | Check | Tool | Condition | + |-------|------|-----------| + | Lint | `ruff check` / `eslint` / `golangci-lint` | Corresponding config exists | + | Type check | `tsc --noEmit` / `mypy` / `pyright` | TypeScript or Python project | + | Fast tests | `pytest -x --last-failed` / `jest --changedSince=HEAD` | Test files were modified by this task | + + **Rules:** + - If gate fails: fix the issue immediately before proceeding to next task. Max 2 fix attempts per task. + - If still failing after 2 attempts: log the failure, mark task as `[!]` (needs attention), continue to next task. + - Do NOT skip the gate — error cascading across 25 tasks is the #1 cause of implementation failure. + - Gate is optional for setup tasks (T001-T003 typically) that install dependencies. + +8. Implementation execution rules: - **Setup first**: Initialize project structure, dependencies, configuration - **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios - **Core development**: Implement models, services, CLI commands, endpoints - **Integration work**: Database connections, middleware, logging, external services - **Polish and validation**: Unit tests, performance optimization, documentation -8. Progress tracking and error handling: +9. Progress tracking and error handling: - Report progress after each completed task - Halt execution if any non-parallel task fails - For parallel tasks [P], continue with successful tasks, report failed ones @@ -162,7 +178,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Suggest next steps if implementation cannot proceed - **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file. -9. Completion validation: +10. Completion validation: - Verify all required tasks are completed - Check that implemented features match the original specification - Validate that tests pass and coverage meets requirements @@ -171,7 +187,7 @@ You **MUST** consider the user input before proceeding (if not empty). Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. -10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. +11. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_implement` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. diff --git a/templates/commands/pipeline.md b/templates/commands/pipeline.md index 3582496323..c8f9f38c68 100644 --- a/templates/commands/pipeline.md +++ b/templates/commands/pipeline.md @@ -13,13 +13,27 @@ $ARGUMENTS The user input is the **feature description** (required). Optional flags: - `--commit` — after implementation, create a git commit, push, and open a PR +- `--scope XS|S|M|L` — override scope classification (default: M). Controls which stages run: -Parse the arguments: extract the feature description text and whether `--commit` is present. +| Scope | Stages | Use Case | +|-------|--------|----------| +| **XS** | implement → commit | Typo fix, config tweak, one-file change. No spec needed. | +| **S** | specify → implement → commit | Small feature, clear scope. Skip plan/tasks/clarification/quality gate. | +| **M** | Full pipeline (default) | Standard feature. All stages run. | +| **L** | Full pipeline + cost/risk gate | Large feature. All stages + explicit effort review before implement. | + +Parse the arguments: extract the feature description, `--commit` flag, and `--scope` value (default M if not provided). ## Goal Run the complete speckit pipeline autonomously — from specification through implementation — with convention-based quality gates. No human checkpoints. Halts only on unresolvable blockers (writes `blockers.md`). +The pipeline adapts its depth based on `--scope`: +- **XS**: Jump straight to Step 11 (implement). No spec, plan, or tasks. +- **S**: Run Steps 2, 3, then jump to Step 11 (implement). Skip clarification, quality gate, plan, tasks. +- **M**: Run all steps (default, current behavior). +- **L**: Run all steps + Step 10.5 (cost/risk gate before implement). + ## Execution Steps ### 1. Initialize @@ -28,17 +42,30 @@ Run `{SCRIPT}` once from repo root to get `REPO_ROOT`. Extract the feature description and `--commit` flag from user input. -### 2. Specify +### 1.5. Scope Routing + +Based on the `--scope` value, determine which steps to execute: + +- **XS**: Skip to Step 11 (Implement). The feature description IS the implementation instruction. No feature branch, no spec directory — just edit, test, done. +- **S**: Run Step 2 (Specify) → Step 3 (Resolve Feature Directory) → Step 11 (Implement) → Step 12 (E2E) → Step 13 (Commit). Skip Steps 4-10. +- **M**: Run all steps 2-13 as written below (default). +- **L**: Run all steps 2-13 + Step 10.5 (Cost/Risk Gate). + +For **XS scope**: create a minimal feature branch (`git checkout -b fix/{slugified-description}`) and skip directly to Step 11. After implementation, run any fast validation (lint, type check) and proceed to Step 13 if `--commit`. + +For all other scopes, continue to Step 2. + +### 2. Specify (skip if XS) Run `/speckit.specify {feature-description}`. This creates the feature branch, spec directory, and `spec.md`. -### 3. Resolve Feature Directory +### 3. Resolve Feature Directory (skip if XS) Re-run `{SCRIPT}` (without `--require-tasks`) to get `FEATURE_DIR` now that the feature branch and spec exist. -### 4. Convention Detection +### 4. Convention Detection (skip if XS or S) Check which autonomous infrastructure is available: @@ -53,7 +80,7 @@ E2E_SKILL = /speckit.e2e is available as a skill Each gate is independently enabled. Missing infrastructure means that gate is skipped (not an error). -### 5. Self-Clarification Loop (if AUTONOMOUS_CONSTITUTION) +### 5. Self-Clarification Loop (if AUTONOMOUS_CONSTITUTION; skip if XS or S) Read `autonomous-constitution.md` (or the autonomous sections in `constitution.md`) for the clarification protocol. @@ -63,7 +90,7 @@ Read `autonomous-constitution.md` (or the autonomous sections in `constitution.m 4. Write all resolutions to `{FEATURE_DIR}/decisions.md` (use `decisions-template.md` if available). 5. Do NOT ask for human input. Resolve autonomously. -### 6. Quality Gate Loop (if QUALITY_GATE) +### 6. Quality Gate Loop (if QUALITY_GATE; skip if XS or S) Read `.specify/memory/quality-gate.md`. @@ -74,39 +101,21 @@ Read `.specify/memory/quality-gate.md`. If after 3 full cycles any item still fails: write `{FEATURE_DIR}/blockers.md` and **halt**. -### 7. Plan +### 7. Plan (skip if XS or S) Run `/speckit.plan`. -### 8. Tasks +### 8. Tasks (skip if XS or S) Run `/speckit.tasks`. -### 9. Task Structural Validation (if AUTONOMOUS_CONSTITUTION) +### 9. Task Structural Validation (skip if XS or S) -Read the Task Structural Validation section from `autonomous-constitution.md` (or `constitution.md`). +Structural validation now runs inside `/speckit.tasks` (Step 5 of tasks.md). Check that `{FEATURE_DIR}/task-validation-report.md` exists and all rules passed. -Validate `tasks.md` against: +If the report shows failures: write `{FEATURE_DIR}/blockers.md` and **halt**. -**Coverage:** -- Every user story in `spec.md` maps to at least one task. -- Every acceptance criterion is addressed by at least one task. - -**Structure:** -- Every task specifies at least one target file path. -- Every task has a clear success condition. -- No open questions or unresolved references. - -**Ordering:** -- No dependency on a later task. -- Parallel tasks `[P]` don't share write targets. - -**Autonomy:** -- Every task can be implemented without human input. - -If validation fails: fix `tasks.md` and re-validate. If still failing after 3 cycles: write `{FEATURE_DIR}/blockers.md` and **halt**. - -### 10. Pre-Flight Assertions (if AUTONOMOUS_CONSTITUTION) +### 10. Pre-Flight Assertions (if AUTONOMOUS_CONSTITUTION; skip if XS or S) Read the Pre-Flight Assertions section. Assert ALL of: @@ -119,6 +128,36 @@ Read the Pre-Flight Assertions section. Assert ALL of: If any fail: write `{FEATURE_DIR}/blockers.md` and **halt**. +### 10.5. Cost / Risk Gate (L scope only) + +**Only runs when `--scope L`** or when auto-detected thresholds are exceeded. + +Compute from `tasks.md`: +- **Total task count** +- **Distinct files touched** (union of all target file paths) +- **Parallel-able count** (tasks marked `[P]`) +- **Sensitive paths**: any task targeting files matching these patterns: + - `**/migrations/**`, `**/migrate*` + - `**/auth/**`, `**/middleware/auth*` + - `**/payment*`, `**/billing*`, `**/stripe*` + - `**/deploy*`, `**/.github/**`, `**/ci*`, `**/.gitlab*` + - `**/.env*`, `**/secrets*` + +**Hard gate** — halt with `{FEATURE_DIR}/effort-report.md` if ANY of: +- `total_tasks > 25` AND scope was not explicitly set to L (i.e., was auto-classified or defaulted to M) +- Any sensitive path matched AND scope was not explicitly set to L + +`effort-report.md` contains: +``` +## Effort & Risk Report +- Total tasks: N +- Files touched: N +- Sensitive paths: [list or "none"] +- Recommendation: [proceed / review with human / reduce scope] +``` + +If scope IS explicitly L (user or productowner pre-approved): log the report but do NOT halt. + ### 11. Implement Run `/speckit.implement`. @@ -132,10 +171,21 @@ Run `/speckit.implement`. Then run `/speckit.e2e`. -If tests fail after 3 retry cycles: write `{FEATURE_DIR}/blockers.md` and **halt**. +If E2E writes `blockers.md`, check the `failure_class` field: + +- **`spec-ambiguity`**: Re-run `/speckit.specify` with the `respec_request` from blockers.md, then restart from Step 7 (Plan). Cap at **one round-trip** — if the same class recurs, halt. +- **`plan-gap`**: Re-run `/speckit.plan` with the `replan_request`, then restart from Step 8 (Tasks). Cap at **one round-trip**. +- **`task-decomposition` / `implementation-bug` / `infra`**: Standard halt — write blockers.md and stop. **Skip** if any condition is false (not an error — just skip silently). +### 12.5. AI Code Review (skip if XS) + +Run `/speckit.review`. + +If review reports any FAIL scores: read `{FEATURE_DIR}/blockers.md` and **halt** for human review. +If review reports only PASS/WARN: continue to commit. + ### 13. Commit and PR (if --commit flag) 1. Use the `/commit` skill to create the git commit. Do NOT create commits manually. @@ -144,15 +194,43 @@ If tests fail after 3 retry cycles: write `{FEATURE_DIR}/blockers.md` and **halt If `/commit` skill is not available, fall back to manual `git add` + `git commit`. +### 13.5. Collect Pipeline Metrics + +Write `{FEATURE_DIR}/pipeline-metrics.json` with timing and stats for each stage that ran: + +```json +{ + "feature_id": "{FEATURE_ID}", + "scope": "{XS|S|M|L}", + "stages": { + "specify": {"ran": true, "files_created": ["spec.md"]}, + "clarify": {"ran": true, "decisions_count": 5}, + "quality_gate": {"ran": true, "iterations": 1, "all_pass": true}, + "plan": {"ran": true, "files_created": ["plan.md", "research.md", "data-model.md"]}, + "tasks": {"ran": true, "task_count": 18, "parallel_count": 6}, + "implement": {"ran": true, "files_modified": 14, "tasks_completed": 18, "tasks_flagged": 0}, + "e2e": {"ran": false, "reason": "backend-only"}, + "review": {"ran": true, "pass": 5, "warn": 1, "fail": 0}, + "commit": {"ran": true, "pr_url": "..."} + }, + "fix_iterations": 0, + "halted": false, + "ts": "YYYY-MM-DDTHH:MM:SSZ" +} +``` + +Only include stages that actually ran. Omit stages that were skipped due to scope. + ### 14. Report Display a summary of what was done: ``` ## Pipeline Complete: {feature-description} +## Scope: {scope} (XS|S|M|L) ### Steps Executed -- [x] Specify — spec.md created +- [x] Specify — spec.md created [or: skipped — XS scope] - [x] Self-clarification — decisions.md ({N} decisions) [or: skipped — no autonomous constitution] - [x] Quality gate — all items PASS [or: skipped — no quality-gate.md] - [x] Plan — plan.md created @@ -161,6 +239,8 @@ Display a summary of what was done: - [x] Pre-flight — all assertions pass [or: skipped] - [x] Implement — all tasks executed - [x] E2E — tests pass [or: skipped — no playwright config / backend-only] +- [x] Review — N/6 PASS, N/6 WARN [or: skipped — XS scope] +- [x] Metrics — pipeline-metrics.json written - [x] Commit + PR — {PR_URL} [or: skipped — no --commit flag] ### Artifacts diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..a6e84883d1 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -60,6 +60,13 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline +0. **Query episodic memory** (if `.specify/memory/lessons.jsonl` exists): + - Read `lessons.jsonl` and scan for lessons relevant to this feature + - Match by category and keyword overlap with the spec + - If relevant lessons found: factor them into planning decisions (avoid repeating past mistakes) + - Display relevant warnings before proceeding + - If no relevant lessons or file doesn't exist: skip silently + 1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied). @@ -73,9 +80,28 @@ You **MUST** consider the user input before proceeding (if not empty). - Phase 1: Update agent context by running the agent script - Re-evaluate Constitution Check post-design -4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. +4. **Plan Quality Validation**: Before reporting, self-validate the plan. Generate a `plan-quality-checklist.md` in FEATURE_DIR and validate against it: + + **Checklist items:** + - [ ] Every requirement in spec.md is addressable by at least one design decision + - [ ] Every architectural decision in research.md has a rationale (not just "we chose X") + - [ ] Technical Context has zero NEEDS CLARIFICATION remaining + - [ ] Data model covers all entities implied by spec user stories + - [ ] No constitution gate violations remain unjustified + - [ ] Interface contracts (if any) match spec acceptance criteria + - [ ] No circular dependencies in the design + + **Validation loop** (max 3 iterations): + 1. Score each item PASS or FAIL with one-line rationale + 2. For each FAIL: fix the relevant plan artifact immediately + 3. Re-score until all PASS or 3 iterations exhausted + 4. If still failing after 3 iterations: note unresolved items in the report (do not halt — plan is still usable) + + Write final `plan-quality-checklist.md` with all scores. + +5. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, generated artifacts, and plan quality score (N/N passed). -5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. +6. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_plan` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. diff --git a/templates/commands/review.md b/templates/commands/review.md new file mode 100644 index 0000000000..4d3e24490e --- /dev/null +++ b/templates/commands/review.md @@ -0,0 +1,148 @@ +--- +description: Run an AI code review on the current feature branch before creating a PR. +scripts: + sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +# speckit.review — AI Code Review + +You are a **Senior Code Reviewer**. You review the implementation diff against the feature spec, constitution, and codebase patterns before the PR is created. + +## Step 1: Setup + +Run `{SCRIPT}` from repo root and parse JSON for `FEATURE_DIR` and `AVAILABLE_DOCS`. All paths must be absolute. + +## Step 2: Load Context + +Read these files to understand what was intended: + +1. **`{FEATURE_DIR}/spec.md`** — what was specified (acceptance criteria) +2. **`{FEATURE_DIR}/plan.md`** — what was planned (architecture, file structure) +3. **`{FEATURE_DIR}/tasks.md`** — what tasks were defined +4. **`{FEATURE_DIR}/decisions.md`** — autonomous decisions made (if exists) +5. **`.specify/memory/constitution.md`** or **`.specify/memory/autonomous-constitution.md`** — project principles (if exists) + +## Step 3: Get the Diff + +Run `git diff main...HEAD` (or appropriate base branch) to get all changes in the feature branch. + +If diff is large (>2000 lines), focus on: +- New files first +- Modified files with highest line count +- Skip generated files, lock files, and config-only changes + +## Step 4: Review Checks + +Run each check and score PASS / WARN / FAIL: + +### 4a. Spec Compliance +- Every acceptance criterion in spec.md is addressed by the implementation +- No acceptance criteria left unimplemented +- Score: PASS if all addressed, WARN if minor gaps, FAIL if major gaps + +### 4b. Constitution Compliance (if constitution exists) +- Diff doesn't violate any constitution principles +- Naming conventions followed +- Error handling patterns consistent +- Score: PASS / WARN / FAIL + +### 4c. Pattern Consistency +- New code follows patterns established in existing codebase +- Imports, file structure, naming match project conventions +- No inconsistent patterns introduced (e.g., callbacks vs promises, different error handling) +- Score: PASS / WARN / FAIL + +### 4d. Error Handling +- External calls have error handling +- User-facing errors have meaningful messages +- No swallowed errors (empty catch blocks) +- Score: PASS / WARN / FAIL + +### 4e. Security +- No hardcoded secrets, tokens, or credentials +- User input validated/sanitized where applicable +- No SQL injection, XSS, or command injection vectors +- Auth checks present where needed +- Score: PASS / WARN / FAIL + +### 4f. Scope Creep +- Files modified match what's in tasks.md +- No unexpected files changed that aren't in the plan +- Score: PASS / WARN if <3 extra files, FAIL if >5 extra files + +## Step 5: Write Review Report + +Write `{FEATURE_DIR}/review-report.md`: + +```markdown +# Code Review: {feature-name} + +## Summary +- Overall: PASS / WARN / FAIL +- Checks: N/6 PASS, N/6 WARN, N/6 FAIL + +## Check Results +| Check | Score | Notes | +|-------|-------|-------| +| Spec Compliance | PASS | All 5 ACs addressed | +| Constitution | PASS | Follows all principles | +| Pattern Consistency | WARN | Mixed import style in utils/ | +| Error Handling | PASS | All external calls handled | +| Security | PASS | No issues found | +| Scope Creep | PASS | 0 unexpected files | + +## Issues Found +### FAIL: [check name] +- **File**: path/to/file.ts:42 +- **Issue**: [description] +- **Fix**: [suggestion] + +### WARN: [check name] +- **File**: path/to/file.ts:15 +- **Issue**: [description] +- **Suggestion**: [optional improvement] + +## Files Reviewed +- [list of files in diff] +``` + +## Step 6: Report + +Display review summary: + +``` +## Code Review: {status} + +| Check | Score | +|-------|-------| +| Spec Compliance | ... | +| Constitution | ... | +| Patterns | ... | +| Error Handling | ... | +| Security | ... | +| Scope Creep | ... | + +Issues: N FAIL, N WARN +Report: {FEATURE_DIR}/review-report.md +``` + +**If any FAIL**: The pipeline should halt for human review. Write issues to `{FEATURE_DIR}/blockers.md` with `failure_class: review-fail`. + +**If only WARN or PASS**: Pipeline can continue. + +## Rules + +1. **Never modify application code** — only write the review report +2. **Be specific** — cite exact file paths and line numbers +3. **Don't be pedantic** — skip style nits that linters should catch +4. **Focus on correctness and security** — these matter most +5. **Compare against spec, not personal preference** — the spec is the source of truth diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 318d31a288..2aedae578a 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -68,6 +68,18 @@ Given that feature description, do this: - **Simplicity flag**: If the user includes `--simple`, `--simplify`, or phrases like "make it simple", "simple solution", "keep it simple", "simplify" in their input, and the constitution does NOT already contain a `## Implementation Constraints` section with the `` comment, run the `/speckit.constitution --simple` flow to add it before continuing - Once both fields are populated, continue with step 1 +0.5. **Query episodic memory** (if `.specify/memory/lessons.jsonl` exists): + - Read `lessons.jsonl` and scan for lessons relevant to the current feature description + - Match by category (e.g., if feature touches auth, look for `"category": "auth"` lessons) + - Match by keyword overlap between lesson text and feature description + - If relevant lessons found: display them as warnings before proceeding: + ``` + ⚠ Lessons from past features: + - [FEATURE-ID] "lesson text" (severity) + ``` + - Factor these into the spec — avoid known pitfalls, address known edge cases + - If no relevant lessons or file doesn't exist: skip silently + 1. **Generate a concise short name** (2-4 words) for the branch: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..eb397a1c01 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -89,15 +89,43 @@ You **MUST** consider the user input before proceeding (if not empty). - Parallel execution examples per story - Implementation strategy section (MVP first, incremental delivery) -5. **Report**: Output path to generated tasks.md and summary: +5. **Structural Validation**: Before reporting, validate `tasks.md` against these rules: + + **Coverage:** + - Every user story in `spec.md` maps to at least one task + - Every acceptance criterion is addressed by at least one task + + **Structure:** + - Every task specifies at least one target file path + - Every task has a clear success condition (implied by description) + - No open questions or unresolved references + - All tasks follow the checklist format (checkbox, ID, labels, file paths) + + **Ordering:** + - No task depends on a later task + - Parallel tasks `[P]` don't share write targets + + **Autonomy:** + - Every task can be implemented without human input + + **Validation loop** (max 3 iterations): + 1. Score each rule PASS or FAIL + 2. For each FAIL: fix `tasks.md` immediately + 3. Re-score until all PASS or 3 iterations exhausted + 4. If a user story has zero tasks after 3 iterations: halt with error + + Write `task-validation-report.md` in FEATURE_DIR with results. + +6. **Report**: Output path to generated tasks.md and summary: - Total task count - Task count per user story - Parallel opportunities identified - Independent test criteria for each story - Suggested MVP scope (typically just User Story 1) + - Structural validation: N/N rules passed - Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths) -6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. +7. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_tasks` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. From 2a38aa39fe29784e0947471e2ea1a60aa1d54cff Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Mon, 1 Jun 2026 15:59:57 +0300 Subject: [PATCH 13/14] Add ticket-prefixed branch pattern to feature branch check Allow feature/PROJECT-123-name format (e.g. feature/URA-42-fix-auth) in check_feature_branch validation alongside existing patterns. --- scripts/bash/common.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index cbbea5e7c8..b08634d66e 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -129,9 +129,9 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^(feature/([A-Z0-9]+-)?)?[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + if [[ ! "$branch" =~ ^(feature/([A-Z0-9]+-)?)?[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^feature/[A-Z][A-Z0-9]*-[0-9]+- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: feature/001-feature-name, feature/URA-001-feature-name, or 20260319-143022-feature-name" >&2 + echo "Feature branches should be named like: feature/001-feature-name, feature/PROJECT-123-name, or 20260319-143022-feature-name" >&2 return 1 fi From 868608ff710a32da253697f29ff092c5463247f0 Mon Sep 17 00:00:00 2001 From: Stanislav Aituhanov Date: Fri, 31 Jul 2026 13:54:48 +0300 Subject: [PATCH 14/14] feat: worktree mode for feature creation, and fix numbering that ignores it (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create-new-feature.sh` runs `git checkout -b` in whatever worktree it is called from. In a repository laid out with one worktree per feature, the directory it is usually called from is the one holding the shared local state — the virtualenv, the data directories, everything the linked worktrees symlink into — and moving it off its branch is the one thing it must not do. ## Worktree mode `specify init -wt` (and `fork-init -wt`) records `"worktree": true` in `.specify/init-options.json`. From then on `create-new-feature.sh` creates the branch in a NEW linked worktree instead of checking it out, and points REPO_ROOT and SPECS_DIR at that worktree so the spec is written on the branch that carries it rather than into the caller's tree. Recorded at init rather than passed per feature, so an agent-driven flow needs no extra argument: the decision is made once. `--worktree` / `--no-worktree` override a single run, and SPECIFY_WORKTREE overrides the file. Default is false, so a project initialized without -wt behaves exactly as before. The new worktree branches from `main`/`master`, not from HEAD. HEAD is whatever the caller happened to have open, and a feature silently based on another feature is a merge conflict discovered days later. JSON output gains WORKTREE_DIR, and stderr says where to continue — in this mode the branch is not checked out in the calling directory, so plan/tasks/implement would otherwise resolve the repository root to the wrong tree. ## The numbering bug this exposed `get_highest_from_branches` cleaned branch names with `sed 's/^[* ]*//'`. `git branch` writes `*` for the branch checked out HERE and `+` for one checked out in a LINKED WORKTREE. Every `+` line therefore stayed as "+ feature/JSE-023-x", failed the `feature/` strip, failed the digit test, and was skipped — silently, because a skipped branch merely fails to raise the maximum. So every in-flight feature was invisible to numbering exactly when features run in parallel, which is the only situation in which a number can collide at all. Measured on a live repository: highest 23 with `+` handled, 22 without — the next feature was about to reuse a number already taken. ## Verified on a live repository - worktree mode: number 024 (023 without the `+` fix), calling directory still on main, worktree created, spec.md written inside it, WORKTREE_DIR in the JSON - no flag and no recorded option: branch checked out in place, no WORKTREE_DIR, no "continue from" line — unchanged behaviour - `"worktree": true` in init-options.json with no flag: worktree used, calling directory's branch unchanged - every test branch and worktree removed afterwards --- scripts/bash/create-new-feature.sh | 103 +++++++++++++++++++++++++++-- src/specify_cli/__init__.py | 11 +++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f7d5405e11..cebdbd24d1 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -7,6 +7,9 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false +# Empty means "not stated on the command line" — resolved later from +# .specify/init-options.json, which is where `specify init -wt` records it. +WORKTREE_MODE="" ARGS=() i=1 while [ $i -le $# ]; do @@ -48,6 +51,12 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --worktree) + WORKTREE_MODE=true + ;; + --no-worktree) + WORKTREE_MODE=false + ;; --help|-h) echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" @@ -57,6 +66,8 @@ while [ $i -le $# ]; do echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --worktree Create the branch in a NEW linked worktree instead of checking it out here" + echo " --no-worktree Force checkout-in-place even if the project was initialized with -wt" echo " --help, -h Show this help message" echo "" echo "Examples:" @@ -120,8 +131,22 @@ get_highest_from_branches() { if [ -n "$branches" ]; then while IFS= read -r branch; do - # Clean branch name: remove leading markers and remote prefixes - clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + # Clean branch name: remove leading markers and remote prefixes. + # + # The marker class must include '+', not only '*'. `git branch` writes + # '*' for the branch checked out HERE and '+' for one checked out in a + # LINKED WORKTREE. Matching only '*' left every '+' line as + # "+ feature/JSE-023-x", which does not start with "feature/", so the + # prefix strip missed, the digit test failed, and the branch was + # skipped — silently, because a skipped branch merely fails to raise + # the maximum. + # + # The effect is that every in-flight feature is invisible to numbering + # exactly when features run in parallel, which is the only situation + # in which the number can collide at all. Measured on a live repo: + # highest 23 with '+' handled, 22 without, so the next feature was + # about to reuse a number already taken. + clean_branch=$(echo "$branch" | sed 's/^[*+ ]*//; s|^remotes/[^/]*/||') # Strip feature/ prefix if present clean_branch="${clean_branch#feature/}" @@ -399,7 +424,64 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -if [ "$HAS_GIT" = true ]; then +# Resolve worktree mode: an explicit flag wins, then SPECIFY_WORKTREE in the +# environment, then what `specify init -wt` recorded in init-options.json. The +# recorded value is the default so the agent flow needs no extra argument — the +# choice was made once, at init, and does not have to be remembered per feature. +if [ -z "$WORKTREE_MODE" ]; then + if [ -n "${SPECIFY_WORKTREE:-}" ]; then + case "$SPECIFY_WORKTREE" in + 1|true|yes) WORKTREE_MODE=true ;; + *) WORKTREE_MODE=false ;; + esac + elif grep -q '"worktree"[[:space:]]*:[[:space:]]*true' "$REPO_ROOT/.specify/init-options.json" 2>/dev/null; then + WORKTREE_MODE=true + else + WORKTREE_MODE=false + fi +fi + +if [ "$HAS_GIT" = true ] && [ "$WORKTREE_MODE" = true ]; then + # The worktree that holds the repository's shared state is the one containing + # the common git dir. It must not be moved off its branch: in a project laid + # out this way it owns the virtualenv, the data directories and everything the + # linked worktrees symlink into. + COMMON_DIR="$(git rev-parse --git-common-dir)" + case "$COMMON_DIR" in /*) ;; *) COMMON_DIR="$(cd "$COMMON_DIR" && pwd)" ;; esac + ANCHOR="$(dirname "$COMMON_DIR")" + + WORKTREE_ROOT="${SPECIFY_WORKTREE_ROOT:-${ANCHOR}-worktrees}" + WORKTREE_DIR="$WORKTREE_ROOT/$(basename "$BRANCH_NAME")" + + # Branch from the default branch, not from HEAD. HEAD here is whatever the + # caller happened to have open, and a feature silently based on another + # feature is a merge conflict that surfaces days later. + BASE_BRANCH="" + for candidate in main master; do + if git show-ref --verify --quiet "refs/heads/$candidate"; then + BASE_BRANCH="$candidate" + break + fi + done + [ -n "$BASE_BRANCH" ] || BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + + if [ -e "$WORKTREE_DIR" ]; then + >&2 echo "Error: $WORKTREE_DIR already exists. Remove it or pick a different short name." + exit 1 + fi + + if ! git worktree add "$WORKTREE_DIR" -b "$BRANCH_NAME" "$BASE_BRANCH" >&2; then + >&2 echo "Error: Failed to create worktree for '$BRANCH_NAME'." + exit 1 + fi + + # Everything below writes the spec relative to REPO_ROOT/SPECS_DIR. Point both + # at the new worktree, or the spec lands in the anchor's working tree while the + # branch that is supposed to carry it lives somewhere else. + REPO_ROOT="$WORKTREE_DIR" + SPECS_DIR="$REPO_ROOT/specs" + mkdir -p "$SPECS_DIR" +elif [ "$HAS_GIT" = true ]; then if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then # Check if branch already exists if git branch --list "$BRANCH_NAME" | grep -q .; then @@ -444,6 +526,15 @@ fi # Inform the user how to persist the feature variable in their own shell printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +# In worktree mode the branch is NOT checked out here, so every later step — +# plan, tasks, implement — resolves the repository root from its own working +# directory and would look for this spec in the wrong tree. Say so on stderr, +# where it is visible whether or not the caller asked for JSON. +if [ -n "${WORKTREE_DIR:-}" ]; then + >&2 echo "[specify] Branch $BRANCH_NAME lives in a new worktree; this directory is unchanged." + >&2 echo "[specify] Continue from: $WORKTREE_DIR" +fi + if $JSON_MODE; then if command -v jq >/dev/null 2>&1; then jq -cn \ @@ -451,14 +542,16 @@ if $JSON_MODE; then --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ --arg project_acronym "${PROJECT_ACRONYM:-}" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,PROJECT_ACRONYM:$project_acronym}' + --arg worktree_dir "${WORKTREE_DIR:-}" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,PROJECT_ACRONYM:$project_acronym,WORKTREE_DIR:$worktree_dir}' else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","PROJECT_ACRONYM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$(json_escape "${PROJECT_ACRONYM:-}")" + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","PROJECT_ACRONYM":"%s","WORKTREE_DIR":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$(json_escape "${PROJECT_ACRONYM:-}")" "$(json_escape "${WORKTREE_DIR:-}")" fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" echo "PROJECT_ACRONYM: ${PROJECT_ACRONYM:-}" + [ -n "${WORKTREE_DIR:-}" ] && echo "WORKTREE_DIR: $WORKTREE_DIR" printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" fi diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0efa33304e..b202fb0b15 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2187,6 +2187,7 @@ def init( preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), e2e: bool = typer.Option(False, "--e2e", help="Scaffold Playwright E2E testing infrastructure"), + worktree: bool = typer.Option(False, "--worktree", "-wt", help="Create each new feature in its own linked git worktree instead of checking the branch out in place"), ): """ Initialize a new Specify project. @@ -2612,6 +2613,7 @@ def init( "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), + "worktree": worktree, }) # Install preset if specified @@ -2768,6 +2770,7 @@ def fork_init( force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output"), e2e: bool = typer.Option(False, "--e2e", help="Scaffold Playwright E2E testing infrastructure"), + worktree: bool = typer.Option(False, "--worktree", "-wt", help="Create each new feature in its own linked git worktree instead of checking the branch out in place"), ): """Initialize a project from the local spec-kit fork (no GitHub download). @@ -2779,6 +2782,13 @@ def fork_init( specify fork-init my-project --ai claude --e2e specify fork-init . --ai claude --script sh specify fork-init --here --ai copilot + specify fork-init my-project --ai claude -wt + + With -wt every later `/speckit.specify` creates the feature branch in a new + linked worktree instead of checking it out in place, so the directory the + command was run from keeps its own branch and its untracked local state. The + choice is recorded in .specify/init-options.json and applies from then on; + a single run can still override it with --worktree / --no-worktree. """ # Derive the repo root from this file's location (editable install) # __file__ = .../spec-kit/src/specify_cli/__init__.py → repo root is 3 levels up @@ -2807,6 +2817,7 @@ def fork_init( preset=None, branch_numbering=None, e2e=e2e, + worktree=worktree, )