diff --git a/.github/agents/speckit.analyze.agent.md b/.github/agents/speckit.analyze.agent.md index 542a3de..45b84bf 100644 --- a/.github/agents/speckit.analyze.agent.md +++ b/.github/agents/speckit.analyze.agent.md @@ -10,6 +10,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before analysis)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_analyze` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Goal. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Goal Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. @@ -41,7 +75,7 @@ Load only the minimal necessary context from each artifact: - Overview/Context - Functional Requirements -- Non-Functional Requirements +- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact) - User Stories - Edge Cases (if present) @@ -68,7 +102,7 @@ Load only the minimal necessary context from each artifact: Create internal representations (do not include raw artifacts in output): -- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`) +- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%"). - **User story/action inventory**: Discrete user actions with acceptance criteria - **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases) - **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements @@ -102,7 +136,7 @@ Focus on high-signal findings. Limit to 50 findings total; aggregate remainder i - Requirements with zero associated tasks - Tasks with no mapped requirement/story -- Non-functional requirements not reflected in tasks (e.g., performance, security) +- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks #### F. Inconsistency @@ -162,6 +196,37 @@ At end of report, output a concise Next Actions block: Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) +### 9. Check for extension hooks + +After reporting, check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_analyze` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Operating Principles ### Context Efficiency diff --git a/.github/agents/speckit.checklist.agent.md b/.github/agents/speckit.checklist.agent.md index 004de01..6115a49 100644 --- a/.github/agents/speckit.checklist.agent.md +++ b/.github/agents/speckit.checklist.agent.md @@ -31,6 +31,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before checklist generation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_checklist` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Execution Steps. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Execution Steps 1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. @@ -293,3 +327,35 @@ Sample items: - Correct: Validation of requirement quality - Wrong: "Does it do X?" - Correct: "Is X clearly specified?" + +## Post-Execution Checks + +**Check for extension hooks (after checklist generation)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_checklist` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/.github/agents/speckit.clarify.agent.md b/.github/agents/speckit.clarify.agent.md index 328e964..63c0708 100644 --- a/.github/agents/speckit.clarify.agent.md +++ b/.github/agents/speckit.clarify.agent.md @@ -14,6 +14,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before clarification)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_clarify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. @@ -142,7 +176,7 @@ Execution steps: - Functional ambiguity → Update or add a bullet in Functional Requirements. - User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario. - Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly. - - Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target). + - Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target). - Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it). - Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once. - If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text. @@ -179,3 +213,35 @@ Behavior rules: - If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale. Context for prioritization: $ARGUMENTS + +## Post-Execution Checks + +**Check for extension hooks (after clarification)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_clarify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/.github/agents/speckit.constitution.agent.md b/.github/agents/speckit.constitution.agent.md index 63d4f66..29ae9a0 100644 --- a/.github/agents/speckit.constitution.agent.md +++ b/.github/agents/speckit.constitution.agent.md @@ -14,6 +14,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before constitution update)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_constitution` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts. @@ -82,3 +116,35 @@ If the user supplies partial updates (e.g., only one principle revision), still If critical info missing (e.g., ratification date truly unknown), insert `TODO(): explanation` and include in the Sync Impact Report under deferred items. Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file. + +## Post-Execution Checks + +**Check for extension hooks (after constitution update)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_constitution` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/.github/agents/speckit.git.commit.agent.md b/.github/agents/speckit.git.commit.agent.md new file mode 100644 index 0000000..9ece588 --- /dev/null +++ b/.github/agents/speckit.git.commit.agent.md @@ -0,0 +1,51 @@ +--- +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 \ No newline at end of file diff --git a/.github/agents/speckit.git.feature.agent.md b/.github/agents/speckit.git.feature.agent.md new file mode 100644 index 0000000..ab5be09 --- /dev/null +++ b/.github/agents/speckit.git.feature.agent.md @@ -0,0 +1,70 @@ +--- +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.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 \ No newline at end of file diff --git a/.github/agents/speckit.git.initialize.agent.md b/.github/agents/speckit.git.initialize.agent.md new file mode 100644 index 0000000..597e6f7 --- /dev/null +++ b/.github/agents/speckit.git.initialize.agent.md @@ -0,0 +1,52 @@ +--- +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: +- `✓ 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 \ No newline at end of file diff --git a/.github/agents/speckit.git.remote.agent.md b/.github/agents/speckit.git.remote.agent.md new file mode 100644 index 0000000..5601e64 --- /dev/null +++ b/.github/agents/speckit.git.remote.agent.md @@ -0,0 +1,48 @@ +--- +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 \ No newline at end of file diff --git a/.github/agents/speckit.git.validate.agent.md b/.github/agents/speckit.git.validate.agent.md new file mode 100644 index 0000000..4359735 --- /dev/null +++ b/.github/agents/speckit.git.validate.agent.md @@ -0,0 +1,52 @@ +--- +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 \ No newline at end of file diff --git a/.github/agents/speckit.implement.agent.md b/.github/agents/speckit.implement.agent.md index a8cc290..d518c3d 100644 --- a/.github/agents/speckit.implement.agent.md +++ b/.github/agents/speckit.implement.agent.md @@ -10,6 +10,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before implementation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_implement` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -85,7 +119,7 @@ You **MUST** consider the user input before proceeding (if not empty). - **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*` - **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*` - **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*` - - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` + - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` - **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/` - **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/` - **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/` @@ -133,3 +167,32 @@ You **MUST** consider the user input before proceeding (if not empty). - Report final status with summary of completed work Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. + +10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_implement` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/.github/agents/speckit.plan.agent.md b/.github/agents/speckit.plan.agent.md index d6b7d0b..3669df5 100644 --- a/.github/agents/speckit.plan.agent.md +++ b/.github/agents/speckit.plan.agent.md @@ -18,6 +18,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before planning)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_plan` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -35,6 +69,35 @@ You **MUST** consider the user input before proceeding (if not empty). 4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. +5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_plan` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Phases ### Phase 0: Outline & Research diff --git a/.github/agents/speckit.specify.agent.md b/.github/agents/speckit.specify.agent.md index 727c128..8d6bda6 100644 --- a/.github/agents/speckit.specify.agent.md +++ b/.github/agents/speckit.specify.agent.md @@ -18,13 +18,47 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before specification)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_specify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command. Given that feature description, do this: -1. **Generate a concise short name** (2-4 words) for the branch: +1. **Generate a concise short name** (2-4 words) for the feature: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") @@ -36,43 +70,47 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Check for existing branches before creating new one**: +2. **Branch creation** (optional, via hook): - a. First, fetch all remote branches to ensure we have the latest information: + If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name. - ```bash - git fetch --all --prune - ``` + If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation). - b. Find the highest feature number across all sources for the short-name: - - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-$'` - - Local branches: `git branch | grep -E '^[* ]*[0-9]+-$'` - - Specs directories: Check for directories matching `specs/[0-9]+-` +3. **Create the spec feature directory**: - c. Determine the next available number: - - Extract all numbers from all three sources - - Find the highest number N - - Use N+1 for the new branch number + Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. - d. Run the script `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS"` with the calculated number and short-name: - - Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description - - Bash example: `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"` - - PowerShell example: `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"` + **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: + 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is + 2. Otherwise, auto-generate it under `specs/`: + - Check `.specify/init-options.json` for `branch_numbering` + - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) + - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) + - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` - **IMPORTANT**: - - Check all three sources (remote branches, local branches, specs directories) to find the highest number - - Only match branches/directories with the exact short-name pattern - - If no existing branches/directories found with this short-name, start with number 1 - - You must only ever run this script once per feature - - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths - - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") + **Create the directory and spec file**: + - `mkdir -p SPECIFY_FEATURE_DIRECTORY` + - Copy `.specify/templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point + - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md` + - Persist the resolved path to `.specify/feature.json`: + ```json + { + "feature_directory": "" + } + ``` + Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. + This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. -3. Load `.specify/templates/spec-template.md` to understand required sections. + **IMPORTANT**: + - You must only create one feature per `/speckit.specify` invocation + - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice + - The spec directory and file are always created by this command, never by the hook -4. Follow this execution flow: +4. Load `.specify/templates/spec-template.md` to understand required sections. - 1. Parse user description from Input +5. Follow this execution flow: + 1. Parse user description from arguments If empty: ERROR "No feature description provided" 2. Extract key concepts from description Identify: actors, actions, data, constraints @@ -96,11 +134,11 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: - a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: + a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: ```markdown # Specification Quality Checklist: [FEATURE NAME] @@ -145,7 +183,7 @@ Given that feature description, do this: c. **Handle Validation Results**: - - **If all items pass**: Mark checklist complete and proceed to step 6 + - **If all items pass**: Mark checklist complete and proceed to step 7 - **If items fail (excluding [NEEDS CLARIFICATION])**: 1. List the failing items and specific issues @@ -190,11 +228,42 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). - -**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. - -## General Guidelines +8. **Report completion** to the user with: + - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path + - `SPEC_FILE` — the spec file path + - Checklist results summary + - Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`) + +9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_specify` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + +**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command. ## Quick Guidelines diff --git a/.github/agents/speckit.tasks.agent.md b/.github/agents/speckit.tasks.agent.md index 0e932da..d94a03b 100644 --- a/.github/agents/speckit.tasks.agent.md +++ b/.github/agents/speckit.tasks.agent.md @@ -19,6 +19,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before tasks generation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_tasks` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -60,6 +94,35 @@ You **MUST** consider the user input before proceeding (if not empty). - Suggested MVP scope (typically just User Story 1) - Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths) +6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_tasks` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + Context for task generation: $ARGUMENTS The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context. diff --git a/.github/agents/speckit.taskstoissues.agent.md b/.github/agents/speckit.taskstoissues.agent.md index 9ca5b17..aa5a7d9 100644 --- a/.github/agents/speckit.taskstoissues.agent.md +++ b/.github/agents/speckit.taskstoissues.agent.md @@ -11,6 +11,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before tasks-to-issues conversion)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -28,3 +62,35 @@ git config --get remote.origin.url > [!CAUTION] > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL + +## Post-Execution Checks + +**Check for extension hooks (after tasks-to-issues conversion)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/.github/prompts/speckit.git.commit.prompt.md b/.github/prompts/speckit.git.commit.prompt.md new file mode 100644 index 0000000..ababc2b --- /dev/null +++ b/.github/prompts/speckit.git.commit.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.commit +--- diff --git a/.github/prompts/speckit.git.feature.prompt.md b/.github/prompts/speckit.git.feature.prompt.md new file mode 100644 index 0000000..9d9c1b0 --- /dev/null +++ b/.github/prompts/speckit.git.feature.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.feature +--- diff --git a/.github/prompts/speckit.git.initialize.prompt.md b/.github/prompts/speckit.git.initialize.prompt.md new file mode 100644 index 0000000..cf62da8 --- /dev/null +++ b/.github/prompts/speckit.git.initialize.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.initialize +--- diff --git a/.github/prompts/speckit.git.remote.prompt.md b/.github/prompts/speckit.git.remote.prompt.md new file mode 100644 index 0000000..fc187d0 --- /dev/null +++ b/.github/prompts/speckit.git.remote.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.remote +--- diff --git a/.github/prompts/speckit.git.validate.prompt.md b/.github/prompts/speckit.git.validate.prompt.md new file mode 100644 index 0000000..e6fcb72 --- /dev/null +++ b/.github/prompts/speckit.git.validate.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.validate +--- diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 0000000..dff278f --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,148 @@ +installed: [] +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..6464d01 --- /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": { + "copilot": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-04-14T16:06:35.812985+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..1a9c5e3 --- /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.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..4451ee6 --- /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: +- `✓ 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 100644 index 0000000..49c32fe --- /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 "✓ 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 100644 index 0000000..286aaf7 --- /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" | xargs) +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 100644 index 0000000..882a385 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,41 @@ +#!/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 +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +check_feature_branch() { + local branch="$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 + + # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) + if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 + fi + + # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + return 0 + fi + + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 +} diff --git a/.specify/extensions/git/scripts/bash/initialize-repo.sh b/.specify/extensions/git/scripts/bash/initialize-repo.sh new file mode 100644 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..e9777ff --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,149 @@ +#!/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 +} + +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "not a repo" } +} catch { + 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 +$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE +$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE +$untracked = git ls-files --others --exclude-standard 2>$null + +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 +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 +} + +Write-Host "✓ 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..8a9c4fd --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,50 @@ +#!/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 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 + } + + # Reject malformed timestamps (7-digit date or no trailing slug) + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or + ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + if ($hasMalformedTimestamp) { + Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + return $false + } + + # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + $isTimestamp = $Branch -match '^\d{8}-\d{6}-' + + if ($isSequential -or $isTimestamp) { + return $true + } + + Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + return $false +} 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..324240a --- /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 "✓ Git repository initialized" diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 0000000..39aa824 --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,9 @@ +{ + "ai": "copilot", + "branch_numbering": "sequential", + "here": true, + "integration": "copilot", + "preset": null, + "script": "ps", + "speckit_version": "0.6.2" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..9a17284 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,7 @@ +{ + "integration": "copilot", + "version": "0.6.2", + "scripts": { + "update-context": ".specify/integrations/copilot/scripts/update-context.ps1" + } +} diff --git a/.specify/integrations/copilot.manifest.json b/.specify/integrations/copilot.manifest.json new file mode 100644 index 0000000..4d4bea6 --- /dev/null +++ b/.specify/integrations/copilot.manifest.json @@ -0,0 +1,27 @@ +{ + "integration": "copilot", + "version": "0.6.2", + "installed_at": "2026-04-14T16:06:35.572321+00:00", + "files": { + ".github/agents/speckit.analyze.agent.md": "56285817b93f55ae926eade0528673d25ffcf441e8109492323bfbe8941be8f7", + ".github/agents/speckit.checklist.agent.md": "53ce35462c1df402780e730ea54150a30872e6e0d6d44a8bd38dd8b5d42900a9", + ".github/agents/speckit.clarify.agent.md": "97b14836d6174f0ef51d8bbda0b731c87ef0b011aa36ccd4171a2fc3d7e3280a", + ".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7", + ".github/agents/speckit.implement.agent.md": "9dda40376414146a8992d09e6853976c75708ba4cad7d9df9fcd6e7faa8f0a4c", + ".github/agents/speckit.plan.agent.md": "82961180ad08d9c5f33003d673996dad0bf73d06d7e7b6f07e967cff0a103a0c", + ".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b", + ".github/agents/speckit.tasks.agent.md": "d505523168e1f1c758350da9d45c08ff990ed8686d6bc8451db8b89a145768a4", + ".github/agents/speckit.taskstoissues.agent.md": "12532e50c8f9aeb453a565aec6aa6f53abe53cf92926ca553b3d2c61b6b7b6e3", + ".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77", + ".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c", + ".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4", + ".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d", + ".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80", + ".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e", + ".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98", + ".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f", + ".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d", + ".specify/integrations/copilot/scripts/update-context.ps1": "31b3cb0e13a4cbb93932ee0ac5de79436b30f58b1da63f73f8e82ca8050b9ea3", + ".specify/integrations/copilot/scripts/update-context.sh": "89abb5cf143d07d9bd4cb4e4fe2623ec850933674a5c3d2ce2236c65c99d4100" + } +} diff --git a/.specify/integrations/copilot/scripts/update-context.ps1 b/.specify/integrations/copilot/scripts/update-context.ps1 new file mode 100644 index 0000000..26e746a --- /dev/null +++ b/.specify/integrations/copilot/scripts/update-context.ps1 @@ -0,0 +1,32 @@ +# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md +# +# This is the copilot-specific implementation that produces the GitHub +# Copilot instructions file. The shared dispatcher reads +# .specify/integration.json and calls this script. +# +# NOTE: This script is not yet active. It will be activated in Stage 7 +# when the shared update-agent-context.ps1 replaces its switch statement +# with integration.json-based dispatch. The shared script must also be +# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before +# dot-sourcing will work. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +# Invoke shared update-agent-context script as a separate process. +# Dot-sourcing is unsafe until that script guards its Main call. +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/.specify/integrations/copilot/scripts/update-context.sh b/.specify/integrations/copilot/scripts/update-context.sh new file mode 100644 index 0000000..c7f3bc6 --- /dev/null +++ b/.specify/integrations/copilot/scripts/update-context.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md +# +# This is the copilot-specific implementation that produces the GitHub +# Copilot instructions file. The shared dispatcher reads +# .specify/integration.json and calls this script. +# +# NOTE: This script is not yet active. It will be activated in Stage 7 +# when the shared update-agent-context.sh replaces its case statement +# with integration.json-based dispatch. The shared script must also be +# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) +# before sourcing will work. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +# Invoke shared update-agent-context script as a separate process. +# Sourcing is unsafe until that script guards its main logic. +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..3863a67 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,6 @@ +{ + "integration": "speckit", + "version": "0.6.2", + "installed_at": "2026-04-14T16:06:35.580029+00:00", + "files": {} +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 91ec506..f4ec317 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -45,6 +45,7 @@ Previous Updates: For detailed amendment history, see [DECISIONS.md](./DECISIONS.md). ## Core Principles +prefer using the available LSP for the language over grep whenever I'm looking for symbols, call graphs, or type relationships — e.g., finding all callers of an F# domain function, or locating where a C# DTO is consumer, or using Typescript, yaml, etc. ### I. Clean Architecture, Domain-Driven Design & Ports-and-Adapters diff --git a/specs/014-ride-notes/checklists/requirements.md b/specs/014-ride-notes/checklists/requirements.md new file mode 100644 index 0000000..3892862 --- /dev/null +++ b/specs/014-ride-notes/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Ride Notes + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-14 +**Feature**: [Feature Spec](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed in one iteration; no unresolved issues found. diff --git a/specs/014-ride-notes/contracts/api-contracts.md b/specs/014-ride-notes/contracts/api-contracts.md new file mode 100644 index 0000000..fdcb71a --- /dev/null +++ b/specs/014-ride-notes/contracts/api-contracts.md @@ -0,0 +1,112 @@ +# API Contracts: Ride Notes + +**Feature**: 014-ride-notes +**Date**: 2026-04-14 + +## Scope + +This contract describes additive API shape changes required to support notes in: +- Manual ride record/edit (`/api/rides`) +- Ride history projection (`/api/rides/history`) +- CSV import validation/results (`/api/imports/*`) + +## 1. Record Ride Request (Additive) + +Endpoint: `POST /api/rides` + +### Request shape change + +Add optional `note` field: + +```json +{ + "rideDateTimeLocal": "2026-04-14T08:15:00", + "miles": 8.4, + "rideMinutes": 33, + "note": "Headwind on the bridge, took side street return route." +} +``` + +### Validation + +- `note` optional. +- When provided, `note.length <= 500`. +- On violation, return `400` validation response with explicit field-level message. + +## 2. Edit Ride Request (Additive) + +Endpoint: `PUT /api/rides/{rideId}` + +### Request shape change + +Add optional `note` field with same validation rules: + +```json +{ + "rideDateTimeLocal": "2026-04-14T08:15:00", + "miles": 8.4, + "expectedVersion": 2, + "note": "Updated: rain started halfway through ride." +} +``` + +### Validation + +- `note` optional. +- Max length 500. +- Existing optimistic concurrency behavior remains unchanged. + +## 3. Ride History Row Response (Additive) + +Endpoint: `GET /api/rides/history` + +### Response shape change + +Add optional `note` field on each `rides[]` row: + +```json +{ + "rideId": 120, + "rideDateTimeLocal": "2026-04-14T08:15:00", + "miles": 8.4, + "note": "Headwind on the bridge, took side street return route." +} +``` + +### Semantics + +- `note` null or omitted means no note indicator should render. +- `note` non-empty means history UI renders compact indicator with reveal interaction. + +## 4. CSV Import Preview/Processing (Behavioral Contract) + +Endpoints: +- `POST /api/imports/preview` +- `POST /api/imports/start` +- `GET /api/imports/{importJobId}/status` + +### Existing field + +`ImportPreviewRow.notes` remains optional and continues to carry parsed note text from CSV. + +### Validation behavior refinement + +- If parsed note length > 500, row is invalid with note-specific error. +- Oversized-note row remains in preview errors and is excluded from valid import processing. +- Other valid rows are still importable in same job. + +### Example row-level error + +```json +{ + "rowNumber": 12, + "field": "Notes", + "code": "NOTE_TOO_LONG", + "message": "Note must be 500 characters or fewer." +} +``` + +## Backward Compatibility + +- Changes are additive and non-breaking for existing clients that do not send/use `note`. +- Existing import payloads remain valid; only oversized notes produce row-level errors. diff --git a/specs/014-ride-notes/data-model.md b/specs/014-ride-notes/data-model.md new file mode 100644 index 0000000..6704d10 --- /dev/null +++ b/specs/014-ride-notes/data-model.md @@ -0,0 +1,73 @@ +# Data Model: Ride Notes + +**Feature**: 014-ride-notes +**Date**: 2026-04-14 +**Status**: Complete + +## Overview + +This feature adds note support across manual ride record/edit, ride history projection, and CSV import validation. + +## Entity: Ride + +Existing persistent ride aggregate row (`RideEntity`) gains an optional note field. + +| Field | Type | Rules / Notes | +|------|------|----------------| +| `Id` | `int` | Existing primary key | +| `RiderId` | `long` | Existing ownership scope | +| `RideDateTimeLocal` | `DateTime` | Existing required field | +| `Miles` | `decimal` | Existing range validation `(0.01..200]` | +| `Notes` | `string?` | New optional plain-text note, max 500 characters | + +### Validation invariants + +- `Notes == null` or empty is valid. +- Non-empty `Notes` must have `Length <= 500`. +- Notes are treated as plain text and must be escaped/encoded when rendered. + +## Entity: RideHistoryRow (API contract projection) + +Ride history response row includes note presence/content for UI rendering. + +| Field | Type | Rules / Notes | +|------|------|----------------| +| `RideId` | `long` | Existing | +| `RideDateTimeLocal` | `DateTime` | Existing | +| `Miles` | `decimal` | Existing | +| `Note` | `string?` | New optional field used by compact indicator and overlay reveal | + +## Entity: ImportRow + +Import row already includes `Notes`; this feature refines validation behavior. + +| Field | Type | Rules / Notes | +|------|------|----------------| +| `RowNumber` | `int` | Existing | +| `Notes` | `string?` | Existing parsed CSV Notes value | +| `ValidationStatus` | `string enum` | Existing: `valid` / `invalid` | +| `ValidationErrorsJson` | `string?` | Existing error payload store | + +### Validation invariants + +- If `Notes` length is greater than 500, row is `invalid` with a specific note-length error. +- Oversized-note rows do not block processing of other valid rows in same import job. + +## State transitions + +### Manual create/edit + +- `NoNote -> NoteSaved` when valid note text is submitted. +- `NoteSaved -> NoteUpdated` when valid edit replaces note. +- `NoteSaved -> NoNote` when note is cleared and saved. + +### Import + +- `pending -> valid -> processed` for rows where note is null/empty/<=500. +- `pending -> invalid -> failed` for rows where note length > 500. + +## Relationship map + +- One rider has many rides. +- One ride may have zero or one note value. +- One import job has many import rows; each row can carry one optional note value. diff --git a/specs/014-ride-notes/plan.md b/specs/014-ride-notes/plan.md new file mode 100644 index 0000000..f97e23c --- /dev/null +++ b/specs/014-ride-notes/plan.md @@ -0,0 +1,156 @@ +# Implementation Plan: Ride Notes + +**Branch**: `014-ride-notes` | **Date**: 2026-04-14 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/014-ride-notes/spec.md` + +## Summary + +Add rider notes across three existing flows: manual ride record/edit, ride history display, and CSV import. Notes are optional plain text capped at 500 characters, rendered safely via escaped text, and surfaced in history through a compact info indicator with hover/focus/tap accessibility. Oversized imported notes fail row-level validation while valid rows continue importing. + +## Technical Context + +**Language/Version**: C# .NET 10 (API), TypeScript 6 + React 19 (frontend), F# domain unchanged +**Primary Dependencies**: ASP.NET Core Minimal API, EF Core + SQLite, React 19 + Vite, existing import pipeline (`CsvRideImportService`), existing ride services (`RecordRideService`, `EditRideService`) +**Storage**: SQLite `Rides` table (add nullable note column), existing import job/row tables already include import notes +**Testing**: xUnit (`BikeTracking.Api.Tests`), Vitest (`BikeTracking.Frontend`), Playwright E2E +**Target Platform**: Local-first Aspire web app (Linux/macOS/Windows via DevContainer) +**Project Type**: Full-stack web app (React frontend + Minimal API backend) +**Performance Goals**: No degradation of existing ride record/history UX; history row height remains stable with note indicator; import behavior remains row-resilient +**Constraints**: 500-character hard limit; plain-text storage and escaped rendering; touch and keyboard accessibility; preserve existing event and versioning patterns +**Scale/Scope**: Single feature slice touching existing rides contracts, services, persistence, history page, and import validation + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Gate | Status | Notes | +|------|--------|-------| +| Clean Architecture / DDD / Ports-Adapters | PASS | Extend existing contracts, services, and repository-backed EF entity without leaking infra concerns to UI/domain boundaries | +| Functional Core / Side Effects | PASS | Validation and note-display helpers remain deterministic; persistence and HTTP remain at edges | +| Event Sourcing & CQRS | PASS | Ride write path remains through existing ride services/events; additive note field only | +| Quality-First / TDD | PASS | Quickstart defines mandatory red tests first with user confirmation before implementation | +| UX Consistency & Accessibility | PASS | History note reveal includes hover, focus, and touch interaction; row density preserved | +| Performance & Observability | PASS | Compact indicator avoids grid expansion; no new heavy background workloads | +| Data Validation & Integrity | PASS | Dual validation (client + server) with import row-level invalidation for oversized notes | +| Security / Learning | PASS | Plain-text notes and escaped rendering mitigate XSS risks while preserving user-entered content | +| Modularity / Contract-First | PASS | API/frontend contracts for notes defined in contracts artifact before implementation | +| TDD mandatory gate | PASS | Plan enforces red-green-refactor and explicit user confirmation on failing tests | +| Migration test coverage policy | PASS | Additive migration must include migration coverage policy test update | +| Spec completion gate | PASS | Completion includes migration application and full backend/frontend/E2E validation | + +**Constitution Check post-design**: PASS. No constitutional violations introduced; design remains additive, modular, and test-first. + +## Project Structure + +### Documentation (this feature) + +```text +specs/014-ride-notes/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── api-contracts.md +└── tasks.md +``` + +### Source Code Layout + +```text +src/BikeTracking.Api/ +├── Contracts/ +│ ├── RidesContracts.cs +│ └── ImportContracts.cs +├── Application/ +│ ├── Rides/ +│ │ ├── RecordRideService.cs +│ │ ├── EditRideService.cs +│ │ └── GetRideHistoryService.cs +│ ├── Imports/ +│ │ ├── CsvValidationRules.cs +│ │ └── CsvRideImportService.cs +│ └── Events/ +│ └── RideRecordedEventPayload.cs +└── Infrastructure/ + └── Persistence/ + ├── Entities/ + │ └── RideEntity.cs + └── Migrations/ + +src/BikeTracking.Api.Tests/ +├── Application/ +│ ├── RidesApplicationServiceTests.cs +│ └── Imports/ +│ └── CsvRideImportServiceTests.cs +└── Infrastructure/ + └── MigrationTestCoveragePolicyTests.cs + +src/BikeTracking.Frontend/src/ +├── pages/ +│ ├── RecordRidePage.tsx +│ ├── RecordRidePage.test.tsx +│ ├── HistoryPage.tsx +│ ├── HistoryPage.css +│ └── HistoryPage.test.tsx +├── pages/import-rides/ +│ ├── ImportRidesPage.tsx +│ └── ImportRidesPage.test.tsx +└── services/ + └── ridesService.ts +``` + +**Structure Decision**: Reuse the current backend/frontend split and extend only existing ride/import slices. No new projects are required. + +## Implementation Phases + +### Phase 0 - Research + +Resolved in `research.md`: +- Best-fit note model (optional plain text, max 500 chars) +- Security-safe rendering strategy (escaped output, no HTML rendering) +- Accessible compact history display pattern (hover/focus/tap) +- Import row-level failure behavior for oversized notes +- Test coverage strategy that aligns with TDD and constitution gates + +### Phase 1 - Design and Contracts + +**Slice 1.1 - Contracts first** +- Add note fields and validation constraints to ride request/response contracts +- Confirm import contracts already carry `Notes` and document oversized-note error contract + +**Slice 1.2 - Persistence and event/write model** +- Add nullable note field to `RideEntity` via migration +- Thread note through record/edit services and history projections +- Ensure ride event payload/update path preserves note + +**Slice 1.3 - Import validation** +- Extend `CsvValidationRules` with 500-char note validation +- Keep row-level invalid behavior while allowing valid row processing + +**Slice 1.4 - Frontend UX** +- Add note input/validation to record ride form +- Add compact note indicator and reveal interactions in history table +- Ensure imported notes render in history via same pattern + +### Phase 2 - Verification-ready planning output + +- Define red tests first for backend, frontend, and E2E note flows +- Define migration coverage update and full validation command matrix + +## Test Plan Summary + +| Category | Count | Location | +|----------|-------|----------| +| Backend unit - ride record/edit note validation | 6 | `RidesApplicationServiceTests.cs` and ride service tests | +| Backend unit - import oversized note row handling | 4 | import service/validation tests | +| Endpoint/integration - ride contracts with notes | 4 | rides endpoint tests | +| Persistence/migration coverage | 2 | migration + policy coverage tests | +| Frontend unit - record/history note UX | 8 | `RecordRidePage.test.tsx`, `HistoryPage.test.tsx` | +| Frontend unit - import preview note errors | 3 | `ImportRidesPage.test.tsx` | +| E2E | 3 | create/view note, import mixed rows, safe escaped rendering | +| **Total** | **30** | | + +## Complexity Tracking + +No constitution violations requiring exception. diff --git a/specs/014-ride-notes/quickstart.md b/specs/014-ride-notes/quickstart.md new file mode 100644 index 0000000..0e3aa69 --- /dev/null +++ b/specs/014-ride-notes/quickstart.md @@ -0,0 +1,114 @@ +# Developer Quickstart: Ride Notes + +**Feature**: 014-ride-notes +**Branch**: `014-ride-notes` +**Date**: 2026-04-14 + +## Overview + +Implement note support in three paths: +1. Manual ride record/edit +2. Ride history compact display +3. CSV import row validation and history surfacing + +Notes are optional plain text with a 500-character maximum. + +## Prerequisites + +- DevContainer running +- App launch command: `dotnet run --project src/BikeTracking.AppHost` +- Follow strict TDD gate: write failing tests first, run and confirm red with user before implementation + +## Implementation Order + +### Step 1: Contracts first + +Update backend and frontend contract/types before service logic. + +```text +src/BikeTracking.Api/Contracts/RidesContracts.cs +src/BikeTracking.Api/Contracts/ImportContracts.cs +src/BikeTracking.Frontend/src/services/ridesService.ts +``` + +### Step 2: Persistence and projection wiring + +Add note persistence to rides and map note into history rows. + +```text +src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +src/BikeTracking.Api/Infrastructure/Persistence/Migrations/{timestamp}_AddRideNotes.cs +src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +``` + +### Step 3: Manual record/edit handling + +Thread notes through record and edit write paths. + +```text +src/BikeTracking.Api/Application/Rides/RecordRideService.cs +src/BikeTracking.Api/Application/Rides/EditRideService.cs +src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +``` + +### Step 4: Import validation behavior + +Add row-level note length validation (`>500`) while keeping valid-row processing. + +```text +src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs +src/BikeTracking.Api/Application/Imports/CsvRideImportService.cs +``` + +### Step 5: Frontend note UX + +Add note input to record page and compact indicator overlay behavior to history page. + +```text +src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +src/BikeTracking.Frontend/src/pages/HistoryPage.css +src/BikeTracking.Frontend/src/pages/import-rides/ImportRidesPage.tsx +``` + +## TDD-First Test Checklist + +Write and run these failing tests first. + +### Backend unit/integration + +- Record ride accepts valid note (<=500) and persists it. +- Record ride rejects note over 500 with clear validation. +- Edit ride updates/clears note and keeps version behavior intact. +- History response includes note field for rides with notes. +- CSV row with note >500 is invalid with `NOTE_TOO_LONG` (row-level). +- Import still processes other valid rows when one row note is invalid. + +### Frontend unit + +- Record ride form renders note input and enforces max length behavior. +- History row shows note indicator only when note exists. +- Note reveal works for keyboard focus and touch tap interactions. +- Import preview shows note-length row error and still allows valid rows. + +### E2E + +- Rider records note and sees indicator/reveal in history. +- Rider edits note and sees updated text in history. +- Rider imports CSV with mixed valid and oversized notes; valid rows import, oversized rows flagged. + +## Verification Commands + +Run after each meaningful slice: + +```bash +dotnet test BikeTracking.slnx +cd src/BikeTracking.Frontend && npm run lint && npm run build && npm run test:unit +cd src/BikeTracking.Frontend && npm run test:e2e +``` + +Formatting before merge: + +```bash +csharpier format . +``` diff --git a/specs/014-ride-notes/research.md b/specs/014-ride-notes/research.md new file mode 100644 index 0000000..9c1c3b0 --- /dev/null +++ b/specs/014-ride-notes/research.md @@ -0,0 +1,70 @@ +# Research: Ride Notes + +**Feature**: 014-ride-notes +**Date**: 2026-04-14 +**Status**: Complete + +## Decision 1: Note storage model + +**Decision**: Store ride notes as optional plain text with a hard maximum of 500 characters. + +**Rationale**: +- Matches explicit clarifications and FR-005. +- Supports rides without notes (FR-004). +- Fits current `RideEntity` scalar-field pattern. + +**Alternatives considered**: +- Separate notes table: rejected as unnecessary complexity for a single optional ride attribute. +- Unlimited-length note text: rejected due to UX and data quality constraints. + +## Decision 2: Security and rendering behavior + +**Decision**: Treat notes as plain text only and always render with escaped output (no raw HTML rendering). + +**Rationale**: +- Prevents script injection in history overlays and import previews. +- Preserves punctuation and line breaks without introducing HTML sanitization dependencies. +- Aligns with clarification to store plain text and encode on display. + +**Alternatives considered**: +- Allow HTML or Markdown: rejected due to XSS risk and out-of-scope formatting behavior. +- Strip subsets of tags: rejected due to ambiguous edge cases and inconsistent UX. + +## Decision 3: History UX pattern for dense rows + +**Decision**: Use a compact per-row note indicator icon shown only when a note exists, with reveal via hover/focus and equivalent tap behavior. + +**Rationale**: +- Preserves row density and avoids grid reflow. +- Satisfies keyboard and touch accessibility requirements. +- Reuses existing lightweight icon affordance pattern in the history UI. + +**Alternatives considered**: +- Full inline note column text: rejected because it expands row height and harms scanability. +- Click-only modal without hover/focus support: rejected because desktop discoverability and keyboard usability degrade. + +## Decision 4: Import validation for oversized notes + +**Decision**: If imported note length exceeds 500 characters, mark only that row invalid and continue importing other valid rows. + +**Rationale**: +- Matches FR-016 and accepted clarification. +- Preserves resilient import behavior for mixed-quality CSVs. +- Keeps import failure localized and actionable. + +**Alternatives considered**: +- Auto-truncate notes: rejected because it silently mutates user data. +- Fail entire import: rejected because one bad row should not block all valid rows. + +## Decision 5: Test strategy + +**Decision**: Add note coverage at backend unit/service level, frontend component/unit level, and E2E happy/security paths. + +**Rationale**: +- Honors constitution TDD gate and multi-layer validation requirements. +- Ensures parity across manual entry, history display, and import. +- Catches regressions in both contracts and UI behavior. + +**Alternatives considered**: +- Backend-only tests: rejected because hover/focus/tap UI behavior would be unverified. +- Frontend-only tests: rejected because contract and import validation rules require backend assertions. diff --git a/specs/014-ride-notes/spec.md b/specs/014-ride-notes/spec.md new file mode 100644 index 0000000..b4ba572 --- /dev/null +++ b/specs/014-ride-notes/spec.md @@ -0,0 +1,140 @@ +# Feature Specification: Ride Notes + +**Feature Branch**: `014-ride-notes` +**Created**: 2026-04-14 +**Status**: Draft +**Input**: User description: "Add Notes to the ride record, ride history (in an info icon with hover to save space in the grid row) and import." + +## Clarifications + +### Session 2026-04-14 + +- Q: What is the maximum note length? -> A: 500 characters. +- Q: How should note text be handled for safe display? -> A: Store as plain text and always escape/encode on display. +- Q: What should happen when an imported row note exceeds 500 characters? -> A: Mark that row invalid and continue importing other valid rows. + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Add and Edit Ride Notes (Priority: P1) + +As a rider, I can enter a free-text note when creating or editing a ride so I can capture context such as commute issues, route conditions, or reminders. + +**Why this priority**: Capturing notes at record time is the core value. Without this, there is no note data to show in history or import. + +**Independent Test**: Can be fully tested by creating a ride with a note, editing the note, and confirming the updated note is retained when the ride is reopened. + +**Acceptance Scenarios**: + +1. **Given** a signed-in rider on the ride record form, **When** they provide note text and save the ride, **Then** the ride is saved with that note attached. +2. **Given** an existing ride with a note, **When** the rider edits and saves the note, **Then** the updated note replaces the previous note for that ride revision. +3. **Given** a rider leaves the note blank, **When** they save the ride, **Then** the ride is saved successfully with no note. + +--- + +### User Story 2 - View Notes in Compact Ride History (Priority: P2) + +As a rider, I can quickly discover and read notes in ride history through a compact indicator so the grid stays dense and readable. + +**Why this priority**: The request explicitly requires space-saving display in history rows while still making notes accessible. + +**Independent Test**: Can be fully tested by viewing ride history with mixed rows (some with notes, some without) and confirming note visibility through an info indicator with hover/focus behavior. + +**Acceptance Scenarios**: + +1. **Given** a ride history row with a saved note, **When** the row is rendered, **Then** the row shows an info icon indicator instead of expanding the full note text inline. +2. **Given** a rider points to or focuses the info icon for a row with notes, **When** the interaction occurs, **Then** the note content is revealed in a lightweight overlay without changing row height. +3. **Given** a ride history row without a note, **When** the row is rendered, **Then** no note indicator is shown. +4. **Given** a touch-only device where hover is unavailable, **When** the rider taps the note indicator, **Then** the note content is revealed in an equivalent tap-accessible way. + +--- + +### User Story 3 - Import Notes from CSV (Priority: P2) + +As a rider, I can include notes in imported ride data so my historical context is preserved without manual re-entry. + +**Why this priority**: Import is a major ingestion path for historical rides; losing notes during import would create incomplete records. + +**Independent Test**: Can be fully tested by importing a CSV containing a Notes column and verifying imported rides retain note text and show note indicators in history. + +**Acceptance Scenarios**: + +1. **Given** a valid import file containing a Notes column, **When** the import completes, **Then** each imported ride includes its note text from the corresponding row. +2. **Given** an import file that omits Notes values for some rows, **When** import completes, **Then** those rows are imported successfully with empty notes. +3. **Given** imported rides include notes, **When** the rider opens ride history, **Then** imported rides with notes show the same note indicator and reveal behavior as manually created rides. + +--- + +### Edge Cases + +- A note exceeds 500 characters; the rider receives clear validation and the ride is not saved until corrected. +- A note contains punctuation, line breaks, or quoted text; the note is preserved and displayed as entered. +- A CSV row contains an empty Notes field; the row still imports. +- A CSV file includes note text longer than 500 characters; that row is marked invalid with a clear message while other valid rows continue importing. +- A rider navigates ride history with keyboard only; note content remains discoverable through focus interaction, not hover alone. +- A rider has many rides with notes; note indicators do not cause row height expansion or layout instability. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a note field when creating a ride. +- **FR-002**: System MUST provide the same note field when editing an existing ride. +- **FR-003**: System MUST persist note text as part of the saved ride data so it can be retrieved later. +- **FR-004**: System MUST allow rides to be saved with no note. +- **FR-005**: System MUST enforce a maximum note length of 500 characters and show user-facing validation when exceeded. +- **FR-006**: System MUST render a compact note indicator in ride history rows only when a ride has a note. +- **FR-007**: System MUST reveal note content from the ride history indicator through hover or focus interaction without increasing row height. +- **FR-008**: System MUST provide an equivalent non-hover interaction for note reveal on touch devices. +- **FR-009**: System MUST not show a note indicator for rows where note data is absent. +- **FR-010**: System MUST support importing notes from the CSV Notes column when present. +- **FR-011**: System MUST continue importing rows when note values are blank. +- **FR-012**: System MUST apply the same note validation rules to imported notes as manual-entry notes. +- **FR-013**: System MUST make imported notes available in ride history with the same indicator and reveal behavior as manual rides. +- **FR-014**: System MUST preserve note text exactly as entered in supported characters and spacing (subject to validation limits). +- **FR-015**: System MUST treat notes as plain text and MUST escape/encode note content whenever rendered in the UI. +- **FR-016**: System MUST treat imported notes longer than 500 characters as row-level validation failures, mark those rows invalid with a specific error, and continue importing other valid rows. + +### Key Entities *(include if feature involves data)* + +- **Ride Note**: Free-text rider-provided context attached to a ride entry. Attributes include note text, note presence flag, and last-updated timestamp context. +- **Ride Record**: A single ride entry that may include a note alongside date, distance, duration, and related ride attributes. +- **Imported Ride Row**: One parsed CSV row that can include a Notes value mapped into the resulting ride record. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 95% of riders in acceptance testing can add a note while recording a ride in under 20 seconds. +- **SC-002**: 100% of rides saved with valid note text (0-500 characters) retain that note when reopened for edit or viewed later. +- **SC-003**: 100% of history rows with notes display a note indicator, and 0% of rows without notes display the indicator. +- **SC-004**: 95% of riders can read a ride note from history in one interaction (hover/focus or tap) without row expansion. +- **SC-005**: 100% of imported CSV rows with valid Notes values preserve note text on resulting rides. +- **SC-006**: Import success rate for rows with blank Notes remains equal to rows without a Notes column. +- **SC-007**: 100% of imported rows with notes over 500 characters are rejected at row level with explicit note-length errors, while other valid rows in the same file are still imported. + +## Assumptions + +- Existing ride import supports a Notes column in the incoming file structure. +- Existing authentication and ride ownership rules continue to apply to notes exactly as they apply to other ride fields. +- Manual entry and import share one validation policy with a 500-character note limit. +- Notes are stored and rendered as plain text, not HTML markup. +- Notes are intended for personal rider context and are displayed only to that rider. + +## Dependencies + +- Ride record create/edit flow remains the source of truth for manual note entry. +- Ride history grid continues to support compact per-row metadata indicators. +- CSV import mapping remains configurable enough to include the Notes field consistently. diff --git a/specs/014-ride-notes/tasks.md b/specs/014-ride-notes/tasks.md new file mode 100644 index 0000000..46a8724 --- /dev/null +++ b/specs/014-ride-notes/tasks.md @@ -0,0 +1,182 @@ +# Tasks: Ride Notes + +**Input**: Design documents from `/specs/014-ride-notes/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api-contracts.md, quickstart.md + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare implementation scaffolding and validation workflow for this feature. + +- [X] T001 Prepare EF migration scaffolding for ride note column in src/BikeTracking.Api/Infrastructure/Persistence/Migrations/ +- [X] T002 [P] Prepare backend TDD scaffolding for note scenarios in src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +- [X] T003 [P] Prepare frontend TDD scaffolding for note scenarios in src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core contracts and persistence changes required before any user story work. + +**CRITICAL**: No user story tasks begin until this phase is complete. + +- [X] T004 Add optional `Notes` field to ride persistence model in src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +- [X] T005 Add and apply additive migration for `Rides.Notes` (nullable, max 500) in src/BikeTracking.Api/Infrastructure/Persistence/Migrations/ +- [X] T006 Extend ride API contracts with optional note + 500-char validation in src/BikeTracking.Api/Contracts/RidesContracts.cs +- [X] T007 [P] Extend frontend ride DTO types with note fields in src/BikeTracking.Frontend/src/services/ridesService.ts +- [X] T008 Thread note through ride event payload contract in src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +- [X] T009 Update migration coverage policy test for new migration in src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs + +**Checkpoint**: Foundation complete; user stories can now be implemented. + +--- + +## Phase 3: User Story 1 - Add and Edit Ride Notes (Priority: P1) MVP + +**Goal**: Riders can create, edit, and clear plain-text notes (0-500 chars) on rides. + +**Independent Test**: Create a ride with a note, edit the note, clear it, and confirm data persists correctly. + +### Tests for User Story 1 + +- [X] T010 [P] [US1] Add backend failing tests for create/edit note validation and persistence in src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +- [X] T011 [P] [US1] Add frontend failing tests for record form note input and max-length behavior in src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx + +### Implementation for User Story 1 + +- [X] T012 [US1] Implement note persistence and validation in record flow in src/BikeTracking.Api/Application/Rides/RecordRideService.cs +- [X] T013 [US1] Implement note persistence and validation in edit flow in src/BikeTracking.Api/Application/Rides/EditRideService.cs +- [X] T014 [US1] Add note field and client-side note validation to record ride UI in src/BikeTracking.Frontend/src/pages/RecordRidePage.tsx +- [X] T015 [US1] Ensure note is carried through ride create/edit endpoint mapping in src/BikeTracking.Api/Endpoints/RidesEndpoints.cs + +**Checkpoint**: US1 is independently functional and testable. + +--- + +## Phase 4: User Story 2 - View Notes in Compact Ride History (Priority: P2) + +**Goal**: History rows show a compact note indicator with hover/focus/tap reveal without row expansion. + +**Independent Test**: View rides with and without notes; verify icon visibility rules and accessible reveal interactions. + +### Tests for User Story 2 + +- [X] T016 [P] [US2] Add backend failing tests for note projection in history responses in src/BikeTracking.Api.Tests/Application/GetRideHistoryServiceTests.cs +- [X] T017 [P] [US2] Add frontend failing tests for note indicator and reveal interactions in src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx + +### Implementation for User Story 2 + +- [X] T018 [US2] Project note values into ride history API rows in src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +- [X] T019 [US2] Add compact note indicator and escaped note reveal interactions in src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +- [X] T020 [US2] Add styles for hover/focus/tap note reveal overlay without row-height growth in src/BikeTracking.Frontend/src/pages/HistoryPage.css + +**Checkpoint**: US2 is independently functional and testable. + +--- + +## Phase 5: User Story 3 - Import Notes from CSV (Priority: P2) + +**Goal**: Import path accepts note values, rejects note values >500 at row level, and continues valid rows. + +**Independent Test**: Import CSV with mixed valid/oversized/blank notes and verify row-level outcomes plus history visibility. + +### Tests for User Story 3 + +- [X] T021 [P] [US3] Add backend failing tests for CSV note validation and row-level continuation in src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs +- [X] T022 [P] [US3] Add frontend failing tests for import preview note errors in src/BikeTracking.Frontend/src/pages/import-rides/ImportRidesPage.test.tsx + +### Implementation for User Story 3 + +- [X] T023 [US3] Add note-length rule (`<=500`) with `NOTE_TOO_LONG` errors in src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs +- [X] T024 [US3] Ensure import processing marks oversized-note rows invalid while continuing valid rows in src/BikeTracking.Api/Application/Imports/CsvRideImportService.cs +- [X] T025 [US3] Ensure imported note values are persisted through ride-write mapping in src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs +- [X] T026 [US3] Surface note-related preview validation feedback in import UI in src/BikeTracking.Frontend/src/pages/import-rides/ImportRidesPage.tsx + +**Checkpoint**: US3 is independently functional and testable. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final hardening and full-stack validation. + +- [X] T027 [P] Add/refresh manual API examples with note payloads in src/BikeTracking.Api/BikeTracking.Api.http +- [X] T028 Run full backend and frontend validation matrix from quickstart in specs/014-ride-notes/quickstart.md +- [X] T029 [P] Run formatting and cleanup pass for touched backend files in src/BikeTracking.Api/ +- [X] T030 [P] Run formatting and cleanup pass for touched frontend files in src/BikeTracking.Frontend/src/ + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup (Phase 1): starts immediately. +- Foundational (Phase 2): depends on Setup; blocks all user stories. +- User Stories (Phases 3-5): depend on Foundational completion. +- Polish (Phase 6): depends on completing desired user stories. + +### User Story Dependencies + +- **US1 (P1)**: starts immediately after Foundational completion. +- **US2 (P2)**: starts after Foundational completion; does not require US3. +- **US3 (P2)**: starts after Foundational completion; reuses history note rendering from US2 for final visibility checks. + +### Within Each User Story + +- Tests must be written and verified failing before implementation. +- Backend contract/service changes before frontend wiring. +- Story must pass its independent test criteria before moving on. + +--- + +## Parallel Execution Examples + +### User Story 1 + +```bash +# Parallel test authoring +T010 in src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +T011 in src/BikeTracking.Frontend/src/pages/RecordRidePage.test.tsx +``` + +### User Story 2 + +```bash +# Parallel backend/frontend test authoring +T016 in src/BikeTracking.Api.Tests/Application/GetRideHistoryServiceTests.cs +T017 in src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +``` + +### User Story 3 + +```bash +# Parallel import test authoring +T021 in src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs +T022 in src/BikeTracking.Frontend/src/pages/import-rides/ImportRidesPage.test.tsx +``` + +--- + +## Implementation Strategy + +### MVP First (US1) + +1. Complete Phase 1 and Phase 2. +2. Complete US1 (Phase 3). +3. Validate US1 independently before moving to P2 stories. + +### Incremental Delivery + +1. Deliver US1 (note capture/edit). +2. Deliver US2 (compact history visibility). +3. Deliver US3 (import note behavior and row-level invalid handling). +4. Finish with cross-cutting polish and validation. + +### Team Parallelization + +1. Team aligns on Foundational tasks. +2. After Phase 2 completion: + - Dev A: US1 + - Dev B: US2 + - Dev C: US3 +3. Re-integrate in Phase 6 with full validation. diff --git a/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs index db157b0..cacd43b 100644 --- a/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs @@ -81,4 +81,40 @@ public async Task TokenBucketThrottle_WaitsWhenTokensExhausted() Assert.True(sw.ElapsedMilliseconds >= 50); } + + [Fact] + public void ValidateRow_WithNoteLongerThanFiveHundredChars_ReturnsNoteTooLongError() + { + var row = new ParsedCsvRow( + RowNumber: 1, + Date: "2026-04-14", + Miles: "12.3", + Time: "45", + Temp: "63", + Tags: "commute", + Notes: new string('n', 501) + ); + + var errors = CsvValidationRules.ValidateRow(row); + + Assert.Contains(errors, error => error.Code == "NOTE_TOO_LONG" && error.Field == "Notes"); + } + + [Fact] + public void ValidateRow_WithBlankNote_DoesNotReturnNoteTooLongError() + { + var row = new ParsedCsvRow( + RowNumber: 1, + Date: "2026-04-14", + Miles: "12.3", + Time: "45", + Temp: "63", + Tags: "commute", + Notes: "" + ); + + var errors = CsvValidationRules.ValidateRow(row); + + Assert.DoesNotContain(errors, error => error.Code == "NOTE_TOO_LONG"); + } } diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs index 5c81dbe..5b05546 100644 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs @@ -98,6 +98,74 @@ public async Task RecordRideService_WithWeatherFields_PersistsWeatherAndEventPay Assert.True(eventPayload.WeatherUserOverridden); } + [Fact] + public async Task RecordRideService_WithValidNote_PersistsRideNoteAndEventPayloadNote() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Notes Rider", + NormalizedName = "notes rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var note = "Bridge detour this morning."; + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 8.2m, + RideMinutes: 32, + Temperature: 64m, + Note: note + ); + + var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); + + var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); + Assert.Equal(note, persistedRide.Notes); + Assert.Equal(note, eventPayload.Note); + } + + [Fact] + public async Task RecordRideService_WithNoteLongerThanFiveHundredChars_ThrowsArgumentException() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Long Notes Rider", + NormalizedName = "long notes rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var tooLongNote = new string('n', 501); + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 7.1m, + RideMinutes: 28, + Temperature: 60m, + Note: tooLongNote + ); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + [Fact] public async Task RecordRideService_CapturesUserSettingsSnapshots_OnRideAndEventPayload() { @@ -652,6 +720,37 @@ public async Task GetRideHistoryService_WithPageSize_RespectsPagination() Assert.Equal(5, result.TotalRows); } + [Fact] + public async Task GetRideHistoryService_WithRideNote_ProjectsNoteInHistoryRow() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Note History", + NormalizedName = "note history", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + context.Rides.Add( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 6.4m, + Notes = "Strong crosswind near downtown bridge.", + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context); + var result = await service.GetRideHistoryAsync(user.UserId, null, null); + + Assert.Single(result.Rides); + Assert.Equal("Strong crosswind near downtown bridge.", result.Rides[0].Note); + } + [Fact] public async Task EditRideService_WithValidRequest_UpdatesRideVersionAndQueuesOutboxEvent() { diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index 5b0aea3..5706175 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -34,6 +34,8 @@ public sealed class MigrationTestCoveragePolicyTests "Added test: import endpoint and persistence integration coverage validates ImportJobs and ImportRows schema after migration.", ["20260409150320_AddGasPriceWeeklyDeduplication"] = "Updated test: gas lookup and import enrichment coverage validates weekly WeekStartDate cache-key behavior after schema migration.", + ["20260414164512_AddRideNotes"] = + "Added test: rides service and history projection coverage validates note persistence and retrieval after schema migration.", }; [Fact] diff --git a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs index 5b653e8..00553c4 100644 --- a/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideEditedEventPayload.cs @@ -18,6 +18,7 @@ public sealed record RideEditedEventPayload( int? RelativeHumidityPercent, int? CloudCoverPercent, string? PrecipitationType, + string? Note, bool WeatherUserOverridden, decimal? SnapshotAverageCarMpg, decimal? SnapshotMileageRateCents, @@ -44,6 +45,7 @@ public static RideEditedEventPayload Create( int? relativeHumidityPercent = null, int? cloudCoverPercent = null, string? precipitationType = null, + string? note = null, bool weatherUserOverridden = false, decimal? snapshotAverageCarMpg = null, decimal? snapshotMileageRateCents = null, @@ -70,6 +72,7 @@ public static RideEditedEventPayload Create( RelativeHumidityPercent: relativeHumidityPercent, CloudCoverPercent: cloudCoverPercent, PrecipitationType: precipitationType, + Note: note, WeatherUserOverridden: weatherUserOverridden, SnapshotAverageCarMpg: snapshotAverageCarMpg, SnapshotMileageRateCents: snapshotMileageRateCents, diff --git a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs index 1c7b5d5..d1e5632 100644 --- a/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs +++ b/src/BikeTracking.Api/Application/Events/RideRecordedEventPayload.cs @@ -15,6 +15,7 @@ public sealed record RideRecordedEventPayload( int? RelativeHumidityPercent, int? CloudCoverPercent, string? PrecipitationType, + string? Note, bool WeatherUserOverridden, decimal? SnapshotAverageCarMpg, decimal? SnapshotMileageRateCents, @@ -38,6 +39,7 @@ public static RideRecordedEventPayload Create( int? relativeHumidityPercent = null, int? cloudCoverPercent = null, string? precipitationType = null, + string? note = null, bool weatherUserOverridden = false, decimal? snapshotAverageCarMpg = null, decimal? snapshotMileageRateCents = null, @@ -61,6 +63,7 @@ public static RideRecordedEventPayload Create( RelativeHumidityPercent: relativeHumidityPercent, CloudCoverPercent: cloudCoverPercent, PrecipitationType: precipitationType, + Note: note, WeatherUserOverridden: weatherUserOverridden, SnapshotAverageCarMpg: snapshotAverageCarMpg, SnapshotMileageRateCents: snapshotMileageRateCents, diff --git a/src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs b/src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs index feaf900..8063e99 100644 --- a/src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs +++ b/src/BikeTracking.Api/Application/Imports/CsvValidationRules.cs @@ -81,6 +81,18 @@ public static IReadOnlyList ValidateRow(ParsedCsvRow row) ); } + if (row.Notes is not null && row.Notes.Length > 500) + { + errors.Add( + new ImportValidationError( + row.RowNumber, + "NOTE_TOO_LONG", + "Note must be 500 characters or fewer.", + "Notes" + ) + ); + } + return errors; } diff --git a/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs b/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs index 4ae156b..8a8d301 100644 --- a/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs +++ b/src/BikeTracking.Api/Application/Imports/ImportJobProcessor.cs @@ -215,6 +215,7 @@ ImportEnrichmentLookup enrichment RelativeHumidityPercent: weather?.RelativeHumidityPercent, CloudCoverPercent: weather?.CloudCoverPercent, PrecipitationType: weather?.PrecipitationType, + Note: row.Notes, WeatherUserOverridden: csvTemperature.HasValue ); } diff --git a/src/BikeTracking.Api/Application/Rides/EditRideService.cs b/src/BikeTracking.Api/Application/Rides/EditRideService.cs index c3bf4bb..c0a7d02 100644 --- a/src/BikeTracking.Api/Application/Rides/EditRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/EditRideService.cs @@ -132,6 +132,11 @@ public async Task ExecuteAsync( ride.RelativeHumidityPercent = relativeHumidityPercent; ride.CloudCoverPercent = cloudCoverPercent; ride.PrecipitationType = precipitationType; + ride.Notes = request.Note ?? ride.Notes; + if (request.Note is not null && request.Note.Length == 0) + { + ride.Notes = null; + } ride.WeatherUserOverridden = request.WeatherUserOverridden; ride.Version = currentVersion + 1; @@ -152,6 +157,7 @@ public async Task ExecuteAsync( relativeHumidityPercent: relativeHumidityPercent, cloudCoverPercent: cloudCoverPercent, precipitationType: precipitationType, + note: ride.Notes, weatherUserOverridden: request.WeatherUserOverridden, snapshotAverageCarMpg: ride.SnapshotAverageCarMpg, snapshotMileageRateCents: ride.SnapshotMileageRateCents, @@ -237,6 +243,14 @@ public async Task ExecuteAsync( ); } + if (request.Note is not null && request.Note.Length > 500) + { + return EditRideResult.Failure( + "VALIDATION_FAILED", + "Note must be 500 characters or fewer." + ); + } + return null; } diff --git a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs index 577ab49..a375798 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs @@ -122,7 +122,8 @@ public async Task GetRideHistoryAsync( WindDirectionDeg: r.WindDirectionDeg, RelativeHumidityPercent: r.RelativeHumidityPercent, CloudCoverPercent: r.CloudCoverPercent, - PrecipitationType: r.PrecipitationType + PrecipitationType: r.PrecipitationType, + Note: r.Notes )) .ToList(); diff --git a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs index 7f100bc..c83f4c7 100644 --- a/src/BikeTracking.Api/Application/Rides/RecordRideService.cs +++ b/src/BikeTracking.Api/Application/Rides/RecordRideService.cs @@ -36,6 +36,11 @@ ILogger logger ); } + if (request.Note is not null && request.Note.Length > 500) + { + throw new ArgumentException("Note must be 500 characters or fewer", nameof(request)); + } + var userSettings = await dbContext .UserSettings.AsNoTracking() .SingleOrDefaultAsync(settings => settings.UserId == riderId, cancellationToken); @@ -90,6 +95,7 @@ ILogger logger RelativeHumidityPercent = relativeHumidityPercent, CloudCoverPercent = cloudCoverPercent, PrecipitationType = precipitationType, + Notes = request.Note, WeatherUserOverridden = request.WeatherUserOverridden, CreatedAtUtc = DateTime.UtcNow, }; @@ -109,6 +115,7 @@ ILogger logger relativeHumidityPercent: relativeHumidityPercent, cloudCoverPercent: cloudCoverPercent, precipitationType: precipitationType, + note: request.Note, weatherUserOverridden: request.WeatherUserOverridden, snapshotAverageCarMpg: rideEntity.SnapshotAverageCarMpg, snapshotMileageRateCents: rideEntity.SnapshotMileageRateCents, diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index 35d8ef4..302a92b 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -14,7 +14,8 @@ X-User-Id: {{RiderId}} "rideDateTimeLocal": "2026-03-26T07:30:00", "miles": 18.4, "rideMinutes": 58, - "temperature": 54 + "temperature": 54, + "note": "Headwind on river trail." } ### Record a ride (weather fields) @@ -145,6 +146,7 @@ X-User-Id: {{RiderId}} "miles": 19.5, "rideMinutes": 62, "temperature": 56, + "note": "Updated route due to bridge closure.", "expectedVersion": 1 } @@ -163,6 +165,7 @@ X-User-Id: {{RiderId}} "relativeHumidityPercent": 66, "cloudCoverPercent": 40, "precipitationType": "snow", + "note": "Steady crosswind for first 20 minutes.", "weatherUserOverridden": true, "expectedVersion": 1 } diff --git a/src/BikeTracking.Api/Contracts/RidesContracts.cs b/src/BikeTracking.Api/Contracts/RidesContracts.cs index 6ae1f21..fc53c8a 100644 --- a/src/BikeTracking.Api/Contracts/RidesContracts.cs +++ b/src/BikeTracking.Api/Contracts/RidesContracts.cs @@ -26,6 +26,8 @@ public sealed record RecordRideRequest( int? CloudCoverPercent = null, [property: MaxLength(50, ErrorMessage = "Precipitation type must be 50 characters or fewer")] string? PrecipitationType = null, + [property: MaxLength(500, ErrorMessage = "Note must be 500 characters or fewer")] + string? Note = null, bool WeatherUserOverridden = false ); @@ -105,6 +107,8 @@ public sealed record EditRideRequest( int? CloudCoverPercent = null, [property: MaxLength(50, ErrorMessage = "Precipitation type must be 50 characters or fewer")] string? PrecipitationType = null, + [property: MaxLength(500, ErrorMessage = "Note must be 500 characters or fewer")] + string? Note = null, bool WeatherUserOverridden = false ); @@ -136,6 +140,7 @@ public sealed record RideHistoryRow( int? RelativeHumidityPercent = null, int? CloudCoverPercent = null, string? PrecipitationType = null, + string? Note = null, bool WeatherUserOverridden = false ); diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index e2e7c07..9bbaecd 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -129,6 +129,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(static x => x.RelativeHumidityPercent); entity.Property(static x => x.CloudCoverPercent); entity.Property(static x => x.PrecipitationType).HasMaxLength(50); + entity.Property(static x => x.Notes).HasMaxLength(500); entity.Property(static x => x.WeatherUserOverridden).HasDefaultValue(false); entity .Property(static x => x.Version) diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs index f689ff3..f63268c 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs @@ -34,6 +34,8 @@ public sealed class RideEntity public string? PrecipitationType { get; set; } + public string? Notes { get; set; } + public bool WeatherUserOverridden { get; set; } public int Version { get; set; } = 1; diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs index 2a2412a..bf51978 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260406183601_AddDashboardSnapshotsAndPreferences.cs @@ -15,14 +15,16 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "UserSettings", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: false + ); migrationBuilder.AddColumn( name: "DashboardGoalProgressEnabled", table: "UserSettings", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: false + ); migrationBuilder.AddColumn( name: "SnapshotAverageCarMpg", @@ -30,7 +32,8 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "TEXT", precision: 10, scale: 4, - nullable: true); + nullable: true + ); migrationBuilder.AddColumn( name: "SnapshotMileageRateCents", @@ -38,7 +41,8 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "TEXT", precision: 10, scale: 4, - nullable: true); + nullable: true + ); migrationBuilder.AddColumn( name: "SnapshotOilChangePrice", @@ -46,7 +50,8 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "TEXT", precision: 10, scale: 4, - nullable: true); + nullable: true + ); migrationBuilder.AddColumn( name: "SnapshotYearlyGoalMiles", @@ -54,27 +59,32 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "TEXT", precision: 10, scale: 4, - nullable: true); + nullable: true + ); migrationBuilder.AddCheckConstraint( name: "CK_Rides_SnapshotAverageCarMpg_Positive", table: "Rides", - sql: "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + sql: "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0" + ); migrationBuilder.AddCheckConstraint( name: "CK_Rides_SnapshotMileageRateCents_Positive", table: "Rides", - sql: "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + sql: "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0" + ); migrationBuilder.AddCheckConstraint( name: "CK_Rides_SnapshotOilChangePrice_Positive", table: "Rides", - sql: "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + sql: "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0" + ); migrationBuilder.AddCheckConstraint( name: "CK_Rides_SnapshotYearlyGoalMiles_Positive", table: "Rides", - sql: "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + sql: "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0" + ); } /// @@ -82,43 +92,41 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropCheckConstraint( name: "CK_Rides_SnapshotAverageCarMpg_Positive", - table: "Rides"); + table: "Rides" + ); migrationBuilder.DropCheckConstraint( name: "CK_Rides_SnapshotMileageRateCents_Positive", - table: "Rides"); + table: "Rides" + ); migrationBuilder.DropCheckConstraint( name: "CK_Rides_SnapshotOilChangePrice_Positive", - table: "Rides"); + table: "Rides" + ); migrationBuilder.DropCheckConstraint( name: "CK_Rides_SnapshotYearlyGoalMiles_Positive", - table: "Rides"); + table: "Rides" + ); migrationBuilder.DropColumn( name: "DashboardGallonsAvoidedEnabled", - table: "UserSettings"); + table: "UserSettings" + ); migrationBuilder.DropColumn( name: "DashboardGoalProgressEnabled", - table: "UserSettings"); + table: "UserSettings" + ); - migrationBuilder.DropColumn( - name: "SnapshotAverageCarMpg", - table: "Rides"); + migrationBuilder.DropColumn(name: "SnapshotAverageCarMpg", table: "Rides"); - migrationBuilder.DropColumn( - name: "SnapshotMileageRateCents", - table: "Rides"); + migrationBuilder.DropColumn(name: "SnapshotMileageRateCents", table: "Rides"); - migrationBuilder.DropColumn( - name: "SnapshotOilChangePrice", - table: "Rides"); + migrationBuilder.DropColumn(name: "SnapshotOilChangePrice", table: "Rides"); - migrationBuilder.DropColumn( - name: "SnapshotYearlyGoalMiles", - table: "Rides"); + migrationBuilder.DropColumn(name: "SnapshotYearlyGoalMiles", table: "Rides"); } } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260408185627_AddCsvRideImport.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260408185627_AddCsvRideImport.cs index 4d8e4f1..c3bd571 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260408185627_AddCsvRideImport.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260408185627_AddCsvRideImport.cs @@ -15,96 +15,176 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ImportJobs", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) + Id = table + .Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), RiderId = table.Column(type: "INTEGER", nullable: false), FileName = table.Column(type: "TEXT", maxLength: 255, nullable: false), - TotalRows = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), - ProcessedRows = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), - ImportedRows = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), - SkippedRows = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), - FailedRows = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + TotalRows = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: 0 + ), + ProcessedRows = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: 0 + ), + ImportedRows = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: 0 + ), + SkippedRows = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: 0 + ), + FailedRows = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: 0 + ), Status = table.Column(type: "TEXT", maxLength: 50, nullable: false), - OverrideAllDuplicates = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + OverrideAllDuplicates = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: false + ), EtaMinutesRounded = table.Column(type: "INTEGER", nullable: true), CreatedAtUtc = table.Column(type: "TEXT", nullable: false), StartedAtUtc = table.Column(type: "TEXT", nullable: true), CompletedAtUtc = table.Column(type: "TEXT", nullable: true), - LastError = table.Column(type: "TEXT", maxLength: 2048, nullable: true) + LastError = table.Column(type: "TEXT", maxLength: 2048, nullable: true), }, constraints: table => { table.PrimaryKey("PK_ImportJobs", x => x.Id); - table.CheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); - table.CheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); - table.CheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); - table.CheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); - table.CheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); - table.CheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + table.CheckConstraint( + "CK_ImportJobs_FailedRows_NonNegative", + "\"FailedRows\" >= 0" + ); + table.CheckConstraint( + "CK_ImportJobs_ImportedRows_NonNegative", + "\"ImportedRows\" >= 0" + ); + table.CheckConstraint( + "CK_ImportJobs_ProcessedRows_Lte_TotalRows", + "\"ProcessedRows\" <= \"TotalRows\"" + ); + table.CheckConstraint( + "CK_ImportJobs_ProcessedRows_NonNegative", + "\"ProcessedRows\" >= 0" + ); + table.CheckConstraint( + "CK_ImportJobs_SkippedRows_NonNegative", + "\"SkippedRows\" >= 0" + ); + table.CheckConstraint( + "CK_ImportJobs_TotalRows_NonNegative", + "\"TotalRows\" >= 0" + ); table.ForeignKey( name: "FK_ImportJobs_Users_RiderId", column: x => x.RiderId, principalTable: "Users", principalColumn: "UserId", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateTable( name: "ImportRows", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) + Id = table + .Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), ImportJobId = table.Column(type: "INTEGER", nullable: false), RowNumber = table.Column(type: "INTEGER", nullable: false), RideDateLocal = table.Column(type: "TEXT", nullable: true), - Miles = table.Column(type: "TEXT", precision: 10, scale: 4, nullable: true), + Miles = table.Column( + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ), RideMinutes = table.Column(type: "INTEGER", nullable: true), - Temperature = table.Column(type: "TEXT", precision: 10, scale: 4, nullable: true), + Temperature = table.Column( + type: "TEXT", + precision: 10, + scale: 4, + nullable: true + ), TagsRaw = table.Column(type: "TEXT", maxLength: 512, nullable: true), Notes = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - ValidationStatus = table.Column(type: "TEXT", maxLength: 30, nullable: false), + ValidationStatus = table.Column( + type: "TEXT", + maxLength: 30, + nullable: false + ), ValidationErrorsJson = table.Column(type: "TEXT", nullable: true), - DuplicateStatus = table.Column(type: "TEXT", maxLength: 30, nullable: false), - DuplicateResolution = table.Column(type: "TEXT", maxLength: 30, nullable: true), - ProcessingStatus = table.Column(type: "TEXT", maxLength: 30, nullable: false), + DuplicateStatus = table.Column( + type: "TEXT", + maxLength: 30, + nullable: false + ), + DuplicateResolution = table.Column( + type: "TEXT", + maxLength: 30, + nullable: true + ), + ProcessingStatus = table.Column( + type: "TEXT", + maxLength: 30, + nullable: false + ), ExistingRideIdsJson = table.Column(type: "TEXT", nullable: true), - CreatedRideId = table.Column(type: "INTEGER", nullable: true) + CreatedRideId = table.Column(type: "INTEGER", nullable: true), }, constraints: table => { table.PrimaryKey("PK_ImportRows", x => x.Id); - table.CheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); - table.CheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + table.CheckConstraint( + "CK_ImportRows_Miles_Range", + "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)" + ); + table.CheckConstraint( + "CK_ImportRows_RideMinutes_Positive", + "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0" + ); table.CheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); table.ForeignKey( name: "FK_ImportRows_ImportJobs_ImportJobId", column: x => x.ImportJobId, principalTable: "ImportJobs", principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateIndex( name: "IX_ImportJobs_RiderId_CreatedAtUtc", table: "ImportJobs", - columns: new[] { "RiderId", "CreatedAtUtc" }); + columns: new[] { "RiderId", "CreatedAtUtc" } + ); migrationBuilder.CreateIndex( name: "IX_ImportRows_ImportJobId_RowNumber", table: "ImportRows", columns: new[] { "ImportJobId", "RowNumber" }, - unique: true); + unique: true + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "ImportRows"); + migrationBuilder.DropTable(name: "ImportRows"); - migrationBuilder.DropTable( - name: "ImportJobs"); + migrationBuilder.DropTable(name: "ImportJobs"); } } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.Designer.cs new file mode 100644 index 0000000..5126356 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.Designer.cs @@ -0,0 +1,657 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260414164512_AddRideNotes")] + partial class AddRideNotes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("WeekStartDate") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.HasIndex("WeekStartDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EtaMinutesRounded") + .HasColumnType("INTEGER"); + + b.Property("FailedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProcessedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc"); + + b.ToTable("ImportJobs", null, t => + { + t.HasCheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedRideId") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingRideIdsJson") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Miles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RideDateLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("TagsRaw") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ImportRows", null, t => + { + t.HasCheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); + + t.HasCheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.cs new file mode 100644 index 0000000..f447d29 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260414164512_AddRideNotes.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRideNotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notes", + table: "Rides", + type: "TEXT", + maxLength: 500, + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "Notes", table: "Rides"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index f42ab66..3d1b266 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -260,6 +260,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Miles") .HasColumnType("TEXT"); + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + b.Property("PrecipitationType") .HasMaxLength(50) .HasColumnType("TEXT"); diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.css b/src/BikeTracking.Frontend/src/pages/HistoryPage.css index 6718b4c..2e7f9ae 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.css +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.css @@ -185,6 +185,63 @@ max-width: 8rem; } +.history-page-inline-editor textarea { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 0.3rem 0.45rem; + font: inherit; + width: 100%; + max-width: 14rem; + min-height: 4.2rem; + resize: vertical; +} + +.history-note-wrap { + position: relative; + display: inline-flex; + align-items: center; +} + +.history-note-button { + width: 1.35rem; + height: 1.35rem; + border-radius: 999px; + border: 1px solid #94a3b8; + background: #f8fafc; + color: #0f172a; + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.history-note-button:hover, +.history-note-button:focus-visible { + background: #e2e8f0; +} + +.history-note-popover { + position: absolute; + top: calc(100% + 0.3rem); + left: 0; + z-index: 20; + display: none; + min-width: 14rem; + max-width: 20rem; + padding: 0.5rem 0.6rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #0f172a; + box-shadow: 0 8px 18px rgb(15 23 42 / 12%); + white-space: pre-wrap; +} + +.history-note-wrap:hover .history-note-popover, +.history-note-wrap:focus-within .history-note-popover, +.history-note-wrap.is-open .history-note-popover { + display: block; +} + .history-page-edit-actions { display: flex; gap: 0.4rem; diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index 61bb2b0..f3f184a 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -136,6 +136,40 @@ describe('HistoryPage', () => { }) }) + it('should render note indicator only for rows with notes', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 12, rideCount: 2, period: 'thisMonth' }, + thisYear: { miles: 12, rideCount: 2, period: 'thisYear' }, + allTime: { miles: 12, rideCount: 2, period: 'allTime' }, + }, + filteredTotal: { miles: 12, rideCount: 2, period: 'filtered' }, + rides: [ + { + rideId: 1, + rideDateTimeLocal: '2026-03-20T10:30:00', + miles: 5, + note: 'Bridge detour this morning.', + }, + { + rideId: 2, + rideDateTimeLocal: '2026-03-21T10:30:00', + miles: 7, + }, + ], + page: 1, + pageSize: 25, + totalRows: 2, + }) + + render() + + await waitFor(() => { + const indicators = screen.getAllByRole('button', { name: /view ride note/i }) + expect(indicators).toHaveLength(1) + }) + }) + it('should show empty state when no rides exist', async () => { mockGetRideHistory.mockResolvedValue({ summaries: { diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx index ac7381f..e69b492 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.tsx @@ -34,6 +34,7 @@ function HistoryTable({ editedRelativeHumidityPercent, editedCloudCoverPercent, editedPrecipitationType, + editedNote, editedGasPrice, editedGasPriceSource, loadingWeather, @@ -46,6 +47,7 @@ function HistoryTable({ onEditedRelativeHumidityPercentChange, onEditedCloudCoverPercentChange, onEditedPrecipitationTypeChange, + onEditedNoteChange, onEditedGasPriceChange, onLoadWeather, onSaveEdit, @@ -62,6 +64,7 @@ function HistoryTable({ editedRelativeHumidityPercent: string editedCloudCoverPercent: string editedPrecipitationType: string + editedNote: string editedGasPrice: string editedGasPriceSource: string loadingWeather: boolean @@ -74,12 +77,15 @@ function HistoryTable({ onEditedRelativeHumidityPercentChange: (value: string) => void onEditedCloudCoverPercentChange: (value: string) => void onEditedPrecipitationTypeChange: (value: string) => void + onEditedNoteChange: (value: string) => void onEditedGasPriceChange: (value: string) => void onLoadWeather: () => void onSaveEdit: (ride: RideHistoryRow) => void onCancelEdit: () => void onStartDelete: (ride: RideHistoryRow) => void }) { + const [openNoteRideId, setOpenNoteRideId] = useState(null) + if (rides.length === 0) { return

No rides found for this rider.

} @@ -93,6 +99,7 @@ function HistoryTable({ Duration Temperature Gas Price + Notes Actions @@ -212,6 +219,41 @@ function HistoryTable({ 'N/A' )} + + {editingRideId === ride.rideId ? ( +
+ +