diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 0000000..42dd0a9 --- /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 0000000..2add9da --- /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": { + "opencode": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-05-30T17:18:55.080456+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 0000000..31ba75c --- /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 0000000..e606f91 --- /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 0000000..5bed9e5 --- /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 0000000..93962c2 --- /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 0000000..712a3e8 --- /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 0000000..dd84618 --- /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 0000000..8c414ba --- /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 0000000..13c1977 --- /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 0000000..8c414ba --- /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 0000000..f0b4231 --- /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 0000000..f7aa316 --- /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 0000000..b78356d --- /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 0000000..296e363 --- /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 0000000..34767f8 --- /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 0000000..b579f05 --- /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 0000000..13ea754 --- /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 0000000..fd835f8 --- /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 0000000..5a2673a --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,9 @@ +{ + "ai": "opencode", + "branch_numbering": "sequential", + "context_file": "AGENTS.md", + "here": true, + "integration": "opencode", + "script": "sh", + "speckit_version": "0.8.18" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..d4a0607 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.8.18", + "integration_state_schema": 1, + "installed_integrations": [ + "opencode" + ], + "integration_settings": { + "opencode": { + "script": "sh", + "invoke_separator": "." + } + }, + "integration": "opencode", + "default_integration": "opencode" +} diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json new file mode 100644 index 0000000..cdacb14 --- /dev/null +++ b/.specify/integrations/opencode.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "opencode", + "version": "0.8.18", + "installed_at": "2026-05-30T17:18:54.959161+00:00", + "files": { + ".opencode/commands/speckit.analyze.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda", + ".opencode/commands/speckit.checklist.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde", + ".opencode/commands/speckit.clarify.md": "c9fea307882747a3e0c5432bc335d1036f593612e7bd49f0f201cd4e73209c2a", + ".opencode/commands/speckit.constitution.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7", + ".opencode/commands/speckit.implement.md": "088b04952e9152ccc192bea842d92448b0aed26c748cb88d2dbd9dd96eb345e0", + ".opencode/commands/speckit.plan.md": "2c7675c4c739588167934360a7909a3f4a5c44b4cc218d0033e0ff22f46e893e", + ".opencode/commands/speckit.specify.md": "3fb3ba3de7e4e061a1a6cb48a4159c209fb6c77ad12f0e30e8c3cffb6c2a53d7", + ".opencode/commands/speckit.tasks.md": "809aead5128adf421be9bb6598257a24b836af2ad257771f4a9f0e722ce290b9", + ".opencode/commands/speckit.taskstoissues.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..83a8ed1 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "speckit", + "version": "0.8.18", + "installed_at": "2026-05-30T17:18:54.991035+00:00", + "files": { + ".specify/scripts/bash/common.sh": "7be3fdf0c81fa4607716bb17f1cf9b2441758f856d5e32965b59daccb49d6634", + ".specify/scripts/bash/setup-plan.sh": "b23cca3d769a217ab812a6059adb549622471f6893af234cf98ca2019ac4e1a1", + ".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2", + ".specify/scripts/bash/check-prerequisites.sh": "3fed31d68ceb4a8ccd02f650581446f20acb28f0da9cac29f258657c91300833", + ".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/checklist-template.md": "312eee8291dfa984b21f95ddd0ca778e7a1f0b3a64bfc470d79762a3e3f5d7b8", + ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/plan-template.md": "3fdc12da2eb157def636948c157bfb638b265e70b2e3246a0e09c8d5db710e91" + } +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 0000000..b244ea7 --- /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 0000000..ada0050 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,644 @@ +#!/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 0000000..c353770 --- /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 0000000..945385c --- /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 0000000..3f6a40b --- /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 0000000..806657d --- /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 0000000..a4670ff --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 0000000..a9868e2 --- /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 0000000..ceb2877 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,131 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` + +**Created**: [DATE] + +**Status**: Draft + +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 0000000..2aa8e74 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,252 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..f69efea --- /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 0000000..f9bf396 --- /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-30T17:18:55.097360+00:00", + "updated_at": "2026-05-30T17:18:55.097372+00:00" + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 940bc46..84dcc67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,8 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - Give concise, direct answers. - Do not output large blocks of code if a small change suffices. - When creating or editing files, ensure consistency with the existing directory structure detailed above. + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan + diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..5330366 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,25 @@ import { Router } from "express"; import { createRoomSchema, + drawingDataSchema, + guessSubmissionSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + clearDrawing, + createRoom, + getRoom, + joinRoom, + restartGame, + saveDrawing, + startGame, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -32,7 +45,7 @@ export function createRoomsRouter() { const result = joinRoom(code.toUpperCase(), playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found. Check the code and try again."); } response.json({ @@ -44,6 +57,96 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const result = startGame(code.toUpperCase(), participantId); + + if ("error" in result) { + throw new HttpError(400, result.error as string); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/draw", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, drawingData } = drawingDataSchema.parse(request.body); + const result = saveDrawing(code.toUpperCase(), participantId, drawingData); + + if ("error" in result) { + throw new HttpError(400, result.error as string); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = drawingDataSchema.parse(request.body); + const result = clearDrawing(code.toUpperCase(), participantId); + + if ("error" in result) { + throw new HttpError(400, result.error as string); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, text } = guessSubmissionSchema.parse(request.body); + const result = submitGuess(code.toUpperCase(), participantId, text); + + if ("error" in result) { + throw new HttpError(400, result.error as string); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const result = restartGame(code.toUpperCase(), participantId); + + if ("error" in result) { + throw new HttpError(400, result.error as string); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 1270595..c80f14f 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -24,13 +24,14 @@ export function notFoundHandler(_request: Request, response: Response) { } export function errorHandler( - error: Error & { statusCode?: number }, + error: Error & { statusCode?: number; issues?: Array<{ message: string }> }, _request: Request, response: Response, _next: NextFunction ) { if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + const message = error.issues?.[0]?.message ?? "Invalid request payload"; + response.status(400).json({ message }); return; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba0..b27a1c2 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,17 @@ import { z } from "zod"; +export const playerNameSchema = z.string().trim().min(1, "Player name is required"); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema +}); + +export const startGameSchema = z.object({ + participantId: z.string() }); export const roomCodeParamsSchema = z.object({ @@ -16,6 +22,16 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const drawingDataSchema = z.object({ + participantId: z.string(), + drawingData: z.string() +}); + +export const guessSubmissionSchema = z.object({ + participantId: z.string(), + text: z.string().trim().min(1, "Guess cannot be empty") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..38ff4415 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,15 +1,30 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "finished"; export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface Guess { + participantId: string; + participantName: string; + text: string; + correct: boolean; + timestamp: string; } export interface Room { code: string; status: RoomStatus; + hostId: string; + drawerId: string | null; + secretWord: string | null; + round: number; + drawingData: string; + guesses: Guess[]; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +33,12 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; + drawerId: string | null; + secretWord: string | null; + round: number; + drawingData: string; + guesses: Guess[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a..da848fd 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -29,15 +29,16 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +function displayName(name: string) { + return name.trim(); } -function createParticipant(name?: string): Participant { +function createParticipant(name: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + score: 0 }; } @@ -49,11 +50,17 @@ export function listWords() { return [...STARTER_WORDS]; } -export function createRoom(playerName?: string) { +export function createRoom(playerName: string) { const participant = createParticipant(playerName); const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, + drawerId: null, + secretWord: null, + round: 0, + drawingData: "", + guesses: [], participants: [participant], createdAt: now(), updatedAt: now() @@ -67,7 +74,7 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); if (!room) { @@ -96,12 +103,166 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" } as const; + } + + if (room.hostId !== participantId) { + return { error: "Only the host can start the game" } as const; + } + + if (room.participants.length < 2) { + return { error: "Need at least 2 players to start" } as const; + } + + for (const p of room.participants) { + if (!p.name || p.name.trim().length === 0) { + return { error: "All players must have a valid name" } as const; + } + } + + room.drawerId = room.hostId; + room.secretWord = STARTER_WORDS[room.participants.length % STARTER_WORDS.length]; + room.round = 1; + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function saveDrawing(code: string, participantId: string, drawingData: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" } as const; + } + + if (room.drawerId !== participantId) { + return { error: "Only the drawer can draw" } as const; + } + + if (room.status !== "playing") { + return { error: "Game is not in progress" } as const; + } + + room.drawingData = drawingData; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function clearDrawing(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" } as const; + } + + if (room.drawerId !== participantId) { + return { error: "Only the drawer can clear the canvas" } as const; + } + + room.drawingData = ""; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function submitGuess(code: string, participantId: string, text: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" } as const; + } + + if (room.status !== "playing") { + return { error: "Game is not in progress" } as const; + } + + if (room.drawerId === participantId) { + return { error: "The drawer cannot submit guesses" } as const; + } + + const trimmed = text.trim(); + if (!trimmed) { + return { error: "Guess cannot be empty" } as const; + } + + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) { + return { error: "Participant not found" } as const; + } + + const correct = trimmed.toLowerCase() === (room.secretWord ?? "").toLowerCase(); + + const guess: Guess = { + participantId, + participantName: participant.name, + text: trimmed, + correct, + timestamp: now() + }; + + room.guesses.push(guess); + + if (correct) { + participant.score += 100; + room.status = "finished"; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" } as const; + } + + if (room.hostId !== participantId) { + return { error: "Only the host can restart the game" } as const; + } + + room.drawerId = null; + room.secretWord = null; + room.round = 0; + room.drawingData = ""; + room.guesses = []; + room.status = "lobby"; + + for (const p of room.participants) { + p.score = 0; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawer = viewerParticipantId !== undefined && viewerParticipantId === room.drawerId; + const showWord = isDrawer || room.status === "finished"; return { code: room.code, status: room.status, + hostId: room.hostId, + drawerId: room.drawerId, + secretWord: showWord ? room.secretWord : null, + round: room.round, + drawingData: room.drawingData, + guesses: [...room.guesses], participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/discovery.md b/discovery.md new file mode 100644 index 0000000..988c49e --- /dev/null +++ b/discovery.md @@ -0,0 +1,69 @@ +# Discovery Notes — Scribble Starter + +## Incomplete Behaviors (≥3) + +### 1. No Host Tracking +The `Room` model has no `hostId`. The creator is added as a regular participant with no special status. Any participant could theoretically trigger start-game actions, but there is no start-game endpoint to trigger. The "Start Game" button in `LobbyPage.tsx` simply navigates to `/game` with no backend interaction. + +**Relevant files:** `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `frontend/src/pages/LobbyPage.tsx` + +### 2. No Game State Machine +`RoomStatus` is a single literal: `"lobby"`. There are no states for `"playing"`, `"round_end"`, or `"finished"`. The `RoomSnapshot` interface on both frontend and backend only types `status: "lobby"`. This means the entire game lifecycle (drawer assignment, round progression, result state) has no representation in the data model. + +**Relevant files:** `backend/src/models/game.ts`, `frontend/src/services/api.ts` + +### 3. No Auto-Polling +The lobby relies on a manual "Refresh Room" button calling `roomStore.fetchRoom()`. There is no `setInterval` or `useEffect`-based polling timer. The game page does not poll at all — it reads the initial room snapshot from the store and never refreshes. + +**Relevant files:** `frontend/src/pages/LobbyPage.tsx`, `frontend/src/pages/GamePage.tsx`, `frontend/src/state/roomStore.ts` + +### 4. Placeholder Drawing Surface +The "Canvas" area in `GamePage.tsx` is a `
` with hardcoded text "Waiting for drawer...". No `` element exists. No drawing interaction (mouse events, brush strokes) is implemented. + +**Relevant files:** `frontend/src/pages/GamePage.tsx` + +### 5. Empty Guess Submission +`GuessForm.handleSubmit()` calls only `event.preventDefault()` with no API call. The `GuessForm` component accepts a `disabled` prop but nothing ever submits the guess text. There is no backend endpoint for guess submission. + +**Relevant files:** `frontend/src/components/GuessForm.tsx`, `backend/src/api/rooms.ts` + +### 6. No Scoring or Result State +Participants have no `score` field. There is no tracking of guesses (correct/incorrect). The `Scoreboard` and `ResultPanel` components render hardcoded placeholder text. No endpoint exists to retrieve round results. + +**Relevant files:** `frontend/src/components/Scoreboard.tsx`, `frontend/src/components/ResultPanel.tsx`, `backend/src/models/game.ts` + +### 7. No Restart Flow +There is no mechanism to restart a game. The only navigation option from the game page is "Exit Game" which navigates to `/lobby` but does not reset any round state. + +**Relevant files:** `frontend/src/pages/GamePage.tsx` + +## Assumptions (≥2) + +### 1. Viewer-Specific Filtering is Expected +`toRoomSnapshot()` in `roomStore.ts` accepts `viewerParticipantId` but immediately voids it (`void viewerParticipantId`). This suggests the starter was designed with the expectation that future filtering (e.g., hiding secret words from non-drawers) would use this parameter. The parameter is plumbed through from the API routes and query string schema. + +### 2. Backend is Mounted Under a Subpath +The frontend API client defaults to `http://localhost:3001/bug` (the `/bug` suffix). This implies the original design anticipated the backend being mounted under a sub-path. The current backend does not use `/bug` — routes are at root level (`/rooms`, `/health`). This could be a remnant or intentional future-proofing. + +### 3. Player Names Default to "Player" +`displayName()` in `roomStore.ts` defaults to `"Player"` when no name is provided. The frontend create/join forms send whatever the user types, including empty strings, which become `"Player"` silently. This suggests validation was intentionally deferred. + +## Full File Map + +| Layer | File | Purpose | +|-------|------|---------| +| Backend | `src/models/game.ts` | Data types: Participant, Room, RoomSnapshot, RoomSessionResponse | +| Backend | `src/services/roomStore.ts` | In-memory store: CRUD operations on rooms, snapshot builder | +| Backend | `src/api/rooms.ts` | Route handlers: POST /rooms, POST /rooms/:code/join, GET /rooms/:code | +| Backend | `src/api/schemas.ts` | Zod validation schemas for requests | +| Backend | `src/api/router.ts` | Router composition, error handlers | +| Backend | `src/seed/starterData.ts` | Static word list and roles | +| Frontend | `src/services/api.ts` | Typed fetch wrapper for all backend endpoints | +| Frontend | `src/state/roomStore.ts` | Class-based state store with React Context | +| Frontend | `src/pages/LobbyPage.tsx` | Lobby view with participant list, refresh, start button | +| Frontend | `src/pages/GamePage.tsx` | Game view with placeholder canvas, guess form, scoreboard | +| Frontend | `src/components/GuessForm.tsx` | Guess input form (non-functional) | +| Frontend | `src/components/Scoreboard.tsx` | Scoreboard (placeholder) | +| Frontend | `src/components/ResultPanel.tsx` | Activity/results (placeholder) | +| Frontend | `src/pages/CreateRoomPage.tsx` | Room creation form | +| Frontend | `src/pages/JoinRoomPage.tsx` | Room join form | diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec47..87aea9e 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; interface GuessFormProps { disabled?: boolean; @@ -6,9 +7,34 @@ interface GuessFormProps { export function GuessForm({ disabled = false }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const roomStore = useRoomStore(); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + const trimmed = guessText.trim(); + if (!trimmed) { + setError("Guess cannot be empty."); + return; + } + + setError(null); + setSubmitting(true); + + try { + const result = await roomStore.submitGuess(trimmed); + if (result.error) { + setError(result.error); + } else { + setGuessText(""); + } + } catch { + setError("Failed to submit guess"); + } finally { + setSubmitting(false); + } } return ( @@ -17,14 +43,18 @@ export function GuessForm({ disabled = false }: GuessFormProps) { setGuessText(event.target.value)} + onChange={(event) => { + setGuessText(event.target.value); + setError(null); + }} placeholder="Type your guess here..." - disabled={disabled} + disabled={disabled || submitting} /> + {error ?

{error}

: null}
-
diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42..f66dbf3 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,30 @@ import { Card } from "./Card"; +import { useRoomState } from "../state/roomStore"; export function ResultPanel() { + const { room } = useRoomState(); + const guesses = room?.guesses ?? []; + return ( -
-

Game activity and guesses will appear here.

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

Game activity and guesses will appear here.

+
+ ) : ( +
    + {guesses.map((g, index) => ( +
  • + + {g.participantName}: {g.text} + + + {g.correct ? "✓" : "✗"} + +
  • + ))} +
+ )}
); } diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734..02e75de 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,30 @@ import { Card } from "./Card"; +import { useRoomState } from "../state/roomStore"; export function Scoreboard() { + const { room } = useRoomState(); + + const participants = room?.participants ?? []; + return ( -
-
- Waiting for players... - 0 + {participants.length === 0 ? ( +
+
+ Waiting for players... + 0 +
-
+ ) : ( +
    + {participants.map((p) => ( +
  • + {p.name} + {p.score} +
  • + ))} +
+ )} ); } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee..aac2970 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,15 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmed = playerName.trim(); + if (!trimmed) { + setError("Player name is required."); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(trimmed); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183..412041c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,55 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; + +type Point = { x: number; y: number }; +type Stroke = Point[]; + +const CANVAS_WIDTH = 600; +const CANVAS_HEIGHT = 500; +const DRAW_COLOR = "#111827"; +const DRAW_WIDTH = 3; + +function renderStrokes(ctx: CanvasRenderingContext2D, strokes: Stroke[]) { + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = DRAW_COLOR; + ctx.lineWidth = DRAW_WIDTH; + + for (const stroke of strokes) { + if (stroke.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(stroke[0].x, stroke[0].y); + for (let i = 1; i < stroke.length; i++) { + ctx.lineTo(stroke[i].x, stroke[i].y); + } + ctx.stroke(); + } +} + +function parseDrawingData(data: string): Stroke[] { + try { + return JSON.parse(data) as Stroke[]; + } catch { + return []; + } +} export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); + const canvasRef = useRef(null); + const isDrawing = useRef(false); + const currentStroke = useRef([]); + const strokesRef = useRef([]); + const saveTimerRef = useRef | null>(null); useEffect(() => { if (!room) { @@ -17,6 +57,95 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room) { + roomStore.startPolling(2000); + } + return () => { + roomStore.stopPolling(); + }; + }, [room, roomStore]); + + useEffect(() => { + if (!room || !canvasRef.current) return; + const strokes = parseDrawingData(room.drawingData); + strokesRef.current = strokes; + const ctx = canvasRef.current.getContext("2d"); + if (ctx) renderStrokes(ctx, strokes); + }, [room?.drawingData, room]); + + const isDrawer = participantId === room?.drawerId; + const isHost = participantId === room?.hostId; + const isFinished = room?.status === "finished"; + + const handleRestart = useCallback(async () => { + if (!isHost) return; + const result = await roomStore.restartGame(); + if (result.room) { + navigate("/lobby"); + } + }, [isHost, roomStore, navigate]); + + const getCanvasPos = useCallback((event: React.MouseEvent): Point => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + }, []); + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + if (!isDrawer || isFinished) return; + isDrawing.current = true; + const pos = getCanvasPos(event); + currentStroke.current = [pos]; + }, [isDrawer, isFinished, getCanvasPos]); + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isDrawing.current || !isDrawer) return; + const pos = getCanvasPos(event); + currentStroke.current.push(pos); + + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const stroke = currentStroke.current; + if (stroke.length < 2) return; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = DRAW_COLOR; + ctx.lineWidth = DRAW_WIDTH; + ctx.beginPath(); + ctx.moveTo(stroke[stroke.length - 2].x, stroke[stroke.length - 2].y); + ctx.lineTo(stroke[stroke.length - 1].x, stroke[stroke.length - 1].y); + ctx.stroke(); + }, [isDrawer, getCanvasPos]); + + const handleMouseUp = useCallback(() => { + if (!isDrawing.current) return; + isDrawing.current = false; + + if (currentStroke.current.length > 0) { + strokesRef.current.push([...currentStroke.current]); + currentStroke.current = []; + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + roomStore.saveDrawing(JSON.stringify(strokesRef.current)).catch(() => {}); + }, 300); + } + }, [roomStore]); + + const handleClear = useCallback(async () => { + if (!isDrawer) return; + strokesRef.current = []; + currentStroke.current = []; + const ctx = canvasRef.current?.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + await roomStore.clearDrawing(); + }, [isDrawer, roomStore]); + if (!room) { return null; } @@ -27,12 +156,26 @@ export function GamePage() {
- Round 1 -

Guess the Word!

+ Round {room.round} +

{isFinished ? "Round Over!" : "Guess the Word!"}

+ {isFinished ? ( +
+ The word was: {room.secretWord} +
+ ) : isDrawer && room.secretWord ? ( +
+ Your word: {room.secretWord} +
+ ) : ( +
+ Waiting for drawer to start drawing... +
+ )} +
+
+
Role
+
{isDrawer ? "Drawer" : "Guesser"}
+
Status
-
Playing
+
{isFinished ? "Finished" : "Playing"}
- +
+ {isFinished ? ( + isHost ? ( + + ) : ( +

Waiting for host to restart...

+ ) + ) : null} diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f530..b7ea4cf 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,21 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + if (!trimmedName) { + setError("Player name is required."); + return; + } + + const trimmedCode = roomCode.trim(); + if (!trimmedCode) { + setError("Room code is required."); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(trimmedCode.toUpperCase(), trimmedName); 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 1c99bd2..6d62209 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,8 +8,9 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, error, isLoading, participantId } = useRoomState(); const [refreshError, setRefreshError] = useState(null); + const [startError, setStartError] = useState(null); useEffect(() => { if (!room) { @@ -17,6 +18,15 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (room) { + roomStore.startPolling(2000); + } + return () => { + roomStore.stopPolling(); + }; + }, [room, roomStore]); + async function handleRefresh() { try { setRefreshError(null); @@ -26,10 +36,25 @@ export function LobbyPage() { } } + async function handleStart() { + try { + setStartError(null); + const response = await roomStore.startGame(); + if (response) { + navigate("/game"); + } + } catch (caughtError) { + setStartError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + } + if (!room) { return null; } + const isHost = participantId === room.hostId; + const canStart = isHost && room.participants.length >= 2; + return (
@@ -49,7 +74,12 @@ export function LobbyPage() {
    {room.participants.map((participant) => (
  • - {participant.name} + + {participant.name} + {participant.id === room.hostId ? ( + Host + ) : null} + joined
  • ))} @@ -61,7 +91,7 @@ export function LobbyPage() {

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

    -

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

    +

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

@@ -69,9 +99,18 @@ export function LobbyPage() { - + {isHost ? ( + + ) : ( +

Waiting for host to start...

+ )}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..d9f9178 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,11 +4,28 @@ export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export type RoomStatus = "lobby" | "playing" | "finished"; + +export interface Guess { + participantId: string; + participantName: string; + text: string; + correct: boolean; + timestamp: string; } export interface RoomSnapshot { code: string; - status: "lobby"; + status: RoomStatus; + hostId: string; + drawerId: string | null; + secretWord: string | null; + round: number; + drawingData: string; + guesses: Guess[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -19,7 +36,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 +74,35 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + saveDrawing(code: string, participantId: string, drawingData: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/draw`, { + method: "POST", + body: JSON.stringify({ participantId, drawingData }) + }); + }, + clearDrawing(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/clear`, { + method: "POST", + body: JSON.stringify({ participantId, drawingData: "" }) + }); + }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, text }) + }); + }, + restartGame(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 aefd373..7cc40bc 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -27,6 +27,7 @@ class RoomStore { }; private listeners = new Set(); + private pollTimer: ReturnType | null = null; subscribe = (listener: Listener) => { this.listeners.add(listener); @@ -89,6 +90,65 @@ class RoomStore { return response; } + async startGame() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response; + } + + async saveDrawing(drawingData: string) { + if (!this.state.room || !this.state.participantId) { + return; + } + + await api.saveDrawing(this.state.room.code, this.state.participantId, drawingData); + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + return { error: "No active room" }; + } + + try { + const response = await api.restartGame(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return { room: response.room }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to restart game"; + return { error: message }; + } + } + + async clearDrawing() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await api.clearDrawing(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) { + return { error: "No active room" }; + } + + try { + const response = await api.submitGuess(this.state.room.code, this.state.participantId, text); + this.setRoomSnapshot(response.room); + return { room: response.room }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to submit guess"; + return { error: message }; + } + } + async fetchRoom() { if (!this.state.room) { return null; @@ -98,6 +158,20 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + startPolling(intervalMs: number = 2000) { + this.stopPolling(); + this.pollTimer = setInterval(() => { + this.fetchRoom().catch(() => {}); + }, intervalMs); + } + + stopPolling() { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } } const RoomStoreContext = createContext(null); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6d..89ec872 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -404,6 +404,36 @@ input { font-weight: 400; } +.host-badge { + display: inline-block; + background: var(--brand); + color: #ffffff; + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + margin-left: 8px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.word-banner { + text-align: center; + padding: 12px 20px; + margin: 0 auto 16px; + max-width: 400px; + background: #dbeafe; + color: #1e40af; + border-radius: 8px; + font-size: 1.1rem; + font-weight: 500; +} + +.word-banner--muted { + background: #f3f4f6; + color: #6b7280; +} + .status-line, .placeholder-note { width: max-content; diff --git a/reflection.md b/reflection.md new file mode 100644 index 0000000..4f19482 --- /dev/null +++ b/reflection.md @@ -0,0 +1,78 @@ +# Reflection Report — Scribble Assignment + +## What did the starter app already have? + +The starter provided a working shell for a multiplayer drawing game: + +- **Routing:** Full app shell with navigation between Start, Create Room, Join Room, Lobby, and Game screens +- **Room management:** Backend with POST /rooms, POST /rooms/:code/join, GET /rooms/:code endpoints and in-memory storage using `Map` +- **Frontend state management:** Custom class-based store using `useSyncExternalStore` with React Context +- **API client:** Typed fetch wrapper for backend communication +- **Seed data:** 5 starter words and 2 roles (drawer, guesser) +- **UI styling:** Complete CSS design system with custom properties, layout grids, and component styles + +## What did you add? + +### Spec Kit Artifacts +- **Discovery notes** (`discovery.md`): 7 incomplete behaviors, 3 assumptions, file map +- **Constitution** (`speckit.constitution`): engineering principles, AI usage rules, review discipline, game rule constraints +- **Specification** (`speckit.specify`): 4 scenario groups with acceptance criteria and edge cases +- **Plan** (`speckit.plan`): state model changes, data flow, file-level changes per scenario +- **Tasks** (`speckit.tasks`): ordered task list with dependencies and status tracking + +### Implementation by Scenario + +**Scenario 1 — Room Setup & Lobby:** +- Host tracking (hostId stored on room creation, host badge in lobby) +- Client-side name/code validation on create and join forms +- Auto-polling (2s interval) in lobby using `setInterval` +- Host-only start button, disabled until 2+ players present +- Fixed `/bug` suffix bug in API base URL + +**Scenario 2 — Game Start & Drawer Flow:** +- Drawer assignment (host becomes drawer on game start) +- Deterministic word selection (`STARTER_WORDS[participantCount % 5]`) +- Role-based word visibility (only drawer sees secret word during play) +- Auto-polling on game page + +**Scenario 3 — Gameplay Interaction:** +- Interactive HTML5 `` with mouse drawing (mousedown/mousemove/mouseup) +- Debounced stroke serialization and sync via POST /rooms/:code/draw +- Clear canvas functionality with backend sync +- Guess submission with validation (trim, reject empty, case-insensitive comparison) +- Deterministic scoring (100 points for correct, 0 otherwise) +- Correct guess ends the round (status → "finished") +- Real-time scoreboard and guess history via polling + +**Scenario 4 — Result, Restart & Final Validation:** +- Result state reveals secret word to all players +- Host-only restart button, non-hosts see "Waiting for host to restart..." +- Full state reset on restart (scores, drawing, guesses, round) while preserving participants and host +- Redirect to lobby after restart + +## Key Decisions and Tradeoffs + +1. **Drawing serialization:** Strokes are stored as JSON arrays of points rather than image data. This keeps the data small and makes it easy to replay strokes, but means the backend does no image processing. + +2. **Debounced save (300ms):** The drawing is not sent on every mousemove event. This reduces API calls while keeping the canvas responsive locally. The tradeoff is a slight delay in syncing to other players. + +3. **No drawer rotation:** Per the spec (out of scope), only one round is supported. The host is always the drawer. + +4. **Deterministic word selection:** Using `participantCount % wordList.length` ensures the same room with the same participants always gets the same word, making behavior predictable. + +## AI-Assisted Workflow + +The Spec Kit workflow (specify → plan → tasks → implement) provided clear structure. Working incrementally by scenario and committing after each one kept changes manageable and traceable. + +Key patterns: +- Starting with artifact creation forced upfront thinking about edge cases +- The task list in `speckit.tasks` served as a reliable checklist +- Building and testing after each scenario prevented regressions +- The constitution's "brownfield, not greenfield" rule prevented unnecessary refactoring + +## AI Usage + +- AI assisted with generating Spec Kit artifacts based on codebase exploration +- AI generated implementation code following the plan's file-level changes +- All AI output was reviewed before committing +- Adjustments were made when types didn't match between frontend and backend (e.g., missing `score` field on Participant) diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 0000000..d19de25 --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,35 @@ +# Speckit Constitution — Scribble Assignment + +## Engineering Principles + +1. **Spec-First.** Every code change must trace to a spec entry. No speculative or untracked work. +2. **Brownfield, not greenfield.** Extend existing files and patterns. Do not rewrite the starter. +3. **Minimal dependencies.** No new npm packages unless the spec explicitly justifies them. +4. **Deterministic rules.** All game rules (word selection, scoring, role assignment) must be deterministic — no randomness after room creation. +5. **Stateless API endpoints.** Each request carries enough context (participantId, roomCode) to be self-describing. Server holds state in memory only. +6. **Fail fast.** Invalid inputs must be rejected at the API boundary with clear error messages. No silent defaults. +7. **Polling, not pushing.** All sync uses HTTP polling (~2s interval). No WebSockets, no real-time push. +8. **Typed everywhere.** No `any` types. Use Zod for backend validation. Frontend types mirror backend types exactly. + +## AI Usage Rules + +1. **Review before apply.** Every AI-suggested change must be reviewed before being written. Understand what it does. +2. **Small, focused prompts.** Each prompt addresses one concern (one backend model change, one route, one component). +3. **No blind acceptance.** If AI output introduces patterns inconsistent with the starter (different state management, different error handling, new libraries), reject it. +4. **Traceable output.** All AI-generated code must include inline comments only if the starter uses them. Follow starter conventions exactly. + +## Review Discipline + +1. **Run builds after each scenario.** `npm run build` in both backend and frontend before marking a scenario done. +2. **Run tests.** `npm test` in both directories. Existing tests must continue passing. +3. **Two-tab validation.** After each scenario, test with two browser tabs to confirm multi-player behavior. +4. **One concern per commit.** Granular, meaningful commits with clear messages. No "WIP" or "fix stuff" commits. +5. **Spec drift.** If implementation deviates from spec, update the spec first, then the code. + +## Game Rule Constraints + +- Word selection: `STARTER_WORDS[hostIndex % STARTER_WORDS.length]` +- Drawer: the creator/host of the room (first participant) +- Scoring: exactly 100 for a correct guess, 0 otherwise +- Guess comparison: case-insensitive trim on both sides +- Single round only: no drawer rotation, no multiple rounds diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 0000000..37b303f --- /dev/null +++ b/speckit.plan @@ -0,0 +1,161 @@ +# Speckit Plan — Scribble Assignment + +> Updated incrementally by feature group. + +--- + +## Scenario 1 — Room Setup & Lobby + +### Findings +- Host tracking missing: no `hostId` on Room model +- RoomStatus is only `"lobby"` — needs `"playing"` and `"finished"` +- No start-game endpoint exists +- Player name validation missing (empty names silently become "Player") +- No auto-polling in lobby (manual refresh only) + +### State Model Changes + +**Backend — Room type additions:** +``` +hostId: string // participantId of the creator +status: "lobby" | "playing" | "finished" +``` + +**Backend — RoomSnapshot additions:** +``` +hostId: string +``` + +**Frontend — RoomSnapshot type additions:** +``` +status: "lobby" | "playing" | "finished" +hostId: string +``` + +### Data Flow + +1. **Create Room** → POST /rooms with `{ playerName }` → backend stores hostId = participantId → returns RoomSnapshot with hostId +2. **Join Room** → POST /rooms/:code/join with `{ playerName }` → validates name → returns RoomSnapshot +3. **Poll Lobby** → GET /rooms/:code?participantId=... every 2s → returns updated participant list +4. **Start Game** → POST /rooms/:code/start with `{ participantId }` → validates host + min 2 players → updates status to "playing" → returns updated RoomSnapshot + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add `hostId` to Room and RoomSnapshot; expand RoomStatus union | +| `backend/src/services/roomStore.ts` | Store hostId on create; validate name not empty; add `startGame()`; add `isHost` check | +| `backend/src/api/schemas.ts` | Add validation that playerName is non-empty string | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/start route | +| `frontend/src/services/api.ts` | Update RoomSnapshot type; add `startGame()` method; add `submitGuess()` and `fetchCanvas()` stubs | +| `frontend/src/state/roomStore.ts` | Add `startGame()` method; auto-poll room state | +| `frontend/src/pages/LobbyPage.tsx` | Auto-poll every 2s; gate start button to host only; disable until 2+ players; show host badge | +| `frontend/src/pages/CreateRoomPage.tsx` | Validate name before submission | +| `frontend/src/pages/JoinRoomPage.tsx` | Validate name and code before submission | +| `frontend/src/components/Scoreboard.tsx` | No change in Scenario 1 | +| `frontend/src/components/ResultPanel.tsx` | No change in Scenario 1 | + +### Testing Strategy +- Manual two-tab validation: create room, join room, verify host badge, verify auto-polling, verify host-only start button. +- Backend tests: verify hostId on create, verify startGame rejects non-host, verify startGame rejects <2 players. + +### Risks +- Polling interval choice: 2s may cause noticeable lag on poor connections, but per spec. +- Race condition on rapid join + start: handled by server-side validation (atomicity in single-threaded Node). + +--- + +## Scenario 2 — Game Start & Drawer Flow + +### Findings +- No drawer assignment logic exists +- No secret word selection exists +- No role-based filtering in snapshots +- Player names not validated before game start + +### State Model Changes + +**Backend — Room type additions:** +``` +drawerId: string | null +secretWord: string | null +round: number +``` + +**Frontend — RoomSnapshot type additions:** +``` +drawerId: string | null +secretWord: string | null +round: number +``` + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add `drawerId`, `secretWord`, `round` to Room and RoomSnapshot | +| `backend/src/services/roomStore.ts` | Update `startGame()` to assign drawer + word; word visibility logic in `toRoomSnapshot()` | +| `frontend/src/services/api.ts` | Update types | +| `frontend/src/pages/GamePage.tsx` | Show role, word, drawer badge; conditional rendering based on drawer/guesser | +| `frontend/src/pages/LobbyPage.tsx` | Validate all names before starting | + +--- + +## Scenario 3 — Gameplay Interaction + +### State Model Changes + +**Backend — Room type additions:** +``` +drawingData: string // JSON string of strokes +guesses: Guess[] // array of guess objects +``` + +**Guess type:** +``` +{ participantId: string, participantName: string, text: string, correct: boolean, timestamp: string } +``` + +**RoomSnapshot additions:** +``` +drawingData: string +guesses: Guess[] +scores: Record // participantId -> score +``` + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add Guess interface, drawing/guesses/scores to Room and RoomSnapshot | +| `backend/src/services/roomStore.ts` | Add drawing save/clear, guess submission, scoring logic | +| `backend/src/api/schemas.ts` | Add guess submission schema | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/draw, POST /rooms/:code/guess, POST /rooms/:code/clear | +| `frontend/src/services/api.ts` | Add draw/guess/clear API methods | +| `frontend/src/state/roomStore.ts` | Add draw/guess/clear actions | +| `frontend/src/pages/GamePage.tsx` | Replace canvas placeholder with real ``; conditional drawing | +| `frontend/src/components/GuessForm.tsx` | Wire up API call, validation | +| `frontend/src/components/Scoreboard.tsx` | Render real scores from snapshot | +| `frontend/src/components/ResultPanel.tsx` | Render guess history from snapshot | + +--- + +## Scenario 4 — Result, Restart & Final Validation + +### State Model Changes + +No new fields. The existing `"finished"` status and `guesses`/`scores` fields serve the result state. + +**Backend — Room additions:** +- No new fields; restart is a mutation (reset specific fields, preserve participants + host) + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/services/roomStore.ts` | Add `restartGame()` function | +| `backend/src/api/schemas.ts` | Add restart action schema | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/restart | +| `frontend/src/services/api.ts` | Add restart API method | +| `frontend/src/state/roomStore.ts` | Add restart action | +| `frontend/src/pages/GamePage.tsx` | Show result state when finished; conditional restart button | diff --git a/speckit.specify b/speckit.specify new file mode 100644 index 0000000..bdd7a57 --- /dev/null +++ b/speckit.specify @@ -0,0 +1,169 @@ +# Speckit Specification — Scribble Assignment + +> Updated incrementally by feature group. + +--- + +## Scenario 1 — Room Setup & Lobby + +### Acceptance Criteria + +**AC1.1 — Host tracking** +- The player who creates a room is automatically designated as the host. +- The host is identified by `hostId` on the room. +- The lobby displays a host badge next to the host's name. + +**AC1.2 — Create room with empty name** +- If the player submits an empty or whitespace-only name, the form shows an inline error: "Player name is required." +- No API call is made. +- The room is not created. + +**AC1.3 — Join room with empty name** +- Same validation as AC1.2. +- No API call is made. + +**AC1.4 — Join room with invalid/missing code** +- If the code is empty or whitespace, the form shows an inline error: "Room code is required." +- No API call is made. +- If the code does not match any room, the API returns 404 and the form shows: "Room not found. Check the code and try again." + +**AC1.5 — Room isolation** +- Each room has a unique 4-character code. +- Participants in one room cannot see or interact with participants in another room. +- GET /rooms/:code for room A only returns data for room A. + +**AC1.6 — Lobby auto-polling** +- The lobby automatically polls the backend every ~2 seconds. +- The participant list updates without manual intervention. +- The polling stops when the user navigates away from the lobby. +- A loading indicator shows during each poll request. + +**AC1.7 — Host-only start game** +- Only the host can start the game. +- Non-host participants see a "Waiting for host to start..." message. +- A minimum of 2 participants (including host) is required to start. +- If fewer than 2 participants, the start button is disabled with the message: "Need at least 2 players to start." +- Starting the game calls POST /rooms/:code/start. + +### Edge Cases + +- Room code is case-insensitive (already handled: uppercased before lookup). +- Multiple rapid create requests generate unique codes (existing guard). +- Player can join the same room from multiple tabs (each gets a unique participantId). +- Polling continues to work after a failed request (the interval is not stopped). + +--- + +## Scenario 2 — Game Start & Drawer Flow + +### Acceptance Criteria + +**AC2.1 — Player name validation on game start** +- All player names are trimmed before the game starts. +- Any player with an empty/whitespace-only name after trim is rejected with: "All players must have a valid name." +- The game does not start if any name is invalid. + +**AC2.2 — Drawer assignment** +- When the game starts, the host (room creator) becomes the first drawer. +- The drawer is identified by `drawerId` on the room. +- The lobby and game screen clearly indicate who the drawer is. + +**AC2.3 — Secret word selection** +- The secret word is selected deterministically from `STARTER_WORDS`. +- Selection formula: `STARTER_WORDS[participantCount % STARTER_WORDS.length]`. +- The same room always produces the same word for the same participant set. + +**AC2.4 — Drawer-only word visibility** +- The secret word is included in the room snapshot only when the requesting `participantId` matches the `drawerId`. +- For guessers, the word field is `null` or omitted. +- The drawer sees: "Your word: [secret word]" on the game screen. +- Guessers see: "Waiting for drawer to start drawing..." + +### Edge Cases + +- If the host leaves (not handled — out of scope for this lab), the drawer assignment remains as-is. +- Word selection is deterministic per room state (same participants = same word). + +--- + +## Scenario 3 — Gameplay Interaction + +### Acceptance Criteria + +**AC3.1 — Drawing canvas** +- The game page renders an interactive HTML5 `` element. +- The canvas is only interactive for the drawer. +- Guessers see a read-only view of the canvas (latest drawing state). +- Drawing works via mouse: mousedown starts a stroke, mousemove draws, mouseup ends the stroke. +- Drawings are serialized and sent to the backend via POST /rooms/:code/draw. + +**AC3.2 — Clear canvas** +- The drawer sees a "Clear Canvas" button. +- Clicking it sends POST /rooms/:code/clear. +- The canvas is wiped and the cleared state is synced to all players via polling. + +**AC3.3 — Guess submission** +- Guessers can type a guess and click "Submit Guess". +- Empty/whitespace-only guesses are rejected with: "Guess cannot be empty." +- Guesses are trimmed before storage and comparison. +- The drawer cannot submit guesses (the form is hidden or disabled). +- Submitting a guess calls POST /rooms/:code/guess. + +**AC3.4 — Case-insensitive comparison** +- A guess is correct if, after trimming and lowercasing, it matches the secret word (lowercased). +- Example: "Rocket", "ROCKET", " rocket " all match "rocket". + +**AC3.5 — Scoring** +- A correct guess awards exactly 100 points. +- An incorrect guess awards 0 points. +- Scores are tracked per participant and persisted in the room state. +- The scoreboard reflects the latest scores from the room snapshot. + +**AC3.6 — Guess history sync** +- All guesses (correct and incorrect) are stored in the room state. +- The full guess history is included in the room snapshot. +- All players see the same guess history via polling. + +### Edge Cases + +- The drawer cannot guess (their guess button is disabled/hidden). +- Multiple correct guesses: first correct guess ends the round. +- Guesses from the same player are all stored (history shows all attempts). + +--- + +## Scenario 4 — Result, Restart & Final Validation + +### Acceptance Criteria + +**AC4.1 — Result state** +- When a correct guess is made, the room status changes to `"finished"`. +- All players see: + - The secret word (revealed to everyone). + - Final scores for all participants. + - Full guess history (who guessed what and whether it was correct). +- The result state is available via the standard GET /rooms/:code endpoint. + +**AC4.2 — Host restart** +- Only the host sees a "Restart Game" button in the result state. +- Clicking it calls POST /rooms/:code/restart. +- Non-host participants see: "Waiting for host to restart..." + +**AC4.3 — Restart behavior** +- Restart resets: + - Room status to `"lobby"`. + - `drawerId` to `null`. + - `secretWord` to `null`. + - `drawingData` to empty. + - `guesses` to empty. + - All participant scores to 0. +- Restart preserves: + - All participants in the room. + - The host (hostId unchanged). +- After restart, all players are redirected to the lobby. + +### Edge Cases + +- Restart with only 1 player remaining: host cannot start until a second player joins. +- Restart does not change the room code. +- Multiple restarts work identically (state is fully reset each time). diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 0000000..2fbcd1a --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,91 @@ +# Speckit Tasks — Scribble Assignment + +> Updated incrementally by feature group. Tasks are ordered with dependencies. + +--- + +## Scenario 1 — Room Setup & Lobby + +### Dependencies +- None (starter is the base) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 1.1 | Add `hostId` to `Room` and `RoomSnapshot` models; expand `RoomStatus` union | `backend/src/models/game.ts` | — | ✅ | +| 1.2 | Update `createRoom()` to store `hostId` = creator's participantId; trim and validate player name | `backend/src/services/roomStore.ts` | 1.1 | ✅ | +| 1.3 | Update request schemas to validate non-empty playerName | `backend/src/api/schemas.ts` | — | ✅ | +| 1.4 | Add `POST /rooms/:code/start` endpoint with host-only + min-2 validation | `backend/src/api/rooms.ts` | 1.1, 1.2 | ✅ | +| 1.5 | Update `toRoomSnapshot()` to include `hostId` | `backend/src/services/roomStore.ts` | 1.1 | ✅ | +| 1.6 | Update frontend `api.ts` types to include `hostId`, expanded `status`, and `startGame()` method | `frontend/src/services/api.ts` | 1.1 | ✅ | +| 1.7 | Add `startGame()` and polling methods to `RoomStore` | `frontend/src/state/roomStore.ts` | 1.6 | ✅ | +| 1.8 | Add auto-polling (2s interval) to LobbyPage | `frontend/src/pages/LobbyPage.tsx` | 1.7 | ✅ | +| 1.9 | Gate "Start Game" button to host only; disable until 2+ players; show host badge in participant list | `frontend/src/pages/LobbyPage.tsx` | 1.8 | ✅ | +| 1.10 | Add name validation to CreateRoomPage (trim, reject empty) | `frontend/src/pages/CreateRoomPage.tsx` | — | ✅ | +| 1.11 | Add name and code validation to JoinRoomPage | `frontend/src/pages/JoinRoomPage.tsx` | — | ✅ | +| 1.12 | Verify builds pass (`npm run build` in both directories) | — | 1.1–1.11 | ✅ | + +--- + +## Scenario 2 — Game Start & Drawer Flow + +### Dependencies +- Scenario 1 complete (room has hostId, start endpoint exists) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 2.1 | Add `drawerId`, `secretWord`, `round` to `Room` and `RoomSnapshot` models | `backend/src/models/game.ts` | 1.1 | ✅ | +| 2.2 | Update `startGame()` to assign host as drawer, pick deterministic word, set round=1 | `backend/src/services/roomStore.ts` | 2.1 | ✅ | +| 2.3 | Update `toRoomSnapshot()` to include word only when `viewerParticipantId === drawerId` | `backend/src/services/roomStore.ts` | 2.1, 2.2 | ✅ | +| 2.4 | Update frontend API types to include drawerId, secretWord, round | `frontend/src/services/api.ts` | 2.1 | ✅ | +| 2.5 | Add lobby name re-validation on start (trim all names, reject if empty) | `backend/src/services/roomStore.ts` | — | ✅ | +| 2.6 | Update GamePage to show role indicator, secret word for drawer, waiting state for guessers | `frontend/src/pages/GamePage.tsx` | 2.4 | ✅ | +| 2.7 | Verify builds pass | — | 2.1–2.6 | ✅ | + +--- + +## Scenario 3 — Gameplay Interaction + +### Dependencies +- Scenario 2 complete (game can start, drawer assigned, word selected) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 3.1 | Add `drawingData`, `guesses[]`, `scores` to Room model; add `Guess` interface | `backend/src/models/game.ts` | 2.1 | ✅ | +| 3.2 | Add `saveDrawing()`, `clearDrawing()`, `submitGuess()`, scoring logic to roomStore | `backend/src/services/roomStore.ts` | 3.1 | ✅ | +| 3.3 | Add Zod schemas for draw/guess/clear requests | `backend/src/api/schemas.ts` | — | ✅ | +| 3.4 | Add POST /rooms/:code/draw, POST /rooms/:code/guess, POST /rooms/:code/clear routes | `backend/src/api/rooms.ts` | 3.2, 3.3 | ✅ | +| 3.5 | Update `toRoomSnapshot()` to include drawingData, guesses, scores | `backend/src/services/roomStore.ts` | 3.1 | ✅ | +| 3.6 | Update frontend API types and add draw/guess/clear methods | `frontend/src/services/api.ts` | 3.1 | ✅ | +| 3.7 | Add draw/guess/clear actions to RoomStore | `frontend/src/state/roomStore.ts` | 3.6 | ✅ | +| 3.8 | Replace canvas placeholder with interactive ``, wire drawing + clear | `frontend/src/pages/GamePage.tsx` | 3.7 | ✅ | +| 3.9 | Wire GuessForm to submit guesses via API, add validation | `frontend/src/components/GuessForm.tsx` | 3.7 | ✅ | +| 3.10 | Update Scoreboard to render real scores from snapshot | `frontend/src/components/Scoreboard.tsx` | 3.6 | ✅ | +| 3.11 | Update ResultPanel to render guess history | `frontend/src/components/ResultPanel.tsx` | 3.6 | ✅ | +| 3.12 | Add auto-polling to GamePage for drawing/guesses/scores sync | `frontend/src/pages/GamePage.tsx` | 3.7 | ✅ | +| 3.13 | Verify builds pass | — | 3.1–3.12 | ✅ | + +--- + +## Scenario 4 — Result, Restart & Final Validation + +### Dependencies +- Scenario 3 complete (gameplay loop works, correct guess detected) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 4.1 | Add `restartGame()` to roomStore (reset round state, preserve participants + host) | `backend/src/services/roomStore.ts` | 3.2 | ✅ | +| 4.2 | Add POST /rooms/:code/restart route with host-only validation | `backend/src/api/rooms.ts` | 4.1 | ✅ | +| 4.3 | Update frontend API with restart method | `frontend/src/services/api.ts` | 4.2 | ✅ | +| 4.4 | Add restart action to RoomStore | `frontend/src/state/roomStore.ts` | 4.3 | ✅ | +| 4.5 | Show result state (word, scores, guess history) when status === "finished" | `frontend/src/pages/GamePage.tsx` | 3.10, 3.11 | ✅ | +| 4.6 | Show restart button for host, waiting message for guessers | `frontend/src/pages/GamePage.tsx` | 4.4 | ✅ | +| 4.7 | Handle restart redirect back to lobby | `frontend/src/pages/GamePage.tsx` | 4.6 | ✅ | +| 4.8 | Verify builds pass and two-tab validation | — | 4.1–4.7 | ✅ | diff --git a/specs/001-room-setup-and-lobby/plan.md b/specs/001-room-setup-and-lobby/plan.md new file mode 100644 index 0000000..eeae194 --- /dev/null +++ b/specs/001-room-setup-and-lobby/plan.md @@ -0,0 +1,58 @@ +## Scenario 1 — Room Setup & Lobby + +### Findings +- Host tracking missing: no `hostId` on Room model +- RoomStatus is only `"lobby"` — needs `"playing"` and `"finished"` +- No start-game endpoint exists +- Player name validation missing (empty names silently become "Player") +- No auto-polling in lobby (manual refresh only) + +### State Model Changes + +**Backend — Room type additions:** +``` +hostId: string // participantId of the creator +status: "lobby" | "playing" | "finished" +``` + +**Backend — RoomSnapshot additions:** +``` +hostId: string +``` + +**Frontend — RoomSnapshot type additions:** +``` +status: "lobby" | "playing" | "finished" +hostId: string +``` + +### Data Flow + +1. **Create Room** → POST /rooms with `{ playerName }` → backend stores hostId = participantId → returns RoomSnapshot with hostId +2. **Join Room** → POST /rooms/:code/join with `{ playerName }` → validates name → returns RoomSnapshot +3. **Poll Lobby** → GET /rooms/:code?participantId=... every 2s → returns updated participant list +4. **Start Game** → POST /rooms/:code/start with `{ participantId }` → validates host + min 2 players → updates status to "playing" → returns updated RoomSnapshot + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add `hostId` to Room and RoomSnapshot; expand RoomStatus union | +| `backend/src/services/roomStore.ts` | Store hostId on create; validate name not empty; add `startGame()`; add `isHost` check | +| `backend/src/api/schemas.ts` | Add validation that playerName is non-empty string | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/start route | +| `frontend/src/services/api.ts` | Update RoomSnapshot type; add `startGame()` method; add `submitGuess()` and `fetchCanvas()` stubs | +| `frontend/src/state/roomStore.ts` | Add `startGame()` method; auto-poll room state | +| `frontend/src/pages/LobbyPage.tsx` | Auto-poll every 2s; gate start button to host only; disable until 2+ players; show host badge | +| `frontend/src/pages/CreateRoomPage.tsx` | Validate name before submission | +| `frontend/src/pages/JoinRoomPage.tsx` | Validate name and code before submission | +| `frontend/src/components/Scoreboard.tsx` | No change in Scenario 1 | +| `frontend/src/components/ResultPanel.tsx` | No change in Scenario 1 | + +### Testing Strategy +- Manual two-tab validation: create room, join room, verify host badge, verify auto-polling, verify host-only start button. +- Backend tests: verify hostId on create, verify startGame rejects non-host, verify startGame rejects <2 players. + +### Risks +- Polling interval choice: 2s may cause noticeable lag on poor connections, but per spec. +- Race condition on rapid join + start: handled by server-side validation (atomicity in single-threaded Node). diff --git a/specs/001-room-setup-and-lobby/spec.md b/specs/001-room-setup-and-lobby/spec.md new file mode 100644 index 0000000..005453f --- /dev/null +++ b/specs/001-room-setup-and-lobby/spec.md @@ -0,0 +1,47 @@ +## Scenario 1 — Room Setup & Lobby + +### Acceptance Criteria + +**AC1.1 — Host tracking** +- The player who creates a room is automatically designated as the host. +- The host is identified by `hostId` on the room. +- The lobby displays a host badge next to the host's name. + +**AC1.2 — Create room with empty name** +- If the player submits an empty or whitespace-only name, the form shows an inline error: "Player name is required." +- No API call is made. +- The room is not created. + +**AC1.3 — Join room with empty name** +- Same validation as AC1.2. +- No API call is made. + +**AC1.4 — Join room with invalid/missing code** +- If the code is empty or whitespace, the form shows an inline error: "Room code is required." +- No API call is made. +- If the code does not match any room, the API returns 404 and the form shows: "Room not found. Check the code and try again." + +**AC1.5 — Room isolation** +- Each room has a unique 4-character code. +- Participants in one room cannot see or interact with participants in another room. +- GET /rooms/:code for room A only returns data for room A. + +**AC1.6 — Lobby auto-polling** +- The lobby automatically polls the backend every ~2 seconds. +- The participant list updates without manual intervention. +- The polling stops when the user navigates away from the lobby. +- A loading indicator shows during each poll request. + +**AC1.7 — Host-only start game** +- Only the host can start the game. +- Non-host participants see a "Waiting for host to start..." message. +- A minimum of 2 participants (including host) is required to start. +- If fewer than 2 participants, the start button is disabled with the message: "Need at least 2 players to start." +- Starting the game calls POST /rooms/:code/start. + +### Edge Cases + +- Room code is case-insensitive (already handled: uppercased before lookup). +- Multiple rapid create requests generate unique codes (existing guard). +- Player can join the same room from multiple tabs (each gets a unique participantId). +- Polling continues to work after a failed request (the interval is not stopped). diff --git a/specs/001-room-setup-and-lobby/tasks.md b/specs/001-room-setup-and-lobby/tasks.md new file mode 100644 index 0000000..1515640 --- /dev/null +++ b/specs/001-room-setup-and-lobby/tasks.md @@ -0,0 +1,21 @@ +## Scenario 1 — Room Setup & Lobby + +### Dependencies +- None (starter is the base) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 1.1 | Add `hostId` to `Room` and `RoomSnapshot` models; expand `RoomStatus` union | `backend/src/models/game.ts` | — | ✅ | +| 1.2 | Update `createRoom()` to store `hostId` = creator's participantId; trim and validate player name | `backend/src/services/roomStore.ts` | 1.1 | ✅ | +| 1.3 | Update request schemas to validate non-empty playerName | `backend/src/api/schemas.ts` | — | ✅ | +| 1.4 | Add `POST /rooms/:code/start` endpoint with host-only + min-2 validation | `backend/src/api/rooms.ts` | 1.1, 1.2 | ✅ | +| 1.5 | Update `toRoomSnapshot()` to include `hostId` | `backend/src/services/roomStore.ts` | 1.1 | ✅ | +| 1.6 | Update frontend `api.ts` types to include `hostId`, expanded `status`, and `startGame()` method | `frontend/src/services/api.ts` | 1.1 | ✅ | +| 1.7 | Add `startGame()` and polling methods to `RoomStore` | `frontend/src/state/roomStore.ts` | 1.6 | ✅ | +| 1.8 | Add auto-polling (2s interval) to LobbyPage | `frontend/src/pages/LobbyPage.tsx` | 1.7 | ✅ | +| 1.9 | Gate "Start Game" button to host only; disable until 2+ players; show host badge in participant list | `frontend/src/pages/LobbyPage.tsx` | 1.8 | ✅ | +| 1.10 | Add name validation to CreateRoomPage (trim, reject empty) | `frontend/src/pages/CreateRoomPage.tsx` | — | ✅ | +| 1.11 | Add name and code validation to JoinRoomPage | `frontend/src/pages/JoinRoomPage.tsx` | — | ✅ | +| 1.12 | Verify builds pass (`npm run build` in both directories) | — | 1.1–1.11 | ✅ | diff --git a/specs/002-game-start-and-drawer-flow/plan.md b/specs/002-game-start-and-drawer-flow/plan.md new file mode 100644 index 0000000..066487c --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/plan.md @@ -0,0 +1,33 @@ +## Scenario 2 — Game Start & Drawer Flow + +### Findings +- No drawer assignment logic exists +- No secret word selection exists +- No role-based filtering in snapshots +- Player names not validated before game start + +### State Model Changes + +**Backend — Room type additions:** +``` +drawerId: string | null +secretWord: string | null +round: number +``` + +**Frontend — RoomSnapshot type additions:** +``` +drawerId: string | null +secretWord: string | null +round: number +``` + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add `drawerId`, `secretWord`, `round` to Room and RoomSnapshot | +| `backend/src/services/roomStore.ts` | Update `startGame()` to assign drawer + word; word visibility logic in `toRoomSnapshot()` | +| `frontend/src/services/api.ts` | Update types | +| `frontend/src/pages/GamePage.tsx` | Show role, word, drawer badge; conditional rendering based on drawer/guesser | +| `frontend/src/pages/LobbyPage.tsx` | Validate all names before starting | diff --git a/specs/002-game-start-and-drawer-flow/spec.md b/specs/002-game-start-and-drawer-flow/spec.md new file mode 100644 index 0000000..45461cb --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/spec.md @@ -0,0 +1,29 @@ +## Scenario 2 — Game Start & Drawer Flow + +### Acceptance Criteria + +**AC2.1 — Player name validation on game start** +- All player names are trimmed before the game starts. +- Any player with an empty/whitespace-only name after trim is rejected with: "All players must have a valid name." +- The game does not start if any name is invalid. + +**AC2.2 — Drawer assignment** +- When the game starts, the host (room creator) becomes the first drawer. +- The drawer is identified by `drawerId` on the room. +- The lobby and game screen clearly indicate who the drawer is. + +**AC2.3 — Secret word selection** +- The secret word is selected deterministically from `STARTER_WORDS`. +- Selection formula: `STARTER_WORDS[participantCount % STARTER_WORDS.length]`. +- The same room always produces the same word for the same participant set. + +**AC2.4 — Drawer-only word visibility** +- The secret word is included in the room snapshot only when the requesting `participantId` matches the `drawerId`. +- For guessers, the word field is `null` or omitted. +- The drawer sees: "Your word: [secret word]" on the game screen. +- Guessers see: "Waiting for drawer to start drawing..." + +### Edge Cases + +- If the host leaves (not handled — out of scope for this lab), the drawer assignment remains as-is. +- Word selection is deterministic per room state (same participants = same word). diff --git a/specs/002-game-start-and-drawer-flow/tasks.md b/specs/002-game-start-and-drawer-flow/tasks.md new file mode 100644 index 0000000..94848ca --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/tasks.md @@ -0,0 +1,16 @@ +## Scenario 2 — Game Start & Drawer Flow + +### Dependencies +- Scenario 1 complete (room has hostId, start endpoint exists) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 2.1 | Add `drawerId`, `secretWord`, `round` to `Room` and `RoomSnapshot` models | `backend/src/models/game.ts` | 1.1 | ✅ | +| 2.2 | Update `startGame()` to assign host as drawer, pick deterministic word, set round=1 | `backend/src/services/roomStore.ts` | 2.1 | ✅ | +| 2.3 | Update `toRoomSnapshot()` to include word only when `viewerParticipantId === drawerId` | `backend/src/services/roomStore.ts` | 2.1, 2.2 | ✅ | +| 2.4 | Update frontend API types to include drawerId, secretWord, round | `frontend/src/services/api.ts` | 2.1 | ✅ | +| 2.5 | Add lobby name re-validation on start (trim all names, reject if empty) | `backend/src/services/roomStore.ts` | — | ✅ | +| 2.6 | Update GamePage to show role indicator, secret word for drawer, waiting state for guessers | `frontend/src/pages/GamePage.tsx` | 2.4 | ✅ | +| 2.7 | Verify builds pass | — | 2.1–2.6 | ✅ | diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 0000000..8e744f5 --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,36 @@ +## Scenario 3 — Gameplay Interaction + +### State Model Changes + +**Backend — Room type additions:** +``` +drawingData: string // JSON string of strokes +guesses: Guess[] // array of guess objects +``` + +**Guess type:** +``` +{ participantId: string, participantName: string, text: string, correct: boolean, timestamp: string } +``` + +**RoomSnapshot additions:** +``` +drawingData: string +guesses: Guess[] +scores: Record // participantId -> score +``` + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/models/game.ts` | Add Guess interface, drawing/guesses/scores to Room and RoomSnapshot | +| `backend/src/services/roomStore.ts` | Add drawing save/clear, guess submission, scoring logic | +| `backend/src/api/schemas.ts` | Add guess submission schema | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/draw, POST /rooms/:code/guess, POST /rooms/:code/clear | +| `frontend/src/services/api.ts` | Add draw/guess/clear API methods | +| `frontend/src/state/roomStore.ts` | Add draw/guess/clear actions | +| `frontend/src/pages/GamePage.tsx` | Replace canvas placeholder with real ``; conditional drawing | +| `frontend/src/components/GuessForm.tsx` | Wire up API call, validation | +| `frontend/src/components/Scoreboard.tsx` | Render real scores from snapshot | +| `frontend/src/components/ResultPanel.tsx` | Render guess history from snapshot | diff --git a/specs/003-gameplay-interaction/spec.md b/specs/003-gameplay-interaction/spec.md new file mode 100644 index 0000000..c114841 --- /dev/null +++ b/specs/003-gameplay-interaction/spec.md @@ -0,0 +1,43 @@ +## Scenario 3 — Gameplay Interaction + +### Acceptance Criteria + +**AC3.1 — Drawing canvas** +- The game page renders an interactive HTML5 `` element. +- The canvas is only interactive for the drawer. +- Guessers see a read-only view of the canvas (latest drawing state). +- Drawing works via mouse: mousedown starts a stroke, mousemove draws, mouseup ends the stroke. +- Drawings are serialized and sent to the backend via POST /rooms/:code/draw. + +**AC3.2 — Clear canvas** +- The drawer sees a "Clear Canvas" button. +- Clicking it sends POST /rooms/:code/clear. +- The canvas is wiped and the cleared state is synced to all players via polling. + +**AC3.3 — Guess submission** +- Guessers can type a guess and click "Submit Guess". +- Empty/whitespace-only guesses are rejected with: "Guess cannot be empty." +- Guesses are trimmed before storage and comparison. +- The drawer cannot submit guesses (the form is hidden or disabled). +- Submitting a guess calls POST /rooms/:code/guess. + +**AC3.4 — Case-insensitive comparison** +- A guess is correct if, after trimming and lowercasing, it matches the secret word (lowercased). +- Example: "Rocket", "ROCKET", " rocket " all match "rocket". + +**AC3.5 — Scoring** +- A correct guess awards exactly 100 points. +- An incorrect guess awards 0 points. +- Scores are tracked per participant and persisted in the room state. +- The scoreboard reflects the latest scores from the room snapshot. + +**AC3.6 — Guess history sync** +- All guesses (correct and incorrect) are stored in the room state. +- The full guess history is included in the room snapshot. +- All players see the same guess history via polling. + +### Edge Cases + +- The drawer cannot guess (their guess button is disabled/hidden). +- Multiple correct guesses: first correct guess ends the round. +- Guesses from the same player are all stored (history shows all attempts). diff --git a/specs/003-gameplay-interaction/tasks.md b/specs/003-gameplay-interaction/tasks.md new file mode 100644 index 0000000..96cc611 --- /dev/null +++ b/specs/003-gameplay-interaction/tasks.md @@ -0,0 +1,22 @@ +## Scenario 3 — Gameplay Interaction + +### Dependencies +- Scenario 2 complete (game can start, drawer assigned, word selected) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 3.1 | Add `drawingData`, `guesses[]`, `scores` to Room model; add `Guess` interface | `backend/src/models/game.ts` | 2.1 | ✅ | +| 3.2 | Add `saveDrawing()`, `clearDrawing()`, `submitGuess()`, scoring logic to roomStore | `backend/src/services/roomStore.ts` | 3.1 | ✅ | +| 3.3 | Add Zod schemas for draw/guess/clear requests | `backend/src/api/schemas.ts` | — | ✅ | +| 3.4 | Add POST /rooms/:code/draw, POST /rooms/:code/guess, POST /rooms/:code/clear routes | `backend/src/api/rooms.ts` | 3.2, 3.3 | ✅ | +| 3.5 | Update `toRoomSnapshot()` to include drawingData, guesses, scores | `backend/src/services/roomStore.ts` | 3.1 | ✅ | +| 3.6 | Update frontend API types and add draw/guess/clear methods | `frontend/src/services/api.ts` | 3.1 | ✅ | +| 3.7 | Add draw/guess/clear actions to RoomStore | `frontend/src/state/roomStore.ts` | 3.6 | ✅ | +| 3.8 | Replace canvas placeholder with interactive ``, wire drawing + clear | `frontend/src/pages/GamePage.tsx` | 3.7 | ✅ | +| 3.9 | Wire GuessForm to submit guesses via API, add validation | `frontend/src/components/GuessForm.tsx` | 3.7 | ✅ | +| 3.10 | Update Scoreboard to render real scores from snapshot | `frontend/src/components/Scoreboard.tsx` | 3.6 | ✅ | +| 3.11 | Update ResultPanel to render guess history | `frontend/src/components/ResultPanel.tsx` | 3.6 | ✅ | +| 3.12 | Add auto-polling to GamePage for drawing/guesses/scores sync | `frontend/src/pages/GamePage.tsx` | 3.7 | ✅ | +| 3.13 | Verify builds pass | — | 3.1–3.12 | ✅ | diff --git a/specs/004-result-restart-and-final-validation/plan.md b/specs/004-result-restart-and-final-validation/plan.md new file mode 100644 index 0000000..5056eae --- /dev/null +++ b/specs/004-result-restart-and-final-validation/plan.md @@ -0,0 +1,19 @@ +## Scenario 4 — Result, Restart & Final Validation + +### State Model Changes + +No new fields. The existing `"finished"` status and `guesses`/`scores` fields serve the result state. + +**Backend — Room additions:** +- No new fields; restart is a mutation (reset specific fields, preserve participants + host) + +### File-Level Changes + +| File | Change | +|------|--------| +| `backend/src/services/roomStore.ts` | Add `restartGame()` function | +| `backend/src/api/schemas.ts` | Add restart action schema | +| `backend/src/api/rooms.ts` | Add POST /rooms/:code/restart | +| `frontend/src/services/api.ts` | Add restart API method | +| `frontend/src/state/roomStore.ts` | Add restart action | +| `frontend/src/pages/GamePage.tsx` | Show result state when finished; conditional restart button | diff --git a/specs/004-result-restart-and-final-validation/spec.md b/specs/004-result-restart-and-final-validation/spec.md new file mode 100644 index 0000000..bd95d07 --- /dev/null +++ b/specs/004-result-restart-and-final-validation/spec.md @@ -0,0 +1,35 @@ +## Scenario 4 — Result, Restart & Final Validation + +### Acceptance Criteria + +**AC4.1 — Result state** +- When a correct guess is made, the room status changes to `"finished"`. +- All players see: + - The secret word (revealed to everyone). + - Final scores for all participants. + - Full guess history (who guessed what and whether it was correct). +- The result state is available via the standard GET /rooms/:code endpoint. + +**AC4.2 — Host restart** +- Only the host sees a "Restart Game" button in the result state. +- Clicking it calls POST /rooms/:code/restart. +- Non-host participants see: "Waiting for host to restart..." + +**AC4.3 — Restart behavior** +- Restart resets: + - Room status to `"lobby"`. + - `drawerId` to `null`. + - `secretWord` to `null`. + - `drawingData` to empty. + - `guesses` to empty. + - All participant scores to 0. +- Restart preserves: + - All participants in the room. + - The host (hostId unchanged). +- After restart, all players are redirected to the lobby. + +### Edge Cases + +- Restart with only 1 player remaining: host cannot start until a second player joins. +- Restart does not change the room code. +- Multiple restarts work identically (state is fully reset each time). diff --git a/specs/004-result-restart-and-final-validation/tasks.md b/specs/004-result-restart-and-final-validation/tasks.md new file mode 100644 index 0000000..4f7c775 --- /dev/null +++ b/specs/004-result-restart-and-final-validation/tasks.md @@ -0,0 +1,17 @@ +## Scenario 4 — Result, Restart & Final Validation + +### Dependencies +- Scenario 3 complete (gameplay loop works, correct guess detected) + +### Task List + +| # | Task | File(s) | Depends On | Status | +|---|------|---------|------------|--------| +| 4.1 | Add `restartGame()` to roomStore (reset round state, preserve participants + host) | `backend/src/services/roomStore.ts` | 3.2 | ✅ | +| 4.2 | Add POST /rooms/:code/restart route with host-only validation | `backend/src/api/rooms.ts` | 4.1 | ✅ | +| 4.3 | Update frontend API with restart method | `frontend/src/services/api.ts` | 4.2 | ✅ | +| 4.4 | Add restart action to RoomStore | `frontend/src/state/roomStore.ts` | 4.3 | ✅ | +| 4.5 | Show result state (word, scores, guess history) when status === "finished" | `frontend/src/pages/GamePage.tsx` | 3.10, 3.11 | ✅ | +| 4.6 | Show restart button for host, waiting message for guessers | `frontend/src/pages/GamePage.tsx` | 4.4 | ✅ | +| 4.7 | Handle restart redirect back to lobby | `frontend/src/pages/GamePage.tsx` | 4.6 | ✅ | +| 4.8 | Verify builds pass and two-tab validation | — | 4.1–4.7 | ✅ |