From d8e8f2da4fb20a3203cb77a6f29ed8c64becd623 Mon Sep 17 00:00:00 2001 From: Swarup Mahapatra Date: Sat, 30 May 2026 07:41:09 +0530 Subject: [PATCH 1/7] Project initialization --- .gitignore | 2 + .specify/extensions.yml | 149 ++++ .specify/extensions/.registry | 23 + .specify/extensions/git/README.md | 100 +++ .../git/commands/speckit.git.commit.md | 48 ++ .../git/commands/speckit.git.feature.md | 67 ++ .../git/commands/speckit.git.initialize.md | 49 ++ .../git/commands/speckit.git.remote.md | 45 ++ .../git/commands/speckit.git.validate.md | 49 ++ .specify/extensions/git/config-template.yml | 62 ++ .specify/extensions/git/extension.yml | 140 ++++ .specify/extensions/git/git-config.yml | 62 ++ .../git/scripts/bash/auto-commit.sh | 140 ++++ .../git/scripts/bash/create-new-feature.sh | 453 ++++++++++++ .../extensions/git/scripts/bash/git-common.sh | 54 ++ .../git/scripts/bash/initialize-repo.sh | 54 ++ .../git/scripts/powershell/auto-commit.ps1 | 169 +++++ .../scripts/powershell/create-new-feature.ps1 | 403 +++++++++++ .../git/scripts/powershell/git-common.ps1 | 51 ++ .../scripts/powershell/initialize-repo.ps1 | 69 ++ .specify/init-options.json | 10 + .specify/integration.json | 15 + .specify/integrations/claude.manifest.json | 16 + .specify/integrations/speckit.manifest.json | 17 + .specify/memory/constitution.md | 111 +++ .specify/scripts/bash/check-prerequisites.sh | 192 ++++++ .specify/scripts/bash/common.sh | 645 ++++++++++++++++++ .specify/scripts/bash/create-new-feature.sh | 413 +++++++++++ .specify/scripts/bash/setup-plan.sh | 91 +++ .specify/scripts/bash/setup-tasks.sh | 96 +++ .specify/templates/checklist-template.md | 40 ++ .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 113 +++ .specify/templates/spec-template.md | 131 ++++ .specify/templates/tasks-template.md | 252 +++++++ .specify/workflows/speckit/workflow.yml | 77 +++ .specify/workflows/workflow-registry.json | 13 + CLAUDE.md | 4 + backend/package-lock.json | 3 - frontend/package-lock.json | 10 - 40 files changed, 4475 insertions(+), 13 deletions(-) create mode 100644 .specify/extensions.yml create mode 100644 .specify/extensions/.registry create mode 100644 .specify/extensions/git/README.md create mode 100644 .specify/extensions/git/commands/speckit.git.commit.md create mode 100644 .specify/extensions/git/commands/speckit.git.feature.md create mode 100644 .specify/extensions/git/commands/speckit.git.initialize.md create mode 100644 .specify/extensions/git/commands/speckit.git.remote.md create mode 100644 .specify/extensions/git/commands/speckit.git.validate.md create mode 100644 .specify/extensions/git/config-template.yml create mode 100644 .specify/extensions/git/extension.yml create mode 100644 .specify/extensions/git/git-config.yml create mode 100755 .specify/extensions/git/scripts/bash/auto-commit.sh create mode 100755 .specify/extensions/git/scripts/bash/create-new-feature.sh create mode 100755 .specify/extensions/git/scripts/bash/git-common.sh create mode 100755 .specify/extensions/git/scripts/bash/initialize-repo.sh create mode 100644 .specify/extensions/git/scripts/powershell/auto-commit.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/create-new-feature.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/git-common.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/initialize-repo.ps1 create mode 100644 .specify/init-options.json create mode 100644 .specify/integration.json create mode 100644 .specify/integrations/claude.manifest.json create mode 100644 .specify/integrations/speckit.manifest.json create mode 100644 .specify/memory/constitution.md create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/setup-tasks.sh create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md create mode 100644 .specify/workflows/speckit/workflow.yml create mode 100644 .specify/workflows/workflow-registry.json create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 75a55bc1..66fc4be9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ precheck-results/ .vscode/ .vite/ + +.claude \ No newline at end of file diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 00000000..42dd0a91 --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,149 @@ +installed: +- 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 + 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 + 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..e07357ba --- /dev/null +++ b/.specify/extensions/.registry @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "extensions": { + "git": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79", + "enabled": true, + "priority": 10, + "registered_commands": { + "claude": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-05-30T01:52:26.160135+00:00" + } + } +} \ No newline at end of file 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/init-options.json b/.specify/init-options.json new file mode 100644 index 00000000..3a66e854 --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,10 @@ +{ + "ai": "claude", + "ai_skills": true, + "branch_numbering": "sequential", + "context_file": "CLAUDE.md", + "here": true, + "integration": "claude", + "script": "sh", + "speckit_version": "0.8.17.dev0" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 00000000..350e96cb --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.8.17.dev0", + "integration_state_schema": 1, + "installed_integrations": [ + "claude" + ], + "integration_settings": { + "claude": { + "script": "sh", + "invoke_separator": "-" + } + }, + "integration": "claude", + "default_integration": "claude" +} diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json new file mode 100644 index 00000000..16ae0faa --- /dev/null +++ b/.specify/integrations/claude.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "claude", + "version": "0.8.17.dev0", + "installed_at": "2026-05-30T01:52:26.061105+00:00", + "files": { + ".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90", + ".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977", + ".claude/skills/speckit-clarify/SKILL.md": "35795a017d6d2ed3ace35a333b22e450788e539e24ed5c756d815aa34cd5b6f5", + ".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15", + ".claude/skills/speckit-implement/SKILL.md": "042a0415ce60a5b66adf039da431d9a98cb4897ff330635e0bab0becbbaf3cd2", + ".claude/skills/speckit-plan/SKILL.md": "bad923f08ffd0e61e37da038626bff374386541fb4c69858a5473d6dc302ee17", + ".claude/skills/speckit-specify/SKILL.md": "4cb1cb21f598288859e84cbf29052c1c41f213a2ca42b79b3a6d6293dde99d02", + ".claude/skills/speckit-tasks/SKILL.md": "ca048fadbe29d761c05b497605789656d112a3a9db5f774efcf84503e77a684e", + ".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 00000000..ac9d4274 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "speckit", + "version": "0.8.17.dev0", + "installed_at": "2026-05-30T01:52:26.079208+00:00", + "files": { + ".specify/scripts/bash/common.sh": "dd638316259e699fd466542c77ef16af5eb198efe0447c081f86b890db414ba8", + ".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": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c", + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7" + } +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..22d8c2e6 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,111 @@ + + +# Scribble Assignment Constitution + +## Core Principles + +### I. Brownfield-First + +This project MUST be enhanced incrementally on top of the existing scaffold. AI and developers MUST +read and understand the relevant starter files before proposing any change. Rewriting the starter +from scratch is prohibited. New code MUST integrate with existing routing, components, and API +patterns rather than replacing them. Every change MUST be explainable by reference to a spec +artifact. + +### II. Spec-Driven Development + +No feature code MAY be written without a corresponding spec artifact (spec.md, plan.md, tasks.md). +Acceptance criteria MUST be defined before implementation begins. Deviations between code behavior +and the spec MUST be documented explicitly. AI-generated output MUST be reviewed and verified +against the spec before committing. Complete a minimum of 4 specify iterations across the lab. + +### III. Deterministic Game Rules + +Game mechanics MUST produce consistent, testable outcomes: +- Word selection MUST be deterministic (from the starter seed list; no random or external packs). +- Drawer assignment MUST follow a defined rule (host or first player for the first round). +- Scoring MUST be fixed: correct guess = 100 points, incorrect guess = 0 points. +- Guess comparison MUST be case-insensitive and trim whitespace. +- Empty or whitespace-only player names and guesses MUST be rejected with a clear message. + +Non-determinism (timers, countdowns, rotation across multiple rounds) is out of scope and MUST NOT +be introduced. + +### IV. Incremental Validation + +Each user story slice MUST be independently testable before the next slice begins. Validation MUST +use two real browser tabs to confirm multi-player behavior. Polling intervals MUST be approximately +2 seconds (lobby and guess-history sync). No story is complete until its acceptance scenarios from +the spec pass in the browser. Commits MUST be granular and map to individual tasks or logical +groups. + +### V. Simplicity and Scope Discipline + +YAGNI applies strictly. The following are permanently out of scope and MUST NOT appear in spec, +plan, tasks, or code: +- WebSockets, real-time sync, databases, auth, sessions, deployment, Docker +- Multiple rounds, timers, countdowns, speed/drawer bonuses, drawer rotation +- Custom/random word packs, spectator mode, moderation, room passwords +- New state-management or routing libraries beyond the starter +- Unrelated refactors or unjustified top-level dependencies + +Complexity MUST be justified in the plan's Complexity Tracking table before implementation. When +in doubt, choose the simpler option. + +## Coding Standards & AI Usage + +- **Language/Stack**: TypeScript throughout. Frontend: Vite + React + TypeScript. Backend: Node.js + + Express + TypeScript. In-memory store only — no database. +- **Type safety**: TypeScript strict mode; `any` is prohibited unless explicitly justified. +- **File placement**: New backend code in `backend/src/`; new frontend code in `frontend/src/`. + Follow the existing directory conventions (`models/`, `services/`, `api/`, `components/`, `pages/`). +- **AI usage rules**: AI-generated code MUST be read, understood, and verified by the developer + before committing. AI MUST NOT be allowed to commit directly without human review. Prompts MUST + reference specific spec acceptance criteria. +- **Commits**: Every commit MUST be traceable to a task ID and MUST reference the relevant spec + scenario. Vague commit messages (e.g., "fix stuff") are not acceptable. +- **Out-of-scope detection**: If an AI assistant suggests out-of-scope work (WebSockets, auth, + databases), reject and redirect it to the in-scope spec. + +## Testing & Self-Review + +- **Browser validation**: Every implemented story MUST be verified with two browser tabs before + marking it complete. The Quick Verification steps in README.md serve as the baseline smoke test. +- **Edge cases**: Validation MUST cover empty input, whitespace-only input, invalid room codes, + multi-room isolation, and case-insensitive guess matching. +- **Build gates**: Both `backend/npm run build` and `frontend/npm run build` MUST pass before + raising a pull request. +- **Self-review**: The developer MUST review the PR diff against the spec and plan before requesting + review. Implementation alignment (code matches spec) is a graded rubric item. +- **Test scope**: Automated tests are optional for this lab. When included, they MUST target the + API contract and integration paths, not mocked internals. + +## Governance + +This constitution supersedes all other informal practices for this project. Amendments require: +1. A documented rationale for the change. +2. An update to this file with a version bump (MAJOR for principle removal/redefinition, MINOR for + new principle/section, PATCH for clarifications). +3. A corresponding update to any affected templates under `.specify/templates/`. + +All pull requests MUST be checked against this constitution before merging. Complexity MUST be +justified; scope creep MUST be flagged. Use `README.md` and `.specify/memory/` for runtime +development guidance. + +**Version**: 1.0.0 | **Ratified**: 2026-05-30 | **Last Amended**: 2026-05-30 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..03141e44 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,645 @@ +#!/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 +} + +# 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..c4aa1666 --- /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..92b96c71 --- /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..d46a1f1f --- /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..6f2ac36e --- /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-05-30T01:52:26.174078+00:00", + "updated_at": "2026-05-30T01:52:26.174086+00:00" + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7e6443a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan + 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/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", From d1359d9dbeb362eabe1a01f5fb50fdd3335aaf21 Mon Sep 17 00:00:00 2001 From: Swarup Mahapatra Date: Sat, 30 May 2026 08:10:49 +0530 Subject: [PATCH 2/7] Add game room lobby --- .specify/feature.json | 3 + CLAUDE.md | 1 + backend/src/api/rooms.ts | 23 +- backend/src/api/schemas.test.ts | 84 ++++++- backend/src/api/schemas.ts | 10 +- backend/src/models/game.ts | 4 +- backend/src/services/roomStore.test.ts | 71 +++++- backend/src/services/roomStore.ts | 27 +++ frontend/src/pages/CreateRoomPage.tsx | 7 +- frontend/src/pages/JoinRoomPage.tsx | 19 +- frontend/src/pages/LobbyPage.tsx | 76 ++++-- frontend/src/services/api.test.ts | 40 ++- frontend/src/services/api.ts | 11 +- frontend/src/state/roomStore.ts | 12 + .../checklists/requirements.md | 37 +++ specs/002-game-room-lobby/contracts/api.md | 183 ++++++++++++++ specs/002-game-room-lobby/data-model.md | 105 ++++++++ specs/002-game-room-lobby/plan.md | 86 +++++++ specs/002-game-room-lobby/research.md | 59 +++++ specs/002-game-room-lobby/spec.md | 132 ++++++++++ specs/002-game-room-lobby/tasks.md | 229 ++++++++++++++++++ 21 files changed, 1169 insertions(+), 50 deletions(-) create mode 100644 .specify/feature.json create mode 100644 specs/002-game-room-lobby/checklists/requirements.md create mode 100644 specs/002-game-room-lobby/contracts/api.md create mode 100644 specs/002-game-room-lobby/data-model.md create mode 100644 specs/002-game-room-lobby/plan.md create mode 100644 specs/002-game-room-lobby/research.md create mode 100644 specs/002-game-room-lobby/spec.md create mode 100644 specs/002-game-room-lobby/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 00000000..c08591c9 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/002-game-room-lobby" +} diff --git a/CLAUDE.md b/CLAUDE.md index 7e6443a1..fec516d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan +at specs/002-game-room-lobby/plan.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..1fa511e5 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,10 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startRoomBodySchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -62,5 +63,23 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startRoomBodySchema.parse(request.body); + const result = startRoom(code.toUpperCase(), participantId); + + if ("error" in result) { + if (result.error === "not_found") throw new HttpError(404, "Room not found"); + if (result.error === "forbidden") throw new HttpError(403, "Only the host can start the game"); + if (result.error === "not_enough_players") throw new HttpError(409, "At least 2 players are required to start the game"); + } + + response.json({ room: (result as { room: ReturnType }).room }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea3..c45b8ae2 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,14 +1,86 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { createRoomSchema, joinRoomSchema, roomCodeParamsSchema, startRoomBodySchema } from "./schemas.js"; describe("schemas", () => { - it("createRoomSchema accepts a valid body with playerName", () => { - const result = createRoomSchema.parse({ playerName: "Alice" }); + describe("createRoomSchema", () => { + it("accepts a valid playerName", () => { + const result = createRoomSchema.parse({ playerName: "Alice" }); + expect(result.playerName).toBe("Alice"); + }); - expect(result.playerName).toBe("Alice"); + it("trims whitespace from playerName", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); + expect(result.playerName).toBe("Alice"); + }); + + it("rejects an empty playerName", () => { + expect(() => createRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("rejects a whitespace-only playerName", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow(); + }); + + it("rejects missing playerName", () => { + expect(() => createRoomSchema.parse({})).toThrow(); + }); + }); + + describe("joinRoomSchema", () => { + it("accepts a valid playerName", () => { + const result = joinRoomSchema.parse({ playerName: "Bob" }); + expect(result.playerName).toBe("Bob"); + }); + + it("rejects an empty playerName", () => { + expect(() => joinRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("rejects missing playerName", () => { + expect(() => joinRoomSchema.parse({})).toThrow(); + }); + }); + + describe("roomCodeParamsSchema", () => { + it("accepts a valid 4-char code", () => { + const result = roomCodeParamsSchema.parse({ code: "AB2C" }); + expect(result.code).toBe("AB2C"); + }); + + it("rejects missing code", () => { + expect(() => roomCodeParamsSchema.parse({})).toThrow(); + }); + + it("rejects a code shorter than 4 characters", () => { + expect(() => roomCodeParamsSchema.parse({ code: "ABC" })).toThrow(); + }); + + it("rejects a code longer than 4 characters", () => { + expect(() => roomCodeParamsSchema.parse({ code: "ABCDE" })).toThrow(); + }); + + it("rejects lowercase characters", () => { + expect(() => roomCodeParamsSchema.parse({ code: "ab2c" })).toThrow(); + }); + + it("rejects excluded characters (O, I, L, 1, 0)", () => { + expect(() => roomCodeParamsSchema.parse({ code: "ABO1" })).toThrow(); + }); }); - it("roomCodeParamsSchema rejects missing code", () => { - expect(() => roomCodeParamsSchema.parse({})).toThrow(); + describe("startRoomBodySchema", () => { + it("accepts a valid UUID participantId", () => { + const id = "123e4567-e89b-12d3-a456-426614174000"; + const result = startRoomBodySchema.parse({ participantId: id }); + expect(result.participantId).toBe(id); + }); + + it("rejects missing participantId", () => { + expect(() => startRoomBodySchema.parse({})).toThrow(); + }); + + it("rejects a non-UUID participantId", () => { + expect(() => startRoomBodySchema.parse({ participantId: "not-a-uuid" })).toThrow(); + }); }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..8bd7a07c 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,21 +1,25 @@ import { z } from "zod"; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name is required") }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name is required") }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: z.string().regex(/^[A-Z2-9]{4}$/, "Room code must be 4 uppercase characters") }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startRoomBodySchema = z.object({ + participantId: z.string().uuid("participantId must be a valid UUID") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..583f7595 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" | "active"; export interface Participant { id: string; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b..6cb340ca 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,70 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, startRoom } from "./roomStore.js"; describe("roomStore", () => { - it("createRoom returns a room with a 4-character uppercase code", () => { - const result = createRoom("Alice"); + describe("createRoom", () => { + it("returns a room with a 4-character uppercase code", () => { + const result = createRoom("Alice"); - expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); - expect(result.room.participants).toHaveLength(1); - expect(result.room.participants[0].name).toBe("Alice"); - expect(result.participantId).toBeDefined(); + expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); + expect(result.room.participants).toHaveLength(1); + expect(result.room.participants[0].name).toBe("Alice"); + expect(result.participantId).toBeDefined(); + }); + + it("sets hostId equal to the creator's participantId", () => { + const result = createRoom("Alice"); + + expect(result.room.hostId).toBe(result.participantId); + }); + + it("creates the room with status lobby", () => { + const result = createRoom("Alice"); + + expect(result.room.status).toBe("lobby"); + }); + }); + + describe("joinRoom", () => { + it("returns null for an unknown room code", () => { + const result = joinRoom("ZZZZ", "Bob"); + + expect(result).toBeNull(); + }); }); - it("joinRoom returns null for an unknown room code", () => { - const result = joinRoom("ZZZZ", "Bob"); + describe("startRoom", () => { + it("returns not_found for an unknown code", () => { + const result = startRoom("ZZZZ", "some-id"); + + expect(result).toEqual({ error: "not_found" }); + }); + + it("returns forbidden when caller is not the host", () => { + const { room } = createRoom("Alice"); + const result = startRoom(room.code, "not-the-host-id"); + + expect(result).toEqual({ error: "forbidden" }); + }); + + it("returns not_enough_players when only 1 participant", () => { + const { room, participantId } = createRoom("Alice"); + const result = startRoom(room.code, participantId); + + expect(result).toEqual({ error: "not_enough_players" }); + }); + + it("sets status to active when host starts with 2+ players", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const result = startRoom(room.code, participantId); - expect(result).toBeNull(); + expect("room" in result).toBe(true); + if ("room" in result) { + expect(result.room.status).toBe("active"); + expect(result.room.hostId).toBe(participantId); + } + }); }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..8a989be2 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -54,6 +54,7 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], createdAt: now(), updatedAt: now() @@ -96,12 +97,38 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +export function startRoom( + code: string, + requestingParticipantId: string +): { error: "not_found" | "forbidden" | "not_enough_players" } | { room: RoomSnapshot } { + const room = rooms.get(code); + + if (!room) { + return { error: "not_found" }; + } + + if (room.hostId !== requestingParticipantId) { + return { error: "forbidden" }; + } + + if (room.participants.length < 2) { + return { error: "not_enough_players" }; + } + + room.status = "active"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: toRoomSnapshot(cloneRoom(room)) }; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { void viewerParticipantId; return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..ab92a7a5 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,14 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!playerName.trim()) { + setError("Player name is required"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..bfdbd101 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,26 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!playerName.trim()) { + setError("Player name is required"); + return; + } + + const trimmedCode = roomCode.trim().toUpperCase(); + + if (!trimmedCode) { + setError("Room code is required"); + return; + } + + if (!/^[A-Z2-9]{4}$/.test(trimmedCode)) { + setError("Room code must be 4 uppercase characters (letters and numbers only, no O, I, L, or 1)"); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(trimmedCode, playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..dcfacf6e 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,15 +1,17 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef } 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_MS = 2000; + export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); + const { room, participantId, isLoading } = useRoomState(); + const navigatingRef = useRef(false); useEffect(() => { if (!room) { @@ -17,19 +19,41 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { - try { - setRefreshError(null); - await roomStore.fetchRoom(); - } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); - } - } + useEffect(() => { + if (!room) return; + + const intervalId = setInterval(async () => { + try { + const updated = await roomStore.fetchRoom(); + if (updated?.status === "active" && !navigatingRef.current) { + navigatingRef.current = true; + navigate("/game", { replace: true }); + } + } catch { + // swallow poll errors — a briefly stale list is acceptable + } + }, POLL_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [navigate, room, roomStore]); if (!room) { return null; } + const isHost = participantId === room.hostId; + const canStart = room.participants.length >= 2; + + async function handleStartGame() { + if (!room || !participantId) return; + try { + await roomStore.startRoom(); + navigate("/game", { replace: true }); + } catch { + // error is reflected in roomStore state + } + } + return (
@@ -50,7 +74,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostId ? "host" : "joined"} +
  • ))} @@ -61,17 +87,29 @@ export function LobbyPage() {

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

    -

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

    + {!canStart && isHost && ( +

    At least 2 players are needed to start.

    + )} + {!isHost && ( +

    Waiting for the host to start the game.

    + )}
    - - + {isHost ? ( + + ) : ( +

    + Waiting for host to start… +

    + )}
    ); diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 67601f5d..26cfc576 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; +const mockRoomSnapshot = { + code: "ABCD", + status: "lobby" as const, + hostId: "p1", + participants: [], + availableWords: [], + roles: [] +}; + describe("api service", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -12,8 +21,8 @@ describe("api service", () => { json: () => Promise.resolve({ participantId: "p1", - room: { code: "ABCD", status: "lobby", participants: [] }, - }), + room: mockRoomSnapshot + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -23,7 +32,7 @@ describe("api service", () => { expect.stringContaining("/rooms"), expect.objectContaining({ method: "POST", - body: JSON.stringify({ playerName: "Alice" }), + body: JSON.stringify({ playerName: "Alice" }) }) ); }); @@ -33,8 +42,8 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: { code: "XYZW", status: "lobby", participants: [] }, - }), + room: { ...mockRoomSnapshot, code: "XYZW" } + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -45,4 +54,25 @@ describe("api service", () => { expect.anything() ); }); + + it("startRoom sends POST to /rooms/:code/start with participantId", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { ...mockRoomSnapshot, status: "active" } + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.startRoom("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/start"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }) + }) + ); + }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..3a1e3f4c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,8 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "active"; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -19,7 +20,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 +58,11 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..523b48d7 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,18 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startRoom() { + if (!this.state.room || !this.state.participantId) { + return null; + } + + const response = await this.withLoading(() => + api.startRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); diff --git a/specs/002-game-room-lobby/checklists/requirements.md b/specs/002-game-room-lobby/checklists/requirements.md new file mode 100644 index 00000000..90630131 --- /dev/null +++ b/specs/002-game-room-lobby/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Game Room Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-30 +**Feature**: [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 12 functional requirements are covered by acceptance scenarios across 4 user stories. +- Edge cases section explicitly flags open questions (host departure, duplicate names, max players) that are addressed conservatively in Assumptions. +- Polling interval is specified as ~2 seconds per the original requirement; exact variance tolerance documented in Assumptions. +- Spec is ready to proceed to `/speckit-clarify` or `/speckit-plan`. diff --git a/specs/002-game-room-lobby/contracts/api.md b/specs/002-game-room-lobby/contracts/api.md new file mode 100644 index 00000000..7959b498 --- /dev/null +++ b/specs/002-game-room-lobby/contracts/api.md @@ -0,0 +1,183 @@ +# API Contract: Game Room Lobby + +**Branch**: `002-game-room-lobby` | **Date**: 2026-05-30 +**Base URL**: `http://localhost:3001` (backend Express server) + +All request and response bodies are JSON. All error responses follow the shape `{ "message": string }`. + +--- + +## Existing Endpoints (unchanged behaviour, schema tightened) + +### `POST /rooms` — Create Room + +Creates a new room. The calling player becomes the host. + +**Request body**: +```json +{ "playerName": "Alice" } +``` + +| Field | Type | Constraints | Required | +|--------------|----------|------------------------------------|----------| +| `playerName` | `string` | Non-empty, non-whitespace-only | Yes ✱ | + +> ✱ **Change from starter**: `playerName` was optional. It is now required and must not be blank. + +**Success response** — `201 Created`: +```json +{ + "participantId": "uuid-of-alice", + "room": { + "code": "ABCD", + "status": "lobby", + "hostId": "uuid-of-alice", + "participants": [ + { "id": "uuid-of-alice", "name": "Alice", "joinedAt": "2026-05-30T10:00:00.000Z" } + ], + "availableWords": ["rocket", "pizza", "castle", "guitar", "sunflower"], + "roles": ["drawer", "guesser"] + } +} +``` + +> ✱ **Change from starter**: `room` now includes `hostId`. + +**Error responses**: +| Status | Condition | +|--------|-----------| +| `400` | `playerName` is missing, empty, or whitespace-only | + +--- + +### `POST /rooms/:code/join` — Join Room + +Adds a player to an existing room. + +**URL params**: +| Param | Type | Constraints | +|--------|----------|-----------------------------------------------| +| `code` | `string` | 4 uppercase characters from `[A-Z2-9]` ✱ | + +> ✱ **Change from starter**: `roomCodeParamsSchema` now validates the code format. + +**Request body**: +```json +{ "playerName": "Bob" } +``` + +| Field | Type | Constraints | Required | +|--------------|----------|------------------------------------|----------| +| `playerName` | `string` | Non-empty, non-whitespace-only | Yes ✱ | + +**Success response** — `200 OK`: +```json +{ + "participantId": "uuid-of-bob", + "room": { + "code": "ABCD", + "status": "lobby", + "hostId": "uuid-of-alice", + "participants": [ + { "id": "uuid-of-alice", "name": "Alice", "joinedAt": "..." }, + { "id": "uuid-of-bob", "name": "Bob", "joinedAt": "..." } + ], + "availableWords": ["..."], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error responses**: +| Status | Condition | +|--------|-----------| +| `400` | `playerName` missing/blank, or `code` malformed (not 4 chars from valid alphabet) | +| `404` | No room exists with that code | + +--- + +### `GET /rooms/:code` — Fetch Room (lobby polling) + +Returns the current snapshot of a room. Called by the frontend poll every ~2 seconds. + +**URL params**: same as join. + +**Query params**: +| Param | Type | Required | +|-----------------|----------|----------| +| `participantId` | `string` | No | + +**Success response** — `200 OK`: +```json +{ + "room": { + "code": "ABCD", + "status": "lobby", + "hostId": "uuid-of-alice", + "participants": [ ... ], + "availableWords": [ ... ], + "roles": [ ... ] + } +} +``` + +**Error responses**: +| Status | Condition | +|--------|-----------| +| `404` | Room not found | + +--- + +## New Endpoint + +### `POST /rooms/:code/start` — Start Game + +Transitions the room from `"lobby"` to `"active"`. Only the host may call this when at least 2 players are present. + +**URL params**: same `code` validation as join. + +**Request body**: +```json +{ "participantId": "uuid-of-alice" } +``` + +| Field | Type | Constraints | Required | +|-----------------|-----------------|-------------|----------| +| `participantId` | `string` (UUID) | Valid UUID | Yes | + +**Success response** — `200 OK`: +```json +{ + "room": { + "code": "ABCD", + "status": "active", + "hostId": "uuid-of-alice", + "participants": [ ... ], + "availableWords": [ ... ], + "roles": [ ... ] + } +} +``` + +**Error responses**: +| Status | Condition | +|--------|-----------| +| `400` | Missing or invalid `participantId` in body | +| `403` | `participantId` is not the room's `hostId` | +| `404` | Room not found | +| `409` | Fewer than 2 participants in the room | + +--- + +## Frontend API Client (`frontend/src/services/api.ts`) + +| Method | Signature | Calls | +|----------------|--------------------------------------------------------------|-----------------------------------| +| `createRoom` | `(playerName: string) => Promise` | `POST /rooms` | +| `joinRoom` | `(code: string, playerName: string) => Promise` | `POST /rooms/:code/join` | +| `fetchRoom` | `(code: string, participantId?: string) => Promise<{ room: RoomSnapshot }>` | `GET /rooms/:code` | +| `startRoom` ✱ | `(code: string, participantId: string) => Promise<{ room: RoomSnapshot }>` | `POST /rooms/:code/start` | + +> ✱ New method added in this feature. + +**Base URL fix**: `API_BASE_URL` in `api.ts` must be `http://localhost:3001` (the current starter value `http://localhost:3001/bug` is a known bug). diff --git a/specs/002-game-room-lobby/data-model.md b/specs/002-game-room-lobby/data-model.md new file mode 100644 index 00000000..48643c33 --- /dev/null +++ b/specs/002-game-room-lobby/data-model.md @@ -0,0 +1,105 @@ +# Data Model: Game Room Lobby + +**Branch**: `002-game-room-lobby` | **Date**: 2026-05-30 + +## Entities + +### Room + +Represents an active game session container, held in the in-memory store on the backend. + +| Field | Type | Description | +|----------------|-------------------|---------------------------------------------------------------------| +| `code` | `string` (4 chars) | Unique room identifier; 4 uppercase chars from safe alphabet | +| `status` | `RoomStatus` | `"lobby"` (waiting) or `"active"` (game started) | +| `hostId` | `string` (UUID) | `id` of the participant who created the room — **new field** | +| `participants` | `Participant[]` | Ordered list of players; host is always `participants[0]` | +| `createdAt` | `string` (ISO) | Timestamp of room creation | +| `updatedAt` | `string` (ISO) | Timestamp of last mutation | + +**State transitions**: +- `"lobby"` → `"active"`: triggered by `POST /rooms/:code/start` when the host has ≥ 2 participants and `status === "lobby"`. +- No transition back to `"lobby"` — rooms are ephemeral and single-session. + +**Validation rules**: +- A room with < 2 participants cannot be started (enforced server-side with HTTP 409). +- Only the participant whose `id === room.hostId` may call the start endpoint (enforced server-side with HTTP 403). + +--- + +### Participant + +Represents a player within a room. + +| Field | Type | Description | +|------------|-----------------|-----------------------------------------------------------| +| `id` | `string` (UUID) | Unique participant identifier, assigned at join time | +| `name` | `string` | Display name; must be non-empty and non-whitespace-only | +| `joinedAt` | `string` (ISO) | Timestamp of when this player joined | + +**Validation rules**: +- `name` must be at least 1 non-whitespace character (enforced at API schema layer and client-side). + +--- + +### RoomSnapshot (API read shape) + +The read-only view of a room returned to clients. This is what `GET /rooms/:code` and the responses of POST endpoints return inside `room`. + +| Field | Type | Description | +|----------------|-------------------|------------------------------------------------------| +| `code` | `string` | Room code | +| `status` | `RoomStatus` | `"lobby"` or `"active"` | +| `hostId` | `string` | Participant `id` of the host — **new field** | +| `participants` | `Participant[]` | Current player list | +| `availableWords` | `string[]` | Seed word list (unchanged) | +| `roles` | `ParticipantRole[]` | Seed roles (unchanged) | + +--- + +### RoomStatus (type) + +``` +type RoomStatus = "lobby" | "active" +``` + +- `"lobby"`: room exists, waiting for host to start. +- `"active"`: game in progress; added in this feature. *(Existing code only had `"lobby"`.)* + +--- + +## Schema Changes (backend) + +### `backend/src/models/game.ts` + +- Add `hostId: string` field to `Room` interface. +- Extend `RoomStatus` type: `"lobby" | "active"`. +- Add `hostId: string` field to `RoomSnapshot` interface. + +### `backend/src/services/roomStore.ts` + +- `createRoom()`: set `room.hostId = participant.id` when constructing the room object. +- `toRoomSnapshot()`: include `hostId` in the returned snapshot. +- Add `startRoom(code: string, requestingParticipantId: string)`: validates host + player count, sets `status = "active"`, persists. + +### `backend/src/api/schemas.ts` + +- `createRoomSchema`: `playerName` becomes `z.string().min(1).trim()` (required, non-whitespace). +- `joinRoomSchema`: `playerName` becomes `z.string().min(1).trim()` (required, non-whitespace). +- `roomCodeParamsSchema`: add `.regex(/^[A-Z2-9]{4}$/)` refinement to `code` field. +- Add `startRoomBodySchema`: `z.object({ participantId: z.string().uuid() })` — caller identifies themselves as the requesting participant. + +--- + +## Frontend Type Changes + +### `frontend/src/services/api.ts` + +- Fix `API_BASE_URL`: remove `/bug` suffix — correct value is `http://localhost:3001`. +- Add `RoomStatus` type: `"lobby" | "active"`. +- Add `hostId: string` to `RoomSnapshot` interface. +- Add `startRoom(code: string, participantId: string)` API method: `POST /rooms/:code/start`. + +### `frontend/src/state/roomStore.ts` + +- Add `startRoom()` method: calls `api.startRoom()`, then updates room snapshot in state. diff --git a/specs/002-game-room-lobby/plan.md b/specs/002-game-room-lobby/plan.md new file mode 100644 index 00000000..2e96ff00 --- /dev/null +++ b/specs/002-game-room-lobby/plan.md @@ -0,0 +1,86 @@ +# Implementation Plan: Game Room Lobby + +**Branch**: `002-game-room-lobby` | **Date**: 2026-05-30 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/002-game-room-lobby/spec.md` + +## Summary + +Enable players to create or join a drawing game room via a unique 4-character code, with the creator automatically becoming the host. The lobby auto-refreshes every ~2 seconds via polling, displays the live player list, and lets only the host start the game once at least 2 players are present. Invalid and empty inputs are rejected with clear inline feedback. Implemented as incremental changes on top of the existing Express + React starter scaffold — no new libraries, no WebSockets, in-memory store only. + +## Technical Context + +**Language/Version**: TypeScript (strict mode throughout). Node.js 20+ backend, React 18 frontend. + +**Primary Dependencies**: Express 4 (backend), Vite + React Router 6 (frontend), Zod (validation), Vitest (tests). All already installed in the starter. + +**Storage**: In-memory `Map` in `backend/src/services/roomStore.ts`. No database. + +**Testing**: Vitest (`backend/vitest.config.ts`, `frontend/vitest.config.ts`). Manual browser verification with two tabs per constitution Principle IV. + +**Target Platform**: Local development only (no deployment). Backend on `localhost:3001`, frontend on `localhost:5173`. + +**Project Type**: Web application — separate `backend/` and `frontend/` packages. + +**Performance Goals**: Lobby refresh within ~2 seconds of any player list change. API responses under 100ms (in-memory store, no I/O). + +**Constraints**: No WebSockets. No external databases. No new routing or state-management libraries. Polling interval fixed at 2000ms. + +**Scale/Scope**: 2–8 players per room, single active room per session, single-round game. Rooms are ephemeral — lost on server restart. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked post-design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | All changes extend existing files; no rewrites. Existing routing, components, and API patterns preserved. | +| II. Spec-Driven Development | ✅ Pass | `spec.md` exists; every task in `tasks.md` will map to a spec acceptance criterion. | +| III. Deterministic Game Rules | ✅ Pass | Host = first participant (deterministic). No timers, no random word packs, no rounds. | +| IV. Incremental Validation | ✅ Pass | Four user stories ordered P1→P2. Each independently testable in two browser tabs. | +| V. Simplicity & Scope | ✅ Pass | Polling (explicitly called for in spec). No WebSockets, no DB, no auth, no new libraries. | + +**Post-design re-check**: No violations introduced. `hostId` scalar on `Room` is the minimal shared signal. `startRoom` endpoint is the only new route. All changes are additive to existing files. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-game-room-lobby/ +├── plan.md # This file +├── research.md # Phase 0: decisions and rationale +├── data-model.md # Phase 1: entity definitions and schema changes +├── contracts/ +│ └── api.md # Phase 1: API endpoint contract +└── tasks.md # Phase 2 output (/speckit-tasks — not yet created) +``` + +### Source Code + +```text +backend/src/ +├── models/ +│ └── game.ts # Add hostId to Room + RoomSnapshot; extend RoomStatus to "lobby"|"active" +├── services/ +│ └── roomStore.ts # Set hostId on createRoom(); add startRoom(); include hostId in toRoomSnapshot() +└── api/ + ├── schemas.ts # Tighten playerName (required, non-blank); validate code format; add startRoomBodySchema + └── rooms.ts # Add POST /:code/start route + +frontend/src/ +├── services/ +│ └── api.ts # Fix API_BASE_URL (/bug → root); add hostId to RoomSnapshot; add startRoom() +├── state/ +│ └── roomStore.ts # Add startRoom() method +└── pages/ + ├── CreateRoomPage.tsx # Add client-side validation: non-empty playerName + ├── JoinRoomPage.tsx # Add client-side validation: non-empty playerName + code format + └── LobbyPage.tsx # Auto-poll every 2s; host-only Start Game button (disabled < 2); guest waiting view; auto-navigate on status="active" +``` + +**Structure Decision**: Web application layout (Option 2) — `backend/` and `frontend/` already exist and are used. No new top-level directories needed. + +## Complexity Tracking + +> No constitution violations — table not required. diff --git a/specs/002-game-room-lobby/research.md b/specs/002-game-room-lobby/research.md new file mode 100644 index 00000000..bbe78eb4 --- /dev/null +++ b/specs/002-game-room-lobby/research.md @@ -0,0 +1,59 @@ +# Research: Game Room Lobby + +**Branch**: `002-game-room-lobby` | **Date**: 2026-05-30 + +## Host Designation + +**Decision**: Store `hostId` as the `id` of the first participant added to a room (i.e. the creator). Expose `hostId` on both the server-side `Room` model and the `RoomSnapshot` returned to clients. + +**Rationale**: The spec mandates "the creator is automatically the host" with no host-promotion mechanic. Capturing `hostId` at room creation time is the simplest deterministic rule: whoever calls `createRoom()` supplies the first `Participant`; that `participant.id` becomes `room.hostId`. No additional bookkeeping is required. + +**Alternatives considered**: Deriving host from `participants[0]` at read time — rejected because it breaks if participant order ever changes (sort, de-dup). An explicit `isHost` flag per participant — rejected as redundant when a single scalar on `Room` suffices. + +--- + +## Room Code Validation + +**Decision**: Validate the room code format (4 uppercase alphanumeric characters from the generation alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`) at the API layer using a Zod regex refinement on `roomCodeParamsSchema`. Client-side: the `JoinRoomPage` validates non-empty and trims whitespace before sending. + +**Rationale**: The generator already produces 4-char codes. Rejecting malformed codes at the schema level means invalid lookups never hit `rooms.get()` and the error message can reference the expected format. Client-side validation catches the empty-field case before a network round-trip. + +**Alternatives considered**: Length check only (no character-set check) — rejected because it allows codes that can never exist (lowercase, special chars) through to the 404 path, giving a misleading "room not found" instead of "invalid code format". + +--- + +## Player Name Validation + +**Decision**: Make `playerName` required and non-whitespace-only on both `createRoomSchema` and `joinRoomSchema` using `z.string().min(1).trim()`. The frontend forms also validate client-side before submission. + +**Rationale**: The constitution (Principle III) explicitly prohibits empty/whitespace-only player names. The current schema marks `playerName` as `optional()`, which silently falls back to `"Player"` — this hides the validation gap. Making it required server-side is the authoritative gate; client-side validation is a UX convenience. + +**Alternatives considered**: Keep `optional()` and enforce only client-side — rejected because the API would remain permissive, violating the constitution. + +--- + +## Auto-Polling Strategy + +**Decision**: In `LobbyPage`, use a `useEffect` with `setInterval` at a 2000ms interval calling `roomStore.fetchRoom()`. Clear the interval on component unmount. + +**Rationale**: The spec requires ~2s lobby refresh. `setInterval` inside a `useEffect` is the idiomatic React pattern for polling without introducing new libraries (respecting constitution Principle V: no new state-management or routing libraries). The existing `roomStore.fetchRoom()` method already calls `GET /rooms/:code` and updates state — no new plumbing needed. + +**Alternatives considered**: Short-circuit polling on polling error — deferred; the spec says errors should not crash the lobby, so polling failures are swallowed silently (the displayed list may be briefly stale, which is acceptable per spec assumption). + +--- + +## Start Game Flow + +**Decision**: Add `POST /rooms/:code/start` backend endpoint that sets `room.status` to `"active"`. Extend `RoomStatus` to include `"active"`. The frontend calls this endpoint from the host's "Start Game" button, then navigates to `/game`. Non-host players have no actionable "Start Game" control; the lobby polls and the frontend detects `status === "active"` to auto-navigate guests to `/game`. + +**Rationale**: The spec requires the host to explicitly start the game, and all players must transition together. A status field on `Room` is the minimal shared signal (in-memory, no WebSockets needed). Guests detecting the status change via the existing poll is consistent with the ~2s polling mechanism already defined for player list refresh. + +**Alternatives considered**: Host pushes a separate "start" signal to guests — rejected as it requires WebSockets or a second polling endpoint (out of scope per constitution). Having the frontend navigate guests based on a timer — rejected as non-deterministic. + +--- + +## API Base URL Bug + +**Decision**: The `API_BASE_URL` in `frontend/src/services/api.ts` is currently `http://localhost:3001/bug`. The correct value is `http://localhost:3001` (the Express app mounts `/rooms` directly at the root via `router.use("/rooms", createRoomsRouter())`). This is a known starter bug and must be fixed as part of this feature. + +**Rationale**: Without this fix, all API calls fail with 404. Fixing it is a prerequisite for any lobby feature to work. diff --git a/specs/002-game-room-lobby/spec.md b/specs/002-game-room-lobby/spec.md new file mode 100644 index 00000000..e68cfb46 --- /dev/null +++ b/specs/002-game-room-lobby/spec.md @@ -0,0 +1,132 @@ +# Feature Specification: Game Room Lobby + +**Feature Branch**: `002-game-room-lobby` + +**Created**: 2026-05-30 + +**Status**: Draft + +**Input**: User description: "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 who wants to run a drawing game session opens the app and chooses to create a new room. The system immediately generates a unique code they can share with friends, and that player becomes the host automatically — no extra setup required. + +**Why this priority**: This is the entry point for every game session. Without room creation, nothing else is possible. It anchors all other stories. + +**Independent Test**: A single player can create a room, receive a unique code, and see themselves listed as host in the lobby. Delivers the foundational hosting capability. + +**Acceptance Scenarios**: + +1. **Given** a player is on the home screen, **When** they choose "Create Room", **Then** a new room is created with a unique shareable code and the player is designated host. +2. **Given** a room has been created, **When** the host views the lobby, **Then** they see the room code prominently, their own name marked as host, and a disabled "Start Game" button (fewer than 2 players). +3. **Given** two separate sessions simultaneously create rooms, **When** both rooms are active, **Then** each room has a distinct code and players in one room cannot see or interact with players in the other. + +--- + +### User Story 2 - Player Joins an Existing Room (Priority: P1) + +A player who received a room code from a friend enters it to join the session. They land in the lobby, can see who is already there, and wait for the host to start the game. + +**Why this priority**: Equal priority to room creation — the game is only useful when players can join. Together these two stories form the minimum viable lobby. + +**Independent Test**: One player creates a room, a second player enters the code, and the second player appears in the lobby of the first player's session. + +**Acceptance Scenarios**: + +1. **Given** a player is on the home screen and has a valid room code, **When** they enter the code and confirm, **Then** they join the matching room and see the current player list. +2. **Given** a player enters an empty code field and tries to join, **When** they submit, **Then** an inline error message is shown indicating the field cannot be empty and the join is blocked. +3. **Given** a player enters a code that does not correspond to any active room, **When** they submit, **Then** a clear error message is shown (e.g., "Room not found — check your code and try again") and the player remains on the join screen. +4. **Given** a player enters a malformed code (wrong format/length), **When** they submit, **Then** an inline validation message explains the expected format and the join is blocked. + +--- + +### User Story 3 - Lobby Stays Up to Date in Real Time (Priority: P2) + +All players in the lobby — host and guests alike — see an automatically refreshing list of who has joined. They do not need to manually refresh the page; the list updates within about 2 seconds of any change. + +**Why this priority**: Without live updates, players have no signal that others have arrived and the host cannot confidently decide when to start. However, game creation and joining already work independently (P1), so this is an enhancement to the experience. + +**Independent Test**: Three players join a room in sequence; each player's lobby screen shows all three names without any manual page reload, within 2 seconds of each arrival. + +**Acceptance Scenarios**: + +1. **Given** two players are in a lobby, **When** a third player joins, **Then** all existing players see the updated player list within approximately 2 seconds, without reloading the page. +2. **Given** a player is in the lobby, **When** another player disconnects or leaves, **Then** the remaining players see the updated list within approximately 2 seconds. +3. **Given** the lobby is open, **When** no players join or leave for 10 seconds, **Then** no unnecessary errors or flickering occur and the player list remains stable. + +--- + +### User Story 4 - Host Starts the Game (Priority: P2) + +Once enough players (at least 2) have joined, the host can start the drawing game session. Only the host sees an active "Start Game" control; other players wait. Trying to start with fewer than 2 players is prevented. + +**Why this priority**: This completes the pre-game flow. Lobby refresh (P2 above) and this story together represent the full lobby experience, but neither is a prerequisite for validating the core join mechanics. + +**Independent Test**: With exactly 2 players in the lobby, the host's "Start Game" button becomes active and clickable; clicking it transitions the session out of the lobby state. Guest players do not see the button as actionable. + +**Acceptance Scenarios**: + +1. **Given** the host is in the lobby with only 1 player total, **When** they view the lobby, **Then** the "Start Game" button is visible but disabled, with a message indicating more players are needed. +2. **Given** the host is in the lobby with 2 or more players, **When** they view the lobby, **Then** the "Start Game" button becomes active and clickable. +3. **Given** a guest player is in the lobby, **When** they view the lobby, **Then** they do not see an active "Start Game" control — they see a waiting indicator instead. +4. **Given** the host clicks "Start Game" with 2 or more players present, **When** the action is confirmed, **Then** the game session begins for all players in the room. + +--- + +### Edge Cases + +- What happens when a player tries to join a room that has already started? +- How does the system handle a player who closes their browser tab mid-lobby? +- What happens if the host leaves the lobby before starting — is a new host assigned, or does the room dissolve? +- What is the maximum number of players per room? +- Can the same player name appear twice in one room (duplicate display names)? +- What happens if a polling request fails (network error) — does the lobby show a stale state or an error? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST generate a unique room code when a player creates a new room. +- **FR-002**: System MUST designate the room creator as the host automatically, without any manual step. +- **FR-003**: System MUST allow a player to join an existing room by entering its code. +- **FR-004**: System MUST reject join attempts with an empty code and display a clear inline error. +- **FR-005**: System MUST reject join attempts with a code that does not match any active room and display a descriptive error message. +- **FR-006**: System MUST reject join attempts with a malformed code (incorrect format or length) and explain the expected format. +- **FR-007**: System MUST keep each room's player list and state fully isolated from all other rooms. +- **FR-008**: System MUST refresh the lobby player list automatically for all players in a room at approximately 2-second intervals via polling. +- **FR-009**: System MUST display a disabled "Start Game" control to the host when fewer than 2 players are in the room, accompanied by a message explaining why it is unavailable. +- **FR-010**: System MUST enable the "Start Game" control for the host only once at least 2 players are present in the room. +- **FR-011**: System MUST NOT display an active "Start Game" control to non-host players. +- **FR-012**: System MUST transition all players in the room into the game session when the host starts the game. + +### Key Entities *(include if feature involves data)* + +- **Room**: Represents an active game session container. Identified by a unique code. Has a designated host and a list of current players. Exists independently from all other rooms. +- **Player**: A participant in a room. Has a display name and a role (host or guest). Belongs to exactly one room at a time. +- **Room Code**: A short, unique, human-readable identifier shared out-of-band so others can join the room. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Players can create a room and receive a shareable code in under 3 seconds. +- **SC-002**: Players can join an existing room using a valid code in under 3 seconds. +- **SC-003**: Invalid or empty code submissions are rejected with visible feedback within 1 second, without a full page reload. +- **SC-004**: The lobby player list reflects the current state of the room within 2 seconds of any join or leave event, without manual user action. +- **SC-005**: 100% of "Start Game" actions initiated by a host with fewer than 2 players present are blocked before submission. +- **SC-006**: No player in Room A can view or interact with data belonging to Room B under any sequence of actions. +- **SC-007**: At least 90% of players successfully complete the join flow on their first attempt when given a valid code. + +## Assumptions + +- Players must provide a display name before creating or joining a room; name uniqueness within a room is enforced by the system (scoped assumption — see Edge Cases). +- Room codes are short alphanumeric strings (e.g., 6 characters); exact format is an implementation detail but must be consistent enough for the validation error message to describe it clearly. +- Rooms are temporary and exist only for the duration of an active game session; there is no persistence of rooms between sessions. +- The polling interval of ~2 seconds is a target; minor variance (±500ms) is acceptable as long as updates feel near-real-time. +- If the host disconnects before starting, the room is dissolved and remaining players are notified — a host-promotion mechanism is out of scope for this feature. +- Maximum player count per room is a product decision not specified; the lobby must function correctly for at least 2 and up to 8 players (assumption — can be revised). +- Players are assumed to have a stable enough connection to support 2-second polling; degraded-network handling (e.g., showing a stale-data warning) is a stretch goal. +- Authentication (persistent accounts, passwords) is out of scope; players identify themselves by a session-scoped display name only. diff --git a/specs/002-game-room-lobby/tasks.md b/specs/002-game-room-lobby/tasks.md new file mode 100644 index 00000000..59b6a014 --- /dev/null +++ b/specs/002-game-room-lobby/tasks.md @@ -0,0 +1,229 @@ +# Tasks: Game Room Lobby + +**Input**: Design documents from `specs/002-game-room-lobby/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/api.md ✅ + +**Tests**: Existing tests updated in Polish phase only. No new test files — spec does not request TDD. + +**Organization**: Tasks are grouped by user story. Each story is independently testable after its phase completes. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (touches different files, no unresolved dependencies) +- **[Story]**: Which user story this task belongs to (US1–US4) +- File paths are relative to the repository root + +--- + +## Phase 1: Setup + +**Purpose**: Verify the starter scaffold builds cleanly before any changes are made. + +- [x] T001 Confirm baseline — run `npm run build` in both `backend/` and `frontend/` and fix any pre-existing TypeScript errors before proceeding + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core type and store changes that every user story depends on. Must be complete before any user story work begins. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T002 Fix `API_BASE_URL` in `frontend/src/services/api.ts` — change `http://localhost:3001/bug` to `http://localhost:3001` (without this fix all API calls return 404) +- [x] T003 [P] Extend `backend/src/models/game.ts` — add `hostId: string` field to the `Room` interface, add `hostId: string` to the `RoomSnapshot` interface, and extend `RoomStatus` from `"lobby"` to `"lobby" | "active"` +- [x] T004 [P] Mirror type changes in `frontend/src/services/api.ts` — add `hostId: string` to the `RoomSnapshot` interface and add `"active"` to the `status` union type +- [x] T005 Update `backend/src/services/roomStore.ts` — in `createRoom()` set `room.hostId = participant.id` when constructing the room object; in `toRoomSnapshot()` include `hostId: room.hostId` in the returned snapshot (depends on T003) + +**Checkpoint**: Foundation complete — all API responses now include `hostId`, types are consistent across backend and frontend, and the frontend can reach the backend. + +--- + +## Phase 3: User Story 1 — Host Creates a Room (Priority: P1) 🎯 MVP + +**Goal**: A player creates a room, receives a unique code, and lands in the lobby as the designated host. + +**Independent Test**: Open the app → Create Room → enter a name → submit. Verify: (1) you land on `/lobby`, (2) your name appears in the Participants list, (3) a 4-character room code is displayed. Submitting with an empty name shows an inline error and does not navigate away. + +### Implementation + +- [x] T006 [US1] Require non-empty `playerName` in `backend/src/api/schemas.ts` — change `createRoomSchema` from `z.string().optional()` to `z.string().min(1).trim()` so the API rejects blank names with HTTP 400 +- [x] T007 [P] [US1] Add client-side validation to `frontend/src/pages/CreateRoomPage.tsx` — in `handleSubmit`, check that `playerName.trim()` is non-empty before calling `roomStore.createRoom()`; display an inline error message (using the existing `

    ` pattern) if the check fails; do not navigate + +**Checkpoint**: User Story 1 complete — room creation works end-to-end; blank name is rejected both server-side (400) and client-side (inline message). + +--- + +## Phase 4: User Story 2 — Player Joins an Existing Room (Priority: P1) + +**Goal**: A second player enters a valid room code and joins the lobby; invalid or empty codes are rejected with clear, inline feedback. + +**Independent Test**: Tab A creates a room and copies the code. Tab B opens Join Room, enters the code and a name → both names appear in the lobby. Then: (1) submit with empty name → inline error; (2) submit with empty code → inline error; (3) submit with a wrong-length or lowercase code → inline error; (4) submit a correctly-formatted but non-existent code → error "Unable to join room". + +### Implementation + +- [x] T008 [US2] Tighten `joinRoomSchema` in `backend/src/api/schemas.ts` — change `playerName` from `z.string().optional()` to `z.string().min(1).trim()` (same pattern as T006) +- [x] T009 [US2] Add code format validation to `roomCodeParamsSchema` in `backend/src/api/schemas.ts` — append `.regex(/^[A-Z2-9]{4}$/, "Room code must be 4 uppercase characters")` so malformed codes return HTTP 400 instead of a 404 +- [x] T010 [P] [US2] Add client-side validation to `frontend/src/pages/JoinRoomPage.tsx` — before calling `roomStore.joinRoom()`, check: (1) `playerName.trim()` non-empty; (2) `roomCode.trim()` non-empty; (3) `/^[A-Z2-9]{4}$/.test(roomCode)` is true; display a specific inline error message for each failing case using the existing `

    ` pattern + +**Checkpoint**: User Story 2 complete — valid join works; each invalid input (empty name, empty code, malformed code, unknown code) shows a distinct, correct error without a page reload. + +--- + +## Phase 5: User Story 3 — Lobby Auto-Refresh (Priority: P2) + +**Goal**: The lobby player list updates automatically for all participants every ~2 seconds; no manual refresh button is required. + +**Independent Test**: Tab A creates a room. Tab B opens the join page. Tab A is on the lobby screen. Tab B joins. Within 2 seconds Tab A's lobby shows both names — without Tab A pressing any button. Leave Tab B open; Tab A's list remains stable with no flickering or errors. + +### Implementation + +- [x] T011 [US3] Replace the manual "Refresh Room" button with auto-polling in `frontend/src/pages/LobbyPage.tsx`: + - Remove the `handleRefresh` function and the manual "Refresh Room" ` + + + ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..4cf778be 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,32 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; interface GuessFormProps { disabled?: boolean; } export function GuessForm({ disabled = false }: GuessFormProps) { + const store = useRoomStore(); const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmed = guessText.trim(); + + if (!trimmed) { + setError("Please enter a guess."); + return; + } + + setError(null); + + try { + await store.submitGuess(trimmed); + setGuessText(""); + } catch { + // error already set in store; leave form as-is so user can retry + } } return ( @@ -17,11 +35,17 @@ export function GuessForm({ disabled = false }: GuessFormProps) { setGuessText(event.target.value)} + onChange={(event) => { + setGuessText(event.target.value); + if (error) setError(null); + }} placeholder="Type your guess here..." disabled={disabled} /> + {error && ( +

    {error}

    + )}
    ` + +**`GamePage.tsx`** — Add polling `useEffect` with `setInterval(() => store.fetchRoom(), 2000)` and cleanup `clearInterval` on unmount. Replace the drawer's word-card with ``. + +**`GuessForm.tsx`** — On submit: trim input; show "Please enter a guess." if empty; call `store.submitGuess(trimmed)`; clear input and error on success. + +**`Scoreboard.tsx`** — Replace placeholder: render `room?.scores ?? []` sorted descending by score, showing participant name and score. + +**`ResultPanel.tsx`** — Replace placeholder: render `room?.guesses ?? []` in order, showing guesser name (lookup from `room.participants`), guess text, and ✓ / ✗ indicator. + +## Complexity Tracking + +> No constitution violations — table not required. diff --git a/specs/004-guess-scoring-sync/research.md b/specs/004-guess-scoring-sync/research.md new file mode 100644 index 00000000..7f2a7d74 --- /dev/null +++ b/specs/004-guess-scoring-sync/research.md @@ -0,0 +1,77 @@ +# Research: Guess Submission, Scoring, and History Sync + +**Branch**: `004-guess-scoring-sync` | **Date**: 2026-05-30 + +## Decision Log + +### D-001: Canvas Implementation Approach + +- **Decision**: Use the browser's native HTML5 Canvas API with mouse event listeners (`mousedown`, `mousemove`, `mouseup`, `mouseleave`). Clear via `ctx.clearRect(0, 0, canvas.width, canvas.height)`. +- **Rationale**: No third-party library needed. The native API is sufficient for freehand drawing. Adding a library would violate constitution Principle V (no unjustified top-level dependencies). +- **Alternatives considered**: + - `fabric.js` / `konva` — feature-rich but heavyweight; out of scope. + - SVG-based drawing — more DOM overhead with no benefit for this use case. + +### D-002: Canvas Sync Scope + +- **Decision**: Canvas data is **not synced** to guessers. The canvas is local to the drawer's browser tab only. +- **Rationale**: Syncing canvas frames via 2-second polling would produce a deeply laggy and unusable experience. WebSockets are prohibited by the constitution. The spec explicitly states only "the drawing is visible on the drawer's screen" — guessers are not mentioned for canvas visibility. +- **Alternatives considered**: + - Base64 PNG snapshot in `RoomSnapshot` — adds ~50–100 KB per poll; 2-second delay makes drawing lag 2 seconds behind. Rejected. + - Drawing command list (line segments) — reduces payload but still lags 2 seconds. Rejected. + +### D-003: Guess Storage and Score Computation + +- **Decision**: Add a `guesses: Guess[]` array to the `Room` model (in-memory). Scores are **computed** from guesses when building `RoomSnapshot` — no separate score field on `Participant` or elsewhere. +- **Rationale**: Derived data stays consistent by construction. Adding a `score` field risks divergence from the guess list. Score computation is O(n) over guesses, which is trivially fast for a single-room game. +- **Alternatives considered**: + - `score` on `Participant` — requires updating two places on each guess; can diverge. + - Separate scores map in `roomStore` — another data structure to keep in sync. + +### D-004: API Surface for Guesses + +- **Decision**: + - New endpoint: `POST /rooms/:code/guesses` — submit a guess. Body: `{ guesserId, text }`. Returns the saved `Guess` plus the updated score for the guesser. + - Extend existing: `GET /rooms/:code` — `RoomSnapshot` gains `guesses: Guess[]` and `scores: Score[]`. No new read endpoint. +- **Rationale**: Clients already poll `GET /rooms/:code` every 2 seconds. Extending `RoomSnapshot` is the least-invasive change. A separate `GET /rooms/:code/guesses` would be redundant. +- **Alternatives considered**: + - Separate `GET /rooms/:code/guesses` endpoint — unnecessary duplication of the polling call. + - Return full room snapshot on `POST /guesses` — useful for immediate score update; included as a convenience field alongside the new guess. + +### D-005: Polling Mechanism + +- **Decision**: Add a `useEffect` in `GamePage.tsx` with `setInterval(2000)` that calls `store.fetchRoom()`. Clean up on unmount. +- **Rationale**: `store.fetchRoom()` already exists and updates the store. The component re-renders reactively via `useSyncExternalStore`. This is a one-line addition per the existing pattern. +- **Alternatives considered**: + - Lobby already polls via a similar pattern — confirmed as the established project pattern from feature 002. + - `setInterval` in `RoomStore` class — would poll even when game screen is not mounted; incorrect. + +### D-006: Guess Validation Strategy + +- **Decision**: Two-layer validation: + 1. **Client**: Trim + check non-empty before any fetch call; show inline error "Please enter a guess." if empty. Clear form on successful submission. + 2. **Server**: Zod schema trims and requires `min(1)` on `text`; validates `guesserId` as a non-empty string. +- **Rationale**: Belt-and-suspenders. Client validation avoids unnecessary network round trips. Server validation prevents invalid data from reaching the store regardless of client. +- **Alternatives considered**: + - Server-only validation — valid but wastes a round trip for empty submissions. + +### D-007: Files Changed + +**Backend** (4 files, all existing): +- `backend/src/models/game.ts` — add `Guess` interface; add `guesses` to `Room`; add `guesses` + `scores` to `RoomSnapshot` +- `backend/src/services/roomStore.ts` — add `submitGuess()`; update `toRoomSnapshot()` +- `backend/src/api/schemas.ts` — add `submitGuessSchema` +- `backend/src/api/rooms.ts` — add `POST /:code/guesses` handler + +**Frontend** (5 files, all existing): +- `frontend/src/services/api.ts` — add `Guess`, `Score` types; update `RoomSnapshot`; add `submitGuess()` method +- `frontend/src/state/roomStore.ts` — add `submitGuess()` method to `RoomStore` +- `frontend/src/pages/GamePage.tsx` — add polling `useEffect`; replace canvas placeholder with `` +- `frontend/src/components/GuessForm.tsx` — wire submit to store; add validation; clear on success +- `frontend/src/components/Scoreboard.tsx` — render actual scores from room state +- `frontend/src/components/ResultPanel.tsx` — render guess history from room state + +**New files created** (1 component): +- `frontend/src/components/DrawingCanvas.tsx` — self-contained canvas component for the drawer + +Total: 9 modified files, 1 new file. Zero new npm dependencies. diff --git a/specs/004-guess-scoring-sync/spec.md b/specs/004-guess-scoring-sync/spec.md new file mode 100644 index 00000000..ff2fcc21 --- /dev/null +++ b/specs/004-guess-scoring-sync/spec.md @@ -0,0 +1,113 @@ +# Feature Specification: Guess Submission, Scoring, and History Sync + +**Feature Branch**: `004-guess-scoring-sync` + +**Created**: 2026-05-30 + +**Status**: Draft + +**Input**: User description: "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)." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Drawer Uses the Canvas (Priority: P1) + +The drawer sees a live drawing canvas on the game screen. They can draw freely by clicking and dragging. A "Clear" button resets the canvas to a blank state. No other player sees the canvas — guessers see a static placeholder while the drawer works. The canvas is purely local (not synced to guessers) for this feature. + +**Why this priority**: The drawing mechanic is the core input for the game. Without a functional canvas, the drawer has no way to communicate the secret word to guessers. This is the foundational action of every round. + +**Independent Test**: Two tabs open. Tab A (drawer) sees a canvas that responds to mouse draw events and has a Clear button. Drawing on Tab A is visible only on Tab A. Tab B (guesser) still shows the waiting placeholder — the canvas content is not transferred. Clearing the canvas on Tab A removes all strokes. + +**Acceptance Scenarios**: + +1. **Given** an active game with the player identified as the drawer, **When** the game screen loads, **Then** a drawable canvas is displayed to the drawer — replacing the word-card area with a canvas the drawer can interact with. +2. **Given** the drawer has drawn strokes on the canvas, **When** the drawer presses the "Clear Canvas" button, **Then** all strokes are erased and the canvas returns to a blank white state. +3. **Given** a guesser's game screen is displayed, **When** the guesser views the main canvas area, **Then** they see a static placeholder ("Waiting for drawer to draw…") — the drawer's canvas content is not visible to guessers. + +--- + +### User Story 2 — Guessers Submit Guesses (Priority: P1) + +A guesser types a word into the guess form and submits it. The system trims whitespace from both ends and rejects empty or whitespace-only input with a clear inline message. The guess is compared to the secret word case-insensitively. If correct, the guesser scores 100 points for the round; if incorrect, the score does not change (0 points added). + +**Why this priority**: Guess submission is the primary guesser action. Without it, guessers cannot participate. Validation is essential to prevent silent empty submissions. + +**Independent Test**: Tab B (guesser) submits " Rocket " when the secret word is "rocket". After submission the form clears, the guess appears in the activity panel marked correct, and the guesser's score on the scoreboard increases to 100. Then Tab B submits " " (whitespace-only) — the form does not submit and shows an error: "Please enter a guess." + +**Acceptance Scenarios**: + +1. **Given** a guesser is on the game screen, **When** they submit a guess with leading/trailing whitespace (e.g., " rocket "), **Then** the whitespace is trimmed before comparison and the guess is treated as "rocket". +2. **Given** a guesser types only whitespace or leaves the field empty, **When** they press Submit, **Then** the submission is rejected and an inline error message "Please enter a guess." is shown — no API call is made. +3. **Given** a guesser submits a guess that matches the secret word (case-insensitively), **When** the result is received, **Then** the guess is marked correct and the guesser's score increases by 100. +4. **Given** a guesser submits a guess that does not match the secret word, **When** the result is received, **Then** the guess is marked incorrect and the guesser's score does not change. +5. **Given** the viewer is the drawer, **When** they view the game screen, **Then** the guess form is not displayed — only guessers can submit guesses. + +--- + +### User Story 3 — Guess History and Scores Sync to All Players (Priority: P2) + +All players — drawer and guessers alike — see the guess history in the Activity panel and the live scoreboard. Both panels refresh automatically every 2 seconds by polling the room state from the server. The scoreboard lists every participant with their current score. The Activity panel lists each guess in order, showing the guesser's name, their guess text, and whether it was correct or incorrect. + +**Why this priority**: Shared visibility of guesses and scores is what makes the game feel live and fair. It is not required for the core submit-and-score mechanic (Story 2), but it completes the social loop and is required for all players to track progress. + +**Independent Test**: Tab A (drawer) and Tab B (guesser) both have the game screen open. Tab B submits a correct guess. Within 2 seconds (next poll), Tab A's Activity panel shows the new guess and Tab A's Scoreboard shows Tab B's score updated to 100. + +**Acceptance Scenarios**: + +1. **Given** an active game, **When** any player's game screen is open, **Then** the scoreboard and activity panel are automatically refreshed approximately every 2 seconds without any user action. +2. **Given** a guesser has submitted a correct guess, **When** the next poll completes, **Then** all players see the updated score on the scoreboard and the correct guess in the activity panel. +3. **Given** multiple guesses have been submitted, **When** the activity panel updates, **Then** guesses are listed in submission order (oldest first), each showing the guesser's name, the guess text, and a correct/incorrect indicator. +4. **Given** all participants start the round with 0 points, **When** the scoreboard is first displayed, **Then** every participant is shown with a score of 0. + +--- + +### Edge Cases + +- What if a guesser submits the same word multiple times? Each submission is recorded independently; subsequent correct guesses still award 100 points each (no de-duplication in this feature). +- What if the guesser's name cannot be found (e.g., data inconsistency)? The Activity panel shows "Unknown player" as a fallback. +- What happens if the polling request fails (e.g., server unreachable)? The existing error state in the store handles this; the panel shows the last known state without crashing. +- Can the drawer submit guesses? No — the guess form is not rendered for the drawer. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST render an interactive drawing canvas in the main content area for the drawer on the game screen. +- **FR-002**: System MUST provide a "Clear Canvas" button that resets the canvas to a blank state when pressed. +- **FR-003**: System MUST NOT sync canvas drawing data to other participants — the canvas is local to the drawer's screen. +- **FR-004**: System MUST render a guess submission form for guessers only; the form MUST NOT appear for the drawer. +- **FR-005**: System MUST reject empty or whitespace-only guess submissions client-side before any API call, displaying "Please enter a guess." +- **FR-006**: System MUST trim leading and trailing whitespace from a guess before sending it to the server. +- **FR-007**: System MUST compare the submitted guess to the secret word case-insensitively on the server. +- **FR-008**: System MUST award 100 points to a guesser for a correct guess; incorrect guesses add 0 points. +- **FR-009**: System MUST store each submitted guess (trimmed text, guesser ID, correctness, timestamp) on the server in memory. +- **FR-010**: System MUST expose the current guess history and participant scores through the existing room polling endpoint so clients can retrieve them. +- **FR-011**: Frontend MUST poll for updated room state (including guesses and scores) approximately every 2 seconds while the game screen is active. +- **FR-012**: System MUST display the guess history in the Activity panel for all players, ordered by submission time (oldest first), showing guesser name, guess text, and correct/incorrect status. +- **FR-013**: System MUST display each participant's current score in the Scoreboard, starting at 0. + +### Key Entities *(include if feature involves data)* + +- **Guess**: A single guess attempt submitted by a guesser. Attributes: unique ID, guesser participant ID, submitted text (trimmed), correctness flag, submission timestamp. +- **Score**: Computed per participant from accumulated correct guesses (each correct guess = 100 points). Not stored separately — derived from the guess list. +- **Room** (extended): The existing Room entity gains a `guesses` list to hold all guess attempts for the active round. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All guesses submitted with only whitespace are rejected before reaching the server — 0% of empty guesses appear in the server-side guess history. +- **SC-002**: Correct guess detection is 100% accurate — every case-insensitive match of the secret word is scored as correct, and every non-match is scored 0. +- **SC-003**: All players see the same guess history and scores within 2 seconds of a guess being submitted — cross-tab consistency is verifiable by manual two-tab testing. +- **SC-004**: Scores shown in the Scoreboard match the number of correct guesses × 100 for each participant — no score discrepancy between players' views. +- **SC-005**: The drawer's canvas responds to mouse interaction (draw and clear) with no visible delay — drawing feels immediate on the drawer's screen. + +## Assumptions + +- The secret word used for comparison is always the first word in the starter list (`availableWords[0]`) — already established in feature 003. +- All guesses are stored in the server's in-memory `Room` object and are lost if the server restarts — no persistence layer exists or is needed. +- A guesser may submit multiple guesses; each is recorded separately and each correct guess awards 100 points independently. No de-duplication or "one correct guess per guesser" rule is applied in this feature. +- Polling uses the existing `GET /rooms/:code` endpoint; guesses and computed scores are included in the `RoomSnapshot` returned by that endpoint. +- The canvas is rendered using the browser's native HTML5 Canvas API — no third-party drawing library is introduced. +- The game screen is already reached only via valid room state (per feature 003); no additional navigation guarding is needed here. +- Guessers and the drawer are already identified by `participantId` stored in the frontend `RoomStore` — no new session or auth mechanism is required. diff --git a/specs/004-guess-scoring-sync/tasks.md b/specs/004-guess-scoring-sync/tasks.md new file mode 100644 index 00000000..2abbb57d --- /dev/null +++ b/specs/004-guess-scoring-sync/tasks.md @@ -0,0 +1,170 @@ +# Tasks: Guess Submission, Scoring, and History Sync + +**Input**: Design documents from `specs/004-guess-scoring-sync/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/api.md ✅ + +**Tests**: Not requested in spec — no test tasks generated. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- Exact file paths are included in every description + +## Path Conventions (Web application — per plan.md) + +- Backend: `backend/src/` +- Frontend: `frontend/src/` + +--- + +## Phase 1: Foundational (Shared Data Model) + +**Purpose**: Add `Guess` and `Score` types to both backend and frontend. These are prerequisites for all three user stories. Backend and frontend files are different — both tasks can run in parallel. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T001 [P] Add `Guess` interface (id, guesserId, text, isCorrect, submittedAt) and `Score` interface (participantId, score) to `backend/src/models/game.ts`; add `guesses: Guess[]` to `Room`; add `guesses: Guess[]` and `scores: Score[]` to `RoomSnapshot`; initialize `guesses: []` in `createRoom()` in `backend/src/services/roomStore.ts` +- [x] T002 [P] Add `Guess` interface and `Score` interface to `frontend/src/services/api.ts`; add `guesses: Guess[]` and `scores: Score[]` to the frontend `RoomSnapshot` type + +**Checkpoint**: Shared types defined — all three user stories can now proceed independently. + +--- + +## Phase 2: User Story 1 — Drawer Uses the Canvas (Priority: P1) 🎯 MVP + +**Goal**: The drawer sees a working HTML5 canvas on the game screen and can draw freehand and clear the canvas. + +**Independent Test**: Tab A (host/drawer) opens the game screen. A white canvas is visible in the main area. Drawing with the mouse creates strokes. Clicking "Clear Canvas" removes all strokes. Tab B (guesser) still shows the static "Waiting for drawer to draw…" placeholder — no canvas on Tab B. + +- [x] T003 [US1] Create `frontend/src/components/DrawingCanvas.tsx`: `useRef` + `useEffect` attaching `mousedown/mousemove/mouseup/mouseleave` listeners; track drawing state with `useRef` (not React state, to avoid re-renders mid-stroke); `handleClear()` calls `ctx.clearRect(0, 0, canvas.width, canvas.height)`; render `` with a "Clear Canvas" ` +
    + ) : ( +

    + Waiting for host to start a new round… +

    + )} + + + + + ); + } + return (
    @@ -103,6 +184,11 @@ export function GamePage() {
    + {isDrawer && room.status === "active" && ( + + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3c4a8f3a..91afd4a6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -21,7 +21,7 @@ export interface Score { export interface RoomSnapshot { code: string; - status: "lobby" | "active"; + status: "lobby" | "active" | "ended"; hostId: string; participants: Participant[]; availableWords: string[]; @@ -85,5 +85,17 @@ export const api = { method: "POST", body: JSON.stringify({ guesserId, text }) }); + }, + endRound(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/end`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + restartRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 51130a05..ade5172b 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -121,6 +121,30 @@ class RoomStore { ); return response.guess; } + + async endRound() { + if (!this.state.room || !this.state.participantId) { + return null; + } + + const response = await this.withLoading(() => + api.endRound(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async restartRoom() { + if (!this.state.room || !this.state.participantId) { + return null; + } + + const response = await this.withLoading(() => + api.restartRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); diff --git a/specs/005-round-end-restart/checklists/requirements.md b/specs/005-round-end-restart/checklists/requirements.md new file mode 100644 index 00000000..dfea566c --- /dev/null +++ b/specs/005-round-end-restart/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Round End — Results Display and Lobby Restart + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-30 +**Feature**: [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 `/speckit-plan`. diff --git a/specs/005-round-end-restart/contracts/api.md b/specs/005-round-end-restart/contracts/api.md new file mode 100644 index 00000000..c82a1d7a --- /dev/null +++ b/specs/005-round-end-restart/contracts/api.md @@ -0,0 +1,113 @@ +# API Contract: Round End — Results Display and Lobby Restart + +**Branch**: `005-round-end-restart` | **Date**: 2026-05-30 + +## New Endpoints + +### POST /rooms/:code/end + +End the current round. Only the host may call this while the room is active. + +**Request** + +``` +POST /rooms/:code/end +Content-Type: application/json + +{ "participantId": "550e8400-e29b-41d4-a716-446655440000" } +``` + +**Success Response — 200 OK** + +```json +{ + "room": { + "code": "AB3D", + "status": "ended", + "hostId": "550e8400-...", + "participants": [...], + "availableWords": ["rocket", ...], + "roles": ["drawer", "guesser"], + "guesses": [...], + "scores": [...] + } +} +``` + +**Error Responses** + +| Status | Condition | Body | +|--------|-----------|------| +| 403 | Caller is not the host | `{ "message": "Only the host can end the round" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | +| 409 | Room is not active | `{ "message": "Round is not active" }` | + +--- + +### POST /rooms/:code/restart + +Reset the room to lobby. Only the host may call this while the room is ended. + +**Request** + +``` +POST /rooms/:code/restart +Content-Type: application/json + +{ "participantId": "550e8400-e29b-41d4-a716-446655440000" } +``` + +**Success Response — 200 OK** + +```json +{ + "room": { + "code": "AB3D", + "status": "lobby", + "hostId": "550e8400-...", + "participants": [...], + "availableWords": ["rocket", ...], + "roles": ["drawer", "guesser"], + "guesses": [], + "scores": [ + { "participantId": "550e8400-...", "score": 0 }, + { "participantId": "b6d9e1a2-...", "score": 0 } + ] + } +} +``` + +**Error Responses** + +| Status | Condition | Body | +|--------|-----------|------| +| 403 | Caller is not the host | `{ "message": "Only the host can restart the game" }` | +| 404 | Room not found | `{ "message": "Room not found" }` | +| 409 | Room is not ended | `{ "message": "Round is not ended" }` | + +--- + +## Modified: GET /rooms/:code + +No change to the request. The response now includes `"ended"` as a possible `status` value (alongside `"lobby"` and `"active"`). Clients must handle all three. + +--- + +## Frontend API Client Additions + +New methods in `frontend/src/services/api.ts`: + +``` +endRound(code: string, participantId: string) + → Promise<{ room: RoomSnapshot }> + +restartRoom(code: string, participantId: string) + → Promise<{ room: RoomSnapshot }> +``` + +Updated type in `frontend/src/services/api.ts`: + +```typescript +// RoomSnapshot.status gains "ended": +status: "lobby" | "active" | "ended" +``` diff --git a/specs/005-round-end-restart/data-model.md b/specs/005-round-end-restart/data-model.md new file mode 100644 index 00000000..1f9def51 --- /dev/null +++ b/specs/005-round-end-restart/data-model.md @@ -0,0 +1,52 @@ +# Data Model: Round End — Results Display and Lobby Restart + +**Branch**: `005-round-end-restart` | **Date**: 2026-05-30 + +## Modified Type: RoomStatus + +The existing `RoomStatus` union gains a third value. + +| Value | Meaning | Client behaviour | +|-------|---------|-----------------| +| `"lobby"` | Room waiting for players / post-restart | Show `LobbyPage` | +| `"active"` | Round in progress | Show `GamePage` (game UI) | +| `"ended"` | Round finished, results displayed | Show `GamePage` (result UI) | + +**State transitions** (all server-side, host-triggered): + +``` +lobby ──[host starts]──► active ──[host ends round]──► ended ──[host restarts]──► lobby +``` + +Clients observe status via 2-second polling of `GET /rooms/:code`; UI transitions happen automatically. + +## Derived Concept: RoundResult + +Not a new stored entity — computed on demand from the current `RoomSnapshot` when `status === "ended"`. Combines: + +| Field | Source | +|-------|--------| +| `correctWord` | `room.availableWords[0]` | +| `scores` | Already in `RoomSnapshot.scores` (computed from guesses) | +| `guesses` | Already in `RoomSnapshot.guesses` | + +No new fields, no new storage. The result screen reads directly from the snapshot. + +## State Reset on Restart + +When `restartRoom()` executes: + +| Field | Before restart | After restart | +|-------|---------------|---------------| +| `room.status` | `"ended"` | `"lobby"` | +| `room.guesses` | `[...guesses from round]` | `[]` (cleared) | +| `room.participants` | `[...all players]` | `[...all players]` (preserved) | +| `room.hostId` | `` | `` (unchanged) | +| `room.availableWords` | `["rocket", ...]` | `["rocket", ...]` (unchanged) | + +## Validation Rules + +| Action | Who | Guard | +|--------|-----|-------| +| End round (`POST /end`) | Host only | `participantId === room.hostId`; room must be `"active"` | +| Restart (`POST /restart`) | Host only | `participantId === room.hostId`; room must be `"ended"` | diff --git a/specs/005-round-end-restart/plan.md b/specs/005-round-end-restart/plan.md new file mode 100644 index 00000000..e4cfdac7 --- /dev/null +++ b/specs/005-round-end-restart/plan.md @@ -0,0 +1,194 @@ +# Implementation Plan: Round End — Results Display and Lobby Restart + +**Branch**: `005-round-end-restart` | **Date**: 2026-05-30 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/005-round-end-restart/spec.md` + +## Summary + +The host can end an active round via a new "End Round" button; the room transitions to `"ended"` status. All clients polling `GET /rooms/:code` detect the new status and display a result screen showing the correct word, final scores, and full guess history. The host then clicks "Play Again" to reset the room to `"lobby"` with players preserved and guesses cleared; all clients redirect to the lobby within the next poll cycle. This is a **backend + frontend change** across 7 existing files — no new files, no new routes. + +## Technical Context + +**Language/Version**: TypeScript (strict). React 18 (frontend). Node.js + Express (backend). + +**Primary Dependencies**: Existing — React Router 6, Zod, existing `useRoomState` / `useRoomStore` hooks. All already installed. + +**Storage**: In-memory only. `room.status` transitions to `"ended"` on end-round; `room.guesses` reset to `[]` on restart. No database. + +**Testing**: Manual two-tab browser verification per constitution Principle IV. Existing Vitest suite (25 tests) must remain green. + +**Target Platform**: Local development. Backend on `localhost:3001`, frontend on `localhost:5173`. + +**Project Type**: Web application — fullstack change (`backend/src/` + `frontend/src/`). + +**Performance Goals**: Result screen appears within 2 seconds (one polling cycle). Lobby redirect after restart appears within 2 seconds (SC-001, SC-004). + +**Constraints**: No new npm dependencies. No new routes. No new page files. Result screen rendered conditionally inside `GamePage.tsx`. + +**Scale/Scope**: 2–8 players, single round, in-memory. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked post-design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | 7 existing files modified, 0 new files. No new routes, no new page components. | +| II. Spec-Driven Development | ✅ Pass | `spec.md` exists with 12 FRs and 2 user stories before any code is written. | +| III. Deterministic Game Rules | ✅ Pass | End-round and restart are host-triggered (not timer-based). Word selection unchanged across restarts (`availableWords[0]`). | +| IV. Incremental Validation | ✅ Pass | US1 (result display) independently testable; US2 (restart) buildable on top. Both verified with two browser tabs. Polling remains at 2 seconds. | +| V. Simplicity & Scope | ✅ Pass | No WebSockets, no timers, no new dependencies. `"ended"` is a one-character string addition to an existing type. | + +**Post-design re-check**: No violations. Adding `"ended"` to `RoomStatus` is additive; all existing handlers continue to work because they check specific status values, not negation. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-round-end-restart/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0: 6 decisions resolved +├── data-model.md # Phase 1: RoomStatus extension, state reset table +├── contracts/ +│ └── api.md # Phase 1: POST /end, POST /restart, modified GET /rooms/:code +├── checklists/ +│ └── requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit-tasks — not yet created) +``` + +### Source Code + +```text +backend/src/ +├── models/ +│ └── game.ts # Add "ended" to RoomStatus union +├── services/ +│ └── roomStore.ts # Add endRound(), restartRoom() +└── api/ + ├── schemas.ts # Add endRoundBodySchema, restartRoomBodySchema + └── rooms.ts # Add POST /:code/end, POST /:code/restart handlers + +frontend/src/ +├── services/ +│ └── api.ts # Update RoomSnapshot.status type; add endRound(), restartRoom() +├── state/ +│ └── roomStore.ts # Add endRound(), restartRoom() to RoomStore +└── pages/ + └── GamePage.tsx # Add result-screen conditional render; "End Round" button; + # lobby-redirect useEffect when status === "lobby" +``` + +**Structure Decision**: Web application (existing layout). Zero new files in source code. + +## Implementation Notes + +### Backend + +**`game.ts`** — Extend the union: +```typescript +export type RoomStatus = "lobby" | "active" | "ended"; +``` + +**`roomStore.ts`** — Add two functions: + +`endRound(code, requestingParticipantId)`: +- Get room; return `{ error: "not_found" }` if missing +- Return `{ error: "forbidden" }` if caller is not `room.hostId` +- Return `{ error: "not_active" }` if `room.status !== "active"` +- Set `room.status = "ended"`; `saveRoom(room)`; return `{ room: toRoomSnapshot(cloneRoom(room)) }` + +`restartRoom(code, requestingParticipantId)`: +- Get room; return `{ error: "not_found" }` if missing +- Return `{ error: "forbidden" }` if caller is not `room.hostId` +- Return `{ error: "not_ended" }` if `room.status !== "ended"` +- Set `room.status = "lobby"`, `room.guesses = []`; `saveRoom(room)`; return `{ room: toRoomSnapshot(cloneRoom(room)) }` + +**`schemas.ts`** — Both endpoints share the same body shape: +```typescript +export const endRoundBodySchema = z.object({ + participantId: z.string().uuid() +}); +export const restartRoomBodySchema = z.object({ + participantId: z.string().uuid() +}); +``` + +**`rooms.ts`** — Add two handlers following the existing `startRoom` pattern: +- `POST /:code/end` → calls `endRound()`; 200 on success; 403/404/409 on error +- `POST /:code/restart` → calls `restartRoom()`; 200 on success; 403/404/409 on error + +### Frontend + +**`api.ts`** — Update status type, add two methods: +```typescript +// In RoomSnapshot: +status: "lobby" | "active" | "ended" + +// New api methods: +endRound(code: string, participantId: string) → Promise<{ room: RoomSnapshot }> +restartRoom(code: string, participantId: string) → Promise<{ room: RoomSnapshot }> +``` + +**`roomStore.ts`** — Add to `RoomStore` class: +```typescript +async endRound() { + if (!this.state.room || !this.state.participantId) return null; + const response = await this.withLoading(() => + api.endRound(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; +} + +async restartRoom() { + if (!this.state.room || !this.state.participantId) return null; + const response = await this.withLoading(() => + api.restartRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; +} +``` + +**`GamePage.tsx`** — Three additions: + +1. **Lobby-redirect effect** — add after existing effects: +```typescript +useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } +}, [room?.status, navigate]); +``` + +2. **"End Round" button** — inside the existing `
    ` at the bottom, show conditionally for the host when status is "active": +```tsx +{isDrawer && room.status === "active" && ( + +)} +``` + +3. **Result screen** — when `room.status === "ended"`, render a result view instead of the full game layout: +```tsx +if (room.status === "ended") { + return ; +} +``` + +The result screen is a small inline render (not a new component file) showing: +- "The word was: [secretWord]" heading +- Sorted scoreboard list (reuses `room.scores`) +- Guess history list (reuses `room.guesses`) +- Host: "Play Again" button → `store.restartRoom()` +- Non-host: "Waiting for host to start a new round…" paragraph + +Inline vs component: the result screen is simple enough (< 30 lines JSX) to inline in `GamePage.tsx` as a local helper or early return, avoiding a new file (constitution Principle I). + +## Complexity Tracking + +> No constitution violations — table not required. diff --git a/specs/005-round-end-restart/research.md b/specs/005-round-end-restart/research.md new file mode 100644 index 00000000..a80b62d5 --- /dev/null +++ b/specs/005-round-end-restart/research.md @@ -0,0 +1,59 @@ +# Research: Round End — Results Display and Lobby Restart + +**Branch**: `005-round-end-restart` | **Date**: 2026-05-30 + +## Decision Log + +### D-001: Room Status Extension + +- **Decision**: Add `"ended"` as a third value for `RoomStatus` (alongside existing `"lobby"` and `"active"`). The type becomes `"lobby" | "active" | "ended"`. +- **Rationale**: The result screen is triggered by a server-side state change. Clients polling `GET /rooms/:code` detect `status: "ended"` and switch to the result view — no new endpoint or push mechanism needed. This is the established polling pattern already used in features 002–004. +- **Alternatives considered**: + - Separate result endpoint (`GET /rooms/:code/result`) — redundant; the snapshot already has all data needed. + - Client-side only state — would not sync across tabs; rejected. + +### D-002: Result Screen Placement + +- **Decision**: Render the result screen as a conditional branch **inside the existing `GamePage.tsx`** — when `room.status === "ended"`, show results UI; when `"active"`, show the current game UI. +- **Rationale**: Brownfield-first (constitution Principle I). No new route, no new page file. The polling `useEffect` already runs in `GamePage`; results appear within 2 seconds automatically. Adding a new page would require routing changes and a new file with minimal benefit. +- **Alternatives considered**: + - New `ResultPage.tsx` with its own route — adds a route change and a new file; unnecessary given the conditional-render approach is sufficient. + +### D-003: Lobby Redirect After Restart + +- **Decision**: Add a check in `GamePage.tsx`'s existing `useEffect` (or a new `useEffect`) that navigates to `/lobby` when `room.status === "lobby"`. This fires automatically when the polling detects the restart. +- **Rationale**: The store's `fetchRoom()` already updates `room` reactively. A `useEffect` on `room.status` checking for `"lobby"` will redirect all players seamlessly within one polling cycle (≤ 2 s). +- **Alternatives considered**: + - Redirect inside `restartRoom()` store method — can't navigate from a store method (no router access there). + +### D-004: New API Endpoints + +- **Decision**: + - `POST /rooms/:code/end` — host ends the round; transitions room to `"ended"`. Body: `{ participantId }`. + - `POST /rooms/:code/restart` — host restarts; clears guesses, transitions room to `"lobby"`. Body: `{ participantId }`. +- **Rationale**: Follows the existing verb-noun REST pattern (`/start`, `/guesses`). Each action is a discrete mutation with its own error surface. +- **Alternatives considered**: + - Combining end + restart into a single `PATCH /rooms/:code` with a `status` field — less explicit, harder to validate role permissions per transition. + +### D-005: State Cleared on Restart + +- **Decision**: `restartRoom()` sets `room.status = "lobby"` and `room.guesses = []`. Everything else (participants, code, hostId, availableWords order) is preserved. +- **Rationale**: Spec FR-010 requires guesses cleared; FR-011 requires participants preserved. The word stays deterministic (`availableWords[0]`) per constitution Principle III. +- **Alternatives considered**: + - Generating a fresh room code — violates player preservation (they'd need to rejoin). + - Keeping guesses for history — contradicts FR-010 and would bleed history into the new round. + +### D-006: Files Changed + +**Backend** (4 existing files, 0 new): +- `backend/src/models/game.ts` — extend `RoomStatus` with `"ended"` +- `backend/src/services/roomStore.ts` — add `endRound()`, `restartRoom()` +- `backend/src/api/schemas.ts` — add `endRoundBodySchema`, `restartRoomBodySchema` +- `backend/src/api/rooms.ts` — add `POST /:code/end`, `POST /:code/restart` handlers + +**Frontend** (3 existing files, 0 new): +- `frontend/src/services/api.ts` — update `RoomSnapshot.status` type; add `endRound()`, `restartRoom()` methods +- `frontend/src/state/roomStore.ts` — add `endRound()`, `restartRoom()` to `RoomStore` +- `frontend/src/pages/GamePage.tsx` — add result-screen conditional render, "End Round" button, lobby-redirect effect + +Total: 7 modified files, 0 new files. Zero new npm dependencies. diff --git a/specs/005-round-end-restart/spec.md b/specs/005-round-end-restart/spec.md new file mode 100644 index 00000000..aa32349b --- /dev/null +++ b/specs/005-round-end-restart/spec.md @@ -0,0 +1,99 @@ +# Feature Specification: Round End — Results Display and Lobby Restart + +**Feature Branch**: `005-round-end-restart` + +**Created**: 2026-05-30 + +**Status**: Draft + +**Input**: User description: "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 — All Players See the Round Results (Priority: P1) + +When the host ends the round, every player's screen transitions from the game view to a results screen. The results screen shows three things: the correct word (the word the drawer was drawing), the final scoreboard (each player's name and total score), and the full guess history (every guess submitted during the round, in order, with a correct/incorrect label). Players do not need to refresh — the results appear automatically within 2 seconds of the host ending the round. + +**Why this priority**: This is the primary payoff of the game. Without seeing the results, players have no feedback on who guessed correctly or what the word was. It is the foundational requirement for the restart flow to make sense. + +**Independent Test**: Two tabs open — Tab A (host/drawer) and Tab B (guesser). Tab B submits "pizza" (incorrect) then "rocket" (correct). Host clicks "End Round" on Tab A. Within 2 seconds, both Tab A and Tab B display a result screen showing: (1) "The word was: rocket", (2) scores — Tab A player with 0, Tab B player with 100, (3) guess history — "pizza ✗", "rocket ✓". + +**Acceptance Scenarios**: + +1. **Given** the game is active with at least one guess submitted, **When** the host clicks "End Round", **Then** all players see a result screen within 2 seconds (via polling). +2. **Given** the result screen is displayed, **When** any player views it, **Then** they see the correct word prominently labelled (e.g., "The word was: rocket"). +3. **Given** the result screen is displayed, **When** any player views the scoreboard section, **Then** each participant is listed with their final score (number of correct guesses × 100), and the list is sorted from highest to lowest score. +4. **Given** the result screen is displayed, **When** any player views the guess history section, **Then** every guess submitted during the round is shown in submission order with the guesser's name, guess text, and correct/incorrect indicator. +5. **Given** no guesses were submitted during the round, **When** the result screen is shown, **Then** all players show a score of 0 and the guess history section shows "No guesses submitted." + +--- + +### User Story 2 — Host Restarts; Everyone Returns to Lobby (Priority: P1) + +On the result screen, the host sees a "Play Again" button. Clicking it resets the room: all players are returned to the lobby, the room is ready for a new round, and all previous round data (guesses, scores) is cleared. Non-host players do not have a "Play Again" button — they see a waiting message until the host restarts. Once the host restarts, all players are taken back to the lobby automatically within 2 seconds. + +**Why this priority**: The restart completes the game loop. Without it, the game is a one-shot experience. This story is inseparable from Story 1 in terms of user value — results are only meaningful if the game can continue. + +**Independent Test**: After the result screen from Story 1 is displayed: Tab B (guesser) sees "Waiting for host to start a new round…" and no restart button. Tab A (host) sees "Play Again". Host clicks "Play Again". Within 2 seconds, both Tab A and Tab B display the lobby. The lobby shows both players. The lobby shows 0 guesses for any future round (no residual history). Starting a new game again works normally. + +**Acceptance Scenarios**: + +1. **Given** the result screen is displayed, **When** a non-host player views it, **Then** they see a "Waiting for host to start a new round…" message and no restart button. +2. **Given** the result screen is displayed, **When** the host views it, **Then** they see a clearly labelled "Play Again" button. +3. **Given** the host clicks "Play Again", **When** the restart completes, **Then** all players see the lobby screen within 2 seconds (via polling). +4. **Given** the restart has completed, **When** any player views the lobby, **Then** all prior participants are still listed — no player has been removed. +5. **Given** the restart has completed, **When** the host starts a new game, **Then** all scores start at 0 and there is no guess history visible from the previous round. +6. **Given** the restart has completed, **When** a new game starts, **Then** the same word ("rocket") is the secret word for the new round (deterministic selection is preserved). + +--- + +### Edge Cases + +- What if the host closes the browser during the result screen? Guessers remain on the result screen until a new host session is established; this is out of scope — the host is assumed to remain connected. +- What if a player joins the room during the result screen? Joining during an "ended" round is out of scope; the join endpoint is unmodified and may return the result state, which is sufficient. +- What happens if the host clicks "End Round" when no guesses were submitted? The result screen still appears, showing all scores at 0 and an empty guess history. +- Can the host end the round multiple times? No — the "End Round" button is only present when the room status is "active"; it is not shown on the result screen. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide an "End Round" button visible only to the host, and only while the game is in the active state. +- **FR-002**: System MUST transition the room to a "ended" state when the host clicks "End Round". +- **FR-003**: System MUST display a result screen to all players when the room is in the "ended" state; the result screen replaces the game view. +- **FR-004**: System MUST display the correct word on the result screen, labelled clearly (e.g., "The word was: [word]"). +- **FR-005**: System MUST display each participant's final score on the result screen, sorted from highest to lowest. +- **FR-006**: System MUST display the full guess history on the result screen, in submission order, with each entry showing guesser name, guess text, and correct/incorrect status. +- **FR-007**: System MUST display a "Waiting for host to start a new round…" message to non-host players on the result screen; no restart button is shown to them. +- **FR-008**: System MUST display a "Play Again" button to the host on the result screen. +- **FR-009**: System MUST reset the room to "lobby" status when the host clicks "Play Again". +- **FR-010**: System MUST clear all round data (guess history) when the room is reset to lobby. +- **FR-011**: System MUST preserve all participants in the room after a restart — no player is removed. +- **FR-012**: System MUST return all players to the lobby screen within 2 seconds of the host clicking "Play Again" (via polling). + +### Key Entities *(include if feature involves data)* + +- **RoundResult** (derived): Not stored separately — computed on demand from room state when status is "ended". Contains: the correct word (`availableWords[0]`), final scores (computed from guesses), and the full guess history. +- **Room** (extended): Gains a new status value — `"ended"` — in addition to the existing `"lobby"` and `"active"` statuses. The `"ended"` status signals all clients to display the result screen. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All players see the result screen within 2 seconds of the host ending the round — verified by observing two browser tabs update within the polling interval. +- **SC-002**: The correct word displayed on the result screen matches the word the drawer was given — 100% accuracy, verifiable by comparing the displayed word to the known starter list entry. +- **SC-003**: All player scores on the result screen equal the number of correct guesses × 100 — no score discrepancy between players' views. +- **SC-004**: All players see the lobby screen within 2 seconds of the host clicking "Play Again" — verifiable with two-tab testing. +- **SC-005**: After restart, 0 guesses from the previous round appear in any subsequent game session — complete state reset is verifiable by starting a new game and checking the activity panel. +- **SC-006**: After restart, all players who were present before the restart remain in the lobby — 100% player retention across a restart. + +## Assumptions + +- "End Round" is triggered exclusively by the host via a button click — there is no automatic round-end trigger (no timers, no auto-end on correct guess per constitution Principle V). +- The "ended" status is a new third value for `RoomStatus` (alongside existing "lobby" and "active"). +- The correct word is always `availableWords[0]` ("rocket"), consistent with features 003 and 004. +- All round data stored in memory is cleared on restart — guesses array is reset to `[]` and status reverts to "lobby". +- The restart does NOT reassign the host or change who the drawer would be in the next round — the host role remains with the same player. +- Non-host players poll the same room endpoint as during the game; the `"ended"` status in the response triggers the result screen client-side. +- Players who navigate directly to the game URL when the room is in "ended" status see the result screen (same redirect logic as the active game screen). +- The word selection for new rounds remains deterministic: `availableWords[0]` — the same word is used each time (per constitution Principle III). diff --git a/specs/005-round-end-restart/tasks.md b/specs/005-round-end-restart/tasks.md new file mode 100644 index 00000000..5eab1009 --- /dev/null +++ b/specs/005-round-end-restart/tasks.md @@ -0,0 +1,148 @@ +# Tasks: Round End — Results Display and Lobby Restart + +**Input**: Design documents from `specs/005-round-end-restart/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/api.md ✅ + +**Tests**: Not requested in spec — no test tasks generated. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no conflict with concurrent tasks) +- **[Story]**: Which user story this task belongs to (US1, US2) +- Exact file paths are included in every description + +## Path Conventions (Web application — per plan.md) + +- Backend: `backend/src/` +- Frontend: `frontend/src/` + +--- + +## Phase 1: Foundational (Shared Type Extension) + +**Purpose**: Extend `RoomStatus` with the `"ended"` value in both backend and frontend. These two files are independent — both tasks run in parallel. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T001 [P] Add `"ended"` to the `RoomStatus` union in `backend/src/models/game.ts`: change `export type RoomStatus = "lobby" | "active"` to `export type RoomStatus = "lobby" | "active" | "ended"` +- [x] T002 [P] Add `"ended"` to the `status` union in the frontend `RoomSnapshot` interface in `frontend/src/services/api.ts`: change `status: "lobby" | "active"` to `status: "lobby" | "active" | "ended"` + +**Checkpoint**: Both type definitions updated — all three user story tasks can now proceed. + +--- + +## Phase 2: User Story 1 — All Players See the Round Results (Priority: P1) 🎯 MVP + +**Goal**: The host ends the active round; all players' screens automatically transition to a result screen showing the correct word, final scores, and the full guess history within 2 seconds (next poll cycle). + +**Independent Test**: Tab A (host) and Tab B (guesser) are on the game screen. Tab B has submitted "rocket" (correct). Host clicks "End Round" on Tab A. Within 2 seconds, both tabs show a result screen with: "The word was: rocket", Tab B at 100 points, Tab A at 0 points, and the guess history entry "rocket ✓". Calling `POST /rooms/:code/end` directly returns 200 with `room.status === "ended"`. + +Backend and frontend streams are independent (different files) and can be worked simultaneously. + +- [x] T003 [P] [US1] Add `endRoundBodySchema` to `backend/src/api/schemas.ts`: `export const endRoundBodySchema = z.object({ participantId: z.string().uuid() })` +- [x] T004 [P] [US1] Add `endRound(code: string, requestingParticipantId: string)` to `backend/src/services/roomStore.ts`: get room from `rooms.get(code)`; return `{ error: "not_found" }` if absent; return `{ error: "forbidden" }` if `room.hostId !== requestingParticipantId`; return `{ error: "not_active" }` if `room.status !== "active"`; set `room.status = "ended"`; call `saveRoom(room)`; return `{ room: toRoomSnapshot(cloneRoom(room)) }` (depends on T001) +- [x] T005 [US1] Add `POST /:code/end` handler to `backend/src/api/rooms.ts`: import `endRoundBodySchema` and `endRound`; parse params via `roomCodeParamsSchema`, body via `endRoundBodySchema`; call `endRound(code.toUpperCase(), participantId)`; respond 200 `{ room }` on success; throw `HttpError(404, "Room not found")` for `not_found`, `HttpError(403, "Only the host can end the round")` for `forbidden`, `HttpError(409, "Round is not active")` for `not_active` (depends on T003, T004) +- [x] T006 [P] [US1] Add `endRound(code: string, participantId: string): Promise<{ room: RoomSnapshot }>` to `frontend/src/services/api.ts`: POST to `/rooms/${encodeURIComponent(code)}/end` with body `{ participantId }` (depends on T002) +- [x] T007 [US1] Add `endRound()` method to the `RoomStore` class in `frontend/src/state/roomStore.ts`: returns `null` if `this.state.room` or `this.state.participantId` are missing; calls `api.endRound(room.code, participantId)` via `this.withLoading()`; calls `this.setRoomSnapshot(response.room)` on success; returns updated room (depends on T006) +- [x] T008 [US1] Update `frontend/src/pages/GamePage.tsx` — two additions: (1) Add an "End Round" `