init
3d48bcd added
.gitignore +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +.claude/ | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +.claude/ | ||
added
.specify/.gitignore +9 -0 | new file mode 100644 | ||
| @@ -0,0 +1,9 @@ | ||
| 1 | +# Machine-local Spec Kit state — not meant to be shared. | |
| 2 | +# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh). | |
| 3 | + | |
| 4 | +# Local pointer to the current feature directory. Rewritten every time you | |
| 5 | +# switch features, so it is per-checkout state rather than something to share. | |
| 6 | +feature.json | |
| 7 | + | |
| 8 | +# Per-machine extension config overrides. | |
| 9 | +extensions/*/local-config.yml | |
| new file mode 100644 | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | +# Machine-local Spec Kit state — not meant to be shared. | ||
| 2 | +# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh). | ||
| 3 | + | ||
| 4 | +# Local pointer to the current feature directory. Rewritten every time you | ||
| 5 | +# switch features, so it is per-checkout state rather than something to share. | ||
| 6 | +feature.json | ||
| 7 | + | ||
| 8 | +# Per-machine extension config overrides. | ||
| 9 | +extensions/*/local-config.yml | ||
added
.specify/init-options.json +9 -0 | new file mode 100644 | ||
| @@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "ai": "claude", | |
| 3 | + "ai_skills": true, | |
| 4 | + "feature_numbering": "sequential", | |
| 5 | + "here": false, | |
| 6 | + "integration": "claude", | |
| 7 | + "script": "sh", | |
| 8 | + "speckit_version": "1.0.4" | |
| 9 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | +{ | ||
| 2 | + "ai": "claude", | ||
| 3 | + "ai_skills": true, | ||
| 4 | + "feature_numbering": "sequential", | ||
| 5 | + "here": false, | ||
| 6 | + "integration": "claude", | ||
| 7 | + "script": "sh", | ||
| 8 | + "speckit_version": "1.0.4" | ||
| 9 | +} | ||
added
.specify/integration.json +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{ | |
| 2 | + "version": "1.0.4", | |
| 3 | + "integration_state_schema": 1, | |
| 4 | + "installed_integrations": [ | |
| 5 | + "claude" | |
| 6 | + ], | |
| 7 | + "integration_settings": { | |
| 8 | + "claude": { | |
| 9 | + "script": "sh", | |
| 10 | + "invoke_separator": "-" | |
| 11 | + } | |
| 12 | + }, | |
| 13 | + "integration": "claude", | |
| 14 | + "default_integration": "claude" | |
| 15 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +{ | ||
| 2 | + "version": "1.0.4", | ||
| 3 | + "integration_state_schema": 1, | ||
| 4 | + "installed_integrations": [ | ||
| 5 | + "claude" | ||
| 6 | + ], | ||
| 7 | + "integration_settings": { | ||
| 8 | + "claude": { | ||
| 9 | + "script": "sh", | ||
| 10 | + "invoke_separator": "-" | ||
| 11 | + } | ||
| 12 | + }, | ||
| 13 | + "integration": "claude", | ||
| 14 | + "default_integration": "claude" | ||
| 15 | +} | ||
added
.specify/integrations/claude.manifest.json +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "integration": "claude", | |
| 3 | + "version": "1.0.4", | |
| 4 | + "installed_at": "2026-09-07T14:46:33.242231+00:00", | |
| 5 | + "files": { | |
| 6 | + ".claude/skills/speckit-analyze/SKILL.md": "72a6e6ff794e3099debe70e492433b94e6c8da57e49e03e8711b506ecfc3e608", | |
| 7 | + ".claude/skills/speckit-clarify/SKILL.md": "f4b3f2c95087ac2343c0b67faff67f7223d34213ca1816aa25908db5b9aff0ac", | |
| 8 | + ".claude/skills/speckit-constitution/SKILL.md": "a93047917a5fefeefff7aa35991109ce8f9938c4890e52206408b511bf756eb5", | |
| 9 | + ".claude/skills/speckit-implement/SKILL.md": "51bd89322e0258ae377ea66a6af41a159b0e1a05304d5a17ea0f9a9baa6640a9", | |
| 10 | + ".claude/skills/speckit-converge/SKILL.md": "6eca60f035306017d43afefd3a7a23ae448484b584080aa404ff65e3e3fdec0a", | |
| 11 | + ".claude/skills/speckit-plan/SKILL.md": "2fe3f96886e96284965c8586d14df5d226c1e796b3eb39d110c9d8231abe6417", | |
| 12 | + ".claude/skills/speckit-checklist/SKILL.md": "34c8c681f5472f2790d65ac29ca01e23f4f32ab6f06c9dca1b38fc89e2cfba86", | |
| 13 | + ".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886", | |
| 14 | + ".claude/skills/speckit-tasks/SKILL.md": "597853362a0a770fe967c5d56136e2db38de1aa7b97b89552db667b70ac493c1", | |
| 15 | + ".claude/skills/speckit-taskstoissues/SKILL.md": "76a6ec1fcc2d4f2f4ae4d1a70e59eca034fd9a3f97c5454094b023ed3da65151" | |
| 16 | + } | |
| 17 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +{ | ||
| 2 | + "integration": "claude", | ||
| 3 | + "version": "1.0.4", | ||
| 4 | + "installed_at": "2026-09-07T14:46:33.242231+00:00", | ||
| 5 | + "files": { | ||
| 6 | + ".claude/skills/speckit-analyze/SKILL.md": "72a6e6ff794e3099debe70e492433b94e6c8da57e49e03e8711b506ecfc3e608", | ||
| 7 | + ".claude/skills/speckit-clarify/SKILL.md": "f4b3f2c95087ac2343c0b67faff67f7223d34213ca1816aa25908db5b9aff0ac", | ||
| 8 | + ".claude/skills/speckit-constitution/SKILL.md": "a93047917a5fefeefff7aa35991109ce8f9938c4890e52206408b511bf756eb5", | ||
| 9 | + ".claude/skills/speckit-implement/SKILL.md": "51bd89322e0258ae377ea66a6af41a159b0e1a05304d5a17ea0f9a9baa6640a9", | ||
| 10 | + ".claude/skills/speckit-converge/SKILL.md": "6eca60f035306017d43afefd3a7a23ae448484b584080aa404ff65e3e3fdec0a", | ||
| 11 | + ".claude/skills/speckit-plan/SKILL.md": "2fe3f96886e96284965c8586d14df5d226c1e796b3eb39d110c9d8231abe6417", | ||
| 12 | + ".claude/skills/speckit-checklist/SKILL.md": "34c8c681f5472f2790d65ac29ca01e23f4f32ab6f06c9dca1b38fc89e2cfba86", | ||
| 13 | + ".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886", | ||
| 14 | + ".claude/skills/speckit-tasks/SKILL.md": "597853362a0a770fe967c5d56136e2db38de1aa7b97b89552db667b70ac493c1", | ||
| 15 | + ".claude/skills/speckit-taskstoissues/SKILL.md": "76a6ec1fcc2d4f2f4ae4d1a70e59eca034fd9a3f97c5454094b023ed3da65151" | ||
| 16 | + } | ||
| 17 | +} | ||
added
.specify/integrations/speckit.manifest.json +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "integration": "speckit", | |
| 3 | + "version": "1.0.4", | |
| 4 | + "installed_at": "2026-09-07T14:46:33.263099+00:00", | |
| 5 | + "files": { | |
| 6 | + ".specify/scripts/bash/common.sh": "170e91ece502b88d83c715e427473843e0b358f550e86e3ff524546df405a9ca", | |
| 7 | + ".specify/scripts/bash/setup-plan.sh": "f417e1b8de7a48fa9d5fea3aabea8fe62149fa11c4b41b3103de06efe73dec8d", | |
| 8 | + ".specify/scripts/bash/setup-tasks.sh": "4a33dd1e6c32ddc7d570f4b538572c54069192573eed0ec654d9fb826e31a4fe", | |
| 9 | + ".specify/scripts/bash/check-prerequisites.sh": "daa377146db4fb69912f42611a7b91c5d55873b3e3e27ed33bd8d67505826344", | |
| 10 | + ".specify/scripts/bash/resolve-template.sh": "829e227096abc8bf0889889ec9f792f503ca5d395b7836a8a7eb739ee75214e7", | |
| 11 | + ".specify/scripts/bash/create-new-feature.sh": "fe99ea8da184380056ce8512ca5d37f67fb499ee4234835c15dcf7c4fb75451c", | |
| 12 | + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", | |
| 13 | + ".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1", | |
| 14 | + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", | |
| 15 | + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", | |
| 16 | + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", | |
| 17 | + ".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1" | |
| 18 | + } | |
| 19 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +{ | ||
| 2 | + "integration": "speckit", | ||
| 3 | + "version": "1.0.4", | ||
| 4 | + "installed_at": "2026-09-07T14:46:33.263099+00:00", | ||
| 5 | + "files": { | ||
| 6 | + ".specify/scripts/bash/common.sh": "170e91ece502b88d83c715e427473843e0b358f550e86e3ff524546df405a9ca", | ||
| 7 | + ".specify/scripts/bash/setup-plan.sh": "f417e1b8de7a48fa9d5fea3aabea8fe62149fa11c4b41b3103de06efe73dec8d", | ||
| 8 | + ".specify/scripts/bash/setup-tasks.sh": "4a33dd1e6c32ddc7d570f4b538572c54069192573eed0ec654d9fb826e31a4fe", | ||
| 9 | + ".specify/scripts/bash/check-prerequisites.sh": "daa377146db4fb69912f42611a7b91c5d55873b3e3e27ed33bd8d67505826344", | ||
| 10 | + ".specify/scripts/bash/resolve-template.sh": "829e227096abc8bf0889889ec9f792f503ca5d395b7836a8a7eb739ee75214e7", | ||
| 11 | + ".specify/scripts/bash/create-new-feature.sh": "fe99ea8da184380056ce8512ca5d37f67fb499ee4234835c15dcf7c4fb75451c", | ||
| 12 | + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", | ||
| 13 | + ".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1", | ||
| 14 | + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", | ||
| 15 | + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", | ||
| 16 | + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", | ||
| 17 | + ".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1" | ||
| 18 | + } | ||
| 19 | +} | ||
added
.specify/memory/.constitution-template.json +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", | |
| 3 | + "source": "core" | |
| 4 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +{ | ||
| 2 | + "sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", | ||
| 3 | + "source": "core" | ||
| 4 | +} | ||
added
.specify/memory/constitution.md +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +# [PROJECT_NAME] Constitution | |
| 2 | +<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | |
| 3 | + | |
| 4 | +## Core Principles | |
| 5 | + | |
| 6 | +### [PRINCIPLE_1_NAME] | |
| 7 | +<!-- Example: I. Library-First --> | |
| 8 | +[PRINCIPLE_1_DESCRIPTION] | |
| 9 | +<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | |
| 10 | + | |
| 11 | +### [PRINCIPLE_2_NAME] | |
| 12 | +<!-- Example: II. CLI Interface --> | |
| 13 | +[PRINCIPLE_2_DESCRIPTION] | |
| 14 | +<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | |
| 15 | + | |
| 16 | +### [PRINCIPLE_3_NAME] | |
| 17 | +<!-- Example: III. Test-First (NON-NEGOTIABLE) --> | |
| 18 | +[PRINCIPLE_3_DESCRIPTION] | |
| 19 | +<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | |
| 20 | + | |
| 21 | +### [PRINCIPLE_4_NAME] | |
| 22 | +<!-- Example: IV. Integration Testing --> | |
| 23 | +[PRINCIPLE_4_DESCRIPTION] | |
| 24 | +<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | |
| 25 | + | |
| 26 | +### [PRINCIPLE_5_NAME] | |
| 27 | +<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | |
| 28 | +[PRINCIPLE_5_DESCRIPTION] | |
| 29 | +<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | |
| 30 | + | |
| 31 | +## [SECTION_2_NAME] | |
| 32 | +<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | |
| 33 | + | |
| 34 | +[SECTION_2_CONTENT] | |
| 35 | +<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | |
| 36 | + | |
| 37 | +## [SECTION_3_NAME] | |
| 38 | +<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | |
| 39 | + | |
| 40 | +[SECTION_3_CONTENT] | |
| 41 | +<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | |
| 42 | + | |
| 43 | +## Governance | |
| 44 | +<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | |
| 45 | + | |
| 46 | +[GOVERNANCE_RULES] | |
| 47 | +<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | |
| 48 | + | |
| 49 | +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | |
| 50 | +<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +# [PROJECT_NAME] Constitution | ||
| 2 | +<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | ||
| 3 | + | ||
| 4 | +## Core Principles | ||
| 5 | + | ||
| 6 | +### [PRINCIPLE_1_NAME] | ||
| 7 | +<!-- Example: I. Library-First --> | ||
| 8 | +[PRINCIPLE_1_DESCRIPTION] | ||
| 9 | +<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | ||
| 10 | + | ||
| 11 | +### [PRINCIPLE_2_NAME] | ||
| 12 | +<!-- Example: II. CLI Interface --> | ||
| 13 | +[PRINCIPLE_2_DESCRIPTION] | ||
| 14 | +<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | ||
| 15 | + | ||
| 16 | +### [PRINCIPLE_3_NAME] | ||
| 17 | +<!-- Example: III. Test-First (NON-NEGOTIABLE) --> | ||
| 18 | +[PRINCIPLE_3_DESCRIPTION] | ||
| 19 | +<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | ||
| 20 | + | ||
| 21 | +### [PRINCIPLE_4_NAME] | ||
| 22 | +<!-- Example: IV. Integration Testing --> | ||
| 23 | +[PRINCIPLE_4_DESCRIPTION] | ||
| 24 | +<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | ||
| 25 | + | ||
| 26 | +### [PRINCIPLE_5_NAME] | ||
| 27 | +<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | ||
| 28 | +[PRINCIPLE_5_DESCRIPTION] | ||
| 29 | +<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | ||
| 30 | + | ||
| 31 | +## [SECTION_2_NAME] | ||
| 32 | +<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | ||
| 33 | + | ||
| 34 | +[SECTION_2_CONTENT] | ||
| 35 | +<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | ||
| 36 | + | ||
| 37 | +## [SECTION_3_NAME] | ||
| 38 | +<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | ||
| 39 | + | ||
| 40 | +[SECTION_3_CONTENT] | ||
| 41 | +<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | ||
| 42 | + | ||
| 43 | +## Governance | ||
| 44 | +<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | ||
| 45 | + | ||
| 46 | +[GOVERNANCE_RULES] | ||
| 47 | +<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | ||
| 48 | + | ||
| 49 | +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | ||
| 50 | +<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> | ||
added
.specify/scripts/bash/check-prerequisites.sh +243 -0 | new file mode 100755 | ||
| @@ -0,0 +1,243 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +# Consolidated prerequisite checking script | |
| 4 | +# | |
| 5 | +# This script provides unified prerequisite checking for Spec-Driven Development workflow. | |
| 6 | +# It replaces the functionality previously spread across multiple scripts. | |
| 7 | +# | |
| 8 | +# Usage: ./check-prerequisites.sh [OPTIONS] | |
| 9 | +# | |
| 10 | +# OPTIONS: | |
| 11 | +# --json Output in JSON format | |
| 12 | +# --require-spec Require spec.md to exist (for analysis phase) | |
| 13 | +# --require-tasks Require tasks.md to exist (for implementation phase) | |
| 14 | +# --include-tasks Include tasks.md in AVAILABLE_DOCS list | |
| 15 | +# --paths-only Only output path variables (no validation) | |
| 16 | +# --template NAME Include composed template content in JSON output | |
| 17 | +# --help, -h Show help message | |
| 18 | +# | |
| 19 | +# OUTPUTS: | |
| 20 | +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} | |
| 21 | +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md | |
| 22 | +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. | |
| 23 | + | |
| 24 | +set -e | |
| 25 | + | |
| 26 | +# Parse command line arguments | |
| 27 | +JSON_MODE=false | |
| 28 | +REQUIRE_SPEC=false | |
| 29 | +REQUIRE_TASKS=false | |
| 30 | +INCLUDE_TASKS=false | |
| 31 | +PATHS_ONLY=false | |
| 32 | +TEMPLATE_NAME="" | |
| 33 | + | |
| 34 | +while [[ $# -gt 0 ]]; do | |
| 35 | + case "$1" in | |
| 36 | + --json) | |
| 37 | + JSON_MODE=true | |
| 38 | + ;; | |
| 39 | + --require-spec) | |
| 40 | + REQUIRE_SPEC=true | |
| 41 | + ;; | |
| 42 | + --require-tasks) | |
| 43 | + REQUIRE_TASKS=true | |
| 44 | + ;; | |
| 45 | + --include-tasks) | |
| 46 | + INCLUDE_TASKS=true | |
| 47 | + ;; | |
| 48 | + --paths-only) | |
| 49 | + PATHS_ONLY=true | |
| 50 | + ;; | |
| 51 | + --template) | |
| 52 | + shift | |
| 53 | + if [[ $# -eq 0 ]]; then | |
| 54 | + echo "ERROR: --template requires a template name" >&2 | |
| 55 | + exit 1 | |
| 56 | + fi | |
| 57 | + TEMPLATE_NAME="$1" | |
| 58 | + ;; | |
| 59 | + --help|-h) | |
| 60 | + cat << 'EOF' | |
| 61 | +Usage: check-prerequisites.sh [OPTIONS] | |
| 62 | + | |
| 63 | +Consolidated prerequisite checking for Spec-Driven Development workflow. | |
| 64 | + | |
| 65 | +OPTIONS: | |
| 66 | + --json Output in JSON format | |
| 67 | + --require-spec Require spec.md to exist (for analysis phase) | |
| 68 | + --require-tasks Require tasks.md to exist (for implementation phase) | |
| 69 | + --include-tasks Include tasks.md in AVAILABLE_DOCS list | |
| 70 | + --paths-only Only output path variables (no prerequisite validation) | |
| 71 | + --template NAME Include composed template content in JSON output | |
| 72 | + --help, -h Show this help message | |
| 73 | + | |
| 74 | +EXAMPLES: | |
| 75 | + # Check task prerequisites (plan.md required) | |
| 76 | + ./check-prerequisites.sh --json | |
| 77 | + | |
| 78 | + # Check implementation prerequisites (plan.md + tasks.md required) | |
| 79 | + ./check-prerequisites.sh --json --require-tasks --include-tasks | |
| 80 | + | |
| 81 | + # Get feature paths only (no validation) | |
| 82 | + ./check-prerequisites.sh --paths-only | |
| 83 | + | |
| 84 | +EOF | |
| 85 | + exit 0 | |
| 86 | + ;; | |
| 87 | + *) | |
| 88 | + echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2 | |
| 89 | + exit 1 | |
| 90 | + ;; | |
| 91 | + esac | |
| 92 | + shift | |
| 93 | +done | |
| 94 | + | |
| 95 | +# Source common functions | |
| 96 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 97 | +source "$SCRIPT_DIR/common.sh" | |
| 98 | + | |
| 99 | +# Get feature paths. | |
| 100 | +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out | |
| 101 | +# of the feature.json write side effect (issue #3025). | |
| 102 | +if $PATHS_ONLY; then | |
| 103 | + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | |
| 104 | +else | |
| 105 | + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | |
| 106 | +fi | |
| 107 | +eval "$_paths_output" | |
| 108 | +unset _paths_output | |
| 109 | + | |
| 110 | +# If paths-only mode, output paths and exit (no validation) | |
| 111 | +if $PATHS_ONLY; then | |
| 112 | + if $JSON_MODE; then | |
| 113 | + # Minimal JSON paths payload (no validation performed) | |
| 114 | + if has_jq; then | |
| 115 | + jq -cn \ | |
| 116 | + --arg repo_root "$REPO_ROOT" \ | |
| 117 | + --arg branch "$CURRENT_BRANCH" \ | |
| 118 | + --arg feature_dir "$FEATURE_DIR" \ | |
| 119 | + --arg feature_spec "$FEATURE_SPEC" \ | |
| 120 | + --arg impl_plan "$IMPL_PLAN" \ | |
| 121 | + --arg tasks "$TASKS" \ | |
| 122 | + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' | |
| 123 | + else | |
| 124 | + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ | |
| 125 | + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" | |
| 126 | + fi | |
| 127 | + else | |
| 128 | + echo "REPO_ROOT: $REPO_ROOT" | |
| 129 | + echo "BRANCH: $CURRENT_BRANCH" | |
| 130 | + echo "FEATURE_DIR: $FEATURE_DIR" | |
| 131 | + echo "FEATURE_SPEC: $FEATURE_SPEC" | |
| 132 | + echo "IMPL_PLAN: $IMPL_PLAN" | |
| 133 | + echo "TASKS: $TASKS" | |
| 134 | + fi | |
| 135 | + exit 0 | |
| 136 | +fi | |
| 137 | + | |
| 138 | +# Validate required directories and files | |
| 139 | +if [[ ! -d "$FEATURE_DIR" ]]; then | |
| 140 | + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 | |
| 141 | + echo "Run /speckit-specify first to create the feature structure." >&2 | |
| 142 | + exit 1 | |
| 143 | +fi | |
| 144 | + | |
| 145 | +if [[ ! -f "$IMPL_PLAN" ]]; then | |
| 146 | + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 | |
| 147 | + echo "Run /speckit-plan first to create the implementation plan." >&2 | |
| 148 | + exit 1 | |
| 149 | +fi | |
| 150 | + | |
| 151 | +# Check for spec.md if required | |
| 152 | +if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then | |
| 153 | + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 | |
| 154 | + echo "Run /speckit-specify first to create the feature specification." >&2 | |
| 155 | + exit 1 | |
| 156 | +fi | |
| 157 | + | |
| 158 | +# Check for tasks.md if required | |
| 159 | +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then | |
| 160 | + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 | |
| 161 | + echo "Run /speckit-tasks first to create the task list." >&2 | |
| 162 | + exit 1 | |
| 163 | +fi | |
| 164 | + | |
| 165 | +# Build list of available documents | |
| 166 | +docs=() | |
| 167 | + | |
| 168 | +# Always check these optional docs | |
| 169 | +[[ -f "$RESEARCH" ]] && docs+=("research.md") | |
| 170 | +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") | |
| 171 | + | |
| 172 | +# Check contracts directory (only if it exists and has files) | |
| 173 | +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then | |
| 174 | + docs+=("contracts/") | |
| 175 | +fi | |
| 176 | + | |
| 177 | +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") | |
| 178 | + | |
| 179 | +# Include tasks.md if requested and it exists | |
| 180 | +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then | |
| 181 | + docs+=("tasks.md") | |
| 182 | +fi | |
| 183 | + | |
| 184 | +TEMPLATE_CONTENT="" | |
| 185 | +if [[ -n "$TEMPLATE_NAME" ]]; then | |
| 186 | + if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | |
| 187 | + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" | |
| 188 | + else | |
| 189 | + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 | |
| 190 | + exit 1 | |
| 191 | + fi | |
| 192 | +fi | |
| 193 | + | |
| 194 | +# Output results | |
| 195 | +if $JSON_MODE; then | |
| 196 | + # Build JSON array of documents | |
| 197 | + if has_jq; then | |
| 198 | + if [[ ${#docs[@]} -eq 0 ]]; then | |
| 199 | + json_docs="[]" | |
| 200 | + else | |
| 201 | + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) | |
| 202 | + fi | |
| 203 | + if [[ -n "$TEMPLATE_NAME" ]]; then | |
| 204 | + jq -cn \ | |
| 205 | + --arg feature_dir "$FEATURE_DIR" \ | |
| 206 | + --argjson docs "$json_docs" \ | |
| 207 | + --arg template_content "$TEMPLATE_CONTENT" \ | |
| 208 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}' | |
| 209 | + else | |
| 210 | + jq -cn \ | |
| 211 | + --arg feature_dir "$FEATURE_DIR" \ | |
| 212 | + --argjson docs "$json_docs" \ | |
| 213 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' | |
| 214 | + fi | |
| 215 | + else | |
| 216 | + if [[ ${#docs[@]} -eq 0 ]]; then | |
| 217 | + json_docs="[]" | |
| 218 | + else | |
| 219 | + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) | |
| 220 | + json_docs="[${json_docs%,}]" | |
| 221 | + fi | |
| 222 | + if [[ -n "$TEMPLATE_NAME" ]]; then | |
| 223 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \ | |
| 224 | + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")" | |
| 225 | + else | |
| 226 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" | |
| 227 | + fi | |
| 228 | + fi | |
| 229 | +else | |
| 230 | + # Text output | |
| 231 | + echo "FEATURE_DIR:$FEATURE_DIR" | |
| 232 | + echo "AVAILABLE_DOCS:" | |
| 233 | + | |
| 234 | + # Show status of each potential document | |
| 235 | + check_file "$RESEARCH" "research.md" | |
| 236 | + check_file "$DATA_MODEL" "data-model.md" | |
| 237 | + check_dir "$CONTRACTS_DIR" "contracts/" | |
| 238 | + check_file "$QUICKSTART" "quickstart.md" | |
| 239 | + | |
| 240 | + if $INCLUDE_TASKS; then | |
| 241 | + check_file "$TASKS" "tasks.md" | |
| 242 | + fi | |
| 243 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,243 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | + | ||
| 3 | +# Consolidated prerequisite checking script | ||
| 4 | +# | ||
| 5 | +# This script provides unified prerequisite checking for Spec-Driven Development workflow. | ||
| 6 | +# It replaces the functionality previously spread across multiple scripts. | ||
| 7 | +# | ||
| 8 | +# Usage: ./check-prerequisites.sh [OPTIONS] | ||
| 9 | +# | ||
| 10 | +# OPTIONS: | ||
| 11 | +# --json Output in JSON format | ||
| 12 | +# --require-spec Require spec.md to exist (for analysis phase) | ||
| 13 | +# --require-tasks Require tasks.md to exist (for implementation phase) | ||
| 14 | +# --include-tasks Include tasks.md in AVAILABLE_DOCS list | ||
| 15 | +# --paths-only Only output path variables (no validation) | ||
| 16 | +# --template NAME Include composed template content in JSON output | ||
| 17 | +# --help, -h Show help message | ||
| 18 | +# | ||
| 19 | +# OUTPUTS: | ||
| 20 | +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} | ||
| 21 | +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md | ||
| 22 | +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. | ||
| 23 | + | ||
| 24 | +set -e | ||
| 25 | + | ||
| 26 | +# Parse command line arguments | ||
| 27 | +JSON_MODE=false | ||
| 28 | +REQUIRE_SPEC=false | ||
| 29 | +REQUIRE_TASKS=false | ||
| 30 | +INCLUDE_TASKS=false | ||
| 31 | +PATHS_ONLY=false | ||
| 32 | +TEMPLATE_NAME="" | ||
| 33 | + | ||
| 34 | +while [[ $# -gt 0 ]]; do | ||
| 35 | + case "$1" in | ||
| 36 | + --json) | ||
| 37 | + JSON_MODE=true | ||
| 38 | + ;; | ||
| 39 | + --require-spec) | ||
| 40 | + REQUIRE_SPEC=true | ||
| 41 | + ;; | ||
| 42 | + --require-tasks) | ||
| 43 | + REQUIRE_TASKS=true | ||
| 44 | + ;; | ||
| 45 | + --include-tasks) | ||
| 46 | + INCLUDE_TASKS=true | ||
| 47 | + ;; | ||
| 48 | + --paths-only) | ||
| 49 | + PATHS_ONLY=true | ||
| 50 | + ;; | ||
| 51 | + --template) | ||
| 52 | + shift | ||
| 53 | + if [[ $# -eq 0 ]]; then | ||
| 54 | + echo "ERROR: --template requires a template name" >&2 | ||
| 55 | + exit 1 | ||
| 56 | + fi | ||
| 57 | + TEMPLATE_NAME="$1" | ||
| 58 | + ;; | ||
| 59 | + --help|-h) | ||
| 60 | + cat << 'EOF' | ||
| 61 | +Usage: check-prerequisites.sh [OPTIONS] | ||
| 62 | + | ||
| 63 | +Consolidated prerequisite checking for Spec-Driven Development workflow. | ||
| 64 | + | ||
| 65 | +OPTIONS: | ||
| 66 | + --json Output in JSON format | ||
| 67 | + --require-spec Require spec.md to exist (for analysis phase) | ||
| 68 | + --require-tasks Require tasks.md to exist (for implementation phase) | ||
| 69 | + --include-tasks Include tasks.md in AVAILABLE_DOCS list | ||
| 70 | + --paths-only Only output path variables (no prerequisite validation) | ||
| 71 | + --template NAME Include composed template content in JSON output | ||
| 72 | + --help, -h Show this help message | ||
| 73 | + | ||
| 74 | +EXAMPLES: | ||
| 75 | + # Check task prerequisites (plan.md required) | ||
| 76 | + ./check-prerequisites.sh --json | ||
| 77 | + | ||
| 78 | + # Check implementation prerequisites (plan.md + tasks.md required) | ||
| 79 | + ./check-prerequisites.sh --json --require-tasks --include-tasks | ||
| 80 | + | ||
| 81 | + # Get feature paths only (no validation) | ||
| 82 | + ./check-prerequisites.sh --paths-only | ||
| 83 | + | ||
| 84 | +EOF | ||
| 85 | + exit 0 | ||
| 86 | + ;; | ||
| 87 | + *) | ||
| 88 | + echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2 | ||
| 89 | + exit 1 | ||
| 90 | + ;; | ||
| 91 | + esac | ||
| 92 | + shift | ||
| 93 | +done | ||
| 94 | + | ||
| 95 | +# Source common functions | ||
| 96 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 97 | +source "$SCRIPT_DIR/common.sh" | ||
| 98 | + | ||
| 99 | +# Get feature paths. | ||
| 100 | +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out | ||
| 101 | +# of the feature.json write side effect (issue #3025). | ||
| 102 | +if $PATHS_ONLY; then | ||
| 103 | + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | ||
| 104 | +else | ||
| 105 | + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | ||
| 106 | +fi | ||
| 107 | +eval "$_paths_output" | ||
| 108 | +unset _paths_output | ||
| 109 | + | ||
| 110 | +# If paths-only mode, output paths and exit (no validation) | ||
| 111 | +if $PATHS_ONLY; then | ||
| 112 | + if $JSON_MODE; then | ||
| 113 | + # Minimal JSON paths payload (no validation performed) | ||
| 114 | + if has_jq; then | ||
| 115 | + jq -cn \ | ||
| 116 | + --arg repo_root "$REPO_ROOT" \ | ||
| 117 | + --arg branch "$CURRENT_BRANCH" \ | ||
| 118 | + --arg feature_dir "$FEATURE_DIR" \ | ||
| 119 | + --arg feature_spec "$FEATURE_SPEC" \ | ||
| 120 | + --arg impl_plan "$IMPL_PLAN" \ | ||
| 121 | + --arg tasks "$TASKS" \ | ||
| 122 | + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' | ||
| 123 | + else | ||
| 124 | + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ | ||
| 125 | + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" | ||
| 126 | + fi | ||
| 127 | + else | ||
| 128 | + echo "REPO_ROOT: $REPO_ROOT" | ||
| 129 | + echo "BRANCH: $CURRENT_BRANCH" | ||
| 130 | + echo "FEATURE_DIR: $FEATURE_DIR" | ||
| 131 | + echo "FEATURE_SPEC: $FEATURE_SPEC" | ||
| 132 | + echo "IMPL_PLAN: $IMPL_PLAN" | ||
| 133 | + echo "TASKS: $TASKS" | ||
| 134 | + fi | ||
| 135 | + exit 0 | ||
| 136 | +fi | ||
| 137 | + | ||
| 138 | +# Validate required directories and files | ||
| 139 | +if [[ ! -d "$FEATURE_DIR" ]]; then | ||
| 140 | + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 | ||
| 141 | + echo "Run /speckit-specify first to create the feature structure." >&2 | ||
| 142 | + exit 1 | ||
| 143 | +fi | ||
| 144 | + | ||
| 145 | +if [[ ! -f "$IMPL_PLAN" ]]; then | ||
| 146 | + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 | ||
| 147 | + echo "Run /speckit-plan first to create the implementation plan." >&2 | ||
| 148 | + exit 1 | ||
| 149 | +fi | ||
| 150 | + | ||
| 151 | +# Check for spec.md if required | ||
| 152 | +if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then | ||
| 153 | + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 | ||
| 154 | + echo "Run /speckit-specify first to create the feature specification." >&2 | ||
| 155 | + exit 1 | ||
| 156 | +fi | ||
| 157 | + | ||
| 158 | +# Check for tasks.md if required | ||
| 159 | +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then | ||
| 160 | + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 | ||
| 161 | + echo "Run /speckit-tasks first to create the task list." >&2 | ||
| 162 | + exit 1 | ||
| 163 | +fi | ||
| 164 | + | ||
| 165 | +# Build list of available documents | ||
| 166 | +docs=() | ||
| 167 | + | ||
| 168 | +# Always check these optional docs | ||
| 169 | +[[ -f "$RESEARCH" ]] && docs+=("research.md") | ||
| 170 | +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") | ||
| 171 | + | ||
| 172 | +# Check contracts directory (only if it exists and has files) | ||
| 173 | +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then | ||
| 174 | + docs+=("contracts/") | ||
| 175 | +fi | ||
| 176 | + | ||
| 177 | +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") | ||
| 178 | + | ||
| 179 | +# Include tasks.md if requested and it exists | ||
| 180 | +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then | ||
| 181 | + docs+=("tasks.md") | ||
| 182 | +fi | ||
| 183 | + | ||
| 184 | +TEMPLATE_CONTENT="" | ||
| 185 | +if [[ -n "$TEMPLATE_NAME" ]]; then | ||
| 186 | + if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | ||
| 187 | + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" | ||
| 188 | + else | ||
| 189 | + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 | ||
| 190 | + exit 1 | ||
| 191 | + fi | ||
| 192 | +fi | ||
| 193 | + | ||
| 194 | +# Output results | ||
| 195 | +if $JSON_MODE; then | ||
| 196 | + # Build JSON array of documents | ||
| 197 | + if has_jq; then | ||
| 198 | + if [[ ${#docs[@]} -eq 0 ]]; then | ||
| 199 | + json_docs="[]" | ||
| 200 | + else | ||
| 201 | + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) | ||
| 202 | + fi | ||
| 203 | + if [[ -n "$TEMPLATE_NAME" ]]; then | ||
| 204 | + jq -cn \ | ||
| 205 | + --arg feature_dir "$FEATURE_DIR" \ | ||
| 206 | + --argjson docs "$json_docs" \ | ||
| 207 | + --arg template_content "$TEMPLATE_CONTENT" \ | ||
| 208 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}' | ||
| 209 | + else | ||
| 210 | + jq -cn \ | ||
| 211 | + --arg feature_dir "$FEATURE_DIR" \ | ||
| 212 | + --argjson docs "$json_docs" \ | ||
| 213 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' | ||
| 214 | + fi | ||
| 215 | + else | ||
| 216 | + if [[ ${#docs[@]} -eq 0 ]]; then | ||
| 217 | + json_docs="[]" | ||
| 218 | + else | ||
| 219 | + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) | ||
| 220 | + json_docs="[${json_docs%,}]" | ||
| 221 | + fi | ||
| 222 | + if [[ -n "$TEMPLATE_NAME" ]]; then | ||
| 223 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \ | ||
| 224 | + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")" | ||
| 225 | + else | ||
| 226 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" | ||
| 227 | + fi | ||
| 228 | + fi | ||
| 229 | +else | ||
| 230 | + # Text output | ||
| 231 | + echo "FEATURE_DIR:$FEATURE_DIR" | ||
| 232 | + echo "AVAILABLE_DOCS:" | ||
| 233 | + | ||
| 234 | + # Show status of each potential document | ||
| 235 | + check_file "$RESEARCH" "research.md" | ||
| 236 | + check_file "$DATA_MODEL" "data-model.md" | ||
| 237 | + check_dir "$CONTRACTS_DIR" "contracts/" | ||
| 238 | + check_file "$QUICKSTART" "quickstart.md" | ||
| 239 | + | ||
| 240 | + if $INCLUDE_TASKS; then | ||
| 241 | + check_file "$TASKS" "tasks.md" | ||
| 242 | + fi | ||
| 243 | +fi | ||
added
.specify/scripts/bash/common.sh +926 -0 | new file mode 100755 | ||
| @@ -0,0 +1,926 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Common functions and variables for all scripts | |
| 3 | + | |
| 4 | +# Find repository root by searching upward for .specify directory | |
| 5 | +# This is the primary marker for spec-kit projects | |
| 6 | +find_specify_root() { | |
| 7 | + local dir="${1:-$(pwd)}" | |
| 8 | + # Normalize to absolute path to prevent infinite loop with relative paths | |
| 9 | + # Use -- to handle paths starting with - (e.g., -P, -L) | |
| 10 | + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 | |
| 11 | + local prev_dir="" | |
| 12 | + while true; do | |
| 13 | + if [ -d "$dir/.specify" ]; then | |
| 14 | + echo "$dir" | |
| 15 | + return 0 | |
| 16 | + fi | |
| 17 | + # Stop if we've reached filesystem root or dirname stops changing | |
| 18 | + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then | |
| 19 | + break | |
| 20 | + fi | |
| 21 | + prev_dir="$dir" | |
| 22 | + dir="$(dirname "$dir")" | |
| 23 | + done | |
| 24 | + return 1 | |
| 25 | +} | |
| 26 | + | |
| 27 | +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that | |
| 28 | +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit | |
| 29 | +# command against a member project from a monorepo root without cd. | |
| 30 | +# | |
| 31 | +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute | |
| 32 | +# project root, or prints an error and returns 1. Strict by design: the path | |
| 33 | +# must exist and contain .specify/, with no silent fallback to cwd or the | |
| 34 | +# script-location default (which would silently write to the wrong project). | |
| 35 | +# | |
| 36 | +# This is the single resolver: bundled extensions inherit it by sourcing core | |
| 37 | +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. | |
| 38 | +resolve_specify_init_dir() { | |
| 39 | + local init_root | |
| 40 | + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. | |
| 41 | + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH | |
| 42 | + # (which would also echo to stdout and corrupt the captured path). | |
| 43 | + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then | |
| 44 | + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 | |
| 45 | + return 1 | |
| 46 | + fi | |
| 47 | + if [[ ! -d "$init_root/.specify" ]]; then | |
| 48 | + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 | |
| 49 | + return 1 | |
| 50 | + fi | |
| 51 | + printf '%s\n' "$init_root" | |
| 52 | +} | |
| 53 | + | |
| 54 | +# Get repository root, prioritizing .specify directory | |
| 55 | +# This prevents using a parent repository when spec-kit is initialized in a subdirectory | |
| 56 | +get_repo_root() { | |
| 57 | + # Explicit project override wins (see resolve_specify_init_dir). | |
| 58 | + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then | |
| 59 | + resolve_specify_init_dir | |
| 60 | + return | |
| 61 | + fi | |
| 62 | + | |
| 63 | + # First, look for .specify directory (spec-kit's own marker) | |
| 64 | + local specify_root | |
| 65 | + if specify_root=$(find_specify_root); then | |
| 66 | + echo "$specify_root" | |
| 67 | + return | |
| 68 | + fi | |
| 69 | + | |
| 70 | + # Final fallback to script location | |
| 71 | + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 72 | + (cd "$script_dir/../../.." && pwd) | |
| 73 | +} | |
| 74 | + | |
| 75 | +# Get current feature name from explicit state only. | |
| 76 | +# Returns the feature identifier or empty string if none is set. | |
| 77 | +# Feature state is set by SPECIFY_FEATURE (from create-new-feature or | |
| 78 | +# the git extension) or implicitly via .specify/feature.json. | |
| 79 | +get_current_branch() { | |
| 80 | + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then | |
| 81 | + echo "$SPECIFY_FEATURE" | |
| 82 | + return | |
| 83 | + fi | |
| 84 | + | |
| 85 | + # No explicit feature set — caller must handle this via feature.json | |
| 86 | + # in get_feature_paths(). Return empty to signal "unknown". | |
| 87 | + echo "" | |
| 88 | +} | |
| 89 | + | |
| 90 | +# Safely read .specify/feature.json's "feature_directory" value. | |
| 91 | +# Prints the raw value (possibly relative) to stdout, or empty string if the file | |
| 92 | +# is missing, unparseable, or does not contain the key. Always returns 0 so callers | |
| 93 | +# under `set -e` cannot be aborted by parser failure. | |
| 94 | +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. | |
| 95 | +read_feature_json_feature_directory() { | |
| 96 | + local repo_root="$1" | |
| 97 | + local fj="$repo_root/.specify/feature.json" | |
| 98 | + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } | |
| 99 | + | |
| 100 | + # Try parsers in order (jq -> python3 -> grep/sed), falling through on | |
| 101 | + # failure. Selection is by *parse success*, not mere availability: on | |
| 102 | + # Windows `python3` commonly resolves to the Microsoft Store App Execution | |
| 103 | + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so | |
| 104 | + # an availability-gated `elif` would pick python3, swallow its failure, and | |
| 105 | + # never reach the grep/sed fallback -- leaving feature.json unreadable even | |
| 106 | + # though it is valid (issue #3304). | |
| 107 | + local _fd='' | |
| 108 | + if command -v jq >/dev/null 2>&1; then | |
| 109 | + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then | |
| 110 | + _fd='' | |
| 111 | + fi | |
| 112 | + fi | |
| 113 | + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then | |
| 114 | + # Use Python so pretty-printed/multi-line JSON still parses correctly. | |
| 115 | + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then | |
| 116 | + _fd='' | |
| 117 | + fi | |
| 118 | + fi | |
| 119 | + if [[ -z "$_fd" ]]; then | |
| 120 | + # Last-resort single-line grep/sed fallback. The `|| true` guards against | |
| 121 | + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. | |
| 122 | + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ | |
| 123 | + | head -n 1 \ | |
| 124 | + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) | |
| 125 | + fi | |
| 126 | + | |
| 127 | + printf '%s' "$_fd" | |
| 128 | + return 0 | |
| 129 | +} | |
| 130 | + | |
| 131 | +# Persist a feature_directory value to .specify/feature.json. | |
| 132 | +# Writes only when the file is missing or the value differs from what's stored. | |
| 133 | +# Accepts the raw (possibly relative) path — callers should pass the original | |
| 134 | +# user-supplied value, not the normalized absolute path. | |
| 135 | +_persist_feature_json() { | |
| 136 | + local repo_root="$1" | |
| 137 | + local feature_dir_value="$2" | |
| 138 | + local fj="$repo_root/.specify/feature.json" | |
| 139 | + | |
| 140 | + # Strip repo_root prefix if the value is absolute and under repo_root | |
| 141 | + if [[ "$feature_dir_value" == "$repo_root/"* ]]; then | |
| 142 | + feature_dir_value="${feature_dir_value#"$repo_root/"}" | |
| 143 | + fi | |
| 144 | + | |
| 145 | + # Read current value (if any) and skip write when unchanged | |
| 146 | + local current_val | |
| 147 | + current_val=$(read_feature_json_feature_directory "$repo_root") | |
| 148 | + if [[ "$current_val" == "$feature_dir_value" ]]; then | |
| 149 | + return 0 | |
| 150 | + fi | |
| 151 | + | |
| 152 | + # Ensure .specify/ directory exists | |
| 153 | + mkdir -p "$repo_root/.specify" | |
| 154 | + | |
| 155 | + # Write feature.json — prefer jq for safe JSON, fall back to printf | |
| 156 | + if command -v jq >/dev/null 2>&1; then | |
| 157 | + jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" | |
| 158 | + else | |
| 159 | + printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" | |
| 160 | + fi | |
| 161 | +} | |
| 162 | + | |
| 163 | +get_feature_paths() { | |
| 164 | + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass | |
| 165 | + # --no-persist so pure path resolution never writes .specify/feature.json, | |
| 166 | + # which would dirty the working tree or overwrite a pinned value (issue #3025). | |
| 167 | + local no_persist=false | |
| 168 | + if [[ "${1:-}" == "--no-persist" ]]; then | |
| 169 | + no_persist=true | |
| 170 | + shift | |
| 171 | + fi | |
| 172 | + | |
| 173 | + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in | |
| 174 | + # get_repo_root propagates as a hard error instead of being masked by `local`. | |
| 175 | + local repo_root | |
| 176 | + repo_root=$(get_repo_root) || return 1 | |
| 177 | + local current_branch | |
| 178 | + current_branch=$(get_current_branch) | |
| 179 | + | |
| 180 | + # Resolve feature directory. Priority: | |
| 181 | + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) | |
| 182 | + # 2. .specify/feature.json "feature_directory" key (persisted by specify command) | |
| 183 | + # 3. Error — no feature context available | |
| 184 | + local feature_dir | |
| 185 | + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then | |
| 186 | + feature_dir="$SPECIFY_FEATURE_DIRECTORY" | |
| 187 | + # Normalize relative paths to absolute under repo root | |
| 188 | + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" | |
| 189 | + # Persist to feature.json so future sessions without the env var still | |
| 190 | + # work — unless the caller opted out for read-only resolution (#3025). | |
| 191 | + if [[ "$no_persist" != true ]]; then | |
| 192 | + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" | |
| 193 | + fi | |
| 194 | + elif [[ -f "$repo_root/.specify/feature.json" ]]; then | |
| 195 | + local _fd | |
| 196 | + _fd=$(read_feature_json_feature_directory "$repo_root") | |
| 197 | + if [[ -n "$_fd" ]]; then | |
| 198 | + feature_dir="$_fd" | |
| 199 | + # Normalize relative paths to absolute under repo root | |
| 200 | + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" | |
| 201 | + else | |
| 202 | + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 | |
| 203 | + return 1 | |
| 204 | + fi | |
| 205 | + else | |
| 206 | + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 | |
| 207 | + return 1 | |
| 208 | + fi | |
| 209 | + | |
| 210 | + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via | |
| 211 | + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature | |
| 212 | + # directory basename so CURRENT_BRANCH is a usable identifier rather than | |
| 213 | + # an empty, misleading value (issue #3026). | |
| 214 | + if [[ -z "$current_branch" ]]; then | |
| 215 | + local feature_dir_trimmed="${feature_dir%/}" | |
| 216 | + current_branch="${feature_dir_trimmed##*/}" | |
| 217 | + fi | |
| 218 | + | |
| 219 | + # Use printf '%q' to safely quote values, preventing shell injection | |
| 220 | + # via crafted branch names or paths containing special characters | |
| 221 | + printf 'REPO_ROOT=%q\n' "$repo_root" | |
| 222 | + printf 'CURRENT_BRANCH=%q\n' "$current_branch" | |
| 223 | + printf 'FEATURE_DIR=%q\n' "$feature_dir" | |
| 224 | + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" | |
| 225 | + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" | |
| 226 | + printf 'TASKS=%q\n' "$feature_dir/tasks.md" | |
| 227 | + printf 'RESEARCH=%q\n' "$feature_dir/research.md" | |
| 228 | + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" | |
| 229 | + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" | |
| 230 | + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" | |
| 231 | +} | |
| 232 | + | |
| 233 | +# Check if jq is available for safe JSON construction | |
| 234 | +has_jq() { | |
| 235 | + command -v jq >/dev/null 2>&1 | |
| 236 | +} | |
| 237 | + | |
| 238 | +get_invoke_separator() { | |
| 239 | + local repo_root="${1:-$(get_repo_root)}" | |
| 240 | + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then | |
| 241 | + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" | |
| 242 | + return 0 | |
| 243 | + fi | |
| 244 | + | |
| 245 | + local integration_json="$repo_root/.specify/integration.json" | |
| 246 | + local separator="." | |
| 247 | + local parsed=0 | |
| 248 | + | |
| 249 | + if [[ -f "$integration_json" ]]; then | |
| 250 | + # Try parsers in order (jq -> python3 -> awk), falling through on | |
| 251 | + # failure. Selection is by *parse success*, not mere availability: on | |
| 252 | + # Windows `python3` commonly resolves to the Microsoft Store App | |
| 253 | + # Execution Alias stub, which passes `command -v` but fails at runtime | |
| 254 | + # (exit 49). An availability-gated branch would pick python3, swallow | |
| 255 | + # its failure, and — because this function historically had no text | |
| 256 | + # fallback — silently return "." even for `-`-separator integrations | |
| 257 | + # (e.g. forge, cline), yielding wrong command hints (issue #3304). | |
| 258 | + if command -v jq >/dev/null 2>&1; then | |
| 259 | + local jq_separator | |
| 260 | + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then | |
| 261 | + case "$jq_separator" in | |
| 262 | + "."|"-") separator="$jq_separator"; parsed=1 ;; | |
| 263 | + esac | |
| 264 | + fi | |
| 265 | + fi | |
| 266 | + | |
| 267 | + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then | |
| 268 | + local py_separator | |
| 269 | + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null | |
| 270 | +import json | |
| 271 | +import sys | |
| 272 | + | |
| 273 | +try: | |
| 274 | + with open(sys.argv[1], encoding="utf-8") as fh: | |
| 275 | + state = json.load(fh) | |
| 276 | + key = state.get("default_integration") or state.get("integration") or "" | |
| 277 | + settings = state.get("integration_settings") | |
| 278 | + separator = "." | |
| 279 | + if isinstance(key, str) and isinstance(settings, dict): | |
| 280 | + entry = settings.get(key) | |
| 281 | + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: | |
| 282 | + separator = entry["invoke_separator"] | |
| 283 | + print(separator) | |
| 284 | +except Exception: | |
| 285 | + sys.exit(1) | |
| 286 | +PY | |
| 287 | +); then | |
| 288 | + case "$py_separator" in | |
| 289 | + "."|"-") separator="$py_separator"; parsed=1 ;; | |
| 290 | + esac | |
| 291 | + fi | |
| 292 | + fi | |
| 293 | + | |
| 294 | + if [[ "$parsed" -eq 0 ]]; then | |
| 295 | + # Last-resort text fallback for environments with neither jq nor a | |
| 296 | + # working python3 (e.g. stock Windows + Git Bash). Reads the active | |
| 297 | + # integration key (default_integration, else integration) and its | |
| 298 | + # invoke_separator from within the integration_settings object. | |
| 299 | + # Handles both pretty-printed (the written form) and compact JSON. | |
| 300 | + # Accumulate all lines into one buffer in END rather than using | |
| 301 | + # gawk-only whole-file slurp (RS="^$"), so this stays portable to | |
| 302 | + # the BSD awk on macOS. | |
| 303 | + local awk_separator | |
| 304 | + awk_separator=$(awk ' | |
| 305 | + function keyval(d, name, v) { | |
| 306 | + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { | |
| 307 | + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v | |
| 308 | + } | |
| 309 | + return "" | |
| 310 | + } | |
| 311 | + { doc = doc $0 "\n" } | |
| 312 | + END { | |
| 313 | + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") | |
| 314 | + sep="." | |
| 315 | + if (key!="") { | |
| 316 | + settings=doc | |
| 317 | + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { | |
| 318 | + settings=substr(doc, RSTART+RLENGTH-1) | |
| 319 | + } | |
| 320 | + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { | |
| 321 | + start=RSTART+RLENGTH-1 | |
| 322 | + depth=0 | |
| 323 | + obj="" | |
| 324 | + for (i=start; i<=length(settings); i++) { | |
| 325 | + c=substr(settings,i,1) | |
| 326 | + obj=obj c | |
| 327 | + if (c=="{") depth++ | |
| 328 | + else if (c=="}") { depth--; if (depth==0) break } | |
| 329 | + } | |
| 330 | + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { | |
| 331 | + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) | |
| 332 | + if (s=="." || s=="-") sep=s | |
| 333 | + } | |
| 334 | + } | |
| 335 | + } | |
| 336 | + print sep | |
| 337 | + } | |
| 338 | + ' "$integration_json" 2>/dev/null) | |
| 339 | + case "$awk_separator" in | |
| 340 | + "."|"-") separator="$awk_separator" ;; | |
| 341 | + esac | |
| 342 | + fi | |
| 343 | + fi | |
| 344 | + | |
| 345 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" | |
| 346 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" | |
| 347 | + printf '%s\n' "$separator" | |
| 348 | +} | |
| 349 | + | |
| 350 | +format_speckit_command() { | |
| 351 | + local command_name="$1" | |
| 352 | + local repo_root="${2:-$(get_repo_root)}" | |
| 353 | + local separator | |
| 354 | + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then | |
| 355 | + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" | |
| 356 | + else | |
| 357 | + separator=$(get_invoke_separator "$repo_root") | |
| 358 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" | |
| 359 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" | |
| 360 | + fi | |
| 361 | + | |
| 362 | + command_name="${command_name#/}" | |
| 363 | + command_name="${command_name#speckit.}" | |
| 364 | + command_name="${command_name#speckit-}" | |
| 365 | + command_name="${command_name//./$separator}" | |
| 366 | + | |
| 367 | + printf '/speckit%s%s\n' "$separator" "$command_name" | |
| 368 | +} | |
| 369 | + | |
| 370 | +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). | |
| 371 | +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). | |
| 372 | +json_escape() { | |
| 373 | + local s="$1" | |
| 374 | + s="${s//\\/\\\\}" | |
| 375 | + s="${s//\"/\\\"}" | |
| 376 | + s="${s//$'\n'/\\n}" | |
| 377 | + s="${s//$'\t'/\\t}" | |
| 378 | + s="${s//$'\r'/\\r}" | |
| 379 | + s="${s//$'\b'/\\b}" | |
| 380 | + s="${s//$'\f'/\\f}" | |
| 381 | + # Escape any remaining U+0001-U+001F control characters as \uXXXX. | |
| 382 | + # (U+0000/NUL cannot appear in bash strings and is excluded.) | |
| 383 | + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, | |
| 384 | + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. | |
| 385 | + local LC_ALL=C | |
| 386 | + local i char code | |
| 387 | + for (( i=0; i<${#s}; i++ )); do | |
| 388 | + char="${s:$i:1}" | |
| 389 | + printf -v code '%d' "'$char" 2>/dev/null || code=256 | |
| 390 | + if (( code >= 1 && code <= 31 )); then | |
| 391 | + printf '\\u%04x' "$code" | |
| 392 | + else | |
| 393 | + printf '%s' "$char" | |
| 394 | + fi | |
| 395 | + done | |
| 396 | +} | |
| 397 | + | |
| 398 | +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } | |
| 399 | +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } | |
| 400 | + | |
| 401 | +_python3_command() { | |
| 402 | + if command -v python3 >/dev/null 2>&1 && | |
| 403 | + python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then | |
| 404 | + printf '%s\n' "python3" | |
| 405 | + elif command -v python >/dev/null 2>&1 && | |
| 406 | + python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then | |
| 407 | + printf '%s\n' "python" | |
| 408 | + elif command -v py >/dev/null 2>&1 && | |
| 409 | + py -3 -c 'import sys' >/dev/null 2>&1; then | |
| 410 | + printf '%s\n' "py -3" | |
| 411 | + else | |
| 412 | + return 1 | |
| 413 | + fi | |
| 414 | +} | |
| 415 | + | |
| 416 | +_sorted_extension_ids() { | |
| 417 | + local ext_dir="$1" | |
| 418 | + local python_spec | |
| 419 | + if python_spec=$(_python3_command); then | |
| 420 | + local -a python_cmd | |
| 421 | + read -r -a python_cmd <<< "$python_spec" | |
| 422 | + local py_stderr sorted_ids | |
| 423 | + py_stderr=$(mktemp) | |
| 424 | + if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c " | |
| 425 | +import json, os, re, sys | |
| 426 | +from pathlib import Path | |
| 427 | + | |
| 428 | +root = Path(os.environ['SPECKIT_EXTENSIONS']) | |
| 429 | +registered = {} | |
| 430 | +registry = root / '.registry' | |
| 431 | +if os.path.lexists(registry): | |
| 432 | + if not registry.is_file(): | |
| 433 | + print('registry_invalid: not a regular file', file=sys.stderr) | |
| 434 | + sys.exit(1) | |
| 435 | + try: | |
| 436 | + data = json.loads(registry.read_text(encoding='utf-8')) | |
| 437 | + except Exception as exc: | |
| 438 | + print('registry_invalid: ' + str(exc), file=sys.stderr) | |
| 439 | + sys.exit(1) | |
| 440 | + if not isinstance(data, dict): | |
| 441 | + print('registry_invalid: root must be a mapping', file=sys.stderr) | |
| 442 | + sys.exit(1) | |
| 443 | + raw_extensions = data.get('extensions', {}) | |
| 444 | + if not isinstance(raw_extensions, dict): | |
| 445 | + print('registry_invalid: extensions must be a mapping', file=sys.stderr) | |
| 446 | + sys.exit(1) | |
| 447 | + registered = raw_extensions | |
| 448 | + | |
| 449 | +def priority(value): | |
| 450 | + if isinstance(value, bool): | |
| 451 | + return 10 | |
| 452 | + try: | |
| 453 | + parsed = int(value) | |
| 454 | + return parsed if parsed >= 1 else 10 | |
| 455 | + except (TypeError, ValueError, OverflowError): | |
| 456 | + return 10 | |
| 457 | + | |
| 458 | +ranked = [] | |
| 459 | +for ext_id, meta in registered.items(): | |
| 460 | + if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)): | |
| 461 | + ranked.append((priority(meta.get('priority')), ext_id)) | |
| 462 | +for path in root.iterdir(): | |
| 463 | + if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered: | |
| 464 | + ranked.append((10, path.name)) | |
| 465 | +for _, ext_id in sorted(ranked): | |
| 466 | + print(ext_id) | |
| 467 | +" 2>"$py_stderr"); then | |
| 468 | + rm -f "$py_stderr" | |
| 469 | + printf '%s\n' "$sorted_ids" | |
| 470 | + return 0 | |
| 471 | + else | |
| 472 | + echo "Error: invalid extension registry $ext_dir/.registry" >&2 | |
| 473 | + rm -f "$py_stderr" | |
| 474 | + return 1 | |
| 475 | + fi | |
| 476 | + fi | |
| 477 | + | |
| 478 | + if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then | |
| 479 | + if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then | |
| 480 | + echo "Error: invalid extension registry $ext_dir/.registry" >&2 | |
| 481 | + return 1 | |
| 482 | + fi | |
| 483 | + echo "Error: Python 3 is required to honor the extension registry" >&2 | |
| 484 | + return 2 | |
| 485 | + fi | |
| 486 | + | |
| 487 | + local ext extension_id | |
| 488 | + for ext in "$ext_dir"/*/; do | |
| 489 | + [ -d "$ext" ] || continue | |
| 490 | + extension_id=$(basename "$ext") | |
| 491 | + case "$extension_id" in *[!a-z0-9-]*) continue ;; esac | |
| 492 | + printf '%s\n' "$extension_id" | |
| 493 | + done | |
| 494 | +} | |
| 495 | + | |
| 496 | +# Resolve a template name to a file path using the priority stack: | |
| 497 | +# 1. .specify/templates/overrides/ | |
| 498 | +# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry) | |
| 499 | +# 3. .specify/extensions/<ext-id>/templates/ | |
| 500 | +# 4. .specify/templates/ (core) | |
| 501 | +resolve_template() { | |
| 502 | + local template_name="$1" | |
| 503 | + local repo_root="$2" | |
| 504 | + local base="$repo_root/.specify/templates" | |
| 505 | + | |
| 506 | + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac | |
| 507 | + | |
| 508 | + # Priority 1: Project overrides | |
| 509 | + local override="$base/overrides/${template_name}.md" | |
| 510 | + [ -f "$override" ] && echo "$override" && return 0 | |
| 511 | + | |
| 512 | + # Priority 2: Installed presets (sorted by priority from .registry) | |
| 513 | + local presets_dir="$repo_root/.specify/presets" | |
| 514 | + if [ -d "$presets_dir" ]; then | |
| 515 | + local registry_file="$presets_dir/.registry" | |
| 516 | + local python_spec="" | |
| 517 | + local -a python_cmd=() | |
| 518 | + if python_spec=$(_python3_command); then | |
| 519 | + read -r -a python_cmd <<< "$python_spec" | |
| 520 | + fi | |
| 521 | + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then | |
| 522 | + # Read preset IDs sorted by priority (lower number = higher precedence). | |
| 523 | + # The python3 call is wrapped in an if-condition so that set -e does not | |
| 524 | + # abort the function when python3 exits non-zero (e.g. invalid JSON). | |
| 525 | + local sorted_presets="" | |
| 526 | + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " | |
| 527 | +import json, re, sys, os | |
| 528 | +try: | |
| 529 | + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: | |
| 530 | + data = json.load(f) | |
| 531 | + presets = data.get('presets', {}) | |
| 532 | + def priority(meta): | |
| 533 | + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): | |
| 534 | + return 10 | |
| 535 | + try: | |
| 536 | + value = int(meta.get('priority', 10)) | |
| 537 | + return value if value >= 1 else 10 | |
| 538 | + except (TypeError, ValueError, OverflowError): | |
| 539 | + return 10 | |
| 540 | + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): | |
| 541 | + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): | |
| 542 | + print(pid) | |
| 543 | +except Exception: | |
| 544 | + sys.exit(1) | |
| 545 | +" 2>/dev/null); then | |
| 546 | + if [ -n "$sorted_presets" ]; then | |
| 547 | + # python3 succeeded and returned preset IDs — search in priority order | |
| 548 | + while IFS= read -r preset_id; do | |
| 549 | + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" | |
| 550 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 551 | + candidate="$presets_dir/$preset_id/${template_name}.md" | |
| 552 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 553 | + done <<< "$sorted_presets" | |
| 554 | + fi | |
| 555 | + # python3 succeeded but registry has no presets — nothing to search | |
| 556 | + else | |
| 557 | + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan | |
| 558 | + for preset in "$presets_dir"/*/; do | |
| 559 | + [ -d "$preset" ] || continue | |
| 560 | + local candidate="$preset/templates/${template_name}.md" | |
| 561 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 562 | + candidate="$preset/${template_name}.md" | |
| 563 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 564 | + done | |
| 565 | + fi | |
| 566 | + else | |
| 567 | + # Fallback: alphabetical directory order (no python3 available) | |
| 568 | + for preset in "$presets_dir"/*/; do | |
| 569 | + [ -d "$preset" ] || continue | |
| 570 | + local candidate="$preset/templates/${template_name}.md" | |
| 571 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 572 | + candidate="$preset/${template_name}.md" | |
| 573 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 574 | + done | |
| 575 | + fi | |
| 576 | + fi | |
| 577 | + | |
| 578 | + # Priority 3: Extension-provided templates | |
| 579 | + local ext_dir="$repo_root/.specify/extensions" | |
| 580 | + if [ -d "$ext_dir" ]; then | |
| 581 | + local sorted_extensions="" | |
| 582 | + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then | |
| 583 | + return 2 | |
| 584 | + fi | |
| 585 | + while IFS= read -r extension_id; do | |
| 586 | + [ -n "$extension_id" ] || continue | |
| 587 | + local ext="$ext_dir/$extension_id" | |
| 588 | + local candidate="$ext/templates/${template_name}.md" | |
| 589 | + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" | |
| 590 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | |
| 591 | + done <<< "$sorted_extensions" | |
| 592 | + fi | |
| 593 | + | |
| 594 | + # Priority 4: Core templates | |
| 595 | + local core="$base/${template_name}.md" | |
| 596 | + [ -f "$core" ] && echo "$core" && return 0 | |
| 597 | + | |
| 598 | + # Template not found in any location. | |
| 599 | + # Return 1 so callers can distinguish "not found" from "found". | |
| 600 | + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true | |
| 601 | + return 1 | |
| 602 | +} | |
| 603 | + | |
| 604 | +# Resolve a template name to composed content using composition strategies. | |
| 605 | +# Reads strategy metadata from preset manifests and composes content | |
| 606 | +# from multiple layers using prepend, append, or wrap strategies. | |
| 607 | +# | |
| 608 | +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") | |
| 609 | +# Returns composed content string on stdout; exit code 1 if not found. | |
| 610 | +resolve_template_content() { | |
| 611 | + local template_name="$1" | |
| 612 | + local repo_root="$2" | |
| 613 | + local base="$repo_root/.specify/templates" | |
| 614 | + | |
| 615 | + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac | |
| 616 | + | |
| 617 | + # Collect all layers (highest priority first) | |
| 618 | + local -a layer_paths=() | |
| 619 | + local -a layer_strategies=() | |
| 620 | + | |
| 621 | + # Priority 1: Project overrides (always "replace") | |
| 622 | + local override="$base/overrides/${template_name}.md" | |
| 623 | + if [ -f "$override" ]; then | |
| 624 | + if ! cat "$override"; then | |
| 625 | + echo "Error: failed to read template layer $override" >&2 | |
| 626 | + return 2 | |
| 627 | + fi | |
| 628 | + return 0 | |
| 629 | + fi | |
| 630 | + | |
| 631 | + local effective_base_found=false | |
| 632 | + | |
| 633 | + # Priority 2: Installed presets (sorted by priority from .registry) | |
| 634 | + local presets_dir="$repo_root/.specify/presets" | |
| 635 | + if [ -d "$presets_dir" ]; then | |
| 636 | + local registry_file="$presets_dir/.registry" | |
| 637 | + local sorted_presets="" | |
| 638 | + local registry_parsed=false | |
| 639 | + local python_spec="" | |
| 640 | + local -a python_cmd=() | |
| 641 | + if python_spec=$(_python3_command); then | |
| 642 | + read -r -a python_cmd <<< "$python_spec" | |
| 643 | + fi | |
| 644 | + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then | |
| 645 | + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " | |
| 646 | +import json, re, sys, os | |
| 647 | +try: | |
| 648 | + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: | |
| 649 | + data = json.load(f) | |
| 650 | + presets = data.get('presets', {}) | |
| 651 | + def priority(meta): | |
| 652 | + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): | |
| 653 | + return 10 | |
| 654 | + try: | |
| 655 | + value = int(meta.get('priority', 10)) | |
| 656 | + return value if value >= 1 else 10 | |
| 657 | + except (TypeError, ValueError, OverflowError): | |
| 658 | + return 10 | |
| 659 | + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): | |
| 660 | + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): | |
| 661 | + print(pid) | |
| 662 | +except Exception: | |
| 663 | + sys.exit(1) | |
| 664 | +" 2>/dev/null); then | |
| 665 | + registry_parsed=true | |
| 666 | + fi | |
| 667 | + fi | |
| 668 | + if [ "$registry_parsed" = false ]; then | |
| 669 | + for preset in "$presets_dir"/*/; do | |
| 670 | + [ -d "$preset" ] || continue | |
| 671 | + local fallback_id | |
| 672 | + fallback_id=$(basename "$preset") | |
| 673 | + case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac | |
| 674 | + sorted_presets+="${sorted_presets:+$'\n'}$fallback_id" | |
| 675 | + done | |
| 676 | + fi | |
| 677 | + | |
| 678 | + if [ -n "$sorted_presets" ]; then | |
| 679 | + while IFS= read -r preset_id; do | |
| 680 | + local strategy="replace" | |
| 681 | + local manifest_file="" | |
| 682 | + local manifest="$presets_dir/$preset_id/preset.yml" | |
| 683 | + local manifest_declared=false | |
| 684 | + if [ -f "$manifest" ]; then | |
| 685 | + if [ "${#python_cmd[@]}" -eq 0 ]; then | |
| 686 | + echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2 | |
| 687 | + return 2 | |
| 688 | + fi | |
| 689 | + local result | |
| 690 | + local py_stderr | |
| 691 | + local parse_status | |
| 692 | + py_stderr=$(mktemp) | |
| 693 | + if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c " | |
| 694 | +import sys, os | |
| 695 | +try: | |
| 696 | + import yaml | |
| 697 | +except ImportError: | |
| 698 | + print('yaml_missing', file=sys.stderr) | |
| 699 | + sys.exit(2) | |
| 700 | +try: | |
| 701 | + with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f: | |
| 702 | + data = yaml.safe_load(f) | |
| 703 | + if not isinstance(data, dict): | |
| 704 | + raise ValueError('manifest root must be a mapping') | |
| 705 | + if 'provides' not in data: | |
| 706 | + raise ValueError('manifest missing provides section') | |
| 707 | + provides = data['provides'] | |
| 708 | + if not isinstance(provides, dict): | |
| 709 | + raise ValueError('manifest provides must be a mapping') | |
| 710 | + if 'templates' not in provides: | |
| 711 | + raise ValueError('manifest provides missing templates') | |
| 712 | + templates = provides['templates'] | |
| 713 | + if not isinstance(templates, list): | |
| 714 | + raise ValueError('manifest templates must be a list') | |
| 715 | + if not templates: | |
| 716 | + raise ValueError('manifest must provide at least one template') | |
| 717 | + valid_types = ('template', 'command', 'script') | |
| 718 | + valid_strategies = ('replace', 'prepend', 'append', 'wrap') | |
| 719 | + for t in templates: | |
| 720 | + if not isinstance(t, dict): | |
| 721 | + raise ValueError('manifest template entries must be mappings') | |
| 722 | + if 'type' not in t or 'name' not in t or 'file' not in t: | |
| 723 | + raise ValueError('manifest template entry missing type, name, or file') | |
| 724 | + for field in ('type', 'name', 'file'): | |
| 725 | + if not isinstance(t[field], str): | |
| 726 | + raise ValueError('manifest template ' + field + ' must be a string') | |
| 727 | + if t['type'] not in valid_types: | |
| 728 | + raise ValueError('invalid manifest template type') | |
| 729 | + strategy = t.get('strategy', 'replace') | |
| 730 | + if not isinstance(strategy, str): | |
| 731 | + raise ValueError('manifest template strategy must be a string') | |
| 732 | + strategy = strategy.lower() | |
| 733 | + if strategy not in valid_strategies: | |
| 734 | + raise ValueError('invalid manifest template strategy') | |
| 735 | + if t['type'] == 'script' and strategy not in ('replace', 'wrap'): | |
| 736 | + raise ValueError('invalid manifest script strategy') | |
| 737 | + for t in templates: | |
| 738 | + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': | |
| 739 | + file_value = t.get('file', '') | |
| 740 | + strategy = t.get('strategy', 'replace') | |
| 741 | + print('found\t' + strategy + '\t' + file_value) | |
| 742 | + sys.exit(0) | |
| 743 | + print('absent\treplace\t') | |
| 744 | +except Exception as exc: | |
| 745 | + print(f'manifest_invalid: {exc}', file=sys.stderr) | |
| 746 | + sys.exit(3) | |
| 747 | +" 2>"$py_stderr"); then | |
| 748 | + parse_status=0 | |
| 749 | + else | |
| 750 | + parse_status=$? | |
| 751 | + fi | |
| 752 | + if [ "$parse_status" -ne 0 ]; then | |
| 753 | + if [ "$parse_status" -eq 2 ]; then | |
| 754 | + echo "Error: PyYAML is required to resolve preset template composition" >&2 | |
| 755 | + else | |
| 756 | + echo "Error: invalid preset manifest $manifest" >&2 | |
| 757 | + fi | |
| 758 | + rm -f "$py_stderr" | |
| 759 | + return 2 | |
| 760 | + fi | |
| 761 | + if [ -n "$result" ]; then | |
| 762 | + local declaration | |
| 763 | + IFS=$'\t' read -r declaration strategy manifest_file <<< "$result" | |
| 764 | + [ "$declaration" = "found" ] && manifest_declared=true | |
| 765 | + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') | |
| 766 | + fi | |
| 767 | + rm -f "$py_stderr" | |
| 768 | + fi | |
| 769 | + | |
| 770 | + local candidate="" | |
| 771 | + if [ -n "$manifest_file" ]; then | |
| 772 | + case "$manifest_file" in | |
| 773 | + /*|*../*|../*) manifest_file="" ;; | |
| 774 | + esac | |
| 775 | + fi | |
| 776 | + if [ -n "$manifest_file" ]; then | |
| 777 | + local mf="$presets_dir/$preset_id/$manifest_file" | |
| 778 | + [ -f "$mf" ] && candidate="$mf" | |
| 779 | + fi | |
| 780 | + if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then | |
| 781 | + local cf="$presets_dir/$preset_id/templates/${template_name}.md" | |
| 782 | + [ -f "$cf" ] && candidate="$cf" | |
| 783 | + if [ -z "$candidate" ]; then | |
| 784 | + cf="$presets_dir/$preset_id/${template_name}.md" | |
| 785 | + [ -f "$cf" ] && candidate="$cf" | |
| 786 | + fi | |
| 787 | + fi | |
| 788 | + if [ -n "$candidate" ]; then | |
| 789 | + layer_paths+=("$candidate") | |
| 790 | + layer_strategies+=("$strategy") | |
| 791 | + if [ "$strategy" = "replace" ]; then | |
| 792 | + effective_base_found=true | |
| 793 | + break | |
| 794 | + fi | |
| 795 | + fi | |
| 796 | + done <<< "$sorted_presets" | |
| 797 | + fi | |
| 798 | + fi | |
| 799 | + | |
| 800 | + # Priority 3: Extension-provided templates (always "replace") | |
| 801 | + local ext_dir="$repo_root/.specify/extensions" | |
| 802 | + if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then | |
| 803 | + local sorted_extensions="" | |
| 804 | + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then | |
| 805 | + return 2 | |
| 806 | + fi | |
| 807 | + while IFS= read -r extension_id; do | |
| 808 | + [ -n "$extension_id" ] || continue | |
| 809 | + local ext="$ext_dir/$extension_id" | |
| 810 | + local candidate="$ext/templates/${template_name}.md" | |
| 811 | + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" | |
| 812 | + if [ -f "$candidate" ]; then | |
| 813 | + layer_paths+=("$candidate") | |
| 814 | + layer_strategies+=("replace") | |
| 815 | + effective_base_found=true | |
| 816 | + break | |
| 817 | + fi | |
| 818 | + done <<< "$sorted_extensions" | |
| 819 | + fi | |
| 820 | + | |
| 821 | + # Priority 4: Core templates (always "replace") | |
| 822 | + local core="$base/${template_name}.md" | |
| 823 | + if [ "$effective_base_found" = false ] && [ -f "$core" ]; then | |
| 824 | + layer_paths+=("$core") | |
| 825 | + layer_strategies+=("replace") | |
| 826 | + fi | |
| 827 | + | |
| 828 | + local count=${#layer_paths[@]} | |
| 829 | + [ "$count" -eq 0 ] && return 1 | |
| 830 | + | |
| 831 | + # Check if any layer uses a non-replace strategy | |
| 832 | + local has_composition=false | |
| 833 | + for s in "${layer_strategies[@]}"; do | |
| 834 | + [ "$s" != "replace" ] && has_composition=true && break | |
| 835 | + done | |
| 836 | + | |
| 837 | + # If the top (highest-priority) layer is replace, it wins entirely — | |
| 838 | + # lower layers are irrelevant regardless of their strategies. | |
| 839 | + if [ "${layer_strategies[0]}" = "replace" ]; then | |
| 840 | + if ! cat "${layer_paths[0]}"; then | |
| 841 | + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 | |
| 842 | + return 2 | |
| 843 | + fi | |
| 844 | + return 0 | |
| 845 | + fi | |
| 846 | + | |
| 847 | + if [ "$has_composition" = false ]; then | |
| 848 | + if ! cat "${layer_paths[0]}"; then | |
| 849 | + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 | |
| 850 | + return 2 | |
| 851 | + fi | |
| 852 | + return 0 | |
| 853 | + fi | |
| 854 | + | |
| 855 | + # Find the effective base: scan from highest priority (index 0) downward | |
| 856 | + # to find the nearest replace layer. Only compose layers above that base. | |
| 857 | + local base_idx=-1 | |
| 858 | + local i | |
| 859 | + for (( i=0; i<count; i++ )); do | |
| 860 | + if [ "${layer_strategies[$i]}" = "replace" ]; then | |
| 861 | + base_idx=$i | |
| 862 | + break | |
| 863 | + fi | |
| 864 | + done | |
| 865 | + | |
| 866 | + if [ $base_idx -lt 0 ]; then | |
| 867 | + echo "Error: template '$template_name' has composing layers but no replace base" >&2 | |
| 868 | + return 2 | |
| 869 | + fi | |
| 870 | + | |
| 871 | + # Read the base content; compose layers above the base (higher priority) | |
| 872 | + local content | |
| 873 | + if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then | |
| 874 | + echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2 | |
| 875 | + return 2 | |
| 876 | + fi | |
| 877 | + content="${content%x}" | |
| 878 | + | |
| 879 | + for (( i=base_idx-1; i>=0; i-- )); do | |
| 880 | + local path="${layer_paths[$i]}" | |
| 881 | + local strat="${layer_strategies[$i]}" | |
| 882 | + local layer_content | |
| 883 | + # Preserve trailing newlines | |
| 884 | + if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then | |
| 885 | + echo "Error: failed to read template layer $path" >&2 | |
| 886 | + return 2 | |
| 887 | + fi | |
| 888 | + layer_content="${layer_content%x}" | |
| 889 | + | |
| 890 | + case "$strat" in | |
| 891 | + replace) content="$layer_content" ;; | |
| 892 | + prepend) | |
| 893 | + content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x) | |
| 894 | + content="${content%x}" | |
| 895 | + ;; | |
| 896 | + append) | |
| 897 | + content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x) | |
| 898 | + content="${content%x}" | |
| 899 | + ;; | |
| 900 | + wrap) | |
| 901 | + case "$layer_content" in | |
| 902 | + *'{CORE_TEMPLATE}'*) ;; | |
| 903 | + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;; | |
| 904 | + esac | |
| 905 | + # Consume the wrapper left to right instead of rewriting it in | |
| 906 | + # place. Rewriting re-scanned the string just modified, so base | |
| 907 | + # content holding a literal {CORE_TEMPLATE} reintroduced the | |
| 908 | + # token every pass and the loop never terminated. Advancing over | |
| 909 | + # ``rest`` bounds the work by the tokens in the original wrapper | |
| 910 | + # and leaves inserted content untouched, matching the single-pass | |
| 911 | + # semantics of .Replace()/.replace() in the PowerShell and Python | |
| 912 | + # ports. | |
| 913 | + local wrapped="" rest="$layer_content" | |
| 914 | + while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do | |
| 915 | + wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}" | |
| 916 | + rest="${rest#*\{CORE_TEMPLATE\}}" | |
| 917 | + done | |
| 918 | + content="${wrapped}${rest}" | |
| 919 | + ;; | |
| 920 | + *) echo "Error: unknown strategy '$strat'" >&2; return 2 ;; | |
| 921 | + esac | |
| 922 | + done | |
| 923 | + | |
| 924 | + printf '%s' "$content" | |
| 925 | + return 0 | |
| 926 | +} | |
| new file mode 100755 | |||
| @@ -0,0 +1,926 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Common functions and variables for all scripts | ||
| 3 | + | ||
| 4 | +# Find repository root by searching upward for .specify directory | ||
| 5 | +# This is the primary marker for spec-kit projects | ||
| 6 | +find_specify_root() { | ||
| 7 | + local dir="${1:-$(pwd)}" | ||
| 8 | + # Normalize to absolute path to prevent infinite loop with relative paths | ||
| 9 | + # Use -- to handle paths starting with - (e.g., -P, -L) | ||
| 10 | + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 | ||
| 11 | + local prev_dir="" | ||
| 12 | + while true; do | ||
| 13 | + if [ -d "$dir/.specify" ]; then | ||
| 14 | + echo "$dir" | ||
| 15 | + return 0 | ||
| 16 | + fi | ||
| 17 | + # Stop if we've reached filesystem root or dirname stops changing | ||
| 18 | + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then | ||
| 19 | + break | ||
| 20 | + fi | ||
| 21 | + prev_dir="$dir" | ||
| 22 | + dir="$(dirname "$dir")" | ||
| 23 | + done | ||
| 24 | + return 1 | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that | ||
| 28 | +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit | ||
| 29 | +# command against a member project from a monorepo root without cd. | ||
| 30 | +# | ||
| 31 | +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute | ||
| 32 | +# project root, or prints an error and returns 1. Strict by design: the path | ||
| 33 | +# must exist and contain .specify/, with no silent fallback to cwd or the | ||
| 34 | +# script-location default (which would silently write to the wrong project). | ||
| 35 | +# | ||
| 36 | +# This is the single resolver: bundled extensions inherit it by sourcing core | ||
| 37 | +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. | ||
| 38 | +resolve_specify_init_dir() { | ||
| 39 | + local init_root | ||
| 40 | + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. | ||
| 41 | + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH | ||
| 42 | + # (which would also echo to stdout and corrupt the captured path). | ||
| 43 | + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then | ||
| 44 | + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 | ||
| 45 | + return 1 | ||
| 46 | + fi | ||
| 47 | + if [[ ! -d "$init_root/.specify" ]]; then | ||
| 48 | + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 | ||
| 49 | + return 1 | ||
| 50 | + fi | ||
| 51 | + printf '%s\n' "$init_root" | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +# Get repository root, prioritizing .specify directory | ||
| 55 | +# This prevents using a parent repository when spec-kit is initialized in a subdirectory | ||
| 56 | +get_repo_root() { | ||
| 57 | + # Explicit project override wins (see resolve_specify_init_dir). | ||
| 58 | + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then | ||
| 59 | + resolve_specify_init_dir | ||
| 60 | + return | ||
| 61 | + fi | ||
| 62 | + | ||
| 63 | + # First, look for .specify directory (spec-kit's own marker) | ||
| 64 | + local specify_root | ||
| 65 | + if specify_root=$(find_specify_root); then | ||
| 66 | + echo "$specify_root" | ||
| 67 | + return | ||
| 68 | + fi | ||
| 69 | + | ||
| 70 | + # Final fallback to script location | ||
| 71 | + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 72 | + (cd "$script_dir/../../.." && pwd) | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +# Get current feature name from explicit state only. | ||
| 76 | +# Returns the feature identifier or empty string if none is set. | ||
| 77 | +# Feature state is set by SPECIFY_FEATURE (from create-new-feature or | ||
| 78 | +# the git extension) or implicitly via .specify/feature.json. | ||
| 79 | +get_current_branch() { | ||
| 80 | + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then | ||
| 81 | + echo "$SPECIFY_FEATURE" | ||
| 82 | + return | ||
| 83 | + fi | ||
| 84 | + | ||
| 85 | + # No explicit feature set — caller must handle this via feature.json | ||
| 86 | + # in get_feature_paths(). Return empty to signal "unknown". | ||
| 87 | + echo "" | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +# Safely read .specify/feature.json's "feature_directory" value. | ||
| 91 | +# Prints the raw value (possibly relative) to stdout, or empty string if the file | ||
| 92 | +# is missing, unparseable, or does not contain the key. Always returns 0 so callers | ||
| 93 | +# under `set -e` cannot be aborted by parser failure. | ||
| 94 | +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. | ||
| 95 | +read_feature_json_feature_directory() { | ||
| 96 | + local repo_root="$1" | ||
| 97 | + local fj="$repo_root/.specify/feature.json" | ||
| 98 | + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } | ||
| 99 | + | ||
| 100 | + # Try parsers in order (jq -> python3 -> grep/sed), falling through on | ||
| 101 | + # failure. Selection is by *parse success*, not mere availability: on | ||
| 102 | + # Windows `python3` commonly resolves to the Microsoft Store App Execution | ||
| 103 | + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so | ||
| 104 | + # an availability-gated `elif` would pick python3, swallow its failure, and | ||
| 105 | + # never reach the grep/sed fallback -- leaving feature.json unreadable even | ||
| 106 | + # though it is valid (issue #3304). | ||
| 107 | + local _fd='' | ||
| 108 | + if command -v jq >/dev/null 2>&1; then | ||
| 109 | + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then | ||
| 110 | + _fd='' | ||
| 111 | + fi | ||
| 112 | + fi | ||
| 113 | + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then | ||
| 114 | + # Use Python so pretty-printed/multi-line JSON still parses correctly. | ||
| 115 | + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then | ||
| 116 | + _fd='' | ||
| 117 | + fi | ||
| 118 | + fi | ||
| 119 | + if [[ -z "$_fd" ]]; then | ||
| 120 | + # Last-resort single-line grep/sed fallback. The `|| true` guards against | ||
| 121 | + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. | ||
| 122 | + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ | ||
| 123 | + | head -n 1 \ | ||
| 124 | + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) | ||
| 125 | + fi | ||
| 126 | + | ||
| 127 | + printf '%s' "$_fd" | ||
| 128 | + return 0 | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +# Persist a feature_directory value to .specify/feature.json. | ||
| 132 | +# Writes only when the file is missing or the value differs from what's stored. | ||
| 133 | +# Accepts the raw (possibly relative) path — callers should pass the original | ||
| 134 | +# user-supplied value, not the normalized absolute path. | ||
| 135 | +_persist_feature_json() { | ||
| 136 | + local repo_root="$1" | ||
| 137 | + local feature_dir_value="$2" | ||
| 138 | + local fj="$repo_root/.specify/feature.json" | ||
| 139 | + | ||
| 140 | + # Strip repo_root prefix if the value is absolute and under repo_root | ||
| 141 | + if [[ "$feature_dir_value" == "$repo_root/"* ]]; then | ||
| 142 | + feature_dir_value="${feature_dir_value#"$repo_root/"}" | ||
| 143 | + fi | ||
| 144 | + | ||
| 145 | + # Read current value (if any) and skip write when unchanged | ||
| 146 | + local current_val | ||
| 147 | + current_val=$(read_feature_json_feature_directory "$repo_root") | ||
| 148 | + if [[ "$current_val" == "$feature_dir_value" ]]; then | ||
| 149 | + return 0 | ||
| 150 | + fi | ||
| 151 | + | ||
| 152 | + # Ensure .specify/ directory exists | ||
| 153 | + mkdir -p "$repo_root/.specify" | ||
| 154 | + | ||
| 155 | + # Write feature.json — prefer jq for safe JSON, fall back to printf | ||
| 156 | + if command -v jq >/dev/null 2>&1; then | ||
| 157 | + jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" | ||
| 158 | + else | ||
| 159 | + printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" | ||
| 160 | + fi | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +get_feature_paths() { | ||
| 164 | + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass | ||
| 165 | + # --no-persist so pure path resolution never writes .specify/feature.json, | ||
| 166 | + # which would dirty the working tree or overwrite a pinned value (issue #3025). | ||
| 167 | + local no_persist=false | ||
| 168 | + if [[ "${1:-}" == "--no-persist" ]]; then | ||
| 169 | + no_persist=true | ||
| 170 | + shift | ||
| 171 | + fi | ||
| 172 | + | ||
| 173 | + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in | ||
| 174 | + # get_repo_root propagates as a hard error instead of being masked by `local`. | ||
| 175 | + local repo_root | ||
| 176 | + repo_root=$(get_repo_root) || return 1 | ||
| 177 | + local current_branch | ||
| 178 | + current_branch=$(get_current_branch) | ||
| 179 | + | ||
| 180 | + # Resolve feature directory. Priority: | ||
| 181 | + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) | ||
| 182 | + # 2. .specify/feature.json "feature_directory" key (persisted by specify command) | ||
| 183 | + # 3. Error — no feature context available | ||
| 184 | + local feature_dir | ||
| 185 | + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then | ||
| 186 | + feature_dir="$SPECIFY_FEATURE_DIRECTORY" | ||
| 187 | + # Normalize relative paths to absolute under repo root | ||
| 188 | + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" | ||
| 189 | + # Persist to feature.json so future sessions without the env var still | ||
| 190 | + # work — unless the caller opted out for read-only resolution (#3025). | ||
| 191 | + if [[ "$no_persist" != true ]]; then | ||
| 192 | + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" | ||
| 193 | + fi | ||
| 194 | + elif [[ -f "$repo_root/.specify/feature.json" ]]; then | ||
| 195 | + local _fd | ||
| 196 | + _fd=$(read_feature_json_feature_directory "$repo_root") | ||
| 197 | + if [[ -n "$_fd" ]]; then | ||
| 198 | + feature_dir="$_fd" | ||
| 199 | + # Normalize relative paths to absolute under repo root | ||
| 200 | + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" | ||
| 201 | + else | ||
| 202 | + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 | ||
| 203 | + return 1 | ||
| 204 | + fi | ||
| 205 | + else | ||
| 206 | + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 | ||
| 207 | + return 1 | ||
| 208 | + fi | ||
| 209 | + | ||
| 210 | + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via | ||
| 211 | + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature | ||
| 212 | + # directory basename so CURRENT_BRANCH is a usable identifier rather than | ||
| 213 | + # an empty, misleading value (issue #3026). | ||
| 214 | + if [[ -z "$current_branch" ]]; then | ||
| 215 | + local feature_dir_trimmed="${feature_dir%/}" | ||
| 216 | + current_branch="${feature_dir_trimmed##*/}" | ||
| 217 | + fi | ||
| 218 | + | ||
| 219 | + # Use printf '%q' to safely quote values, preventing shell injection | ||
| 220 | + # via crafted branch names or paths containing special characters | ||
| 221 | + printf 'REPO_ROOT=%q\n' "$repo_root" | ||
| 222 | + printf 'CURRENT_BRANCH=%q\n' "$current_branch" | ||
| 223 | + printf 'FEATURE_DIR=%q\n' "$feature_dir" | ||
| 224 | + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" | ||
| 225 | + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" | ||
| 226 | + printf 'TASKS=%q\n' "$feature_dir/tasks.md" | ||
| 227 | + printf 'RESEARCH=%q\n' "$feature_dir/research.md" | ||
| 228 | + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" | ||
| 229 | + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" | ||
| 230 | + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +# Check if jq is available for safe JSON construction | ||
| 234 | +has_jq() { | ||
| 235 | + command -v jq >/dev/null 2>&1 | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +get_invoke_separator() { | ||
| 239 | + local repo_root="${1:-$(get_repo_root)}" | ||
| 240 | + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then | ||
| 241 | + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" | ||
| 242 | + return 0 | ||
| 243 | + fi | ||
| 244 | + | ||
| 245 | + local integration_json="$repo_root/.specify/integration.json" | ||
| 246 | + local separator="." | ||
| 247 | + local parsed=0 | ||
| 248 | + | ||
| 249 | + if [[ -f "$integration_json" ]]; then | ||
| 250 | + # Try parsers in order (jq -> python3 -> awk), falling through on | ||
| 251 | + # failure. Selection is by *parse success*, not mere availability: on | ||
| 252 | + # Windows `python3` commonly resolves to the Microsoft Store App | ||
| 253 | + # Execution Alias stub, which passes `command -v` but fails at runtime | ||
| 254 | + # (exit 49). An availability-gated branch would pick python3, swallow | ||
| 255 | + # its failure, and — because this function historically had no text | ||
| 256 | + # fallback — silently return "." even for `-`-separator integrations | ||
| 257 | + # (e.g. forge, cline), yielding wrong command hints (issue #3304). | ||
| 258 | + if command -v jq >/dev/null 2>&1; then | ||
| 259 | + local jq_separator | ||
| 260 | + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then | ||
| 261 | + case "$jq_separator" in | ||
| 262 | + "."|"-") separator="$jq_separator"; parsed=1 ;; | ||
| 263 | + esac | ||
| 264 | + fi | ||
| 265 | + fi | ||
| 266 | + | ||
| 267 | + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then | ||
| 268 | + local py_separator | ||
| 269 | + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null | ||
| 270 | +import json | ||
| 271 | +import sys | ||
| 272 | + | ||
| 273 | +try: | ||
| 274 | + with open(sys.argv[1], encoding="utf-8") as fh: | ||
| 275 | + state = json.load(fh) | ||
| 276 | + key = state.get("default_integration") or state.get("integration") or "" | ||
| 277 | + settings = state.get("integration_settings") | ||
| 278 | + separator = "." | ||
| 279 | + if isinstance(key, str) and isinstance(settings, dict): | ||
| 280 | + entry = settings.get(key) | ||
| 281 | + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: | ||
| 282 | + separator = entry["invoke_separator"] | ||
| 283 | + print(separator) | ||
| 284 | +except Exception: | ||
| 285 | + sys.exit(1) | ||
| 286 | +PY | ||
| 287 | +); then | ||
| 288 | + case "$py_separator" in | ||
| 289 | + "."|"-") separator="$py_separator"; parsed=1 ;; | ||
| 290 | + esac | ||
| 291 | + fi | ||
| 292 | + fi | ||
| 293 | + | ||
| 294 | + if [[ "$parsed" -eq 0 ]]; then | ||
| 295 | + # Last-resort text fallback for environments with neither jq nor a | ||
| 296 | + # working python3 (e.g. stock Windows + Git Bash). Reads the active | ||
| 297 | + # integration key (default_integration, else integration) and its | ||
| 298 | + # invoke_separator from within the integration_settings object. | ||
| 299 | + # Handles both pretty-printed (the written form) and compact JSON. | ||
| 300 | + # Accumulate all lines into one buffer in END rather than using | ||
| 301 | + # gawk-only whole-file slurp (RS="^$"), so this stays portable to | ||
| 302 | + # the BSD awk on macOS. | ||
| 303 | + local awk_separator | ||
| 304 | + awk_separator=$(awk ' | ||
| 305 | + function keyval(d, name, v) { | ||
| 306 | + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { | ||
| 307 | + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v | ||
| 308 | + } | ||
| 309 | + return "" | ||
| 310 | + } | ||
| 311 | + { doc = doc $0 "\n" } | ||
| 312 | + END { | ||
| 313 | + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") | ||
| 314 | + sep="." | ||
| 315 | + if (key!="") { | ||
| 316 | + settings=doc | ||
| 317 | + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { | ||
| 318 | + settings=substr(doc, RSTART+RLENGTH-1) | ||
| 319 | + } | ||
| 320 | + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { | ||
| 321 | + start=RSTART+RLENGTH-1 | ||
| 322 | + depth=0 | ||
| 323 | + obj="" | ||
| 324 | + for (i=start; i<=length(settings); i++) { | ||
| 325 | + c=substr(settings,i,1) | ||
| 326 | + obj=obj c | ||
| 327 | + if (c=="{") depth++ | ||
| 328 | + else if (c=="}") { depth--; if (depth==0) break } | ||
| 329 | + } | ||
| 330 | + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { | ||
| 331 | + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) | ||
| 332 | + if (s=="." || s=="-") sep=s | ||
| 333 | + } | ||
| 334 | + } | ||
| 335 | + } | ||
| 336 | + print sep | ||
| 337 | + } | ||
| 338 | + ' "$integration_json" 2>/dev/null) | ||
| 339 | + case "$awk_separator" in | ||
| 340 | + "."|"-") separator="$awk_separator" ;; | ||
| 341 | + esac | ||
| 342 | + fi | ||
| 343 | + fi | ||
| 344 | + | ||
| 345 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" | ||
| 346 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" | ||
| 347 | + printf '%s\n' "$separator" | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +format_speckit_command() { | ||
| 351 | + local command_name="$1" | ||
| 352 | + local repo_root="${2:-$(get_repo_root)}" | ||
| 353 | + local separator | ||
| 354 | + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then | ||
| 355 | + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" | ||
| 356 | + else | ||
| 357 | + separator=$(get_invoke_separator "$repo_root") | ||
| 358 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" | ||
| 359 | + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" | ||
| 360 | + fi | ||
| 361 | + | ||
| 362 | + command_name="${command_name#/}" | ||
| 363 | + command_name="${command_name#speckit.}" | ||
| 364 | + command_name="${command_name#speckit-}" | ||
| 365 | + command_name="${command_name//./$separator}" | ||
| 366 | + | ||
| 367 | + printf '/speckit%s%s\n' "$separator" "$command_name" | ||
| 368 | +} | ||
| 369 | + | ||
| 370 | +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). | ||
| 371 | +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). | ||
| 372 | +json_escape() { | ||
| 373 | + local s="$1" | ||
| 374 | + s="${s//\\/\\\\}" | ||
| 375 | + s="${s//\"/\\\"}" | ||
| 376 | + s="${s//$'\n'/\\n}" | ||
| 377 | + s="${s//$'\t'/\\t}" | ||
| 378 | + s="${s//$'\r'/\\r}" | ||
| 379 | + s="${s//$'\b'/\\b}" | ||
| 380 | + s="${s//$'\f'/\\f}" | ||
| 381 | + # Escape any remaining U+0001-U+001F control characters as \uXXXX. | ||
| 382 | + # (U+0000/NUL cannot appear in bash strings and is excluded.) | ||
| 383 | + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, | ||
| 384 | + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. | ||
| 385 | + local LC_ALL=C | ||
| 386 | + local i char code | ||
| 387 | + for (( i=0; i<${#s}; i++ )); do | ||
| 388 | + char="${s:$i:1}" | ||
| 389 | + printf -v code '%d' "'$char" 2>/dev/null || code=256 | ||
| 390 | + if (( code >= 1 && code <= 31 )); then | ||
| 391 | + printf '\\u%04x' "$code" | ||
| 392 | + else | ||
| 393 | + printf '%s' "$char" | ||
| 394 | + fi | ||
| 395 | + done | ||
| 396 | +} | ||
| 397 | + | ||
| 398 | +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } | ||
| 399 | +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } | ||
| 400 | + | ||
| 401 | +_python3_command() { | ||
| 402 | + if command -v python3 >/dev/null 2>&1 && | ||
| 403 | + python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then | ||
| 404 | + printf '%s\n' "python3" | ||
| 405 | + elif command -v python >/dev/null 2>&1 && | ||
| 406 | + python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then | ||
| 407 | + printf '%s\n' "python" | ||
| 408 | + elif command -v py >/dev/null 2>&1 && | ||
| 409 | + py -3 -c 'import sys' >/dev/null 2>&1; then | ||
| 410 | + printf '%s\n' "py -3" | ||
| 411 | + else | ||
| 412 | + return 1 | ||
| 413 | + fi | ||
| 414 | +} | ||
| 415 | + | ||
| 416 | +_sorted_extension_ids() { | ||
| 417 | + local ext_dir="$1" | ||
| 418 | + local python_spec | ||
| 419 | + if python_spec=$(_python3_command); then | ||
| 420 | + local -a python_cmd | ||
| 421 | + read -r -a python_cmd <<< "$python_spec" | ||
| 422 | + local py_stderr sorted_ids | ||
| 423 | + py_stderr=$(mktemp) | ||
| 424 | + if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c " | ||
| 425 | +import json, os, re, sys | ||
| 426 | +from pathlib import Path | ||
| 427 | + | ||
| 428 | +root = Path(os.environ['SPECKIT_EXTENSIONS']) | ||
| 429 | +registered = {} | ||
| 430 | +registry = root / '.registry' | ||
| 431 | +if os.path.lexists(registry): | ||
| 432 | + if not registry.is_file(): | ||
| 433 | + print('registry_invalid: not a regular file', file=sys.stderr) | ||
| 434 | + sys.exit(1) | ||
| 435 | + try: | ||
| 436 | + data = json.loads(registry.read_text(encoding='utf-8')) | ||
| 437 | + except Exception as exc: | ||
| 438 | + print('registry_invalid: ' + str(exc), file=sys.stderr) | ||
| 439 | + sys.exit(1) | ||
| 440 | + if not isinstance(data, dict): | ||
| 441 | + print('registry_invalid: root must be a mapping', file=sys.stderr) | ||
| 442 | + sys.exit(1) | ||
| 443 | + raw_extensions = data.get('extensions', {}) | ||
| 444 | + if not isinstance(raw_extensions, dict): | ||
| 445 | + print('registry_invalid: extensions must be a mapping', file=sys.stderr) | ||
| 446 | + sys.exit(1) | ||
| 447 | + registered = raw_extensions | ||
| 448 | + | ||
| 449 | +def priority(value): | ||
| 450 | + if isinstance(value, bool): | ||
| 451 | + return 10 | ||
| 452 | + try: | ||
| 453 | + parsed = int(value) | ||
| 454 | + return parsed if parsed >= 1 else 10 | ||
| 455 | + except (TypeError, ValueError, OverflowError): | ||
| 456 | + return 10 | ||
| 457 | + | ||
| 458 | +ranked = [] | ||
| 459 | +for ext_id, meta in registered.items(): | ||
| 460 | + if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)): | ||
| 461 | + ranked.append((priority(meta.get('priority')), ext_id)) | ||
| 462 | +for path in root.iterdir(): | ||
| 463 | + if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered: | ||
| 464 | + ranked.append((10, path.name)) | ||
| 465 | +for _, ext_id in sorted(ranked): | ||
| 466 | + print(ext_id) | ||
| 467 | +" 2>"$py_stderr"); then | ||
| 468 | + rm -f "$py_stderr" | ||
| 469 | + printf '%s\n' "$sorted_ids" | ||
| 470 | + return 0 | ||
| 471 | + else | ||
| 472 | + echo "Error: invalid extension registry $ext_dir/.registry" >&2 | ||
| 473 | + rm -f "$py_stderr" | ||
| 474 | + return 1 | ||
| 475 | + fi | ||
| 476 | + fi | ||
| 477 | + | ||
| 478 | + if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then | ||
| 479 | + if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then | ||
| 480 | + echo "Error: invalid extension registry $ext_dir/.registry" >&2 | ||
| 481 | + return 1 | ||
| 482 | + fi | ||
| 483 | + echo "Error: Python 3 is required to honor the extension registry" >&2 | ||
| 484 | + return 2 | ||
| 485 | + fi | ||
| 486 | + | ||
| 487 | + local ext extension_id | ||
| 488 | + for ext in "$ext_dir"/*/; do | ||
| 489 | + [ -d "$ext" ] || continue | ||
| 490 | + extension_id=$(basename "$ext") | ||
| 491 | + case "$extension_id" in *[!a-z0-9-]*) continue ;; esac | ||
| 492 | + printf '%s\n' "$extension_id" | ||
| 493 | + done | ||
| 494 | +} | ||
| 495 | + | ||
| 496 | +# Resolve a template name to a file path using the priority stack: | ||
| 497 | +# 1. .specify/templates/overrides/ | ||
| 498 | +# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry) | ||
| 499 | +# 3. .specify/extensions/<ext-id>/templates/ | ||
| 500 | +# 4. .specify/templates/ (core) | ||
| 501 | +resolve_template() { | ||
| 502 | + local template_name="$1" | ||
| 503 | + local repo_root="$2" | ||
| 504 | + local base="$repo_root/.specify/templates" | ||
| 505 | + | ||
| 506 | + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac | ||
| 507 | + | ||
| 508 | + # Priority 1: Project overrides | ||
| 509 | + local override="$base/overrides/${template_name}.md" | ||
| 510 | + [ -f "$override" ] && echo "$override" && return 0 | ||
| 511 | + | ||
| 512 | + # Priority 2: Installed presets (sorted by priority from .registry) | ||
| 513 | + local presets_dir="$repo_root/.specify/presets" | ||
| 514 | + if [ -d "$presets_dir" ]; then | ||
| 515 | + local registry_file="$presets_dir/.registry" | ||
| 516 | + local python_spec="" | ||
| 517 | + local -a python_cmd=() | ||
| 518 | + if python_spec=$(_python3_command); then | ||
| 519 | + read -r -a python_cmd <<< "$python_spec" | ||
| 520 | + fi | ||
| 521 | + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then | ||
| 522 | + # Read preset IDs sorted by priority (lower number = higher precedence). | ||
| 523 | + # The python3 call is wrapped in an if-condition so that set -e does not | ||
| 524 | + # abort the function when python3 exits non-zero (e.g. invalid JSON). | ||
| 525 | + local sorted_presets="" | ||
| 526 | + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " | ||
| 527 | +import json, re, sys, os | ||
| 528 | +try: | ||
| 529 | + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: | ||
| 530 | + data = json.load(f) | ||
| 531 | + presets = data.get('presets', {}) | ||
| 532 | + def priority(meta): | ||
| 533 | + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): | ||
| 534 | + return 10 | ||
| 535 | + try: | ||
| 536 | + value = int(meta.get('priority', 10)) | ||
| 537 | + return value if value >= 1 else 10 | ||
| 538 | + except (TypeError, ValueError, OverflowError): | ||
| 539 | + return 10 | ||
| 540 | + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): | ||
| 541 | + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): | ||
| 542 | + print(pid) | ||
| 543 | +except Exception: | ||
| 544 | + sys.exit(1) | ||
| 545 | +" 2>/dev/null); then | ||
| 546 | + if [ -n "$sorted_presets" ]; then | ||
| 547 | + # python3 succeeded and returned preset IDs — search in priority order | ||
| 548 | + while IFS= read -r preset_id; do | ||
| 549 | + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" | ||
| 550 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 551 | + candidate="$presets_dir/$preset_id/${template_name}.md" | ||
| 552 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 553 | + done <<< "$sorted_presets" | ||
| 554 | + fi | ||
| 555 | + # python3 succeeded but registry has no presets — nothing to search | ||
| 556 | + else | ||
| 557 | + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan | ||
| 558 | + for preset in "$presets_dir"/*/; do | ||
| 559 | + [ -d "$preset" ] || continue | ||
| 560 | + local candidate="$preset/templates/${template_name}.md" | ||
| 561 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 562 | + candidate="$preset/${template_name}.md" | ||
| 563 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 564 | + done | ||
| 565 | + fi | ||
| 566 | + else | ||
| 567 | + # Fallback: alphabetical directory order (no python3 available) | ||
| 568 | + for preset in "$presets_dir"/*/; do | ||
| 569 | + [ -d "$preset" ] || continue | ||
| 570 | + local candidate="$preset/templates/${template_name}.md" | ||
| 571 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 572 | + candidate="$preset/${template_name}.md" | ||
| 573 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 574 | + done | ||
| 575 | + fi | ||
| 576 | + fi | ||
| 577 | + | ||
| 578 | + # Priority 3: Extension-provided templates | ||
| 579 | + local ext_dir="$repo_root/.specify/extensions" | ||
| 580 | + if [ -d "$ext_dir" ]; then | ||
| 581 | + local sorted_extensions="" | ||
| 582 | + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then | ||
| 583 | + return 2 | ||
| 584 | + fi | ||
| 585 | + while IFS= read -r extension_id; do | ||
| 586 | + [ -n "$extension_id" ] || continue | ||
| 587 | + local ext="$ext_dir/$extension_id" | ||
| 588 | + local candidate="$ext/templates/${template_name}.md" | ||
| 589 | + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" | ||
| 590 | + [ -f "$candidate" ] && echo "$candidate" && return 0 | ||
| 591 | + done <<< "$sorted_extensions" | ||
| 592 | + fi | ||
| 593 | + | ||
| 594 | + # Priority 4: Core templates | ||
| 595 | + local core="$base/${template_name}.md" | ||
| 596 | + [ -f "$core" ] && echo "$core" && return 0 | ||
| 597 | + | ||
| 598 | + # Template not found in any location. | ||
| 599 | + # Return 1 so callers can distinguish "not found" from "found". | ||
| 600 | + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true | ||
| 601 | + return 1 | ||
| 602 | +} | ||
| 603 | + | ||
| 604 | +# Resolve a template name to composed content using composition strategies. | ||
| 605 | +# Reads strategy metadata from preset manifests and composes content | ||
| 606 | +# from multiple layers using prepend, append, or wrap strategies. | ||
| 607 | +# | ||
| 608 | +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") | ||
| 609 | +# Returns composed content string on stdout; exit code 1 if not found. | ||
| 610 | +resolve_template_content() { | ||
| 611 | + local template_name="$1" | ||
| 612 | + local repo_root="$2" | ||
| 613 | + local base="$repo_root/.specify/templates" | ||
| 614 | + | ||
| 615 | + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac | ||
| 616 | + | ||
| 617 | + # Collect all layers (highest priority first) | ||
| 618 | + local -a layer_paths=() | ||
| 619 | + local -a layer_strategies=() | ||
| 620 | + | ||
| 621 | + # Priority 1: Project overrides (always "replace") | ||
| 622 | + local override="$base/overrides/${template_name}.md" | ||
| 623 | + if [ -f "$override" ]; then | ||
| 624 | + if ! cat "$override"; then | ||
| 625 | + echo "Error: failed to read template layer $override" >&2 | ||
| 626 | + return 2 | ||
| 627 | + fi | ||
| 628 | + return 0 | ||
| 629 | + fi | ||
| 630 | + | ||
| 631 | + local effective_base_found=false | ||
| 632 | + | ||
| 633 | + # Priority 2: Installed presets (sorted by priority from .registry) | ||
| 634 | + local presets_dir="$repo_root/.specify/presets" | ||
| 635 | + if [ -d "$presets_dir" ]; then | ||
| 636 | + local registry_file="$presets_dir/.registry" | ||
| 637 | + local sorted_presets="" | ||
| 638 | + local registry_parsed=false | ||
| 639 | + local python_spec="" | ||
| 640 | + local -a python_cmd=() | ||
| 641 | + if python_spec=$(_python3_command); then | ||
| 642 | + read -r -a python_cmd <<< "$python_spec" | ||
| 643 | + fi | ||
| 644 | + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then | ||
| 645 | + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " | ||
| 646 | +import json, re, sys, os | ||
| 647 | +try: | ||
| 648 | + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: | ||
| 649 | + data = json.load(f) | ||
| 650 | + presets = data.get('presets', {}) | ||
| 651 | + def priority(meta): | ||
| 652 | + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): | ||
| 653 | + return 10 | ||
| 654 | + try: | ||
| 655 | + value = int(meta.get('priority', 10)) | ||
| 656 | + return value if value >= 1 else 10 | ||
| 657 | + except (TypeError, ValueError, OverflowError): | ||
| 658 | + return 10 | ||
| 659 | + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): | ||
| 660 | + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): | ||
| 661 | + print(pid) | ||
| 662 | +except Exception: | ||
| 663 | + sys.exit(1) | ||
| 664 | +" 2>/dev/null); then | ||
| 665 | + registry_parsed=true | ||
| 666 | + fi | ||
| 667 | + fi | ||
| 668 | + if [ "$registry_parsed" = false ]; then | ||
| 669 | + for preset in "$presets_dir"/*/; do | ||
| 670 | + [ -d "$preset" ] || continue | ||
| 671 | + local fallback_id | ||
| 672 | + fallback_id=$(basename "$preset") | ||
| 673 | + case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac | ||
| 674 | + sorted_presets+="${sorted_presets:+$'\n'}$fallback_id" | ||
| 675 | + done | ||
| 676 | + fi | ||
| 677 | + | ||
| 678 | + if [ -n "$sorted_presets" ]; then | ||
| 679 | + while IFS= read -r preset_id; do | ||
| 680 | + local strategy="replace" | ||
| 681 | + local manifest_file="" | ||
| 682 | + local manifest="$presets_dir/$preset_id/preset.yml" | ||
| 683 | + local manifest_declared=false | ||
| 684 | + if [ -f "$manifest" ]; then | ||
| 685 | + if [ "${#python_cmd[@]}" -eq 0 ]; then | ||
| 686 | + echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2 | ||
| 687 | + return 2 | ||
| 688 | + fi | ||
| 689 | + local result | ||
| 690 | + local py_stderr | ||
| 691 | + local parse_status | ||
| 692 | + py_stderr=$(mktemp) | ||
| 693 | + if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c " | ||
| 694 | +import sys, os | ||
| 695 | +try: | ||
| 696 | + import yaml | ||
| 697 | +except ImportError: | ||
| 698 | + print('yaml_missing', file=sys.stderr) | ||
| 699 | + sys.exit(2) | ||
| 700 | +try: | ||
| 701 | + with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f: | ||
| 702 | + data = yaml.safe_load(f) | ||
| 703 | + if not isinstance(data, dict): | ||
| 704 | + raise ValueError('manifest root must be a mapping') | ||
| 705 | + if 'provides' not in data: | ||
| 706 | + raise ValueError('manifest missing provides section') | ||
| 707 | + provides = data['provides'] | ||
| 708 | + if not isinstance(provides, dict): | ||
| 709 | + raise ValueError('manifest provides must be a mapping') | ||
| 710 | + if 'templates' not in provides: | ||
| 711 | + raise ValueError('manifest provides missing templates') | ||
| 712 | + templates = provides['templates'] | ||
| 713 | + if not isinstance(templates, list): | ||
| 714 | + raise ValueError('manifest templates must be a list') | ||
| 715 | + if not templates: | ||
| 716 | + raise ValueError('manifest must provide at least one template') | ||
| 717 | + valid_types = ('template', 'command', 'script') | ||
| 718 | + valid_strategies = ('replace', 'prepend', 'append', 'wrap') | ||
| 719 | + for t in templates: | ||
| 720 | + if not isinstance(t, dict): | ||
| 721 | + raise ValueError('manifest template entries must be mappings') | ||
| 722 | + if 'type' not in t or 'name' not in t or 'file' not in t: | ||
| 723 | + raise ValueError('manifest template entry missing type, name, or file') | ||
| 724 | + for field in ('type', 'name', 'file'): | ||
| 725 | + if not isinstance(t[field], str): | ||
| 726 | + raise ValueError('manifest template ' + field + ' must be a string') | ||
| 727 | + if t['type'] not in valid_types: | ||
| 728 | + raise ValueError('invalid manifest template type') | ||
| 729 | + strategy = t.get('strategy', 'replace') | ||
| 730 | + if not isinstance(strategy, str): | ||
| 731 | + raise ValueError('manifest template strategy must be a string') | ||
| 732 | + strategy = strategy.lower() | ||
| 733 | + if strategy not in valid_strategies: | ||
| 734 | + raise ValueError('invalid manifest template strategy') | ||
| 735 | + if t['type'] == 'script' and strategy not in ('replace', 'wrap'): | ||
| 736 | + raise ValueError('invalid manifest script strategy') | ||
| 737 | + for t in templates: | ||
| 738 | + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': | ||
| 739 | + file_value = t.get('file', '') | ||
| 740 | + strategy = t.get('strategy', 'replace') | ||
| 741 | + print('found\t' + strategy + '\t' + file_value) | ||
| 742 | + sys.exit(0) | ||
| 743 | + print('absent\treplace\t') | ||
| 744 | +except Exception as exc: | ||
| 745 | + print(f'manifest_invalid: {exc}', file=sys.stderr) | ||
| 746 | + sys.exit(3) | ||
| 747 | +" 2>"$py_stderr"); then | ||
| 748 | + parse_status=0 | ||
| 749 | + else | ||
| 750 | + parse_status=$? | ||
| 751 | + fi | ||
| 752 | + if [ "$parse_status" -ne 0 ]; then | ||
| 753 | + if [ "$parse_status" -eq 2 ]; then | ||
| 754 | + echo "Error: PyYAML is required to resolve preset template composition" >&2 | ||
| 755 | + else | ||
| 756 | + echo "Error: invalid preset manifest $manifest" >&2 | ||
| 757 | + fi | ||
| 758 | + rm -f "$py_stderr" | ||
| 759 | + return 2 | ||
| 760 | + fi | ||
| 761 | + if [ -n "$result" ]; then | ||
| 762 | + local declaration | ||
| 763 | + IFS=$'\t' read -r declaration strategy manifest_file <<< "$result" | ||
| 764 | + [ "$declaration" = "found" ] && manifest_declared=true | ||
| 765 | + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') | ||
| 766 | + fi | ||
| 767 | + rm -f "$py_stderr" | ||
| 768 | + fi | ||
| 769 | + | ||
| 770 | + local candidate="" | ||
| 771 | + if [ -n "$manifest_file" ]; then | ||
| 772 | + case "$manifest_file" in | ||
| 773 | + /*|*../*|../*) manifest_file="" ;; | ||
| 774 | + esac | ||
| 775 | + fi | ||
| 776 | + if [ -n "$manifest_file" ]; then | ||
| 777 | + local mf="$presets_dir/$preset_id/$manifest_file" | ||
| 778 | + [ -f "$mf" ] && candidate="$mf" | ||
| 779 | + fi | ||
| 780 | + if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then | ||
| 781 | + local cf="$presets_dir/$preset_id/templates/${template_name}.md" | ||
| 782 | + [ -f "$cf" ] && candidate="$cf" | ||
| 783 | + if [ -z "$candidate" ]; then | ||
| 784 | + cf="$presets_dir/$preset_id/${template_name}.md" | ||
| 785 | + [ -f "$cf" ] && candidate="$cf" | ||
| 786 | + fi | ||
| 787 | + fi | ||
| 788 | + if [ -n "$candidate" ]; then | ||
| 789 | + layer_paths+=("$candidate") | ||
| 790 | + layer_strategies+=("$strategy") | ||
| 791 | + if [ "$strategy" = "replace" ]; then | ||
| 792 | + effective_base_found=true | ||
| 793 | + break | ||
| 794 | + fi | ||
| 795 | + fi | ||
| 796 | + done <<< "$sorted_presets" | ||
| 797 | + fi | ||
| 798 | + fi | ||
| 799 | + | ||
| 800 | + # Priority 3: Extension-provided templates (always "replace") | ||
| 801 | + local ext_dir="$repo_root/.specify/extensions" | ||
| 802 | + if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then | ||
| 803 | + local sorted_extensions="" | ||
| 804 | + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then | ||
| 805 | + return 2 | ||
| 806 | + fi | ||
| 807 | + while IFS= read -r extension_id; do | ||
| 808 | + [ -n "$extension_id" ] || continue | ||
| 809 | + local ext="$ext_dir/$extension_id" | ||
| 810 | + local candidate="$ext/templates/${template_name}.md" | ||
| 811 | + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" | ||
| 812 | + if [ -f "$candidate" ]; then | ||
| 813 | + layer_paths+=("$candidate") | ||
| 814 | + layer_strategies+=("replace") | ||
| 815 | + effective_base_found=true | ||
| 816 | + break | ||
| 817 | + fi | ||
| 818 | + done <<< "$sorted_extensions" | ||
| 819 | + fi | ||
| 820 | + | ||
| 821 | + # Priority 4: Core templates (always "replace") | ||
| 822 | + local core="$base/${template_name}.md" | ||
| 823 | + if [ "$effective_base_found" = false ] && [ -f "$core" ]; then | ||
| 824 | + layer_paths+=("$core") | ||
| 825 | + layer_strategies+=("replace") | ||
| 826 | + fi | ||
| 827 | + | ||
| 828 | + local count=${#layer_paths[@]} | ||
| 829 | + [ "$count" -eq 0 ] && return 1 | ||
| 830 | + | ||
| 831 | + # Check if any layer uses a non-replace strategy | ||
| 832 | + local has_composition=false | ||
| 833 | + for s in "${layer_strategies[@]}"; do | ||
| 834 | + [ "$s" != "replace" ] && has_composition=true && break | ||
| 835 | + done | ||
| 836 | + | ||
| 837 | + # If the top (highest-priority) layer is replace, it wins entirely — | ||
| 838 | + # lower layers are irrelevant regardless of their strategies. | ||
| 839 | + if [ "${layer_strategies[0]}" = "replace" ]; then | ||
| 840 | + if ! cat "${layer_paths[0]}"; then | ||
| 841 | + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 | ||
| 842 | + return 2 | ||
| 843 | + fi | ||
| 844 | + return 0 | ||
| 845 | + fi | ||
| 846 | + | ||
| 847 | + if [ "$has_composition" = false ]; then | ||
| 848 | + if ! cat "${layer_paths[0]}"; then | ||
| 849 | + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 | ||
| 850 | + return 2 | ||
| 851 | + fi | ||
| 852 | + return 0 | ||
| 853 | + fi | ||
| 854 | + | ||
| 855 | + # Find the effective base: scan from highest priority (index 0) downward | ||
| 856 | + # to find the nearest replace layer. Only compose layers above that base. | ||
| 857 | + local base_idx=-1 | ||
| 858 | + local i | ||
| 859 | + for (( i=0; i<count; i++ )); do | ||
| 860 | + if [ "${layer_strategies[$i]}" = "replace" ]; then | ||
| 861 | + base_idx=$i | ||
| 862 | + break | ||
| 863 | + fi | ||
| 864 | + done | ||
| 865 | + | ||
| 866 | + if [ $base_idx -lt 0 ]; then | ||
| 867 | + echo "Error: template '$template_name' has composing layers but no replace base" >&2 | ||
| 868 | + return 2 | ||
| 869 | + fi | ||
| 870 | + | ||
| 871 | + # Read the base content; compose layers above the base (higher priority) | ||
| 872 | + local content | ||
| 873 | + if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then | ||
| 874 | + echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2 | ||
| 875 | + return 2 | ||
| 876 | + fi | ||
| 877 | + content="${content%x}" | ||
| 878 | + | ||
| 879 | + for (( i=base_idx-1; i>=0; i-- )); do | ||
| 880 | + local path="${layer_paths[$i]}" | ||
| 881 | + local strat="${layer_strategies[$i]}" | ||
| 882 | + local layer_content | ||
| 883 | + # Preserve trailing newlines | ||
| 884 | + if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then | ||
| 885 | + echo "Error: failed to read template layer $path" >&2 | ||
| 886 | + return 2 | ||
| 887 | + fi | ||
| 888 | + layer_content="${layer_content%x}" | ||
| 889 | + | ||
| 890 | + case "$strat" in | ||
| 891 | + replace) content="$layer_content" ;; | ||
| 892 | + prepend) | ||
| 893 | + content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x) | ||
| 894 | + content="${content%x}" | ||
| 895 | + ;; | ||
| 896 | + append) | ||
| 897 | + content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x) | ||
| 898 | + content="${content%x}" | ||
| 899 | + ;; | ||
| 900 | + wrap) | ||
| 901 | + case "$layer_content" in | ||
| 902 | + *'{CORE_TEMPLATE}'*) ;; | ||
| 903 | + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;; | ||
| 904 | + esac | ||
| 905 | + # Consume the wrapper left to right instead of rewriting it in | ||
| 906 | + # place. Rewriting re-scanned the string just modified, so base | ||
| 907 | + # content holding a literal {CORE_TEMPLATE} reintroduced the | ||
| 908 | + # token every pass and the loop never terminated. Advancing over | ||
| 909 | + # ``rest`` bounds the work by the tokens in the original wrapper | ||
| 910 | + # and leaves inserted content untouched, matching the single-pass | ||
| 911 | + # semantics of .Replace()/.replace() in the PowerShell and Python | ||
| 912 | + # ports. | ||
| 913 | + local wrapped="" rest="$layer_content" | ||
| 914 | + while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do | ||
| 915 | + wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}" | ||
| 916 | + rest="${rest#*\{CORE_TEMPLATE\}}" | ||
| 917 | + done | ||
| 918 | + content="${wrapped}${rest}" | ||
| 919 | + ;; | ||
| 920 | + *) echo "Error: unknown strategy '$strat'" >&2; return 2 ;; | ||
| 921 | + esac | ||
| 922 | + done | ||
| 923 | + | ||
| 924 | + printf '%s' "$content" | ||
| 925 | + return 0 | ||
| 926 | +} | ||
added
.specify/scripts/bash/create-new-feature.sh +407 -0 | new file mode 100755 | ||
| @@ -0,0 +1,407 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +set -e | |
| 4 | + | |
| 5 | +JSON_MODE=false | |
| 6 | +DRY_RUN=false | |
| 7 | +ALLOW_EXISTING=false | |
| 8 | +SHORT_NAME="" | |
| 9 | +BRANCH_NUMBER="" | |
| 10 | +USE_TIMESTAMP=false | |
| 11 | +NUMBER_EXPLICIT=false | |
| 12 | +ARGS=() | |
| 13 | +i=1 | |
| 14 | +while [ $i -le $# ]; do | |
| 15 | + arg="${!i}" | |
| 16 | + case "$arg" in | |
| 17 | + --json) | |
| 18 | + JSON_MODE=true | |
| 19 | + ;; | |
| 20 | + --dry-run) | |
| 21 | + DRY_RUN=true | |
| 22 | + ;; | |
| 23 | + --allow-existing-branch) | |
| 24 | + ALLOW_EXISTING=true | |
| 25 | + ;; | |
| 26 | + --short-name) | |
| 27 | + if [ $((i + 1)) -gt $# ]; then | |
| 28 | + echo 'Error: --short-name requires a value' >&2 | |
| 29 | + exit 1 | |
| 30 | + fi | |
| 31 | + i=$((i + 1)) | |
| 32 | + next_arg="${!i}" | |
| 33 | + # Check if the next argument is another option (starts with --) | |
| 34 | + if [[ "$next_arg" == --* ]]; then | |
| 35 | + echo 'Error: --short-name requires a value' >&2 | |
| 36 | + exit 1 | |
| 37 | + fi | |
| 38 | + SHORT_NAME="$next_arg" | |
| 39 | + ;; | |
| 40 | + --number) | |
| 41 | + if [ $((i + 1)) -gt $# ]; then | |
| 42 | + echo 'Error: --number requires a value' >&2 | |
| 43 | + exit 1 | |
| 44 | + fi | |
| 45 | + i=$((i + 1)) | |
| 46 | + next_arg="${!i}" | |
| 47 | + if [[ "$next_arg" == --* ]]; then | |
| 48 | + echo 'Error: --number requires a value' >&2 | |
| 49 | + exit 1 | |
| 50 | + fi | |
| 51 | + BRANCH_NUMBER="$next_arg" | |
| 52 | + if [ -n "$BRANCH_NUMBER" ]; then | |
| 53 | + NUMBER_EXPLICIT=true | |
| 54 | + fi | |
| 55 | + ;; | |
| 56 | + --timestamp) | |
| 57 | + USE_TIMESTAMP=true | |
| 58 | + ;; | |
| 59 | + --help|-h) | |
| 60 | + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" | |
| 61 | + echo "" | |
| 62 | + echo "Options:" | |
| 63 | + echo " --json Output in JSON format" | |
| 64 | + echo " --dry-run Compute feature name and paths without creating directories or files" | |
| 65 | + echo " --allow-existing-branch Reuse an existing feature directory if it already exists" | |
| 66 | + echo " --short-name <name> Provide a custom short name (2-4 words) for the feature" | |
| 67 | + echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)" | |
| 68 | + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" | |
| 69 | + echo " --help, -h Show this help message" | |
| 70 | + echo "" | |
| 71 | + echo "Examples:" | |
| 72 | + echo " $0 'Add user authentication system' --short-name 'user-auth'" | |
| 73 | + echo " $0 'Implement OAuth2 integration for API' --number 5" | |
| 74 | + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" | |
| 75 | + exit 0 | |
| 76 | + ;; | |
| 77 | + *) | |
| 78 | + ARGS+=("$arg") | |
| 79 | + ;; | |
| 80 | + esac | |
| 81 | + i=$((i + 1)) | |
| 82 | +done | |
| 83 | + | |
| 84 | +FEATURE_DESCRIPTION="${ARGS[*]}" | |
| 85 | +if [ -z "$FEATURE_DESCRIPTION" ]; then | |
| 86 | + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2 | |
| 87 | + exit 1 | |
| 88 | +fi | |
| 89 | + | |
| 90 | +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) | |
| 91 | +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') | |
| 92 | +if [ -z "$FEATURE_DESCRIPTION" ]; then | |
| 93 | + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 | |
| 94 | + exit 1 | |
| 95 | +fi | |
| 96 | + | |
| 97 | +MAX_FEATURE_NUMBER=9223372036854775807 | |
| 98 | +MAX_BRANCH_LENGTH=244 | |
| 99 | + | |
| 100 | +is_feature_number_in_range() { | |
| 101 | + local value="$1" | |
| 102 | + local normalized="${value#"${value%%[!0]*}"}" | |
| 103 | + [ -n "$normalized" ] || normalized=0 | |
| 104 | + [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 | |
| 105 | + [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 | |
| 106 | + # Equal-length digit strings must be compared without arithmetic overflow. | |
| 107 | + # shellcheck disable=SC2071 | |
| 108 | + [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] | |
| 109 | +} | |
| 110 | + | |
| 111 | +# Function to get highest number from specs directory | |
| 112 | +get_highest_from_specs() { | |
| 113 | + local specs_dir="$1" | |
| 114 | + local highest=0 | |
| 115 | + | |
| 116 | + if [ -d "$specs_dir" ]; then | |
| 117 | + for dir in "$specs_dir"/*; do | |
| 118 | + [ -d "$dir" ] || continue | |
| 119 | + dirname=$(basename "$dir") | |
| 120 | + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. | |
| 121 | + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then | |
| 122 | + number=$(echo "$dirname" | grep -Eo '^[0-9]+') | |
| 123 | + if is_feature_number_in_range "$number"; then | |
| 124 | + number=$((10#$number)) | |
| 125 | + if [ "$number" -gt "$highest" ]; then | |
| 126 | + highest=$number | |
| 127 | + fi | |
| 128 | + fi | |
| 129 | + fi | |
| 130 | + done | |
| 131 | + fi | |
| 132 | + | |
| 133 | + echo "$highest" | |
| 134 | +} | |
| 135 | + | |
| 136 | +# Return success when a spec directory owns the given numeric prefix. | |
| 137 | +spec_prefix_exists() { | |
| 138 | + local specs_dir="$1" | |
| 139 | + local feature_num="$2" | |
| 140 | + | |
| 141 | + for spec_path in "$specs_dir/${feature_num}-"*; do | |
| 142 | + [ -d "$spec_path" ] && return 0 | |
| 143 | + done | |
| 144 | + return 1 | |
| 145 | +} | |
| 146 | + | |
| 147 | +# Function to clean and format a branch name | |
| 148 | +clean_branch_name() { | |
| 149 | + local name="$1" | |
| 150 | + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' | |
| 151 | +} | |
| 152 | + | |
| 153 | +# Fit a feature prefix and suffix within GitHub's branch-name limit. | |
| 154 | +fit_branch_name() { | |
| 155 | + local feature_num="$1" | |
| 156 | + local branch_suffix="$2" | |
| 157 | + local branch_name="${feature_num}-${branch_suffix}" | |
| 158 | + | |
| 159 | + if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then | |
| 160 | + local prefix_length=$(( ${#feature_num} + 1 )) | |
| 161 | + local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length)) | |
| 162 | + local truncated_suffix | |
| 163 | + truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//') | |
| 164 | + branch_name="${feature_num}-${truncated_suffix}" | |
| 165 | + fi | |
| 166 | + | |
| 167 | + printf '%s' "$branch_name" | |
| 168 | +} | |
| 169 | + | |
| 170 | +# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote | |
| 171 | +# so the persistence hints match the Python variant exactly (printf %q output | |
| 172 | +# differs between bash versions and from shlex.quote for spaces/metachars). | |
| 173 | +shell_quote() { | |
| 174 | + local value="$1" LC_ALL=C | |
| 175 | + if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then | |
| 176 | + printf '%s' "$value" | |
| 177 | + else | |
| 178 | + local q="'\"'\"'" | |
| 179 | + printf "'%s'" "${value//\'/$q}" | |
| 180 | + fi | |
| 181 | +} | |
| 182 | + | |
| 183 | +# Resolve repository root using common.sh functions which prioritize .specify | |
| 184 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 185 | +source "$SCRIPT_DIR/common.sh" | |
| 186 | + | |
| 187 | +REPO_ROOT=$(get_repo_root) || exit 1 | |
| 188 | + | |
| 189 | +cd "$REPO_ROOT" | |
| 190 | + | |
| 191 | +SPECS_DIR="$REPO_ROOT/specs" | |
| 192 | +if [ "$DRY_RUN" != true ]; then | |
| 193 | + mkdir -p "$SPECS_DIR" | |
| 194 | +fi | |
| 195 | + | |
| 196 | +# Function to generate branch name with stop word filtering and length filtering | |
| 197 | +generate_branch_name() { | |
| 198 | + local description="$1" | |
| 199 | + | |
| 200 | + # Common stop words to filter out | |
| 201 | + 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)$" | |
| 202 | + | |
| 203 | + # Convert to lowercase and split into words | |
| 204 | + local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') | |
| 205 | + | |
| 206 | + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) | |
| 207 | + local meaningful_words=() | |
| 208 | + for word in $clean_name; do | |
| 209 | + # Skip empty words | |
| 210 | + [ -z "$word" ] && continue | |
| 211 | + | |
| 212 | + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) | |
| 213 | + if ! echo "$word" | grep -qiE "$stop_words"; then | |
| 214 | + if [ ${#word} -ge 3 ]; then | |
| 215 | + meaningful_words+=("$word") | |
| 216 | + # Keep short words that appear as an uppercase acronym in the original. | |
| 217 | + # Uppercase via tr and match with grep -w (both portable) rather than | |
| 218 | + # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). | |
| 219 | + elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then | |
| 220 | + meaningful_words+=("$word") | |
| 221 | + fi | |
| 222 | + fi | |
| 223 | + done | |
| 224 | + | |
| 225 | + # If we have meaningful words, use first 3-4 of them | |
| 226 | + if [ ${#meaningful_words[@]} -gt 0 ]; then | |
| 227 | + local max_words=3 | |
| 228 | + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi | |
| 229 | + | |
| 230 | + local result="" | |
| 231 | + local count=0 | |
| 232 | + for word in "${meaningful_words[@]}"; do | |
| 233 | + if [ $count -ge $max_words ]; then break; fi | |
| 234 | + if [ -n "$result" ]; then result="$result-"; fi | |
| 235 | + result="$result$word" | |
| 236 | + count=$((count + 1)) | |
| 237 | + done | |
| 238 | + echo "$result" | |
| 239 | + else | |
| 240 | + # Fallback to original logic if no meaningful words found | |
| 241 | + local cleaned=$(clean_branch_name "$description") | |
| 242 | + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' | |
| 243 | + fi | |
| 244 | +} | |
| 245 | + | |
| 246 | +# Generate branch name | |
| 247 | +if [ -n "$SHORT_NAME" ]; then | |
| 248 | + # Use provided short name, just clean it up | |
| 249 | + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") | |
| 250 | +else | |
| 251 | + # Generate from description with smart filtering | |
| 252 | + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") | |
| 253 | +fi | |
| 254 | + | |
| 255 | +# Warn if --number and --timestamp are both specified | |
| 256 | +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then | |
| 257 | + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" | |
| 258 | + BRANCH_NUMBER="" | |
| 259 | +fi | |
| 260 | + | |
| 261 | +# Determine branch prefix | |
| 262 | +if [ "$USE_TIMESTAMP" = true ]; then | |
| 263 | + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) | |
| 264 | + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" | |
| 265 | +else | |
| 266 | + if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then | |
| 267 | + echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 | |
| 268 | + exit 1 | |
| 269 | + fi | |
| 270 | + | |
| 271 | + # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. | |
| 272 | + if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then | |
| 273 | + echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 | |
| 274 | + exit 1 | |
| 275 | + fi | |
| 276 | + | |
| 277 | + # Determine branch number from existing feature directories | |
| 278 | + if [ -z "$BRANCH_NUMBER" ]; then | |
| 279 | + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") | |
| 280 | + if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then | |
| 281 | + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 | |
| 282 | + exit 1 | |
| 283 | + fi | |
| 284 | + BRANCH_NUMBER=$((HIGHEST + 1)) | |
| 285 | + fi | |
| 286 | + | |
| 287 | + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) | |
| 288 | + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") | |
| 289 | + | |
| 290 | + # Treat an explicit number as a preference when its prefix is already used | |
| 291 | + # by a feature directory. Auto-detected numbers are already conflict-free. | |
| 292 | + if [ "$NUMBER_EXPLICIT" = true ]; then | |
| 293 | + SPEC_CONFLICT=false | |
| 294 | + REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") | |
| 295 | + REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME" | |
| 296 | + if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then | |
| 297 | + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true | |
| 298 | + fi | |
| 299 | + | |
| 300 | + if [ "$SPEC_CONFLICT" = true ]; then | |
| 301 | + REQUESTED_NUM="$FEATURE_NUM" | |
| 302 | + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") | |
| 303 | + BRANCH_NUMBER=$HIGHEST | |
| 304 | + while true; do | |
| 305 | + if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then | |
| 306 | + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 | |
| 307 | + exit 1 | |
| 308 | + fi | |
| 309 | + BRANCH_NUMBER=$((BRANCH_NUMBER + 1)) | |
| 310 | + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") | |
| 311 | + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break | |
| 312 | + done | |
| 313 | + >&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead" | |
| 314 | + fi | |
| 315 | + fi | |
| 316 | + | |
| 317 | +fi | |
| 318 | + | |
| 319 | +# GitHub enforces a 244-byte limit on branch names | |
| 320 | +# Validate and truncate if necessary | |
| 321 | +ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" | |
| 322 | +BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") | |
| 323 | +if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then | |
| 324 | + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" | |
| 325 | + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" | |
| 326 | + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" | |
| 327 | +fi | |
| 328 | + | |
| 329 | +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" | |
| 330 | +SPEC_FILE="$FEATURE_DIR/spec.md" | |
| 331 | + | |
| 332 | +if [ "$DRY_RUN" != true ]; then | |
| 333 | + if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then | |
| 334 | + if [ "$USE_TIMESTAMP" = true ]; then | |
| 335 | + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name." | |
| 336 | + else | |
| 337 | + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number." | |
| 338 | + fi | |
| 339 | + exit 1 | |
| 340 | + fi | |
| 341 | + | |
| 342 | + NEEDS_SPEC=false | |
| 343 | + SPEC_TEMPLATE_FOUND=false | |
| 344 | + SPEC_TEMPLATE_CONTENT="" | |
| 345 | + if [ ! -f "$SPEC_FILE" ]; then | |
| 346 | + NEEDS_SPEC=true | |
| 347 | + if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | |
| 348 | + SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}" | |
| 349 | + SPEC_TEMPLATE_FOUND=true | |
| 350 | + else | |
| 351 | + resolve_status=$? | |
| 352 | + if [ "$resolve_status" -ne 1 ]; then | |
| 353 | + exit "$resolve_status" | |
| 354 | + fi | |
| 355 | + fi | |
| 356 | + fi | |
| 357 | + | |
| 358 | + mkdir -p "$FEATURE_DIR" | |
| 359 | + | |
| 360 | + if [ "$NEEDS_SPEC" = true ]; then | |
| 361 | + if [ "$SPEC_TEMPLATE_FOUND" = true ]; then | |
| 362 | + printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE" | |
| 363 | + else | |
| 364 | + echo "Warning: Spec template not found; created empty spec file" >&2 | |
| 365 | + touch "$SPEC_FILE" | |
| 366 | + fi | |
| 367 | + fi | |
| 368 | + | |
| 369 | + # Persist to .specify/feature.json so downstream commands can find the feature | |
| 370 | + _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" | |
| 371 | + | |
| 372 | + # Inform the user how to set feature state in their own shell | |
| 373 | + printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 | |
| 374 | + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 | |
| 375 | +fi | |
| 376 | + | |
| 377 | +if $JSON_MODE; then | |
| 378 | + if command -v jq >/dev/null 2>&1; then | |
| 379 | + if [ "$DRY_RUN" = true ]; then | |
| 380 | + jq -cn \ | |
| 381 | + --arg branch_name "$BRANCH_NAME" \ | |
| 382 | + --arg spec_file "$SPEC_FILE" \ | |
| 383 | + --arg feature_num "$FEATURE_NUM" \ | |
| 384 | + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' | |
| 385 | + else | |
| 386 | + jq -cn \ | |
| 387 | + --arg branch_name "$BRANCH_NAME" \ | |
| 388 | + --arg spec_file "$SPEC_FILE" \ | |
| 389 | + --arg feature_num "$FEATURE_NUM" \ | |
| 390 | + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' | |
| 391 | + fi | |
| 392 | + else | |
| 393 | + if [ "$DRY_RUN" = true ]; then | |
| 394 | + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" | |
| 395 | + else | |
| 396 | + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" | |
| 397 | + fi | |
| 398 | + fi | |
| 399 | +else | |
| 400 | + echo "BRANCH_NAME: $BRANCH_NAME" | |
| 401 | + echo "SPEC_FILE: $SPEC_FILE" | |
| 402 | + echo "FEATURE_NUM: $FEATURE_NUM" | |
| 403 | + if [ "$DRY_RUN" != true ]; then | |
| 404 | + printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" | |
| 405 | + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" | |
| 406 | + fi | |
| 407 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,407 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | + | ||
| 3 | +set -e | ||
| 4 | + | ||
| 5 | +JSON_MODE=false | ||
| 6 | +DRY_RUN=false | ||
| 7 | +ALLOW_EXISTING=false | ||
| 8 | +SHORT_NAME="" | ||
| 9 | +BRANCH_NUMBER="" | ||
| 10 | +USE_TIMESTAMP=false | ||
| 11 | +NUMBER_EXPLICIT=false | ||
| 12 | +ARGS=() | ||
| 13 | +i=1 | ||
| 14 | +while [ $i -le $# ]; do | ||
| 15 | + arg="${!i}" | ||
| 16 | + case "$arg" in | ||
| 17 | + --json) | ||
| 18 | + JSON_MODE=true | ||
| 19 | + ;; | ||
| 20 | + --dry-run) | ||
| 21 | + DRY_RUN=true | ||
| 22 | + ;; | ||
| 23 | + --allow-existing-branch) | ||
| 24 | + ALLOW_EXISTING=true | ||
| 25 | + ;; | ||
| 26 | + --short-name) | ||
| 27 | + if [ $((i + 1)) -gt $# ]; then | ||
| 28 | + echo 'Error: --short-name requires a value' >&2 | ||
| 29 | + exit 1 | ||
| 30 | + fi | ||
| 31 | + i=$((i + 1)) | ||
| 32 | + next_arg="${!i}" | ||
| 33 | + # Check if the next argument is another option (starts with --) | ||
| 34 | + if [[ "$next_arg" == --* ]]; then | ||
| 35 | + echo 'Error: --short-name requires a value' >&2 | ||
| 36 | + exit 1 | ||
| 37 | + fi | ||
| 38 | + SHORT_NAME="$next_arg" | ||
| 39 | + ;; | ||
| 40 | + --number) | ||
| 41 | + if [ $((i + 1)) -gt $# ]; then | ||
| 42 | + echo 'Error: --number requires a value' >&2 | ||
| 43 | + exit 1 | ||
| 44 | + fi | ||
| 45 | + i=$((i + 1)) | ||
| 46 | + next_arg="${!i}" | ||
| 47 | + if [[ "$next_arg" == --* ]]; then | ||
| 48 | + echo 'Error: --number requires a value' >&2 | ||
| 49 | + exit 1 | ||
| 50 | + fi | ||
| 51 | + BRANCH_NUMBER="$next_arg" | ||
| 52 | + if [ -n "$BRANCH_NUMBER" ]; then | ||
| 53 | + NUMBER_EXPLICIT=true | ||
| 54 | + fi | ||
| 55 | + ;; | ||
| 56 | + --timestamp) | ||
| 57 | + USE_TIMESTAMP=true | ||
| 58 | + ;; | ||
| 59 | + --help|-h) | ||
| 60 | + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" | ||
| 61 | + echo "" | ||
| 62 | + echo "Options:" | ||
| 63 | + echo " --json Output in JSON format" | ||
| 64 | + echo " --dry-run Compute feature name and paths without creating directories or files" | ||
| 65 | + echo " --allow-existing-branch Reuse an existing feature directory if it already exists" | ||
| 66 | + echo " --short-name <name> Provide a custom short name (2-4 words) for the feature" | ||
| 67 | + echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)" | ||
| 68 | + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" | ||
| 69 | + echo " --help, -h Show this help message" | ||
| 70 | + echo "" | ||
| 71 | + echo "Examples:" | ||
| 72 | + echo " $0 'Add user authentication system' --short-name 'user-auth'" | ||
| 73 | + echo " $0 'Implement OAuth2 integration for API' --number 5" | ||
| 74 | + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" | ||
| 75 | + exit 0 | ||
| 76 | + ;; | ||
| 77 | + *) | ||
| 78 | + ARGS+=("$arg") | ||
| 79 | + ;; | ||
| 80 | + esac | ||
| 81 | + i=$((i + 1)) | ||
| 82 | +done | ||
| 83 | + | ||
| 84 | +FEATURE_DESCRIPTION="${ARGS[*]}" | ||
| 85 | +if [ -z "$FEATURE_DESCRIPTION" ]; then | ||
| 86 | + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2 | ||
| 87 | + exit 1 | ||
| 88 | +fi | ||
| 89 | + | ||
| 90 | +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) | ||
| 91 | +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') | ||
| 92 | +if [ -z "$FEATURE_DESCRIPTION" ]; then | ||
| 93 | + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 | ||
| 94 | + exit 1 | ||
| 95 | +fi | ||
| 96 | + | ||
| 97 | +MAX_FEATURE_NUMBER=9223372036854775807 | ||
| 98 | +MAX_BRANCH_LENGTH=244 | ||
| 99 | + | ||
| 100 | +is_feature_number_in_range() { | ||
| 101 | + local value="$1" | ||
| 102 | + local normalized="${value#"${value%%[!0]*}"}" | ||
| 103 | + [ -n "$normalized" ] || normalized=0 | ||
| 104 | + [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 | ||
| 105 | + [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 | ||
| 106 | + # Equal-length digit strings must be compared without arithmetic overflow. | ||
| 107 | + # shellcheck disable=SC2071 | ||
| 108 | + [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +# Function to get highest number from specs directory | ||
| 112 | +get_highest_from_specs() { | ||
| 113 | + local specs_dir="$1" | ||
| 114 | + local highest=0 | ||
| 115 | + | ||
| 116 | + if [ -d "$specs_dir" ]; then | ||
| 117 | + for dir in "$specs_dir"/*; do | ||
| 118 | + [ -d "$dir" ] || continue | ||
| 119 | + dirname=$(basename "$dir") | ||
| 120 | + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. | ||
| 121 | + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then | ||
| 122 | + number=$(echo "$dirname" | grep -Eo '^[0-9]+') | ||
| 123 | + if is_feature_number_in_range "$number"; then | ||
| 124 | + number=$((10#$number)) | ||
| 125 | + if [ "$number" -gt "$highest" ]; then | ||
| 126 | + highest=$number | ||
| 127 | + fi | ||
| 128 | + fi | ||
| 129 | + fi | ||
| 130 | + done | ||
| 131 | + fi | ||
| 132 | + | ||
| 133 | + echo "$highest" | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +# Return success when a spec directory owns the given numeric prefix. | ||
| 137 | +spec_prefix_exists() { | ||
| 138 | + local specs_dir="$1" | ||
| 139 | + local feature_num="$2" | ||
| 140 | + | ||
| 141 | + for spec_path in "$specs_dir/${feature_num}-"*; do | ||
| 142 | + [ -d "$spec_path" ] && return 0 | ||
| 143 | + done | ||
| 144 | + return 1 | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +# Function to clean and format a branch name | ||
| 148 | +clean_branch_name() { | ||
| 149 | + local name="$1" | ||
| 150 | + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +# Fit a feature prefix and suffix within GitHub's branch-name limit. | ||
| 154 | +fit_branch_name() { | ||
| 155 | + local feature_num="$1" | ||
| 156 | + local branch_suffix="$2" | ||
| 157 | + local branch_name="${feature_num}-${branch_suffix}" | ||
| 158 | + | ||
| 159 | + if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then | ||
| 160 | + local prefix_length=$(( ${#feature_num} + 1 )) | ||
| 161 | + local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length)) | ||
| 162 | + local truncated_suffix | ||
| 163 | + truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//') | ||
| 164 | + branch_name="${feature_num}-${truncated_suffix}" | ||
| 165 | + fi | ||
| 166 | + | ||
| 167 | + printf '%s' "$branch_name" | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote | ||
| 171 | +# so the persistence hints match the Python variant exactly (printf %q output | ||
| 172 | +# differs between bash versions and from shlex.quote for spaces/metachars). | ||
| 173 | +shell_quote() { | ||
| 174 | + local value="$1" LC_ALL=C | ||
| 175 | + if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then | ||
| 176 | + printf '%s' "$value" | ||
| 177 | + else | ||
| 178 | + local q="'\"'\"'" | ||
| 179 | + printf "'%s'" "${value//\'/$q}" | ||
| 180 | + fi | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +# Resolve repository root using common.sh functions which prioritize .specify | ||
| 184 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 185 | +source "$SCRIPT_DIR/common.sh" | ||
| 186 | + | ||
| 187 | +REPO_ROOT=$(get_repo_root) || exit 1 | ||
| 188 | + | ||
| 189 | +cd "$REPO_ROOT" | ||
| 190 | + | ||
| 191 | +SPECS_DIR="$REPO_ROOT/specs" | ||
| 192 | +if [ "$DRY_RUN" != true ]; then | ||
| 193 | + mkdir -p "$SPECS_DIR" | ||
| 194 | +fi | ||
| 195 | + | ||
| 196 | +# Function to generate branch name with stop word filtering and length filtering | ||
| 197 | +generate_branch_name() { | ||
| 198 | + local description="$1" | ||
| 199 | + | ||
| 200 | + # Common stop words to filter out | ||
| 201 | + 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)$" | ||
| 202 | + | ||
| 203 | + # Convert to lowercase and split into words | ||
| 204 | + local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') | ||
| 205 | + | ||
| 206 | + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) | ||
| 207 | + local meaningful_words=() | ||
| 208 | + for word in $clean_name; do | ||
| 209 | + # Skip empty words | ||
| 210 | + [ -z "$word" ] && continue | ||
| 211 | + | ||
| 212 | + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) | ||
| 213 | + if ! echo "$word" | grep -qiE "$stop_words"; then | ||
| 214 | + if [ ${#word} -ge 3 ]; then | ||
| 215 | + meaningful_words+=("$word") | ||
| 216 | + # Keep short words that appear as an uppercase acronym in the original. | ||
| 217 | + # Uppercase via tr and match with grep -w (both portable) rather than | ||
| 218 | + # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). | ||
| 219 | + elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then | ||
| 220 | + meaningful_words+=("$word") | ||
| 221 | + fi | ||
| 222 | + fi | ||
| 223 | + done | ||
| 224 | + | ||
| 225 | + # If we have meaningful words, use first 3-4 of them | ||
| 226 | + if [ ${#meaningful_words[@]} -gt 0 ]; then | ||
| 227 | + local max_words=3 | ||
| 228 | + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi | ||
| 229 | + | ||
| 230 | + local result="" | ||
| 231 | + local count=0 | ||
| 232 | + for word in "${meaningful_words[@]}"; do | ||
| 233 | + if [ $count -ge $max_words ]; then break; fi | ||
| 234 | + if [ -n "$result" ]; then result="$result-"; fi | ||
| 235 | + result="$result$word" | ||
| 236 | + count=$((count + 1)) | ||
| 237 | + done | ||
| 238 | + echo "$result" | ||
| 239 | + else | ||
| 240 | + # Fallback to original logic if no meaningful words found | ||
| 241 | + local cleaned=$(clean_branch_name "$description") | ||
| 242 | + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' | ||
| 243 | + fi | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +# Generate branch name | ||
| 247 | +if [ -n "$SHORT_NAME" ]; then | ||
| 248 | + # Use provided short name, just clean it up | ||
| 249 | + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") | ||
| 250 | +else | ||
| 251 | + # Generate from description with smart filtering | ||
| 252 | + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") | ||
| 253 | +fi | ||
| 254 | + | ||
| 255 | +# Warn if --number and --timestamp are both specified | ||
| 256 | +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then | ||
| 257 | + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" | ||
| 258 | + BRANCH_NUMBER="" | ||
| 259 | +fi | ||
| 260 | + | ||
| 261 | +# Determine branch prefix | ||
| 262 | +if [ "$USE_TIMESTAMP" = true ]; then | ||
| 263 | + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) | ||
| 264 | + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" | ||
| 265 | +else | ||
| 266 | + if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then | ||
| 267 | + echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 | ||
| 268 | + exit 1 | ||
| 269 | + fi | ||
| 270 | + | ||
| 271 | + # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. | ||
| 272 | + if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then | ||
| 273 | + echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 | ||
| 274 | + exit 1 | ||
| 275 | + fi | ||
| 276 | + | ||
| 277 | + # Determine branch number from existing feature directories | ||
| 278 | + if [ -z "$BRANCH_NUMBER" ]; then | ||
| 279 | + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") | ||
| 280 | + if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then | ||
| 281 | + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 | ||
| 282 | + exit 1 | ||
| 283 | + fi | ||
| 284 | + BRANCH_NUMBER=$((HIGHEST + 1)) | ||
| 285 | + fi | ||
| 286 | + | ||
| 287 | + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) | ||
| 288 | + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") | ||
| 289 | + | ||
| 290 | + # Treat an explicit number as a preference when its prefix is already used | ||
| 291 | + # by a feature directory. Auto-detected numbers are already conflict-free. | ||
| 292 | + if [ "$NUMBER_EXPLICIT" = true ]; then | ||
| 293 | + SPEC_CONFLICT=false | ||
| 294 | + REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") | ||
| 295 | + REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME" | ||
| 296 | + if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then | ||
| 297 | + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true | ||
| 298 | + fi | ||
| 299 | + | ||
| 300 | + if [ "$SPEC_CONFLICT" = true ]; then | ||
| 301 | + REQUESTED_NUM="$FEATURE_NUM" | ||
| 302 | + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") | ||
| 303 | + BRANCH_NUMBER=$HIGHEST | ||
| 304 | + while true; do | ||
| 305 | + if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then | ||
| 306 | + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 | ||
| 307 | + exit 1 | ||
| 308 | + fi | ||
| 309 | + BRANCH_NUMBER=$((BRANCH_NUMBER + 1)) | ||
| 310 | + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") | ||
| 311 | + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break | ||
| 312 | + done | ||
| 313 | + >&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead" | ||
| 314 | + fi | ||
| 315 | + fi | ||
| 316 | + | ||
| 317 | +fi | ||
| 318 | + | ||
| 319 | +# GitHub enforces a 244-byte limit on branch names | ||
| 320 | +# Validate and truncate if necessary | ||
| 321 | +ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" | ||
| 322 | +BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") | ||
| 323 | +if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then | ||
| 324 | + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" | ||
| 325 | + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" | ||
| 326 | + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" | ||
| 327 | +fi | ||
| 328 | + | ||
| 329 | +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" | ||
| 330 | +SPEC_FILE="$FEATURE_DIR/spec.md" | ||
| 331 | + | ||
| 332 | +if [ "$DRY_RUN" != true ]; then | ||
| 333 | + if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then | ||
| 334 | + if [ "$USE_TIMESTAMP" = true ]; then | ||
| 335 | + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name." | ||
| 336 | + else | ||
| 337 | + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number." | ||
| 338 | + fi | ||
| 339 | + exit 1 | ||
| 340 | + fi | ||
| 341 | + | ||
| 342 | + NEEDS_SPEC=false | ||
| 343 | + SPEC_TEMPLATE_FOUND=false | ||
| 344 | + SPEC_TEMPLATE_CONTENT="" | ||
| 345 | + if [ ! -f "$SPEC_FILE" ]; then | ||
| 346 | + NEEDS_SPEC=true | ||
| 347 | + if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | ||
| 348 | + SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}" | ||
| 349 | + SPEC_TEMPLATE_FOUND=true | ||
| 350 | + else | ||
| 351 | + resolve_status=$? | ||
| 352 | + if [ "$resolve_status" -ne 1 ]; then | ||
| 353 | + exit "$resolve_status" | ||
| 354 | + fi | ||
| 355 | + fi | ||
| 356 | + fi | ||
| 357 | + | ||
| 358 | + mkdir -p "$FEATURE_DIR" | ||
| 359 | + | ||
| 360 | + if [ "$NEEDS_SPEC" = true ]; then | ||
| 361 | + if [ "$SPEC_TEMPLATE_FOUND" = true ]; then | ||
| 362 | + printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE" | ||
| 363 | + else | ||
| 364 | + echo "Warning: Spec template not found; created empty spec file" >&2 | ||
| 365 | + touch "$SPEC_FILE" | ||
| 366 | + fi | ||
| 367 | + fi | ||
| 368 | + | ||
| 369 | + # Persist to .specify/feature.json so downstream commands can find the feature | ||
| 370 | + _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" | ||
| 371 | + | ||
| 372 | + # Inform the user how to set feature state in their own shell | ||
| 373 | + printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 | ||
| 374 | + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 | ||
| 375 | +fi | ||
| 376 | + | ||
| 377 | +if $JSON_MODE; then | ||
| 378 | + if command -v jq >/dev/null 2>&1; then | ||
| 379 | + if [ "$DRY_RUN" = true ]; then | ||
| 380 | + jq -cn \ | ||
| 381 | + --arg branch_name "$BRANCH_NAME" \ | ||
| 382 | + --arg spec_file "$SPEC_FILE" \ | ||
| 383 | + --arg feature_num "$FEATURE_NUM" \ | ||
| 384 | + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' | ||
| 385 | + else | ||
| 386 | + jq -cn \ | ||
| 387 | + --arg branch_name "$BRANCH_NAME" \ | ||
| 388 | + --arg spec_file "$SPEC_FILE" \ | ||
| 389 | + --arg feature_num "$FEATURE_NUM" \ | ||
| 390 | + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' | ||
| 391 | + fi | ||
| 392 | + else | ||
| 393 | + if [ "$DRY_RUN" = true ]; then | ||
| 394 | + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" | ||
| 395 | + else | ||
| 396 | + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" | ||
| 397 | + fi | ||
| 398 | + fi | ||
| 399 | +else | ||
| 400 | + echo "BRANCH_NAME: $BRANCH_NAME" | ||
| 401 | + echo "SPEC_FILE: $SPEC_FILE" | ||
| 402 | + echo "FEATURE_NUM: $FEATURE_NUM" | ||
| 403 | + if [ "$DRY_RUN" != true ]; then | ||
| 404 | + printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" | ||
| 405 | + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" | ||
| 406 | + fi | ||
| 407 | +fi | ||
added
.specify/scripts/bash/resolve-template.sh +57 -0 | new file mode 100755 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +set -e | |
| 4 | + | |
| 5 | +SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |
| 6 | +source "$SCRIPT_DIR/common.sh" | |
| 7 | + | |
| 8 | +JSON_MODE=false | |
| 9 | +TEMPLATE_NAME="" | |
| 10 | + | |
| 11 | +for arg in "$@"; do | |
| 12 | + case "$arg" in | |
| 13 | + --json) JSON_MODE=true ;; | |
| 14 | + --help|-h) | |
| 15 | + echo "Usage: $0 <template-name> [--json]" | |
| 16 | + exit 0 | |
| 17 | + ;; | |
| 18 | + -*) | |
| 19 | + echo "ERROR: Unknown option '$arg'" >&2 | |
| 20 | + exit 1 | |
| 21 | + ;; | |
| 22 | + *) | |
| 23 | + if [[ -n "$TEMPLATE_NAME" ]]; then | |
| 24 | + echo "ERROR: Unexpected argument '$arg'" >&2 | |
| 25 | + exit 1 | |
| 26 | + fi | |
| 27 | + TEMPLATE_NAME="$arg" | |
| 28 | + ;; | |
| 29 | + esac | |
| 30 | +done | |
| 31 | + | |
| 32 | +if [[ -z "$TEMPLATE_NAME" ]]; then | |
| 33 | + echo "ERROR: Template name is required" >&2 | |
| 34 | + exit 1 | |
| 35 | +fi | |
| 36 | + | |
| 37 | +REPO_ROOT=$(get_repo_root) | |
| 38 | +if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | |
| 39 | + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" | |
| 40 | +else | |
| 41 | + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 | |
| 42 | + exit 1 | |
| 43 | +fi | |
| 44 | + | |
| 45 | +if $JSON_MODE; then | |
| 46 | + if has_jq; then | |
| 47 | + jq -cn \ | |
| 48 | + --arg template_name "$TEMPLATE_NAME" \ | |
| 49 | + --arg template_content "$TEMPLATE_CONTENT" \ | |
| 50 | + '{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}' | |
| 51 | + else | |
| 52 | + printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \ | |
| 53 | + "$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")" | |
| 54 | + fi | |
| 55 | +else | |
| 56 | + printf '%s' "$TEMPLATE_CONTENT" | |
| 57 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | + | ||
| 3 | +set -e | ||
| 4 | + | ||
| 5 | +SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | ||
| 6 | +source "$SCRIPT_DIR/common.sh" | ||
| 7 | + | ||
| 8 | +JSON_MODE=false | ||
| 9 | +TEMPLATE_NAME="" | ||
| 10 | + | ||
| 11 | +for arg in "$@"; do | ||
| 12 | + case "$arg" in | ||
| 13 | + --json) JSON_MODE=true ;; | ||
| 14 | + --help|-h) | ||
| 15 | + echo "Usage: $0 <template-name> [--json]" | ||
| 16 | + exit 0 | ||
| 17 | + ;; | ||
| 18 | + -*) | ||
| 19 | + echo "ERROR: Unknown option '$arg'" >&2 | ||
| 20 | + exit 1 | ||
| 21 | + ;; | ||
| 22 | + *) | ||
| 23 | + if [[ -n "$TEMPLATE_NAME" ]]; then | ||
| 24 | + echo "ERROR: Unexpected argument '$arg'" >&2 | ||
| 25 | + exit 1 | ||
| 26 | + fi | ||
| 27 | + TEMPLATE_NAME="$arg" | ||
| 28 | + ;; | ||
| 29 | + esac | ||
| 30 | +done | ||
| 31 | + | ||
| 32 | +if [[ -z "$TEMPLATE_NAME" ]]; then | ||
| 33 | + echo "ERROR: Template name is required" >&2 | ||
| 34 | + exit 1 | ||
| 35 | +fi | ||
| 36 | + | ||
| 37 | +REPO_ROOT=$(get_repo_root) | ||
| 38 | +if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | ||
| 39 | + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" | ||
| 40 | +else | ||
| 41 | + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 | ||
| 42 | + exit 1 | ||
| 43 | +fi | ||
| 44 | + | ||
| 45 | +if $JSON_MODE; then | ||
| 46 | + if has_jq; then | ||
| 47 | + jq -cn \ | ||
| 48 | + --arg template_name "$TEMPLATE_NAME" \ | ||
| 49 | + --arg template_content "$TEMPLATE_CONTENT" \ | ||
| 50 | + '{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}' | ||
| 51 | + else | ||
| 52 | + printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \ | ||
| 53 | + "$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")" | ||
| 54 | + fi | ||
| 55 | +else | ||
| 56 | + printf '%s' "$TEMPLATE_CONTENT" | ||
| 57 | +fi | ||
added
.specify/scripts/bash/setup-plan.sh +85 -0 | new file mode 100755 | ||
| @@ -0,0 +1,85 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +set -e | |
| 4 | + | |
| 5 | +# Parse command line arguments | |
| 6 | +JSON_MODE=false | |
| 7 | + | |
| 8 | +for arg in "$@"; do | |
| 9 | + case "$arg" in | |
| 10 | + --json) | |
| 11 | + JSON_MODE=true | |
| 12 | + ;; | |
| 13 | + --help|-h) | |
| 14 | + echo "Usage: $0 [--json]" | |
| 15 | + echo " --json Output results in JSON format" | |
| 16 | + echo " --help Show this help message" | |
| 17 | + exit 0 | |
| 18 | + ;; | |
| 19 | + *) | |
| 20 | + echo "ERROR: Unknown option '$arg'" >&2 | |
| 21 | + exit 1 | |
| 22 | + ;; | |
| 23 | + esac | |
| 24 | +done | |
| 25 | + | |
| 26 | +# Get script directory and load common functions | |
| 27 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 28 | +source "$SCRIPT_DIR/common.sh" | |
| 29 | + | |
| 30 | +# Get all paths and variables from common functions | |
| 31 | +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | |
| 32 | +eval "$_paths_output" | |
| 33 | +unset _paths_output | |
| 34 | + | |
| 35 | +# Ensure the feature directory exists | |
| 36 | +mkdir -p "$FEATURE_DIR" | |
| 37 | + | |
| 38 | +# Copy plan template if plan doesn't already exist | |
| 39 | +if [[ -f "$IMPL_PLAN" ]]; then | |
| 40 | + if $JSON_MODE; then | |
| 41 | + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 | |
| 42 | + else | |
| 43 | + echo "Plan already exists at $IMPL_PLAN, skipping template copy" | |
| 44 | + fi | |
| 45 | +else | |
| 46 | + if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then | |
| 47 | + if $JSON_MODE; then | |
| 48 | + echo "Copied plan template to $IMPL_PLAN" >&2 | |
| 49 | + else | |
| 50 | + echo "Copied plan template to $IMPL_PLAN" | |
| 51 | + fi | |
| 52 | + else | |
| 53 | + resolve_status=$? | |
| 54 | + rm -f "$IMPL_PLAN" | |
| 55 | + if [ "$resolve_status" -ne 1 ]; then | |
| 56 | + exit "$resolve_status" | |
| 57 | + fi | |
| 58 | + if $JSON_MODE; then | |
| 59 | + echo "Warning: Plan template not found" >&2 | |
| 60 | + else | |
| 61 | + echo "Warning: Plan template not found" | |
| 62 | + fi | |
| 63 | + touch "$IMPL_PLAN" | |
| 64 | + fi | |
| 65 | +fi | |
| 66 | + | |
| 67 | +# Output results | |
| 68 | +if $JSON_MODE; then | |
| 69 | + if has_jq; then | |
| 70 | + jq -cn \ | |
| 71 | + --arg feature_spec "$FEATURE_SPEC" \ | |
| 72 | + --arg impl_plan "$IMPL_PLAN" \ | |
| 73 | + --arg specs_dir "$FEATURE_DIR" \ | |
| 74 | + --arg branch "$CURRENT_BRANCH" \ | |
| 75 | + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' | |
| 76 | + else | |
| 77 | + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ | |
| 78 | + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" | |
| 79 | + fi | |
| 80 | +else | |
| 81 | + echo "FEATURE_SPEC: $FEATURE_SPEC" | |
| 82 | + echo "IMPL_PLAN: $IMPL_PLAN" | |
| 83 | + echo "SPECS_DIR: $FEATURE_DIR" | |
| 84 | + echo "BRANCH: $CURRENT_BRANCH" | |
| 85 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | + | ||
| 3 | +set -e | ||
| 4 | + | ||
| 5 | +# Parse command line arguments | ||
| 6 | +JSON_MODE=false | ||
| 7 | + | ||
| 8 | +for arg in "$@"; do | ||
| 9 | + case "$arg" in | ||
| 10 | + --json) | ||
| 11 | + JSON_MODE=true | ||
| 12 | + ;; | ||
| 13 | + --help|-h) | ||
| 14 | + echo "Usage: $0 [--json]" | ||
| 15 | + echo " --json Output results in JSON format" | ||
| 16 | + echo " --help Show this help message" | ||
| 17 | + exit 0 | ||
| 18 | + ;; | ||
| 19 | + *) | ||
| 20 | + echo "ERROR: Unknown option '$arg'" >&2 | ||
| 21 | + exit 1 | ||
| 22 | + ;; | ||
| 23 | + esac | ||
| 24 | +done | ||
| 25 | + | ||
| 26 | +# Get script directory and load common functions | ||
| 27 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 28 | +source "$SCRIPT_DIR/common.sh" | ||
| 29 | + | ||
| 30 | +# Get all paths and variables from common functions | ||
| 31 | +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | ||
| 32 | +eval "$_paths_output" | ||
| 33 | +unset _paths_output | ||
| 34 | + | ||
| 35 | +# Ensure the feature directory exists | ||
| 36 | +mkdir -p "$FEATURE_DIR" | ||
| 37 | + | ||
| 38 | +# Copy plan template if plan doesn't already exist | ||
| 39 | +if [[ -f "$IMPL_PLAN" ]]; then | ||
| 40 | + if $JSON_MODE; then | ||
| 41 | + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 | ||
| 42 | + else | ||
| 43 | + echo "Plan already exists at $IMPL_PLAN, skipping template copy" | ||
| 44 | + fi | ||
| 45 | +else | ||
| 46 | + if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then | ||
| 47 | + if $JSON_MODE; then | ||
| 48 | + echo "Copied plan template to $IMPL_PLAN" >&2 | ||
| 49 | + else | ||
| 50 | + echo "Copied plan template to $IMPL_PLAN" | ||
| 51 | + fi | ||
| 52 | + else | ||
| 53 | + resolve_status=$? | ||
| 54 | + rm -f "$IMPL_PLAN" | ||
| 55 | + if [ "$resolve_status" -ne 1 ]; then | ||
| 56 | + exit "$resolve_status" | ||
| 57 | + fi | ||
| 58 | + if $JSON_MODE; then | ||
| 59 | + echo "Warning: Plan template not found" >&2 | ||
| 60 | + else | ||
| 61 | + echo "Warning: Plan template not found" | ||
| 62 | + fi | ||
| 63 | + touch "$IMPL_PLAN" | ||
| 64 | + fi | ||
| 65 | +fi | ||
| 66 | + | ||
| 67 | +# Output results | ||
| 68 | +if $JSON_MODE; then | ||
| 69 | + if has_jq; then | ||
| 70 | + jq -cn \ | ||
| 71 | + --arg feature_spec "$FEATURE_SPEC" \ | ||
| 72 | + --arg impl_plan "$IMPL_PLAN" \ | ||
| 73 | + --arg specs_dir "$FEATURE_DIR" \ | ||
| 74 | + --arg branch "$CURRENT_BRANCH" \ | ||
| 75 | + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' | ||
| 76 | + else | ||
| 77 | + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ | ||
| 78 | + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" | ||
| 79 | + fi | ||
| 80 | +else | ||
| 81 | + echo "FEATURE_SPEC: $FEATURE_SPEC" | ||
| 82 | + echo "IMPL_PLAN: $IMPL_PLAN" | ||
| 83 | + echo "SPECS_DIR: $FEATURE_DIR" | ||
| 84 | + echo "BRANCH: $CURRENT_BRANCH" | ||
| 85 | +fi | ||
added
.specify/scripts/bash/setup-tasks.sh +94 -0 | new file mode 100755 | ||
| @@ -0,0 +1,94 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +set -e | |
| 4 | + | |
| 5 | +# Parse command line arguments | |
| 6 | +JSON_MODE=false | |
| 7 | + | |
| 8 | +for arg in "$@"; do | |
| 9 | + case "$arg" in | |
| 10 | + --json) JSON_MODE=true ;; | |
| 11 | + --help|-h) | |
| 12 | + echo "Usage: $0 [--json]" | |
| 13 | + echo " --json Output results in JSON format" | |
| 14 | + echo " --help Show this help message" | |
| 15 | + exit 0 | |
| 16 | + ;; | |
| 17 | + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; | |
| 18 | + esac | |
| 19 | +done | |
| 20 | + | |
| 21 | +# Source common functions | |
| 22 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 23 | +source "$SCRIPT_DIR/common.sh" | |
| 24 | + | |
| 25 | +# Get feature paths | |
| 26 | +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | |
| 27 | +eval "$_paths_output" | |
| 28 | +unset _paths_output | |
| 29 | + | |
| 30 | +# Validate required files | |
| 31 | +if [[ ! -f "$IMPL_PLAN" ]]; then | |
| 32 | + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 | |
| 33 | + echo "Run /speckit-plan first to create the implementation plan." >&2 | |
| 34 | + exit 1 | |
| 35 | +fi | |
| 36 | + | |
| 37 | +if [[ ! -f "$FEATURE_SPEC" ]]; then | |
| 38 | + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 | |
| 39 | + echo "Run /speckit-specify first to create the feature structure." >&2 | |
| 40 | + exit 1 | |
| 41 | +fi | |
| 42 | + | |
| 43 | +# Build available docs list | |
| 44 | +docs=() | |
| 45 | +[[ -f "$RESEARCH" ]] && docs+=("research.md") | |
| 46 | +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") | |
| 47 | +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then | |
| 48 | + docs+=("contracts/") | |
| 49 | +fi | |
| 50 | +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") | |
| 51 | + | |
| 52 | +# Resolve tasks template through override stack | |
| 53 | +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true | |
| 54 | +if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | |
| 55 | + TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}" | |
| 56 | +else | |
| 57 | + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 | |
| 58 | + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 | |
| 59 | + exit 1 | |
| 60 | +fi | |
| 61 | + | |
| 62 | +# Output results | |
| 63 | +if $JSON_MODE; then | |
| 64 | + if has_jq; then | |
| 65 | + if [[ ${#docs[@]} -eq 0 ]]; then | |
| 66 | + json_docs="[]" | |
| 67 | + else | |
| 68 | + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) | |
| 69 | + fi | |
| 70 | + jq -cn \ | |
| 71 | + --arg feature_dir "$FEATURE_DIR" \ | |
| 72 | + --argjson docs "$json_docs" \ | |
| 73 | + --arg tasks_template "${TASKS_TEMPLATE:-}" \ | |
| 74 | + --arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \ | |
| 75 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}' | |
| 76 | + else | |
| 77 | + if [[ ${#docs[@]} -eq 0 ]]; then | |
| 78 | + json_docs="[]" | |
| 79 | + else | |
| 80 | + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) | |
| 81 | + json_docs="[${json_docs%,}]" | |
| 82 | + fi | |
| 83 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \ | |
| 84 | + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")" | |
| 85 | + fi | |
| 86 | +else | |
| 87 | + echo "FEATURE_DIR: $FEATURE_DIR" | |
| 88 | + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" | |
| 89 | + echo "AVAILABLE_DOCS:" | |
| 90 | + check_file "$RESEARCH" "research.md" | |
| 91 | + check_file "$DATA_MODEL" "data-model.md" | |
| 92 | + check_dir "$CONTRACTS_DIR" "contracts/" | |
| 93 | + check_file "$QUICKSTART" "quickstart.md" | |
| 94 | +fi | |
| new file mode 100755 | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | + | ||
| 3 | +set -e | ||
| 4 | + | ||
| 5 | +# Parse command line arguments | ||
| 6 | +JSON_MODE=false | ||
| 7 | + | ||
| 8 | +for arg in "$@"; do | ||
| 9 | + case "$arg" in | ||
| 10 | + --json) JSON_MODE=true ;; | ||
| 11 | + --help|-h) | ||
| 12 | + echo "Usage: $0 [--json]" | ||
| 13 | + echo " --json Output results in JSON format" | ||
| 14 | + echo " --help Show this help message" | ||
| 15 | + exit 0 | ||
| 16 | + ;; | ||
| 17 | + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; | ||
| 18 | + esac | ||
| 19 | +done | ||
| 20 | + | ||
| 21 | +# Source common functions | ||
| 22 | +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 23 | +source "$SCRIPT_DIR/common.sh" | ||
| 24 | + | ||
| 25 | +# Get feature paths | ||
| 26 | +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } | ||
| 27 | +eval "$_paths_output" | ||
| 28 | +unset _paths_output | ||
| 29 | + | ||
| 30 | +# Validate required files | ||
| 31 | +if [[ ! -f "$IMPL_PLAN" ]]; then | ||
| 32 | + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 | ||
| 33 | + echo "Run /speckit-plan first to create the implementation plan." >&2 | ||
| 34 | + exit 1 | ||
| 35 | +fi | ||
| 36 | + | ||
| 37 | +if [[ ! -f "$FEATURE_SPEC" ]]; then | ||
| 38 | + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 | ||
| 39 | + echo "Run /speckit-specify first to create the feature structure." >&2 | ||
| 40 | + exit 1 | ||
| 41 | +fi | ||
| 42 | + | ||
| 43 | +# Build available docs list | ||
| 44 | +docs=() | ||
| 45 | +[[ -f "$RESEARCH" ]] && docs+=("research.md") | ||
| 46 | +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") | ||
| 47 | +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then | ||
| 48 | + docs+=("contracts/") | ||
| 49 | +fi | ||
| 50 | +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") | ||
| 51 | + | ||
| 52 | +# Resolve tasks template through override stack | ||
| 53 | +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true | ||
| 54 | +if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then | ||
| 55 | + TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}" | ||
| 56 | +else | ||
| 57 | + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 | ||
| 58 | + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 | ||
| 59 | + exit 1 | ||
| 60 | +fi | ||
| 61 | + | ||
| 62 | +# Output results | ||
| 63 | +if $JSON_MODE; then | ||
| 64 | + if has_jq; then | ||
| 65 | + if [[ ${#docs[@]} -eq 0 ]]; then | ||
| 66 | + json_docs="[]" | ||
| 67 | + else | ||
| 68 | + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) | ||
| 69 | + fi | ||
| 70 | + jq -cn \ | ||
| 71 | + --arg feature_dir "$FEATURE_DIR" \ | ||
| 72 | + --argjson docs "$json_docs" \ | ||
| 73 | + --arg tasks_template "${TASKS_TEMPLATE:-}" \ | ||
| 74 | + --arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \ | ||
| 75 | + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}' | ||
| 76 | + else | ||
| 77 | + if [[ ${#docs[@]} -eq 0 ]]; then | ||
| 78 | + json_docs="[]" | ||
| 79 | + else | ||
| 80 | + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) | ||
| 81 | + json_docs="[${json_docs%,}]" | ||
| 82 | + fi | ||
| 83 | + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \ | ||
| 84 | + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")" | ||
| 85 | + fi | ||
| 86 | +else | ||
| 87 | + echo "FEATURE_DIR: $FEATURE_DIR" | ||
| 88 | + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" | ||
| 89 | + echo "AVAILABLE_DOCS:" | ||
| 90 | + check_file "$RESEARCH" "research.md" | ||
| 91 | + check_file "$DATA_MODEL" "data-model.md" | ||
| 92 | + check_dir "$CONTRACTS_DIR" "contracts/" | ||
| 93 | + check_file "$QUICKSTART" "quickstart.md" | ||
| 94 | +fi | ||
added
.specify/templates/checklist-template.md +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] | |
| 2 | + | |
| 3 | +**Purpose**: [Brief description of what this checklist covers] | |
| 4 | +**Created**: [DATE] | |
| 5 | +**Feature**: [Link to spec.md or relevant documentation] | |
| 6 | + | |
| 7 | +**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements. | |
| 8 | +**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied. | |
| 9 | +**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete. | |
| 10 | + | |
| 11 | +<!-- | |
| 12 | + ============================================================================ | |
| 13 | + IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only. | |
| 14 | + | |
| 15 | + The /speckit-checklist command MUST replace these with actual items based on: | |
| 16 | + - User's specific checklist request | |
| 17 | + - Feature requirements from spec.md | |
| 18 | + - Technical context from plan.md | |
| 19 | + - Implementation details from tasks.md | |
| 20 | + | |
| 21 | + DO NOT keep these sample items in the generated checklist file. | |
| 22 | + ============================================================================ | |
| 23 | +--> | |
| 24 | + | |
| 25 | +## [Category 1] | |
| 26 | + | |
| 27 | +- [ ] CHK001 First checklist item with clear action | |
| 28 | +- [ ] CHK002 Second checklist item | |
| 29 | +- [ ] CHK003 Third checklist item | |
| 30 | + | |
| 31 | +## [Category 2] | |
| 32 | + | |
| 33 | +- [ ] CHK004 Another category item | |
| 34 | +- [ ] CHK005 Item with specific criteria | |
| 35 | +- [ ] CHK006 Final item in this category | |
| 36 | + | |
| 37 | +## Notes | |
| 38 | + | |
| 39 | +- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied | |
| 40 | +- Leave items unchecked when they still require clarification, correction, or reviewer evaluation | |
| 41 | +- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers | |
| 42 | +- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify` | |
| 43 | +- Add comments or findings inline | |
| 44 | +- Link to relevant resources or documentation | |
| 45 | +- Items are numbered sequentially for easy reference | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] | ||
| 2 | + | ||
| 3 | +**Purpose**: [Brief description of what this checklist covers] | ||
| 4 | +**Created**: [DATE] | ||
| 5 | +**Feature**: [Link to spec.md or relevant documentation] | ||
| 6 | + | ||
| 7 | +**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements. | ||
| 8 | +**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied. | ||
| 9 | +**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete. | ||
| 10 | + | ||
| 11 | +<!-- | ||
| 12 | + ============================================================================ | ||
| 13 | + IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only. | ||
| 14 | + | ||
| 15 | + The /speckit-checklist command MUST replace these with actual items based on: | ||
| 16 | + - User's specific checklist request | ||
| 17 | + - Feature requirements from spec.md | ||
| 18 | + - Technical context from plan.md | ||
| 19 | + - Implementation details from tasks.md | ||
| 20 | + | ||
| 21 | + DO NOT keep these sample items in the generated checklist file. | ||
| 22 | + ============================================================================ | ||
| 23 | +--> | ||
| 24 | + | ||
| 25 | +## [Category 1] | ||
| 26 | + | ||
| 27 | +- [ ] CHK001 First checklist item with clear action | ||
| 28 | +- [ ] CHK002 Second checklist item | ||
| 29 | +- [ ] CHK003 Third checklist item | ||
| 30 | + | ||
| 31 | +## [Category 2] | ||
| 32 | + | ||
| 33 | +- [ ] CHK004 Another category item | ||
| 34 | +- [ ] CHK005 Item with specific criteria | ||
| 35 | +- [ ] CHK006 Final item in this category | ||
| 36 | + | ||
| 37 | +## Notes | ||
| 38 | + | ||
| 39 | +- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied | ||
| 40 | +- Leave items unchecked when they still require clarification, correction, or reviewer evaluation | ||
| 41 | +- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers | ||
| 42 | +- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify` | ||
| 43 | +- Add comments or findings inline | ||
| 44 | +- Link to relevant resources or documentation | ||
| 45 | +- Items are numbered sequentially for easy reference | ||
added
.specify/templates/constitution-template.md +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +# [PROJECT_NAME] Constitution | |
| 2 | +<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | |
| 3 | + | |
| 4 | +## Core Principles | |
| 5 | + | |
| 6 | +### [PRINCIPLE_1_NAME] | |
| 7 | +<!-- Example: I. Library-First --> | |
| 8 | +[PRINCIPLE_1_DESCRIPTION] | |
| 9 | +<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | |
| 10 | + | |
| 11 | +### [PRINCIPLE_2_NAME] | |
| 12 | +<!-- Example: II. CLI Interface --> | |
| 13 | +[PRINCIPLE_2_DESCRIPTION] | |
| 14 | +<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | |
| 15 | + | |
| 16 | +### [PRINCIPLE_3_NAME] | |
| 17 | +<!-- Example: III. Test-First (NON-NEGOTIABLE) --> | |
| 18 | +[PRINCIPLE_3_DESCRIPTION] | |
| 19 | +<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | |
| 20 | + | |
| 21 | +### [PRINCIPLE_4_NAME] | |
| 22 | +<!-- Example: IV. Integration Testing --> | |
| 23 | +[PRINCIPLE_4_DESCRIPTION] | |
| 24 | +<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | |
| 25 | + | |
| 26 | +### [PRINCIPLE_5_NAME] | |
| 27 | +<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | |
| 28 | +[PRINCIPLE_5_DESCRIPTION] | |
| 29 | +<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | |
| 30 | + | |
| 31 | +## [SECTION_2_NAME] | |
| 32 | +<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | |
| 33 | + | |
| 34 | +[SECTION_2_CONTENT] | |
| 35 | +<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | |
| 36 | + | |
| 37 | +## [SECTION_3_NAME] | |
| 38 | +<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | |
| 39 | + | |
| 40 | +[SECTION_3_CONTENT] | |
| 41 | +<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | |
| 42 | + | |
| 43 | +## Governance | |
| 44 | +<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | |
| 45 | + | |
| 46 | +[GOVERNANCE_RULES] | |
| 47 | +<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | |
| 48 | + | |
| 49 | +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | |
| 50 | +<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +# [PROJECT_NAME] Constitution | ||
| 2 | +<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | ||
| 3 | + | ||
| 4 | +## Core Principles | ||
| 5 | + | ||
| 6 | +### [PRINCIPLE_1_NAME] | ||
| 7 | +<!-- Example: I. Library-First --> | ||
| 8 | +[PRINCIPLE_1_DESCRIPTION] | ||
| 9 | +<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | ||
| 10 | + | ||
| 11 | +### [PRINCIPLE_2_NAME] | ||
| 12 | +<!-- Example: II. CLI Interface --> | ||
| 13 | +[PRINCIPLE_2_DESCRIPTION] | ||
| 14 | +<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | ||
| 15 | + | ||
| 16 | +### [PRINCIPLE_3_NAME] | ||
| 17 | +<!-- Example: III. Test-First (NON-NEGOTIABLE) --> | ||
| 18 | +[PRINCIPLE_3_DESCRIPTION] | ||
| 19 | +<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | ||
| 20 | + | ||
| 21 | +### [PRINCIPLE_4_NAME] | ||
| 22 | +<!-- Example: IV. Integration Testing --> | ||
| 23 | +[PRINCIPLE_4_DESCRIPTION] | ||
| 24 | +<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | ||
| 25 | + | ||
| 26 | +### [PRINCIPLE_5_NAME] | ||
| 27 | +<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | ||
| 28 | +[PRINCIPLE_5_DESCRIPTION] | ||
| 29 | +<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | ||
| 30 | + | ||
| 31 | +## [SECTION_2_NAME] | ||
| 32 | +<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | ||
| 33 | + | ||
| 34 | +[SECTION_2_CONTENT] | ||
| 35 | +<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | ||
| 36 | + | ||
| 37 | +## [SECTION_3_NAME] | ||
| 38 | +<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | ||
| 39 | + | ||
| 40 | +[SECTION_3_CONTENT] | ||
| 41 | +<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | ||
| 42 | + | ||
| 43 | +## Governance | ||
| 44 | +<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | ||
| 45 | + | ||
| 46 | +[GOVERNANCE_RULES] | ||
| 47 | +<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | ||
| 48 | + | ||
| 49 | +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | ||
| 50 | +<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> | ||
added
.specify/templates/plan-template.md +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +# Implementation Plan: [FEATURE] | |
| 2 | + | |
| 3 | +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] | |
| 4 | + | |
| 5 | +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` | |
| 6 | + | |
| 7 | +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. | |
| 8 | + | |
| 9 | +## Summary | |
| 10 | + | |
| 11 | +[Extract from feature spec: primary requirement + technical approach from research] | |
| 12 | + | |
| 13 | +## Technical Context | |
| 14 | + | |
| 15 | +<!-- | |
| 16 | + ACTION REQUIRED: Replace the content in this section with the technical details | |
| 17 | + for the project. The structure here is presented in advisory capacity to guide | |
| 18 | + the iteration process. | |
| 19 | +--> | |
| 20 | + | |
| 21 | +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] | |
| 22 | + | |
| 23 | +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] | |
| 24 | + | |
| 25 | +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] | |
| 26 | + | |
| 27 | +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] | |
| 28 | + | |
| 29 | +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] | |
| 30 | + | |
| 31 | +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] | |
| 32 | + | |
| 33 | +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] | |
| 34 | + | |
| 35 | +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] | |
| 36 | + | |
| 37 | +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] | |
| 38 | + | |
| 39 | +## Constitution Check | |
| 40 | + | |
| 41 | +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | |
| 42 | + | |
| 43 | +[Gates determined based on constitution file] | |
| 44 | + | |
| 45 | +## Project Structure | |
| 46 | + | |
| 47 | +### Documentation (this feature) | |
| 48 | + | |
| 49 | +```text | |
| 50 | +specs/[###-feature]/ | |
| 51 | +├── plan.md # This file (/speckit-plan command output) | |
| 52 | +├── research.md # Phase 0 output (/speckit-plan command) | |
| 53 | +├── data-model.md # Phase 1 output (/speckit-plan command) | |
| 54 | +├── quickstart.md # Phase 1 output (/speckit-plan command) | |
| 55 | +├── contracts/ # Phase 1 output (/speckit-plan command) | |
| 56 | +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) | |
| 57 | +``` | |
| 58 | + | |
| 59 | +### Source Code (repository root) | |
| 60 | +<!-- | |
| 61 | + ACTION REQUIRED: Replace the placeholder tree below with the concrete layout | |
| 62 | + for this feature. Delete unused options and expand the chosen structure with | |
| 63 | + real paths (e.g., apps/admin, packages/something). The delivered plan must | |
| 64 | + not include Option labels. | |
| 65 | +--> | |
| 66 | + | |
| 67 | +```text | |
| 68 | +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) | |
| 69 | +src/ | |
| 70 | +├── models/ | |
| 71 | +├── services/ | |
| 72 | +├── cli/ | |
| 73 | +└── lib/ | |
| 74 | + | |
| 75 | +tests/ | |
| 76 | +├── contract/ | |
| 77 | +├── integration/ | |
| 78 | +└── unit/ | |
| 79 | + | |
| 80 | +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) | |
| 81 | +backend/ | |
| 82 | +├── src/ | |
| 83 | +│ ├── models/ | |
| 84 | +│ ├── services/ | |
| 85 | +│ └── api/ | |
| 86 | +└── tests/ | |
| 87 | + | |
| 88 | +frontend/ | |
| 89 | +├── src/ | |
| 90 | +│ ├── components/ | |
| 91 | +│ ├── pages/ | |
| 92 | +│ └── services/ | |
| 93 | +└── tests/ | |
| 94 | + | |
| 95 | +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) | |
| 96 | +api/ | |
| 97 | +└── [same as backend above] | |
| 98 | + | |
| 99 | +ios/ or android/ | |
| 100 | +└── [platform-specific structure: feature modules, UI flows, platform tests] | |
| 101 | +``` | |
| 102 | + | |
| 103 | +**Structure Decision**: [Document the selected structure and reference the real | |
| 104 | +directories captured above] | |
| 105 | + | |
| 106 | +## Complexity Tracking | |
| 107 | + | |
| 108 | +> **Fill ONLY if Constitution Check has violations that must be justified** | |
| 109 | + | |
| 110 | +| Violation | Why Needed | Simpler Alternative Rejected Because | | |
| 111 | +|-----------|------------|-------------------------------------| | |
| 112 | +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | | |
| 113 | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +# Implementation Plan: [FEATURE] | ||
| 2 | + | ||
| 3 | +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] | ||
| 4 | + | ||
| 5 | +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` | ||
| 6 | + | ||
| 7 | +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. | ||
| 8 | + | ||
| 9 | +## Summary | ||
| 10 | + | ||
| 11 | +[Extract from feature spec: primary requirement + technical approach from research] | ||
| 12 | + | ||
| 13 | +## Technical Context | ||
| 14 | + | ||
| 15 | +<!-- | ||
| 16 | + ACTION REQUIRED: Replace the content in this section with the technical details | ||
| 17 | + for the project. The structure here is presented in advisory capacity to guide | ||
| 18 | + the iteration process. | ||
| 19 | +--> | ||
| 20 | + | ||
| 21 | +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] | ||
| 22 | + | ||
| 23 | +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] | ||
| 24 | + | ||
| 25 | +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] | ||
| 26 | + | ||
| 27 | +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] | ||
| 28 | + | ||
| 29 | +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] | ||
| 30 | + | ||
| 31 | +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] | ||
| 32 | + | ||
| 33 | +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] | ||
| 34 | + | ||
| 35 | +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] | ||
| 36 | + | ||
| 37 | +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] | ||
| 38 | + | ||
| 39 | +## Constitution Check | ||
| 40 | + | ||
| 41 | +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | ||
| 42 | + | ||
| 43 | +[Gates determined based on constitution file] | ||
| 44 | + | ||
| 45 | +## Project Structure | ||
| 46 | + | ||
| 47 | +### Documentation (this feature) | ||
| 48 | + | ||
| 49 | +```text | ||
| 50 | +specs/[###-feature]/ | ||
| 51 | +├── plan.md # This file (/speckit-plan command output) | ||
| 52 | +├── research.md # Phase 0 output (/speckit-plan command) | ||
| 53 | +├── data-model.md # Phase 1 output (/speckit-plan command) | ||
| 54 | +├── quickstart.md # Phase 1 output (/speckit-plan command) | ||
| 55 | +├── contracts/ # Phase 1 output (/speckit-plan command) | ||
| 56 | +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +### Source Code (repository root) | ||
| 60 | +<!-- | ||
| 61 | + ACTION REQUIRED: Replace the placeholder tree below with the concrete layout | ||
| 62 | + for this feature. Delete unused options and expand the chosen structure with | ||
| 63 | + real paths (e.g., apps/admin, packages/something). The delivered plan must | ||
| 64 | + not include Option labels. | ||
| 65 | +--> | ||
| 66 | + | ||
| 67 | +```text | ||
| 68 | +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) | ||
| 69 | +src/ | ||
| 70 | +├── models/ | ||
| 71 | +├── services/ | ||
| 72 | +├── cli/ | ||
| 73 | +└── lib/ | ||
| 74 | + | ||
| 75 | +tests/ | ||
| 76 | +├── contract/ | ||
| 77 | +├── integration/ | ||
| 78 | +└── unit/ | ||
| 79 | + | ||
| 80 | +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) | ||
| 81 | +backend/ | ||
| 82 | +├── src/ | ||
| 83 | +│ ├── models/ | ||
| 84 | +│ ├── services/ | ||
| 85 | +│ └── api/ | ||
| 86 | +└── tests/ | ||
| 87 | + | ||
| 88 | +frontend/ | ||
| 89 | +├── src/ | ||
| 90 | +│ ├── components/ | ||
| 91 | +│ ├── pages/ | ||
| 92 | +│ └── services/ | ||
| 93 | +└── tests/ | ||
| 94 | + | ||
| 95 | +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) | ||
| 96 | +api/ | ||
| 97 | +└── [same as backend above] | ||
| 98 | + | ||
| 99 | +ios/ or android/ | ||
| 100 | +└── [platform-specific structure: feature modules, UI flows, platform tests] | ||
| 101 | +``` | ||
| 102 | + | ||
| 103 | +**Structure Decision**: [Document the selected structure and reference the real | ||
| 104 | +directories captured above] | ||
| 105 | + | ||
| 106 | +## Complexity Tracking | ||
| 107 | + | ||
| 108 | +> **Fill ONLY if Constitution Check has violations that must be justified** | ||
| 109 | + | ||
| 110 | +| Violation | Why Needed | Simpler Alternative Rejected Because | | ||
| 111 | +|-----------|------------|-------------------------------------| | ||
| 112 | +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | | ||
| 113 | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | | ||
added
.specify/templates/spec-template.md +131 -0 | new file mode 100644 | ||
| @@ -0,0 +1,131 @@ | ||
| 1 | +# Feature Specification: [FEATURE NAME] | |
| 2 | + | |
| 3 | +**Feature Branch**: `[###-feature-name]` | |
| 4 | + | |
| 5 | +**Created**: [DATE] | |
| 6 | + | |
| 7 | +**Status**: Draft | |
| 8 | + | |
| 9 | +**Input**: User description: "$ARGUMENTS" | |
| 10 | + | |
| 11 | +## User Scenarios & Testing *(mandatory)* | |
| 12 | + | |
| 13 | +<!-- | |
| 14 | + IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance. | |
| 15 | + Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them, | |
| 16 | + you should still have a viable MVP (Minimum Viable Product) that delivers value. | |
| 17 | + | |
| 18 | + Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical. | |
| 19 | + Think of each story as a standalone slice of functionality that can be: | |
| 20 | + - Developed independently | |
| 21 | + - Tested independently | |
| 22 | + - Deployed independently | |
| 23 | + - Demonstrated to users independently | |
| 24 | +--> | |
| 25 | + | |
| 26 | +### User Story 1 - [Brief Title] (Priority: P1) | |
| 27 | + | |
| 28 | +[Describe this user journey in plain language] | |
| 29 | + | |
| 30 | +**Why this priority**: [Explain the value and why it has this priority level] | |
| 31 | + | |
| 32 | +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] | |
| 33 | + | |
| 34 | +**Acceptance Scenarios**: | |
| 35 | + | |
| 36 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | |
| 37 | +2. **Given** [initial state], **When** [action], **Then** [expected outcome] | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +### User Story 2 - [Brief Title] (Priority: P2) | |
| 42 | + | |
| 43 | +[Describe this user journey in plain language] | |
| 44 | + | |
| 45 | +**Why this priority**: [Explain the value and why it has this priority level] | |
| 46 | + | |
| 47 | +**Independent Test**: [Describe how this can be tested independently] | |
| 48 | + | |
| 49 | +**Acceptance Scenarios**: | |
| 50 | + | |
| 51 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +### User Story 3 - [Brief Title] (Priority: P3) | |
| 56 | + | |
| 57 | +[Describe this user journey in plain language] | |
| 58 | + | |
| 59 | +**Why this priority**: [Explain the value and why it has this priority level] | |
| 60 | + | |
| 61 | +**Independent Test**: [Describe how this can be tested independently] | |
| 62 | + | |
| 63 | +**Acceptance Scenarios**: | |
| 64 | + | |
| 65 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +[Add more user stories as needed, each with an assigned priority] | |
| 70 | + | |
| 71 | +### Edge Cases | |
| 72 | + | |
| 73 | +<!-- | |
| 74 | + ACTION REQUIRED: The content in this section represents placeholders. | |
| 75 | + Fill them out with the right edge cases. | |
| 76 | +--> | |
| 77 | + | |
| 78 | +- What happens when [boundary condition]? | |
| 79 | +- How does system handle [error scenario]? | |
| 80 | + | |
| 81 | +## Requirements *(mandatory)* | |
| 82 | + | |
| 83 | +<!-- | |
| 84 | + ACTION REQUIRED: The content in this section represents placeholders. | |
| 85 | + Fill them out with the right functional requirements. | |
| 86 | +--> | |
| 87 | + | |
| 88 | +### Functional Requirements | |
| 89 | + | |
| 90 | +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] | |
| 91 | +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] | |
| 92 | +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] | |
| 93 | +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] | |
| 94 | +- **FR-005**: System MUST [behavior, e.g., "log all security events"] | |
| 95 | + | |
| 96 | +*Example of marking unclear requirements:* | |
| 97 | + | |
| 98 | +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] | |
| 99 | +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] | |
| 100 | + | |
| 101 | +### Key Entities *(include if feature involves data)* | |
| 102 | + | |
| 103 | +- **[Entity 1]**: [What it represents, key attributes without implementation] | |
| 104 | +- **[Entity 2]**: [What it represents, relationships to other entities] | |
| 105 | + | |
| 106 | +## Success Criteria *(mandatory)* | |
| 107 | + | |
| 108 | +<!-- | |
| 109 | + ACTION REQUIRED: Define measurable success criteria. | |
| 110 | + These must be technology-agnostic and measurable. | |
| 111 | +--> | |
| 112 | + | |
| 113 | +### Measurable Outcomes | |
| 114 | + | |
| 115 | +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] | |
| 116 | +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] | |
| 117 | +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] | |
| 118 | +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] | |
| 119 | + | |
| 120 | +## Assumptions | |
| 121 | + | |
| 122 | +<!-- | |
| 123 | + ACTION REQUIRED: The content in this section represents placeholders. | |
| 124 | + Fill them out with the right assumptions based on reasonable defaults | |
| 125 | + chosen when the feature description did not specify certain details. | |
| 126 | +--> | |
| 127 | + | |
| 128 | +- [Assumption about target users, e.g., "Users have stable internet connectivity"] | |
| 129 | +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] | |
| 130 | +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] | |
| 131 | +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] | |
| new file mode 100644 | |||
| @@ -0,0 +1,131 @@ | |||
| 1 | +# Feature Specification: [FEATURE NAME] | ||
| 2 | + | ||
| 3 | +**Feature Branch**: `[###-feature-name]` | ||
| 4 | + | ||
| 5 | +**Created**: [DATE] | ||
| 6 | + | ||
| 7 | +**Status**: Draft | ||
| 8 | + | ||
| 9 | +**Input**: User description: "$ARGUMENTS" | ||
| 10 | + | ||
| 11 | +## User Scenarios & Testing *(mandatory)* | ||
| 12 | + | ||
| 13 | +<!-- | ||
| 14 | + IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance. | ||
| 15 | + Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them, | ||
| 16 | + you should still have a viable MVP (Minimum Viable Product) that delivers value. | ||
| 17 | + | ||
| 18 | + Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical. | ||
| 19 | + Think of each story as a standalone slice of functionality that can be: | ||
| 20 | + - Developed independently | ||
| 21 | + - Tested independently | ||
| 22 | + - Deployed independently | ||
| 23 | + - Demonstrated to users independently | ||
| 24 | +--> | ||
| 25 | + | ||
| 26 | +### User Story 1 - [Brief Title] (Priority: P1) | ||
| 27 | + | ||
| 28 | +[Describe this user journey in plain language] | ||
| 29 | + | ||
| 30 | +**Why this priority**: [Explain the value and why it has this priority level] | ||
| 31 | + | ||
| 32 | +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] | ||
| 33 | + | ||
| 34 | +**Acceptance Scenarios**: | ||
| 35 | + | ||
| 36 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | ||
| 37 | +2. **Given** [initial state], **When** [action], **Then** [expected outcome] | ||
| 38 | + | ||
| 39 | +--- | ||
| 40 | + | ||
| 41 | +### User Story 2 - [Brief Title] (Priority: P2) | ||
| 42 | + | ||
| 43 | +[Describe this user journey in plain language] | ||
| 44 | + | ||
| 45 | +**Why this priority**: [Explain the value and why it has this priority level] | ||
| 46 | + | ||
| 47 | +**Independent Test**: [Describe how this can be tested independently] | ||
| 48 | + | ||
| 49 | +**Acceptance Scenarios**: | ||
| 50 | + | ||
| 51 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | ||
| 52 | + | ||
| 53 | +--- | ||
| 54 | + | ||
| 55 | +### User Story 3 - [Brief Title] (Priority: P3) | ||
| 56 | + | ||
| 57 | +[Describe this user journey in plain language] | ||
| 58 | + | ||
| 59 | +**Why this priority**: [Explain the value and why it has this priority level] | ||
| 60 | + | ||
| 61 | +**Independent Test**: [Describe how this can be tested independently] | ||
| 62 | + | ||
| 63 | +**Acceptance Scenarios**: | ||
| 64 | + | ||
| 65 | +1. **Given** [initial state], **When** [action], **Then** [expected outcome] | ||
| 66 | + | ||
| 67 | +--- | ||
| 68 | + | ||
| 69 | +[Add more user stories as needed, each with an assigned priority] | ||
| 70 | + | ||
| 71 | +### Edge Cases | ||
| 72 | + | ||
| 73 | +<!-- | ||
| 74 | + ACTION REQUIRED: The content in this section represents placeholders. | ||
| 75 | + Fill them out with the right edge cases. | ||
| 76 | +--> | ||
| 77 | + | ||
| 78 | +- What happens when [boundary condition]? | ||
| 79 | +- How does system handle [error scenario]? | ||
| 80 | + | ||
| 81 | +## Requirements *(mandatory)* | ||
| 82 | + | ||
| 83 | +<!-- | ||
| 84 | + ACTION REQUIRED: The content in this section represents placeholders. | ||
| 85 | + Fill them out with the right functional requirements. | ||
| 86 | +--> | ||
| 87 | + | ||
| 88 | +### Functional Requirements | ||
| 89 | + | ||
| 90 | +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] | ||
| 91 | +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] | ||
| 92 | +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] | ||
| 93 | +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] | ||
| 94 | +- **FR-005**: System MUST [behavior, e.g., "log all security events"] | ||
| 95 | + | ||
| 96 | +*Example of marking unclear requirements:* | ||
| 97 | + | ||
| 98 | +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] | ||
| 99 | +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] | ||
| 100 | + | ||
| 101 | +### Key Entities *(include if feature involves data)* | ||
| 102 | + | ||
| 103 | +- **[Entity 1]**: [What it represents, key attributes without implementation] | ||
| 104 | +- **[Entity 2]**: [What it represents, relationships to other entities] | ||
| 105 | + | ||
| 106 | +## Success Criteria *(mandatory)* | ||
| 107 | + | ||
| 108 | +<!-- | ||
| 109 | + ACTION REQUIRED: Define measurable success criteria. | ||
| 110 | + These must be technology-agnostic and measurable. | ||
| 111 | +--> | ||
| 112 | + | ||
| 113 | +### Measurable Outcomes | ||
| 114 | + | ||
| 115 | +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] | ||
| 116 | +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] | ||
| 117 | +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] | ||
| 118 | +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] | ||
| 119 | + | ||
| 120 | +## Assumptions | ||
| 121 | + | ||
| 122 | +<!-- | ||
| 123 | + ACTION REQUIRED: The content in this section represents placeholders. | ||
| 124 | + Fill them out with the right assumptions based on reasonable defaults | ||
| 125 | + chosen when the feature description did not specify certain details. | ||
| 126 | +--> | ||
| 127 | + | ||
| 128 | +- [Assumption about target users, e.g., "Users have stable internet connectivity"] | ||
| 129 | +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] | ||
| 130 | +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] | ||
| 131 | +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] | ||
added
.specify/templates/tasks-template.md +252 -0 | new file mode 100644 | ||
| @@ -0,0 +1,252 @@ | ||
| 1 | +--- | |
| 2 | + | |
| 3 | +description: "Task list template for feature implementation" | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# Tasks: [FEATURE NAME] | |
| 7 | + | |
| 8 | +**Input**: Design documents from `/specs/[###-feature-name]/` | |
| 9 | + | |
| 10 | +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ | |
| 11 | + | |
| 12 | +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. | |
| 13 | + | |
| 14 | +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. | |
| 15 | + | |
| 16 | +## Format: `[ID] [P?] [Story] Description` | |
| 17 | + | |
| 18 | +- **[P]**: Can run in parallel (different files, no dependencies) | |
| 19 | +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) | |
| 20 | +- Include exact file paths in descriptions | |
| 21 | + | |
| 22 | +## Path Conventions | |
| 23 | + | |
| 24 | +- **Single project**: `src/`, `tests/` at repository root | |
| 25 | +- **Web app**: `backend/src/`, `frontend/src/` | |
| 26 | +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` | |
| 27 | +- Paths shown below assume single project - adjust based on plan.md structure | |
| 28 | + | |
| 29 | +<!-- | |
| 30 | + ============================================================================ | |
| 31 | + IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only. | |
| 32 | + | |
| 33 | + The /speckit-tasks command MUST replace these with actual tasks based on: | |
| 34 | + - User stories from spec.md (with their priorities P1, P2, P3...) | |
| 35 | + - Feature requirements from plan.md | |
| 36 | + - Entities from data-model.md | |
| 37 | + - Endpoints from contracts/ | |
| 38 | + | |
| 39 | + Tasks MUST be organized by user story so each story can be: | |
| 40 | + - Implemented independently | |
| 41 | + - Tested independently | |
| 42 | + - Delivered as an MVP increment | |
| 43 | + | |
| 44 | + DO NOT keep these sample tasks in the generated tasks.md file. | |
| 45 | + ============================================================================ | |
| 46 | +--> | |
| 47 | + | |
| 48 | +## Phase 1: Setup (Shared Infrastructure) | |
| 49 | + | |
| 50 | +**Purpose**: Project initialization and basic structure | |
| 51 | + | |
| 52 | +- [ ] T001 Create project structure per implementation plan | |
| 53 | +- [ ] T002 Initialize [language] project with [framework] dependencies | |
| 54 | +- [ ] T003 [P] Configure linting and formatting tools | |
| 55 | + | |
| 56 | +--- | |
| 57 | + | |
| 58 | +## Phase 2: Foundational (Blocking Prerequisites) | |
| 59 | + | |
| 60 | +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented | |
| 61 | + | |
| 62 | +**⚠️ CRITICAL**: No user story work can begin until this phase is complete | |
| 63 | + | |
| 64 | +Examples of foundational tasks (adjust based on your project): | |
| 65 | + | |
| 66 | +- [ ] T004 Setup database schema and migrations framework | |
| 67 | +- [ ] T005 [P] Implement authentication/authorization framework | |
| 68 | +- [ ] T006 [P] Setup API routing and middleware structure | |
| 69 | +- [ ] T007 Create base models/entities that all stories depend on | |
| 70 | +- [ ] T008 Configure error handling and logging infrastructure | |
| 71 | +- [ ] T009 Setup environment configuration management | |
| 72 | + | |
| 73 | +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel | |
| 74 | + | |
| 75 | +--- | |
| 76 | + | |
| 77 | +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP | |
| 78 | + | |
| 79 | +**Goal**: [Brief description of what this story delivers] | |
| 80 | + | |
| 81 | +**Independent Test**: [How to verify this story works on its own] | |
| 82 | + | |
| 83 | +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ | |
| 84 | + | |
| 85 | +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** | |
| 86 | + | |
| 87 | +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py | |
| 88 | +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py | |
| 89 | + | |
| 90 | +### Implementation for User Story 1 | |
| 91 | + | |
| 92 | +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py | |
| 93 | +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py | |
| 94 | +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) | |
| 95 | +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py | |
| 96 | +- [ ] T016 [US1] Add validation and error handling | |
| 97 | +- [ ] T017 [US1] Add logging for user story 1 operations | |
| 98 | + | |
| 99 | +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently | |
| 100 | + | |
| 101 | +--- | |
| 102 | + | |
| 103 | +## Phase 4: User Story 2 - [Title] (Priority: P2) | |
| 104 | + | |
| 105 | +**Goal**: [Brief description of what this story delivers] | |
| 106 | + | |
| 107 | +**Independent Test**: [How to verify this story works on its own] | |
| 108 | + | |
| 109 | +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ | |
| 110 | + | |
| 111 | +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py | |
| 112 | +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py | |
| 113 | + | |
| 114 | +### Implementation for User Story 2 | |
| 115 | + | |
| 116 | +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py | |
| 117 | +- [ ] T021 [US2] Implement [Service] in src/services/[service].py | |
| 118 | +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py | |
| 119 | +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) | |
| 120 | + | |
| 121 | +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently | |
| 122 | + | |
| 123 | +--- | |
| 124 | + | |
| 125 | +## Phase 5: User Story 3 - [Title] (Priority: P3) | |
| 126 | + | |
| 127 | +**Goal**: [Brief description of what this story delivers] | |
| 128 | + | |
| 129 | +**Independent Test**: [How to verify this story works on its own] | |
| 130 | + | |
| 131 | +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ | |
| 132 | + | |
| 133 | +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py | |
| 134 | +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py | |
| 135 | + | |
| 136 | +### Implementation for User Story 3 | |
| 137 | + | |
| 138 | +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py | |
| 139 | +- [ ] T027 [US3] Implement [Service] in src/services/[service].py | |
| 140 | +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py | |
| 141 | + | |
| 142 | +**Checkpoint**: All user stories should now be independently functional | |
| 143 | + | |
| 144 | +--- | |
| 145 | + | |
| 146 | +[Add more user story phases as needed, following the same pattern] | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## Phase N: Polish & Cross-Cutting Concerns | |
| 151 | + | |
| 152 | +**Purpose**: Improvements that affect multiple user stories | |
| 153 | + | |
| 154 | +- [ ] TXXX [P] Documentation updates in docs/ | |
| 155 | +- [ ] TXXX Code cleanup and refactoring | |
| 156 | +- [ ] TXXX Performance optimization across all stories | |
| 157 | +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ | |
| 158 | +- [ ] TXXX Security hardening | |
| 159 | +- [ ] TXXX Run quickstart.md validation | |
| 160 | + | |
| 161 | +--- | |
| 162 | + | |
| 163 | +## Dependencies & Execution Order | |
| 164 | + | |
| 165 | +### Phase Dependencies | |
| 166 | + | |
| 167 | +- **Setup (Phase 1)**: No dependencies - can start immediately | |
| 168 | +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories | |
| 169 | +- **User Stories (Phase 3+)**: All depend on Foundational phase completion | |
| 170 | + - User stories can then proceed in parallel (if staffed) | |
| 171 | + - Or sequentially in priority order (P1 → P2 → P3) | |
| 172 | +- **Polish (Final Phase)**: Depends on all desired user stories being complete | |
| 173 | + | |
| 174 | +### User Story Dependencies | |
| 175 | + | |
| 176 | +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories | |
| 177 | +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable | |
| 178 | +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable | |
| 179 | + | |
| 180 | +### Within Each User Story | |
| 181 | + | |
| 182 | +- Tests (if included) MUST be written and FAIL before implementation | |
| 183 | +- Models before services | |
| 184 | +- Services before endpoints | |
| 185 | +- Core implementation before integration | |
| 186 | +- Story complete before moving to next priority | |
| 187 | + | |
| 188 | +### Parallel Opportunities | |
| 189 | + | |
| 190 | +- All Setup tasks marked [P] can run in parallel | |
| 191 | +- All Foundational tasks marked [P] can run in parallel (within Phase 2) | |
| 192 | +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) | |
| 193 | +- All tests for a user story marked [P] can run in parallel | |
| 194 | +- Models within a story marked [P] can run in parallel | |
| 195 | +- Different user stories can be worked on in parallel by different team members | |
| 196 | + | |
| 197 | +--- | |
| 198 | + | |
| 199 | +## Parallel Example: User Story 1 | |
| 200 | + | |
| 201 | +```bash | |
| 202 | +# Launch all tests for User Story 1 together (if tests requested): | |
| 203 | +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" | |
| 204 | +Task: "Integration test for [user journey] in tests/integration/test_[name].py" | |
| 205 | + | |
| 206 | +# Launch all models for User Story 1 together: | |
| 207 | +Task: "Create [Entity1] model in src/models/[entity1].py" | |
| 208 | +Task: "Create [Entity2] model in src/models/[entity2].py" | |
| 209 | +``` | |
| 210 | + | |
| 211 | +--- | |
| 212 | + | |
| 213 | +## Implementation Strategy | |
| 214 | + | |
| 215 | +### MVP First (User Story 1 Only) | |
| 216 | + | |
| 217 | +1. Complete Phase 1: Setup | |
| 218 | +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) | |
| 219 | +3. Complete Phase 3: User Story 1 | |
| 220 | +4. **STOP and VALIDATE**: Test User Story 1 independently | |
| 221 | +5. Deploy/demo if ready | |
| 222 | + | |
| 223 | +### Incremental Delivery | |
| 224 | + | |
| 225 | +1. Complete Setup + Foundational → Foundation ready | |
| 226 | +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) | |
| 227 | +3. Add User Story 2 → Test independently → Deploy/Demo | |
| 228 | +4. Add User Story 3 → Test independently → Deploy/Demo | |
| 229 | +5. Each story adds value without breaking previous stories | |
| 230 | + | |
| 231 | +### Parallel Team Strategy | |
| 232 | + | |
| 233 | +With multiple developers: | |
| 234 | + | |
| 235 | +1. Team completes Setup + Foundational together | |
| 236 | +2. Once Foundational is done: | |
| 237 | + - Developer A: User Story 1 | |
| 238 | + - Developer B: User Story 2 | |
| 239 | + - Developer C: User Story 3 | |
| 240 | +3. Stories complete and integrate independently | |
| 241 | + | |
| 242 | +--- | |
| 243 | + | |
| 244 | +## Notes | |
| 245 | + | |
| 246 | +- [P] tasks = different files, no dependencies | |
| 247 | +- [Story] label maps task to specific user story for traceability | |
| 248 | +- Each user story should be independently completable and testable | |
| 249 | +- Verify tests fail before implementing | |
| 250 | +- Commit after each task or logical group | |
| 251 | +- Stop at any checkpoint to validate story independently | |
| 252 | +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence | |
| new file mode 100644 | |||
| @@ -0,0 +1,252 @@ | |||
| 1 | +--- | ||
| 2 | + | ||
| 3 | +description: "Task list template for feature implementation" | ||
| 4 | +--- | ||
| 5 | + | ||
| 6 | +# Tasks: [FEATURE NAME] | ||
| 7 | + | ||
| 8 | +**Input**: Design documents from `/specs/[###-feature-name]/` | ||
| 9 | + | ||
| 10 | +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ | ||
| 11 | + | ||
| 12 | +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. | ||
| 13 | + | ||
| 14 | +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. | ||
| 15 | + | ||
| 16 | +## Format: `[ID] [P?] [Story] Description` | ||
| 17 | + | ||
| 18 | +- **[P]**: Can run in parallel (different files, no dependencies) | ||
| 19 | +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) | ||
| 20 | +- Include exact file paths in descriptions | ||
| 21 | + | ||
| 22 | +## Path Conventions | ||
| 23 | + | ||
| 24 | +- **Single project**: `src/`, `tests/` at repository root | ||
| 25 | +- **Web app**: `backend/src/`, `frontend/src/` | ||
| 26 | +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` | ||
| 27 | +- Paths shown below assume single project - adjust based on plan.md structure | ||
| 28 | + | ||
| 29 | +<!-- | ||
| 30 | + ============================================================================ | ||
| 31 | + IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only. | ||
| 32 | + | ||
| 33 | + The /speckit-tasks command MUST replace these with actual tasks based on: | ||
| 34 | + - User stories from spec.md (with their priorities P1, P2, P3...) | ||
| 35 | + - Feature requirements from plan.md | ||
| 36 | + - Entities from data-model.md | ||
| 37 | + - Endpoints from contracts/ | ||
| 38 | + | ||
| 39 | + Tasks MUST be organized by user story so each story can be: | ||
| 40 | + - Implemented independently | ||
| 41 | + - Tested independently | ||
| 42 | + - Delivered as an MVP increment | ||
| 43 | + | ||
| 44 | + DO NOT keep these sample tasks in the generated tasks.md file. | ||
| 45 | + ============================================================================ | ||
| 46 | +--> | ||
| 47 | + | ||
| 48 | +## Phase 1: Setup (Shared Infrastructure) | ||
| 49 | + | ||
| 50 | +**Purpose**: Project initialization and basic structure | ||
| 51 | + | ||
| 52 | +- [ ] T001 Create project structure per implementation plan | ||
| 53 | +- [ ] T002 Initialize [language] project with [framework] dependencies | ||
| 54 | +- [ ] T003 [P] Configure linting and formatting tools | ||
| 55 | + | ||
| 56 | +--- | ||
| 57 | + | ||
| 58 | +## Phase 2: Foundational (Blocking Prerequisites) | ||
| 59 | + | ||
| 60 | +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented | ||
| 61 | + | ||
| 62 | +**⚠️ CRITICAL**: No user story work can begin until this phase is complete | ||
| 63 | + | ||
| 64 | +Examples of foundational tasks (adjust based on your project): | ||
| 65 | + | ||
| 66 | +- [ ] T004 Setup database schema and migrations framework | ||
| 67 | +- [ ] T005 [P] Implement authentication/authorization framework | ||
| 68 | +- [ ] T006 [P] Setup API routing and middleware structure | ||
| 69 | +- [ ] T007 Create base models/entities that all stories depend on | ||
| 70 | +- [ ] T008 Configure error handling and logging infrastructure | ||
| 71 | +- [ ] T009 Setup environment configuration management | ||
| 72 | + | ||
| 73 | +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel | ||
| 74 | + | ||
| 75 | +--- | ||
| 76 | + | ||
| 77 | +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP | ||
| 78 | + | ||
| 79 | +**Goal**: [Brief description of what this story delivers] | ||
| 80 | + | ||
| 81 | +**Independent Test**: [How to verify this story works on its own] | ||
| 82 | + | ||
| 83 | +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ | ||
| 84 | + | ||
| 85 | +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** | ||
| 86 | + | ||
| 87 | +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py | ||
| 88 | +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py | ||
| 89 | + | ||
| 90 | +### Implementation for User Story 1 | ||
| 91 | + | ||
| 92 | +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py | ||
| 93 | +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py | ||
| 94 | +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) | ||
| 95 | +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py | ||
| 96 | +- [ ] T016 [US1] Add validation and error handling | ||
| 97 | +- [ ] T017 [US1] Add logging for user story 1 operations | ||
| 98 | + | ||
| 99 | +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently | ||
| 100 | + | ||
| 101 | +--- | ||
| 102 | + | ||
| 103 | +## Phase 4: User Story 2 - [Title] (Priority: P2) | ||
| 104 | + | ||
| 105 | +**Goal**: [Brief description of what this story delivers] | ||
| 106 | + | ||
| 107 | +**Independent Test**: [How to verify this story works on its own] | ||
| 108 | + | ||
| 109 | +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ | ||
| 110 | + | ||
| 111 | +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py | ||
| 112 | +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py | ||
| 113 | + | ||
| 114 | +### Implementation for User Story 2 | ||
| 115 | + | ||
| 116 | +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py | ||
| 117 | +- [ ] T021 [US2] Implement [Service] in src/services/[service].py | ||
| 118 | +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py | ||
| 119 | +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) | ||
| 120 | + | ||
| 121 | +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently | ||
| 122 | + | ||
| 123 | +--- | ||
| 124 | + | ||
| 125 | +## Phase 5: User Story 3 - [Title] (Priority: P3) | ||
| 126 | + | ||
| 127 | +**Goal**: [Brief description of what this story delivers] | ||
| 128 | + | ||
| 129 | +**Independent Test**: [How to verify this story works on its own] | ||
| 130 | + | ||
| 131 | +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ | ||
| 132 | + | ||
| 133 | +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py | ||
| 134 | +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py | ||
| 135 | + | ||
| 136 | +### Implementation for User Story 3 | ||
| 137 | + | ||
| 138 | +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py | ||
| 139 | +- [ ] T027 [US3] Implement [Service] in src/services/[service].py | ||
| 140 | +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py | ||
| 141 | + | ||
| 142 | +**Checkpoint**: All user stories should now be independently functional | ||
| 143 | + | ||
| 144 | +--- | ||
| 145 | + | ||
| 146 | +[Add more user story phases as needed, following the same pattern] | ||
| 147 | + | ||
| 148 | +--- | ||
| 149 | + | ||
| 150 | +## Phase N: Polish & Cross-Cutting Concerns | ||
| 151 | + | ||
| 152 | +**Purpose**: Improvements that affect multiple user stories | ||
| 153 | + | ||
| 154 | +- [ ] TXXX [P] Documentation updates in docs/ | ||
| 155 | +- [ ] TXXX Code cleanup and refactoring | ||
| 156 | +- [ ] TXXX Performance optimization across all stories | ||
| 157 | +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ | ||
| 158 | +- [ ] TXXX Security hardening | ||
| 159 | +- [ ] TXXX Run quickstart.md validation | ||
| 160 | + | ||
| 161 | +--- | ||
| 162 | + | ||
| 163 | +## Dependencies & Execution Order | ||
| 164 | + | ||
| 165 | +### Phase Dependencies | ||
| 166 | + | ||
| 167 | +- **Setup (Phase 1)**: No dependencies - can start immediately | ||
| 168 | +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories | ||
| 169 | +- **User Stories (Phase 3+)**: All depend on Foundational phase completion | ||
| 170 | + - User stories can then proceed in parallel (if staffed) | ||
| 171 | + - Or sequentially in priority order (P1 → P2 → P3) | ||
| 172 | +- **Polish (Final Phase)**: Depends on all desired user stories being complete | ||
| 173 | + | ||
| 174 | +### User Story Dependencies | ||
| 175 | + | ||
| 176 | +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories | ||
| 177 | +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable | ||
| 178 | +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable | ||
| 179 | + | ||
| 180 | +### Within Each User Story | ||
| 181 | + | ||
| 182 | +- Tests (if included) MUST be written and FAIL before implementation | ||
| 183 | +- Models before services | ||
| 184 | +- Services before endpoints | ||
| 185 | +- Core implementation before integration | ||
| 186 | +- Story complete before moving to next priority | ||
| 187 | + | ||
| 188 | +### Parallel Opportunities | ||
| 189 | + | ||
| 190 | +- All Setup tasks marked [P] can run in parallel | ||
| 191 | +- All Foundational tasks marked [P] can run in parallel (within Phase 2) | ||
| 192 | +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) | ||
| 193 | +- All tests for a user story marked [P] can run in parallel | ||
| 194 | +- Models within a story marked [P] can run in parallel | ||
| 195 | +- Different user stories can be worked on in parallel by different team members | ||
| 196 | + | ||
| 197 | +--- | ||
| 198 | + | ||
| 199 | +## Parallel Example: User Story 1 | ||
| 200 | + | ||
| 201 | +```bash | ||
| 202 | +# Launch all tests for User Story 1 together (if tests requested): | ||
| 203 | +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" | ||
| 204 | +Task: "Integration test for [user journey] in tests/integration/test_[name].py" | ||
| 205 | + | ||
| 206 | +# Launch all models for User Story 1 together: | ||
| 207 | +Task: "Create [Entity1] model in src/models/[entity1].py" | ||
| 208 | +Task: "Create [Entity2] model in src/models/[entity2].py" | ||
| 209 | +``` | ||
| 210 | + | ||
| 211 | +--- | ||
| 212 | + | ||
| 213 | +## Implementation Strategy | ||
| 214 | + | ||
| 215 | +### MVP First (User Story 1 Only) | ||
| 216 | + | ||
| 217 | +1. Complete Phase 1: Setup | ||
| 218 | +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) | ||
| 219 | +3. Complete Phase 3: User Story 1 | ||
| 220 | +4. **STOP and VALIDATE**: Test User Story 1 independently | ||
| 221 | +5. Deploy/demo if ready | ||
| 222 | + | ||
| 223 | +### Incremental Delivery | ||
| 224 | + | ||
| 225 | +1. Complete Setup + Foundational → Foundation ready | ||
| 226 | +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) | ||
| 227 | +3. Add User Story 2 → Test independently → Deploy/Demo | ||
| 228 | +4. Add User Story 3 → Test independently → Deploy/Demo | ||
| 229 | +5. Each story adds value without breaking previous stories | ||
| 230 | + | ||
| 231 | +### Parallel Team Strategy | ||
| 232 | + | ||
| 233 | +With multiple developers: | ||
| 234 | + | ||
| 235 | +1. Team completes Setup + Foundational together | ||
| 236 | +2. Once Foundational is done: | ||
| 237 | + - Developer A: User Story 1 | ||
| 238 | + - Developer B: User Story 2 | ||
| 239 | + - Developer C: User Story 3 | ||
| 240 | +3. Stories complete and integrate independently | ||
| 241 | + | ||
| 242 | +--- | ||
| 243 | + | ||
| 244 | +## Notes | ||
| 245 | + | ||
| 246 | +- [P] tasks = different files, no dependencies | ||
| 247 | +- [Story] label maps task to specific user story for traceability | ||
| 248 | +- Each user story should be independently completable and testable | ||
| 249 | +- Verify tests fail before implementing | ||
| 250 | +- Commit after each task or logical group | ||
| 251 | +- Stop at any checkpoint to validate story independently | ||
| 252 | +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence | ||
added
.specify/workflows/speckit/workflow.yml +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +schema_version: "1.0" | |
| 2 | +workflow: | |
| 3 | + id: "speckit" | |
| 4 | + name: "Full SDD Cycle" | |
| 5 | + version: "1.0.0" | |
| 6 | + author: "GitHub" | |
| 7 | + description: "Runs specify → plan → tasks → implement with review gates" | |
| 8 | + | |
| 9 | +requires: | |
| 10 | + # 0.8.5 is the first release with engine-side resolution of the | |
| 11 | + # ``integration: "auto"`` default. Older versions would treat "auto" | |
| 12 | + # as a literal integration key and fail at dispatch. | |
| 13 | + speckit_version: ">=0.8.5" | |
| 14 | + integrations: | |
| 15 | + # The four commands below (specify, plan, tasks, implement) are core | |
| 16 | + # spec-kit commands provided by every integration. The list here is an | |
| 17 | + # advisory, non-exhaustive compatibility hint following the documented | |
| 18 | + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs | |
| 19 | + # against any integration the project was initialized with, including | |
| 20 | + # ones not listed below, as long as that integration provides the four | |
| 21 | + # core commands referenced in ``steps``. | |
| 22 | + any: | |
| 23 | + - "alquimia" | |
| 24 | + - "claude" | |
| 25 | + - "copilot" | |
| 26 | + - "gemini" | |
| 27 | + - "opencode" | |
| 28 | + | |
| 29 | +inputs: | |
| 30 | + spec: | |
| 31 | + type: string | |
| 32 | + required: true | |
| 33 | + prompt: "Describe what you want to build" | |
| 34 | + integration: | |
| 35 | + type: string | |
| 36 | + default: "auto" | |
| 37 | + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" | |
| 38 | + scope: | |
| 39 | + type: string | |
| 40 | + default: "full" | |
| 41 | + enum: ["full", "backend-only", "frontend-only"] | |
| 42 | + | |
| 43 | +steps: | |
| 44 | + - id: specify | |
| 45 | + command: speckit.specify | |
| 46 | + integration: "{{ inputs.integration }}" | |
| 47 | + input: | |
| 48 | + args: "{{ inputs.spec }}" | |
| 49 | + | |
| 50 | + - id: review-spec | |
| 51 | + type: gate | |
| 52 | + message: "Review the generated spec before planning." | |
| 53 | + options: [approve, reject] | |
| 54 | + on_reject: abort | |
| 55 | + | |
| 56 | + - id: plan | |
| 57 | + command: speckit.plan | |
| 58 | + integration: "{{ inputs.integration }}" | |
| 59 | + input: | |
| 60 | + args: "{{ inputs.spec }}" | |
| 61 | + | |
| 62 | + - id: review-plan | |
| 63 | + type: gate | |
| 64 | + message: "Review the plan before generating tasks." | |
| 65 | + options: [approve, reject] | |
| 66 | + on_reject: abort | |
| 67 | + | |
| 68 | + - id: tasks | |
| 69 | + command: speckit.tasks | |
| 70 | + integration: "{{ inputs.integration }}" | |
| 71 | + input: | |
| 72 | + args: "{{ inputs.spec }}" | |
| 73 | + | |
| 74 | + - id: implement | |
| 75 | + command: speckit.implement | |
| 76 | + integration: "{{ inputs.integration }}" | |
| 77 | + input: | |
| 78 | + args: "{{ inputs.spec }}" | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +schema_version: "1.0" | ||
| 2 | +workflow: | ||
| 3 | + id: "speckit" | ||
| 4 | + name: "Full SDD Cycle" | ||
| 5 | + version: "1.0.0" | ||
| 6 | + author: "GitHub" | ||
| 7 | + description: "Runs specify → plan → tasks → implement with review gates" | ||
| 8 | + | ||
| 9 | +requires: | ||
| 10 | + # 0.8.5 is the first release with engine-side resolution of the | ||
| 11 | + # ``integration: "auto"`` default. Older versions would treat "auto" | ||
| 12 | + # as a literal integration key and fail at dispatch. | ||
| 13 | + speckit_version: ">=0.8.5" | ||
| 14 | + integrations: | ||
| 15 | + # The four commands below (specify, plan, tasks, implement) are core | ||
| 16 | + # spec-kit commands provided by every integration. The list here is an | ||
| 17 | + # advisory, non-exhaustive compatibility hint following the documented | ||
| 18 | + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs | ||
| 19 | + # against any integration the project was initialized with, including | ||
| 20 | + # ones not listed below, as long as that integration provides the four | ||
| 21 | + # core commands referenced in ``steps``. | ||
| 22 | + any: | ||
| 23 | + - "alquimia" | ||
| 24 | + - "claude" | ||
| 25 | + - "copilot" | ||
| 26 | + - "gemini" | ||
| 27 | + - "opencode" | ||
| 28 | + | ||
| 29 | +inputs: | ||
| 30 | + spec: | ||
| 31 | + type: string | ||
| 32 | + required: true | ||
| 33 | + prompt: "Describe what you want to build" | ||
| 34 | + integration: | ||
| 35 | + type: string | ||
| 36 | + default: "auto" | ||
| 37 | + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" | ||
| 38 | + scope: | ||
| 39 | + type: string | ||
| 40 | + default: "full" | ||
| 41 | + enum: ["full", "backend-only", "frontend-only"] | ||
| 42 | + | ||
| 43 | +steps: | ||
| 44 | + - id: specify | ||
| 45 | + command: speckit.specify | ||
| 46 | + integration: "{{ inputs.integration }}" | ||
| 47 | + input: | ||
| 48 | + args: "{{ inputs.spec }}" | ||
| 49 | + | ||
| 50 | + - id: review-spec | ||
| 51 | + type: gate | ||
| 52 | + message: "Review the generated spec before planning." | ||
| 53 | + options: [approve, reject] | ||
| 54 | + on_reject: abort | ||
| 55 | + | ||
| 56 | + - id: plan | ||
| 57 | + command: speckit.plan | ||
| 58 | + integration: "{{ inputs.integration }}" | ||
| 59 | + input: | ||
| 60 | + args: "{{ inputs.spec }}" | ||
| 61 | + | ||
| 62 | + - id: review-plan | ||
| 63 | + type: gate | ||
| 64 | + message: "Review the plan before generating tasks." | ||
| 65 | + options: [approve, reject] | ||
| 66 | + on_reject: abort | ||
| 67 | + | ||
| 68 | + - id: tasks | ||
| 69 | + command: speckit.tasks | ||
| 70 | + integration: "{{ inputs.integration }}" | ||
| 71 | + input: | ||
| 72 | + args: "{{ inputs.spec }}" | ||
| 73 | + | ||
| 74 | + - id: implement | ||
| 75 | + command: speckit.implement | ||
| 76 | + integration: "{{ inputs.integration }}" | ||
| 77 | + input: | ||
| 78 | + args: "{{ inputs.spec }}" | ||
added
.specify/workflows/workflow-registry.json +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +{ | |
| 2 | + "schema_version": "1.0", | |
| 3 | + "workflows": { | |
| 4 | + "speckit": { | |
| 5 | + "name": "Full SDD Cycle", | |
| 6 | + "version": "1.0.0", | |
| 7 | + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", | |
| 8 | + "source": "bundled", | |
| 9 | + "installed_at": "2026-09-07T14:46:33.281392+00:00", | |
| 10 | + "updated_at": "2026-09-07T14:46:33.281399+00:00" | |
| 11 | + } | |
| 12 | + } | |
| 13 | +} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +{ | ||
| 2 | + "schema_version": "1.0", | ||
| 3 | + "workflows": { | ||
| 4 | + "speckit": { | ||
| 5 | + "name": "Full SDD Cycle", | ||
| 6 | + "version": "1.0.0", | ||
| 7 | + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", | ||
| 8 | + "source": "bundled", | ||
| 9 | + "installed_at": "2026-09-07T14:46:33.281392+00:00", | ||
| 10 | + "updated_at": "2026-09-07T14:46:33.281399+00:00" | ||
| 11 | + } | ||
| 12 | + } | ||
| 13 | +} | ||
| \ No newline at end of file | \ No newline at end of file | ||