diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 00000000..651400d3 --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,164 @@ +installed: +- agent-context +- git +settings: + auto_execute_hooks: true +hooks: + before_constitution: + - extension: git + command: speckit.git.initialize + enabled: true + optional: false + prompt: Execute speckit.git.initialize? + description: Initialize Git repository before constitution setup + condition: null + before_specify: + - extension: git + command: speckit.git.feature + enabled: true + optional: false + prompt: Execute speckit.git.feature? + description: Create feature branch before specification + condition: null + before_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before clarification? + description: Auto-commit before spec clarification + condition: null + before_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before planning? + description: Auto-commit before implementation planning + condition: null + before_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before task generation? + description: Auto-commit before task generation + condition: null + before_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before implementation? + description: Auto-commit before implementation + condition: null + before_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before checklist? + description: Auto-commit before checklist generation + condition: null + before_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before analysis? + description: Auto-commit before analysis + condition: null + before_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before issue sync? + description: Auto-commit before tasks-to-issues conversion + condition: null + after_constitution: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit constitution changes? + description: Auto-commit after constitution update + condition: null + after_specify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit specification changes? + description: Auto-commit after specification + condition: null + - extension: agent-context + command: speckit.agent-context.update + enabled: true + optional: true + prompt: Execute speckit.agent-context.update? + description: Refresh agent context after specification + condition: null + after_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit clarification changes? + description: Auto-commit after spec clarification + condition: null + after_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit plan changes? + description: Auto-commit after implementation planning + condition: null + - extension: agent-context + command: speckit.agent-context.update + enabled: true + optional: true + prompt: Execute speckit.agent-context.update? + description: Refresh agent context after planning + condition: null + after_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit task changes? + description: Auto-commit after task generation + condition: null + after_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit implementation changes? + description: Auto-commit after implementation + condition: null + after_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit checklist changes? + description: Auto-commit after checklist generation + condition: null + after_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit analysis results? + description: Auto-commit after analysis + condition: null + after_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit after syncing issues? + description: Auto-commit after tasks-to-issues conversion + condition: null diff --git a/.specify/extensions/.registry b/.specify/extensions/.registry new file mode 100644 index 00000000..6b682b95 --- /dev/null +++ b/.specify/extensions/.registry @@ -0,0 +1,37 @@ +{ + "schema_version": "1.0", + "extensions": { + "git": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79", + "enabled": true, + "priority": 10, + "registered_commands": { + "opencode": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-06-10T11:52:53.950620+00:00" + }, + "agent-context": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9a1dc02d2d0139bb03860392ecacef79183be2c442feda2f9ccaa4e5907b1e47", + "enabled": true, + "priority": 10, + "registered_commands": { + "opencode": [ + "speckit.agent-context.update" + ] + }, + "registered_skills": [], + "installed_at": "2026-06-10T11:52:53.995615+00:00" + } + } +} \ No newline at end of file diff --git a/.specify/extensions/agent-context/README.md b/.specify/extensions/agent-context/README.md new file mode 100644 index 00000000..dba004eb --- /dev/null +++ b/.specify/extensions/agent-context/README.md @@ -0,0 +1,57 @@ +# Coding Agent Context Extension + +This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration. + +It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``). + +## Why an extension? + +Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users: + +- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. +- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | + +## Configuration + +All configuration flows through the extension's own config file at +`.specify/extensions/agent-context/agent-context-config.yml`: + +```yaml +# Path to the coding agent context file managed by this extension +context_file: CLAUDE.md + +# Delimiters for the managed Spec Kit section +context_markers: + start: "" + end: "" +``` + +- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. + +## Requirements + +The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). + +PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run: + +```bash +pip install pyyaml +# or target the specific interpreter Spec Kit uses: +/path/to/speckit-python -m pip install pyyaml +``` + +## Disable + +```bash +specify extension disable agent-context +``` + +When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). diff --git a/.specify/extensions/agent-context/agent-context-config.yml b/.specify/extensions/agent-context/agent-context-config.yml new file mode 100644 index 00000000..eeaa6a9e --- /dev/null +++ b/.specify/extensions/agent-context/agent-context-config.yml @@ -0,0 +1,4 @@ +context_file: AGENTS.md +context_markers: + start: + end: diff --git a/.specify/extensions/agent-context/commands/speckit.agent-context.update.md b/.specify/extensions/agent-context/commands/speckit.agent-context.update.md new file mode 100644 index 00000000..02f17069 --- /dev/null +++ b/.specify/extensions/agent-context/commands/speckit.agent-context.update.md @@ -0,0 +1,26 @@ +--- +description: "Refresh the managed Spec Kit section in the coding agent context file" +--- + +# Update Coding Agent Context + +Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`). + +## Behavior + +The script reads the agent-context extension config at +`.specify/extensions/agent-context/agent-context-config.yml` to discover: + +- `context_file` — the path of the coding agent context file to manage. +- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. + +It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs//plan.md`). + +If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully. + +## Execution + +- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` +- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` + +When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`. diff --git a/.specify/extensions/agent-context/extension.yml b/.specify/extensions/agent-context/extension.yml new file mode 100644 index 00000000..191069e3 --- /dev/null +++ b/.specify/extensions/agent-context/extension.yml @@ -0,0 +1,34 @@ +schema_version: "1.0" + +extension: + id: agent-context + name: "Coding Agent Context" + version: "1.0.0" + description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + +provides: + commands: + - name: speckit.agent-context.update + file: commands/speckit.agent-context.update.md + description: "Refresh the managed Spec Kit section in the coding agent context file" + +hooks: + after_specify: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after specification" + after_plan: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after planning" + +tags: + - "agent" + - "context" + - "core" diff --git a/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh b/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh new file mode 100755 index 00000000..42ce44df --- /dev/null +++ b/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# update-agent-context.sh +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.sh [plan_path] +# +# When `plan_path` is omitted, the script picks the most recently modified +# `specs/*/plan.md` if any exist, otherwise emits the section without a +# concrete plan path. + +set -euo pipefail + +PROJECT_ROOT="$(pwd)" +EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" +DEFAULT_START="" +DEFAULT_END="" + +if [[ ! -f "$EXT_CONFIG" ]]; then + echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 + exit 0 +fi + +# Locate a suitable Python interpreter (python3, then python). +_python="" +if command -v python3 >/dev/null 2>&1; then + _python="python3" +elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then + _python="python" +fi + +if [[ -z "$_python" ]]; then + echo "agent-context: Python 3 not found on PATH; skipping update." >&2 + exit 0 +fi + +# Parse extension config once; emit three newline-separated fields: +# context_file, context_markers.start, context_markers.end +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +import sys +try: + import yaml +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config but is not available " + "in the current Python environment.\n" + " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" + " Context file will not be updated until PyYAML is importable.", + file=sys.stderr, + ) + sys.exit(2) +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +except Exception as exc: + print( + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", + file=sys.stderr, + ) + sys.exit(2) +if not isinstance(data, dict): + data = {} +def get_str(obj, *keys): + node = obj + for k in keys: + if isinstance(node, dict) and k in node: + node = node[k] + else: + return "" + return node if isinstance(node, str) else "" +print(get_str(data, "context_file")) +print(get_str(data, "context_markers", "start")) +print(get_str(data, "context_markers", "end")) +PY +)"; then + echo "agent-context: skipping update (see above for details)." >&2 + exit 0 +fi + +_opts_lines=() +while IFS= read -r _line || [[ -n "$_line" ]]; do + _opts_lines+=("$_line") +done < <(printf '%s\n' "$_raw_opts") +if (( ${#_opts_lines[@]} < 3 )); then + echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 + exit 0 +fi +CONTEXT_FILE="${_opts_lines[0]}" +MARKER_START="${_opts_lines[1]}" +MARKER_END="${_opts_lines[2]}" + +if [[ -z "$CONTEXT_FILE" ]]; then + echo "agent-context: context_file not set in extension config; nothing to do." >&2 + exit 0 +fi + +# Reject absolute paths, backslash separators, and '..' path segments in context_file +if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then + echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2 + exit 1 +fi +if [[ "$CONTEXT_FILE" == *\\* ]]; then + echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2 + exit 1 +fi +IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE" +for _seg in "${_cf_parts[@]}"; do + if [[ "$_seg" == ".." ]]; then + echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 + exit 1 + fi +done +unset _cf_parts _seg + +[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" +[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" + +PLAN_PATH="${1:-}" +if [[ -z "$PLAN_PATH" ]]; then + # Pick the most recently modified plan.md one level deep (specs//plan.md). + # Use find + sort by modification time to avoid ls/head fragility with + # spaces in paths or SIGPIPE from pipefail. + _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys, os +from pathlib import Path +specs = Path(sys.argv[1]) / "specs" +plans = sorted( + specs.glob("*/plan.md"), + key=lambda p: p.stat().st_mtime, + reverse=True, +) +print(plans[0] if plans else "") +PY +)" + if [[ -n "$_plan_abs" ]]; then + PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + fi +fi + +CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" +mkdir -p "$(dirname "$CTX_PATH")" + +# Build the managed section +TMP_SECTION="$(mktemp)" +trap 'rm -f "$TMP_SECTION"' EXIT +{ + echo "$MARKER_START" + echo "For additional context about technologies to be used, project structure," + echo "shell commands, and other important information, read the current plan" + if [[ -n "$PLAN_PATH" ]]; then + echo "at $PLAN_PATH" + fi + echo "$MARKER_END" +} > "$TMP_SECTION" + +"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +import sys, os +ctx_path, start, end, section_path = sys.argv[1:5] +with open(section_path, "r", encoding="utf-8") as fh: + section = fh.read().rstrip("\n") + "\n" + +if os.path.exists(ctx_path): + with open(ctx_path, "r", encoding="utf-8-sig") as fh: + content = fh.read() + s = content.find(start) + e = content.find(end, s if s != -1 else 0) + if s != -1 and e != -1 and e > s: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:s] + section + content[end_of_marker:] + elif s != -1: + new_content = content[:s] + section + elif e != -1: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + if content and not content.endswith("\n"): + content += "\n" + new_content = (content + "\n" + section) if content else section +else: + new_content = section + +new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +with open(ctx_path, "wb") as fh: + fh.write(new_content.encode("utf-8")) +PY + +echo "agent-context: updated $CONTEXT_FILE" diff --git a/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 new file mode 100644 index 00000000..dad309c0 --- /dev/null +++ b/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -0,0 +1,237 @@ +#!/usr/bin/env pwsh +# update-agent-context.ps1 +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.ps1 [plan_path] + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$PlanPath +) + +function Get-ConfigValue { + param( + [AllowNull()][object]$Object, + [Parameter(Mandatory = $true)][string]$Key + ) + + if ($null -eq $Object) { + return $null + } + if ($Object -is [System.Collections.IDictionary]) { + return $Object[$Key] + } + $prop = $Object.PSObject.Properties[$Key] + if ($prop) { + return $prop.Value + } + return $null +} + +function Test-ConfigObject { + param( + [AllowNull()][object]$Object + ) + + if ($null -eq $Object) { + return $false + } + if ($Object -is [System.Collections.IDictionary]) { + return $true + } + if ($Object -is [System.Management.Automation.PSCustomObject]) { + return $true + } + return $false +} + +$ErrorActionPreference = 'Stop' +$DefaultStart = '' +$DefaultEnd = '' +$ProjectRoot = (Get-Location).Path +$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' + +if (-not (Test-Path -LiteralPath $ExtConfig)) { + Write-Warning "agent-context: $ExtConfig not found; nothing to do." + exit 0 +} + +$Options = $null +if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { + try { + $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + } catch { + # fall through to Python fallback + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. + $pythonCmd = $null + foreach ($candidate in @('python3', 'python')) { + if (Get-Command $candidate -ErrorAction SilentlyContinue) { + # Verify it is Python 3 + $verOut = & $candidate --version 2>&1 + if ($verOut -match 'Python 3') { + $pythonCmd = $candidate + break + } + } + } + + if ($pythonCmd) { + try { + $jsonOut = & $pythonCmd -c @' +import json +import sys +try: + import yaml +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config; cannot update context.", + file=sys.stderr, + ) + sys.exit(2) + +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +except Exception as exc: + print( + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", + file=sys.stderr, + ) + sys.exit(2) + +if not isinstance(data, dict): + data = {} + +print(json.dumps(data)) +'@ $ExtConfig + if ($LASTEXITCODE -eq 0 -and $jsonOut) { + $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop + } + } catch { + $Options = $null + } + } + + if (-not $Options) { + Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." + exit 0 + } +} + +if (-not (Test-ConfigObject -Object $Options)) { + Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update." + exit 0 +} + +$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' +if (-not $ContextFile) { + Write-Warning 'agent-context: context_file not set in extension config; nothing to do.' + exit 0 +} + +# Reject absolute paths and '..' path segments in context_file +if ([System.IO.Path]::IsPathRooted($ContextFile)) { + Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'." + exit 1 +} +$cfSegments = $ContextFile -split '[/\\]' +if ($cfSegments -contains '..') { + Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'." + exit 1 +} + +$MarkerStart = $DefaultStart +$MarkerEnd = $DefaultEnd +$cm = Get-ConfigValue -Object $Options -Key 'context_markers' +if ($cm) { + $cmStart = Get-ConfigValue -Object $cm -Key 'start' + if ($cmStart -is [string] -and $cmStart) { + $MarkerStart = $cmStart + } + $cmEnd = Get-ConfigValue -Object $cm -Key 'end' + if ($cmEnd -is [string] -and $cmEnd) { + $MarkerEnd = $cmEnd + } +} + +if (-not $PlanPath) { + # Discover plan.md exactly one level deep (specs//plan.md), + # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under + # $ErrorActionPreference = 'Stop' don't abort the script. + try { + $specsDir = Join-Path $ProjectRoot 'specs' + $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | + Where-Object { $_ } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + } + } catch { + # Non-fatal: continue without a plan path. + } +} + +$CtxPath = Join-Path $ProjectRoot $ContextFile +$CtxDir = Split-Path -Parent $CtxPath +if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { + New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null +} + +$lines = @($MarkerStart, + 'For additional context about technologies to be used, project structure,', + 'shell commands, and other important information, read the current plan') +if ($PlanPath) { + $lines += "at $PlanPath" +} +$lines += $MarkerEnd +$Section = ($lines -join "`n") + "`n" + +if (Test-Path -LiteralPath $CtxPath) { + $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) + # Strip UTF-8 BOM if present + if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) + } else { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) + } + + $s = $content.IndexOf($MarkerStart) + $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } + + if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) + } elseif ($s -ge 0) { + $newContent = $content.Substring(0, $s) + $Section + } elseif ($e -ge 0) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $Section + $content.Substring($endOfMarker) + } else { + if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } + if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + } +} else { + $newContent = $Section +} + +$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") +[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) + +Write-Host "agent-context: updated $ContextFile" diff --git a/.specify/extensions/git/README.md b/.specify/extensions/git/README.md new file mode 100644 index 00000000..31ba75c3 --- /dev/null +++ b/.specify/extensions/git/README.md @@ -0,0 +1,100 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/.specify/extensions/git/commands/speckit.git.commit.md b/.specify/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 00000000..e606f911 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/.specify/extensions/git/commands/speckit.git.feature.md b/.specify/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 00000000..5bed9e5e --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,67 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- 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 output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/.specify/extensions/git/commands/speckit.git.initialize.md b/.specify/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 00000000..93962c24 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,49 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `[OK] Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/.specify/extensions/git/commands/speckit.git.remote.md b/.specify/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 00000000..712a3e8b --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/.specify/extensions/git/commands/speckit.git.validate.md b/.specify/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 00000000..dd84618c --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/.specify/extensions/git/config-template.yml b/.specify/extensions/git/config-template.yml new file mode 100644 index 00000000..8c414bab --- /dev/null +++ b/.specify/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/extension.yml b/.specify/extensions/git/extension.yml new file mode 100644 index 00000000..13c1977e --- /dev/null +++ b/.specify/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml new file mode 100644 index 00000000..8c414bab --- /dev/null +++ b/.specify/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/scripts/bash/auto-commit.sh b/.specify/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 00000000..f0b42318 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/.specify/extensions/git/scripts/bash/create-new-feature.sh b/.specify/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 00000000..f7aa3161 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name without creating the branch" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + 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 " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + local stop_words="^(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)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi +else + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi + + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi + + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&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)" +fi + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/extensions/git/scripts/bash/git-common.sh b/.specify/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 00000000..b78356d1 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/.specify/extensions/git/scripts/bash/initialize-repo.sh b/.specify/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 00000000..296e363b --- /dev/null +++ b/.specify/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Git repository initialized" >&2 diff --git a/.specify/extensions/git/scripts/powershell/auto-commit.ps1 b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 00000000..34767f8a --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,169 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file -- auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} finally { + $ErrorActionPreference = $savedEAP +} + +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 00000000..b579f051 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,403 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +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 branch name without creating the branch" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" + exit 0 +} + +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() + +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 { + 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 Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + 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 Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' + +function Get-BranchName { + param([string]$Description) + + $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' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } +} else { + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } + + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } + + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$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)" +} + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/.specify/extensions/git/scripts/powershell/git-common.ps1 b/.specify/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 00000000..13ea7542 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 -- contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false + } + return $true +} diff --git a/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 00000000..fd835f8f --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable -- replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "[OK] Git repository initialized" diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 00000000..5b3f4b1e --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/004-result-restart" +} diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 00000000..1bd047b4 --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,8 @@ +{ + "ai": "opencode", + "branch_numbering": "sequential", + "here": true, + "integration": "opencode", + "script": "sh", + "speckit_version": "0.9.3" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 00000000..52716465 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.9.3", + "integration_state_schema": 1, + "installed_integrations": [ + "opencode" + ], + "integration_settings": { + "opencode": { + "script": "sh", + "invoke_separator": "." + } + }, + "integration": "opencode", + "default_integration": "opencode" +} diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json new file mode 100644 index 00000000..3864c798 --- /dev/null +++ b/.specify/integrations/opencode.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "opencode", + "version": "0.9.3", + "installed_at": "2026-06-10T11:52:53.849031+00:00", + "files": { + ".opencode/commands/speckit.analyze.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda", + ".opencode/commands/speckit.checklist.md": "d258d63195c45d5c5900177194a1cecb865b7b81fc71cce096642c4b528bbf83", + ".opencode/commands/speckit.clarify.md": "0acc9e8fa225d1519a1729ce9ee2a26ac5a426f527fe48e14bdea14170fd78a9", + ".opencode/commands/speckit.constitution.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7", + ".opencode/commands/speckit.implement.md": "088b04952e9152ccc192bea842d92448b0aed26c748cb88d2dbd9dd96eb345e0", + ".opencode/commands/speckit.plan.md": "1fc3978d678cabcca33f02c346b333b12da175ca0270be58d4c1797834fcb0e0", + ".opencode/commands/speckit.specify.md": "9052f1f909d36119334182cfb35b4d3b9126d3313a4c20bc21b8c96862b8c909", + ".opencode/commands/speckit.tasks.md": "6998de540ae9564c729062ca0a3d493c3902e4647514e4d1011e6680c4c9f0c9", + ".opencode/commands/speckit.taskstoissues.md": "6fb1d3f1b6668282d41f45127faa427a93d8fdd88215d1c2c5c8ee3185037e7e" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 00000000..a0bb3b0e --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "speckit", + "version": "0.9.3", + "installed_at": "2026-06-10T11:52:53.872033+00:00", + "files": { + ".specify/scripts/bash/common.sh": "1b7f1e7d84fa255ab151410caf66055b5498270a7cebcc0921df130929429c08", + ".specify/scripts/bash/setup-plan.sh": "b23cca3d769a217ab812a6059adb549622471f6893af234cf98ca2019ac4e1a1", + ".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2", + ".specify/scripts/bash/check-prerequisites.sh": "3fed31d68ceb4a8ccd02f650581446f20acb28f0da9cac29f258657c91300833", + ".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/checklist-template.md": "312eee8291dfa984b21f95ddd0ca778e7a1f0b3a64bfc470d79762a3e3f5d7b8", + ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/plan-template.md": "3fdc12da2eb157def636948c157bfb638b265e70b2e3246a0e09c8d5db710e91" + } +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..27fa8e49 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,66 @@ + + +# Scribble Constitution + +## Core Principles + +### I. SOLID Architecture +Every module MUST adhere to SOLID principles: Single Responsibility (each class/component has one reason to change), Open/Closed (extend without modifying), Liskov Substitution (subtypes replace parents transparently), Interface Segregation (small focused interfaces), Dependency Inversion (depend on abstractions). Backend services MUST follow the existing `src/services/` pattern. Frontend components MUST follow the functional-component-with-hooks pattern. Rationale: SOLID keeps the codebase maintainable as features are layered incrementally. + +### II. Type Safety & Contract Discipline +All code MUST be strictly typed TypeScript. Avoid `any`; use `unknown` for truly dynamic values. Backend request/response payloads MUST be validated with Zod schemas. Frontend API calls MUST infer types from backend responses. Shared types MUST be kept in sync between frontend and backend. Rationale: TypeScript + Zod provides compile-time and runtime safety without a database schema layer. + +### III. HTTP Polling Protocol +All real-time sync MUST use HTTP polling (~2s interval for lobby and game state). No WebSockets, Server-Sent Events, or push protocols are permitted. Frontend polling MUST use `setInterval` with cleanup on unmount. Polling MUST NOT cause UI flicker or lost state. Rationale: HTTP polling is simpler to implement, debug, and review than push protocols, and aligns with the stateless backend model. + +### IV. In-Memory State Discipline +All game state MUST be stored in-memory only. No databases (SQL, NoSQL, SQLite) are permitted. Rooms MUST be explicitly cleaned up when empty or inactive. The backend MUST handle server restarts gracefully (state loss is acceptable; clients reconnect). Rationale: In-memory state keeps deployment minimal and avoids persistence complexity. + +### V. Determinism & Testability +Game logic MUST be deterministic: word selection, scoring, and round transitions MUST produce identical outcomes given identical inputs. No randomness in gameplay. Scoring MUST be pure functions. State transitions MUST be testable without a running server. Rationale: Deterministic logic makes the game predictable for players and straightforward to test. + +## Architecture Constraints + +The following are FORBIDDEN: +- WebSockets or any real-time push protocol +- Databases or persistent storage of any kind +- Authentication, accounts, sessions, or JWT +- Deployment, hosting, CI/CD, or Docker configuration +- New state-management or routing libraries beyond what the starter ships +- Multiple rounds, drawer rotation, timers, countdowns, speed bonuses, or drawer bonuses +- Custom or random word packs beyond the starter's seed list +- Spectator mode +- Moderation features (kick, mute, etc.) +- Room passwords or invite links +- Rewriting the starter from scratch +- Unjustified top-level dependencies +- Unrelated refactors + +Rationale: These boundaries keep the project focused on Spec Kit artifact discipline and incremental brownfield enhancement. + +## Development Workflow + +All implementation MUST follow this workflow: +1. **Discovery**: read starter files, document gaps and assumptions +2. **Specify**: write acceptance criteria in the spec +3. **Clarify**: resolve ambiguity before planning +4. **Plan**: document state model, data flow, file-level changes +5. **Tasks**: decompose plan into ordered, testable units +6. **Implement**: one meaningful slice at a time, commit after each +7. **Validate**: verify acceptance criteria with two browser tabs +8. Move forward only after the current scenario passes + +Commits MUST be granular and meaningful. Self-review is REQUIRED before every commit. + +## Governance + +This constitution supersedes all other development practices. Amendments require a documented rationale, maintainer approval, and a migration plan for affected artifacts. + +Versioning follows Semantic Versioning: +- **MAJOR**: backward-incompatible governance/principle removals or redefinitions +- **MINOR**: new principles or materially expanded guidance +- **PATCH**: clarifications, wording fixes, non-semantic refinements + +All PRs and reviews MUST verify constitution compliance. Complexity MUST be justified when it conflicts with a stated principle. + +**Version**: 1.0.0 | **Ratified**: 2026-06-11 | **Last Amended**: 2026-06-11 diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 00000000..b244ea7a --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# If paths-only mode, output paths and exit (no validation) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate branch name +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 00000000..0b51ca9c --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,721 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory +get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + return + fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current branch, with fallback for non-git repositories +get_current_branch() { + # First check if SPECIFY_FEATURE environment variable is set + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD + return + fi + + # For non-git repos, try to find the latest feature directory + local specs_dir="$repo_root/specs" + + if [[ -d "$specs_dir" ]]; then + local latest_feature="" + local highest=0 + local latest_timestamp="" + + for dir in "$specs_dir"/*; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi + fi + fi + fi + done + + if [[ -n "$latest_feature" ]]; then + echo "$latest_feature" + return + fi + fi + + echo "main" # Final fallback +} + +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) +has_git() { + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} + +# Safely read .specify/feature.json's "feature_directory" value. +# Prints the raw value (possibly relative) to stdout, or empty string if the file +# is missing, unparseable, or does not contain the key. Always returns 0 so callers +# under `set -e` cannot be aborted by parser failure. +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + elif command -v python3 >/dev/null 2>&1; then + # Use Python so pretty-printed/multi-line JSON still parses correctly. + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + else + # Last-resort single-line grep/sed fallback. The `|| true` guards against + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory +# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks). +# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. +feature_json_matches_feature_dir() { + local repo_root="$1" + local active_feature_dir="$2" + + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + + [[ -n "$_fd" ]] || return 1 + [[ "$_fd" != /* ]] && _fd="$repo_root/$_fd" + [[ -d "$_fd" ]] || return 1 + + local norm_json norm_active + norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1 + norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1 + + [[ "$norm_json" == "$norm_active" ]] +} + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) +find_feature_dir_by_prefix() { + local repo_root="$1" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") + local specs_dir="$repo_root/specs" + + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match + echo "$specs_dir/$branch_name" + return + fi + + # Search for directories in specs/ that start with this prefix + local matches=() + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"$prefix"-*; do + if [[ -d "$dir" ]]; then + matches+=("$(basename "$dir")") + 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" + elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! + echo "$specs_dir/${matches[0]}" + else + # 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 prefix." >&2 + return 1 + fi +} + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local has_git_repo="false" + + if has_git; then + has_git_repo="true" + fi + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) + local feature_dir + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + # Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on + # missing/unparseable/unset so we fall through to the branch-prefix lookup. + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +get_invoke_separator() { + local repo_root="${1:-$(get_repo_root)}" + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + return 0 + fi + + local integration_json="$repo_root/.specify/integration.json" + local separator="." + local parsed_with_jq=0 + + if [[ -f "$integration_json" ]]; then + if command -v jq >/dev/null 2>&1; then + local jq_separator + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then + parsed_with_jq=1 + case "$jq_separator" in + "."|"-") separator="$jq_separator" ;; + esac + fi + fi + + if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as fh: + state = json.load(fh) + key = state.get("default_integration") or state.get("integration") or "" + settings = state.get("integration_settings") + separator = "." + if isinstance(key, str) and isinstance(settings, dict): + entry = settings.get(key) + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: + separator = entry["invoke_separator"] + print(separator) +except Exception: + print(".") +PY +); then + case "$separator" in + "."|"-") ;; + *) separator="." ;; + esac + else + separator="." + fi + fi + fi + + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + printf '%s\n' "$separator" +} + +format_speckit_command() { + local command_name="$1" + local repo_root="${2:-$(get_repo_root)}" + local separator + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + else + separator=$(get_invoke_separator "$repo_root") + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + fi + + command_name="${command_name#/}" + command_name="${command_name#speckit.}" + command_name="${command_name#speckit-}" + command_name="${command_name//./$separator}" + + printf '/speckit%s%s\n' "$separator" "$command_name" +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# 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) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} + +# 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. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 00000000..c3537704 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + 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 " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root using common.sh functions which prioritize .specify over git +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(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 split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + 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}" +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 prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + 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}" + + >&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)" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + # If we're already on the branch, continue without another checkout. + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + # Otherwise switch to the existing branch instead of failing. + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + 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 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 00000000..945385c6 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi +else + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" + echo "HAS_GIT: $HAS_GIT" +fi + diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh new file mode 100755 index 00000000..3f6a40b1 --- /dev/null +++ b/.specify/scripts/bash/setup-tasks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate branch +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "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." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 00000000..806657da --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [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 checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + + + +## [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 + +- Check items off as completed: `[x]` +- 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 00000000..a4670ff4 --- /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 00000000..a9868e2b --- /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. See `.specify/templates/plan-template.md` for 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 00000000..ceb28776 --- /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 00000000..2aa8e749 --- /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 00000000..f69efeaf --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,77 @@ +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: + - "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 00000000..74535e1b --- /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-06-10T11:52:53.964655+00:00", + "updated_at": "2026-06-10T11:52:53.964662+00:00" + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 940bc464..235d659b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,9 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - Give concise, direct answers. - Do not output large blocks of code if a small change suffices. - When creating or editing files, ensure consistency with the existing directory structure detailed above. + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan +at `specs/004-result-restart/plan.md`. + diff --git a/backend/package-lock.json b/backend/package-lock.json index 38f3d3c8..9839cda9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1879,7 +1879,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2298,7 +2297,6 @@ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2379,7 +2377,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..5902f73f 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,26 @@ import { Router } from "express"; import { createRoomSchema, + drawStrokeSchema, + guessSchema, HttpError, joinRoomSchema, + requireParticipantIdQuerySchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + clearCanvas, + createRoom, + drawStroke, + getRoom, + joinRoom, + restartGame, + startGame, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -35,6 +49,10 @@ export function createRoomsRouter() { throw new HttpError(404, "Unable to join room"); } + if ("error" in result) { + throw new HttpError(400, result.error); + } + response.json({ participantId: result.participantId, room: toRoomSnapshot(result.room, result.participantId) @@ -44,6 +62,146 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + startGameSchema.parse(request.body); + const { participantId } = roomViewerQuerySchema.parse(request.query); + const result = startGame(code.toUpperCase(), participantId ?? ""); + + if (!result) { + throw new HttpError(404, "Room not found"); + } + + if ("error" in result) { + if (result.error === "Room not found") { + throw new HttpError(404, result.error); + } + if (result.error.startsWith("Only the host")) { + throw new HttpError(403, result.error); + } + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/draw", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const stroke = drawStrokeSchema.parse(request.body); + const { participantId } = requireParticipantIdQuerySchema.parse(request.query); + const result = drawStroke(code.toUpperCase(), participantId, stroke); + + if (!result) { + throw new HttpError(404, "Room not found"); + } + + if ("error" in result) { + if (result.error === "Room not found") { + throw new HttpError(404, result.error); + } + throw new HttpError(403, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = requireParticipantIdQuerySchema.parse(request.query); + const result = clearCanvas(code.toUpperCase(), participantId); + + if (!result) { + throw new HttpError(404, "Room not found"); + } + + if ("error" in result) { + if (result.error === "Room not found") { + throw new HttpError(404, result.error); + } + throw new HttpError(403, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { text } = guessSchema.parse(request.body); + const { participantId } = requireParticipantIdQuerySchema.parse(request.query); + const result = submitGuess(code.toUpperCase(), participantId, text); + + if (!result) { + throw new HttpError(404, "Room not found"); + } + + if ("error" in result) { + if (result.error === "Room not found") { + throw new HttpError(404, result.error); + } + if (result.error.startsWith("The drawer")) { + throw new HttpError(403, result.error); + } + if (result.error.startsWith("You have already")) { + throw new HttpError(403, result.error); + } + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + const result = restartGame(code.toUpperCase(), participantId ?? ""); + + if (!result) { + throw new HttpError(404, "Room not found"); + } + + if ("error" in result) { + if (result.error === "Room not found") { + throw new HttpError(404, result.error); + } + if (result.error.startsWith("Only the host")) { + throw new HttpError(403, result.error); + } + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..9c5a6703 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -12,10 +12,33 @@ export const roomCodeParamsSchema = z.object({ code: z.string() }); +export const startGameSchema = z.object({}); + export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const requireParticipantIdQuerySchema = z.object({ + participantId: z.string().min(1, "participantId query parameter is required") +}); + +export const pointSchema = z.object({ + x: z.number(), + y: z.number() +}); + +export const drawStrokeSchema = z.object({ + points: z.array(pointSchema).min(1, "Stroke must have at least one point"), + color: z.string().min(1), + width: z.number().positive() +}); + +export const clearCanvasSchema = z.object({}); + +export const guessSchema = z.object({ + text: z.string() +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..ad0c8e2d 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "finished"; export interface Participant { id: string; @@ -7,10 +7,34 @@ export interface Participant { joinedAt: string; } +export interface Point { + x: number; + y: number; +} + +export interface Stroke { + points: Point[]; + color: string; + width: number; +} + +export interface Guess { + participantId: string; + name: string; + text: string; + isCorrect: boolean; + timestamp: string; +} + export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; + currentDrawerId: string | null; + secretWord: string | null; + guessHistory: Guess[]; + canvasStrokes: Stroke[]; createdAt: string; updatedAt: string; } @@ -18,7 +42,13 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; + currentDrawerId: string | null; + secretWord: string | null; + guessHistory: Guess[]; + scores: Record; + canvasStrokes: Stroke[]; availableWords: string[]; roles: ParticipantRole[]; } diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..52125f6b 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot, Stroke } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -54,7 +54,12 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], + currentDrawerId: null, + secretWord: null, + guessHistory: [], + canvasStrokes: [], createdAt: now(), updatedAt: now() }; @@ -67,13 +72,24 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName?: string): { room: Room; participantId: string } | { error: string } | null { const room = rooms.get(code); if (!room) { return null; } + const resolvedName = displayName(playerName); + + if (room.participants.some((p) => p.name === resolvedName)) { + return { error: `Name '${resolvedName}' is already taken in this room` }; + } + + const MAX_PARTICIPANTS = 4; + if (room.participants.length >= MAX_PARTICIPANTS) { + return { error: "Room is full" }; + } + const participant = createParticipant(playerName); room.participants.push(participant); room.updatedAt = now(); @@ -96,13 +112,215 @@ export function saveRoom(room: Room) { return getRoom(room.code); } -export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; +export function startGame(code: string, requesterId: string): { room: Room } | { error: string } | null { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.hostId !== requesterId) { + return { error: "Only the host can start the game" }; + } + + if (room.participants.length < 2) { + return { error: "At least 2 players are required to start" }; + } + + const trimmedNames = room.participants.map((p) => ({ + id: p.id, + name: p.name.trim() + })); + + const emptyName = trimmedNames.find((p) => p.name.length === 0); + if (emptyName) { + return { error: `Name is empty after trimming` }; + } + + const seen = new Map(); + for (const p of trimmedNames) { + const existing = seen.get(p.name) ?? []; + existing.push(p.id); + seen.set(p.name, existing); + } + + const duplicates = [...seen.entries()].filter(([, ids]) => ids.length > 1); + if (duplicates.length > 0) { + const names = duplicates.map(([name]) => name).join(", "); + return { error: `Duplicate names after trimming: ${names}` }; + } + + for (const p of room.participants) { + p.name = p.name.trim(); + } + + const wordIndex = (room.participants.length - 2) % STARTER_WORDS.length; + + room.currentDrawerId = room.hostId; + room.secretWord = STARTER_WORDS[wordIndex]; + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +function computeScores(guessHistory: Guess[]): Record { + const scores: Record = {}; + + for (const guess of guessHistory) { + if (guess.isCorrect) { + scores[guess.participantId] = (scores[guess.participantId] ?? 0) + 100; + } + } + + return scores; +} + +function allGuessersCorrect(room: Room): boolean { + const guesserIds = room.participants + .filter((p) => p.id !== room.currentDrawerId) + .map((p) => p.id); + + if (guesserIds.length === 0) { + return false; + } + + const correctGuesserIds = new Set( + room.guessHistory + .filter((g) => g.isCorrect) + .map((g) => g.participantId) + ); + + return guesserIds.every((id) => correctGuesserIds.has(id)); +} + +export function drawStroke(code: string, requesterId: string, stroke: Stroke): { room: Room } | { error: string } | null { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.currentDrawerId !== requesterId) { + return { error: "Only the drawer can draw" }; + } + + room.canvasStrokes.push(stroke); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function clearCanvas(code: string, requesterId: string): { room: Room } | { error: string } | null { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.currentDrawerId !== requesterId) { + return { error: "Only the drawer can clear the canvas" }; + } + + room.canvasStrokes = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function submitGuess(code: string, requesterId: string, text: string): { room: Room } | { error: string } | null { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + const trimmed = text.trim(); + + if (trimmed.length === 0) { + return { error: "Guess cannot be empty" }; + } + + if (room.currentDrawerId === requesterId) { + return { error: "The drawer cannot submit guesses" }; + } + + const alreadyCorrect = room.guessHistory.some( + (g) => g.participantId === requesterId && g.isCorrect + ); + + if (alreadyCorrect) { + return { error: "You have already guessed correctly" }; + } + + const participant = room.participants.find((p) => p.id === requesterId); + + if (!participant) { + return { error: "Participant not found in room" }; + } + const isCorrect = trimmed.toLowerCase() === (room.secretWord ?? "").toLowerCase(); + + const guess: Guess = { + participantId: requesterId, + name: participant.name, + text: trimmed, + isCorrect, + timestamp: now() + }; + + room.guessHistory.push(guess); + + if (allGuessersCorrect(room)) { + room.status = "finished"; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function restartGame(code: string, requesterId: string): { room: Room } | { error: string } | null { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.hostId !== requesterId) { + return { error: "Only the host can restart the game" }; + } + + if (room.status !== "finished") { + return { error: "Game is not finished" }; + } + + room.currentDrawerId = null; + room.secretWord = null; + room.guessHistory = []; + room.canvasStrokes = []; + room.status = "lobby"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), + currentDrawerId: room.currentDrawerId, + secretWord: room.status === "finished" || viewerParticipantId === room.currentDrawerId ? room.secretWord : null, + guessHistory: room.guessHistory.map((g) => ({ ...g })), + scores: computeScores(room.guessHistory), + canvasStrokes: room.canvasStrokes.map((s) => structuredClone(s)), availableWords: listWords(), roles: [...STARTER_ROLES] }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d054..c7ac2635 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -414,7 +413,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -438,7 +436,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1333,7 +1330,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1538,7 +1534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1900,7 +1895,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2083,7 +2077,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2135,7 +2128,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2148,7 +2140,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2506,7 +2497,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/Canvas.tsx b/frontend/src/components/Canvas.tsx new file mode 100644 index 00000000..c3a8df19 --- /dev/null +++ b/frontend/src/components/Canvas.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import type { Stroke } from "../services/api"; + +interface CanvasProps { + strokes: Stroke[]; + isDrawer: boolean; + onStrokeComplete?: (stroke: Stroke) => void; + onClear?: () => void; +} + +const COLORS = ["#000000", "#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#8b5cf6"]; +const BRUSH_SIZES = [2, 4, 6, 8]; + +export function Canvas({ strokes, isDrawer, onStrokeComplete, onClear }: CanvasProps) { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [currentColor, setCurrentColor] = useState(COLORS[0]); + const [currentWidth, setCurrentWidth] = useState(BRUSH_SIZES[1]); + const currentStrokeRef = useRef<{ x: number; y: number }[]>([]); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const stroke of strokes) { + if (stroke.points.length < 2) continue; + + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(stroke.points[0].x, stroke.points[0].y); + + for (let index = 1; index < stroke.points.length; index++) { + ctx.lineTo(stroke.points[index].x, stroke.points[index].y); + } + + ctx.stroke(); + } + }, [strokes]); + + useEffect(() => { + redraw(); + }, [redraw]); + + function getCanvasPoint(event: React.MouseEvent | React.TouchEvent) { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + + if ("touches" in event) { + const touch = event.touches[0] ?? event.changedTouches[0]; + return { + x: touch.clientX - rect.left, + y: touch.clientY - rect.top + }; + } + + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + } + + function handlePointerDown(event: React.MouseEvent | React.TouchEvent) { + if (!isDrawer) return; + + event.preventDefault(); + setIsDrawing(true); + const point = getCanvasPoint(event); + currentStrokeRef.current = [point]; + } + + function handlePointerMove(event: React.MouseEvent | React.TouchEvent) { + if (!isDrawing || !isDrawer) return; + + event.preventDefault(); + const point = getCanvasPoint(event); + currentStrokeRef.current.push(point); + + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + + const points = currentStrokeRef.current; + if (points.length < 2) return; + + const prev = points[points.length - 2]; + ctx.strokeStyle = currentColor; + ctx.lineWidth = currentWidth; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(prev.x, prev.y); + ctx.lineTo(point.x, point.y); + ctx.stroke(); + } + + function handlePointerUp(event: React.MouseEvent | React.TouchEvent) { + if (!isDrawing || !isDrawer) return; + + event.preventDefault(); + setIsDrawing(false); + + const points = currentStrokeRef.current; + if (points.length > 1 && onStrokeComplete) { + onStrokeComplete({ points, color: currentColor, width: currentWidth }); + } + + currentStrokeRef.current = []; + } + + return ( +
+ {isDrawer && ( +
+
+ {COLORS.map((color) => ( +
+
+ {BRUSH_SIZES.map((size) => ( + + ))} +
+ {onClear && ( + + )} +
+ )} + + {!isDrawer && strokes.length === 0 && ( +
+ Waiting for the drawer to start drawing... +
+ )} +
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..0aac0269 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,43 @@ import { useState } from "react"; interface GuessFormProps { + onGuess: (text: string) => Promise; disabled?: boolean; + alreadyCorrect?: boolean; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ onGuess, disabled = false, alreadyCorrect = false }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + setError(null); + + if (guessText.trim().length === 0) { + setError("Guess cannot be empty"); + return; + } + + setSubmitting(true); + + try { + await onGuess(guessText); + setGuessText(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit guess"); + } finally { + setSubmitting(false); + } + } + + if (alreadyCorrect) { + return ( +
+ You guessed correctly! +
+ ); } return ( @@ -19,12 +48,13 @@ export function GuessForm({ disabled = false }: GuessFormProps) { value={guessText} onChange={(event) => setGuessText(event.target.value)} placeholder="Type your guess here..." - disabled={disabled} + disabled={disabled || submitting} /> + {error &&

{error}

}
-
diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..33ce21c7 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,30 @@ +import type { Guess } from "../services/api"; import { Card } from "./Card"; -export function ResultPanel() { +interface ResultPanelProps { + guessHistory: Guess[]; +} + +export function ResultPanel({ guessHistory }: ResultPanelProps) { return ( -
-

Game activity and guesses will appear here.

-
+ {guessHistory.length === 0 ? ( +
+

No guesses yet.

+
+ ) : ( +
    + {guessHistory.map((guess, index) => ( +
  • + {guess.name} + "{guess.text}" + + {guess.isCorrect ? "✓ Correct" : "✗ Wrong"} + +
  • + ))} +
+ )}
); } diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..121ae301 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,13 +1,25 @@ +import type { Participant } from "../services/api"; import { Card } from "./Card"; -export function Scoreboard() { +interface ScoreboardProps { + participants: Participant[]; + scores: Record; + currentDrawerId: string | null; +} + +export function Scoreboard({ participants, scores, currentDrawerId }: ScoreboardProps) { return ( -
-
- Waiting for players... - 0 -
+
+ {participants.map((p) => ( +
+ + {p.name} + {p.id === currentDrawerId ? (drawer) : null} + + {scores[p.id] ?? 0} +
+ ))}
); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..63345ffc 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,17 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { Canvas } from "../components/Canvas"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); - const { room, participantId } = useRoomState(); + const { room, participantId, isLoading } = useRoomState(); + const store = useRoomStore(); useEffect(() => { if (!room) { @@ -22,6 +24,100 @@ export function GamePage() { } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = participantId === room.currentDrawerId; + const drawer = room.participants.find((p) => p.id === room.currentDrawerId) ?? null; + const isFinished = room.status === "finished"; + + const isHost = participantId === room.hostId; + const alreadyCorrect = !isDrawer && room.guessHistory.some( + (g) => g.participantId === participantId && g.isCorrect + ); + + async function handleGuess(text: string) { + await store.submitGuess(text); + } + + async function handleRestart() { + await store.restartGame(); + navigate("/lobby"); + } + + if (isFinished) { + return ( +
+
+
+ Round 1 +

Game Over!

+
+ +
+ +
+ + +
+ +
+ {room.secretWord ?? "No word assigned"} +
+
+ + + +
+ + +
+ +
+ {isHost && ( + + )} + +
+
+ ); + } return (
@@ -35,15 +131,44 @@ export function GamePage() {
- -
- Waiting for drawer... -
+ {isDrawer && ( + +
+ {room.secretWord ?? "No word assigned"} +
+
+ )} + + { + await store.drawStroke(stroke); + }} + onClear={async () => { + await store.clearCanvas(); + }} + />
@@ -54,6 +179,10 @@ export function GamePage() {
Name
{viewer?.name ?? "Unknown player"}
+
+
Role
+
{isDrawer ? "Drawer" : "Guesser"}
+
Status
Playing
@@ -61,9 +190,11 @@ export function GamePage() { - - - + {!isDrawer && ( + + + + )}
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..ee08254d 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,15 +1,18 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { useRoomState, useRoomStore } from "../state/roomStore"; +const POLL_INTERVAL = 2000; + export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); + const [actionError, setActionError] = useState(null); + const pollingRef = useRef | null>(null); useEffect(() => { if (!room) { @@ -17,15 +20,59 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (!room) { + return; + } + + async function poll() { + try { + await roomStore.fetchRoom(); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + } + } + + pollingRef.current = setInterval(poll, POLL_INTERVAL); + + return () => { + if (pollingRef.current !== null) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }; + }, [room, roomStore]); + + useEffect(() => { + if (room && room.status === "playing") { + navigate("/game"); + } + }, [room, navigate]); + async function handleRefresh() { try { - setRefreshError(null); + setActionError(null); await roomStore.fetchRoom(); } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); } } + const handleStartGame = useCallback(async () => { + try { + setActionError(null); + const updatedRoom = await roomStore.startGame(); + if (updatedRoom.status === "playing") { + navigate("/game"); + } + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + }, [roomStore, navigate]); + + const snapshot = roomStore.getSnapshot(); + const isHost = room ? room.hostId === snapshot.participantId : false; + if (!room) { return null; } @@ -59,9 +106,12 @@ export function LobbyPage() {

- {isLoading ? "Refreshing players..." : "Ready to play"} + {isLoading ? "Processing..." : "Ready to play"} +

+

+ {actionError && {actionError}} + {!actionError && (error ?? "Waiting for the host to start the game.")}

-

{error ?? refreshError ?? "Waiting for the host to start the game."}

@@ -69,9 +119,11 @@ export function LobbyPage() { - + {isHost && ( + + )} ); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..37b268a8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; +export type RoomStatus = "lobby" | "playing" | "finished"; export interface Participant { id: string; @@ -6,10 +7,35 @@ export interface Participant { joinedAt: string; } +export interface Point { + x: number; + y: number; +} + +export interface Stroke { + points: Point[]; + color: string; + width: number; +} + +export interface Guess { + participantId: string; + name: string; + text: string; + isCorrect: boolean; + timestamp: string; +} + export interface RoomSnapshot { code: string; - status: "lobby"; + status: RoomStatus; + hostId: string; participants: Participant[]; + currentDrawerId: string | null; + secretWord: string | null; + guessHistory: Guess[]; + scores: Record; + canvasStrokes: Stroke[]; availableWords: string[]; roles: ParticipantRole[]; } @@ -19,7 +45,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -57,5 +83,37 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId?: string) { + const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start${query}`, { + method: "POST", + body: "{}" + }); + }, + drawStroke(code: string, participantId: string, stroke: { points: Point[]; color: string; width: number }) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/draw?participantId=${encodeURIComponent(participantId)}`, { + method: "POST", + body: JSON.stringify(stroke) + }); + }, + clearCanvas(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/clear?participantId=${encodeURIComponent(participantId)}`, { + method: "POST", + body: "{}" + }); + }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess?participantId=${encodeURIComponent(participantId)}`, { + method: "POST", + body: JSON.stringify({ text }) + }); + }, + restartGame(code: string, participantId?: string) { + const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart${query}`, { + method: "POST", + body: "{}" + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..a4258197 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,56 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame() { + if (!this.state.room) { + throw new Error("No active room"); + } + + const response = await this.withLoading(() => api.startGame(this.state.room!.code, this.state.participantId ?? undefined)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async drawStroke(stroke: { points: { x: number; y: number }[]; color: string; width: number }) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room"); + } + + const response = await this.withLoading(() => api.drawStroke(this.state.room!.code, this.state.participantId!, stroke)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async clearCanvas() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room"); + } + + const response = await this.withLoading(() => api.clearCanvas(this.state.room!.code, this.state.participantId!)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room"); + } + + const response = await this.withLoading(() => api.submitGuess(this.state.room!.code, this.state.participantId!, text)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async restartGame() { + if (!this.state.room) { + throw new Error("No active room"); + } + + const response = await this.withLoading(() => api.restartGame(this.state.room!.code, this.state.participantId ?? undefined)); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6dd..74e6dc31 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -543,6 +543,147 @@ input { font-weight: 500; } +/* --- CANVAS --- */ +.canvas-container { + position: relative; +} + +.canvas-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + flex-wrap: wrap; +} + +.canvas-toolbar__group { + display: flex; + align-items: center; + gap: 4px; +} + +.canvas-toolbar__color { + width: 24px; + height: 24px; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; + padding: 0; + transition: border-color 0.15s; +} + +.canvas-toolbar__color--active { + border-color: #6366f1; +} + +.canvas-toolbar__size { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + padding: 0; + background: none; + transition: border-color 0.15s; +} + +.canvas-toolbar__size--active { + border-color: #6366f1; +} + +.button--small { + padding: 4px 12px; + font-size: 0.8125rem; +} + +/* --- SCOREBOARD --- */ +.scoreboard { + display: grid; + gap: 8px; +} + +.scoreboard__row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--line); +} + +.scoreboard__row:last-child { + border-bottom: 0; +} + +.scoreboard__name { + font-weight: 500; + color: var(--ink); +} + +.scoreboard__role { + font-size: 0.75rem; + color: var(--ink-soft); + font-weight: 400; +} + +.scoreboard__score { + font-size: 1.125rem; + color: var(--primary); +} + +/* --- GUESS HISTORY --- */ +.guess-history { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 6px; +} + +.guess-history__item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px; + border-radius: 6px; + font-size: 0.875rem; +} + +.guess-history__item--correct { + background-color: #f0fdf4; +} + +.guess-history__item--incorrect { + background-color: #fef2f2; +} + +.guess-history__name { + font-weight: 600; + color: var(--ink); +} + +.guess-history__text { + color: var(--ink-soft); + font-style: italic; +} + +.guess-history__result { + margin-left: auto; + font-weight: 600; + font-size: 0.8125rem; +} + +.guess-history__result--correct { + color: #16a34a; +} + +.guess-history__result--incorrect { + color: #dc2626; +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; diff --git a/reflection.md b/reflection.md new file mode 100644 index 00000000..f342c886 --- /dev/null +++ b/reflection.md @@ -0,0 +1,11 @@ +# Reflection Report + +## What the starter app already had +The starter provided a monorepo skeleton with an Express backend (TypeScript, Zod, in-memory state) and a React frontend (Vite, TypeScript, React Router v6). It had room creation/joining, a basic polling mechanism, and a canvas component. Game logic (word assignment, guessing, scoring, rounds, drawer rotation) was absent. + +## What we added +- **Room setup & lobby**: Unique room codes, duplicate name detection, max 4 participants, host-only start +- **Game start & drawer flow**: Secret word assignment to drawer, role-based UI (drawer sees word + canvas, guessers see guess form) +- **Gameplay interaction**: Canvas drawing/clearing (drawer only), case-insensitive guess submission, correct-guess detection, automatic game finish when all guessers succeed +- **Result & restart**: Secret word revealed to all on finish, final scores and guess history displayed, host-only "Play Again" resets round state and returns everyone to lobby +- **Spec Kit artifacts**: Full SDD cycle for each slice — constitution, spec, plan, tasks, research, data model, API contracts, quickstart, and checklists — all committed and traceable diff --git a/specs/001-room-setup-lobby/checklists/requirements.md b/specs/001-room-setup-lobby/checklists/requirements.md new file mode 100644 index 00000000..14333095 --- /dev/null +++ b/specs/001-room-setup-lobby/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Room Setup & Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-11 +**Feature**: [specs/001-room-setup-lobby/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All checklist items passed validation on first iteration. +- No [NEEDS CLARIFICATION] markers present — spec is ready for `/speckit.plan`. diff --git a/specs/001-room-setup-lobby/contracts/api.md b/specs/001-room-setup-lobby/contracts/api.md new file mode 100644 index 00000000..f53af9aa --- /dev/null +++ b/specs/001-room-setup-lobby/contracts/api.md @@ -0,0 +1,140 @@ +# API Contracts: Room Setup & Lobby + +Base URL: `http://localhost:3001` + +All responses return JSON. Errors return `{ "error": "" }` with appropriate HTTP status codes. + +--- + +## POST /rooms + +Create a new room. The creator is automatically designated as host. + +**Request Body**: +```json +{ + "playerName": "Alice" +} +``` + +**Success Response** (201): +```json +{ + "participantId": "uuid-string", + "room": { + "code": "XK4M", + "status": "lobby", + "hostId": "uuid-string", + "participants": [ + { + "id": "uuid-string", + "name": "Alice", + "joinedAt": "2026-06-11T12:00:00.000Z" + } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Empty/whitespace-only name | `{ "error": "Player name is required" }` | + +--- + +## POST /rooms/:code/join + +Join an existing room by code. + +**Request Body**: +```json +{ + "playerName": "Bob" +} +``` + +**Success Response** (200): +```json +{ + "participantId": "uuid-string", + "room": { + "code": "XK4M", + "status": "lobby", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Empty/whitespace-only name | `{ "error": "Player name is required" }` | +| 400 | Empty room code | `{ "error": "Room code is required" }` | +| 400 | Display name already taken | `{ "error": "Name 'Bob' is already taken in this room" }` | +| 404 | Invalid/non-existent room code | `{ "error": "Room not found" }` | +| 403 | Room is full (max 4 participants) | `{ "error": "Room is full" }` | + +**Note**: Room codes are trimmed of leading/trailing whitespace before lookup. + +--- + +## GET /rooms/:code + +Fetch the current room snapshot (polling endpoint). + +**Query Parameters**: `?participantId=` (optional) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "lobby", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 404 | Room not found (expired/never existed) | `{ "error": "Room not found" }` | + +--- + +## POST /rooms/:code/start + +Start the game. Only the host can start, and at least 2 players must be present. + +**Request Body**: (empty) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 403 | Non-host attempts to start | `{ "error": "Only the host can start the game" }` | +| 400 | Fewer than 2 players | `{ "error": "At least 2 players are required to start" }` | +| 404 | Room not found | `{ "error": "Room not found" }` | diff --git a/specs/001-room-setup-lobby/data-model.md b/specs/001-room-setup-lobby/data-model.md new file mode 100644 index 00000000..9465d6f9 --- /dev/null +++ b/specs/001-room-setup-lobby/data-model.md @@ -0,0 +1,57 @@ +# Data Model: Room Setup & Lobby + +## Entities + +### Room + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Unique 4-char uppercase alphanumeric room identifier (no vowels, no 0/1) | +| `status` | `RoomStatus` | Current room status: `"lobby"` | +| `hostId` | `string` | Participant ID of the room creator (host) | +| `participants` | `Participant[]` | List of participants in the room | +| `createdAt` | `string` | ISO 8601 timestamp of room creation | +| `updatedAt` | `string` | ISO 8601 timestamp of last update | + +**Validation Rules**: +- Room codes are 4 characters, uppercase, excluding vowels and digits 0/1 +- Host must be an existing participant in the room +- Status transitions: `"lobby" → "playing"` (when host starts game) +- Max 4 participants per room + +### Participant + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | UUID v4 unique participant identifier | +| `name` | `string` | Display name (non-empty, trimmed, unique within room) | +| `joinedAt` | `string` | ISO 8601 timestamp of join | + +**Validation Rules**: +- Name must be non-empty after trimming whitespace +- Name must be unique among current participants in the same room +- Max 4 participants per room + +### RoomStatus + +| Value | Description | +|-------|-------------| +| `"lobby"` | Room created, waiting for players; host can start game | + +*Note: `"playing"` and `"finished"` statuses will be added in subsequent slices.* + +## State Transitions + +``` +[Room Created] ──→ lobby + │ + │ (host starts game, ≥2 players) + ↓ + playing ←── (added in Slice 2) +``` + +## Relationships + +- **Room** 1───* **Participant**: A room contains 0 or more participants +- **Room.hostId** references **Participant.id**: The host is one of the participants +- Rooms are fully isolated — no cross-room relationships diff --git a/specs/001-room-setup-lobby/plan.md b/specs/001-room-setup-lobby/plan.md new file mode 100644 index 00000000..05b6a615 --- /dev/null +++ b/specs/001-room-setup-lobby/plan.md @@ -0,0 +1,103 @@ +# Implementation Plan: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-11 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-room-setup-lobby/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Enable players to create and join drawing-game rooms with unique codes, see room participants update automatically via ~2s polling, and allow the host to start the game once at least 2 players are present. The room creator is automatically host; display names must be unique within a room; invalid/empty codes and names are rejected with clear errors. + +## Technical Context + +**Language/Version**: TypeScript 5.x (backend Node.js 18+, frontend browser ESM via Vite) + +**Primary Dependencies**: Express 4 (backend), React 18 + React Router 6 (frontend), Zod (backend validation), Vitest (testing both) + +**Storage**: In-memory (no database). Room state stored in a `Map` on the backend; lost on server restart. + +**Testing**: Vitest (backend unit + integration, frontend unit). Run `cd backend && npm test` and `cd frontend && npm test`. + +**Target Platform**: Modern web browsers (Chrome, Firefox, Safari, Edge) + Node.js server + +**Project Type**: Web application (separate backend + frontend) + +**Performance Goals**: API responses under 200ms; lobby polling at ~2s interval; participant list visible within one poll cycle after join. + +**Constraints**: No WebSockets (HTTP polling only); no databases (in-memory only); no authentication; max 4 participants per room; room codes are 4-char uppercase alphanumeric. + +**Scale/Scope**: Local multiplayer (2-4 players per room), single server instance, no horizontal scaling. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Assessment | +|-----------|------------| +| I. SOLID Architecture | ✅ Services follow existing `src/services/` pattern; components follow hooks pattern | +| II. Type Safety & Contract Discipline | ✅ Zod schemas for request validation; strict TypeScript throughout | +| III. HTTP Polling Protocol | ✅ Auto-refresh uses setInterval polling at ~2s; no WebSockets | +| IV. In-Memory State Discipline | ✅ All room state in-memory Map; no database; rooms cleaned when empty | +| V. Determinism & Testability | ✅ Room creation/joining is deterministic; testable without running server | + +**Gate result**: PASS — no violations. All constraints align with constitution principles. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-room-setup-lobby/ +├── 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) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/ +│ ├── models/ +│ │ └── game.ts # Room, Participant types (extend with hostId, status) +│ ├── services/ +│ │ └── roomStore.ts # Extend: unique name check, host tracking, start game +│ └── api/ +│ ├── rooms.ts # Add POST /rooms/:code/start endpoint +│ └── schemas.ts # Add startGameSchema, update join schemas + +frontend/ +├── src/ +│ ├── pages/ +│ │ └── LobbyPage.tsx # Add auto-polling, host-only start button, error display +│ ├── components/ +│ │ └── GuessForm.tsx # No changes for this slice +│ ├── state/ +│ │ └── roomStore.ts # Add startGame action, auto-poll timer +│ └── services/ +│ └── api.ts # Add startGame API call, fix /bug fallback URL +``` + +**Structure Decision**: Web application with separate `backend/` and `frontend/` directories. Backend follows Express route → service → model layering. Frontend follows page → component → store → API layering. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +No violations. Complexity tracking is not required. + +## Phase 0: Research + +All technical context items are derivable from the existing codebase. No NEEDS CLARIFICATION markers remain. Research artifacts consolidated in [research.md](./research.md). + +## Phase 1: Design + +Data model, API contracts, and validation guide generated in: +- [data-model.md](./data-model.md) — entities, fields, relationships, state transitions +- [contracts/](./contracts/) — REST API endpoint contracts +- [quickstart.md](./quickstart.md) — end-to-end validation scenarios diff --git a/specs/001-room-setup-lobby/quickstart.md b/specs/001-room-setup-lobby/quickstart.md new file mode 100644 index 00000000..3111a09b --- /dev/null +++ b/specs/001-room-setup-lobby/quickstart.md @@ -0,0 +1,82 @@ +# Quickstart: Room Setup & Lobby + +End-to-end validation scenarios for the Room Setup & Lobby feature. + +## Prerequisites + +- Backend running on `http://localhost:3001` (`cd backend && npm run dev`) +- Frontend running on `http://localhost:5173` (`cd frontend && npm run dev`) +- Two browser tabs open to `http://localhost:5173` + +## Validation Scenarios + +### Scenario 1: Create a Room + +1. Open the app in Tab A +2. Click **Create Room** +3. Enter `Alice` as the display name +4. Click **Create** +5. **Expected**: You land on the lobby page showing: + - A 4-character room code badge + - `Alice` in the participant list + - A **Start Game** button visible + +### Scenario 2: Join a Room + +1. In Tab B, click **Join Room** +2. Enter `Bob` as the display name +3. Enter the room code from Tab A +4. Click **Join** +5. **Expected**: Tab B lands on the lobby showing: + - The same room code + - `Alice` and `Bob` in the participant list + - **No** Start Game button (Bob is not host) + +### Scenario 3: Auto-Refresh (Polling) + +1. After Scenario 2, look at Tab A (Alice's lobby) +2. Wait ~2 seconds +3. **Expected**: Tab A's participant list automatically shows both `Alice` and `Bob` without clicking a refresh button + +### Scenario 4: Host Starts Game + +1. Ensure Tab A (Alice, host) shows 2+ participants +2. Click **Start Game** +3. **Expected**: Both tabs transition to the game screen + +### Scenario 5: Start Game Denied (Fewer Than 2 Players) + +1. Create a new room with only 1 player +2. Click **Start Game** +3. **Expected**: An error message appears: at least 2 players required +4. The game does not start + +### Scenario 6: Invalid Inputs + +| Action | Input | Expected Error | +|--------|-------|----------------| +| Create with empty name | (empty) | "Player name is required" | +| Create with whitespace name | `" "` | "Player name is required" | +| Join with invalid code | `"ZZZZ"` | "Room not found" | +| Join with empty code | (empty) | "Room code is required" | +| Join with taken name | `"Alice"` into Alice's room | "Name is already taken" | + +### Scenario 7: Room Isolation + +1. Create Room A in Tab A, Room B in Tab B (different browsers/sessions) +2. Verify Room A's participant list only shows Room A's players +3. Verify Room B's participant list only shows Room B's players + +## Verification Matrix + +| # | Test | Status | +|---|------|--------| +| 1 | Create room with valid name | ✅ / ❌ | +| 2 | Create room with empty name (rejected) | ✅ / ❌ | +| 3 | Join room with valid code | ✅ / ❌ | +| 4 | Join room with invalid code (rejected) | ✅ / ❌ | +| 5 | Join with taken display name (rejected) | ✅ / ❌ | +| 6 | Lobby auto-refresh (~2s) | ✅ / ❌ | +| 7 | Host-only start game | ✅ / ❌ | +| 8 | Start denied with <2 players | ✅ / ❌ | +| 9 | Room isolation (separate participants) | ✅ / ❌ | diff --git a/specs/001-room-setup-lobby/research.md b/specs/001-room-setup-lobby/research.md new file mode 100644 index 00000000..ec3d628d --- /dev/null +++ b/specs/001-room-setup-lobby/research.md @@ -0,0 +1,48 @@ +# Research: Room Setup & Lobby + +## Technical Decisions + +### Language & Runtime +- **Decision**: TypeScript 5.x on Node.js 18+ (backend) and browser ESM via Vite (frontend) +- **Rationale**: Existing project foundation; both environments already use TypeScript + ES Modules +- **Alternatives considered**: None — constrained by starter project + +### Backend Framework +- **Decision**: Express 4 with Zod validation +- **Rationale**: Existing starter uses Express; Zod provides runtime type safety for request payloads +- **Alternatives considered**: None — constrained by starter project + +### Frontend Framework +- **Decision**: React 18 with functional components and hooks +- **Rationale**: Existing starter uses React 18; functional components are the established pattern +- **Alternatives considered**: None — constrained by starter project + +### State Management +- **Decision**: Custom store via `useSyncExternalStore` (existing pattern in `roomStore.ts`) +- **Rationale**: No additional state libraries required per project constraints +- **Alternatives considered**: Zustand, Context API — out of scope per constitution + +### Real-time Sync +- **Decision**: HTTP polling at ~2s interval +- **Rationale**: Constitution mandates no WebSockets; polling is the simplest HTTP-based approach +- **Alternatives considered**: WebSockets, SSE — explicitly forbidden + +### Data Storage +- **Decision**: In-memory `Map` on backend +- **Rationale**: Constitution mandates no databases; existing pattern in `roomStore.ts` +- **Alternatives considered**: SQLite, PostgreSQL — explicitly forbidden + +### Testing +- **Decision**: Vitest (both backend and frontend) +- **Rationale**: Existing starter uses Vitest with `vitest.config.ts` in both directories +- **Alternatives considered**: Jest — not in existing project + +## Host Tracking Implementation +- **Decision**: Add `hostId` field to Room model; set on creation; checked on start-game +- **Rationale**: Simplest approach; no role object needed; host is just the creator's participant ID +- **Alternatives considered**: Separate host entity, role array — over-engineered for a single host + +## Display Name Uniqueness +- **Decision**: Check name against existing participants at join time; reject duplicates +- **Rationale**: Clarified during clarification phase; prevents scoreboard ambiguity +- **Alternatives considered**: Allow duplicates — rejected to avoid UX confusion diff --git a/specs/001-room-setup-lobby/spec.md b/specs/001-room-setup-lobby/spec.md new file mode 100644 index 00000000..8572f91c --- /dev/null +++ b/specs/001-room-setup-lobby/spec.md @@ -0,0 +1,143 @@ +# Feature Specification: Room Setup & Lobby + +**Feature Branch**: `001-room-setup-lobby` + +**Created**: 2026-06-11 + +**Status**: Draft + +**Input**: User description: "Slice 1 — Room Setup & Lobby. Given a player wants to host or join a drawing game, When they create or join a room via a unique code, Then the creator is automatically the host; invalid/empty codes are rejected with clear feedback; rooms are fully isolated; the lobby refreshes via polling (~2s); and only the host can start the game once at least 2 players are present." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Host Creates a Room (Priority: P1) + +A player opens the game, enters their display name, and creates a new room. They receive a unique room code and are automatically designated as the host. + +**Why this priority**: Room creation is the entry point for the entire game flow. Without it, no other scenario is possible. + +**Independent Test**: A single player can create a room, see the room code, confirm they are the host, and land on the lobby screen. + +**Acceptance Scenarios**: + +1. **Given** the game has not started, **When** a player creates a room with a valid non-empty display name, **Then** the system returns a unique 4-character room code, the player is marked as the host, and they are redirected to the lobby. + +2. **Given** a player attempts to create a room, **When** the display name is empty or contains only whitespace, **Then** the system rejects the request with a clear error message and does not create a room. + +3. **Given** a room has been created, **When** another player attempts to create a room, **Then** they receive a different unique room code, ensuring room isolation. + +--- + +### User Story 2 - Player Joins a Room (Priority: P1) + +A player opens the game, enters their display name and a room code, and joins an existing room as a participant. + +**Why this priority**: Joining rooms is required for multiplayer gameplay. Both create and join are equally fundamental. + +**Independent Test**: A player can join an existing room by entering a valid room code and see themselves appear in the lobby participant list. + +**Acceptance Scenarios**: + +1. **Given** a room exists, **When** a player enters the correct room code and a valid display name, **Then** they are added to the room's participant list and redirected to the lobby. + +2. **Given** a player attempts to join a room, **When** the room code is invalid or does not exist, **Then** the system returns a clear error message and does not add them to any room. + +3. **Given** a player attempts to join a room, **When** the room code is empty, **Then** the system rejects with a clear error message. + +4. **Given** a player attempts to join a room, **When** their display name is empty or whitespace-only, **Then** the system rejects with a clear error message. + +--- + +### User Story 3 - Lobby Auto-Refresh (Priority: P2) + +All players in the lobby see an up-to-date participant list that refreshes automatically. + +**Why this priority**: Without automatic refresh, players would need to manually reload to see new participants. This is essential for a smooth multiplayer experience. + +**Independent Test**: Two browser tabs show the same room; when a new player joins, the existing player's lobby updates automatically within ~2 seconds. + +**Acceptance Scenarios**: + +1. **Given** a player is in the lobby, **When** another player joins the room, **Then** the first player's participant list updates within approximately 2 seconds without manual action. + +2. **Given** a player is in the lobby, **When** they navigate away and return, **Then** the current participant list is displayed. + +--- + +### User Story 4 - Host Starts the Game (Priority: P2) + +The host can start the game from the lobby once a minimum of 2 players are present. + +**Why this priority**: Starting the game is a precondition for all gameplay scenarios. The 2-player minimum ensures the game is playable. + +**Independent Test**: A host with at least 2 players in the lobby can start the game; a host with only 1 player cannot. + +**Acceptance Scenarios**: + +1. **Given** a room has at least 2 players in the lobby, **When** the host clicks the start button, **Then** the game round begins and all players transition to the game screen. + +2. **Given** a room has fewer than 2 players, **When** the host clicks the start button, **Then** the system shows a message that at least 2 players are required and does not start the game. + +3. **Given** a room has at least 2 players, **When** a non-host player attempts to start the game, **Then** the system ignores the request or shows a permission error. + +--- + +### Edge Cases + +- What happens when a player enters a room code with leading/trailing whitespace? The system SHOULD trim the code before lookup. +- What happens when the room is full? The system SHOULD reject new join attempts with a clear message. +- What happens when the server restarts and all rooms are lost? Players SHOULD see that the room no longer exists and be redirected appropriately. +- What happens when a player disconnects or closes their browser? Their participant entry MAY remain until explicitly removed or the room is cleaned up. +- What happens when the host disconnects? The room becomes hostless; remaining players see the lobby but cannot start the game. No auto-promotion occurs. +- What happens when a player tries to join a room with a display name that matches an existing participant? The system SHOULD reject the join with a message that the name is taken. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow a player to create a room with a display name and receive a unique room code. +- **FR-002**: System MUST designate the room creator as the host. +- **FR-003**: System MUST reject room creation with empty or whitespace-only display names. +- **FR-004**: System MUST allow a player to join an existing room by providing a room code and a unique display name. +- **FR-005**: System MUST reject join attempts with invalid or non-existent room codes. +- **FR-006**: System MUST reject join attempts with empty or whitespace-only display names. +- **FR-014**: System MUST reject join attempts when the requested display name is already taken by another participant in the same room. +- **FR-007**: System MUST reject join attempts with empty room codes. +- **FR-008**: System MUST ensure rooms are fully isolated (actions in one room do not affect another). +- **FR-009**: System MUST auto-refresh the lobby participant list approximately every 2 seconds via polling. +- **FR-010**: System MUST allow only the host to start the game. +- **FR-011**: System MUST require at least 2 players before the game can be started. +- **FR-012**: System MUST return a clear error message when start is attempted with fewer than 2 players. +- **FR-013**: System MUST trim leading and trailing whitespace from room codes before processing. + +### Key Entities *(include if feature involves data)* + +- **Room**: A game session identified by a unique 4-character code. Contains a list of participants, a host designation, and a status (lobby, playing, finished). Rooms are stored in-memory and are fully isolated from each other. +- **Participant**: A player within a room, identified by a unique participant ID and a display name. Display names MUST be unique within a room. One participant is designated as the host. +- **Host**: The participant who created the room. Has exclusive permission to start the game. If the host disconnects, the room becomes hostless and no participant can start the game (players must create a new room). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A player can create a room and see a unique room code within 2 seconds. +- **SC-002**: A second player can join an existing room by entering the correct room code within 2 seconds. +- **SC-003**: The lobby participant list updates within approximately 2 seconds of a new player joining, without manual refresh. +- **SC-004**: A host with 2+ players can start the game; a host with only 1 player sees a clear message explaining 2 players are needed. +- **SC-005**: Invalid room codes and empty display names always produce a clear, user-facing error message. + +## Clarifications + +### Session 2026-06-11 + +- Q: Can two participants in the same room share the same display name, or must names be unique within a room? → A: Names MUST be unique within a room; joining with a taken name shows an error message. +- Q: What happens when the host leaves or disconnects? → A: Host departure leaves the room hostless; remaining players see the lobby but cannot start the game. Auto-promotion is not implemented. + +## Assumptions + +- Room codes are 4-character alphanumeric strings generated by the system (not user-chosen). +- The server runs in-memory only; room state is lost on server restart. +- Display names are trimmed and must be non-empty after trimming. +- A maximum of 4 participants is reasonable for a drawing game room. +- Players use modern browsers (Chrome, Firefox, Safari, Edge) with JavaScript enabled. +- Network latency for polling is within normal local/cloud ranges (sub-200ms response time). diff --git a/specs/001-room-setup-lobby/tasks.md b/specs/001-room-setup-lobby/tasks.md new file mode 100644 index 00000000..0631a081 --- /dev/null +++ b/specs/001-room-setup-lobby/tasks.md @@ -0,0 +1,216 @@ +--- + +description: "Task list for Room Setup & Lobby feature implementation" + +--- + +# Tasks: Room Setup & Lobby + +**Input**: Design documents from `specs/001-room-setup-lobby/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: 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 + +- **Web app**: `backend/src/`, `frontend/src/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic bug fixes + +- [x] T001 Fix fallback API URL in frontend/src/services/api.ts (replace `/bug` suffix with empty path) + +**Checkpoint**: Setup complete + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Backend model and schema changes that ALL user stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T002 [P] Add `hostId: string` field and `RoomStatus` enum (`"lobby" | "playing" | "finished"`) to Room model in backend/src/models/game.ts +- [x] T003 [P] Add `startGameSchema` (empty body) and update `joinRoomSchema` (add player name uniqueness field description) in backend/src/api/schemas.ts + +**Checkpoint**: Foundation ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Host Creates a Room (Priority: P1) 🎯 MVP + +**Goal**: A player can create a room with a display name, receive a unique room code, and be designated as the host. + +**Independent Test**: A single player can create a room, see the room code, confirm they are the host, and land on the lobby screen. + +### Implementation for User Story 1 + +- [x] T004 [US1] Update `createRoom` service in backend/src/services/roomStore.ts to set `hostId` to the creating participant's ID +- [x] T005 [US1] Update POST /rooms endpoint response in backend/src/api/rooms.ts to return `hostId` in RoomSnapshot + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently. Player creates room → lands on lobby → sees room code and Start Game button. + +--- + +## Phase 4: User Story 2 - Player Joins a Room (Priority: P1) + +**Goal**: A player can join an existing room by entering a valid room code and display name. Invalid codes, empty names, and duplicate names are rejected with clear error messages. + +**Independent Test**: A player can join an existing room by entering a valid room code and see themselves appear in the lobby participant list. + +### Implementation for User Story 2 + +- [x] T006 [US2] Add display name uniqueness validation to `joinRoom` service in backend/src/services/roomStore.ts (check against existing participant names) +- [x] T007 [US2] Add max participants check (4) to `joinRoom` service in backend/src/services/roomStore.ts +- [x] T008 [US2] Update POST /rooms/:code/join endpoint in backend/src/api/rooms.ts with duplicate name error (400) and room full error (403) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently. Create + Join flows with all validation errors work. + +--- + +## Phase 5: User Story 3 - Lobby Auto-Refresh (Priority: P2) + +**Goal**: The lobby participant list refreshes automatically via ~2s polling without manual action. + +**Independent Test**: Two browser tabs show the same room; when a new player joins, the existing player's lobby updates automatically within ~2 seconds. + +### Implementation for User Story 3 + +- [x] T009 [US3] Add auto-polling with `setInterval` (~2s) in frontend/src/pages/LobbyPage.tsx using `fetchRoom` from roomStore +- [x] T010 [US3] Add polling cleanup on component unmount (clearInterval) in frontend/src/pages/LobbyPage.tsx +- [x] T011 [US3] Add loading state indicator during polling in frontend/src/pages/LobbyPage.tsx + +**Checkpoint**: At this point, User Stories 1, 2, AND 3 should all work. Participants appear in lobby automatically within ~2s. + +--- + +## Phase 6: User Story 4 - Host Starts the Game (Priority: P2) + +**Goal**: Only the host can start the game from the lobby once at least 2 players are present. Non-host players see no start button; insufficient players see an error. + +**Independent Test**: A host with at least 2 players in the lobby can start the game; a host with only 1 player cannot. + +### Implementation for User Story 4 + +- [x] T012 [P] [US4] Add `startGame` method to backend/src/services/roomStore.ts: validate hostId matches requester, validate ≥2 participants, set status to `"playing"` +- [x] T013 [US4] Add POST /rooms/:code/start endpoint in backend/src/api/rooms.ts that calls startGame and returns updated room +- [x] T014 [P] [US4] Add `startGame` API call function in frontend/src/services/api.ts +- [x] T015 [US4] Add `startGame` action and loading/error state to frontend/src/state/roomStore.ts +- [x] T016 [US4] Update LobbyPage in frontend/src/pages/LobbyPage.tsx: host-only Start Game button, error display for <2 players, non-host hides the button + +**Checkpoint**: At this point, all user stories should be independently functional. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and cleanup + +- [x] T017 Run validation scenarios from quickstart.md (specs/001-room-setup-lobby/quickstart.md) in two browser tabs + +--- + +## 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-6)**: All depend on Foundational phase completion + - US1 creates host concept → US4 depends on US1 for host tracking + - US2 adds join validation → US3 depends on room state being correct + - US3 can proceed after US2 since polling fetches existing GET /rooms/:code endpoint + - US4 depends on US1 + US2 (needs hostId + participants) +- **Polish (Phase 7)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational - No dependencies on other stories +- **User Story 2 (P1)**: Can start after Foundational - No dependencies on other stories (can be parallel with US1) +- **User Story 3 (P2)**: Can start after Foundational + US2 (needs valid participants to observe polling) +- **User Story 4 (P2)**: Depends on US1 (hostId) + US2 (participants for 2-player check) + +### Within Each User Story + +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- US1 and US2 can run in parallel (different service methods, different endpoints) +- T012 and T014 [P] can run in parallel (backend endpoint vs frontend API call) +- Once Foundational phase completes, US1 and US2 can start in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# US1 is sequential within itself (service → endpoint): +Task: "Update createRoom service in backend/src/services/roomStore.ts" +Task: "Update POST /rooms endpoint in backend/src/api/rooms.ts" +``` + +## Parallel Example: User Story 4 + +```bash +# Launch backend endpoint and frontend API call in parallel: +Task: "Add startGame method to backend/src/services/roomStore.ts" +Task: "Add startGame API call in frontend/src/services/api.ts" + +# Then launch endpoint (depends on service): +Task: "Add POST /rooms/:code/start endpoint in backend/src/api/rooms.ts" + +# Then launch store (depends on API call): +Task: "Add startGame action to frontend/src/state/roomStore.ts" + +# Then LobbyPage (depends on store): +Task: "Update LobbyPage in frontend/src/pages/LobbyPage.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 (Create Room) +4. Complete Phase 4: User Story 2 (Join Room) +5. **STOP and VALIDATE**: Test create + join flows independently +6. Validate with two browser tabs using quickstart.md Scenarios 1-2, 6-7 + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Create room works independently +3. Add User Story 2 → Join room works independently +4. Add User Story 3 → Lobby auto-refresh works +5. Add User Story 4 → Host can start game +6. Run all quickstart.md scenarios to validate end-to-end + +--- + +## 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 +- 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/specs/002-game-start-drawer/checklists/requirements.md b/specs/002-game-start-drawer/checklists/requirements.md new file mode 100644 index 00000000..e08585e6 --- /dev/null +++ b/specs/002-game-start-drawer/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-11 +**Feature**: [specs/002-game-start-drawer/spec.md](spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/002-game-start-drawer/contracts/api.md b/specs/002-game-start-drawer/contracts/api.md new file mode 100644 index 00000000..3bfaf6fd --- /dev/null +++ b/specs/002-game-start-drawer/contracts/api.md @@ -0,0 +1,84 @@ +# API Contracts: Game Start & Drawer Flow + +Base URL: `http://localhost:3001` + +All responses return JSON. Errors return `{ "error": "" }` with appropriate HTTP status codes. + +--- + +## POST /rooms/:code/start + +Start the game. Only the host can start, and at least 2 players must be present. All participant names are validated before the game starts. + +**Request Body**: (empty) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Empty/whitespace-only name after trimming | `{ "error": "Name is empty after trimming: ''" }` | +| 400 | Duplicate names after trimming | `{ "error": "Duplicate names after trimming: Alice" }` | +| 400 | Fewer than 2 players | `{ "error": "At least 2 players are required to start" }` | +| 403 | Non-host attempts to start | `{ "error": "Only the host can start the game" }` | +| 404 | Room not found | `{ "error": "Room not found" }` | + +--- + +## GET /rooms/:code + +Fetch the current room snapshot (polling endpoint). The `secretWord` field is only present when the requesting participant is the drawer. + +**Query Parameters**: `?participantId=` (optional, recommended) + +**Success Response** (200) — for the drawer: +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ], + "currentDrawerId": "uuid-of-host", + "secretWord": "pizza" + } +} +``` + +**Success Response** (200) — for a non-drawer: +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ], + "currentDrawerId": "uuid-of-host", + "secretWord": null + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 404 | Room not found (expired/never existed) | `{ "error": "Room not found" }` | diff --git a/specs/002-game-start-drawer/data-model.md b/specs/002-game-start-drawer/data-model.md new file mode 100644 index 00000000..1f115ccd --- /dev/null +++ b/specs/002-game-start-drawer/data-model.md @@ -0,0 +1,68 @@ +# Data Model: Game Start & Drawer Flow + +## Entities + +### Room + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Unique 4-char uppercase alphanumeric room identifier | +| `status` | `RoomStatus` | Current room status: `"lobby"` | `"playing"` | `"finished"` | +| `hostId` | `string` | Participant ID of the room creator (host) | +| `participants` | `Participant[]` | List of participants in the room | +| `currentDrawerId` | `string \| null` | Participant ID of the current drawer; set when game starts | +| `secretWord` | `string \| null` | The secret word for the current round; set when game starts | +| `createdAt` | `string` | ISO 8601 timestamp of room creation | +| `updatedAt` | `string` | ISO 8601 timestamp of last update | + +**Validation Rules (added or updated from Slice 1)**: +- Status transitions: `"lobby" → "playing"` (when host starts game with ≥2 valid participants) +- `currentDrawerId` MUST be one of the participant IDs when status is `"playing"` +- `secretWord` MUST be a word from the starter list when status is `"playing"` + +### Participant + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | UUID v4 unique participant identifier | +| `name` | `string` | Display name (non-empty after trim, no duplicates after trim, unique within room) | +| `joinedAt` | `string` | ISO 8601 timestamp of join | + +**Validation Rules (updated)**: +- Name must be non-empty after trimming whitespace (checked at join time and game start) +- Name must be unique among current participants (checked at join time; re-checked after trim at game start) +- Max 4 participants per room + +### RoomSnapshot + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Room code | +| `status` | `RoomStatus` | Current status | +| `hostId` | `string` | Host participant ID | +| `participants` | `Participant[]` | Participant list | +| `currentDrawerId` | `string \| null` | Drawer participant ID (visible to all) | +| `secretWord` | `string \| null` | Secret word (`null` for non-drawer viewers; the word for the drawer) | +| `availableWords` | `string[]` | Starter word list | +| `roles` | `ParticipantRole[]` | Available role values | + +## State Transitions + +``` +[Room Created] ──→ lobby + │ + │ (host starts game, ≥2 players, names valid) + ↓ + playing (drawer assigned, secret word selected) + │ + │ (game ends — future slice) + ↓ + finished +``` + +## Relationships + +- **Room** 1───* **Participant**: A room contains 0 or more participants +- **Room.hostId** references **Participant.id**: The host is one of the participants +- **Room.currentDrawerId** references **Participant.id**: The drawer is one of the participants (set when game starts) +- Rooms are fully isolated — no cross-room relationships diff --git a/specs/002-game-start-drawer/plan.md b/specs/002-game-start-drawer/plan.md new file mode 100644 index 00000000..2e39b48c --- /dev/null +++ b/specs/002-game-start-drawer/plan.md @@ -0,0 +1,104 @@ +# Implementation Plan: Game Start & Drawer Flow + +**Branch**: `002-game-start-drawer` | **Date**: 2026-06-11 | **Spec**: `specs/002-game-start-drawer/spec.md` + +**Input**: Feature specification from `/specs/002-game-start-drawer/spec.md` + +## Summary + +When the host starts a game from the lobby, the system validates all participant names (trimmed, no empty/whitespace-only, no duplicates after trimming). On success, the host is assigned as the drawer, a secret word is selected deterministically from the starter list, and the word is revealed only to the drawer via polls. All participants see who the drawer is. + +## Technical Context + +**Language/Version**: TypeScript 5.x (Node.js 18+ backend, browser ESM via Vite frontend) + +**Primary Dependencies**: Express 4 + Zod (backend), React 18 + React Router 6 (frontend) + +**Storage**: In-memory `Map` on backend + +**Testing**: Vitest (both backend and frontend) + +**Target Platform**: Node.js server (backend), browser (frontend) + +**Project Type**: Web application (monorepo: `backend/` + `frontend/`) + +**Performance Goals**: ~2s poll cycle; game start response <500ms + +**Constraints**: No WebSockets, no databases, no auth, single round, no drawer rotation, no timers + +**Scale/Scope**: Max 4 participants per room, single round per game + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Principle I (SOLID Architecture)**: Not violated. Business logic changes stay in `roomStore.ts`; new `startGame` validation and drawer assignment are single-responsibility additions. + +**Principle II (Type Safety & Contract Discipline)**: Not violated. All new fields typed in TypeScript; Zod schemas updated if needed. + +**Principle III (HTTP Polling Protocol)**: Not violated. Game state changes propagated via existing `GET /rooms/:code` polling. No push protocol introduced. + +**Principle IV (In-Memory State Discipline)**: Not violated. Drawer ID and secret word stored in-memory on the Room object. No persistence added. + +**Principle V (Determinism & Testability)**: Not violated. Word selection uses a pure function of room state (participant count). No randomness. + +**Forbidden items check**: None violated. No WebSockets, databases, auth, multiplayer round features, custom word packs, spectator mode, moderation, room passwords, or rewrites. + +**GATE: PASS** — No violations detected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-game-start-drawer/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── api.md # Phase 1 output +└── spec.md # Feature specification +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── models/ + │ └── game.ts # Add currentDrawerId, secretWord to Room/RoomSnapshot + ├── services/ + │ └── roomStore.ts # Update startGame (name validation, drawer assign, word select) + │ # Update toRoomSnapshot (secret word visibility) + ├── api/ + │ └── rooms.ts # Update POST /rooms/:code/start error handling + └── seed/ + └── starterData.ts # Unchanged (word list reference) + +frontend/ +└── src/ + ├── services/ + │ └── api.ts # Update RoomSnapshot types + ├── state/ + │ └── roomStore.ts # Add drawerId, secretWord to store + └── pages/ + └── LobbyPage.tsx # Handle game start errors, display drawer info + └── GamePage.tsx # New: post-start drawing/guessing UI + +tests/ +├── backend/ +│ └── src/ +│ └── services/ +│ └── roomStore.test.ts # Tests for name validation, drawer assign, word selection +└── frontend/ + └── src/ + └── pages/ + └── LobbyPage.test.tsx # Tests for game start error states +``` + +**Structure Decision**: Option 2 — Web application (frontend + backend). Mono-repo layout already established by starter. + +## Complexity Tracking + +No constitution violations to justify. diff --git a/specs/002-game-start-drawer/quickstart.md b/specs/002-game-start-drawer/quickstart.md new file mode 100644 index 00000000..a1f776b0 --- /dev/null +++ b/specs/002-game-start-drawer/quickstart.md @@ -0,0 +1,53 @@ +# Quickstart Validation: Game Start & Drawer Flow + +## Prerequisites + +- Backend running on `http://localhost:3001` (`cd backend && npm run dev`) +- Frontend running on `http://localhost:5173` (`cd frontend && npm run dev`) + +## Scenario 1: Successful Game Start + +1. Open two browser tabs to `http://localhost:5173` +2. In Tab 1, enter a name (e.g. "Alice") and click "Create Room" +3. In Tab 2, enter the room code shown in Tab 1 and enter name "Bob", click "Join" +4. In Tab 1, verify the "Start Game" button is now visible (host-only) +5. Click "Start Game" in Tab 1 + +**Expected**: +- Tab 1 and Tab 2 both show game status as "playing" within ~2s +- Tab 1 shows "You are the drawer" and the secret word (e.g., "pizza") +- Tab 2 shows "Alice is the drawer" but NO secret word +- Both tabs show the drawer is clearly identified + +## Scenario 2: Empty/Whitespace Name Rejection + +1. Create a room with a player "Alice" +2. Join with a second player named `" "` (whitespace) +3. In Tab 1, click "Start Game" + +**Expected**: Game does not start. An error message is shown indicating the empty/whitespace name. + +## Scenario 3: Duplicate Name After Trim Rejection + +1. Create a room with player "Alice" +2. Join with a second player named `"Alice "` (with trailing space) +3. In Tab 1, click "Start Game" + +**Expected**: Game does not start. An error message is shown indicating duplicate names after trimming. + +## Scenario 4: Non-Host Cannot Start + +1. Create a room with player "Alice" (Tab 1) +2. Join with "Bob" (Tab 2) +3. In Tab 2, verify the "Start Game" button is NOT visible (or disabled) +4. Try to start from Tab 2 + +**Expected**: Tab 2 cannot start the game. Error message "Only the host can start the game". + +## Data Model Reference + +See [data-model.md](../data-model.md) for entity definitions. + +## API Contract Reference + +See [contracts/api.md](../contracts/api.md) for endpoint details. diff --git a/specs/002-game-start-drawer/research.md b/specs/002-game-start-drawer/research.md new file mode 100644 index 00000000..3b59adf0 --- /dev/null +++ b/specs/002-game-start-drawer/research.md @@ -0,0 +1,39 @@ +# Research: Game Start & Drawer Flow + +## Technical Decisions + +### Name Validation Strategy +- **Decision**: Validate all participant names on game start: trim whitespace, reject empty/whitespace-only, reject duplicates after trim +- **Rationale**: Spec requires trimming at game-start time; existing join-time check does not trim, so two names like "Alice " and "Alice" could pass join but collide on start +- **Alternatives considered**: Trim at join time — would change Slice 1 behavior; cleaner to validate at start per spec + +### Drawer Assignment +- **Decision**: Assign host participant (room creator) as the drawer +- **Rationale**: Spec explicitly states "the host (or first player) becomes the clearly-identified drawer" +- **Alternatives considered**: Random drawer selection — rejected (spec says host); first in participant list — same as host since host is always first + +### Secret Word Selection Formula +- **Decision**: `STARTER_WORDS[(participantCount - 2) % STARTER_WORDS.length]` where participantCount ≥ 2 + - 2 players → word[0] = "rocket" + - 3 players → word[1] = "pizza" + - 4 players → word[2] = "castle" +- **Rationale**: Deterministic pure function of room state; scales with player count; always picks the same word for the same room composition +- **Alternatives considered**: Use room code character codes — less predictable; use round number — only 1 round per constitution + +### Secret Word Visibility +- **Decision**: `toRoomSnapshot` includes `secretWord` field only when `viewerParticipantId === currentDrawerId`; otherwise field is `null` +- **Rationale**: Simplest enforcement at the snapshot layer; no risk of leaking via polling +- **Alternatives considered**: Separate drawer-only endpoint — unnecessary complexity; filter on frontend — insecure (data already sent) + +### Participant Role Derivation +- **Decision**: Derive role (`"drawer"` or `"guesser"`) from `currentDrawerId` rather than storing a role per participant +- **Rationale**: Only one drawer exists per game; storing per-participant role would duplicate information +- **Alternatives considered**: Per-participant `role` field — redundant when drawer is a single ID; use existing `ParticipantRole` type — can be added if needed later + +### Game Start Error Handling +- **Decision**: Return specific error messages for: + - `"Name is empty after trimming"` (400) + - `"Duplicate names after trimming: Alice"` (400) + - Existing: `"Only the host can start the game"` (403), `"At least 2 players are required to start"` (400), `"Room not found"` (404) +- **Rationale**: Spec requires clear error messages for name validation failures +- **Alternatives considered**: Generic "cannot start" message — less helpful for debugging diff --git a/specs/002-game-start-drawer/spec.md b/specs/002-game-start-drawer/spec.md new file mode 100644 index 00000000..8eb558b7 --- /dev/null +++ b/specs/002-game-start-drawer/spec.md @@ -0,0 +1,95 @@ +# Feature Specification: Game Start & Drawer Flow + +**Feature Branch**: `002-game-start-drawer` + +**Created**: 2026-06-11 + +**Status**: Draft + +**Input**: User description: "Slice 2 — Game Start & Drawer Flow. Given a game is starting and player names are trimmed (empty/whitespace-only rejected with a message), When the first round begins, Then the host (or first player) becomes the clearly-identified drawer, and the secret word (deterministically selected from the starter list) is visible only to the drawer." + +## Clarifications + +### Session 2026-06-11 + +- Q: When trimming produces duplicate names (e.g. "Alice " and "Alice" both become "Alice"), how should the system handle the collision? → A: Reject game start with an error message listing the conflicting names. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Game Starts with Drawer and Secret Word (Priority: P1) + +As players in a lobby with 2+ participants, when the host starts the game, I want the game to begin by assigning a drawer and revealing a secret word only to that drawer so that the drawing round can proceed. + +**Why this priority**: Core flow of the feature — without drawer assignment and word revelation the game cannot progress beyond the lobby. + +**Independent Test**: Can be tested by joining a room with 2+ players and starting the game. Each player's game state reveals the drawer identity; only the drawer sees the word. + +**Acceptance Scenarios**: + +1. **Given** a room with 2 or more participants, **When** the host starts the game, **Then** the game status changes to "playing" for all participants within one poll cycle +2. **Given** a game that has started, **When** any participant views the game state, **Then** exactly one participant is identified as the drawer and all others as guessers +3. **Given** a game that has started, **When** the drawer views the game state, **Then** the secret word is visible in the response +4. **Given** a game that has started, **When** a non-drawer participant views the game state, **Then** the secret word is absent or obscured + +--- + +### User Story 2 - Player Name Validation on Game Start (Priority: P1) + +As a host trying to start a game, I want participant names to be trimmed and empty/whitespace-only names to be rejected so that all players have meaningful display names during gameplay. + +**Why this priority**: Prevents anonymous or unidentifiable participants from entering gameplay, which would break the drawing/guessing experience. + +**Independent Test**: Can be tested by joining a room with a whitespace-only name and attempting to start the game. Game start is rejected with an error message. + +**Acceptance Scenarios**: + +1. **Given** a room with 2+ participants where at least one participant's name is empty or whitespace-only after trimming, **When** the host attempts to start the game, **Then** the game does not start and an error message is returned +2. **Given** a room where all participant names are non-empty after trimming, **When** the host starts the game, **Then** names are trimmed and the game starts successfully +3. **Given** a room where two or more participant names become identical after trimming, **When** the host attempts to start the game, **Then** the game does not start and an error message listing the conflicting names is returned + +--- + +### Edge Cases + +- What happens when all participants in a 2+ player room have empty/whitespace-only names? +- What happens when two participant names become identical after trimming (e.g. "Alice " and "Alice")? +- How does the system ensure the secret word is never leaked to non-drawer players via game state updates? +- What happens if the host leaves the room before starting the game (room is hostless)? +- How is the word deterministically selected when the room has the same participants and starter list each time? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: When the host requests game start, the system MUST trim leading/trailing whitespace from all participant names +- **FR-002**: If any participant name is empty or whitespace-only after trimming, the system MUST reject the game start and return an error message +- **FR-002b**: If trimming causes two or more participant names to become identical, the system MUST reject the game start and return an error message listing the conflicting names +- **FR-003**: On successful game start, the system MUST assign exactly one participant as the drawer +- **FR-004**: The system MUST assign the host participant as the drawer +- **FR-005**: The system MUST select a secret word deterministically from the starter list such that identical room state always produces the same word +- **FR-006**: The system MUST reveal the secret word only when the requesting participant is the drawer +- **FR-007**: Non-drawer participants MUST NOT be able to see the secret word in any game data returned to them +- **FR-008**: All participants MUST be able to identify who the current drawer is from the game state + +### Key Entities *(include if feature involves data)* + +- **Game Room**: Holds game status ("playing"), participant list, drawer assignment, and the secret word for the current round +- **Participant**: A player in the room with a trimmed display name, assigned role (drawer or guesser), and optional visibility of the secret word +- **Secret Word**: A word deterministically selected from the starter word list, visible only to the assigned drawer + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Every participant can identify who the drawer is within one poll cycle (~2s) of game start +- **SC-002**: Non-drawer participants never see the secret word in any game data they receive +- **SC-003**: Empty, whitespace-only, or collision-causing names are rejected with a clear error message before game start +- **SC-004**: Given identical room state (same participants, same starter list), the same secret word is selected every time + +## Assumptions + +- The starter word list contains exactly 5 words: "rocket", "pizza", "castle", "guitar", "sunflower" +- The game has a single round only (multiple rounds and drawer rotation are out of scope per architecture constraints) +- The host is always the first participant in the room and is designated as the drawer +- Name validation occurs at game-start time, not at join time (players may join with empty/whitespace names but cannot start the game) +- The room snapshot polling mechanism from Slice 1 is reused to propagate game state changes diff --git a/specs/002-game-start-drawer/tasks.md b/specs/002-game-start-drawer/tasks.md new file mode 100644 index 00000000..4b624840 --- /dev/null +++ b/specs/002-game-start-drawer/tasks.md @@ -0,0 +1,164 @@ +# Tasks: Game Start & Drawer Flow + +**Input**: Design documents from `/specs/002-game-start-drawer/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Not requested — no test tasks generated. + +**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 + +- **Web app**: `backend/src/`, `frontend/src/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Review existing structure before making changes + +- [x] T001 Review current Room/RoomSnapshot types in `backend/src/models/game.ts` and `backend/src/services/roomStore.ts` to understand extension points + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core model changes that MUST be complete before any user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T002 Add `currentDrawerId: string | null` and `secretWord: string | null` fields to the `Room` and `RoomSnapshot` interfaces in `backend/src/models/game.ts` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 2 - Player Name Validation (Priority: P1) 🎯 MVP + +**Goal**: Validate all participant names on game start: trim whitespace, reject empty/whitespace-only, reject duplicates after trimming + +**Independent Test**: Join a room with a whitespace-only name and attempt to start. Game start is rejected with a clear error message. + +### Implementation for User Story 2 + +- [x] T003 [P] [US2] Update `startGame` in `backend/src/services/roomStore.ts` to trim all participant names, reject empty/whitespace-only after trim, and reject duplicates after trim +- [x] T004 [US2] Update `POST /rooms/:code/start` in `backend/src/api/rooms.ts` to return 400 with descriptive error messages for name validation failures + +**Checkpoint**: At this point, User Story 2 should be fully functional and testable independently + +--- + +## Phase 4: User Story 1 - Game Starts with Drawer and Secret Word (Priority: P1) 🎯 MVP + +**Goal**: On successful game start, assign the host as drawer, select a secret word deterministically, reveal it only to the drawer, and show drawer identity to all + +**Independent Test**: Join a room with 2+ players, start the game. All players see the drawer identity; only the drawer sees the secret word. + +### Implementation for User Story 1 + +- [x] T005 [US1] Update `startGame` in `backend/src/services/roomStore.ts` to set `currentDrawerId = hostId` and select a secret word deterministically from `STARTER_WORDS` (using participant count as index) +- [x] T006 [US1] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts` to include `currentDrawerId` for all viewers and `secretWord` only when the viewer is the drawer (`null` otherwise) +- [x] T007 [P] [US1] Update `RoomSnapshot` type and `RoomSessionResponse` in `frontend/src/services/api.ts` to add `currentDrawerId: string | null` and `secretWord: string | null` +- [x] T008 [US1] Update room state handling in `frontend/src/state/roomStore.ts` to extract and expose `currentDrawerId` and `secretWord` from room snapshots +- [x] T009 [P] [US1] Update `frontend/src/pages/LobbyPage.tsx` to display drawer identity and secret word after game starts, and handle game start error messages +- [x] T010 [US1] Create `frontend/src/pages/GamePage.tsx` with post-start UI showing drawer status, player list with roles, and secret word (drawer only) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [x] T011 Run build checks: `cd backend && npx tsc --noEmit` and `cd frontend && npx tsc --noEmit` to verify no type errors +- [ ] T012 Run quickstart.md validation scenarios + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Story 2 (Phase 3)**: Depends on Foundational — name validation gate +- **User Story 1 (Phase 4)**: Depends on Foundational and US2 +- **Polish (Phase 5)**: Depends on Phase 3 and Phase 4 completion + +### User Story Dependencies + +- **User Story 2 (P1)**: Can start after Foundational — validates names on game start +- **User Story 1 (P1)**: Can start after User Story 2 — name validation must pass before drawer/word assignment + +### Within Each User Story + +- Models before services +- Services before endpoints +- Backend before frontend + +### Parallel Opportunities + +- T003 and T004 are [P] (different files: `roomStore.ts` vs `rooms.ts`) +- T007 and T009 are [P] (different files: `api.ts` vs `LobbyPage.tsx`) + +--- + +## Parallel Example: Phase 3 (User Story 2) + +```bash +# Launch both User Story 2 tasks together: +Task: "T003 [P] [US2] Update startGame in backend/src/services/roomStore.ts" +Task: "T004 [US2] Update POST /rooms/:code/start in backend/src/api/rooms.ts" +``` + +## Parallel Example: Phase 4 (User Story 1) + +```bash +# Launch independent tasks together: +Task: "T007 [P] [US1] Update RoomSnapshot type in frontend/src/services/api.ts" +Task: "T009 [P] [US1] Update LobbyPage.tsx in frontend/src/pages/LobbyPage.tsx" + +# After type and store tasks complete: +Task: "T008 [US1] Update roomStore.ts in frontend/src/state/roomStore.ts" + +# After store and backend complete: +Task: "T010 [US1] Create GamePage.tsx in frontend/src/pages/GamePage.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 2 + User Story 1) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (model changes — blocks all stories) +3. Complete Phase 3: User Story 2 (name validation) +4. Complete Phase 4: User Story 1 (drawer/word assignment) +5. **STOP and VALIDATE**: Run quickstart.md scenarios +6. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 2 → Name validation testable independently (game rejects bad names) +3. Add User Story 1 → Full game start flow testable (drawer assigned, word shown) +4. Polish → Build passes and validation scenarios pass + +--- + +## 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 +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently diff --git a/specs/003-gameplay-interaction/checklists/requirements.md b/specs/003-gameplay-interaction/checklists/requirements.md new file mode 100644 index 00000000..0107f1e8 --- /dev/null +++ b/specs/003-gameplay-interaction/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Gameplay Interaction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-11 +**Feature**: [specs/003-gameplay-interaction/spec.md](spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/003-gameplay-interaction/contracts/api.md b/specs/003-gameplay-interaction/contracts/api.md new file mode 100644 index 00000000..babf7516 --- /dev/null +++ b/specs/003-gameplay-interaction/contracts/api.md @@ -0,0 +1,171 @@ +# API Contracts: Gameplay Interaction + +Base URL: `http://localhost:3001` + +All responses return JSON. Errors return `{ "message": "" }` with appropriate HTTP status codes. + +--- + +## POST /rooms/:code/draw + +Append a stroke to the canvas. Only the drawer may draw. + +**Request Body**: +```json +{ + "points": [{ "x": 10, "y": 20 }, { "x": 15, "y": 25 }], + "color": "#000000", + "width": 3 +} +``` + +**Query Parameters**: `?participantId=` (required, identifies the drawer) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ], + "currentDrawerId": "uuid-of-drawer", + "secretWord": null, + "guessHistory": [], + "scores": { "uuid-alice": 0, "uuid-bob": 0 }, + "canvasStrokes": [ + { "points": [{ "x": 10, "y": 20 }, { "x": 15, "y": 25 }], "color": "#000000", "width": 3 } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Missing or invalid stroke data | `{ "message": "Invalid stroke data" }` | +| 403 | Non-drawer attempts to draw | `{ "message": "Only the drawer can draw" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | + +--- + +## POST /rooms/:code/clear + +Clear the canvas (reset strokes to empty). Only the drawer may clear. + +**Request Body**: (empty) + +**Query Parameters**: `?participantId=` (required, identifies the drawer) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ ... ], + "currentDrawerId": "uuid-of-drawer", + "secretWord": null, + "guessHistory": [], + "scores": { "uuid-alice": 0, "uuid-bob": 0 }, + "canvasStrokes": [] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 403 | Non-drawer attempts to clear | `{ "message": "Only the drawer can clear the canvas" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | + +--- + +## POST /rooms/:code/guess + +Submit a guess. Only guessers may submit; drawer and already-correct guessers are rejected. + +**Query Parameters**: `?participantId=` (required, identifies the guesser) + +**Request Body**: +```json +{ + "text": " rocket " +} +``` + +**Success Response** (200) — correct guess: +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ ... ], + "currentDrawerId": "uuid-of-drawer", + "secretWord": null, + "guessHistory": [ + { "participantId": "uuid-bob", "name": "Bob", "text": "rocket", "isCorrect": true, "timestamp": "..." } + ], + "scores": { "uuid-alice": 0, "uuid-bob": 100 }, + "canvasStrokes": [ ... ] + } +} +``` + +**Success Response** (200) — guess that ends the game (all guessers correct): +```json +{ + "room": { + "code": "XK4M", + "status": "finished", + ... + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Empty or whitespace-only guess | `{ "message": "Guess cannot be empty" }` | +| 403 | Drawer attempts to guess | `{ "message": "The drawer cannot submit guesses" }` | +| 403 | Guesser already guessed correctly | `{ "message": "You have already guessed correctly" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | + +--- + +## GET /rooms/:code (updated) + +Fetch the current room snapshot (polling endpoint). Now includes `guessHistory`, `scores`, and `canvasStrokes`. + +**Query Parameters**: `?participantId=` (optional, recommended) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "playing", + "hostId": "uuid-string", + "participants": [ ... ], + "currentDrawerId": "uuid-of-drawer", + "secretWord": "pizza", + "guessHistory": [ + { "participantId": "uuid-bob", "name": "Bob", "text": "rocket", "isCorrect": true, "timestamp": "..." } + ], + "scores": { "uuid-alice": 0, "uuid-bob": 100 }, + "canvasStrokes": [ + { "points": [{ "x": 10, "y": 20 }], "color": "#000000", "width": 3 } + ] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 404 | Room not found | `{ "message": "Room not found" }` | diff --git a/specs/003-gameplay-interaction/data-model.md b/specs/003-gameplay-interaction/data-model.md new file mode 100644 index 00000000..84a4913a --- /dev/null +++ b/specs/003-gameplay-interaction/data-model.md @@ -0,0 +1,101 @@ +# Data Model: Gameplay Interaction + +## Entities + +### Room (updated) + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Unique 4-char uppercase alphanumeric room identifier | +| `status` | `RoomStatus` | Current room status: `"lobby"` \| `"playing"` \| `"finished"` | +| `hostId` | `string` | Participant ID of the room creator (host) | +| `participants` | `Participant[]` | List of participants in the room | +| `currentDrawerId` | `string \| null` | Participant ID of the current drawer; set when game starts | +| `secretWord` | `string \| null` | The secret word for the current round; set when game starts | +| `guessHistory` | `Guess[]` | Chronological list of all guess submissions | +| `canvasStrokes` | `Stroke[]` | Chronological list of all drawing strokes | +| `createdAt` | `string` | ISO 8601 timestamp of room creation | +| `updatedAt` | `string` | ISO 8601 timestamp of last update | + +**Validation Rules (added)**: +- Status transitions: `"playing" → "finished"` (when all guessers have guessed correctly) +- `guessHistory` is append-only — entries may not be removed or modified +- `canvasStrokes` is append-only for draw actions; may be reset to `[]` by clear action + +### Participant (unchanged from Slice 2) + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | UUID v4 unique participant identifier | +| `name` | `string` | Display name (non-empty after trim, unique within room) | +| `joinedAt` | `string` | ISO 8601 timestamp of join | + +### Guess (new) + +| Field | Type | Description | +|-------|------|-------------| +| `participantId` | `string` | UUID of the participant who submitted the guess | +| `name` | `string` | Display name of the guesser (denormalized for history display) | +| `text` | `string` | The trimmed guess text | +| `isCorrect` | `boolean` | Whether the guess matched the secret word | +| `timestamp` | `string` | ISO 8601 timestamp of submission | + +**Validation Rules**: +- `text` must be non-empty after trimming whitespace +- `participantId` must belong to a current room participant +- `participantId` must NOT equal `currentDrawerId` +- If the participant already has a correct guess in `guessHistory`, subsequent guesses must be rejected + +### Stroke (new) + +| Field | Type | Description | +|-------|------|-------------| +| `points` | `Point[]` | Array of points comprising the stroke | +| `color` | `string` | Stroke color (hex, e.g. `"#000000"`) | +| `width` | `number` | Stroke width in pixels | + +### Point (new) + +| Field | Type | Description | +|-------|------|-------------| +| `x` | `number` | X coordinate relative to canvas | +| `y` | `number` | Y coordinate relative to canvas | + +### RoomSnapshot (updated) + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Room code | +| `status` | `RoomStatus` | Current status | +| `hostId` | `string` | Host participant ID | +| `participants` | `Participant[]` | Participant list | +| `currentDrawerId` | `string \| null` | Drawer participant ID | +| `secretWord` | `string \| null` | Secret word (`null` for non-drawer viewers) | +| `guessHistory` | `Guess[]` | All guesses submitted so far (visible to all) | +| `scores` | `Record` | Computed scores: participantId → score (100 per correct guess) | +| `canvasStrokes` | `Stroke[]` | Current canvas strokes | +| `availableWords` | `string[]` | Starter word list | +| `roles` | `ParticipantRole[]` | Available role values | + +## State Transitions + +``` +[Room Created] ──→ lobby + │ + │ (host starts game, ≥2 players, names valid) + ↓ + playing (drawer assigned, secret word selected) + │ + │ (all guessers have guessed correctly) + ↓ + finished +``` + +## Relationships + +- **Room** 1───* **Guess**: A room has a guess history +- **Room** 1───* **Stroke**: A room has a canvas stroke collection +- **Room.hostId** references **Participant.id**: The host is one of the participants +- **Room.currentDrawerId** references **Participant.id**: The drawer is one of the participants +- **Guess.participantId** references **Participant.id**: Each guess belongs to a participant +- Rooms are fully isolated — no cross-room relationships diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 00000000..f1c84529 --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,105 @@ +# Implementation Plan: Gameplay Interaction + +**Branch**: `003-gameplay-interaction` | **Date**: 2026-06-11 | **Spec**: `specs/003-gameplay-interaction/spec.md` + +**Input**: Feature specification from `/specs/003-gameplay-interaction/spec.md` + +## Summary + +Add core gameplay loop: the drawer draws/clears a canvas (strokes synced via polling), guessers submit text guesses (trimmed, case-insensitive, empty rejection), scores (+100 per correct guess) are computed from guess history, and the game ends when all guessers guess correctly. All state changes are visible within one poll cycle (~2s). + +## Technical Context + +**Language/Version**: TypeScript 5.x (Node.js 18+ backend, browser ESM via Vite frontend) + +**Primary Dependencies**: Express 4 + Zod (backend), React 18 + React Router 6 (frontend) + +**Storage**: In-memory `Map` on backend — no database + +**Testing**: Vitest (both backend and frontend) + +**Target Platform**: Node.js server (backend), browser (frontend) + +**Project Type**: Web application (monorepo: `backend/` + `frontend/`) + +**Performance Goals**: ~2s poll cycle; draw/guess response <500ms + +**Constraints**: No WebSockets, no databases, no auth, single round, no drawer rotation, no timers, max 4 participants + +**Scale/Scope**: Max 4 participants per room, single round per game + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Principle I (SOLID Architecture)**: Not violated. Drawing and guessing add new service functions; existing `startGame` and `toRoomSnapshot` are extended with new fields. No existing behavior is modified. + +**Principle II (Type Safety & Contract Discipline)**: Not violated. New `Guess`, `Stroke`, `Point` types added; existing interfaces extended with optional/union types. + +**Principle III (HTTP Polling Protocol)**: Not violated. Canvas strokes, guess history, and scores are transmitted via existing `GET /rooms/:code` polling endpoint. No push protocol introduced. + +**Principle IV (In-Memory State Discipline)**: Not violated. All new state (guess history, canvas strokes, computed scores) stored in-memory on the Room object. + +**Principle V (Determinism & Testability)**: Not violated. Guess comparison is a pure function (trim + lowercase + equality). Score computation is a pure function (filter correct guesses × 100). Game end detection is a pure function (check all non-drawers have a correct guess). + +**Forbidden items check**: None violated. No WebSockets, databases, auth, multiplayer round features, custom word packs, spectator mode, moderation, room passwords, or rewrites. + +**GATE: PASS** — No violations detected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-gameplay-interaction/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── api.md # Phase 1 output +└── spec.md # Feature specification +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── models/ + │ └── game.ts # Add Guess, Stroke, Point interfaces + │ # Add guessHistory, canvasStrokes to Room + │ # Add guessHistory, scores, canvasStrokes to RoomSnapshot + ├── services/ + │ └── roomStore.ts # Add drawStroke, clearCanvas, submitGuess functions + │ # Update toRoomSnapshot (compute scores, include new fields) + │ # Add allGuessersCorrect helper for game-end detection + ├── api/ + │ ├── rooms.ts # Add POST /draw, POST /clear, POST /guess routes + │ └── schemas.ts # Add drawStrokeSchema, guessSchema schemas + └── seed/ + └── starterData.ts # Unchanged + +frontend/ +└── src/ + ├── services/ + │ └── api.ts # Add drawStroke, clearCanvas, submitGuess methods + │ # Update RoomSnapshot type with new fields + ├── state/ + │ └── roomStore.ts # Add drawStroke, clearCanvas, submitGuess actions + ├── pages/ + │ └── GamePage.tsx # Integrate canvas drawing for drawer + │ # Pass guess history and scores to sidebar + │ # Handle game "finished" status → show end state + └── components/ + ├── GuessForm.tsx # Wire up to actually submit guesses + ├── ResultPanel.tsx # Show real guess history + ├── Scoreboard.tsx # Show real scores + └── Canvas.tsx # New: drawing canvas component (drawer) / display canvas (guessers) +``` + +**Structure Decision**: Option 2 — Web application (frontend + backend). Mono-repo layout already established. + +## Complexity Tracking + +No constitution violations to justify. diff --git a/specs/003-gameplay-interaction/quickstart.md b/specs/003-gameplay-interaction/quickstart.md new file mode 100644 index 00000000..aeeafb59 --- /dev/null +++ b/specs/003-gameplay-interaction/quickstart.md @@ -0,0 +1,67 @@ +# Quickstart Validation: Gameplay Interaction + +## Prerequisites + +- Backend running on `http://localhost:3001` (`cd backend && npm run dev`) +- Frontend running on `http://localhost:5173` (`cd frontend && npm run dev`) + +## Scenario 1: Drawer Draws and Clearers + +1. Open two browser tabs to `http://localhost:5173` +2. Start a game with 2+ players (as per Quickstart in Slice 2) +3. In the drawer's tab, verify the canvas is blank +4. Draw a stroke on the canvas — verify it appears +5. Click "Clear Canvas" — verify the canvas resets to blank + +**Expected**: +- Drawing appears immediately on the drawer's screen +- After one poll cycle (~2s), the guesser sees the same drawing +- After clearing, both players see a blank canvas within one poll cycle + +## Scenario 2: Guesser Submits Guesses + +1. From Scenario 1, as a guesser, type an empty/whitespace guess and submit +2. Type an incorrect word and submit +3. Type the correct secret word (e.g., "pizza" for 3-player game) with mixed case and trailing spaces + +**Expected**: +- Empty/whitespace guess is rejected with an error message (no state change) +- Incorrect guess adds to history marked as incorrect (score unchanged) +- Correct guess is accepted, score increases by 100, history shows correct, guesser cannot submit more guesses + +## Scenario 3: All Players See Guess History and Scores + +1. As guesser 1, submit an incorrect guess and a correct guess +2. As guesser 2, submit a correct guess +3. After each submission, verify via polling + +**Expected**: +- Both guessers see the full guess history (names, texts, correct/incorrect markers) +- Each correct guess adds 100 to that guesser's score +- After all guessers have guessed correctly, game status transitions to "finished" + +## Scenario 4: Game Ends When All Guessers Guess Correctly + +1. Start a game with 3 players (1 drawer + 2 guessers) +2. Both guessers submit correct guesses +3. After the second correct guess, verify game status + +**Expected**: +- After all guessers have a correct guess, status is "finished" +- All players see the "finished" status within one poll cycle + +## Scenario 5: Drawer Cannot Guess + +1. In the drawer's tab (after game start), attempt to submit a guess + +**Expected**: +- The guess form is not visible to the drawer +- If attempted via API, receives 403 "The drawer cannot submit guesses" + +## Data Model Reference + +See [data-model.md](../data-model.md) for entity definitions. + +## API Contract Reference + +See [contracts/api.md](../contracts/api.md) for endpoint details. diff --git a/specs/003-gameplay-interaction/research.md b/specs/003-gameplay-interaction/research.md new file mode 100644 index 00000000..9d1fe2dc --- /dev/null +++ b/specs/003-gameplay-interaction/research.md @@ -0,0 +1,38 @@ +# Research: Gameplay Interaction + +## Technical Decisions + +### Canvas Drawing Serialization +- **Decision**: Store drawing as an array of strokes, each stroke being an array of `{x, y}` points with `color` and `width` metadata. Transmitted as JSON via HTTP polling. +- **Rationale**: Strokes are trivially serializable, replayable on any canvas, and support incremental addition without data loss. Base64 images would be larger and harder to diff; SVG would require a DOM parser. +- **Alternatives considered**: Base64 PNG snapshots — larger payloads, no incremental support; SVG path data — more complex on the drawing side; single flat point array — loses stroke boundaries for rendering. + +### Canvas Clear Implementation +- **Decision**: `POST /rooms/:code/clear` resets `canvasStrokes` to an empty array on the server. No soft-delete or undo history. +- **Rationale**: Spec requires only clear (not undo). Simple array reset matches spec requirements exactly. +- **Alternatives considered**: Tombstone/marker stroke to indicate clear point — unnecessary complexity for a single clear action. + +### Score Derivation +- **Decision**: Scores are derived from `guessHistory` at snapshot time: for each participant, count their correct guesses and multiply by 100. +- **Rationale**: Single source of truth (guess history). No risk of scores drifting out of sync. Minimal computation cost (max 4 participants, limited guesses per round). +- **Alternatives considered**: Store `scores: Record` on Room — duplicated state that must be manually kept in sync. + +### Guess Validation Strategy +- **Decision**: Trim whitespace server-side before comparison. Empty/whitespace-only → 400 error. Case-insensitive comparison via `.toLowerCase()`. Reject guesses from the drawer and from guessers who already guessed correctly. +- **Rationale**: Server-side validation ensures consistency across clients. Case-insensitive comparison matches spec requirement. +- **Alternatives considered**: Client-only validation — insecure (malformed requests could bypass). + +### Game End Detection +- **Decision**: After each correct guess, check if every non-drawer participant has at least one correct guess in `guessHistory`. If so, transition room status to `"finished"`. +- **Rationale**: Matches the clarified requirement: "game ends when all guessers have guessed correctly." Simple linear scan of guess history with max 4 participants. +- **Alternatives considered**: Track a count of correct guessers — more complex state to update. + +### Drawing Authorization +- **Decision**: `POST /rooms/:code/draw` and `POST /rooms/:code/clear` validate that `requesterId === currentDrawerId`. Non-drawers receive 403. +- **Rationale**: Prevents guessers from modifying the canvas. Consistent with existing `startGame` authorization pattern. +- **Alternatives considered**: No authorization — guessers could erase/draw over the canvas. + +### Guess Authorization +- **Decision**: Reject guesses from the drawer (participant ID matches `currentDrawerId`). Reject guesses from guessers who already have a correct guess in history. +- **Rationale**: Drawer knows the secret word so guessing is meaningless. FR-012 requires rejecting subsequent guesses after a correct one. +- **Alternatives considered**: Allow any guess but ignore drawer guesses — confusing UX. diff --git a/specs/003-gameplay-interaction/spec.md b/specs/003-gameplay-interaction/spec.md new file mode 100644 index 00000000..935cb868 --- /dev/null +++ b/specs/003-gameplay-interaction/spec.md @@ -0,0 +1,118 @@ +# Feature Specification: Gameplay Interaction + +**Feature Branch**: `003-gameplay-interaction` + +**Created**: 2026-06-11 + +**Status**: Draft + +**Input**: User description: "Slice 3 — Gameplay Interaction. Given a round is active with a drawer and guessers (all scores start at 0), When the drawer draws/clears the canvas and guessers submit their guesses, Then the drawing is visible on the drawer's screen; guesses are trimmed, case-insensitively compared, and empty ones rejected; the guess history is synced to all players via polling; correct guesses score 100 (incorrect add 0)." + +## Clarifications + +### Session 2026-06-11 + +- Q: When does the game end? → A: After all guessers have guessed correctly (most inclusive, aligns with single-round no-timer design) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Drawer Draws and Clears Canvas (Priority: P1) + +As the drawer, when the game round is active, I want to draw on a canvas and clear it when needed so that guessers can see my drawing and try to guess the word. + +**Why this priority**: Core interaction — without a drawing, guessers have nothing to go on. + +**Independent Test**: Can be tested by starting a game as the drawer, drawing on the canvas, and verifying the drawing appears. Clear the canvas and confirm it resets. + +**Acceptance Scenarios**: + +1. **Given** a game in progress where I am the drawer, **When** I draw on the canvas, **Then** the drawing is visible on my screen +2. **Given** I have drawn on the canvas, **When** I clear the canvas, **Then** the canvas resets to a blank state +3. **Given** I have drawn on the canvas, **When** other players view the game state, **Then** they see the current drawing + +--- + +### User Story 2 - Guessers Submit Guesses (Priority: P1) + +As a guesser, when a round is active, I want to submit text guesses that are compared case-insensitively against the secret word so that I can try to guess correctly. + +**Why this priority**: Core interaction — without guesses, the game has no progression. + +**Independent Test**: Can be tested by joining as a guesser and submitting various guesses (correct, incorrect, empty, mixed case). Correct guesses score 100 points; incorrect add 0; empty guesses are rejected. + +**Acceptance Scenarios**: + +1. **Given** a game in progress where I am a guesser, **When** I submit an empty or whitespace-only guess, **Then** the guess is rejected with an error message +2. **Given** a game in progress where I am a guesser, **When** I submit a guess, **Then** leading/trailing whitespace is trimmed before comparison +3. **Given** a game in progress where I am a guesser, **When** I submit a guess that matches the secret word (case-insensitively), **Then** my score increases by 100 and the guess is marked as correct +4. **Given** a game in progress where I am a guesser, **When** I submit a guess that does not match the secret word, **Then** my score remains unchanged and the guess is marked as incorrect + +--- + +### User Story 3 - Guess History and Scores Are Synced (Priority: P1) + +As any player, when guesses are submitted, I want to see the guess history and updated scores for all players so that I can track the game's progress. + +**Why this priority**: Without shared state, players cannot coordinate and the game stalls. + +**Independent Test**: Can be tested by having multiple guessers submit guesses (correct and incorrect) and verifying all players see the same guess history and scores within one poll cycle. + +**Acceptance Scenarios**: + +1. **Given** guesses have been submitted in a game, **When** any player views the game state, **Then** they see the full guess history with each guesser's name, guess text, and result (correct/incorrect) +2. **Given** a guesser has submitted a correct guess, **When** all players view the game state, **Then** they see the guesser's updated score (previous score + 100) +3. **Given** a guesser has submitted an incorrect guess, **When** all players view the game state, **Then** they see the guesser's score unchanged +4. **Given** all guessers have submitted a correct guess, **When** any player views the game state, **Then** the game status is "finished" + +--- + +### Edge Cases + +- What happens when a guesser submits a guess after already guessing correctly? +- How does the system handle rapid successive guesses from the same guesser? +- What happens if the drawer clears the canvas after guessers have seen a previous drawing? +- How does a guesser know their guess was received while waiting for the next poll cycle? +- What happens to the game state when all guessers have guessed correctly (who sees the "finished" status)? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST capture drawing actions from the drawer and make the current canvas state available to all participants via polling +- **FR-002**: The drawer MUST be able to clear the canvas to a blank state +- **FR-003**: The system MUST reject empty or whitespace-only guesses with an error message +- **FR-004**: The system MUST trim leading and trailing whitespace from guesses before comparison +- **FR-005**: The system MUST compare guesses against the secret word case-insensitively +- **FR-006**: A correct guess MUST increase the guesser's score by exactly 100 points +- **FR-007**: An incorrect guess MUST NOT change the guesser's score +- **FR-008**: The system MUST maintain a guess history containing each guesser's name, guess text, and result (correct/incorrect) +- **FR-009**: The guess history MUST be visible to all participants via polling +- **FR-010**: All participants' scores MUST be visible to all participants via polling +- **FR-011**: The game MUST end (status transitions to "finished") when all guessers have submitted a correct guess +- **FR-012**: Once a guesser has guessed correctly, subsequent guesses from that guesser MUST be rejected + +### Key Entities *(include if feature involves data)* + +- **Game Room**: Holds game status, secret word, drawer ID, participant list with scores, guess history, and current canvas state +- **Guess**: A single guess submission containing the guesser ID, trimmed guess text, result (correct/incorrect), and timestamp +- **Score**: A running integer score per participant (starts at 0, increments by 100 for each correct guess) +- **Canvas State**: The current visual state of the drawing, updated by the drawer's actions + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: The drawer can draw and clear the canvas, and all participants see the updated drawing within one poll cycle (~2s) +- **SC-002**: Empty or whitespace-only guesses are rejected with a clear error message before affecting game state +- **SC-003**: Correct case-insensitive guesses result in a 100-point score increase, visible to all within one poll cycle +- **SC-004**: All participants see the same guess history and scores within one poll cycle of any guess submission + +## Assumptions + +- The canvas drawing is synced to all players via HTTP polling (not real-time); drawings appear within one poll cycle +- Guessers see the current drawing on their screen (the description's "visible on the drawer's screen" applies to all participants) +- Each guesser can guess multiple times; only the first correct guess scores 100; subsequent guesses from that guesser are rejected +- Incorrect guesses do not penalize the guesser (no negative scoring) +- The drawer does not submit guesses and does not accumulate guess-based score +- All participants' scores start at 0 when the game begins +- Guesses are compared against the single secret word assigned at game start diff --git a/specs/003-gameplay-interaction/tasks.md b/specs/003-gameplay-interaction/tasks.md new file mode 100644 index 00000000..7cd29087 --- /dev/null +++ b/specs/003-gameplay-interaction/tasks.md @@ -0,0 +1,159 @@ +# Tasks: Gameplay Interaction + +**Input**: Design documents from `/specs/003-gameplay-interaction/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Not requested — no test tasks generated. + +**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 + +- **Web app**: `backend/src/`, `frontend/src/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Review existing structure before making changes + +- [ ] T001 Review current Room/RoomSnapshot types and roomStore functions to understand extension points for guess history, canvas strokes, and scoring + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core model changes that MUST be complete before any user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T002 Add `Guess`, `Stroke`, and `Point` interfaces to `backend/src/models/game.ts`; add `guessHistory` and `canvasStrokes` fields to `Room`; add `guessHistory`, `scores`, and `canvasStrokes` fields to `RoomSnapshot` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Drawer Draws and Clears Canvas (Priority: P1) 🎯 MVP + +**Goal**: The drawer can draw strokes on a canvas and clear it. All participants see the current drawing via polling. + +**Independent Test**: Start a game as the drawer, draw a stroke, verify it appears. Clear the canvas, verify it resets. The guesser sees both updates within one poll cycle. + +### Implementation for User Story 1 + +- [ ] T003 [P] [US1] Add `drawStroke` function in `backend/src/services/roomStore.ts` — appends a stroke to room.canvasStrokes (validates room exists, requester is drawer) +- [ ] T004 [P] [US1] Add `clearCanvas` function in `backend/src/services/roomStore.ts` — resets room.canvasStrokes to `[]` (validates room exists, requester is drawer) +- [ ] T005 [US1] Add `drawStrokeSchema` and `clearCanvasSchema` in `backend/src/api/schemas.ts` using Zod +- [ ] T006 [US1] Add `POST /rooms/:code/draw` and `POST /rooms/:code/clear` routes in `backend/src/api/rooms.ts` +- [ ] T007 [P] [US1] Update `RoomSnapshot` type and add `requireParticipantIdQuerySchema` in `frontend/src/services/api.ts`; add `drawStroke` and `clearCanvas` methods to `api` object +- [ ] T008 [US1] Add `drawStroke` and `clearCanvas` actions in `frontend/src/state/roomStore.ts` +- [ ] T009 [P] [US1] Create `frontend/src/components/Canvas.tsx` — drawing canvas (drawer: interactive with mouse/touch; guesser: read-only replay) +- [ ] T010 [US1] Update `frontend/src/pages/GamePage.tsx` — replace canvas placeholder with `` component; pass drawing callbacks for drawer + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - Guessers Submit Guesses (Priority: P1) 🎯 MVP + +**Goal**: Guessers can submit text guesses. Empty/whitespace guesses are rejected. Guesses are trimmed and compared case-insensitively. Correct guesses score 100. + +**Independent Test**: Join as a guesser, submit an empty guess (rejected), an incorrect guess (no score change), and a correct guess (+100 points, marked correct, subsequent guesses rejected). + +### Implementation for User Story 2 + +- [ ] T011 [P] [US2] Add `submitGuess` function in `backend/src/services/roomStore.ts` — validates room/participant, trims text, compares case-insensitively, records in guessHistory, detects game end (all guessers correct), computes scores +- [ ] T012 [P] [US2] Add `guessSchema` in `backend/src/api/schemas.ts` using Zod +- [ ] T013 [US2] Add `POST /rooms/:code/guess` route in `backend/src/api/rooms.ts` +- [ ] T014 [P] [US2] Add `submitGuess` method to `api` object in `frontend/src/services/api.ts` +- [ ] T015 [US2] Add `submitGuess` action in `frontend/src/state/roomStore.ts` +- [ ] T016 [US2] Update `frontend/src/components/GuessForm.tsx` — wire up form submission to call store.submitGuess; display error and success states; disable input after correct guess + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - Guess History and Scores Synced (Priority: P1) 🎯 MVP + +**Goal**: All participants see the same guess history and scores within one poll cycle. Game ends with "finished" status when all guessers have guessed correctly. + +**Independent Test**: Submit guesses from multiple guessers. Verify all players see identical history, scores, and game-end transition within one poll cycle. + +### Implementation for User Story 3 + +- [ ] T017 [US3] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts` to compute and include `scores` (derived from guessHistory) and pass through `guessHistory` and `canvasStrokes` +- [ ] T018 [P] [US3] Update `frontend/src/components/Scoreboard.tsx` to display real participant names and scores from `room.scores` +- [ ] T019 [US3] Update `frontend/src/components/ResultPanel.tsx` to display real guess history from `room.guessHistory` +- [ ] T020 [US3] Update `frontend/src/pages/GamePage.tsx` to handle `room.status === "finished"` — show game-over state (final scores, disable drawing/guessing) + +**Checkpoint**: At this point, User Stories 1, 2, AND 3 should all work independently + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T021 Run build checks: `cd backend && npx tsc --noEmit` and `cd frontend && npx tsc --noEmit` to verify no type errors +- [ ] T022 Run quickstart.md validation scenarios + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational — canvas drawing +- **User Story 2 (Phase 4)**: Depends on Foundational — guess submission +- **User Story 3 (Phase 5)**: Depends on Phases 3 and 4 — builds on guess history + canvas state +- **Polish (Phase 6)**: Depends on all user stories + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational — independent of other stories +- **User Story 2 (P1)**: Can start after Foundational — independent of other stories +- **User Story 3 (P1)**: Integrates data from US1 and US2 but implementation is additive (updating existing components) + +### Within Each User Story + +- Models before services +- Services before endpoints +- Backend before frontend + +### Parallel Opportunities + +- T003 and T004 (roomStore functions) — same file, sequential +- T007 and T009 (frontend api.ts vs Canvas.tsx) — different files +- T011 and T012 (roomStore vs schemas) — different files +- T018 and T019 (Scoreboard vs ResultPanel) — different files + +--- + +## Implementation Strategy + +### MVP First (All Three User Stories) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (model changes — blocks all stories) +3. Complete Phase 3: User Story 1 (drawing) +4. Complete Phase 4: User Story 2 (guessing) +5. Complete Phase 5: User Story 3 (sync + game end) +6. **STOP and VALIDATE**: Run quickstart.md scenarios +7. Complete Phase 6: Polish + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Drawing testable independently (drawer draws, guessers see it) +3. Add User Story 2 → Guessing testable independently (guess validation, scoring) +4. Add User Story 3 → Full gameplay testable (history, sync, game end) +5. Polish → Build passes and validation scenarios pass diff --git a/specs/004-result-restart/checklists/requirements.md b/specs/004-result-restart/checklists/requirements.md new file mode 100644 index 00000000..d9fe336e --- /dev/null +++ b/specs/004-result-restart/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Result, Restart & Final Validation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-11 +**Feature**: [specs/004-result-restart/spec.md](spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for planning. diff --git a/specs/004-result-restart/contracts/api.md b/specs/004-result-restart/contracts/api.md new file mode 100644 index 00000000..37e2648f --- /dev/null +++ b/specs/004-result-restart/contracts/api.md @@ -0,0 +1,103 @@ +# API Contracts: Result, Restart & Final Validation + +Base URL: `http://localhost:3001` + +All responses return JSON. Errors return `{ "message": "" }` with appropriate HTTP status codes. + +--- + +## POST /rooms/:code/restart + +Restart a finished game. Only the host may restart. All participants are preserved; round-specific state (guesses, canvas, scores, drawer, word) is cleared. + +**Request Body**: (empty) + +**Query Parameters**: `?participantId=` (required, identifies the host) + +**Success Response** (200): +```json +{ + "room": { + "code": "XK4M", + "status": "lobby", + "hostId": "uuid-string", + "participants": [ + { "id": "uuid-string", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-string", "name": "Bob", "joinedAt": "..." } + ], + "currentDrawerId": null, + "secretWord": null, + "guessHistory": [], + "scores": {}, + "canvasStrokes": [], + "availableWords": ["rocket", "pizza", "castle", "guitar", "sunflower"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 400 | Game is not finished | `{ "message": "Game is not finished" }` | +| 403 | Non-host attempts to restart | `{ "message": "Only the host can restart the game" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | + +--- + +## GET /rooms/:code (updated) + +Fetch the current room snapshot. When `status` is `"finished"`, the `secretWord` is now visible to ALL participants (not just the drawer). + +**Query Parameters**: `?participantId=` (optional) + +**Success Response** (200) — when status is "finished" (all viewers see the secret word): +```json +{ + "room": { + "code": "XK4M", + "status": "finished", + "hostId": "uuid-string", + "participants": [ ... ], + "currentDrawerId": "uuid-of-drawer", + "secretWord": "pizza", + "guessHistory": [ + { "participantId": "uuid-bob", "name": "Bob", "text": "pizza", "isCorrect": true, "timestamp": "..." } + ], + "scores": { "uuid-alice": 0, "uuid-bob": 100 }, + "canvasStrokes": [ ... ], + "availableWords": [...], + "roles": ["drawer", "guesser"] + } +} +``` + +**Success Response** (200) — when status is "lobby" (after restart, round state cleared): +```json +{ + "room": { + "code": "XK4M", + "status": "lobby", + "hostId": "uuid-string", + "participants": [ ... ], + "currentDrawerId": null, + "secretWord": null, + "guessHistory": [], + "scores": {}, + "canvasStrokes": [], + "availableWords": [...], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error Responses**: +| Status | Condition | Body | +|--------|-----------|------| +| 404 | Room not found | `{ "message": "Room not found" }` | + +--- + +## POST /rooms/:code/start (unchanged from Slice 2) + +After restart, the host can start a new game from the lobby. This endpoint is unchanged — it works identically whether the lobby is fresh or after a restart. diff --git a/specs/004-result-restart/data-model.md b/specs/004-result-restart/data-model.md new file mode 100644 index 00000000..db5366df --- /dev/null +++ b/specs/004-result-restart/data-model.md @@ -0,0 +1,91 @@ +# Data Model: Result, Restart & Final Validation + +## Entities + +### Room (updated) + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Unique 4-char uppercase alphanumeric room identifier | +| `status` | `RoomStatus` | Current room status: `"lobby"` \| `"playing"` \| `"finished"` | +| `hostId` | `string` | Participant ID of the room creator (host) | +| `participants` | `Participant[]` | List of participants in the room | +| `currentDrawerId` | `string \| null` | Participant ID of the current drawer; cleared on restart | +| `secretWord` | `string \| null` | Secret word for the round; cleared on restart; revealed to all when finished | +| `guessHistory` | `Guess[]` | Chronological list of guess submissions; cleared on restart | +| `canvasStrokes` | `Stroke[]` | Drawing strokes; cleared on restart | +| `createdAt` | `string` | ISO 8601 timestamp of room creation | +| `updatedAt` | `string` | ISO 8601 timestamp of last update | + +**Validation Rules (added)**: +- Status transitions: `"playing" → "finished"` (existing) → `"finished" → "lobby"` (new: on restart) +- `status` MUST NOT transition from `"lobby"` directly to `"finished"` (must go through `"playing"`) +- On restart, `guessHistory`, `canvasStrokes`, `currentDrawerId`, and `secretWord` MUST be reset to initial/empty values +- On restart, `participants` array MUST NOT be modified + +### Participant (unchanged from previous slices) + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | UUID v4 unique participant identifier | +| `name` | `string` | Display name | +| `joinedAt` | `string` | ISO 8601 timestamp of join | + +### Guess (unchanged) + +| Field | Type | Description | +|-------|------|-------------| +| `participantId` | `string` | UUID of the guesser | +| `name` | `string` | Display name of the guesser | +| `text` | `string` | The trimmed guess text | +| `isCorrect` | `boolean` | Whether the guess matched the secret word | +| `timestamp` | `string` | ISO 8601 timestamp of submission | + +### Stroke (unchanged) + +| Field | Type | Description | +|-------|------|-------------| +| `points` | `Point[]` | Array of points comprising the stroke | +| `color` | `string` | Stroke color | +| `width` | `number` | Stroke width | + +### RoomSnapshot (updated) + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` | Room code | +| `status` | `RoomStatus` | Current status | +| `hostId` | `string` | Host participant ID | +| `participants` | `Participant[]` | Participant list | +| `currentDrawerId` | `string \| null` | Drawer participant ID | +| `secretWord` | `string \| null` | Secret word: shown to all when `finished`, only to drawer when `playing`, null when `lobby` | +| `guessHistory` | `Guess[]` | All guesses (shown to all participants) | +| `scores` | `Record` | Computed scores (shown to all participants) | +| `canvasStrokes` | `Stroke[]` | Current canvas strokes | +| `availableWords` | `string[]` | Starter word list | +| `roles` | `ParticipantRole[]` | Available role values | + +## State Transitions + +``` +[Room Created] ──→ lobby + │ + │ (host starts game, ≥2 players, names valid) + ↓ + playing (drawer assigned, secret word selected) + │ + │ (all guessers guessed correctly) + ↓ + finished (word revealed to all, final scores displayed) + │ + │ (host restarts — NEW TRANSITION) + ↓ + lobby (players preserved, round state cleared) +``` + +## Relationships + +- **Room** 1───* **Participant**: Room contains 0-4 participants (unchanged) +- **Room** 1───* **Guess**: Room has a guess history (cleared on restart) +- **Room** 1───* **Stroke**: Room has a canvas stroke collection (cleared on restart) +- Rooms are fully isolated — no cross-room relationships diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 00000000..5857e8ca --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,94 @@ +# Implementation Plan: Result, Restart & Final Validation + +**Branch**: `004-result-restart` | **Date**: 2026-06-11 | **Spec**: `specs/004-result-restart/spec.md` + +**Input**: Feature specification from `/specs/004-result-restart/spec.md` + +## Summary + +When the game ends (all guessers correct), all participants see the secret word, final scores, full guess history, and final canvas. The host can restart the game — players are preserved but round state (guesses, canvas, scores, drawer, word) is cleared, returning everyone to the lobby for a new round. + +## Technical Context + +**Language/Version**: TypeScript 5.x (Node.js 18+ backend, browser ESM via Vite frontend) + +**Primary Dependencies**: Express 4 + Zod (backend), React 18 + React Router 6 (frontend) + +**Storage**: In-memory `Map` on backend — no database + +**Testing**: Vitest (both backend and frontend) + +**Target Platform**: Node.js server (backend), browser (frontend) + +**Project Type**: Web application (monorepo: `backend/` + `frontend/`) + +**Performance Goals**: ~2s poll cycle; restart response <500ms + +**Constraints**: No WebSockets, no databases, no auth, single round per game session, max 4 participants, no drawer rotation + +**Scale/Scope**: Max 4 participants per room, restart returns to lobby with players preserved + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Principle I (SOLID Architecture)**: Not violated. Adding a single `restartGame` function to `roomStore.ts` and a REST endpoint to `rooms.ts`. Existing `toRoomSnapshot` gains one condition for secret word reveal. Single-responsibility additions. + +**Principle II (Type Safety & Contract Discipline)**: Not violated. No new types needed; existing `Room` fields are reused with modified visibility rules. + +**Principle III (HTTP Polling Protocol)**: Not violated. Result display and status transition propagate via existing `GET /rooms/:code` polling. No push protocol. + +**Principle IV (In-Memory State Discipline)**: Not violated. Restart clears in-memory fields on the existing Room object. No persistence. + +**Principle V (Determinism & Testability)**: Not violated. Restart is a pure state reset; word reveal on finished is a deterministic condition. Testable without a running server. + +**Forbidden items check**: None violated. No WebSockets, databases, auth, multi-round features, timers, or other forbidden items. + +**GATE: PASS** — No violations detected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/004-result-restart/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── api.md # Phase 1 output +└── spec.md # Feature specification +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── services/ + │ └── roomStore.ts # Add restartGame function + │ # Update toRoomSnapshot: reveal secretWord when finished + ├── api/ + │ ├── rooms.ts # Add POST /rooms/:code/restart route + │ └── schemas.ts # Unchanged (existing schemas cover restart) + +frontend/ +└── src/ + ├── services/ + │ └── api.ts # Add restartGame method + ├── state/ + │ └── roomStore.ts # Add restartGame action + ├── pages/ + │ └── GamePage.tsx # Update finished view: reveal word to all, add restart button (host only) + │ └── LobbyPage.tsx # Handle lobby after restart (mostly works already; ensure start button visible) + └── components/ + └── ResultPanel.tsx # Unchanged (already shows guess history) + └── Scoreboard.tsx # Unchanged (already shows scores) +``` + +**Structure Decision**: Option 2 — Web application (frontend + backend). Mono-repo layout already established. + +## Complexity Tracking + +No constitution violations to justify. diff --git a/specs/004-result-restart/quickstart.md b/specs/004-result-restart/quickstart.md new file mode 100644 index 00000000..79d07879 --- /dev/null +++ b/specs/004-result-restart/quickstart.md @@ -0,0 +1,63 @@ +# Quickstart Validation: Result, Restart & Final Validation + +## Prerequisites + +- Backend running on `http://localhost:3001` (`cd backend && npm run dev`) +- Frontend running on `http://localhost:5173` (`cd frontend && npm run dev`) + +## Scenario 1: Final Results Display + +1. Start a game with 3 players (1 drawer + 2 guessers) as per Slice 2/Slice 3 quickstart +2. Have both guessers submit correct guesses until the game ends + +**Expected**: +- Within one poll cycle of the last correct guess, all players see: + - Game status as "finished" + - Secret word revealed to ALL players (not just the drawer) + - Final scores for each player + - Full guess history with names, texts, correct/incorrect markers + - Final canvas drawing + +## Scenario 2: Host Restarts Game + +1. From Scenario 1 (game finished), verify the host sees a "Play Again" or "Restart" option +2. Non-host participants do NOT see the restart option +3. Host clicks restart + +**Expected**: +- Within one poll cycle: + - All players see the lobby with the same room code + - All players are still in the room + - Round state is cleared: no guess history, no canvas, no scores, no drawer, no secret word + - The host sees the "Start Game" button again + +## Scenario 3: New Game After Restart + +1. From Scenario 2 (restarted, lobby visible), host clicks "Start Game" + +**Expected**: +- Game starts as normal (following S2 game start flow) +- New secret word assigned +- New drawer assigned (host) +- All players transition to playing state +- No leftover state from the previous round + +## Scenario 4: Non-Host Cannot Restart + +1. From Scenario 1 (game finished), verify the non-host participant: + - Does NOT see a restart button + - If they attempt to call the restart API directly, receives 403 + +## Scenario 5: Restart Only Works From Finished State + +1. Attempt to call `/rooms/:code/restart` when room status is not "finished" + +**Expected**: Error 400 "Game is not finished" + +## Data Model Reference + +See [data-model.md](../data-model.md) for entity definitions. + +## API Contract Reference + +See [contracts/api.md](../contracts/api.md) for endpoint details. diff --git a/specs/004-result-restart/research.md b/specs/004-result-restart/research.md new file mode 100644 index 00000000..26982aaa --- /dev/null +++ b/specs/004-result-restart/research.md @@ -0,0 +1,33 @@ +# Research: Result, Restart & Final Validation + +## Technical Decisions + +### Secret Word Reveal Strategy +- **Decision**: Modify `toRoomSnapshot` to always include `secretWord` when `room.status === "finished"`, regardless of viewer identity. +- **Rationale**: Once the game ends, there is no reason to hide the secret word. All participants should see it. This is a single condition check in the snapshot function. +- **Alternatives considered**: Create a separate field `revealedWord` — unnecessary duplication; `secretWord` already exists and can be conditionally exposed. + +### Restart API Design +- **Decision**: Add `POST /rooms/:code/restart` endpoint. Only the host can call it. It resets room state and returns the updated room with status "lobby". +- **Rationale**: Single atomic endpoint mirrors the existing `POST /rooms/:code/start` pattern. Consistent with host-only authorization model. +- **Alternatives considered**: Reuse `POST /rooms/:code/start` from finished state — would overload the start endpoint with two distinct behaviors; separate endpoint is cleaner. + +### State Reset on Restart +- **Decision**: On restart, set: `guessHistory = []`, `canvasStrokes = []`, `currentDrawerId = null`, `secretWord = null`, `status = "lobby"`. Participants array is untouched. +- **Rationale**: Participants are preserved per spec. Scores are derived from guessHistory so they auto-clear. Drawer and word must be unset so the next `startGame` flow can assign new values deterministically. +- **Alternatives considered**: Reset all fields including participants — violates FR-006 (players preserved); set scores to zero map — unnecessary since scores derive from history. + +### Finished State Secret Word in Snapshot +- **Decision**: In `toRoomSnapshot`, when `status === "finished"`, always include `secretWord` (ignore `viewerParticipantId` check). +- **Rationale**: Simplest way to satisfy FR-001. Single condition that overrides the drawer-only visibility rule when the game is over. +- **Alternatives considered**: Store a separate `revealedSecretWord` field — introduces redundant state that must be kept in sync. + +### Frontend Restart Handling +- **Decision**: In the GamePage "Game Over" view, show a "Play Again" button for the host only. On click, call the restart API. Navigate to lobby page on success. +- **Rationale**: Host initiates restart and all players follow via polling. Navigating to lobby gives consistent experience. Non-host players see the "Game Over" view until their next poll picks up the "lobby" status change. +- **Alternatives considered**: Show inline countdown — adds complexity; auto-navigate — confusing if user is reading results. + +### Lobby Page After Restart +- **Decision**: The existing LobbyPage handles lobby status already. The Start Game button will become visible again (host-only, as before). No special "restarted" banner needed — the cleared state is self-evident. +- **Rationale**: The lobby page already polls and renders for "lobby" status. After restart, the room status transitions back to "lobby", so the existing UI handles it naturally. +- **Alternatives considered**: Add a "Round 2" indicator — unnecessary for first version; keep scores from previous round — violates spec (scores should reset). diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 00000000..c51cdcaa --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Result, Restart & Final Validation + +**Feature Branch**: `004-result-restart` + +**Created**: 2026-06-11 + +**Status**: Draft + +**Input**: User description: "Slice 4 — Result, Restart & Final Validation. Given a round has ended, When the result state is displayed and the host restarts, Then all players see the correct word, final scores, and full guess history; on restart, everyone returns to the lobby with players preserved and all round state cleared." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View Final Results (Priority: P1) + +As any player, when the game has finished (all guessers guessed correctly), I want to see the secret word, final scores, and full guess history so that I can see how everyone performed. + +**Why this priority**: Core interaction — without displaying results, players have no closure and cannot verify the correct answer. + +**Independent Test**: Can be tested by starting a game, playing until all guessers guess correctly, and verifying the game-over state shows the secret word, each player's final score, and all guesses made during the round. + +**Acceptance Scenarios**: + +1. **Given** the game status is "finished", **When** any participant views the game state, **Then** they see the secret word that was assigned at game start +2. **Given** the game status is "finished", **When** any participant views the game state, **Then** they see each participant's final score (0 + 100 for each correct guess) +3. **Given** the game status is "finished", **When** any participant views the game state, **Then** they see the full guess history including each guesser's name, their submitted text, and whether it was correct or incorrect +4. **Given** the game status is "finished", **When** a participant views the game state, **Then** they see the final state of the canvas (drawing) + +--- + +### User Story 2 - Host Restarts the Game (Priority: P1) + +As the host, when the game has finished, I want to restart the game so that all participants can play another round without recreating the room. + +**Why this priority**: Core interaction — without restart, players would have to leave and create a new room each time, severely limiting replayability. + +**Independent Test**: Can be tested by finishing a game as the host, clicking restart, and verifying all participants return to the lobby with players preserved and round state cleared. + +**Acceptance Scenarios**: + +1. **Given** the game status is "finished" and I am the host, **When** I click restart, **Then** all participants see the lobby with the same room code and player list +2. **Given** the game status is "finished" and I am NOT the host, **When** I attempt to restart, **Then** the restart action is rejected +3. **Given** a game has been restarted, **When** participants view the lobby, **Then** the guess history, canvas drawings, scores, drawer assignment, and secret word are all cleared +4. **Given** a game has been restarted, **When** the host starts a new game from the lobby, **Then** a new round begins with a new secret word and drawer assignment (following the existing game start flow) + +--- + +### Edge Cases + +- What happens when the host restarts and a new player joins the lobby before the next game starts? — Same as Slice 1 lobby flow; new player can join if there is space (max 4) +- What happens if the host closes their browser after the game ends? — The game stays finished; no restart is possible without the host. Other players see the results but cannot restart. +- What happens if a player disconnects during the results screen? — Results persist in memory; if they reconnect (via polling), they see the same results. +- What happens to the canvas drawing when returning to lobby? — Canvas state is cleared on restart; the lobby shows no drawing. +- Can the host restart multiple times? — Yes, there is no limit on number of restarts within a session. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST reveal the secret word to all participants when the game status is "finished" +- **FR-002**: The system MUST display each participant's final score (0 + 100 per correct guess) when the game status is "finished" +- **FR-003**: The system MUST display the full guess history (guesser name, guess text, correct/incorrect) when the game status is "finished" +- **FR-004**: The system MUST display the final canvas state when the game status is "finished" +- **FR-005**: Only the host MUST be able to restart a finished game +- **FR-006**: On restart, all existing participants MUST remain in the room (players preserved) +- **FR-007**: On restart, the room code MUST remain unchanged +- **FR-008**: On restart, round-specific state MUST be cleared: guess history, canvas strokes, scores, drawer assignment, and secret word +- **FR-009**: On restart, the room status MUST transition to "lobby" +- **FR-010**: After restart, the host MUST be able to start a new game from the lobby, following the existing game start flow (name validation, drawer assignment, word selection) + +### Key Entities *(include if feature involves data)* + +- **Game Room**: Holds game status, participant list, and round state. On restart, round-specific fields are cleared but the room and participants persist. +- **Room Status**: Transitions "playing" → "finished" (existing) → "lobby" (new transition on restart). +- **Results View**: The finished game state includes the secret word (now visible to all), final scores (derived from guess history), full guess history, and final canvas state. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +*Poll interval is ~2 seconds (standard frontend polling rate).* + +- **SC-001**: Within one poll cycle of the game ending, all participants see the secret word, final scores, and full guess history +- **SC-002**: Only the host can trigger the restart action; non-host participants see the restart option disabled or hidden +- **SC-003**: On restart, all participants see the lobby with the same room code and the same player list within one poll cycle +- **SC-004**: After restart, round-specific state (drawing, guesses, scores, drawer assignment, secret word) is cleared for all participants within one poll cycle +- **SC-005**: A completed game can be restarted and a new game started and played to completion without errors + +## Assumptions + +- Only the host can restart (same privilege model as starting the game) +- Restart does not create a new room; the same room code and participants persist +- The host can restart as many times as desired +- If the host disconnects after game end, no restart is possible without them +- Players who join after restart follow the existing lobby join flow (max 4 participants) +- The canvas is cleared on restart (not preserved for the next round) +- Scores reset to 0 on restart (each round is independent) +- Manual restart by the host does not constitute "multiple rounds" as defined by the architecture constraints (which refer to automated drawer rotation and round progression); each restart is an explicit host action that resets all state to the lobby, and a new game must be started manually diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 00000000..1be78cba --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,154 @@ +# Tasks: Result, Restart & Final Validation + +**Input**: Design documents from `/specs/004-result-restart/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Not requested — no test tasks generated. + +**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 + +- **Web app**: `backend/src/`, `frontend/src/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Review existing structure before making changes + +- [x] T001 Review current RoomSnapshot secretWord visibility logic in `backend/src/services/roomStore.ts` (toRoomSnapshot) and finished game state in `frontend/src/pages/GamePage.tsx` to understand extension points + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core model changes that MUST be complete before any user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- (none needed — no new types or model changes required for this slice) + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 - View Final Results (Priority: P1) 🎯 MVP + +**Goal**: When the game is finished, all participants see the secret word, final scores, full guess history, and final canvas. This builds on the existing "Game Over" view from Slice 3. + +**Independent Test**: Start a game, play until all guessers guess correctly, and verify all participants see the secret word, final scores, and guess history. + +### Implementation for User Story 1 + +- [ ] T002 [P] [US1] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts` to always include `secretWord` when `room.status === "finished"` (regardless of viewer identity) +- [ ] T003 [P] [US1] Update `frontend/src/pages/GamePage.tsx` to show the secret word to ALL participants in the "finished" view (currently only shown to drawer) + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - Host Restarts the Game (Priority: P1) 🎯 MVP + +**Goal**: The host can restart a finished game. All participants return to the lobby with the same room code and players, but round state (guesses, canvas, scores, drawer, word) is cleared. + +**Independent Test**: Finish a game as the host, click restart, and verify all participants see the lobby with the same room code, same players, and no round state. + +### Implementation for User Story 2 + +- [ ] T004 [P] [US2] Add `restartGame` function in `backend/src/services/roomStore.ts` — validates room exists, requester is host, game is finished; resets `guessHistory`, `canvasStrokes`, `currentDrawerId`, `secretWord`; sets `status` to "lobby"; returns updated room +- [ ] T005 [US2] Add `POST /rooms/:code/restart` route in `backend/src/api/rooms.ts` — uses existing `roomViewerQuerySchema` and `roomCodeParamsSchema`; returns 400 if not finished, 403 if not host +- [ ] T006 [P] [US2] Add `restartGame` method to `api` object in `frontend/src/services/api.ts` +- [ ] T007 [US2] Add `restartGame` action in `frontend/src/state/roomStore.ts` +- [ ] T008 [US2] Update `frontend/src/pages/GamePage.tsx` — in the "finished" view: show a "Play Again" button for the host only (hidden/disabled for non-hosts); on click call `store.restartGame()`; on success navigate to `/lobby` +- [ ] T009 [US2] Verify `frontend/src/pages/LobbyPage.tsx` handles the lobby state after restart correctly: lobby shows the same room code and participant list; host sees the "Start Game" button; non-host participants do not see the "Start Game" button; no guess history, canvas, scores, drawer assignment, or secret word are displayed + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T010 Run build checks: `cd backend && npx tsc --noEmit` and `cd frontend && npx tsc --noEmit` to verify no type errors +- [ ] T011 Run quickstart.md validation scenarios + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies +- **Foundational (Phase 2)**: No blocking tasks +- **User Story 1 (Phase 3)**: Can start immediately after Setup +- **User Story 2 (Phase 4)**: Can start immediately after Setup — independent of US1 +- **Polish (Phase 5)**: Depends on Phase 3 and Phase 4 completion + +### User Story Dependencies + +- **User Story 1 (P1)**: Independent — updates snapshot and finished view +- **User Story 2 (P1)**: Independent — adds restart endpoint and frontend button + +### Within Each User Story + +- Backend before frontend (where applicable) + +### Parallel Opportunities + +- T002 and T003 — different files (roomStore.ts vs GamePage.tsx) +- T004 and T006 — different files (roomStore.ts vs api.ts) +- T003 and T008 — same file (GamePage.tsx), sequential +- T004 and T005 — same file cluster but diff concerns, sequential recommended + +--- + +## Parallel Example: Phase 3 (User Story 1) + +```bash +# Launch both US1 tasks together: +Task: "T002 [US1] Update toRoomSnapshot in backend/src/services/roomStore.ts" +Task: "T003 [US1] Update GamePage.tsx finished view in frontend/src/pages/GamePage.tsx" +``` + +## Parallel Example: Phase 4 (User Story 2) + +```bash +# Launch independent tasks together: +Task: "T004 [P] [US2] Add restartGame in backend/src/services/roomStore.ts" +Task: "T006 [P] [US2] Add restartGame method in frontend/src/services/api.ts" + +# After backend and API tasks complete: +Task: "T005 [US2] Add POST /rooms/:code/restart route in backend/src/api/rooms.ts" +Task: "T007 [US2] Add restartGame action in frontend/src/state/roomStore.ts" + +# After store task completes: +Task: "T008 [US2] Update GamePage.tsx finished view with restart button" +``` + +--- + +## Implementation Strategy + +### MVP First (Both User Stories) + +1. Complete Phase 1: Setup +2. Complete Phase 3: User Story 1 (reveal word on finished) +3. Complete Phase 4: User Story 2 (restart flow) +4. **STOP and VALIDATE**: Run quickstart.md scenarios +5. Complete Phase 5: Polish + +### Incremental Delivery + +1. Complete Setup → Ready +2. Add User Story 1 → Final results testable independently (secret word revealed to all) +3. Add User Story 2 → Restart flow testable independently (host restarts, lobby visible) +4. Polish → Build passes and validation scenarios pass