From bb08813089a0390cb54ec07135aa4a7b80e20eea Mon Sep 17 00:00:00 2001 From: Jesse Liberty Date: Tue, 1 Sep 2026 15:01:21 -0400 Subject: [PATCH] Add specify --- .gitignore | 3 +- .specify/.gitignore | 9 + .specify/init-options.json | 9 + .specify/integration.json | 15 + .specify/integrations/claude.manifest.json | 17 + .specify/integrations/speckit.manifest.json | 19 + .specify/memory/.constitution-template.json | 4 + .specify/memory/constitution.md | 50 ++ .../powershell/check-prerequisites.ps1 | 174 ++++ .specify/scripts/powershell/common.ps1 | 796 ++++++++++++++++++ .../scripts/powershell/create-new-feature.ps1 | 319 +++++++ .../scripts/powershell/resolve-template.ps1 | 38 + .specify/scripts/powershell/setup-plan.ps1 | 83 ++ .specify/scripts/powershell/setup-tasks.ps1 | 93 ++ .specify/templates/checklist-template.md | 45 + .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 113 +++ .specify/templates/spec-template.md | 131 +++ .specify/templates/tasks-template.md | 252 ++++++ .specify/workflows/speckit/workflow.yml | 78 ++ .specify/workflows/workflow-registry.json | 13 + 21 files changed, 2310 insertions(+), 1 deletion(-) create mode 100644 .specify/.gitignore create mode 100644 .specify/init-options.json create mode 100644 .specify/integration.json create mode 100644 .specify/integrations/claude.manifest.json create mode 100644 .specify/integrations/speckit.manifest.json create mode 100644 .specify/memory/.constitution-template.json create mode 100644 .specify/memory/constitution.md create mode 100644 .specify/scripts/powershell/check-prerequisites.ps1 create mode 100644 .specify/scripts/powershell/common.ps1 create mode 100644 .specify/scripts/powershell/create-new-feature.ps1 create mode 100644 .specify/scripts/powershell/resolve-template.ps1 create mode 100644 .specify/scripts/powershell/setup-plan.ps1 create mode 100644 .specify/scripts/powershell/setup-tasks.ps1 create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md create mode 100644 .specify/workflows/speckit/workflow.yml create mode 100644 .specify/workflows/workflow-registry.json diff --git a/.gitignore b/.gitignore index dbe4f51..e4be984 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /obj **/bin/ **/obj/ -config.json \ No newline at end of file +config.json +.claude/ \ No newline at end of file diff --git a/.specify/.gitignore b/.specify/.gitignore new file mode 100644 index 0000000..e314fc2 --- /dev/null +++ b/.specify/.gitignore @@ -0,0 +1,9 @@ +# Machine-local Spec Kit state — not meant to be shared. +# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh). + +# Local pointer to the current feature directory. Rewritten every time you +# switch features, so it is per-checkout state rather than something to share. +feature.json + +# Per-machine extension config overrides. +extensions/*/local-config.yml diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 0000000..f743c5b --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,9 @@ +{ + "ai": "claude", + "ai_skills": true, + "feature_numbering": "sequential", + "here": true, + "integration": "claude", + "script": "ps", + "speckit_version": "1.0.0" +} diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..8268b05 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "integration_state_schema": 1, + "installed_integrations": [ + "claude" + ], + "integration_settings": { + "claude": { + "script": "ps", + "invoke_separator": "-" + } + }, + "integration": "claude", + "default_integration": "claude" +} diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json new file mode 100644 index 0000000..4b321d6 --- /dev/null +++ b/.specify/integrations/claude.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "claude", + "version": "1.0.0", + "installed_at": "2026-09-01T18:59:43.633769+00:00", + "files": { + ".claude/skills/speckit-analyze/SKILL.md": "5d0565394ce8a573476718e546df3561357fd89061e9608c26fe97176e3660f4", + ".claude/skills/speckit-clarify/SKILL.md": "122da9a8c710df930fbe8219c3feb33ffd610f9659b83574e1dffb98bf5e1bd4", + ".claude/skills/speckit-constitution/SKILL.md": "78ed5639ada6bafffba4d7def4e3fbf36eb4412edb5fe45664fcea52726eb37a", + ".claude/skills/speckit-implement/SKILL.md": "00a8aeb8aa4038ad7ccdee7b21e15dd473f1aa100d022ae1d04f0939c643bc96", + ".claude/skills/speckit-converge/SKILL.md": "ca224eb399ff835884787dc87aaf862930f54bc44f8b1ad9dcc9eb67962a9e1d", + ".claude/skills/speckit-plan/SKILL.md": "99ee3d64df52b575933123a3491d43c8820d02914e54f98a2ef09ff456257e03", + ".claude/skills/speckit-checklist/SKILL.md": "7c38cd20eae8841226e053a46b6be7e30550a83520d865075c38168bfcef6412", + ".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886", + ".claude/skills/speckit-tasks/SKILL.md": "2d409fd3edb0bb0b97913168b3f2fd9bbfb327bff31a8bf1ed1a737a446889ca", + ".claude/skills/speckit-taskstoissues/SKILL.md": "613f41db8bd472a895b47a3a7051f836e77425d11e23ff72e92ee043225dcd98" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..d3993ff --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,19 @@ +{ + "integration": "speckit", + "version": "1.0.0", + "installed_at": "2026-09-01T18:59:43.710138+00:00", + "files": { + ".specify/scripts/powershell/check-prerequisites.ps1": "c2586898d293c92f0839ef338b7a005c7d9a9d71a5f9e267ed7e01208a66baaf", + ".specify/scripts/powershell/common.ps1": "69c2bc6c40455a268c02d53ca4c8ac5f2e2df98f05293ea05245b0d462040bda", + ".specify/scripts/powershell/create-new-feature.ps1": "c6d5e64455635bc9d19e2ec902de2f72a7f834afd8b47a1a3d6323f7ed0cbb62", + ".specify/scripts/powershell/resolve-template.ps1": "e49c565a09902e4ebd4b5a51c4e014d5067fdee5b1592f15cb44ef31f430d745", + ".specify/scripts/powershell/setup-plan.ps1": "089362994a002bb91d9b93daea2dc21676119839d700d79e7b69f4a72e623ed1", + ".specify/scripts/powershell/setup-tasks.ps1": "57cd05e12bd472c60ac0554ad61b89770c278d50c48e484fb824196f44f75076", + ".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", + ".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1" + } +} diff --git a/.specify/memory/.constitution-template.json b/.specify/memory/.constitution-template.json new file mode 100644 index 0000000..2fe4dee --- /dev/null +++ b/.specify/memory/.constitution-template.json @@ -0,0 +1,4 @@ +{ + "sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + "source": "core" +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/scripts/powershell/check-prerequisites.ps1 b/.specify/scripts/powershell/check-prerequisites.ps1 new file mode 100644 index 0000000..bcc2046 --- /dev/null +++ b/.specify/scripts/powershell/check-prerequisites.ps1 @@ -0,0 +1,174 @@ +#!/usr/bin/env pwsh + +# Consolidated prerequisite checking script (PowerShell) +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.ps1 [OPTIONS] +# +# OPTIONS: +# -Json Output in JSON format +# -RequireTasks Require tasks.md to exist (for implementation phase) +# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list +# -PathsOnly Only output path variables (no validation) +# -Template NAME Include composed template content in JSON output +# -Help, -h Show help message + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$RequireTasks, + [switch]$IncludeTasks, + [switch]$PathsOnly, + [string]$Template, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +# Show help if requested +if ($Help) { + Write-Output @" +Usage: check-prerequisites.ps1 [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + -Json Output in JSON format + -RequireTasks Require tasks.md to exist (for implementation phase) + -IncludeTasks Include tasks.md in AVAILABLE_DOCS list + -PathsOnly Only output path variables (no prerequisite validation) + -Template NAME Include composed template content in JSON output + -Help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + .\check-prerequisites.ps1 -Json + + # Check implementation prerequisites (plan.md + tasks.md required) + .\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks + + # Get feature paths only (no validation) + .\check-prerequisites.ps1 -PathsOnly + +"@ + exit 0 +} + +# Source common functions +. "$PSScriptRoot/common.ps1" + +# Get feature paths. +# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of +# the feature.json write side effect (issue #3025). +if ($PathsOnly) { + $paths = Get-FeaturePathsEnv -NoPersist +} else { + $paths = Get-FeaturePathsEnv +} + +# If paths-only mode, output paths and exit (no validation) +if ($PathsOnly) { + if ($Json) { + [PSCustomObject]@{ + REPO_ROOT = $paths.REPO_ROOT + BRANCH = $paths.CURRENT_BRANCH + FEATURE_DIR = $paths.FEATURE_DIR + FEATURE_SPEC = $paths.FEATURE_SPEC + IMPL_PLAN = $paths.IMPL_PLAN + TASKS = $paths.TASKS + } | ConvertTo-Json -Compress + } else { + Write-Output "REPO_ROOT: $($paths.REPO_ROOT)" + Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" + Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" + Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" + Write-Output "TASKS: $($paths.TASKS)" + } + exit 0 +} + +# Validate required directories and files +if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { + [Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)") + $specifyCommand = '/speckit-specify' + [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.") + exit 1 +} + +if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + $planCommand = '/speckit-plan' + [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") + exit 1 +} + +# Check for tasks.md if required +if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)") + $tasksCommand = '/speckit-tasks' + [Console]::Error.WriteLine("Run $tasksCommand first to create the task list.") + exit 1 +} + +# Build list of available documents +$docs = @() + +# Always check these optional docs +if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } +if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } + +# Check contracts directory (only if it exists and has files) +if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { + $docs += 'contracts/' +} + +if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } + +# Include tasks.md if requested and it exists +if ($IncludeTasks -and (Test-Path $paths.TASKS)) { + $docs += 'tasks.md' +} + +$templateContent = $null +if ($Template) { + $templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT + if ($null -eq $templateContent) { + [Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)") + exit 1 + } +} + +# Output results +if ($Json) { + # JSON output + $result = [ordered]@{ + FEATURE_DIR = $paths.FEATURE_DIR + AVAILABLE_DOCS = $docs + } + if ($Template) { + $result.TEMPLATE_CONTENT = $templateContent + } + [PSCustomObject]$result | ConvertTo-Json -Compress +} else { + # Text output + Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" + Write-Output "AVAILABLE_DOCS:" + + # Show status of each potential document. + # These helpers report their line with Write-Output and ALSO return a + # bool, both on the Success stream, so 'Out-Null' discarded the report + # line along with the return value and left AVAILABLE_DOCS empty. Drop + # only the boolean so the per-document lines reach stdout like the + # bash and Python twins. + Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] } + Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] } + Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] } + Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] } + + if ($IncludeTasks) { + Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Where-Object { $_ -isnot [bool] } + } +} diff --git a/.specify/scripts/powershell/common.ps1 b/.specify/scripts/powershell/common.ps1 new file mode 100644 index 0000000..585e884 --- /dev/null +++ b/.specify/scripts/powershell/common.ps1 @@ -0,0 +1,796 @@ +#!/usr/bin/env pwsh +# Common PowerShell functions analogous to common.sh + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +function Find-SpecifyRoot { + param([string]$StartDir = (Get-Location).Path) + + # Normalize to absolute path to prevent issues with relative paths + # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?) + $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue + $current = if ($resolved) { $resolved.Path } else { $null } + if (-not $current) { return $null } + + while ($true) { + if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) { + return $current + } + $parent = Split-Path $current -Parent + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { + return $null + } + $current = $parent + } +} + +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root, +# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by +# design: the path must exist and +# contain .specify/, with no silent fallback. (An empty string is falsy, so the +# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.) +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. +function Resolve-SpecifyInitDir { + param([switch]$ReturnNullOnError) + + $initDir = $env:SPECIFY_INIT_DIR + # Normalize: relative paths resolve against the current directory. + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + # Resolve-Path also succeeds for files, so check the resolved path is a + # directory; otherwise a file value would slip through to the less accurate + # "not a Spec Kit project" error below. + if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + if ($ReturnNullOnError) { return $null } + exit 1 + } + # Resolve-Path echoes back any trailing separator from the input; trim it so + # the returned root matches the bash resolver, whose `cd && pwd` never yields + # one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core + # only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as + # Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the + # GetPathRoot check preserves a path that *is* its own root ('C:\' must not + # become 'C:', which every later API re-resolves against the current + # directory instead of the drive root). No-op on a path with no trailing + # separator. + $initRoot = $resolved.Path.TrimEnd('/', '\') + if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) { + $initRoot = $resolved.Path + } + if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot") + if ($ReturnNullOnError) { return $null } + exit 1 + } + return $initRoot +} + +# Get repository root, prioritizing .specify directory +# This prevents using a parent repository when spec-kit is initialized in a subdirectory +function Get-RepoRoot { + param([switch]$ReturnNullOnError) + + # Explicit project override wins (see Resolve-SpecifyInitDir). + if ($env:SPECIFY_INIT_DIR) { + return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError) + } + + # First, look for .specify directory (spec-kit's own marker) + $specifyRoot = Find-SpecifyRoot + if ($specifyRoot) { + return $specifyRoot + } + + # Final fallback to script location + # Use -LiteralPath to handle paths with wildcard characters + return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path +} + +function Get-CurrentBranch { + # Return feature name from explicit state only. + # Feature state is set by SPECIFY_FEATURE (from create-new-feature or + # the git extension) or implicitly via .specify/feature.json. + if ($env:SPECIFY_FEATURE) { + return $env:SPECIFY_FEATURE + } + + # No explicit feature set - return empty to signal "unknown". + return "" +} + + + +# Persist a feature_directory value to .specify/feature.json. +# Writes only when the file is missing or the value differs from what's stored. +function Save-FeatureJson { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$FeatureDirectory + ) + + # Strip repo root prefix if the value is absolute and under repo root. + # Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere). + $prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar + if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } + if ($onWin) { + $cmp = [System.StringComparison]::OrdinalIgnoreCase + } else { + $cmp = [System.StringComparison]::Ordinal + } + if ($FeatureDirectory.StartsWith($prefix, $cmp)) { + $FeatureDirectory = $FeatureDirectory.Substring($prefix.Length) + } + + $fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json' + + # Read current value and skip write when unchanged + if (Test-Path -LiteralPath $fjPath -PathType Leaf) { + try { + $raw = Get-Content -LiteralPath $fjPath -Raw + $cfg = $raw | ConvertFrom-Json + if ($cfg.feature_directory -eq $FeatureDirectory) { + return + } + } catch { + # File is corrupt or unreadable - overwrite it + } + } + + # Ensure .specify/ directory exists + $specifyDir = Join-Path $RepoRoot '.specify' + if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) { + New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null + } + + # Write feature.json + $json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom) +} + +function Get-FeaturePathsEnv { + # Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist + # so pure path resolution never writes .specify/feature.json, which would + # dirty the working tree or overwrite a pinned value (issue #3025). + param( + [switch]$NoPersist, + [switch]$ReturnNullOnError + ) + + $repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError + if (-not $repoRoot) { return $null } + $currentBranch = Get-CurrentBranch + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by specify command) + # 3. Error - no feature context available + $featureJson = Join-Path $repoRoot '.specify/feature.json' + if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + # Persist to feature.json so future sessions without the env var still + # work - unless the caller opted out for read-only resolution (#3025). + if (-not $NoPersist) { + Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY + } + } elseif (Test-Path $featureJson) { + $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw + try { + $featureConfig = $featureJsonRaw | ConvertFrom-Json + } catch { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.") + if ($ReturnNullOnError) { return $null } + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.") + if ($ReturnNullOnError) { return $null } + exit 1 + } + } else { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.") + if ($ReturnNullOnError) { return $null } + exit 1 + } + + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature + # directory basename so CURRENT_BRANCH is a usable identifier rather than + # an empty, misleading value (issue #3026). + if (-not $currentBranch) { + # TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core + # only) keeps this working on Windows PowerShell 5.1 / .NET Framework. + $featureDirTrimmed = $featureDir.TrimEnd('/', '\') + $currentBranch = Split-Path -Leaf $featureDirTrimmed + } + + [PSCustomObject]@{ + REPO_ROOT = $repoRoot + CURRENT_BRANCH = $currentBranch + FEATURE_DIR = $featureDir + FEATURE_SPEC = Join-Path $featureDir 'spec.md' + IMPL_PLAN = Join-Path $featureDir 'plan.md' + TASKS = Join-Path $featureDir 'tasks.md' + RESEARCH = Join-Path $featureDir 'research.md' + DATA_MODEL = Join-Path $featureDir 'data-model.md' + QUICKSTART = Join-Path $featureDir 'quickstart.md' + CONTRACTS_DIR = Join-Path $featureDir 'contracts' + } +} + +function Test-FileExists { + param([string]$Path, [string]$Description) + if (Test-Path -Path $Path -PathType Leaf) { + Write-Output " [OK] $Description" + return $true + } else { + Write-Output " [FAIL] $Description" + return $false + } +} + +function Test-DirHasFiles { + param([string]$Path, [string]$Description) + # A directory counts as non-empty when Get-ChildItem returns any entry + # (files or subdirectories) -- matching the JSON contracts checks in + # check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose + # only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as + # non-empty like bash check_dir. Filtering out subdirectories would + # mis-report such a directory as empty. + if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) { + Write-Output " [OK] $Description" + return $true + } else { + Write-Output " [FAIL] $Description" + return $false + } +} + +function Get-InvokeSeparator { + param([string]$RepoRoot = (Get-RepoRoot)) + + if ($null -eq $script:SpecKitInvokeSeparatorCache) { + $script:SpecKitInvokeSeparatorCache = @{} + } + if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) { + return $script:SpecKitInvokeSeparatorCache[$RepoRoot] + } + + $separator = '.' + $integrationJson = Join-Path $RepoRoot '.specify/integration.json' + if (Test-Path -LiteralPath $integrationJson -PathType Leaf) { + try { + $state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json + $key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' } + if ($key -and $state.integration_settings) { + $settingProperty = $state.integration_settings.PSObject.Properties[$key] + if ($settingProperty) { + $setting = $settingProperty.Value + if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) { + $separator = [string]$setting.invoke_separator + } + } + } + } catch { + $separator = '.' + } + } + + $script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator + return $separator +} + +function Format-SpecKitCommand { + param( + [Parameter(Mandatory = $true)][string]$CommandName, + [string]$RepoRoot = (Get-RepoRoot) + ) + + $separator = Get-InvokeSeparator -RepoRoot $RepoRoot + $name = $CommandName.TrimStart('/') + if ($name.StartsWith('speckit.')) { + $name = $name.Substring(8) + } elseif ($name.StartsWith('speckit-')) { + $name = $name.Substring(8) + } + $name = $name -replace '\.', $separator + + return "/speckit$separator$name" +} + +# Find a usable Python 3 executable (python3, python, or py -3). +# Returns the command/arguments as an array, or $null if none found. +function Get-Python3Command { + if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { + $ver = & python --version 2>&1 + if ($ver -match 'Python 3') { return @('python') } + } + if (Get-Command py -ErrorAction SilentlyContinue) { + $ver = & py -3 --version 2>&1 + if ($ver -match 'Python 3') { return @('py', '-3') } + } + return $null +} + +function Get-NormalizedPriority { + param($Value) + + if ($Value -is [bool]) { return 10 } + if ($Value -is [string]) { + $integerText = $Value.Trim() + if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 } + $Value = $integerText.Replace('_', '') + } + try { + $parsedPriority = [System.Numerics.BigInteger]$Value + } catch { + return 10 + } + return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 }) +} + +function Get-SortedExtensionIds { + param([Parameter(Mandatory=$true)][string]$ExtensionsDir) + + $registeredNames = @() + $ranked = @() + $registryFile = Join-Path $ExtensionsDir '.registry' + # Detect any filesystem entry at the registry path without following symlinks. + # Test-Path follows links and reports $false for a dangling symlink, so a + # broken .registry symlink would otherwise bypass this guard and let the + # directory scan below enable every on-disk extension. Enumerating the parent + # directory still observes a broken symlink as an entry. + $registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq '.registry' } | + Select-Object -First 1 + if ($registryEntry) { + if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) { + throw "Invalid extension registry ${registryFile}: not a regular file" + } + try { + $data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + } catch { + throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)" + } + if ($null -eq $data -or $data -isnot [PSCustomObject]) { + throw "Invalid extension registry ${registryFile}: root must be a mapping" + } + $extensionsProperty = $data.PSObject.Properties['extensions'] + if ($extensionsProperty) { + if ($extensionsProperty.Value -isnot [PSCustomObject]) { + throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping" + } + $extensions = $extensionsProperty.Value + } else { + $extensions = [PSCustomObject]@{} + } + $registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name }) + foreach ($entry in $extensions.PSObject.Properties) { + if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) { + continue + } + $enabledProperty = $entry.Value.PSObject.Properties['enabled'] + if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue } + $priority = 10 + $priorityProperty = $entry.Value.PSObject.Properties['priority'] + if ($priorityProperty) { + $priority = Get-NormalizedPriority -Value $priorityProperty.Value + } + $ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name } + } + } + + foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) { + if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) { + $ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name } + } + } + return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id } +} + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +function Resolve-Template { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null } + + $base = Join-Path $RepoRoot '.specify/templates' + + # Priority 1: Project overrides + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { return $override } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + $registryParsed = $false + if (Test-Path $registryFile) { + try { + $registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) { + throw 'Registry root must be an object' + } + $presetsProperty = $registryData.PSObject.Properties['presets'] + if ($presetsProperty) { + $presets = $presetsProperty.Value + if ($null -eq $presets -or $presets -isnot [PSCustomObject]) { + throw 'Registry presets must be an object' + } + $presetEntries = @($presets.PSObject.Properties) + $priorityFor = { + param($Entry) + if ($Entry.Value -is [PSCustomObject]) { + $priorityProperty = $Entry.Value.PSObject.Properties['priority'] + if ($priorityProperty) { + return Get-NormalizedPriority -Value $priorityProperty.Value + } + } + return 10 + } + $sortedPresets = $presetEntries | + Where-Object { $_.Value -is [PSCustomObject] } | + Where-Object { + $enabled = $_.Value.PSObject.Properties['enabled'] + -not $enabled -or [bool]$enabled.Value + } | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } | + ForEach-Object { $_.Name } + } + $registryParsed = $true + } catch { + $registryParsed = $false + } + } + + if ($registryParsed) { + foreach ($presetId in $sortedPresets) { + $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + $candidate = Join-Path $presetsDir "$presetId/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } else { + # Fallback: alphabetical directory order + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + $candidate = Join-Path $preset.FullName "$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + } + + # Priority 3: Extension-provided templates + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) { + $candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md" + if (-not (Test-Path $candidate)) { + $candidate = Join-Path $extDir "$extensionId/$TemplateName.md" + } + if (Test-Path $candidate) { return $candidate } + } + } + + # Priority 4: Core templates + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { return $core } + + return $null +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +function Resolve-TemplateContent { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { + return $null + } + + $base = Join-Path $RepoRoot '.specify/templates' + + # Collect all layers (highest priority first) + $layerPaths = @() + $layerStrategies = @() + + # Priority 1: Project overrides (always "replace") + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { + return [System.IO.File]::ReadAllText( + $override, + [System.Text.Encoding]::UTF8 + ) + } + + $effectiveBaseFound = $false + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + $registryParsed = $false + if (Test-Path $registryFile) { + try { + $registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) { + throw 'Registry root must be an object' + } + $presetsProperty = $registryData.PSObject.Properties['presets'] + if ($presetsProperty) { + $presets = $presetsProperty.Value + if ($null -eq $presets -or $presets -isnot [PSCustomObject]) { + throw 'Registry presets must be an object' + } + $presetEntries = @($presets.PSObject.Properties) + $priorityFor = { + param($Entry) + if ($Entry.Value -is [PSCustomObject]) { + $priorityProperty = $Entry.Value.PSObject.Properties['priority'] + if ($priorityProperty) { + return Get-NormalizedPriority -Value $priorityProperty.Value + } + } + return 10 + } + $sortedPresets = $presetEntries | + Where-Object { $_.Value -is [PSCustomObject] } | + Where-Object { + $enabled = $_.Value.PSObject.Properties['enabled'] + -not $enabled -or [bool]$enabled.Value + } | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } | + ForEach-Object { $_.Name } + } + $registryParsed = $true + } catch { + $registryParsed = $false + } + } + + if (-not $registryParsed) { + $sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object Name | + ForEach-Object { $_.Name } + } + + $pyCmd = @(Get-Python3Command) + foreach ($presetId in $sortedPresets) { + # Read strategy and file path from preset manifest + $strategy = 'replace' + $manifestFilePath = '' + $manifestDeclared = $false + $manifest = Join-Path $presetsDir "$presetId/preset.yml" + if ((Test-Path $manifest) -and -not $pyCmd) { + throw "Python 3 and PyYAML are required to resolve preset template composition" + } + if (Test-Path $manifest) { + try { + # Use Python to parse YAML manifest for strategy and file path + $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + $pyStderrFile = [System.IO.Path]::GetTempFileName() + $stratResult = & $pyCmd[0] @pyArgs -c @" +import sys +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + sys.exit(2) +try: + with open(sys.argv[1], encoding='utf-8') as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise ValueError('manifest root must be a mapping') + if 'provides' not in data: + raise ValueError('manifest missing provides section') + provides = data['provides'] + if not isinstance(provides, dict): + raise ValueError('manifest provides must be a mapping') + if 'templates' not in provides: + raise ValueError('manifest provides missing templates') + templates = provides['templates'] + if not isinstance(templates, list): + raise ValueError('manifest templates must be a list') + if not templates: + raise ValueError('manifest must provide at least one template') + valid_types = ('template', 'command', 'script') + valid_strategies = ('replace', 'prepend', 'append', 'wrap') + for t in templates: + if not isinstance(t, dict): + raise ValueError('manifest template entries must be mappings') + if 'type' not in t or 'name' not in t or 'file' not in t: + raise ValueError('manifest template entry missing type, name, or file') + for field in ('type', 'name', 'file'): + if not isinstance(t[field], str): + raise ValueError('manifest template ' + field + ' must be a string') + if t['type'] not in valid_types: + raise ValueError('invalid manifest template type') + strategy = t.get('strategy', 'replace') + if not isinstance(strategy, str): + raise ValueError('manifest template strategy must be a string') + strategy = strategy.lower() + if strategy not in valid_strategies: + raise ValueError('invalid manifest template strategy') + if t['type'] == 'script' and strategy not in ('replace', 'wrap'): + raise ValueError('invalid manifest script strategy') + for t in templates: + if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template': + file_value = t.get('file', '') + strategy = t.get('strategy', 'replace') + print('found\t' + strategy + '\t' + file_value) + sys.exit(0) + print('absent\treplace\t') +except Exception as exc: + print(f'manifest_invalid: {exc}', file=sys.stderr) + sys.exit(3) +"@ $manifest $TemplateName 2>$pyStderrFile + if ($LASTEXITCODE -ne 0) { + if ($LASTEXITCODE -eq 2) { + throw "PyYAML is required to resolve preset template composition" + } + throw "Invalid preset manifest $manifest" + } + if ($stratResult) { + $parts = $stratResult.Trim() -split "`t", 3 + $manifestDeclared = $parts[0] -eq 'found' + $strategy = $parts[1].ToLowerInvariant() + if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] } + } + Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue + } catch { + if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + throw + } + } + # Try manifest file path first, then convention path + $candidate = $null + if ($manifestFilePath) { + # Reject absolute paths and parent traversal + if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') { + $manifestFilePath = '' + } + } + if ($manifestFilePath) { + $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" + if (Test-Path $mf) { $candidate = $mf } + } + if (-not $candidate -and -not $manifestDeclared) { + $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } + } + if ($candidate) { + $layerPaths += $candidate + $layerStrategies += $strategy + if ($strategy -eq 'replace') { + $effectiveBaseFound = $true + break + } + } + } + } + + # Priority 3: Extension-provided templates (always "replace") + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (-not $effectiveBaseFound -and (Test-Path $extDir)) { + foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) { + $candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md" + if (-not (Test-Path $candidate)) { + $candidate = Join-Path $extDir "$extensionId/$TemplateName.md" + } + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + $effectiveBaseFound = $true + break + } + } + } + + # Priority 4: Core templates (always "replace") + $core = Join-Path $base "$TemplateName.md" + if (-not $effectiveBaseFound -and (Test-Path $core)) { + $layerPaths += $core + $layerStrategies += 'replace' + } + + if ($layerPaths.Count -eq 0) { return $null } + + # If the top (highest-priority) layer is replace, it wins entirely -- + # lower layers are irrelevant regardless of their strategies. + if ($layerStrategies[0] -eq 'replace') { + return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8) + } + + # Check if any layer uses a non-replace strategy + $hasComposition = $false + foreach ($s in $layerStrategies) { + if ($s -ne 'replace') { $hasComposition = $true; break } + } + + if (-not $hasComposition) { + return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8) + } + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + $baseIdx = -1 + for ($i = 0; $i -lt $layerPaths.Count; $i++) { + if ($layerStrategies[$i] -eq 'replace') { + $baseIdx = $i + break + } + } + if ($baseIdx -lt 0) { + throw "Template '$TemplateName' has composing layers but no replace base" + } + + $content = [System.IO.File]::ReadAllText( + $layerPaths[$baseIdx], + [System.Text.Encoding]::UTF8 + ) + + for ($i = $baseIdx - 1; $i -ge 0; $i--) { + $path = $layerPaths[$i] + $strat = $layerStrategies[$i] + $layerContent = [System.IO.File]::ReadAllText( + $path, + [System.Text.Encoding]::UTF8 + ) + + switch ($strat) { + 'replace' { $content = $layerContent } + 'prepend' { $content = "$layerContent`n`n$content" } + 'append' { $content = "$content`n`n$layerContent" } + 'wrap' { + if (-not $layerContent.Contains('{CORE_TEMPLATE}')) { + throw "Wrap strategy missing {CORE_TEMPLATE} placeholder" + } + $content = $layerContent.Replace('{CORE_TEMPLATE}', $content) + } + default { throw "Unknown strategy: $strat" } + } + } + + return $content +} diff --git a/.specify/scripts/powershell/create-new-feature.ps1 b/.specify/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000..e7a68c4 --- /dev/null +++ b/.specify/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,319 @@ +#!/usr/bin/env pwsh +# Create a new feature +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [string]$Number = '', + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' +$maxBranchLength = 244 + +# Show help if requested +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute feature name and paths without creating directories or files" + Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the feature" + Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Examples:" + Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" + Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" + Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" + exit 0 +} + +# Check if feature description provided +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +# Validate description is not empty after trimming (e.g., user passed only whitespace) +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Test-SpecPrefixInUse { + param( + [string]$SpecsDir, + [string]$FeatureNum + ) + + if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) { + return $false + } + + return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "$FeatureNum-*" } | + Select-Object -First 1) +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +function Get-FittedBranchName { + param( + [string]$FeatureNum, + [string]$BranchSuffix + ) + + $fittedName = "$FeatureNum-$BranchSuffix" + if ($fittedName.Length -gt $maxBranchLength) { + $prefixLength = $FeatureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + $truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + $fittedName = "$FeatureNum-$truncatedSuffix" + } + + return $fittedName +} +# Load common functions (includes Get-RepoRoot and Resolve-Template) +. "$PSScriptRoot/common.ps1" + +# Use common.ps1 functions which prioritize .specify +$repoRoot = Get-RepoRoot + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' +if (-not $DryRun) { + New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +} + +# Function to generate branch name with stop word filtering and length filtering +function Get-BranchName { + param([string]$Description) + + # Common stop words to filter out + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + # Convert to lowercase and extract words (alphanumeric only) + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + $meaningfulWords = @() + foreach ($word in $words) { + # Skip stop words + if ($stopWords -contains $word) { continue } + + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + # Keep short words only if they appear as uppercase in original (likely + # acronyms). Use -cmatch so the comparison is case-sensitive, matching the + # bash script's case-sensitive grep; -match would be case-insensitive and + # would keep every short word. + $meaningfulWords += $word + } + } + + # If we have meaningful words, use first 3-4 of them + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + # Fallback to original logic if no meaningful words found + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Generate branch name +if ($ShortName) { + # Use provided short name, just clean it up + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +} else { + # Generate from description with smart filtering + $branchSuffix = Get-BranchName -Description $featureDesc +} + +# Treat an explicit empty string as omitted, matching the bash and Python twins. +$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne '' + +# Warn if -Number and -Timestamp are both specified. +if ($Timestamp -and $hasNumber) { + [Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used") + $Number = '' +} + +# Determine branch prefix +if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" +} else { + # Determine branch number from existing feature directories. Auto-detect only + # when -Number was not supplied; an explicit value (including 0) is honored, + # matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check. + [long]$resolvedNumber = 0 + if (-not $hasNumber) { + $highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir + if ($highestNumber -eq [long]::MaxValue) { + Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'" + exit 1 + } + $resolvedNumber = $highestNumber + 1 + } elseif ($Number -notmatch '^[0-9]+$') { + Write-Error "Error: -Number must be an unsigned integer, got '$Number'" + exit 1 + } elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) { + Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'" + exit 1 + } + + $featureNum = ('{0:000}' -f $resolvedNumber) + + # Treat an explicit number as a preference when its prefix is already used + # by a feature directory. Auto-detected numbers are already conflict-free. + $specConflict = $false + if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) { + $requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix + $requestedDir = Join-Path $specsDir $requestedBranchName + if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) { + $specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum + } + } + + if ($specConflict) { + $requestedNum = $featureNum + $highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir + $resolvedNumber = $highestNumber + do { + if ($resolvedNumber -eq [long]::MaxValue) { + Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'" + exit 1 + } + $resolvedNumber++ + $featureNum = ('{0:000}' -f $resolvedNumber) + } while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum) + [Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead") + } + +} + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +$originalBranchName = "$featureNum-$branchSuffix" +$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix +if ($branchName -ne $originalBranchName) { + [Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit") + [Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)") + [Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)") +} + +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' + +if (-not $DryRun) { + if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) { + if ($Timestamp) { + Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName." + } else { + Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number." + } + exit 1 + } + + $needsSpec = -not (Test-Path -PathType Leaf $specFile) + $content = $null + if ($needsSpec) { + $content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot + } + + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + + if ($needsSpec) { + if ($null -ne $content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom) + } else { + # Match the bash twin (create-new-feature.sh): warn on stderr that no + # spec template was found before creating an empty spec file, so the + # missing-template signal is not silently swallowed on Windows. + [Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file") + New-Item -ItemType File -Path $specFile -Force | Out-Null + } + } + + # Persist to .specify/feature.json so downstream commands can find the feature + Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir + + # Set environment variables for the current session + $env:SPECIFY_FEATURE = $branchName + $env:SPECIFY_FEATURE_DIRECTORY = $featureDir + + $quotedBranchName = "'" + $branchName.Replace("'", "''") + "'" + $quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'" + $featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName + $directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir + [Console]::Error.WriteLine("# To persist: $featureAssignment") + [Console]::Error.WriteLine("# $directoryAssignment") +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + SPEC_FILE = $specFile + FEATURE_NUM = $featureNum + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "SPEC_FILE: $specFile" + Write-Output "FEATURE_NUM: $featureNum" + if (-not $DryRun) { + Write-Output "# To persist in your shell: $featureAssignment" + Write-Output "# $directoryAssignment" + } +} diff --git a/.specify/scripts/powershell/resolve-template.ps1 b/.specify/scripts/powershell/resolve-template.ps1 new file mode 100644 index 0000000..70aee0a --- /dev/null +++ b/.specify/scripts/powershell/resolve-template.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Position=0)] + [string]$TemplateName, + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: resolve-template.ps1 [-Json]" + exit 0 +} + +if (-not $TemplateName) { + [Console]::Error.WriteLine("ERROR: Template name is required") + exit 1 +} + +. "$PSScriptRoot/common.ps1" + +$repoRoot = Get-RepoRoot +$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot +if ($null -eq $templateContent) { + [Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot") + exit 1 +} + +if ($Json) { + [PSCustomObject]@{ + TEMPLATE_NAME = $TemplateName + TEMPLATE_CONTENT = $templateContent + } | ConvertTo-Json -Compress +} else { + [Console]::Out.Write($templateContent) +} diff --git a/.specify/scripts/powershell/setup-plan.ps1 b/.specify/scripts/powershell/setup-plan.ps1 new file mode 100644 index 0000000..52f615a --- /dev/null +++ b/.specify/scripts/powershell/setup-plan.ps1 @@ -0,0 +1,83 @@ +#!/usr/bin/env pwsh +# Setup implementation plan for a feature + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help, + # Capture extra positional arguments to match Bash/Python behavior. + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs +) + +$ErrorActionPreference = 'Stop' + +# Show help if requested +if ($Help) { + Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]" + Write-Output " -Json Output results in JSON format" + Write-Output " -Help Show this help message" + exit 0 +} + +# Load common functions +. "$PSScriptRoot/common.ps1" + +# Get all paths and variables from common functions +$paths = Get-FeaturePathsEnv -ReturnNullOnError +if (-not $paths) { + [Console]::Error.WriteLine("ERROR: Failed to resolve feature paths") + exit 1 +} + +# Ensure the feature directory exists +New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null + +# Copy plan template if plan doesn't already exist +if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { + if ($Json) { + [Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy") + } else { + Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" + } +} else { + $content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT + if ($null -ne $content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) + # Emit the copy status like the bash twin (setup-plan.sh); route to stderr + # in -Json mode so stdout stays pure JSON, matching the sibling messages. + if ($Json) { + [Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)") + } else { + Write-Output "Copied plan template to $($paths.IMPL_PLAN)" + } + } else { + # Match the bash twin's wording and stream routing (stderr in -Json so + # stdout stays pure JSON, stdout otherwise), consistent with the sibling + # "Copied plan template" message above. + if ($Json) { + [Console]::Error.WriteLine("Warning: Plan template not found") + } else { + Write-Output "Warning: Plan template not found" + } + # Create a basic plan file if template doesn't exist + New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null + } +} + +# Output results +if ($Json) { + $result = [PSCustomObject]@{ + FEATURE_SPEC = $paths.FEATURE_SPEC + IMPL_PLAN = $paths.IMPL_PLAN + SPECS_DIR = $paths.FEATURE_DIR + BRANCH = $paths.CURRENT_BRANCH + } + $result | ConvertTo-Json -Compress +} else { + Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" + Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" + Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" + Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" +} diff --git a/.specify/scripts/powershell/setup-tasks.ps1 b/.specify/scripts/powershell/setup-tasks.ps1 new file mode 100644 index 0000000..46b1bf9 --- /dev/null +++ b/.specify/scripts/powershell/setup-tasks.ps1 @@ -0,0 +1,93 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs +) + +$ErrorActionPreference = 'Stop' + +# Help wins over unknown-argument validation to match the Bash/Python +# variants, which stop at --help and exit 0. +if ($Help) { + Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]" + exit 0 +} + +if ($RemainingArgs.Count -gt 0) { + [Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'") + exit 1 +} + +# Source common functions +. "$PSScriptRoot/common.ps1" + +# Get feature paths +$paths = Get-FeaturePathsEnv -ReturnNullOnError +if (-not $paths) { + [Console]::Error.WriteLine("ERROR: Failed to resolve feature paths") + exit 1 +} + +if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + $planCommand = '/speckit-plan' + [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") + exit 1 +} + +if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") + $specifyCommand = '/speckit-specify' + [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.") + exit 1 +} + +# Build available docs list +$docs = @() +if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } +if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } +if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { + $docs += 'contracts/' +} +if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } + +# Resolve tasks template through override stack +$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +if ($null -eq $tasksTemplateContent) { + [Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)") + [Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.") + exit 1 +} +if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { + $tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path +} else { + $tasksTemplate = '' +} + +# Output results +if ($Json) { + [PSCustomObject]@{ + FEATURE_DIR = $paths.FEATURE_DIR + AVAILABLE_DOCS = $docs + TASKS_TEMPLATE = $tasksTemplate + TASKS_TEMPLATE_CONTENT = $tasksTemplateContent + } | ConvertTo-Json -Compress +} else { + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" + Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })" + Write-Output "AVAILABLE_DOCS:" + # These helpers report their line with Write-Output and ALSO return a + # bool, both on the Success stream, so 'Out-Null' discarded the report + # line along with the return value and left AVAILABLE_DOCS empty. Drop + # only the boolean so the per-document lines reach stdout like the + # bash and Python twins. + Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] } + Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] } + Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] } + Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] } +} diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 0000000..c4c4ffb --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,45 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements. +**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied. +**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied +- Leave items unchecked when they still require clarification, correction, or reviewer evaluation +- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers +- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 0000000..be1aa88 --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,113 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] + +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] + +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] + +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] + +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] + +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] + +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] + +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] + +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] + +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 0000000..ceb2877 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,131 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` + +**Created**: [DATE] + +**Status**: Draft + +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 0000000..d46a1f1 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,252 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..230675b --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,78 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + # 0.8.5 is the first release with engine-side resolution of the + # ``integration: "auto"`` default. Older versions would treat "auto" + # as a literal integration key and fail at dispatch. + speckit_version: ">=0.8.5" + integrations: + # The four commands below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration. The list here is an + # advisory, non-exhaustive compatibility hint following the documented + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs + # against any integration the project was initialized with, including + # ones not listed below, as long as that integration provides the four + # core commands referenced in ``steps``. + any: + - "alquimia" + - "claude" + - "copilot" + - "gemini" + - "opencode" + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 0000000..43de095 --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-09-01T18:59:43.793466+00:00", + "updated_at": "2026-09-01T18:59:43.793474+00:00" + } + } +} \ No newline at end of file