Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33de435c91 | ||
|
|
a51b18124b | ||
|
|
a37539794f | ||
|
|
e7e6e853d5 | ||
|
|
76bb6f326b | ||
|
|
f1dc7494d5 | ||
|
|
b20bcc7aff | ||
|
|
95b5a1e03d | ||
|
|
326859e27b | ||
|
|
273e73be07 | ||
|
|
a27749a37a | ||
|
|
3d9ed93f4c | ||
|
|
84d26806cf | ||
|
|
0a9e99518d | ||
|
|
ce1dcfa7b1 |
@@ -0,0 +1,262 @@
|
||||
---
|
||||
name: "speckit-analyze"
|
||||
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
|
||||
argument-hint: "Optional focus areas for analysis"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/analyze.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
name: "speckit-checklist"
|
||||
description: "Generate a custom checklist for the current feature based on user requirements."
|
||||
argument-hint: "Domain or focus area for the checklist"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/checklist.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
**Ownership and checkbox lifecycle**:
|
||||
|
||||
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
|
||||
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
|
||||
- `[x]` does NOT mean implementation work is complete.
|
||||
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
|
||||
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
|
||||
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; do not treat that exception as applying to custom checklists generated here.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `/speckit-implement` reads checklist state but does not modify markers.
|
||||
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: "speckit-clarify"
|
||||
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
|
||||
argument-hint: "Optional areas to clarify in the spec"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/clarify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- **Question writing quality (applies to every question, MC or short-answer):**
|
||||
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
|
||||
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
|
||||
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
|
||||
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
|
||||
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
1. Read the checklist file.
|
||||
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
|
||||
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
|
||||
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
|
||||
5. For each checkbox item, update only if the checked/unchecked state actually changes:
|
||||
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
|
||||
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
|
||||
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
|
||||
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
|
||||
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
|
||||
- **Newly passing**: items that changed from unchecked to checked.
|
||||
- **Regressions**: items that changed from checked to unchecked.
|
||||
- **Still unchecked**: items that remain unchecked.
|
||||
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Spec ambiguities identified and clarifications integrated into spec file
|
||||
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: "speckit-constitution"
|
||||
description: "Create or update the project constitution from interactive or provided principle inputs."
|
||||
argument-hint: "Principles or values for the project constitution"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/constitution.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to updating the project constitution itself. Dependent templates
|
||||
and commands read the constitution at runtime and are not modified here.
|
||||
|
||||
- Classify every part of the user input as either constitution content or a separate,
|
||||
non-governance intent.
|
||||
- If the input includes feature implementation, code generation, refactoring, building, or
|
||||
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
|
||||
- You **MUST NOT** create, modify, or delete application source files, feature routes,
|
||||
components, tests, deployment files, or other artifacts unrelated to the constitution
|
||||
workflow.
|
||||
- If it is unclear whether an instruction is constitution content, ask for clarification before
|
||||
making changes.
|
||||
- After completing the constitution update, include a `Next Actions` section for each deferred
|
||||
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
|
||||
as `/speckit-specify`, without invoking it.
|
||||
- If there are no non-governance intents, omit the `Next Actions` section.
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. The active
|
||||
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
|
||||
preset/template resolution stack.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Run `.specify/scripts/powershell/resolve-template.ps1 constitution-template -Json` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
|
||||
- The shared resolver applies project overrides, composing preset layers, and extension layers
|
||||
before the core template fallback. It MUST succeed before continuing.
|
||||
- If it fails, stop and report the resolution error; do not continue with only one contributing
|
||||
template layer.
|
||||
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
|
||||
values and amendments. Preserve information that is still applicable when applying the newly
|
||||
resolved scaffold.
|
||||
- If it does not exist, use the resolved template as the initial document.
|
||||
- Do not write back to any versioned template layer.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content using the resolved template as the required structure:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
5. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
7. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any TODO placeholders or deferred items requiring manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
- A `Next Actions` section for any deferred non-governance intents.
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after constitution update)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: "speckit-converge"
|
||||
description: "Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it."
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/converge.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before convergence)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Close the gap between what a feature's specification, plan, and tasks call for and what the
|
||||
codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole
|
||||
source of intent** (with the constitution as governing constraints), assess the current
|
||||
state of the code, determine which requirements, acceptance criteria, plan decisions, and
|
||||
existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece
|
||||
of remaining work as a new, traceable task** at the bottom of `tasks.md` so that
|
||||
`/speckit-implement` can complete it. This command MUST run only after
|
||||
`/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`.
|
||||
|
||||
This is **not** a diff tool and does **not** track changes. It assesses the present state
|
||||
of the code relative to the feature's artifacts — no git, no branch comparison, no history.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new
|
||||
`## Phase N: Convergence` section to `tasks.md`. It MUST NOT:
|
||||
|
||||
- modify `spec.md` or `plan.md` in any way;
|
||||
- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior
|
||||
Convergence phase);
|
||||
- modify, create, or delete any application code — completing the appended tasks is the
|
||||
job of `/speckit-implement`.
|
||||
|
||||
When the codebase already satisfies everything, the command MUST leave `tasks.md`
|
||||
**byte-for-byte unchanged** (no empty Convergence header) and report a clean result.
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is
|
||||
**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and
|
||||
produces a corresponding remediation task. If the constitution is an unfilled template,
|
||||
skip constitution checks gracefully rather than failing.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Convergence Context
|
||||
|
||||
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
- CONSTITUTION = `.specify/memory/constitution.md` (if present)
|
||||
If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the
|
||||
prerequisite command to run (`/speckit-specify` for a missing spec, `/speckit-plan` for a missing plan,
|
||||
`/speckit-tasks` for missing tasks). Do not produce partial output.
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Functional Requirements (FR-###)
|
||||
- Success Criteria (SC-###) — include only items requiring buildable work; exclude
|
||||
post-launch outcome metrics and business KPIs
|
||||
- User Stories and their Acceptance Scenarios
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices and technical decisions
|
||||
- Data Model references
|
||||
- Phases and named touch-points (files/components the plan says will be created or edited)
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs (to compute the next ID and next phase number)
|
||||
- Descriptions, phase grouping, and referenced file paths
|
||||
|
||||
**From constitution (if not an unfilled template):**
|
||||
|
||||
- Principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 3. Build the Intent Inventory
|
||||
|
||||
Create an internal model (do not echo raw artifacts):
|
||||
|
||||
- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance
|
||||
scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that
|
||||
impose buildable obligations.
|
||||
- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword
|
||||
search for the concepts each requirement describes, derive the set of source files and
|
||||
components in scope for assessment. Bound the assessment to these — do **not** infer
|
||||
scope beyond what the artifacts define.
|
||||
|
||||
### 4. Assess the Codebase and Classify Findings
|
||||
|
||||
For each item in the intent inventory, inspect the current code in scope and produce a
|
||||
`Finding` only where there is a gap. Classify every finding by **gap type**:
|
||||
|
||||
- **`missing`**: the required work is absent from the code entirely.
|
||||
- **`partial`**: the work exists but does not yet fully satisfy the requirement /
|
||||
acceptance criterion / plan decision.
|
||||
- **`contradicts`**: the code does something that conflicts with stated intent or a
|
||||
constitution MUST principle.
|
||||
- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks
|
||||
(surfaced for awareness — converge does **not** delete code, it only appends a task to
|
||||
review/justify or remove it).
|
||||
|
||||
Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a
|
||||
severity, and a short human-readable description with the evidence (the file/area observed).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- **Little or no code yet**: treat the entire specified scope as `missing` remaining work
|
||||
rather than failing.
|
||||
- **Nothing remains**: produce zero findings and follow the converged branch in Step 7.
|
||||
|
||||
### 5. Assign Severity
|
||||
|
||||
- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap
|
||||
that blocks baseline functionality of a P1 user story.
|
||||
- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance
|
||||
criterion.
|
||||
- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with
|
||||
unclear justification.
|
||||
- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions.
|
||||
|
||||
### 6. Present the In-Session Findings Summary
|
||||
|
||||
Before appending anything, output a compact, severity-graded summary (no file writes yet):
|
||||
|
||||
## Convergence Findings
|
||||
|
||||
| ID | Gap Type | Severity | Source | Evidence | Remaining Work |
|
||||
|----|----------|----------|--------|----------|----------------|
|
||||
| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement |
|
||||
|
||||
**Summary metrics:**
|
||||
|
||||
- Requirements / acceptance criteria checked
|
||||
- Plan decisions checked
|
||||
- Constitution principles checked (or "skipped — template")
|
||||
- Findings by gap type (missing / partial / contradicts / unrequested)
|
||||
- Findings by severity
|
||||
|
||||
### 7. Append Convergence Tasks (or report converged)
|
||||
|
||||
**If there are one or more actionable findings** (`tasks_appended` outcome):
|
||||
|
||||
Append to the **end** of `tasks.md`, per the append contract:
|
||||
|
||||
1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
|
||||
(highest existing phase + 1).
|
||||
2. Write a single new section header `## Phase N: Convergence`.
|
||||
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
|
||||
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
|
||||
|
||||
```markdown
|
||||
- [ ] T042 <imperative description> per <source-ref> (<gap-type>)
|
||||
```
|
||||
|
||||
`<source-ref>` traces the task to its origin: e.g. `FR-003`, `SC-002`,
|
||||
`US1/AC2`, `plan: storage decision`, `Constitution II`.
|
||||
|
||||
`<gap-type>` is one of `missing`, `partial`, `contradicts`, `unrequested`.
|
||||
|
||||
Constitution-violation tasks MUST be emitted first and described as
|
||||
`CRITICAL`.
|
||||
4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new,
|
||||
separately-numbered one below it — do not touch the old one.
|
||||
|
||||
**If there are no actionable findings** (`converged` outcome):
|
||||
|
||||
- Do **not** modify `tasks.md` at all — no empty phase header.
|
||||
- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."**
|
||||
- Include the summary counts of what was checked.
|
||||
|
||||
### 8. Provide Next Actions (Handoff)
|
||||
|
||||
- On `tasks_appended`: state how many tasks were appended under which phase, and recommend
|
||||
running `/speckit-implement` to complete them; note that a follow-up converge
|
||||
run will find fewer or no remaining items.
|
||||
- On `converged`: recommend proceeding to review / opening a PR. No further implement pass
|
||||
is needed for this feature's specified scope.
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After producing the result, check if `.specify/extensions.yml` exists in the project root.
|
||||
|
||||
- If it exists, read it and look for entries under the `hooks.after_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing
|
||||
any hooks, so users can decide whether to run optional follow-up commands.
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: "speckit-implement"
|
||||
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
|
||||
argument-hint: "Optional implementation guidance or task filter"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/implement.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
|
||||
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; custom checklists generated by `/speckit-checklist` are reviewer-owned requirements-quality review artifacts
|
||||
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Checked items: Lines matching `- [X]` or `- [x]`
|
||||
- Unchecked items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Checked | Unchecked | Status |
|
||||
|-----------|-------|---------|-----------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 unchecked items
|
||||
- **FAIL**: One or more checklists have unchecked items
|
||||
|
||||
- **If any checklist has unchecked items**:
|
||||
- Display the table with unchecked item counts
|
||||
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are checked**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report final status with summary of completed work.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] All tasks in tasks.md completed and marked `[X]`
|
||||
- [ ] Implementation validated against specification, plan, and test coverage
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with summary of completed work
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: "speckit-plan"
|
||||
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
|
||||
argument-hint: "Optional guidance for the planning phase"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/plan.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Create quickstart validation guide** → `quickstart.md`:
|
||||
- Document runnable validation scenarios that prove the feature works end-to-end
|
||||
- Include prerequisites, setup commands, test/run commands, and expected outcomes
|
||||
- Use links or references to contracts and data model details instead of duplicating them
|
||||
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
|
||||
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Plan workflow executed and design artifacts generated
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with branch, plan path, and generated artifacts
|
||||
@@ -0,0 +1,348 @@
|
||||
---
|
||||
name: "speckit-specify"
|
||||
description: "Create or update the feature specification from a natural language feature description."
|
||||
argument-hint: "Describe the feature you want to specify"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/specify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
|
||||
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit-specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
4. Load the resolved active `spec-template` file to understand required sections.
|
||||
|
||||
5. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
name: "speckit-tasks"
|
||||
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
|
||||
argument-hint: "Optional task generation constraints"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/tasks.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/setup-tasks.ps1 -Json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] tasks.md generated with all phases, task IDs, and file paths
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: "speckit-taskstoissues"
|
||||
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
|
||||
argument-hint: "Optional filter or label for GitHub issues"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/taskstoissues.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
|
||||
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
|
||||
- Only create issues for tasks that do not yet have a matching issue.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
+3
-3
@@ -4,10 +4,10 @@
|
||||
|
||||
NEXT_PUBLIC_APP_NAME="SupportHub Web (Dev)"
|
||||
NEXT_PUBLIC_APP_ENV="development"
|
||||
NEXT_PUBLIC_APP_URL="https://support-dev.maskantech.in"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
NEXT_PUBLIC_API_URL="https://supportdev-api.maskantech.in/api/v1"
|
||||
NEXT_PUBLIC_WS_URL="wss://supportdev-api.maskantech.in/ws"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:4501"
|
||||
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
|
||||
|
||||
NEXT_PUBLIC_SAAS_PLATFORM_NAME="SaaS Parent Platform (Dev)"
|
||||
NEXT_PUBLIC_SAAS_AUTH_HEADER="X-SaaS-User-Token"
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ NEXT_PUBLIC_APP_ENV="development"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# SupportHub Backend API Integration (supporthub-api)
|
||||
NEXT_PUBLIC_API_URL="http://localhost:4501/api/v1"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:4501"
|
||||
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
|
||||
|
||||
# SaaS Parent Platform Integration
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ NEXT_PUBLIC_APP_ENV="development"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# SupportHub Backend API Integration (supporthub-api)
|
||||
NEXT_PUBLIC_API_URL="http://localhost:4501/api/v1"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:4501"
|
||||
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
|
||||
|
||||
# SaaS Parent Platform Integration
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -26,6 +26,11 @@ yarn-error.log*
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
# TypeScript & Next build cache
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Machine-local Spec Kit state — not meant to be shared.
|
||||
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
|
||||
|
||||
# Local pointer to the current feature directory. Rewritten every time you
|
||||
# switch features, so it is per-checkout state rather than something to share.
|
||||
feature.json
|
||||
|
||||
# Per-machine extension config overrides.
|
||||
extensions/*/local-config.yml
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ai": "claude",
|
||||
"ai_skills": true,
|
||||
"feature_numbering": "sequential",
|
||||
"here": true,
|
||||
"integration": "claude",
|
||||
"script": "ps",
|
||||
"speckit_version": "0.16.4"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "0.16.4",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [
|
||||
"claude"
|
||||
],
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"script": "ps",
|
||||
"invoke_separator": "-"
|
||||
}
|
||||
},
|
||||
"integration": "claude",
|
||||
"default_integration": "claude"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"integration": "claude",
|
||||
"version": "0.16.4",
|
||||
"installed_at": "2026-09-07T05:26:23.990742+00:00",
|
||||
"files": {
|
||||
".claude/skills/speckit-analyze/SKILL.md": "5d0565394ce8a573476718e546df3561357fd89061e9608c26fe97176e3660f4",
|
||||
".claude/skills/speckit-clarify/SKILL.md": "122da9a8c710df930fbe8219c3feb33ffd610f9659b83574e1dffb98bf5e1bd4",
|
||||
".claude/skills/speckit-constitution/SKILL.md": "78ed5639ada6bafffba4d7def4e3fbf36eb4412edb5fe45664fcea52726eb37a",
|
||||
".claude/skills/speckit-implement/SKILL.md": "00a8aeb8aa4038ad7ccdee7b21e15dd473f1aa100d022ae1d04f0939c643bc96",
|
||||
".claude/skills/speckit-converge/SKILL.md": "ca224eb399ff835884787dc87aaf862930f54bc44f8b1ad9dcc9eb67962a9e1d",
|
||||
".claude/skills/speckit-plan/SKILL.md": "99ee3d64df52b575933123a3491d43c8820d02914e54f98a2ef09ff456257e03",
|
||||
".claude/skills/speckit-checklist/SKILL.md": "7c38cd20eae8841226e053a46b6be7e30550a83520d865075c38168bfcef6412",
|
||||
".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886",
|
||||
".claude/skills/speckit-tasks/SKILL.md": "2d409fd3edb0bb0b97913168b3f2fd9bbfb327bff31a8bf1ed1a737a446889ca",
|
||||
".claude/skills/speckit-taskstoissues/SKILL.md": "613f41db8bd472a895b47a3a7051f836e77425d11e23ff72e92ee043225dcd98"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"integration": "speckit",
|
||||
"version": "0.16.4",
|
||||
"installed_at": "2026-09-07T05:26:24.919520+00:00",
|
||||
"files": {
|
||||
".specify/scripts/powershell/check-prerequisites.ps1": "c2586898d293c92f0839ef338b7a005c7d9a9d71a5f9e267ed7e01208a66baaf",
|
||||
".specify/scripts/powershell/common.ps1": "69c2bc6c40455a268c02d53ca4c8ac5f2e2df98f05293ea05245b0d462040bda",
|
||||
".specify/scripts/powershell/create-new-feature.ps1": "c6d5e64455635bc9d19e2ec902de2f72a7f834afd8b47a1a3d6323f7ed0cbb62",
|
||||
".specify/scripts/powershell/resolve-template.ps1": "e49c565a09902e4ebd4b5a51c4e014d5067fdee5b1592f15cb44ef31f430d745",
|
||||
".specify/scripts/powershell/setup-plan.ps1": "089362994a002bb91d9b93daea2dc21676119839d700d79e7b69f4a72e623ed1",
|
||||
".specify/scripts/powershell/setup-tasks.ps1": "c83d843c1640dca75fdac922a95cd97d8612d49bfe8331ee44390e85fe434a19",
|
||||
".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1",
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4",
|
||||
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
|
||||
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
|
||||
".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
"source": "core"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
Version change: 1.0.0 → 1.1.0
|
||||
Modified principles:
|
||||
- I. Renamed from "SaaS Is the Sole Identity & Access Authority" to "Each Identity Has
|
||||
Exactly One Authority, and the Frontend Is Never It" — corrects a mis-scoping in the
|
||||
original text, which treated agent/admin identity as SaaS-delegated. supporthub-api's
|
||||
010-identity-auth (built after this constitution's initial ratification) established that
|
||||
agent/admin login is supporthub-api's own authentication, never SaaS-delegated — mirroring
|
||||
supporthub-api's own Principle I, which was itself clarified during that same feature's
|
||||
planning. Only end-customer identity remains SaaS-delegated.
|
||||
Added sections: none
|
||||
Removed sections: none
|
||||
Deferred items: none
|
||||
Templates requiring follow-up: specs/001-agent-admin-ui/spec.md's FR-011 and Edge Cases still
|
||||
say "SaaS-provided role" for agent/admin access — needs the same correction before that
|
||||
feature's plan.md is written.
|
||||
-->
|
||||
|
||||
# SupportHub Web Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Each Identity Has Exactly One Authority, and the Frontend Is Never It (NON-NEGOTIABLE)
|
||||
supporthub-web MUST NOT implement its own user authentication, session management, or
|
||||
role-based access control for either identity this project serves. It reads, and only reads,
|
||||
whichever authority already owns that identity: for end-customers, that's the parent SaaS host,
|
||||
via the documented token mechanism (`X-SaaS-User-Token`, per README.md); for agents/admins,
|
||||
that's supporthub-api's own login (`POST /auth/login`, specs/010-identity-auth in
|
||||
supporthub-api) — SupportHub is the sole authority for its own support-org structure and staff
|
||||
accounts, exactly as supporthub-api's own Principle I defines, and was never SaaS-delegated.
|
||||
Neither identity's role or session state is ever re-derived, cached as a second source of
|
||||
truth, or locally overridden by this frontend. Rationale: an authentication or role system
|
||||
duplicated on the frontend is worse than none — it creates a second place authorization can
|
||||
silently disagree with whichever backend already decided it, and conflating the two identities'
|
||||
different authorities is exactly the kind of drift Principle I exists to prevent.
|
||||
|
||||
### II. The Backend Is the Sole Source of Business Logic
|
||||
supporthub-web MUST NOT re-implement ticket lifecycle state machines, SLA due-date or
|
||||
pause/resume math, escalation rule evaluation, assignment/routing logic, or any other business
|
||||
rule supporthub-api already owns. The frontend renders the state the API returns and calls the
|
||||
API to change it — it never computes a business decision the backend is authoritative for, even
|
||||
as a "just for the UI" shortcut (e.g. a client-side guess at whether a status transition is
|
||||
valid). Rationale: two implementations of the same rule (one in Fastify, one in React) will
|
||||
drift the moment either one changes; the API is already the tested, audited source of truth.
|
||||
|
||||
### III. Strict Portal Boundaries
|
||||
The four route groups — `(public)`, `(customer)`, `(support)`, `(admin)` — are distinct
|
||||
surfaces with different audiences and trust levels. Components, data, and navigation MUST NOT
|
||||
leak across portal boundaries: an admin-only data shape (e.g. escalation policy internals) MUST
|
||||
NOT be fetched or rendered from a customer-facing route, and customer-portal code MUST NOT
|
||||
import agent- or admin-portal features. Shared UI primitives live in `components/ui` and are
|
||||
portal-agnostic; portal-specific composition lives under each route group or its matching
|
||||
`features/` module. Rationale: this is the frontend analogue of supporthub-api's module
|
||||
boundaries (its own Principle III) — without an enforced seam, a support-agent-only view of a
|
||||
ticket inevitably ends up reachable from the customer portal by accident.
|
||||
|
||||
### IV. Typed API Boundary, No Ad Hoc Fetching
|
||||
Every call to supporthub-api goes through a single typed API client layer (`lib/api`), with
|
||||
request/response shapes kept in sync with the backend's own contracts
|
||||
(`specs/*/contracts/*.md` in supporthub-api). Components and `features/` modules MUST NOT call
|
||||
`fetch`/`axios` directly — all server state flows through TanStack Query hooks built on that
|
||||
client layer, giving one consistent place for auth headers, error handling, and retry/caching
|
||||
policy. Rationale: a typed, single-entry-point client is what keeps a contract change on the
|
||||
backend from becoming a silent runtime break scattered across dozens of components.
|
||||
|
||||
### V. Configuration Over Hardcoding
|
||||
Anything the backend exposes as admin-configurable — SLA policies, escalation rules,
|
||||
routing/assignment strategy, support hierarchy — MUST be rendered and edited generically from
|
||||
what the API returns, never hardcoded as frontend constants or duplicated enums that could drift
|
||||
from the backend's own validated values (e.g. root-cause types, verification methods,
|
||||
escalation trigger types). Rationale: mirrors supporthub-api's own Principle II — a value the
|
||||
business can change from an admin screen must never require a frontend deploy to keep it in
|
||||
sync with the backend's own enum.
|
||||
|
||||
### VI. Accessible, Responsive, Enterprise-Grade UI
|
||||
Every screen MUST be usable via keyboard, meet WCAG AA contrast, and remain usable down to a
|
||||
reasonable minimum viewport. This is an enterprise support tool used for hours at a time by
|
||||
agents and admins, not a marketing site — interaction patterns (tables, forms, workflows) MUST
|
||||
prioritize density and speed of task completion over decorative polish. Rationale: an
|
||||
inaccessible or slow-to-use agent workspace directly costs support throughput and SLA
|
||||
compliance — this is a productivity tool first.
|
||||
|
||||
### VII. Testing Gates
|
||||
Required test categories: unit tests (Vitest) for hooks, utilities, and business-facing
|
||||
component logic; integration tests for each portal's critical flows; Playwright E2E for the two
|
||||
cross-cutting journeys that matter most — (A) a customer submitting and tracking a ticket
|
||||
through to resolution/confirmation, and (B) an agent working an escalated ticket through
|
||||
investigation to resolution. Typecheck and lint MUST be clean before any feature is considered
|
||||
done, matching supporthub-api's own quality-gate discipline. Rationale: a frontend regression
|
||||
that silently breaks the agent workspace or the customer submission flow is a direct support-
|
||||
capacity or customer-trust incident, not a cosmetic bug.
|
||||
|
||||
## Technology & Platform Constraints
|
||||
|
||||
- Stack: Next.js 14 (App Router), React 18, TypeScript, TanStack Query, Tailwind CSS, axios
|
||||
(wrapped by the typed client layer, Principle IV), Vitest, Playwright.
|
||||
- Architecture style: route groups as portals (`(public)`, `(customer)`, `(support)`,
|
||||
`(admin)`), domain-organized `features/` modules, portal-agnostic `components/ui`. Do not
|
||||
introduce a second frontend framework or a competing state-management paradigm without a
|
||||
constitution amendment — one server-state layer (TanStack Query) and one client-state
|
||||
approach (`stores/`) keep data flow predictable.
|
||||
- Every environment (`development`, `test`, `production`) has its own `.env.*` file, matching
|
||||
the existing convention — no secrets committed to the repository.
|
||||
|
||||
## Development Workflow & Quality Gates
|
||||
|
||||
- `npm run typecheck` and `npm run lint` MUST pass before a feature is considered done.
|
||||
- `npm run test` (Vitest) covers unit/integration-level logic; `npm run test:e2e` (Playwright)
|
||||
covers the two cross-cutting journeys named in Principle VII, plus any portal-specific
|
||||
critical flow a feature's own spec identifies.
|
||||
- New pages/features are built against the existing scaffold (route groups, `features/`
|
||||
modules) — replacing a placeholder page's content, not restructuring the route tree, unless a
|
||||
spec explicitly calls for a structural change.
|
||||
|
||||
## Governance
|
||||
|
||||
This constitution supersedes ad hoc conventions and undocumented team habits for
|
||||
supporthub-web. All PRs and code reviews MUST verify compliance with the principles above
|
||||
before merge.
|
||||
|
||||
Amendments require: a documented rationale for the change, a version bump under the semantic
|
||||
versioning rule below, and an updated Sync Impact Report prepended to this file. MAJOR = a
|
||||
backward-incompatible principle removal or redefinition. MINOR = a new principle added, or
|
||||
existing guidance materially expanded. PATCH = clarification, wording, or typo fixes with no
|
||||
semantic change. Any exception to a MUST/MUST NOT rule requires explicit written justification
|
||||
in the relevant PR description and is expected to be rare, not routine.
|
||||
|
||||
This project is a sibling to, and a pure consumer of, `supporthub-api` — that project's own
|
||||
constitution and `docs/00-INDEX.md` through `docs/10-implementation-roadmap.md` remain the
|
||||
authority for backend and product-domain design; this constitution governs only how
|
||||
supporthub-web is engineered.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-09-07 | **Last Amended**: 2026-09-07
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# Consolidated prerequisite checking script (PowerShell)
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.ps1 [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# -Json Output in JSON format
|
||||
# -RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# -PathsOnly Only output path variables (no validation)
|
||||
# -Template NAME Include composed template content in JSON output
|
||||
# -Help, -h Show help message
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$RequireTasks,
|
||||
[switch]$IncludeTasks,
|
||||
[switch]$PathsOnly,
|
||||
[string]$Template,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output @"
|
||||
Usage: check-prerequisites.ps1 [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
-Json Output in JSON format
|
||||
-RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
-PathsOnly Only output path variables (no prerequisite validation)
|
||||
-Template NAME Include composed template content in JSON output
|
||||
-Help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
.\check-prerequisites.ps1 -Json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
.\check-prerequisites.ps1 -PathsOnly
|
||||
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths.
|
||||
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
|
||||
# the feature.json write side effect (issue #3025).
|
||||
if ($PathsOnly) {
|
||||
$paths = Get-FeaturePathsEnv -NoPersist
|
||||
} else {
|
||||
$paths = Get-FeaturePathsEnv
|
||||
}
|
||||
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if ($PathsOnly) {
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $paths.REPO_ROOT
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
TASKS = $paths.TASKS
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "TASKS: $($paths.TASKS)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = '/speckit-specify'
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = '/speckit-plan'
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
|
||||
$tasksCommand = '/speckit-tasks'
|
||||
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build list of available documents
|
||||
$docs = @()
|
||||
|
||||
# Always check these optional docs
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
}
|
||||
|
||||
$templateContent = $null
|
||||
if ($Template) {
|
||||
$templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -eq $templateContent) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
# JSON output
|
||||
$result = [ordered]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
}
|
||||
if ($Template) {
|
||||
$result.TEMPLATE_CONTENT = $templateContent
|
||||
}
|
||||
[PSCustomObject]$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document.
|
||||
# These helpers report their line with Write-Output and ALSO return a
|
||||
# bool, both on the Success stream, so 'Out-Null' discarded the report
|
||||
# line along with the return value and left AVAILABLE_DOCS empty. Drop
|
||||
# only the boolean so the per-document lines reach stdout like the
|
||||
# bash and Python twins.
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] }
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Where-Object { $_ -isnot [bool] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Common PowerShell functions analogous to common.sh
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
function Find-SpecifyRoot {
|
||||
param([string]$StartDir = (Get-Location).Path)
|
||||
|
||||
# Normalize to absolute path to prevent issues with relative paths
|
||||
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
|
||||
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
|
||||
$current = if ($resolved) { $resolved.Path } else { $null }
|
||||
if (-not $current) { return $null }
|
||||
|
||||
while ($true) {
|
||||
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
|
||||
return $current
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
|
||||
return $null
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
||||
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
|
||||
# design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
$initDir = Join-Path (Get-Location).Path $initDir
|
||||
}
|
||||
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
|
||||
# Resolve-Path also succeeds for files, so check the resolved path is a
|
||||
# directory; otherwise a file value would slip through to the less accurate
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
# the returned root matches the bash resolver, whose `cd && pwd` never yields
|
||||
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
|
||||
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
|
||||
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
|
||||
# become 'C:', which every later API re-resolves against the current
|
||||
# directory instead of the drive root). No-op on a path with no trailing
|
||||
# separator.
|
||||
$initRoot = $resolved.Path.TrimEnd('/', '\')
|
||||
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
|
||||
$initRoot = $resolved.Path
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
if ($specifyRoot) {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Final fallback to script location
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# Return feature name from explicit state only.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# No explicit feature set - return empty to signal "unknown".
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
function Save-FeatureJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
||||
)
|
||||
|
||||
# Strip repo root prefix if the value is absolute and under repo root.
|
||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
||||
if ($onWin) {
|
||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$cmp = [System.StringComparison]::Ordinal
|
||||
}
|
||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
||||
}
|
||||
|
||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
|
||||
# Read current value and skip write when unchanged
|
||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# File is corrupt or unreadable - overwrite it
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure .specify/ directory exists
|
||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Write feature.json
|
||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist,
|
||||
[switch]$ReturnNullOnError
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
|
||||
if (-not $repoRoot) { return $null }
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error - no feature context available
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
# Normalize relative paths to absolute under repo root
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still
|
||||
# work - unless the caller opted out for read-only resolution (#3025).
|
||||
if (-not $NoPersist) {
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
if ($featureConfig.feature_directory) {
|
||||
$featureDir = $featureConfig.feature_directory
|
||||
# Normalize relative paths to absolute under repo root
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
# an empty, misleading value (issue #3026).
|
||||
if (-not $currentBranch) {
|
||||
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
|
||||
$featureDirTrimmed = $featureDir.TrimEnd('/', '\')
|
||||
$currentBranch = Split-Path -Leaf $featureDirTrimmed
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
TASKS = Join-Path $featureDir 'tasks.md'
|
||||
RESEARCH = Join-Path $featureDir 'research.md'
|
||||
DATA_MODEL = Join-Path $featureDir 'data-model.md'
|
||||
QUICKSTART = Join-Path $featureDir 'quickstart.md'
|
||||
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FileExists {
|
||||
param([string]$Path, [string]$Description)
|
||||
if (Test-Path -Path $Path -PathType Leaf) {
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DirHasFiles {
|
||||
param([string]$Path, [string]$Description)
|
||||
# A directory counts as non-empty when Get-ChildItem returns any entry
|
||||
# (files or subdirectories) -- matching the JSON contracts checks in
|
||||
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
|
||||
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
|
||||
# non-empty like bash check_dir. Filtering out subdirectories would
|
||||
# mis-report such a directory as empty.
|
||||
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InvokeSeparator {
|
||||
param([string]$RepoRoot = (Get-RepoRoot))
|
||||
|
||||
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
|
||||
$script:SpecKitInvokeSeparatorCache = @{}
|
||||
}
|
||||
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
|
||||
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
|
||||
}
|
||||
|
||||
$separator = '.'
|
||||
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
|
||||
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
|
||||
try {
|
||||
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
|
||||
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
|
||||
if ($key -and $state.integration_settings) {
|
||||
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
|
||||
if ($settingProperty) {
|
||||
$setting = $settingProperty.Value
|
||||
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
|
||||
$separator = [string]$setting.invoke_separator
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$separator = '.'
|
||||
}
|
||||
}
|
||||
|
||||
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
|
||||
return $separator
|
||||
}
|
||||
|
||||
function Format-SpecKitCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CommandName,
|
||||
[string]$RepoRoot = (Get-RepoRoot)
|
||||
)
|
||||
|
||||
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
|
||||
$name = $CommandName.TrimStart('/')
|
||||
if ($name.StartsWith('speckit.')) {
|
||||
$name = $name.Substring(8)
|
||||
} elseif ($name.StartsWith('speckit-')) {
|
||||
$name = $name.Substring(8)
|
||||
}
|
||||
$name = $name -replace '\.', $separator
|
||||
|
||||
return "/speckit$separator$name"
|
||||
}
|
||||
|
||||
# Find a usable Python 3 executable (python3, python, or py -3).
|
||||
# Returns the command/arguments as an array, or $null if none found.
|
||||
function Get-Python3Command {
|
||||
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
|
||||
if (Get-Command python -ErrorAction SilentlyContinue) {
|
||||
$ver = & python --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('python') }
|
||||
}
|
||||
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||
$ver = & py -3 --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('py', '-3') }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-NormalizedPriority {
|
||||
param($Value)
|
||||
|
||||
if ($Value -is [bool]) { return 10 }
|
||||
if ($Value -is [string]) {
|
||||
$integerText = $Value.Trim()
|
||||
if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 }
|
||||
$Value = $integerText.Replace('_', '')
|
||||
}
|
||||
try {
|
||||
$parsedPriority = [System.Numerics.BigInteger]$Value
|
||||
} catch {
|
||||
return 10
|
||||
}
|
||||
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
|
||||
}
|
||||
|
||||
function Get-SortedExtensionIds {
|
||||
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)
|
||||
|
||||
$registeredNames = @()
|
||||
$ranked = @()
|
||||
$registryFile = Join-Path $ExtensionsDir '.registry'
|
||||
# Detect any filesystem entry at the registry path without following symlinks.
|
||||
# Test-Path follows links and reports $false for a dangling symlink, so a
|
||||
# broken .registry symlink would otherwise bypass this guard and let the
|
||||
# directory scan below enable every on-disk extension. Enumerating the parent
|
||||
# directory still observes a broken symlink as an entry.
|
||||
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -eq '.registry' } |
|
||||
Select-Object -First 1
|
||||
if ($registryEntry) {
|
||||
if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) {
|
||||
throw "Invalid extension registry ${registryFile}: not a regular file"
|
||||
}
|
||||
try {
|
||||
$data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)"
|
||||
}
|
||||
if ($null -eq $data -or $data -isnot [PSCustomObject]) {
|
||||
throw "Invalid extension registry ${registryFile}: root must be a mapping"
|
||||
}
|
||||
$extensionsProperty = $data.PSObject.Properties['extensions']
|
||||
if ($extensionsProperty) {
|
||||
if ($extensionsProperty.Value -isnot [PSCustomObject]) {
|
||||
throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping"
|
||||
}
|
||||
$extensions = $extensionsProperty.Value
|
||||
} else {
|
||||
$extensions = [PSCustomObject]@{}
|
||||
}
|
||||
$registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
foreach ($entry in $extensions.PSObject.Properties) {
|
||||
if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) {
|
||||
continue
|
||||
}
|
||||
$enabledProperty = $entry.Value.PSObject.Properties['enabled']
|
||||
if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue }
|
||||
$priority = 10
|
||||
$priorityProperty = $entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
$priority = Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
$ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) {
|
||||
if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) {
|
||||
$ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name }
|
||||
}
|
||||
}
|
||||
return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id }
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
function Resolve-Template {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null }
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Priority 1: Project overrides
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) { return $override }
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
$presetsProperty = $registryData.PSObject.Properties['presets']
|
||||
if ($presetsProperty) {
|
||||
$presets = $presetsProperty.Value
|
||||
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
|
||||
throw 'Registry presets must be an object'
|
||||
}
|
||||
$presetEntries = @($presets.PSObject.Properties)
|
||||
$priorityFor = {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
return Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
}
|
||||
return 10
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object {
|
||||
$enabled = $_.Value.PSObject.Properties['enabled']
|
||||
-not $enabled -or [bool]$enabled.Value
|
||||
} |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($registryParsed) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
$candidate = Join-Path $presetsDir "$presetId/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
$candidate = Join-Path $preset.FullName "$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
|
||||
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
|
||||
if (-not (Test-Path $candidate)) {
|
||||
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
|
||||
}
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) { return $core }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
function Resolve-TemplateContent {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') {
|
||||
return $null
|
||||
}
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
$layerPaths = @()
|
||||
$layerStrategies = @()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
return [System.IO.File]::ReadAllText(
|
||||
$override,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
}
|
||||
|
||||
$effectiveBaseFound = $false
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
$presetsProperty = $registryData.PSObject.Properties['presets']
|
||||
if ($presetsProperty) {
|
||||
$presets = $presetsProperty.Value
|
||||
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
|
||||
throw 'Registry presets must be an object'
|
||||
}
|
||||
$presetEntries = @($presets.PSObject.Properties)
|
||||
$priorityFor = {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
return Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
}
|
||||
return 10
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object {
|
||||
$enabled = $_.Value.PSObject.Properties['enabled']
|
||||
-not $enabled -or [bool]$enabled.Value
|
||||
} |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $registryParsed) {
|
||||
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object Name |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
|
||||
$pyCmd = @(Get-Python3Command)
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
# Read strategy and file path from preset manifest
|
||||
$strategy = 'replace'
|
||||
$manifestFilePath = ''
|
||||
$manifestDeclared = $false
|
||||
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
|
||||
if ((Test-Path $manifest) -and -not $pyCmd) {
|
||||
throw "Python 3 and PyYAML are required to resolve preset template composition"
|
||||
}
|
||||
if (Test-Path $manifest) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
$pyStderrFile = [System.IO.Path]::GetTempFileName()
|
||||
$stratResult = & $pyCmd[0] @pyArgs -c @"
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(sys.argv[1], encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('manifest root must be a mapping')
|
||||
if 'provides' not in data:
|
||||
raise ValueError('manifest missing provides section')
|
||||
provides = data['provides']
|
||||
if not isinstance(provides, dict):
|
||||
raise ValueError('manifest provides must be a mapping')
|
||||
if 'templates' not in provides:
|
||||
raise ValueError('manifest provides missing templates')
|
||||
templates = provides['templates']
|
||||
if not isinstance(templates, list):
|
||||
raise ValueError('manifest templates must be a list')
|
||||
if not templates:
|
||||
raise ValueError('manifest must provide at least one template')
|
||||
valid_types = ('template', 'command', 'script')
|
||||
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
|
||||
for t in templates:
|
||||
if not isinstance(t, dict):
|
||||
raise ValueError('manifest template entries must be mappings')
|
||||
if 'type' not in t or 'name' not in t or 'file' not in t:
|
||||
raise ValueError('manifest template entry missing type, name, or file')
|
||||
for field in ('type', 'name', 'file'):
|
||||
if not isinstance(t[field], str):
|
||||
raise ValueError('manifest template ' + field + ' must be a string')
|
||||
if t['type'] not in valid_types:
|
||||
raise ValueError('invalid manifest template type')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError('manifest template strategy must be a string')
|
||||
strategy = strategy.lower()
|
||||
if strategy not in valid_strategies:
|
||||
raise ValueError('invalid manifest template strategy')
|
||||
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
|
||||
raise ValueError('invalid manifest script strategy')
|
||||
for t in templates:
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
sys.exit(0)
|
||||
print('absent\treplace\t')
|
||||
except Exception as exc:
|
||||
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
"@ $manifest $TemplateName 2>$pyStderrFile
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
throw "PyYAML is required to resolve preset template composition"
|
||||
}
|
||||
throw "Invalid preset manifest $manifest"
|
||||
}
|
||||
if ($stratResult) {
|
||||
$parts = $stratResult.Trim() -split "`t", 3
|
||||
$manifestDeclared = $parts[0] -eq 'found'
|
||||
$strategy = $parts[1].ToLowerInvariant()
|
||||
if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] }
|
||||
}
|
||||
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
|
||||
throw
|
||||
}
|
||||
}
|
||||
# Try manifest file path first, then convention path
|
||||
$candidate = $null
|
||||
if ($manifestFilePath) {
|
||||
# Reject absolute paths and parent traversal
|
||||
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
|
||||
$manifestFilePath = ''
|
||||
}
|
||||
}
|
||||
if ($manifestFilePath) {
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate -and -not $manifestDeclared) {
|
||||
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
}
|
||||
}
|
||||
if ($candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += $strategy
|
||||
if ($strategy -eq 'replace') {
|
||||
$effectiveBaseFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
|
||||
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
|
||||
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
|
||||
if (-not (Test-Path $candidate)) {
|
||||
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
|
||||
}
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
$effectiveBaseFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (-not $effectiveBaseFound -and (Test-Path $core)) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
if ($layerPaths.Count -eq 0) { return $null }
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely --
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
$hasComposition = $false
|
||||
foreach ($s in $layerStrategies) {
|
||||
if ($s -ne 'replace') { $hasComposition = $true; break }
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
$baseIdx = -1
|
||||
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
|
||||
if ($layerStrategies[$i] -eq 'replace') {
|
||||
$baseIdx = $i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) {
|
||||
throw "Template '$TemplateName' has composing layers but no replace base"
|
||||
}
|
||||
|
||||
$content = [System.IO.File]::ReadAllText(
|
||||
$layerPaths[$baseIdx],
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
'prepend' { $content = "$layerContent`n`n$content" }
|
||||
'append' { $content = "$content`n`n$layerContent" }
|
||||
'wrap' {
|
||||
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
|
||||
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
|
||||
}
|
||||
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
|
||||
}
|
||||
default { throw "Unknown strategy: $strat" }
|
||||
}
|
||||
}
|
||||
|
||||
return $content
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Create a new feature
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$AllowExistingBranch,
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[string]$Number = '',
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$maxBranchLength = 244
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Examples:"
|
||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
||||
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
||||
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if feature description provided
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||
|
||||
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
[long]$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Test-SpecPrefixInUse {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[string]$FeatureNum
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "$FeatureNum-*" } |
|
||||
Select-Object -First 1)
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
function Get-FittedBranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$fittedName = "$FeatureNum-$BranchSuffix"
|
||||
if ($fittedName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $FeatureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
$fittedName = "$FeatureNum-$truncatedSuffix"
|
||||
}
|
||||
|
||||
return $fittedName
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
if (-not $DryRun) {
|
||||
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
# Common stop words to filter out
|
||||
$stopWords = @(
|
||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||
'want', 'need', 'add', 'get', 'set'
|
||||
)
|
||||
|
||||
# Convert to lowercase and extract words (alphanumeric only)
|
||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
$meaningfulWords = @()
|
||||
foreach ($word in $words) {
|
||||
# Skip stop words
|
||||
if ($stopWords -contains $word) { continue }
|
||||
|
||||
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Keep short words only if they appear as uppercase in original (likely
|
||||
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
|
||||
# bash script's case-sensitive grep; -match would be case-insensitive and
|
||||
# would keep every short word.
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if ($meaningfulWords.Count -gt 0) {
|
||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||
return $result
|
||||
} else {
|
||||
# Fallback to original logic if no meaningful words found
|
||||
$result = ConvertTo-CleanBranchName -Name $Description
|
||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||
return [string]::Join('-', $fallbackWords)
|
||||
}
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if ($ShortName) {
|
||||
# Use provided short name, just clean it up
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
# Generate from description with smart filtering
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Treat an explicit empty string as omitted, matching the bash and Python twins.
|
||||
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified.
|
||||
if ($Timestamp -and $hasNumber) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
|
||||
$Number = ''
|
||||
}
|
||||
|
||||
# Determine branch prefix
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
# Determine branch number from existing feature directories. Auto-detect only
|
||||
# when -Number was not supplied; an explicit value (including 0) is honored,
|
||||
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
[long]$resolvedNumber = 0
|
||||
if (-not $hasNumber) {
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
if ($highestNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber = $highestNumber + 1
|
||||
} elseif ($Number -notmatch '^[0-9]+$') {
|
||||
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
|
||||
exit 1
|
||||
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
|
||||
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
$specConflict = $false
|
||||
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
|
||||
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$requestedDir = Join-Path $specsDir $requestedBranchName
|
||||
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
|
||||
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
|
||||
}
|
||||
}
|
||||
|
||||
if ($specConflict) {
|
||||
$requestedNum = $featureNum
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
$resolvedNumber = $highestNumber
|
||||
do {
|
||||
if ($resolvedNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber++
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$originalBranchName = "$featureNum-$branchSuffix"
|
||||
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
if ($branchName -ne $originalBranchName) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
||||
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
||||
}
|
||||
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
||||
if ($Timestamp) {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
} else {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
$needsSpec = -not (Test-Path -PathType Leaf $specFile)
|
||||
$content = $null
|
||||
if ($needsSpec) {
|
||||
$content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
if ($needsSpec) {
|
||||
if ($null -ne $content) {
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
# Match the bash twin (create-new-feature.sh): warn on stderr that no
|
||||
# spec template was found before creating an empty spec file, so the
|
||||
# missing-template signal is not silently swallowed on Windows.
|
||||
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
||||
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
|
||||
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
||||
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
|
||||
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
||||
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
|
||||
[Console]::Error.WriteLine("# To persist: $featureAssignment")
|
||||
[Console]::Error.WriteLine("# $directoryAssignment")
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "# $directoryAssignment"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[string]$TemplateName,
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Output "Usage: resolve-template.ps1 <template-name> [-Json]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $TemplateName) {
|
||||
[Console]::Error.WriteLine("ERROR: Template name is required")
|
||||
exit 1
|
||||
}
|
||||
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
$repoRoot = Get-RepoRoot
|
||||
$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot
|
||||
if ($null -eq $templateContent) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
TEMPLATE_NAME = $TemplateName
|
||||
TEMPLATE_CONTENT = $templateContent
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
[Console]::Out.Write($templateContent)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Setup implementation plan for a feature
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
# Capture extra positional arguments to match Bash/Python behavior.
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
|
||||
Write-Output " -Json Output results in JSON format"
|
||||
Write-Output " -Help Show this help message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Load common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if plan doesn't already exist
|
||||
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
|
||||
} else {
|
||||
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
|
||||
}
|
||||
} else {
|
||||
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -ne $content) {
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
|
||||
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)")
|
||||
} else {
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
}
|
||||
} else {
|
||||
# Match the bash twin's wording and stream routing (stderr in -Json so
|
||||
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
|
||||
# "Copied plan template" message above.
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Warning: Plan template not found")
|
||||
} else {
|
||||
Write-Output "Warning: Plan template not found"
|
||||
}
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
$result = [PSCustomObject]@{
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Help wins over unknown-argument validation to match the Bash/Python
|
||||
# variants, which stop at --help and exit 0.
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($RemainingArgs.Count -gt 0) {
|
||||
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = '/speckit-plan'
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = '/speckit-specify'
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build available docs list
|
||||
$docs = @()
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -eq $tasksTemplateContent) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
|
||||
[Console]::Error.WriteLine("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.")
|
||||
exit 1
|
||||
}
|
||||
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
} else {
|
||||
$tasksTemplate = ''
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
TASKS_TEMPLATE = $tasksTemplate
|
||||
TASKS_TEMPLATE_CONTENT = $tasksTemplateContent
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||
**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.
|
||||
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit-checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
|
||||
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
|
||||
- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers
|
||||
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -0,0 +1,113 @@
|
||||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
[Extract from feature spec: primary requirement + technical approach from research]
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit-plan command output)
|
||||
├── research.md # Phase 0 output (/speckit-plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit-plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit-plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit-plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
@@ -0,0 +1,131 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
|
||||
**Created**: [DATE]
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
@@ -0,0 +1,252 @@
|
||||
---
|
||||
|
||||
description: "Task list template for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: [FEATURE NAME]
|
||||
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit-tasks command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Create project structure per implementation plan
|
||||
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||
- [ ] T003 [P] Configure linting and formatting tools
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
- [ ] T004 Setup database schema and migrations framework
|
||||
- [ ] T005 [P] Implement authentication/authorization framework
|
||||
- [ ] T006 [P] Setup API routing and middleware structure
|
||||
- [ ] T007 Create base models/entities that all stories depend on
|
||||
- [ ] T008 Configure error handling and logging infrastructure
|
||||
- [ ] T009 Setup environment configuration management
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
[Add more user story phases as needed, following the same pattern]
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] TXXX [P] Documentation updates in docs/
|
||||
- [ ] TXXX Code cleanup and refactoring
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2 → P3)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) MUST be written and FAIL before implementation
|
||||
- Models before services
|
||||
- Services before endpoints
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||
- All tests for a user story marked [P] can run in parallel
|
||||
- Models within a story marked [P] can run in parallel
|
||||
- Different user stories can be worked on in parallel by different team members
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests for User Story 1 together (if tests requested):
|
||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||
|
||||
# Launch all models for User Story 1 together:
|
||||
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify tests fail before implementing
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
@@ -0,0 +1,78 @@
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "speckit"
|
||||
name: "Full SDD Cycle"
|
||||
version: "1.0.0"
|
||||
author: "GitHub"
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
integrations:
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "alquimia"
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec before planning."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-plan
|
||||
type: gate
|
||||
message: "Review the plan before generating tasks."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: tasks
|
||||
command: speckit.tasks
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: implement
|
||||
command: speckit.implement
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflows": {
|
||||
"speckit": {
|
||||
"name": "Full SDD Cycle",
|
||||
"version": "1.0.0",
|
||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||
"source": "bundled",
|
||||
"installed_at": "2026-09-07T05:26:25.085309+00:00",
|
||||
"updated_at": "2026-09-07T05:26:25.085309+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
// 001-agent-admin-ui: static export ('output: export') is incompatible with Next.js
|
||||
// Middleware, which the (support)/(admin) portals' sign-in guard requires (FR-000) — this app
|
||||
// now runs as a standard Next.js server (`next build && next start`), not a static export.
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
unoptimized: true, // Required for static HTML export
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1279
-1
File diff suppressed because it is too large
Load Diff
+20
-15
@@ -2,11 +2,9 @@
|
||||
"name": "supporthub-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
|
||||
"engines": {
|
||||
"node": ">=22 <23"
|
||||
},
|
||||
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
@@ -20,30 +18,37 @@
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.12",
|
||||
"js-cookie": "^3.0.8",
|
||||
"lucide-react": "^0.417.0",
|
||||
"next": "^14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"lucide-react": "^0.417.0",
|
||||
"axios": "^1.7.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"typescript": "^5.5.4",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"postcss": "^8.4.40",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"vitest": "^2.0.4",
|
||||
"@playwright/test": "^1.45.3"
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// Serial, not fullyParallel: against a Next.js DEV server, concurrent first-hit requests to
|
||||
// different routes contend on that dev server's own on-demand page compilation, causing
|
||||
// response-time flakiness unrelated to the app itself. A production build (`next build &&
|
||||
// next start`) doesn't have this cold-compile-per-route behavior; revisit if E2E ever runs
|
||||
// against one.
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,118 @@
|
||||
# Specification Quality Checklist: Agent and Admin UI
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Scope is Phase 10 per supporthub-api's `docs/10-implementation-roadmap.md`, translated into
|
||||
this project's own first feature: the agent workspace and the admin configuration surfaces
|
||||
for support organization, SLA/business-calendars, and escalation — the subsystems an agent's
|
||||
own day-to-day workflow (User Stories 1-2) most directly depends on existing and being
|
||||
configurable first.
|
||||
- Deliberately excludes the `(public)`/`(customer)` portals, real-time push sync, analytics/
|
||||
reporting, audit-log viewing, platform settings, and AI-confidence-policy administration —
|
||||
each flagged explicitly in Assumptions as follow-up scope, not an oversight, to keep this
|
||||
first feature reviewable rather than attempting all of Phase 10's roadmap line in one spec.
|
||||
- FR-012 and Edge Cases both restate this project's own Constitution Principle II (the backend
|
||||
is the sole source of business logic) as testable requirements, not just governance — every
|
||||
acceptance scenario that touches a business-rule rejection (US2 scenario 2, US3 scenario 3,
|
||||
US5 scenario 3) exists specifically to keep that principle enforced, not merely aspirational.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Post-approval amendment**: this spec originally assumed agent/admin identity arrived the
|
||||
same pre-authenticated way customer identity does (delegated from the parent SaaS host), with
|
||||
no frontend-owned login screen needed — FR-011 and an Edge Case both said "SaaS-provided
|
||||
role." supporthub-api's 010-identity-auth (built after this spec's initial approval)
|
||||
established that agent/admin login is supporthub-api's own, separate authentication, never
|
||||
SaaS-delegated — the same correction already made to both projects' constitutions. Added
|
||||
User Story 0 (sign-in, P1, more foundational than US1), FR-000, SC-006, a new Edge Case, and
|
||||
reworded FR-011/the existing Edge Case/the closing Assumption to say "supporthub-api session
|
||||
role" instead of "SaaS-provided role." This is a correction of a wrong premise, not new scope
|
||||
creep — nothing else in User Stories 1-7 changes.
|
||||
- **Implementation-time finding**: two structural issues with this project's existing scaffold
|
||||
only surfaced by actually running the app, not by inspection — (1) with this project's `src/`
|
||||
directory layout, Next.js requires `middleware.ts` to live at `src/middleware.ts`, not the
|
||||
repo root; the pre-existing empty `middleware.ts` had been scaffolded in the wrong location.
|
||||
(2) `next.config.mjs`'s `output: 'export'` (static HTML export) is fundamentally incompatible
|
||||
with Next.js Middleware — the dev server refused to run it outright. Removing static export
|
||||
is a deployment-mode change (this app now needs `next build && next start`, not a static file
|
||||
host), flagged to and confirmed by the user before making it, since it has real infrastructure
|
||||
implications beyond this feature's own code.
|
||||
- A second discovered blocker, upstream of this feature entirely: supporthub-api had no endpoint
|
||||
to list "tickets currently assigned to agent X" at all, and no way to resolve a logged-in
|
||||
session to its own agent roster row — User Story 1 had no data source without it. Resolved by
|
||||
a new supporthub-api feature, 011-agent-ticket-queue, built and merged into this feature's own
|
||||
data-model.md/contracts before Setup began.
|
||||
- **Implementation-time findings, User Stories 1-3**: a research agent asked to verify exact
|
||||
backend request/response shapes was run in an isolated git worktree and reported supporthub-
|
||||
api's entire problem-management module as unimplemented stubs — false, an artifact of the
|
||||
worktree being based on `main`, which doesn't include the unmerged `009-problem-resolution`
|
||||
branch this session's own backend work already completed and verified. Re-checked directly
|
||||
against the actual working branch instead; the real schemas were all present. Lesson for this
|
||||
project specifically: don't use worktree isolation for a pure read-only lookup against a
|
||||
feature branch — it silently serves a stale, wrong branch state with no warning.
|
||||
- That same direct re-check caught two real contract mismatches in this feature's own
|
||||
`lib/api/tickets.ts`, both fixed before they shipped: `getTicketMessages` was calling the
|
||||
customer-safe `GET /tickets/:id/messages` (excludes internal notes) instead of the agent-facing
|
||||
`GET /agent/tickets/:id/messages` FR-002 actually needs; and `postMessage`'s body was
|
||||
`{body, visibleToCustomer}` instead of the real `{type, body}` — `visibleToCustomer` is always
|
||||
server-derived from `type`, never client-supplied (003-ticketing's own visibility mapping).
|
||||
- `Solution`/`SolutionVerification` have no GET endpoint in supporthub-api (only Investigation/
|
||||
RootCause/Resolution do) — `ProblemResolutionPanel` tracks those two stages' progress in local
|
||||
session state rather than a persisted read-back, an explicit, documented scope decision
|
||||
(data-model.md), not an oversight; Investigation/RootCause gating uses the real GET endpoints
|
||||
instead, so those two stages stay correct across a page reload.
|
||||
- All Setup/Foundational/User Story 0-3 work (T001-T041) verified with real, locally-running
|
||||
supporthub-api + supporthub-web servers — 8 Playwright E2E scenarios passing against live
|
||||
data, not mocks, in addition to the mocked unit/integration suite.
|
||||
- **Implementation-time findings, User Stories 4-7 (tasks-p2-p3.md)**: planning for these four
|
||||
stories surfaced three more of the same "existing domain, never exposed as a read query" gaps
|
||||
011-agent-ticket-queue's own kind — a business calendar's holidays could be added/removed but
|
||||
never read back at all; `GET /admin/escalation-policies` returned bare policies with no way to
|
||||
see which rules already existed under one; and knowledge-governance needed a way to list
|
||||
entries including drafts, which `GET /knowledge/retrieve` (published-only) can't provide. All
|
||||
three were fixed as small follow-up commits to supporthub-api's own 012-admin-list-views
|
||||
rather than new spec-kit cycles, given how narrow and mechanical each one was (one Prisma
|
||||
`include` or one new query method) — this pattern (backend gaps discovered mid-frontend-work)
|
||||
recurred often enough this session to be worth naming explicitly for future work on this pair
|
||||
of projects: build the consuming screen's contract first, verify each read against the actual
|
||||
schema, and expect roughly one small backend gap per admin-configuration domain touched.
|
||||
- A second, unrelated implementation-time bug, caught only by submitting a real form against a
|
||||
live backend (not by any mocked test): `CalendarEditor`'s default timezone value was `"UTC"`,
|
||||
which `Intl.supportedValuesOf('timeZone')` — what supporthub-api validates against — does not
|
||||
contain at all (no bare `"UTC"`, no `"Etc/UTC"`) — only full region-based IANA names like
|
||||
`"America/New_York"` are valid. Every mocked integration test for this screen passed anyway,
|
||||
since the mock never exercised the backend's own validation — a reminder that this feature's
|
||||
own "verify against a real backend" discipline (not just mocked tests) is what this class of
|
||||
bug needs to be caught by.
|
||||
- All User Story 4-7 work (T042-T071) verified the same way as US0-3: real, locally-running
|
||||
supporthub-api + supporthub-web servers, 3 additional Playwright E2E scenarios passing against
|
||||
live data (creating a real calendar/holiday/SLA-policy, a real escalation policy/rule against
|
||||
a real hierarchy node, and reading the real product catalog), alongside the mocked
|
||||
unit/integration suite (26 tests total across both task lists).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Contract: `lib/api` Client Surface (Setup + User Stories 0-3)
|
||||
|
||||
Every function below is a plain async function in `lib/api/<domain>.ts`, calling the shared
|
||||
axios instance from `lib/api/client.ts`. None are called directly from a component — only from
|
||||
a TanStack Query hook in the matching `features/*` module (Constitution Principle IV).
|
||||
|
||||
## `lib/api/auth.ts` (User Story 0)
|
||||
|
||||
| Function | Calls | Notes |
|
||||
|---|---|---|
|
||||
| `login(email, password)` | `POST /auth/login` | On success, sets the `sh_session` cookie (research.md) with the returned token; returns `{ token, user }`. |
|
||||
| `getCurrentSession()` | `GET /auth/me` | Used by `SessionProvider` on mount and after any 401, to re-validate against live account state (010's own FR-007). |
|
||||
| `logout()` | `POST /auth/logout` | Clears `sh_session` regardless of the call's outcome (a failed logout call must not leave a dead session behind client-side). |
|
||||
|
||||
## `lib/api/tickets.ts` (User Stories 1-2)
|
||||
|
||||
| Function | Calls | Notes |
|
||||
|---|---|---|
|
||||
| `getMyAssignedTickets()` | `GET /agents/me/tickets` | US1's only data source. A `404` (no linked agent, 011's own FR-006) is surfaced as a distinct empty/error state, never rendered as "zero tickets." |
|
||||
| `getTicket(ticketId)` | `GET /tickets/:ticketId` | US2 workbench header. |
|
||||
| `getTicketMessages(ticketId)` | `GET /agent/tickets/:ticketId/messages` | The agent-facing list, not the customer-safe `GET /tickets/:ticketId/messages` — the workbench must show internal notes (FR-002). |
|
||||
| `postMessage(ticketId, body)` | `POST /tickets/:ticketId/messages` | Body is `{type, body}`, not `{body, visibleToCustomer}` — `visibleToCustomer` is always server-derived from `type` (003's own message-visibility mapping), never client-supplied. An agent picks `AGENT_MESSAGE` (customer-visible) or `INTERNAL_NOTE` (agent-only) via `type`. |
|
||||
| `updateTicketStatus(ticketId, status, expectedVersion)` | `PATCH /tickets/:ticketId/status` | `expectedVersion` is always the value from the most recently fetched `Ticket` — the frontend never guesses it (optimistic-concurrency contract, 003's own research.md). |
|
||||
| `escalateTicket(ticketId, targetNodeId, reason)` | `POST /tickets/:ticketId/escalate` | US5's manual-escalation action, surfaced from the workbench (US2 acceptance scenario 4's own re-assignment result is read back via `getTicket`, not computed). |
|
||||
|
||||
## `lib/api/problems.ts` (User Story 2)
|
||||
|
||||
| Function | Calls |
|
||||
|---|---|
|
||||
| Function | Calls | Body |
|
||||
|---|---|---|
|
||||
| `listInvestigations(problemId)` / `listRootCauses(problemId)` | `GET /admin/problems/:problemId/investigations` / `.../root-causes` | Used for reload-safe gating in `ProblemResolutionPanel` — real backend state, not local-only session booleans (unlike Solution/SolutionVerification below, which have no GET endpoint). |
|
||||
| `recordInvestigation(problemId, body)` | `POST /admin/problems/:problemId/investigations` | `{investigator: string, findings: Record<string,unknown>, evidence?: Record<string,unknown>, internalNotes?: string, status?: 'open'\|'complete'}` |
|
||||
| `recordRootCause(problemId, body)` | `POST /admin/problems/:problemId/root-causes` | `{type: 'technical'\|'configuration'\|'external_dependency'\|'business'\|'contributing_factor', description: string}` |
|
||||
| `proposeSolution(problemId, body)` | `POST /admin/problems/:problemId/solutions` | `{proposed: string}` |
|
||||
| `approveSolution(solutionId)` | `PATCH /admin/solutions/:solutionId/approve` | none — a solution must be approved before it can be implemented |
|
||||
| `recordImplementation(solutionId, body)` | `POST /admin/solutions/:solutionId/implementation` | `{implementedBy: string, notes?: string}` |
|
||||
| `recordVerification(solutionId, body)` | `POST /admin/solutions/:solutionId/verification` | `{method: 'automated'\|'technical_test'\|'customer_confirmation'\|'agent_confirmation', result: 'success'\|'failed', evidence?: Record<string,unknown>}` |
|
||||
| `recordResolution(ticketId, body)` | `POST /admin/tickets/:ticketId/resolution` | `{outcome: string, resolvedBy: string}` |
|
||||
|
||||
Every one of these surfaces a `409` (precondition not met — e.g. no investigation on file yet)
|
||||
via `ApiError`, rendered verbatim (FR-003/SC-002) — none are pre-validated client-side beyond
|
||||
basic form completeness (required-field presence), matching Principle II.
|
||||
|
||||
## `lib/api/teams.ts` / `lib/api/hierarchy.ts` (User Story 3)
|
||||
|
||||
| Function | Calls |
|
||||
|---|---|
|
||||
| `listTeams()` / `createTeam(name)` | `GET /admin/teams` / `POST /admin/teams` |
|
||||
| `addAgent(teamId, name)` | `POST /admin/teams/:teamId/agents` |
|
||||
| `upsertAgentSkill(agentId, skillTag, level)` | `PUT /admin/agents/:agentId/skills/:skillTag` |
|
||||
| `linkAgentAccount(agentId, userId)` | `PATCH /admin/agents/:agentId` (011's new `userId` field) |
|
||||
| `listHierarchyNodes()` / `createHierarchyNode(body)` | `GET /admin/hierarchy-nodes` / `POST /admin/hierarchy-nodes` |
|
||||
|
||||
A `400 CYCLE_DETECTED` from `createHierarchyNode`/its update equivalent is rendered as-is (US3
|
||||
acceptance scenario 3) — the frontend never pre-checks for a cycle itself.
|
||||
|
||||
## `lib/api/sla.ts` / `lib/api/calendars.ts` (User Story 4)
|
||||
|
||||
| Function | Calls | Body/Query |
|
||||
|---|---|---|
|
||||
| `listSlaPolicies()` / `createSlaPolicy(body)` | `GET`/`POST /admin/sla-policies` | `{name, productId?, categoryId?, problemTypeId?, priority?, firstResponseMinutes, investigationMinutes?, resolutionMinutes, customerResponseMinutes?, businessCalendarId?}` |
|
||||
| `listCalendars()` / `createCalendar(body)` | `GET`/`POST /admin/business-calendars` | `{name, timezone, workingHours: {mon?..sun?: {start,end}}}` |
|
||||
| `addHoliday(calendarId, body)` | `POST /admin/business-calendars/:id/holidays` | `{date, description?}` |
|
||||
|
||||
US4 acceptance scenario 3 ("indicates which policy is more specific"): the frontend sorts/badges
|
||||
by specificity using only which optional scope fields (`productId`/`categoryId`/
|
||||
`problemTypeId`/`priority`) are non-null on each policy — it never re-derives the backend's own
|
||||
most-specific-match *resolution* (which policy actually applies to a given ticket), only a
|
||||
display ordering over already-fetched policies (Constitution Principle II).
|
||||
|
||||
## `lib/api/escalation.ts` (User Story 5)
|
||||
|
||||
| Function | Calls | Body |
|
||||
|---|---|---|
|
||||
| `listEscalationPolicies()` / `createEscalationPolicy(name, productId?)` | `GET`/`POST /admin/escalation-policies` | `{name, productId?}` |
|
||||
| `createEscalationRule(policyId, body)` | `POST /admin/escalation-policies/:id/rules` | `{triggerType, condition, targetNodeId, notify, active?}` — `triggerType` is one of the 10 values in `specs/008-sla-escalation/contracts/sla-escalation-contract.md`, not re-declared as a separate frontend enum (Principle V) |
|
||||
|
||||
Manual escalation (`escalateTicket`, already in `lib/api/tickets.ts`) is this same story's own
|
||||
US2-surfaced action — a stale `targetNodeId` shows the backend's own rejection (US5 scenario 3).
|
||||
|
||||
## `lib/api/monitoring.ts` (User Story 6)
|
||||
|
||||
| Function | Calls |
|
||||
|---|---|
|
||||
| `listSlaRuns(status?)` | `GET /admin/sla-runs` (012-admin-list-views) |
|
||||
| `listEscalationEvents(limit?)` | `GET /admin/escalation-events` (012-admin-list-views) |
|
||||
|
||||
Both reflect live backend state on every fetch/refetch (US6's own "not a snapshot that goes
|
||||
stale") — no client-side caching beyond TanStack Query's own normal `staleTime` behavior.
|
||||
|
||||
## `lib/api/catalog.ts` / `lib/api/knowledge.ts` (User Story 7)
|
||||
|
||||
| Function | Calls |
|
||||
|---|---|
|
||||
| `listProductCatalog()` | `GET /admin/products` (012-admin-list-views) |
|
||||
| `listKnowledgeEntries(externalProductId)` | `GET /admin/products/:externalProductId/knowledge` (012-admin-list-views follow-up — NOT `GET /knowledge/retrieve`, which is published-only and would hide the very drafts this screen exists to publish) |
|
||||
| `publishKnowledgeEntry(code, effectiveDate?)` / `unpublishKnowledgeEntry(code)` | `PATCH /admin/knowledge/:code/publish` / `.../unpublish` |
|
||||
|
||||
## Session guard contract (`middleware.ts`, `SessionProvider`)
|
||||
|
||||
- No `sh_session` cookie on a `(support)`/`(admin)` request → redirect to `/sign-in?from=<path>`.
|
||||
- Cookie present but its decoded `role` is not `ADMIN` on an `(admin)` request → redirect to
|
||||
`/support/dashboard` (never render admin content for a non-admin role, FR-011/SC-005).
|
||||
- Any API response `401` → `SessionProvider` clears `sh_session` and redirects to `/sign-in`
|
||||
(FR-000 Scenario 4) — this is the real enforcement path (middleware's decode is unverified),
|
||||
since it fires from supporthub-api's own rejection of the token, not a client-side guess.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Data Model: Agent and Admin UI
|
||||
|
||||
This feature introduces no persisted frontend data — every "entity" below is a TypeScript type
|
||||
mirroring a supporthub-api response shape, kept in `lib/api/types/`. Fields are not re-listed in
|
||||
full where a backend contract already documents them exactly; each type below names its source
|
||||
contract instead of duplicating it (Constitution Principle V — never a second, driftable copy of
|
||||
a shape the backend already owns).
|
||||
|
||||
Scoped to this plan's immediate implementation target (Setup + User Stories 0-3). US4-US7's own
|
||||
types follow the identical pattern against their own backend contracts when those stories are
|
||||
built.
|
||||
|
||||
## `Session`
|
||||
|
||||
The frontend's own type, not mirrored from any single backend response — assembled from
|
||||
`POST /auth/login` / `GET /auth/me`'s `data.user` shape
|
||||
(`specs/010-identity-auth/contracts/identity-auth-contract.md`, supporthub-api).
|
||||
|
||||
```ts
|
||||
interface Session {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'AGENT';
|
||||
}
|
||||
```
|
||||
|
||||
Stored: nowhere as an object — only the raw JWT lives in the `sh_session` cookie (research.md);
|
||||
`Session` is derived at read time via `GET /auth/me`, never trusted from a locally-decoded JWT
|
||||
claim for anything but middleware's own UX-only redirect decision.
|
||||
|
||||
## `AssignedTicketSummary`
|
||||
|
||||
Mirrors `specs/011-agent-ticket-queue/contracts/agent-ticket-queue-contract.md`'s
|
||||
`GET /agents/me/tickets` response item exactly (id, code, status, priority, severity, product,
|
||||
customer, assignedAt, sla) — the Agent Dashboard's (US1) only data source.
|
||||
|
||||
## `Ticket` (workbench detail)
|
||||
|
||||
Mirrors `specs/003-ticketing/contracts/ticket-lifecycle-contract.md`'s `GET /tickets/:ticketId`
|
||||
response, plus:
|
||||
|
||||
- Messages: `specs/003-ticketing/contracts/ticket-lifecycle-contract.md`'s message-list shape
|
||||
(customer/AI/agent messages, internal notes flagged `internalOnly` or equivalent — see that
|
||||
contract for the exact discriminator field name).
|
||||
- Problem-resolution records (investigation, root cause, solution, verification, resolution):
|
||||
`specs/009-problem-resolution/contracts/problem-resolution-contract.md`.
|
||||
- Current assignment: `specs/007-orchestration-assignment/contracts/orchestration-contract.md`.
|
||||
- SLA run: `specs/008-sla-escalation/contracts/sla-escalation-contract.md`.
|
||||
|
||||
The Ticket Workbench (US2) composes these as separate TanStack Query calls (one per concern),
|
||||
not one oversized aggregate endpoint — matches how supporthub-api itself exposes them as
|
||||
separate resources.
|
||||
|
||||
## Support Organization Editor entities (US3)
|
||||
|
||||
Mirrors `specs/006-support-organization/contracts/support-org-contract.md` exactly: `Team`,
|
||||
`Agent` (including the `userId` link from 011), `AgentSkill`, `AgentAvailability`,
|
||||
`HierarchyNode` (with `productScope`, `skills`, `assignmentStrategy`, `parentId`).
|
||||
|
||||
## SLA & Calendar Configuration entities (US4)
|
||||
|
||||
Mirrors `specs/008-sla-escalation/contracts/sla-escalation-contract.md`: `SLAPolicy` (`name`,
|
||||
`productId?`, `categoryId?`, `problemTypeId?`, `priority?`, `firstResponseMinutes`,
|
||||
`investigationMinutes?`, `resolutionMinutes`, `customerResponseMinutes?`,
|
||||
`businessCalendarId?`), `BusinessCalendar` (`name`, `timezone`, `workingHours` keyed
|
||||
`mon`..`sun` each `{start,end}` in `"HH:mm"`), `BusinessCalendarHoliday` (`date`,
|
||||
`description?`).
|
||||
|
||||
## Escalation Configuration entities (US5)
|
||||
|
||||
Mirrors the same contract: `EscalationPolicy` (`name`, `productId?`), `EscalationRule`
|
||||
(`triggerType` — one of the 10 values in that contract, `condition`, `targetNodeId`, `notify`,
|
||||
`active`).
|
||||
|
||||
## Monitoring Views entities (US6)
|
||||
|
||||
Mirrors `specs/012-admin-list-views/contracts/admin-list-views-contract.md` exactly:
|
||||
`SlaRunListItem` (`ticketId`, `ticketCode`, `status`, `firstResponseDueAt`, `resolutionDueAt`,
|
||||
`breachedAt`, `firstResponseBreachedAt`), `EscalationEventListItem` (`ticketId`, `ticketCode`,
|
||||
`reason`, `ruleId`, `triggeredBy`, `toNodeId`, `createdAt`).
|
||||
|
||||
## Catalog & Knowledge Governance entities (US7)
|
||||
|
||||
`ProductCatalogListItem` mirrors `specs/012-admin-list-views/contracts/admin-list-views-
|
||||
contract.md` exactly (`id`, `externalProductId`, `name`, `status`, `supportEnabled`,
|
||||
`integrationStatus`). `KnowledgeEntry` mirrors `specs/004-product-knowledge/contracts/
|
||||
knowledge-contract.md`: `code`, `feature?`, `type`, `problem?`, `symptoms?`, `errorCode?`,
|
||||
`cause?`, `recommendedSolution?`, `verificationSteps?`, `escalationGuidance?`,
|
||||
`categoryScope?`, `owner?`, `source?`, plus lifecycle fields (`status`/`validationStatus`/
|
||||
`effectiveDate`/`version` — exact names confirmed against the controller at implementation
|
||||
time, not guessed).
|
||||
|
||||
## Frontend-only derived state
|
||||
|
||||
- **`QueryState`**: `'loading' | 'empty' | 'error' | 'ready'` — research.md's discriminated
|
||||
union, derived from a TanStack Query result, never persisted.
|
||||
- **`ApiError`**: `{ code: string; message: string; statusCode: number }` — the shape every
|
||||
supporthub-api error response already uses (`{success:false,error:{code,message}}`), thrown
|
||||
by `lib/api/client.ts`'s response interceptor so a failed mutation's `error` is always this
|
||||
shape, never a raw axios error.
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- No screen computes ticket-transition validity, SLA due dates, most-specific-policy
|
||||
resolution, or escalation-rule matching — every such value is read as-is from the API
|
||||
response types above (FR-012).
|
||||
- `Session.role` gates which portal renders (FR-011) but is never used to compute a business
|
||||
decision beyond that gate.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Implementation Plan: Agent and Admin UI
|
||||
|
||||
**Branch**: `001-agent-admin-ui` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/001-agent-admin-ui/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Builds the `(support)`/`(admin)` portals against the existing Next.js scaffold: a sign-in flow
|
||||
consuming supporthub-api's own login (010-identity-auth, User Story 0 — this feature's true
|
||||
foundation), an agent dashboard and ticket workbench (011-agent-ticket-queue plus the existing
|
||||
ticketing/problem-resolution endpoints), and admin configuration screens for support
|
||||
organization, SLA/calendars, escalation, and (P3) catalog/knowledge governance. Every screen
|
||||
reads and writes through one typed API client layer (`lib/api`) via TanStack Query — no business
|
||||
logic is computed client-side (Constitution Principle II).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.5, Node.js 22 (per `package.json` `engines`), Next.js 14
|
||||
(App Router), React 18. `next.config.mjs`'s `output: 'export'` (static HTML export) is removed
|
||||
by this feature — Next.js Middleware, which FR-000's sign-in guard requires, cannot run under
|
||||
static export (confirmed by the dev server itself refusing to start it); this app now runs as a
|
||||
standard Next.js server (`next build && next start`), a deployment-mode change, not just a code
|
||||
change.
|
||||
|
||||
**Primary Dependencies**: `@tanstack/react-query` (server state), `axios` (wrapped by `lib/api`),
|
||||
`tailwindcss` (styling, already configured), `clsx`/`tailwind-merge` (already present),
|
||||
`lucide-react` (icons, already present). New: `js-cookie` (small, typed cookie read/write for
|
||||
the session token — the codebase has no cookie helper today) and `jose` (Edge-runtime-safe JWT
|
||||
payload decode for `middleware.ts` — `jsonwebtoken` depends on Node APIs `middleware.ts` cannot
|
||||
use).
|
||||
|
||||
**Storage**: N/A (server state is supporthub-api's; the only client-side state is the session
|
||||
cookie itself, `research.md`).
|
||||
|
||||
**Testing**: Vitest (unit — hooks, `lib/api` functions, the query-state helper; integration —
|
||||
per-portal critical flows against a mocked API layer) + Playwright (E2E — the two journeys
|
||||
Constitution Principle VII names, plus this feature's own: an agent signing in and resolving a
|
||||
ticket end-to-end, User Stories 0-2). `vitest.config.ts` and `playwright.config.ts` are both
|
||||
currently empty stubs — this feature is the first to configure either.
|
||||
|
||||
**Target Platform**: Browser (desktop-first, responsive down to a reasonable minimum per
|
||||
Principle VI), served by the existing Next.js app.
|
||||
|
||||
**Project Type**: Web frontend — single Next.js project, consuming a separately-deployed
|
||||
supporthub-api.
|
||||
|
||||
**Performance Goals**: SC-001 — dashboard-open to message-post in under 15 seconds of active use
|
||||
(excluding backend response time), meaning the UI itself must not introduce avoidable extra
|
||||
round trips or re-renders on that path.
|
||||
|
||||
**Constraints**: FR-012/Principle II — no business decision (transition validity, SLA due
|
||||
dates, most-specific-policy resolution, escalation matching) is computed client-side, only
|
||||
rendered from what the API returns. FR-000/Principle I — this frontend never verifies a JWT
|
||||
signature itself (research.md); the unverified middleware decode is UX-only.
|
||||
|
||||
**Scale/Scope**: Eight user stories (US0 sign-in + US1-US7), against ~30 existing placeholder
|
||||
`page.tsx` files across four route groups, all of `lib/api`/`lib/auth`/`lib/query`/`hooks`/
|
||||
`stores`/`providers`/`features/*` currently empty. Given the size, this plan's own Project
|
||||
Structure section scopes Phase 1 (Setup) and User Stories 0-3 (the P1 MVP: sign-in, dashboard,
|
||||
ticket workbench, support-org admin) as the immediate implementation target; User Stories 4-7
|
||||
(P2/P3) follow the identical pattern once the MVP is verified, per the Implementation Strategy
|
||||
below — matching this project's own established phase-by-phase, checkpoint-then-continue
|
||||
discipline (mirrors supporthub-api's own MVP-first pattern, e.g. 010's own tasks.md).
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Check | Result |
|
||||
|---|---|---|
|
||||
| I. Each Identity Has Exactly One Authority | US0 consumes supporthub-api's own login (010) for agent/admin identity, never re-implementing or re-deriving it; the `(public)`/`(customer)` portals (SaaS-delegated identity) are explicitly out of scope for this feature (Assumptions), so no conflict between the two identities' authorities arises here. | PASS |
|
||||
| II. The Backend Is the Sole Source of Business Logic | FR-012 is this principle restated as a testable requirement; every user story's Acceptance Scenarios that touch a business-rule rejection (US2 scenario 2, US3 scenario 3, US5 scenario 3) exist specifically to keep it enforced. | PASS |
|
||||
| III. Strict Portal Boundaries | This feature only touches `(support)`/`(admin)` — `(public)`/`(customer)` are untouched (Assumptions). Shared primitives stay in `components/ui`; portal-specific composition lives in `features/*` and each route group. | PASS |
|
||||
| IV. Typed API Boundary, No Ad Hoc Fetching | `lib/api` is the single client layer (research.md); every `features/*` module consumes it only through TanStack Query hooks. | PASS |
|
||||
| V. Configuration Over Hardcoding | Support hierarchy, SLA policies, escalation rules, and skills are rendered/edited generically from what the API returns — no frontend-duplicated enum for root-cause types, verification methods, escalation trigger types, etc. | PASS |
|
||||
| VI. Accessible, Responsive, Enterprise-Grade UI | Existing `components/ui` primitives (already built, this session's earlier scaffold work) are the basis for every new screen; density/keyboard-usability is a per-task acceptance bar, not a separate follow-up. | PASS |
|
||||
| VII. Testing Gates | This plan's Testing section above configures both currently-empty test runners as part of Setup, not deferred — Vitest unit/integration plus the two Playwright E2E journeys (one of which, agent resolving an escalated ticket, is this feature's own US0-US2). | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
All gates above remain PASS after Phase 1 design (data-model.md, contracts, quickstart.md). One
|
||||
principle is worth restating post-design: Principle I's redefinition (this session's own
|
||||
constitution amendment, 1.0.0 → 1.1.0) is what makes User Story 0 exist at all — without it, this
|
||||
plan would have wrongly assumed no sign-in screen was needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-agent-admin-ui/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-web/
|
||||
├── src/middleware.ts # MODIFIED (was an empty file at repo root — moved
|
||||
│ under src/, required by this project's src/
|
||||
│ directory layout) — session presence + role
|
||||
│ redirect for (support)/(admin), FR-000/research.md
|
||||
├── vitest.config.ts # MODIFIED (was empty) — first real test config
|
||||
├── playwright.config.ts # MODIFIED (was empty) — first real E2E config
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── env/index.ts # MODIFIED (was empty) — typed env accessor
|
||||
│ │ ├── auth/ # MODIFIED (was empty) — session cookie read/write/
|
||||
│ │ │ │ clear, decode-for-display helpers
|
||||
│ │ │ └── (session.ts, use-session.ts)
|
||||
│ │ ├── api/ # MODIFIED (was empty) — axios instance + interceptors
|
||||
│ │ │ │ (client.ts) + one file per domain (auth.ts,
|
||||
│ │ │ │ agents.ts, tickets.ts, teams.ts, hierarchy.ts,
|
||||
│ │ │ │ sla.ts, escalation.ts, problems.ts, ...)
|
||||
│ │ │ └── types/ # response/request shapes, kept in sync with
|
||||
│ │ │ supporthub-api's own contracts/*.md
|
||||
│ │ └── query/ # MODIFIED (was empty) — QueryClient config,
|
||||
│ │ │ query-state.ts (research.md's discriminated union)
|
||||
│ │ └── keys.ts # centralized TanStack Query key factory
|
||||
│ ├── providers/ # MODIFIED — QueryClientProvider, SessionProvider
|
||||
│ ├── hooks/ # MODIFIED — use-session, use-query-state re-exports
|
||||
│ ├── features/
|
||||
│ │ ├── auth/ # NEW — sign-in form + its own mutation hook (US0)
|
||||
│ │ ├── tickets/ # MODIFIED (was empty) — dashboard list, workbench
|
||||
│ │ │ detail, message thread, status/escalate actions
|
||||
│ │ ├── problems/ # MODIFIED — investigation/root-cause/solution/
|
||||
│ │ │ verification/resolution recording (US2)
|
||||
│ │ ├── teams/ # MODIFIED — team/agent/skill admin (US3)
|
||||
│ │ ├── orchestration/ # MODIFIED — hierarchy-node admin (US3)
|
||||
│ │ ├── settings/ # MODIFIED — SLA/calendar admin (US4, deferred)
|
||||
│ │ └── ... # escalation/reports/knowledge/products: US5-US7,
|
||||
│ │ deferred past this plan's immediate MVP scope
|
||||
│ └── app/
|
||||
│ ├── (support)/
|
||||
│ │ ├── layout.tsx # MODIFIED — session-aware shell (nav, sign-out)
|
||||
│ │ └── support/
|
||||
│ │ ├── dashboard/page.tsx # MODIFIED (was a placeholder) — US1
|
||||
│ │ └── agent-tickets/[ticketId]/page.tsx # MODIFIED — US2
|
||||
│ └── (admin)/
|
||||
│ ├── layout.tsx # MODIFIED — admin-only shell
|
||||
│ └── admin/
|
||||
│ ├── teams/page.tsx # MODIFIED — US3
|
||||
│ └── hierarchy/page.tsx # MODIFIED — US3
|
||||
└── tests/
|
||||
├── unit/ # lib/api functions, query-state, session helpers
|
||||
├── integration/ # per-portal critical flows (mocked API layer)
|
||||
└── e2e/ # Playwright — Principle VII's two journeys
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Next.js project, built entirely against the existing scaffold
|
||||
(no new route groups or top-level directories) per the constitution's own Development Workflow
|
||||
rule — replacing placeholder pages, not restructuring the route tree. This plan's own immediate
|
||||
implementation scope is Setup + User Stories 0-3 (P1 MVP); `features/` subdirectories for
|
||||
US4-US7 are named above for completeness but not built until those stories' own turn.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,53 @@
|
||||
# Quickstart: Validating Agent and Admin UI (Setup + User Stories 0-3)
|
||||
|
||||
Prerequisites: supporthub-api running locally with 010-identity-auth and 011-agent-ticket-queue
|
||||
applied; a seeded `ADMIN` account (`admin@supporthub.internal` / the dev password documented in
|
||||
`prisma/seed/roles.seed.ts`); `NEXT_PUBLIC_API_URL` pointed at that backend.
|
||||
|
||||
## Scenario 0 — sign-in and portal guard (User Story 0)
|
||||
|
||||
1. With no session, navigate to `/support/dashboard`. **Expected**: redirected to `/sign-in`.
|
||||
2. Sign in with the seeded admin's credentials. **Expected**: redirected to a role-appropriate
|
||||
landing page; the session persists across a page reload.
|
||||
3. Sign in with a wrong password. **Expected**: one generic error message, not a hint about
|
||||
which part was wrong (010's own identical-failure-response guarantee, surfaced verbatim).
|
||||
4. As an `AGENT`-role session, navigate directly to `/admin/teams`. **Expected**: redirected
|
||||
away — the admin portal never renders.
|
||||
5. Sign out. **Expected**: redirected to `/sign-in`; the session cookie is cleared; navigating
|
||||
back to `/support/dashboard` redirects to sign-in again.
|
||||
|
||||
## Scenario 1 — agent dashboard (User Story 1)
|
||||
|
||||
1. As an agent linked to a roster row (011) with two tickets currently assigned, open
|
||||
`/support/dashboard`. **Expected**: both appear with customer/product/priority/status/SLA
|
||||
time-remaining visible without opening either.
|
||||
2. As an agent with zero assigned tickets, open the dashboard. **Expected**: a clear empty
|
||||
state, never an indefinite spinner or a blank screen.
|
||||
3. Reassign one of the two tickets away from the agent (via the backend directly); refresh the
|
||||
dashboard. **Expected**: that ticket no longer appears.
|
||||
|
||||
## Scenario 2 — ticket workbench (User Story 2)
|
||||
|
||||
1. Open an assigned ticket already in `HUMAN_ESCALATION`. Post a customer-visible message and
|
||||
an internal note. **Expected**: both appear in the thread, visually distinct.
|
||||
2. With no investigation on file, attempt to record a root cause first. **Expected**: the
|
||||
backend's own `409` rejection is shown clearly, not a silent failure.
|
||||
3. Record investigation → root cause → solution → implementation → successful verification →
|
||||
resolution, in order. **Expected**: the ticket's displayed status updates to "Pending
|
||||
Customer Confirmation" without a manual page reload.
|
||||
|
||||
## Scenario 3 — support organization setup (User Story 3)
|
||||
|
||||
1. Create a team, add an agent, grant a skill. **Expected**: both immediately visible in the
|
||||
roster, matching the backend's own read endpoint exactly.
|
||||
2. Create a hierarchy node with a product scope, required skills, and an assignment strategy.
|
||||
**Expected**: retrievable exactly as configured, in its correct tree position.
|
||||
3. Attempt to create a hierarchy node that would be its own ancestor. **Expected**: the
|
||||
backend's own cycle-detection rejection is shown clearly.
|
||||
|
||||
## What "done" looks like (for this plan's immediate scope)
|
||||
|
||||
All four scenarios pass against a real, locally-running supporthub-api — no mocked backend for
|
||||
this verification pass, matching the discipline this project's own backend has followed for
|
||||
every feature so far. `npm run typecheck`, `npm run lint`, `npm run test`, and the two Playwright
|
||||
E2E journeys (Principle VII) are all green.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Research: Agent and Admin UI
|
||||
|
||||
## Decision: session token lives in a plain (non-httpOnly) cookie, not `localStorage`
|
||||
|
||||
- **Decision**: After a successful `POST /auth/login` (supporthub-api, 010-identity-auth), the
|
||||
frontend itself sets a cookie (`sh_session`, `Secure` in production, `SameSite=Lax`, `Max-Age`
|
||||
matching the token's own 4-hour lifetime) holding the raw JWT. `lib/api`'s axios instance reads
|
||||
it via a request interceptor and attaches `Authorization: Bearer <token>`. Next.js middleware
|
||||
(FR-000) reads the same cookie server-side to decide whether to redirect to sign-in, and
|
||||
decodes (not verifies) its payload to read the `role` claim for portal gating.
|
||||
- **Rationale**: A cookie is the only session-storage mechanism readable from both Next.js
|
||||
middleware (which runs before any page renders, server-side, and cannot read `localStorage`)
|
||||
and client-side code (which needs the raw token to build the `Authorization` header, since
|
||||
supporthub-api only ever reads that header — never a cookie itself, and this feature does not
|
||||
modify supporthub-api's auth plugin to accept one). `localStorage` alone would leave
|
||||
middleware blind, defeating FR-000's redirect-before-render requirement.
|
||||
- **Alternatives considered**: An httpOnly cookie plus a Next.js Route Handler (BFF) proxy for
|
||||
every supporthub-api call, so the browser never touches the raw token — more secure against
|
||||
XSS, but adds a full proxy layer duplicating every backend route as a Next.js route handler,
|
||||
for a feature whose own constitution (Principle IV) already mandates a single typed client
|
||||
layer as the XSS mitigation surface (sanitizing/escaping is still React's own default
|
||||
behavior). Rejected as disproportionate infrastructure for an internal agent/admin tool;
|
||||
revisit if a future security review calls for it.
|
||||
|
||||
## Decision: Next.js middleware does a cheap, unverified role/presence check; the API is still the real gate
|
||||
|
||||
- **Decision**: `middleware.ts` runs on every `(support)`/`(admin)` request: no `sh_session`
|
||||
cookie → redirect to `/sign-in`; cookie present → base64-decode the JWT payload (no signature
|
||||
check) to read `role`, and redirect a non-`ADMIN` role away from `(admin)` routes. The actual
|
||||
security boundary remains supporthub-api itself (Constitution Principle I) — every real data
|
||||
fetch still carries the same token, and 010's own `fastify.authenticate`/`requireRole` reject
|
||||
an invalid, expired, or revoked token regardless of what the middleware decided.
|
||||
- **Rationale**: Matches spec.md's Edge Cases: middleware gating is a UX/navigation concern (not
|
||||
rendering a portal that will just fail its own data fetches), not a re-implementation of
|
||||
authorization (Principle I forbids that). A full signature verification in middleware would
|
||||
need `jsonwebtoken` (or the Edge-compatible `jose`) plus the same secret duplicated into the
|
||||
frontend's own environment — an unnecessary second copy of a decision supporthub-api already
|
||||
makes correctly on every request.
|
||||
- **Alternatives considered**: Full JWT verification in middleware — rejected; duplicates
|
||||
`JWT_SECRET` into a second codebase for no additional real security (an unverified decode
|
||||
still redirects instantly on outright tampering the moment the first API call 401s), and
|
||||
contradicts Principle I's "never re-derive" language more directly than a presence-only check.
|
||||
|
||||
## Decision: axios instance + interceptors, wrapped by TanStack Query hooks (Principle IV)
|
||||
|
||||
- **Decision**: `lib/api/client.ts` exports one configured axios instance
|
||||
(`baseURL: env.NEXT_PUBLIC_API_URL`), a request interceptor attaching the session cookie's
|
||||
token, and a response interceptor that clears the session and redirects to sign-in on a `401`
|
||||
(FR-000 Scenario 4). Every domain concern (`lib/api/tickets.ts`, `lib/api/agents.ts`, etc.)
|
||||
exports plain async functions calling that instance, each typed against the backend's own
|
||||
`specs/*/contracts/*.md` shapes. `features/*` modules only ever call these functions through
|
||||
TanStack Query's `useQuery`/`useMutation`, never `axios`/`fetch` directly.
|
||||
- **Rationale**: This is Principle IV's own literal requirement — one client layer is what keeps
|
||||
a backend contract change from becoming a scattered runtime break.
|
||||
- **Alternatives considered**: A codegen'd client from an OpenAPI spec — supporthub-api doesn't
|
||||
currently publish one as part of its own CI (`scripts/generate-openapi.ts` exists but isn't a
|
||||
build-time contract source consumed here); hand-written typed functions against the `contracts/
|
||||
*.md` files were preferred to avoid introducing a new cross-repo build dependency for this
|
||||
feature specifically.
|
||||
|
||||
## Decision: business-rule rejections (409/400) surface the backend's own message verbatim
|
||||
|
||||
- **Decision**: The axios response interceptor does NOT swallow or rewrite 4xx error bodies —
|
||||
it re-throws an `ApiError` carrying the backend's own `error.message`/`error.code`
|
||||
(`{success:false,error:{code,message}}`, the shape every supporthub-api response already
|
||||
uses). UI components render that message directly for a failed mutation (FR-012/SC-002),
|
||||
never a generic "Something went wrong."
|
||||
- **Rationale**: SC-002 requires the backend's own reason be shown 100% of the time — this is
|
||||
only possible if the client layer preserves it rather than mapping every error to one generic
|
||||
state.
|
||||
- **Alternatives considered**: A generic per-mutation error message with the detail only in a
|
||||
console log — rejected outright by SC-002's own wording.
|
||||
|
||||
## Decision: loading/empty/error as an explicit three-state discriminated union, not three booleans
|
||||
|
||||
- **Decision**: A small shared hook/helper (`lib/query/query-state.ts`) derives one of
|
||||
`'loading' | 'empty' | 'error' | 'ready'` from a TanStack Query result (`isLoading`, `isError`,
|
||||
and `data.length === 0` for list queries), and every list/detail view switches on that single
|
||||
value rather than independently checking `isLoading`/`isError`/`data`.
|
||||
- **Rationale**: FR-010/SC-003 require never conflating loading, empty, and error — a
|
||||
discriminated union makes "forgot to handle the error case" a TypeScript exhaustiveness error
|
||||
at the call site instead of a runtime bug found by manual testing.
|
||||
- **Alternatives considered**: Ad hoc `if (isLoading) ... else if (isError) ...` per component —
|
||||
workable but leaves FR-010 compliance unenforced by the type system; rejected once the shared
|
||||
helper's cost was seen to be trivial.
|
||||
@@ -0,0 +1,371 @@
|
||||
# Feature Specification: Agent and Admin UI
|
||||
|
||||
**Feature Branch**: `001-agent-admin-ui`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Phase 10 of supporthub-api's docs/10-implementation-roadmap.md:
|
||||
Agent workspace (continues from AI context), admin configuration surfaces for every
|
||||
configurable subsystem above (SaaS/products, product knowledge, AI support, ticketing, support
|
||||
organization, orchestration/assignment, SLA/escalation, problem resolution) — built against the
|
||||
existing (support)/(admin) route groups already scaffolded in this project, consuming
|
||||
supporthub-api's now-complete backend."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 0 - An agent or admin signs in (Priority: P1)
|
||||
|
||||
Before any other story in this feature can matter, an agent or admin needs a way to actually
|
||||
get a session: a sign-in screen that calls supporthub-api's own login (010-identity-auth,
|
||||
`POST /auth/login`), and a portal that redirects to it — rather than rendering with absent or
|
||||
default data — whenever no valid session exists. This did not exist as a requirement when this
|
||||
spec was first drafted, because agent/admin identity was mistakenly assumed to arrive the same
|
||||
way customer identity does (pre-authenticated, delegated from the parent SaaS host, needing no
|
||||
frontend-owned login screen at all). supporthub-api's 010-identity-auth feature established that
|
||||
agent/admin identity is supporthub-api's own, separate authentication — so unlike the customer
|
||||
portal, this frontend must own a real sign-in flow for the `(support)`/`(admin)` portals.
|
||||
|
||||
**Why this priority**: Nothing in User Stories 1-7 has a session to act as, or a role to gate
|
||||
on, without this. It is more foundational than US1 itself.
|
||||
|
||||
**Independent Test**: With no existing session, navigate to any `(support)` or `(admin)` URL;
|
||||
confirm a redirect to sign-in; sign in with a valid agent account; confirm landing on the agent
|
||||
dashboard with a session that subsequent API calls carry.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** no session, **When** a `(support)` or `(admin)` URL is requested directly,
|
||||
**Then** the user is redirected to a sign-in screen instead of the portal rendering with
|
||||
absent or default data.
|
||||
2. **Given** the sign-in screen, **When** correct credentials are submitted, **Then** the
|
||||
resulting session is stored and the user lands on the role-appropriate landing page (agent
|
||||
dashboard for `AGENT`, admin dashboard for `ADMIN`).
|
||||
3. **Given** the sign-in screen, **When** incorrect credentials are submitted, **Then** the
|
||||
backend's own identical-failure response (010's FR-002) is shown as one generic error,
|
||||
never a hint about which part was wrong.
|
||||
4. **Given** an active session that supporthub-api no longer honors (expired, revoked, or the
|
||||
account deactivated — 010's own `GET /auth/me` re-validation), **When** the next API call is
|
||||
made, **Then** the user is signed out and redirected to sign-in, not left looking at stale
|
||||
data or a silent failure.
|
||||
5. **Given** a signed-in session, **When** the user explicitly signs out, **Then**
|
||||
supporthub-api's own logout (010's `POST /auth/logout`) is called and the session is cleared
|
||||
locally, redirecting to sign-in.
|
||||
|
||||
---
|
||||
|
||||
### User Story 1 - An agent sees and opens their assigned work (Priority: P1)
|
||||
|
||||
A support agent logs into the workspace and immediately sees the tickets currently assigned to
|
||||
them — enough context (customer, product, priority, SLA status, age) to decide what to work on
|
||||
next — and can open any one of them to see its full detail.
|
||||
|
||||
**Why this priority**: Nothing else in the agent workspace matters if an agent can't even see
|
||||
what they're supposed to be working on. This is the entry point for every other agent-facing
|
||||
story.
|
||||
|
||||
**Independent Test**: Sign in as an agent with tickets assigned via the backend's own
|
||||
assignment engine; confirm the dashboard lists exactly those tickets with correct, live status/
|
||||
priority/SLA information, and that opening one navigates to its detail view.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with two tickets currently assigned to them, **When** they open the
|
||||
dashboard, **Then** both tickets appear, each showing customer/product, priority, current
|
||||
status, and time remaining (or overdue) against its SLA.
|
||||
2. **Given** an agent with zero assigned tickets, **When** they open the dashboard, **Then** it
|
||||
clearly shows an empty state, never a loading spinner that never resolves or a blank screen.
|
||||
3. **Given** a ticket reassigned away from the agent while they're viewing the dashboard,
|
||||
**When** the dashboard's data next refreshes, **Then** that ticket no longer appears in their
|
||||
list.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent works a ticket end-to-end (Priority: P1)
|
||||
|
||||
From a ticket's detail view, an agent can read its full message history (customer, AI, and
|
||||
prior agent messages, with internal notes visible only to agents), post a new message or
|
||||
internal note, and move the ticket through its lifecycle — recording an investigation, root
|
||||
cause, proposed and implemented solution, its verification, and finally a resolution — using
|
||||
the same workflow doc 04 defines and supporthub-api's problem-resolution feature already
|
||||
enforces.
|
||||
|
||||
**Why this priority**: This is the actual value the whole workspace exists to deliver — an
|
||||
agent resolving a real customer problem. Every backend capability from tickets through problem
|
||||
resolution has no user-facing path without this.
|
||||
|
||||
**Independent Test**: Open an assigned ticket already in `HUMAN_ESCALATION`/`IN_PROGRESS`; post
|
||||
a message; record an investigation, root cause, solution, implementation, and successful
|
||||
verification; record a resolution; confirm the ticket reaches `RESOLUTION_PENDING_CUSTOMER` and
|
||||
every record is visible on the same screen afterward.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket's detail view, **When** an agent posts a customer-visible message,
|
||||
**Then** it appears in the thread in the correct visibility styling, distinct from an
|
||||
internal note.
|
||||
2. **Given** a ticket with no investigation on file yet, **When** the agent tries to record a
|
||||
root cause first, **Then** the UI reflects the backend's own rejection (409) with a clear
|
||||
message, never a silent failure or a client-side guess that lets the action appear to
|
||||
succeed.
|
||||
3. **Given** a ticket whose problem now has a successfully verified solution, **When** the
|
||||
agent records a resolution, **Then** the ticket's displayed status updates to
|
||||
"Pending Customer Confirmation" without a manual page reload.
|
||||
4. **Given** a solution verification recorded as failed, **When** the agent chooses to
|
||||
escalate rather than re-investigate, **Then** the ticket's status and assignment update to
|
||||
reflect the automatic re-assignment supporthub-api's orchestration already performs.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An admin sets up the support organization (Priority: P1)
|
||||
|
||||
An admin creates teams, adds agents to them, assigns agent skills, and builds the support
|
||||
hierarchy (nodes, scope, required skills, assignment strategy) that orchestration resolves
|
||||
against — all from configuration screens, never a database script.
|
||||
|
||||
**Why this priority**: Nothing can be assigned or routed — and therefore User Stories 1-2 have
|
||||
nothing to show — until a support organization actually exists. This is the necessary
|
||||
precondition for the entire agent workspace to have real data.
|
||||
|
||||
**Independent Test**: Create a team, add an agent to it, grant the agent a skill, create a
|
||||
hierarchy node scoped to a product with that skill required; confirm the same data is visible
|
||||
and editable from the admin screens afterward, matching exactly what the backend's own read
|
||||
endpoints return.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the teams admin screen, **When** an admin creates a team and adds an agent,
|
||||
**Then** both are immediately visible in the roster.
|
||||
2. **Given** the hierarchy admin screen, **When** an admin creates a node with a product scope,
|
||||
required skills, and an assignment strategy, **Then** it's retrievable exactly as configured
|
||||
and appears in the hierarchy tree in its correct position.
|
||||
3. **Given** an attempt to create a hierarchy node that would make itself its own ancestor,
|
||||
**When** it's submitted, **Then** the UI surfaces the backend's own cycle-detection
|
||||
rejection clearly, not a generic error.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - An admin configures SLA policies and business calendars (Priority: P2)
|
||||
|
||||
An admin defines business calendars (working hours, holidays) and SLA policies (response/
|
||||
resolution time limits, scoped to product/category/priority, referencing a calendar) from
|
||||
configuration screens.
|
||||
|
||||
**Why this priority**: Depends on User Story 3 existing (a hierarchy/product context to scope
|
||||
policies against). SLA visibility (User Story 6) and the whole point of Phase 8's backend work
|
||||
have no admin-facing configuration path without this.
|
||||
|
||||
**Independent Test**: Create a business calendar with working hours and a holiday, then an SLA
|
||||
policy referencing it scoped to a specific product; confirm both are retrievable and editable
|
||||
exactly as configured.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the business calendar screen, **When** an admin defines working hours per weekday
|
||||
and adds a holiday, **Then** both are saved and displayed back exactly as entered.
|
||||
2. **Given** the SLA policy screen, **When** an admin creates a policy scoped to a product with
|
||||
a calendar reference, **Then** it appears in the policy list with its scope and time limits
|
||||
visible at a glance.
|
||||
3. **Given** two policies that could both apply to the same context, **When** viewing either in
|
||||
the list, **Then** the UI indicates which one is more specific (mirrors the backend's own
|
||||
most-specific-match resolution), so an admin isn't left guessing which one actually applies.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - An admin configures escalation policies and rules (Priority: P2)
|
||||
|
||||
An admin defines escalation policies and rules — trigger type, target hierarchy node, and
|
||||
notification target — from configuration screens, and can manually escalate a specific ticket
|
||||
to a specific node when needed.
|
||||
|
||||
**Why this priority**: Depends on User Story 3 (a hierarchy to target) and pairs with User
|
||||
Story 4 (the SLA breaches that trigger escalation). Without this, escalation stays entirely
|
||||
invisible and unconfigurable outside direct API calls.
|
||||
|
||||
**Independent Test**: Create an escalation policy and a rule for `resolution_breach` targeting
|
||||
a specific node; confirm it's retrievable and editable, and that manually escalating a ticket
|
||||
from its detail view (User Story 2) creates a visible escalation event.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the escalation policy screen, **When** an admin creates a rule with a trigger type
|
||||
and target node, **Then** it appears in the rule list with both clearly shown.
|
||||
2. **Given** a ticket's detail view, **When** an agent manually escalates it to a specific node
|
||||
with a reason, **Then** the ticket's assignment updates and the escalation appears in that
|
||||
ticket's own history.
|
||||
3. **Given** a manual escalation targeting a node that doesn't exist (a stale reference), **When**
|
||||
it's attempted, **Then** the UI surfaces the rejection clearly rather than showing a false
|
||||
success.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Agents and admins see SLA and escalation status at a glance (Priority: P2)
|
||||
|
||||
A dedicated view shows every ticket's SLA status (running/paused/warning/breached) and a
|
||||
separate view shows recent escalation events — both filterable, both reflecting live backend
|
||||
state rather than a snapshot that goes stale.
|
||||
|
||||
**Why this priority**: Depends on User Stories 4-5 existing (something to actually monitor).
|
||||
This is what makes SLA/escalation a managed, visible commitment rather than something only
|
||||
ever inspected ticket-by-ticket.
|
||||
|
||||
**Independent Test**: With a mix of running, paused, and breached SLA runs across several
|
||||
tickets, confirm the SLA monitor view correctly groups/filters by status and each entry links
|
||||
to its ticket.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets with SLA runs in different states, **When** viewing the SLA monitor,
|
||||
**Then** each is shown with its correct status and due/breached time, filterable by status.
|
||||
2. **Given** a recent automatic escalation, **When** viewing the escalation matrix, **Then** it
|
||||
appears with the triggering reason, the rule (if automatic) or actor (if manual), and the
|
||||
resulting target node.
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 - An admin manages the product catalog and knowledge base governance (Priority: P3)
|
||||
|
||||
An admin views and manages products/categories (as already integrated via supporthub-api) and
|
||||
reviews/publishes knowledge base entries (known issues, FAQs, resolution procedures) from
|
||||
configuration screens.
|
||||
|
||||
**Why this priority**: Lower priority than the operational stories above — a product/knowledge
|
||||
catalog changes far less often day-to-day than tickets, SLAs, or escalations, and the backend
|
||||
for both already has a stable read/write surface this can be built against whenever capacity
|
||||
allows.
|
||||
|
||||
**Independent Test**: View the product list with their integration status; open a knowledge
|
||||
entry, change its status (draft/published/unpublished), and confirm the change is reflected
|
||||
immediately.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the products admin screen, **When** an admin views the list, **Then** each
|
||||
product's integration status (active/suspended) is visible.
|
||||
2. **Given** the knowledge governance screen, **When** an admin publishes a draft entry,
|
||||
**Then** its status updates and it becomes visible whichever way the entry's own
|
||||
`categoryScope`/`effectiveDate` rules mean it should be.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if the backend API is unreachable when a screen loads? Every data view MUST show
|
||||
a clear error state distinct from "no data" — never an indefinitely spinning loader or a
|
||||
silently empty list that looks like a legitimate zero-results state.
|
||||
- What happens if two agents have the same ticket's detail view open and one changes its status?
|
||||
The other agent's view MUST reflect the change on its next data refresh — this feature does
|
||||
not attempt real-time push sync (see Assumptions).
|
||||
- What happens when an admin's action is rejected by a business rule the backend enforces (e.g.
|
||||
a cycle in the hierarchy, a solution proposed with no root cause on file)? The UI MUST surface
|
||||
the backend's own rejection reason, never mask it behind a generic "something went wrong."
|
||||
- What happens if an agent's supporthub-api session role doesn't include admin access but they
|
||||
navigate directly to an admin URL? The admin portal MUST NOT render for that identity — this
|
||||
is an enforcement of Principle I/III, not a cosmetic navigation-hiding concern.
|
||||
- What happens if no supporthub-api session exists at all (no token, an expired token, or a
|
||||
token supporthub-api's own `/auth/me` no longer honors) and a support/admin URL is requested?
|
||||
The user MUST be redirected to a sign-in screen rather than the portal rendering with absent
|
||||
or default data.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-000**: The system MUST provide a sign-in screen for agents/admins that authenticates
|
||||
against supporthub-api's own login (010-identity-auth), MUST redirect any unauthenticated
|
||||
request for a `(support)`/`(admin)` URL to it, and MUST sign a user out (clearing the local
|
||||
session and redirecting to sign-in) the moment supporthub-api no longer honors their session.
|
||||
- **FR-001**: The system MUST show an agent a dashboard of tickets currently assigned to them,
|
||||
with customer/product, priority, status, and SLA time-remaining/overdue visible without
|
||||
opening the ticket.
|
||||
- **FR-002**: The system MUST show a ticket's full message history, distinguishing customer-
|
||||
visible messages from internal notes, and MUST let an agent post either.
|
||||
- **FR-003**: The system MUST let an agent record an investigation, root cause, solution,
|
||||
implementation, verification, and resolution against a ticket's problem, in that order,
|
||||
surfacing the backend's own precondition rejections rather than allowing an invalid order
|
||||
client-side.
|
||||
- **FR-004**: The system MUST let an agent transition a ticket's status (including manual
|
||||
escalation to a specific hierarchy node) and reflect the resulting state without a manual
|
||||
page reload.
|
||||
- **FR-005**: The system MUST let an admin create, view, and edit teams, agents, agent skills,
|
||||
and hierarchy nodes (including scope, required skills, and assignment strategy).
|
||||
- **FR-006**: The system MUST let an admin create, view, and edit business calendars (working
|
||||
hours, holidays) and SLA policies (time limits, scope, calendar reference).
|
||||
- **FR-007**: The system MUST let an admin create, view, and edit escalation policies and
|
||||
rules (trigger type, target node, notification target).
|
||||
- **FR-008**: The system MUST provide an SLA status view across tickets, filterable by status,
|
||||
and an escalation history view showing recent automatic and manual escalations.
|
||||
- **FR-009**: The system MUST let an admin view the product catalog (with integration status)
|
||||
and manage knowledge base entry publication status.
|
||||
- **FR-010**: Every screen that calls a backend endpoint MUST distinguish a loading state, an
|
||||
empty-result state, and an error state — never conflating any two of them.
|
||||
- **FR-011**: The admin portal MUST NOT render for an identity whose supporthub-api session role
|
||||
is not `ADMIN`, regardless of what URL is requested directly.
|
||||
- **FR-012**: No screen in this feature MUST compute or override a business decision the
|
||||
backend already owns (ticket transition validity, SLA due dates, most-specific-policy
|
||||
resolution, escalation rule matching) — every such decision is read from, or requested of,
|
||||
the API, never derived independently in the UI.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Sign-In / Session**: The agent/admin's supporthub-api-issued session (010-identity-auth) —
|
||||
the token, the identity it carries, and the redirect-to-sign-in behavior when it's absent or
|
||||
no longer valid.
|
||||
- **Agent Dashboard**: The agent's own view of currently-assigned tickets, summarizing status
|
||||
relevant to deciding what to work on next.
|
||||
- **Ticket Workbench**: The full detail view of one ticket — messages, lifecycle status,
|
||||
problem-resolution records, and available actions.
|
||||
- **Support Organization Editor**: The admin surface over teams, agents, skills, and hierarchy.
|
||||
- **SLA & Calendar Configuration**: The admin surface over business calendars and SLA policies.
|
||||
- **Escalation Configuration**: The admin surface over escalation policies, rules, and the
|
||||
manual-escalation action available from a ticket.
|
||||
- **Monitoring Views**: The SLA status and escalation history views, read-only aggregations
|
||||
across tickets.
|
||||
- **Catalog & Knowledge Governance**: The lower-priority admin surface over products and
|
||||
knowledge base entries.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An agent can go from opening the dashboard to posting a message on an assigned
|
||||
ticket in under 15 seconds of active use (excluding backend response time).
|
||||
- **SC-002**: 100% of business-rule rejections from the backend (409/400 responses) are shown
|
||||
to the user with the backend's own reason, never a generic failure message.
|
||||
- **SC-003**: 100% of screens in this feature correctly distinguish loading, empty, and error
|
||||
states, verified by simulating each condition against a real backend.
|
||||
- **SC-004**: An admin can create a complete, usable support organization (one team, one agent
|
||||
with a skill, one hierarchy node) in under 5 minutes without consulting API documentation.
|
||||
- **SC-005**: 0% of admin-portal routes render their content for a non-admin identity, verified
|
||||
by attempting direct navigation as a customer- or agent-only identity.
|
||||
- **SC-006**: 100% of direct navigations to a `(support)`/`(admin)` URL with no valid session
|
||||
redirect to sign-in, and 0% render the portal with absent or default data instead.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **This feature does not include the `(public)`/`(customer)` portals** — those serve a
|
||||
different audience (end customers) and aren't named in Phase 10's own roadmap line ("Agent
|
||||
workspace... admin configuration surfaces"); they remain future scope for a later feature.
|
||||
- **No real-time push sync (WebSocket) is required for this feature** — every view refetches on
|
||||
a normal interaction cadence (navigation, an explicit refresh action, or a short polling
|
||||
interval where a screen's own staleness would otherwise be misleading, e.g. the SLA monitor).
|
||||
`lib/websocket` is scaffolded in this project but wiring it up is out of scope here;
|
||||
Edge Cases' "two agents, one ticket" scenario is handled by refetch-on-navigation, not push.
|
||||
- **Analytics/reporting dashboards, the compliance audit-log viewer, and platform-wide settings
|
||||
screens are out of scope for this feature** — they're lower-value, lower-frequency admin
|
||||
surfaces than the operational and configuration screens above, and can be their own
|
||||
follow-up feature once this one's scaffold (typed API client, portal layout, admin table/
|
||||
form patterns) exists to build them against quickly.
|
||||
- **AI confidence policy administration (005's `AIConfidencePolicy`) is out of scope for this
|
||||
feature** — it's a narrower, less frequently touched configuration surface than support org/
|
||||
SLA/escalation; deferred to a follow-up rather than expanding this already-large feature
|
||||
further.
|
||||
- **Role/permission enforcement (FR-011) reads the role on the supporthub-api session**
|
||||
(`ADMIN`/`AGENT`, from `POST /auth/login` and `GET /auth/me`) — this feature does not define
|
||||
what roles exist or how they're granted (Principle I, supporthub-api's own 010-identity-auth);
|
||||
it only gates rendering on whatever role value that session already carries.
|
||||
- **Account provisioning (who gets an agent/admin account, and with what role) is out of
|
||||
scope** — supporthub-api's 010-identity-auth already provides `POST /admin/users` for an
|
||||
admin to provision new accounts; this feature's own admin screens may expose it later, but
|
||||
User Story 0 only needs to consume an existing account's login, not manage account creation.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
description: "Task list for 001-agent-admin-ui, continuation: User Stories 4-7 (P2/P3)"
|
||||
---
|
||||
|
||||
# Tasks: Agent and Admin UI — User Stories 4-7
|
||||
|
||||
**Input**: Design documents from `specs/001-agent-admin-ui/` (plan.md, spec.md, research.md,
|
||||
data-model.md — extended with US4-7 entities, contracts/api-client-contract.md — extended with
|
||||
US4-7 functions, quickstart.md)
|
||||
|
||||
**Prerequisites**: `tasks.md` (Setup + Foundational + User Stories 0-3) already complete. This
|
||||
continuation needs no new Foundational work — every US4-7 story follows the identical `lib/api`
|
||||
function → TanStack Query hook → `features/*` component → page pattern already established.
|
||||
|
||||
**Backend dependency**: 012-admin-list-views (supporthub-api) — SLA-run/escalation-event/
|
||||
product-catalog list endpoints, plus its own follow-up commit adding the knowledge-governance
|
||||
list endpoint. Both already merged into this feature's own contracts before these tasks began.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-web/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - SLA policies and business calendars (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4 (see quickstart.md addendum below).
|
||||
|
||||
- [x] T042 [P] [US4] Add `lib/api/sla.ts` (`listSlaPolicies`, `createSlaPolicy`) and
|
||||
`lib/api/calendars.ts` (`listCalendars`, `createCalendar`, `addHoliday`) per
|
||||
contracts/api-client-contract.md
|
||||
- [x] T043 [US4] Add `lib/api/types/sla.ts` (`SlaPolicy`, `BusinessCalendar`,
|
||||
`BusinessCalendarHoliday` — data-model.md) and export from `lib/api/types/index.ts`
|
||||
- [x] T044 [US4] Add `features/settings/calendar-editor.tsx` (create a calendar with per-weekday
|
||||
working hours + add a holiday, US4 scenario 1) (depends on T042, T043)
|
||||
- [x] T045 [US4] Add `features/settings/sla-policy-editor.tsx` (create a policy scoped to a
|
||||
product/category/priority with a calendar reference; each row in the list badges its own
|
||||
specificity — which optional scope fields are set — per contracts/api-client-
|
||||
contract.md's own note, US4 scenarios 2-3) (depends on T042, T043)
|
||||
- [x] T046 [US4] Replace the placeholder `src/app/(admin)/admin/sla-policies/page.tsx` composing
|
||||
T044+T045 (both calendars and SLA policies live here — Key Entities' own "SLA & Calendar
|
||||
Configuration" grouping) (depends on T044, T045)
|
||||
- [x] T047 [US4] Integration test (mocked `lib/api/sla.ts`/`lib/api/calendars.ts`) covering
|
||||
Quickstart Scenario 4 in `tests/integration/settings/sla-calendar-admin.test.tsx`
|
||||
- [x] T048 [US4] Run Quickstart Scenario 4 locally against a real supporthub-api and confirm it
|
||||
passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 5 - Escalation policies and rules (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 5.
|
||||
|
||||
- [x] T049 [P] [US5] Add `lib/api/escalation.ts` (`listEscalationPolicies`,
|
||||
`createEscalationPolicy`, `createEscalationRule`) per contracts/api-client-contract.md
|
||||
- [x] T050 [US5] Add `lib/api/types/escalation.ts` (`EscalationPolicy`, `EscalationRule`,
|
||||
`ESCALATION_TRIGGER_TYPES` — data-model.md) and export from `lib/api/types/index.ts`
|
||||
- [x] T051 [US5] Add `features/escalation/escalation-policy-editor.tsx` (create a policy, add a
|
||||
rule with trigger type + target node, both clearly shown in the list, US5 scenario 1)
|
||||
(depends on T049, T050)
|
||||
- [x] T052 [US5] Replace the placeholder `src/app/(admin)/admin/escalation-policies/page.tsx`
|
||||
with T051 (depends on T051)
|
||||
- [x] T053 [US5] Integration test (mocked `lib/api/escalation.ts`) covering Quickstart Scenario
|
||||
5's policy/rule creation, plus a stale-`targetNodeId` manual escalation from the ticket
|
||||
workbench (already-built `TicketHeader`, US5 scenario 3) rendering the backend's own
|
||||
rejection, in `tests/integration/escalation/escalation-admin.test.tsx`
|
||||
- [x] T054 [US5] Run Quickstart Scenario 5 locally against a real supporthub-api and confirm it
|
||||
passes, including a manual escalation from a real ticket appearing in that ticket's own
|
||||
history (scenario 2)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 6 - SLA and escalation monitoring views (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 6.
|
||||
|
||||
- [x] T055 [P] [US6] Add `lib/api/monitoring.ts` (`listSlaRuns`, `listEscalationEvents`) per
|
||||
contracts/api-client-contract.md (012-admin-list-views)
|
||||
- [x] T056 [US6] Add `features/reports/sla-monitor.tsx` (every SLA run, filterable by status,
|
||||
each row linking to its ticket via `ticketCode`/`ticketId`, US6 scenario 1) (depends on
|
||||
T055)
|
||||
- [x] T057 [US6] Add `features/reports/escalation-matrix.tsx` (recent escalation events, each
|
||||
showing reason + rule-or-actor + target node, US6 scenario 2) (depends on T055)
|
||||
- [x] T058 [US6] Replace the placeholder `src/app/(admin)/admin/reports/page.tsx` composing
|
||||
T056+T057 (depends on T056, T057)
|
||||
- [x] T059 [US6] Integration test (mocked `lib/api/monitoring.ts`) covering Quickstart Scenario
|
||||
6 (status filter, automatic-vs-manual distinction) in
|
||||
`tests/integration/reports/monitoring-views.test.tsx`
|
||||
- [x] T060 [US6] Run Quickstart Scenario 6 locally against a real supporthub-api and confirm it
|
||||
passes, using real SLA runs/escalation events from earlier stories' own test data
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 7 - Product catalog and knowledge base governance (Priority: P3)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 7.
|
||||
|
||||
- [x] T061 [P] [US7] Add `lib/api/catalog.ts` (`listProductCatalog`) and `lib/api/knowledge.ts`
|
||||
(`listKnowledgeEntries`, `publishKnowledgeEntry`, `unpublishKnowledgeEntry`) per
|
||||
contracts/api-client-contract.md
|
||||
- [x] T062 [US7] Add `lib/api/types/catalog.ts`/`lib/api/types/knowledge.ts` (data-model.md) and
|
||||
export from `lib/api/types/index.ts`
|
||||
- [x] T063 [US7] Add `features/products/product-catalog.tsx` (each product's integration status
|
||||
visible, US7 scenario 1) (depends on T061, T062)
|
||||
- [x] T064 [US7] Add `features/knowledge/knowledge-governance.tsx` (list entries for a selected
|
||||
product including drafts, publish/unpublish action, status update reflected immediately,
|
||||
US7 scenario 2) (depends on T061, T062)
|
||||
- [x] T065 [US7] Replace the placeholder `src/app/(admin)/admin/products/page.tsx` with T063 and
|
||||
`src/app/(admin)/admin/knowledge/page.tsx` with T064 (depends on T063, T064)
|
||||
- [x] T066 [US7] Integration test (mocked `lib/api/catalog.ts`/`lib/api/knowledge.ts`) covering
|
||||
Quickstart Scenario 7 in `tests/integration/products/catalog-and-knowledge.test.tsx`
|
||||
- [x] T067 [US7] Run Quickstart Scenario 7 locally against a real supporthub-api and confirm it
|
||||
passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Polish & Cross-Cutting Concerns (this continuation's own)
|
||||
|
||||
- [x] T068 [P] Update `specs/001-agent-admin-ui/checklists/requirements.md` Notes with any
|
||||
implementation-time findings from User Stories 4-7
|
||||
- [x] T069 Run `npm run typecheck` and `npm run lint`
|
||||
- [x] T070 Run `npm run test` (all unit + integration) and confirm Setup/US0-3's own tests still
|
||||
pass (no regression from US4-7's additions)
|
||||
- [x] T071 Real end-to-end verification: run the app against a live, locally-running
|
||||
supporthub-api and manually (or via a Playwright addition) confirm all four of this
|
||||
continuation's Quickstart scenarios, matching Setup/US0-3's own established discipline of
|
||||
never claiming a scenario done without exercising it against real infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **User Stories 4-7 (Phases 5-8)**: Each depends only on User Story 0 (a session) — independent
|
||||
of each other and of Phases 5-8's own siblings, parallelizable in any order (matches the
|
||||
original tasks.md's own US1-3 independence)
|
||||
- **Polish (Phase 9)**: Depends on all four
|
||||
|
||||
## Quickstart Addenda (this continuation's own scenarios)
|
||||
|
||||
**Scenario 4 — SLA policies and business calendars**: Create a business calendar with working
|
||||
hours and a holiday; create an SLA policy referencing it, scoped to a product. Confirm both
|
||||
retrievable/editable exactly as configured, and that a second, more general policy is visibly
|
||||
marked less specific than a product-scoped one.
|
||||
|
||||
**Scenario 5 — escalation policies and rules**: Create an escalation policy and a
|
||||
`resolution_breach` rule targeting a specific node. Confirm it's retrievable. From an existing
|
||||
ticket's workbench, manually escalate to that node with a reason; confirm the escalation appears
|
||||
in that ticket's own history. Attempt escalating to a nonexistent node; confirm the rejection is
|
||||
shown clearly.
|
||||
|
||||
**Scenario 6 — SLA and escalation monitoring**: With SLA runs in different states and a recent
|
||||
escalation event, open the SLA monitor and filter by status; open the escalation matrix and
|
||||
confirm the triggering reason/rule-or-actor/target node are all shown.
|
||||
|
||||
**Scenario 7 — catalog and knowledge governance**: View the product list with integration
|
||||
status visible. Open the knowledge governance screen for a product, publish a draft entry,
|
||||
confirm its status updates immediately.
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
description: "Task list for 001-agent-admin-ui (Setup + User Stories 0-3, this plan's immediate scope)"
|
||||
---
|
||||
|
||||
# Tasks: Agent and Admin UI (Setup + User Stories 0-3)
|
||||
|
||||
**Input**: Design documents from `specs/001-agent-admin-ui/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md), [contracts/api-client-contract.md](./contracts/api-client-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Scope note**: Per plan.md's own Technical Context, this task list covers Setup + User Stories
|
||||
0-3 (the P1 MVP) only. User Stories 4-7 (P2/P3) get their own tasks.md continuation once this
|
||||
scope is verified — the identical pattern established here (lib/api function → TanStack Query
|
||||
hook → feature component → page) applies directly.
|
||||
|
||||
**Organization**: Tasks are grouped by user story. US0 blocks every other story (no session, no
|
||||
data). US1-US3 are independent of each other once US0 exists.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-web/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Add `js-cookie` + `@types/js-cookie` and `jose` to `package.json`
|
||||
- [x] T002 [P] Populate `src/lib/env/index.ts` — typed accessor over the existing
|
||||
`NEXT_PUBLIC_*` env vars (zod-validated, matching supporthub-api's own `env.ts` pattern),
|
||||
exporting `env.apiUrl` etc.
|
||||
- [x] T003 [P] Configure `vitest.config.ts` (currently empty) — `environment: 'jsdom'`, path
|
||||
aliases matching `tsconfig.json`, `tests/unit` + `tests/integration` include globs
|
||||
- [x] T004 [P] Configure `playwright.config.ts` (currently empty) — base URL from
|
||||
`NEXT_PUBLIC_APP_URL`, `tests/e2e` test dir
|
||||
- [x] T005 [P] Add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as dev
|
||||
dependencies (needed by T003's `jsdom` environment; not yet present in `package.json`)
|
||||
|
||||
**Checkpoint**: Both test runners actually run (even with zero tests) and `env` is typed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: The typed client layer, session plumbing, and query infrastructure every user
|
||||
story is built on.
|
||||
|
||||
**⚠️ CRITICAL**: No user-story work can begin until this phase is complete.
|
||||
|
||||
- [x] T006 Add `lib/auth/session-cookie.ts` — `getSessionToken()`/`setSessionToken(token)`/
|
||||
`clearSessionToken()` using `js-cookie` (`sh_session`, `Secure` in production,
|
||||
`SameSite=Lax`) (depends on T001)
|
||||
- [x] T007 Add `lib/api/client.ts` — axios instance (`baseURL: env.apiUrl`), a request
|
||||
interceptor attaching `Authorization: Bearer <token>` from T006, a response interceptor
|
||||
throwing a typed `ApiError` (data-model.md) and clearing the session + redirecting to
|
||||
`/sign-in` on `401` (depends on T002, T006)
|
||||
- [x] T008 [P] Add `lib/api/types/` — `Session`, `ApiError`, `AssignedTicketSummary` (011's
|
||||
contract), and the ticket/message/problem-resolution/team/agent/hierarchy types
|
||||
data-model.md names, each referencing its source backend contract in a comment
|
||||
- [x] T009 Add `lib/api/auth.ts` — `login`, `getCurrentSession`, `logout` per
|
||||
contracts/api-client-contract.md (depends on T007, T008)
|
||||
- [x] T010 Add `lib/query/query-client.ts` (the shared `QueryClient` instance) and
|
||||
`lib/query/query-state.ts` (research.md's `'loading'|'empty'|'error'|'ready'`
|
||||
discriminated union helper, taking a `UseQueryResult` and an optional
|
||||
`isEmpty(data)` predicate)
|
||||
- [x] T011 Add `providers/query-provider.tsx` (`QueryClientProvider` wrapping T010's client) and
|
||||
`providers/session-provider.tsx` (calls `getCurrentSession` on mount via TanStack Query,
|
||||
exposes `useSession()`; the query's own `onError` for a `401` clears the session and
|
||||
redirects, per contracts/api-client-contract.md's "Session guard contract") (depends on
|
||||
T009, T010)
|
||||
- [x] T012 Wire both providers into `src/app/layout.tsx` (currently the root layout with no
|
||||
providers)
|
||||
- [x] T013 Write `src/middleware.ts` (this project's `src/` layout requires middleware there,
|
||||
not at the repo root — an empty root-level `middleware.ts` had been scaffolded in the
|
||||
wrong place) — no `sh_session` cookie on a `(support)`/`(admin)` path → redirect to
|
||||
`/sign-in?from=<path>`; cookie present but `jose`-decoded `role` isn't `ADMIN` on an
|
||||
`(admin)` path → redirect to `/support/dashboard` (research.md's unverified-decode
|
||||
decision) (depends on T001). Also requires removing `next.config.mjs`'s
|
||||
`output: 'export'` — incompatible with Middleware (discovered by actually running the
|
||||
dev server, not by inspection).
|
||||
|
||||
**Checkpoint**: A session can be established, read, and cleared; every subsequent API call
|
||||
carries it; an unauthenticated or wrongly-roled request never reaches portal content.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 0 - An agent or admin signs in (Priority: P1)
|
||||
|
||||
**Goal**: A real sign-in screen and the redirect/guard behavior FR-000 requires.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 0.
|
||||
|
||||
### Tests for User Story 0
|
||||
|
||||
- [x] T014 [P] [US0] Unit test for `lib/query/query-state.ts`'s discriminated-union logic
|
||||
(loading/empty/error/ready, each input combination) in `tests/unit/lib/query-state.test.ts`
|
||||
- [x] T015 [US0] Integration test (mocked `lib/api/auth.ts`) covering Quickstart Scenario 0
|
||||
steps 2-3 (successful sign-in redirects and stores a session; wrong credentials show one
|
||||
generic error) in `tests/integration/auth/sign-in.test.tsx` (depends on T009)
|
||||
- [x] T016 [US0] Playwright E2E covering Quickstart Scenario 0 end-to-end against a real
|
||||
supporthub-api (redirect-when-unauthenticated, sign-in, role-gated redirect, sign-out) in
|
||||
`tests/e2e/agent-sign-in-and-resolve.spec.ts` — this is also Constitution Principle VII's
|
||||
journey (B), continued by User Story 2's own steps once that story is built
|
||||
|
||||
### Implementation for User Story 0
|
||||
|
||||
- [x] T017 [US0] Add `features/auth/sign-in-form.tsx` + `features/auth/use-login.ts` (a
|
||||
TanStack `useMutation` wrapping `lib/api/auth.ts`'s `login`, redirecting to a
|
||||
role-appropriate landing page on success) (depends on T009, T011)
|
||||
- [x] T018 [US0] Add the `/sign-in` page (`src/app/sign-in/page.tsx`, new — outside every
|
||||
existing route group, since it's neither an admin nor support surface) rendering T017's
|
||||
form
|
||||
- [x] T019 [US0] Add a sign-out action (`features/auth/use-logout.ts`, wrapping
|
||||
`lib/api/auth.ts`'s `logout`) wired into both `(support)/layout.tsx` and
|
||||
`(admin)/layout.tsx`'s existing nav shells (depends on T009, T011)
|
||||
- [x] T020 [US0] Run Quickstart Scenario 0 locally and confirm all 5 steps pass
|
||||
|
||||
**Checkpoint**: A real session exists end-to-end. Every other user story can now consume one.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 - An agent sees and opens their assigned work (Priority: P1)
|
||||
|
||||
**Goal**: The agent dashboard, backed by 011's new endpoint.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T021 [P] [US1] Integration test (mocked `lib/api/tickets.ts`) covering Quickstart
|
||||
Scenario 1's three states (populated list, empty state, list reflects reassignment after
|
||||
refetch) in `tests/integration/tickets/agent-dashboard.test.tsx`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T022 [US1] Add `lib/api/tickets.ts`'s `getMyAssignedTickets` (depends on T007, T008)
|
||||
- [x] T023 [US1] Add `features/tickets/use-my-tickets.ts` (TanStack `useQuery` +
|
||||
`query-state.ts`) and `features/tickets/agent-dashboard.tsx` (the list itself — customer/
|
||||
product/priority/status/SLA time-remaining per row, per FR-001) (depends on T010, T022)
|
||||
- Sub-note: a `404` from `getMyAssignedTickets` (011's "no linked agent" rejection) renders
|
||||
as its own distinct message, not the generic empty state (contracts/api-client-
|
||||
contract.md)
|
||||
- [x] T024 [US1] Replace the placeholder `src/app/(support)/support/dashboard/page.tsx` with
|
||||
T023's component (depends on T023)
|
||||
- [x] T025 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: An agent has a real, live dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 2 - An agent works a ticket end-to-end (Priority: P1)
|
||||
|
||||
**Goal**: The ticket workbench — messages, status transitions, the full problem-resolution
|
||||
workflow — surfacing every backend rejection verbatim.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T026 [P] [US2] Integration test (mocked `lib/api/tickets.ts` + `lib/api/problems.ts`)
|
||||
covering Quickstart Scenario 2's three steps, including the `409` precondition-rejection
|
||||
case rendered verbatim, in `tests/integration/tickets/workbench.test.tsx`
|
||||
- [x] T027 [US2] Extend `tests/e2e/agent-sign-in-and-resolve.spec.ts` (T016) with the full
|
||||
investigation→root-cause→solution→implementation→verification→resolution flow against a
|
||||
real supporthub-api ticket already in `HUMAN_ESCALATION`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T028 [US2] Add `lib/api/tickets.ts`'s `getTicket`/`getTicketMessages`/`postMessage`/
|
||||
`updateTicketStatus`/`escalateTicket` and `lib/api/problems.ts`'s full set (per
|
||||
contracts/api-client-contract.md) (depends on T007, T008)
|
||||
- [x] T029 [US2] Add `features/tickets/ticket-header.tsx` (status, priority, SLA, escalate
|
||||
action) and `features/tickets/message-thread.tsx` (customer/AI/agent messages + internal
|
||||
notes, visually distinct, per FR-002) (depends on T028)
|
||||
- [x] T030 [US2] Add `features/problems/investigation-form.tsx` through
|
||||
`features/problems/resolution-form.tsx` (one per stage, FR-003) — each renders its own
|
||||
`409` precondition rejection from `ApiError` verbatim, never a client-side pre-check of
|
||||
"is there an investigation on file yet" (depends on T028)
|
||||
- [x] T031 [US2] Replace the placeholder
|
||||
`src/app/(support)/support/agent-tickets/[ticketId]/page.tsx` composing T029-T030
|
||||
(depends on T029, T030)
|
||||
- [x] T032 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: An agent can resolve a real customer problem end-to-end from the UI alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 3 - An admin sets up the support organization (Priority: P1)
|
||||
|
||||
**Goal**: Teams/agents/skills/hierarchy admin screens, replacing every placeholder under
|
||||
`(admin)/admin/{teams,agents,hierarchy}`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T033 [P] [US3] Integration test (mocked `lib/api/teams.ts` + `lib/api/hierarchy.ts`)
|
||||
covering Quickstart Scenario 3's three steps, including the cycle-detection rejection
|
||||
rendered verbatim, in `tests/integration/teams/support-org-admin.test.tsx`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T034 [US3] Add `lib/api/teams.ts` and `lib/api/hierarchy.ts` per
|
||||
contracts/api-client-contract.md, including 011's `linkAgentAccount` (depends on T007, T008)
|
||||
- [x] T035 [US3] Add `features/teams/team-roster.tsx` (create team, add agent, upsert skill,
|
||||
link an agent's account per FR-005) (depends on T034)
|
||||
- [x] T036 [US3] Add `features/orchestration/hierarchy-editor.tsx` (create/view a node with
|
||||
scope/skills/strategy, rendered as a tree by `parentId`, per FR-005) (depends on T034)
|
||||
- [x] T037 [US3] Replace the placeholder `src/app/(admin)/admin/teams/page.tsx` and
|
||||
`src/app/(admin)/admin/hierarchy/page.tsx` with T035/T036 (depends on T035, T036)
|
||||
- [x] T038 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: All four Phase 3-6 stories (US0-US3, this plan's full immediate scope) work
|
||||
independently and together — this is the feature's P1 MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns (this scope's own)
|
||||
|
||||
- [x] T039 [P] Update `specs/001-agent-admin-ui/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T040 Run `npm run typecheck` and `npm run lint`
|
||||
- [x] T041 Run `npm run test` (all unit + integration) and `npm run test:e2e` (both Playwright
|
||||
journeys) against a real, locally-running supporthub-api
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies
|
||||
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS every user story
|
||||
- **User Story 0 (Phase 3)**: Depends on Foundational — BLOCKS User Stories 1-3 (no session, no
|
||||
data)
|
||||
- **User Stories 1-3 (Phases 4-6)**: Each depends on US0 only — independent of each other,
|
||||
parallelizable once US0 is done
|
||||
- **Polish (Phase 7)**: Depends on all of Phases 3-6
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T005 (Setup, all independent)
|
||||
- T008 (types) alongside T006-T007 (client plumbing) once T002 exists
|
||||
- Once US0 (Phase 3) is complete: Phases 4, 5, and 6 can proceed in parallel — they touch
|
||||
disjoint `features/*` subdirectories and disjoint page files
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 0 alone is not shippable — it has no destination)
|
||||
|
||||
1. Setup + Foundational (T001-T013)
|
||||
2. User Story 0 (T014-T020) → a real session exists
|
||||
3. **STOP and VALIDATE**: Quickstart Scenario 0 passes end-to-end against a real
|
||||
supporthub-api, including the redirect-on-401 path (contracts/api-client-contract.md's
|
||||
"Session guard contract" — this is the one behavior that can't be verified by a mocked-API
|
||||
integration test alone).
|
||||
4. User Stories 1-3 (T021-T038), in any order or in parallel — this plan's full P1 MVP
|
||||
5. Polish (T039-T041)
|
||||
|
||||
### Continuing past this plan's scope
|
||||
|
||||
User Stories 4-7 (SLA/calendar config, escalation config, monitoring views, catalog/knowledge
|
||||
governance) follow the identical `lib/api` function → TanStack Query hook → `features/*`
|
||||
component → page pattern established above, against their own backend contracts
|
||||
(`specs/008-sla-escalation`, `specs/004-product-knowledge` in supporthub-api) — deferred to a
|
||||
tasks.md continuation once this scope is verified, per plan.md's own Technical Context.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Specification Quality Checklist: Reporting and Analytics Dashboards UI
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Backend counterpart (`supporthub-api`'s 015-reporting-dashboards) is already complete and
|
||||
committed — this feature is presentation-only, per the same backend-first pattern already
|
||||
used for every prior feature spanning both repos this session.
|
||||
- The user asked explicitly for "production-level design" — this feature's plan.md accordingly
|
||||
applies this project's `dataviz` skill methodology (form selection before color, status colors
|
||||
reserved for status-flavored distributions, sequential single-hue for magnitude rankings,
|
||||
validated against both light and dark mode) rather than an ad hoc visual treatment.
|
||||
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
|
||||
required.
|
||||
|
||||
## Implementation-time findings
|
||||
|
||||
- **`dataviz` palette validation caught a real dark-mode gap in this app's existing status
|
||||
colors.** Running `scripts/validate_palette.js` against the Badge component's own
|
||||
emerald-500/amber-500/`--destructive` convention (chosen over the raw `--warning`/`--success`
|
||||
CSS tokens, which fail contrast outright in light mode) showed the convention passes in light
|
||||
mode but fails two checks in dark mode: warning/success CVD separation is 5.5, under the 6
|
||||
floor, and destructive's dark value (#7f1d1d) has a contrast ratio of only 1.74 against the
|
||||
dark surface. Rather than reworking this app's established status-color tokens (out of this
|
||||
feature's scope), every new status-flavored component (`StatusDistribution`) applies the
|
||||
skill's own prescribed mitigation for a borderline palette: identity never rides on color
|
||||
alone — each segment pairs its bar color with a distinct icon shape (`CheckCircle2` /
|
||||
`AlertTriangle` / `XCircle`) and a visible text label, in both the legend and the segment
|
||||
itself. This satisfies the CVD floor-band's secondary-encoding requirement and the contrast
|
||||
WARN's "visible labels required" mitigation.
|
||||
- **A real backend bug was found and fixed via manual verification against real seeded data**,
|
||||
not by any automated test: `ManagementRepository.countEverEscalatedToHuman` and
|
||||
`ProductReportRepository.countEverEscalatedToHuman` (015-reporting-dashboards) checked a list
|
||||
of terminal statuses that, per `ticket-state-machine.ts`'s own transition table, is reachable
|
||||
from BOTH the AI-resolved path and the human-escalation path once they converge on shared
|
||||
terminal statuses (`RESOLUTION_PENDING_CUSTOMER`/`RESOLVED`/`CLOSED`/`REOPENED`) — so every
|
||||
AI-resolved ticket was being double-counted as human-escalated too (confirmed live: 34/34
|
||||
tickets, 100%, against real dev data). Fixed by keying off `assignments: { some: {} }` instead,
|
||||
since `orchestrationService.handleHumanEscalation` is the only code path that ever creates an
|
||||
`Assignment` row. This is why this feature's own frontend verification session is the reason
|
||||
015's own dashboards now report correct figures.
|
||||
- **The `Tabs` primitive's mobile layout had a real overflow bug**, found only by taking an
|
||||
actual screenshot (not just an accessibility-tree read) at a 390px viewport: `TabsList` centered
|
||||
its six tabs with no scroll affordance, so the active tab was clipped off-screen on both edges
|
||||
instead of being scrollable into view. Fixed in `components/ui/tabs.tsx` by making the list
|
||||
horizontally scrollable on narrow viewports (`overflow-x-auto`, left-aligned, `shrink-0`
|
||||
triggers) while keeping the existing centered/inline layout at `sm:` and above.
|
||||
- Per-agent workload in the Support dashboard displays the raw `agentId` (no name-lookup
|
||||
endpoint exists yet) — accepted as data-model.md's own documented fallback rather than adding a
|
||||
new endpoint or an N+1 lookup out of this feature's scope.
|
||||
- The full Playwright E2E suite, run serially against the real dev backend, intermittently trips
|
||||
013-auth-hardening's real login rate limiter (`LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5` per
|
||||
`LOGIN_RATE_LIMIT_WINDOW_SECONDS=300`) because every spec file shares the same
|
||||
`admin@supporthub.internal` test account and each does its own real login. This is confirmed
|
||||
pre-existing: the same failures occur running the suite with this feature's new
|
||||
`reports-dashboards.spec.ts` entirely excluded. This feature's own E2E spec passes 3/3 cleanly
|
||||
in isolation; the shared-account rate-limit interaction is a systemic property of the existing
|
||||
E2E suite design, not a regression introduced here.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Data Model: Reporting and Analytics Dashboards UI
|
||||
|
||||
Response shapes mirror `supporthub-api`'s `specs/015-reporting-dashboards/data-model.md`
|
||||
exactly — reproduced here as the frontend's own typed contract (Constitution Principle IV),
|
||||
plus the one derived view-model type each dashboard actually renders.
|
||||
|
||||
## API Response Types (`lib/api/types/reports.ts`)
|
||||
|
||||
```ts
|
||||
export interface DateRangeDTO { from: string; to: string }
|
||||
|
||||
export interface ManagementDashboardDTO {
|
||||
range: DateRangeDTO;
|
||||
totalCases: number;
|
||||
aiResolved: number;
|
||||
humanEscalated: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
slaCompliance: { met: number; breached: number; rate: number | null };
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface ProductDashboardDTO {
|
||||
productId: string;
|
||||
range: DateRangeDTO;
|
||||
supportVolume: number;
|
||||
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
|
||||
recurringProblems: Array<{ categoryId: string | null; count: number }>;
|
||||
aiResolutionRate: number | null;
|
||||
humanEscalationRate: number | null;
|
||||
topErrors: Array<{ code: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface SupportDashboardDTO {
|
||||
generatedAt: string;
|
||||
range: DateRangeDTO;
|
||||
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
|
||||
slaAtRisk: number;
|
||||
slaBreached: number;
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface AiDashboardDTO {
|
||||
range: DateRangeDTO;
|
||||
totalSessions: number;
|
||||
aiResolutionRate: number | null;
|
||||
humanHandoffRate: number | null;
|
||||
failedTroubleshootingEscalationRate: number | null;
|
||||
knowledgeMatchRate: number | null;
|
||||
confidenceDistribution: { proceed: number; ask: number; escalate: number };
|
||||
toolInvocations: { success: number; failed: number };
|
||||
}
|
||||
```
|
||||
|
||||
## Display Helpers (`lib/format/duration.ts`, inline in each dashboard)
|
||||
|
||||
- **`formatDurationSeconds(value: number | null): string`** — `null` -> `"No data"`; otherwise a
|
||||
compact human duration (`"2h 15m"`, `"45s"`) — never raw seconds.
|
||||
- **`formatRate(value: number | null): string`** — `null` -> `"No data"`; otherwise a percentage,
|
||||
one decimal place (`"62.5%"`).
|
||||
- **`formatCount(value: number): string`** — thousands-comma'd (dataviz `marks-and-anatomy.md`'s
|
||||
own labeling convention), no special-casing needed since a count is never `null`.
|
||||
|
||||
## Ranked List View-Model
|
||||
|
||||
Every ranked list (`problemsByCategory`, `recurringProblems`, `topErrors`, `workloadByAgent`)
|
||||
renders through one shared `RankedBarList` primitive taking:
|
||||
|
||||
```ts
|
||||
interface RankedBarListItem {
|
||||
label: string; // categoryId, error code, or agentId — resolved to a display label upstream
|
||||
// where a friendlier name exists, falling back to the raw id otherwise
|
||||
value: number;
|
||||
}
|
||||
```
|
||||
|
||||
Bar width is `value / max(values)` — proportional within the list shown, not against some
|
||||
absolute scale, per `dataviz` choosing-a-form.md's "compare magnitude" guidance for a bounded
|
||||
top-N list.
|
||||
|
||||
## Status Distribution View-Model
|
||||
|
||||
Confidence distribution and tool invocations both render through one shared
|
||||
`StatusDistribution` primitive:
|
||||
|
||||
```ts
|
||||
interface StatusDistributionSegment {
|
||||
label: string; // "Proceed" | "Ask" | "Escalate" | "Success" | "Failed"
|
||||
value: number;
|
||||
tone: 'success' | 'warning' | 'destructive';
|
||||
}
|
||||
```
|
||||
|
||||
Mapping (fixed, never inferred from label text):
|
||||
|
||||
| Source | proceed/success | ask | escalate/failed |
|
||||
|---|---|---|---|
|
||||
| `confidenceDistribution` | `proceed` -> success | `ask` -> warning | `escalate` -> destructive |
|
||||
| `toolInvocations` | `success` -> success | — (two-segment) | `failed` -> destructive |
|
||||
| `slaCompliance` | `met` -> success | — (two-segment) | `breached` -> destructive |
|
||||
@@ -0,0 +1,124 @@
|
||||
# Implementation Plan: Reporting and Analytics Dashboards UI
|
||||
|
||||
**Branch**: `002-reporting-dashboards-ui` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/002-reporting-dashboards-ui/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Presents `supporthub-api`'s already-complete 015-reporting-dashboards contract (four `GET
|
||||
/admin/reports/*` endpoints) as a tabbed admin surface, replacing nothing existing —
|
||||
`admin/reports/page.tsx` already hosts the SLA Monitor and Escalation Matrix (012-admin-list-
|
||||
views work); this feature adds Management/Product/Support/AI as four more tabs on that same
|
||||
page. New reusable dashboard primitives (stat tile, ranked bar list, status distribution bar,
|
||||
meter) are built once in `components/ui` and reused across all four dashboards, following the
|
||||
`dataviz` skill's methodology: pick the form before color, status colors (already defined in
|
||||
this project's own design tokens) for status-flavored splits, a single sequential hue for
|
||||
magnitude rankings, validated in both light and dark mode.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript, Next.js 14 App Router (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — TanStack Query (already the standard), the existing
|
||||
`components/ui` kit, this project's own Tailwind design tokens. No charting library added
|
||||
(spec.md Assumptions) — every visual is plain HTML/CSS per the `dataviz` skill's Tier 0/1
|
||||
component guidance (a proportional div-based bar needs no SVG/canvas dependency).
|
||||
|
||||
**Storage**: N/A — no client-side persistence; date-range selection is component state
|
||||
(spec.md Assumptions).
|
||||
|
||||
**Testing**: Vitest for the new pure helpers (duration/rate formatting, the "no data" guard);
|
||||
component tests for each dashboard's loading/empty/error/ready rendering (mocked query client,
|
||||
matching 001-agent-admin-ui's own established testing pattern); Playwright E2E against the real
|
||||
running `supporthub-api` + `supporthub-web` pair for the full tab-switching, date-range, and
|
||||
product-selection flow — this session's standing rule of never claiming a frontend scenario done
|
||||
without exercising it against real, live infrastructure.
|
||||
|
||||
**Target Platform**: Web, `(admin)` portal only (Constitution Principle III).
|
||||
|
||||
**Project Type**: Frontend — single Next.js app, no new module boundary crossed.
|
||||
|
||||
**Performance Goals**: Each dashboard fetches independently (its own `useQuery`) so switching
|
||||
tabs doesn't block on data the current tab doesn't need; date-range changes debounce-free (a
|
||||
single explicit "Apply" action, not a fetch per keystroke) to avoid hammering the backend while
|
||||
typing a date.
|
||||
|
||||
**Constraints**: FR-008 — no new/changed backend endpoint; this is presentation-only. FR-004 —
|
||||
every `null` rate/average renders as an explicit "No data" treatment, computed once in the
|
||||
mapping layer (`lib/api/reports.ts`) so every consuming component gets an already-safe shape,
|
||||
never a raw `null` a component might accidentally interpolate into text.
|
||||
|
||||
**Scale/Scope**: One new `lib/api/reports.ts` (+types), four new `features/reports/*-dashboard.tsx`
|
||||
components, four new `components/ui` primitives (stat tile, ranked bar list, status distribution
|
||||
bar, meter), one modified `admin/reports/page.tsx` (adds tabs), one shared date-range control.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. Each Identity Has Exactly One Authority, and the Frontend Is Never It | Not applicable — no identity/access surface touched. | PASS — N/A |
|
||||
| II. The Backend Is the Sole Source of Business Logic | Every figure (rates, distributions, rankings) is rendered exactly as the backend computed it — this feature does no business-rule computation, only display formatting (e.g. seconds -> "2h 15m"). | PASS |
|
||||
| III. Strict Portal Boundaries | All new code lives under `(admin)`/`features/reports` — no customer- or support-portal import, no shared primitive leaks admin-only data (the new `components/ui` primitives are generic value/label/color renderers, not admin-shaped). | PASS |
|
||||
| IV. Typed API Boundary, No Ad Hoc Fetching | All four dashboards fetched through new `lib/api/reports.ts` functions wrapped in TanStack Query hooks — no direct `fetch`/`axios` in any component. | PASS |
|
||||
| V. Configuration Over Hardcoding | Not applicable — no admin-configurable enum rendered by this feature (the confidence bands/status labels are fixed, backend-defined vocabulary, not business policy). | PASS — N/A |
|
||||
| VI. Accessible, Responsive, Enterprise-Grade UI | New primitives keep density over decoration per this principle's own wording — status colors always paired with a text label (never color-alone, satisfying both this principle's WCAG requirement and the `dataviz` skill's own non-negotiable), keyboard-reachable tab/date controls, responsive down to a single column. | PASS |
|
||||
| VII. Testing Gates | Typecheck/lint clean; new Vitest coverage for the mapping/formatting helpers and each dashboard's query-state rendering; Playwright coverage added for the reports tab-switching flow (not one of the constitution's two named cross-cutting journeys, but this project's own established practice of E2E-verifying every admin feature against a real backend, per 001's own precedent). | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-reporting-dashboards-ui/
|
||||
├── plan.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-web/
|
||||
├── src/
|
||||
│ ├── components/ui/
|
||||
│ │ ├── stat-tile.tsx # NEW
|
||||
│ │ ├── ranked-bar-list.tsx # NEW
|
||||
│ │ ├── status-distribution.tsx # NEW
|
||||
│ │ ├── meter.tsx # NEW
|
||||
│ │ └── index.ts # MODIFIED — export the four above
|
||||
│ ├── lib/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── reports.ts # NEW — 4 fetch functions
|
||||
│ │ │ └── types/
|
||||
│ │ │ └── reports.ts # NEW — response + view-model types
|
||||
│ │ └── format/
|
||||
│ │ └── duration.ts # NEW — seconds -> "2h 15m" / "No data"
|
||||
│ ├── features/reports/
|
||||
│ │ ├── management-dashboard.tsx # NEW
|
||||
│ │ ├── product-dashboard.tsx # NEW
|
||||
│ │ ├── support-dashboard.tsx # NEW
|
||||
│ │ ├── ai-dashboard.tsx # NEW
|
||||
│ │ ├── date-range-control.tsx # NEW — shared by Management/Product/AI
|
||||
│ │ ├── sla-monitor.tsx # UNCHANGED (012's own)
|
||||
│ │ └── escalation-matrix.tsx # UNCHANGED (012's own)
|
||||
│ └── app/(admin)/admin/reports/
|
||||
│ └── page.tsx # MODIFIED — adds 4 tabs alongside the 2 existing sections
|
||||
└── tests/
|
||||
├── unit/lib/format/ # duration formatting, no-data guard
|
||||
├── unit/features/reports/ # per-dashboard query-state rendering
|
||||
└── e2e/ # reports tab-switching + date-range Playwright spec
|
||||
```
|
||||
|
||||
**Structure Decision**: No new route, no new portal — extends the existing `(admin)/admin/reports`
|
||||
page and `features/reports` module already established by 012-admin-list-views' SLA Monitor/
|
||||
Escalation Matrix work.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,53 @@
|
||||
# Quickstart: Reporting and Analytics Dashboards UI
|
||||
|
||||
Manual verification against a real, running `supporthub-api` (with real ticket/SLA/AI data —
|
||||
reuse the throwaway Postgres/Redis and admin login already established this session) and a
|
||||
real, running `supporthub-web` dev server, logged in as an admin.
|
||||
|
||||
## Scenario 1 — Management dashboard (User Story 1)
|
||||
|
||||
1. Open Admin -> Reports -> Management tab.
|
||||
2. **Expected**: stat tiles for total cases, AI resolved, human escalated, resolved, open;
|
||||
an SLA compliance meter; escalation count; average response/resolution time — all real
|
||||
numbers, not placeholders.
|
||||
3. Change the date range to one with no activity.
|
||||
4. **Expected**: counts show `0`, rates/averages show "No data," never blank or `NaN`.
|
||||
5. Change the date range back.
|
||||
6. **Expected**: the dashboard re-fetches and every figure updates.
|
||||
|
||||
## Scenario 2 — Product dashboard (User Story 2)
|
||||
|
||||
1. Select Product A in the product picker.
|
||||
2. **Expected**: support volume, problem-category ranked bars, AI/human resolution rates, and
|
||||
top-error ranked bars all reflect Product A only.
|
||||
3. Switch to Product B.
|
||||
4. **Expected**: every figure fully replaces — no leftover Product A data visible mid-transition
|
||||
beyond the loading state.
|
||||
|
||||
## Scenario 3 — Support dashboard (User Story 3)
|
||||
|
||||
1. Open the Support tab.
|
||||
2. **Expected**: a ranked bar list of per-agent workload; SLA at-risk and SLA breached shown as
|
||||
two visually distinct figures; response/resolution performance stat tiles.
|
||||
|
||||
## Scenario 4 — AI dashboard (User Story 4)
|
||||
|
||||
1. Open the AI tab.
|
||||
2. **Expected**: AI resolution rate, human-handoff rate, knowledge-match rate stat tiles; a
|
||||
confidence distribution bar (proceed/ask/escalate, success/warning/destructive-toned, each
|
||||
labeled); a tool success/failure distribution bar, same treatment.
|
||||
|
||||
## Scenario 5 — Responsive and dark mode
|
||||
|
||||
1. Resize the viewport down to a narrow mobile width on each tab.
|
||||
2. **Expected**: stat tiles stack to one column, ranked bars remain fully readable, nothing
|
||||
clips or requires horizontal scroll.
|
||||
3. Toggle dark mode.
|
||||
4. **Expected**: every figure, bar, and status color remains legible and uses this project's
|
||||
existing dark-mode tokens.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All five scenarios pass against a real running backend and frontend pair, verified visually
|
||||
(not just by reading component code) and via the Playwright spec covering tab-switching,
|
||||
date-range changes, and product selection.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Feature Specification: Reporting and Analytics Dashboards UI
|
||||
|
||||
**Feature Branch**: `002-reporting-dashboards-ui`
|
||||
|
||||
**Created**: 2026-09-09
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Reporting and analytics dashboards UI: a production-quality admin surface presenting the four dashboards (Management, Product, Support, AI) supporthub-api's 015-reporting-dashboards feature now exposes — date-range filtering, stat tiles, distributions, and ranked lists, following this project's established loading/empty/error/ready query-state discipline and design system."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Admin sees organization-wide health at a glance (Priority: P1)
|
||||
|
||||
An admin opens the reports area and immediately sees, for a chosen date range, how support is
|
||||
doing overall: total cases, how many were resolved and by whom (AI vs. a human), how many are
|
||||
still open, whether SLA is being met, and average response/resolution time.
|
||||
|
||||
**Why this priority**: This is the dashboard the roadmap's own top-level success criteria map to
|
||||
most directly — it's the first thing anyone opens the reports area to see.
|
||||
|
||||
**Independent Test**: Can be fully tested by loading the Management tab against a backend with
|
||||
known data and confirming every figure on screen matches the API response exactly, with correct
|
||||
loading/empty/error presentation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the backend has ticket/SLA/escalation data for the default period, **When** the
|
||||
admin opens the Management dashboard, **Then** every stat tile and the SLA compliance figure
|
||||
show the real values from `GET /admin/reports/management`, not placeholder text.
|
||||
2. **Given** the admin changes the date range, **When** the new range is applied, **Then** the
|
||||
dashboard re-fetches and every figure updates to match the new range.
|
||||
3. **Given** a rate or average is `null` (no qualifying data), **When** the dashboard renders,
|
||||
**Then** it shows an explicit "No data" treatment, never `NaN`, `undefined`, or a bare `0`
|
||||
that could be misread as a real zero.
|
||||
4. **Given** the backend request fails, **When** the dashboard renders, **Then** it shows the
|
||||
established error state (never a blank screen or a silently stale view).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Admin drills into one product's support health (Priority: P1)
|
||||
|
||||
An admin picks a product and sees that product's own support volume, problem-category
|
||||
breakdown, AI/human resolution split, and most frequent error codes — never another product's
|
||||
data mixed in.
|
||||
|
||||
**Why this priority**: Per-product visibility is as fundamental as the org-wide view for a
|
||||
platform serving multiple SaaS products, and pairs directly with the Management view.
|
||||
|
||||
**Independent Test**: Can be fully tested by selecting two different products against a backend
|
||||
with data for both and confirming each product's own figures show only its own data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a product is selected, **When** the Product dashboard loads, **Then** support
|
||||
volume, the problem-category breakdown, and the AI/human resolution rates all reflect only
|
||||
that product.
|
||||
2. **Given** the admin switches products, **When** the new product's data loads, **Then** the
|
||||
previous product's figures are fully replaced, never blended or stale.
|
||||
3. **Given** a product has no error-code lookups in range, **When** the dashboard renders,
|
||||
**Then** the "top errors" section shows an explicit empty state, not a blank gap.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Admin sees team workload and SLA risk (Priority: P2)
|
||||
|
||||
An admin sees current per-agent workload and which tickets are approaching or past their SLA
|
||||
due date, alongside response/resolution performance for the period.
|
||||
|
||||
**Why this priority**: Operational, day-to-day utility rather than a new class of information —
|
||||
P2 relative to the two org/product-level views above.
|
||||
|
||||
**Independent Test**: Can be fully tested against a backend with known assignment/SLA data and
|
||||
confirming per-agent workload and at-risk/breached counts match exactly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agents have current open assignments, **When** the Support dashboard loads,
|
||||
**Then** each agent's workload is shown, ranked by load.
|
||||
2. **Given** some SLA runs are at risk and others already breached, **When** the dashboard
|
||||
renders, **Then** the two counts are visually distinct, never merged into one figure.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Admin sees how well the AI is performing (Priority: P2)
|
||||
|
||||
An admin sees the AI's resolution rate, human-handoff rate, knowledge-match rate, confidence
|
||||
distribution, and tool success/failure for the period.
|
||||
|
||||
**Why this priority**: Validates the AI-first design's premise for a narrower audience than the
|
||||
org/product views — P2.
|
||||
|
||||
**Independent Test**: Can be fully tested against a backend with known AI session/diagnosis/tool
|
||||
data and confirming every figure matches.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** AI sessions resolved and escalated in the period, **When** the AI dashboard loads,
|
||||
**Then** the resolution/handoff rates and the confidence distribution (proceed/ask/escalate)
|
||||
match the real session data.
|
||||
2. **Given** tool invocations succeeded and failed, **When** the dashboard renders, **Then** the
|
||||
success/failure split is shown with each outcome visually distinct and labeled, never color
|
||||
alone.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens while a request is in flight? A loading state distinct from both "empty" and
|
||||
"error" — the existing `getQueryState` discipline, never a flash of zeroed-out figures.
|
||||
- What happens if the selected date range is invalid (`from` after `to`)? The date-range control
|
||||
itself prevents choosing an invalid range; if the backend still rejects one, the error state
|
||||
shows the backend's own message.
|
||||
- What happens on a narrow viewport? Every dashboard degrades to a single-column layout — stat
|
||||
tiles stack, ranked lists remain fully readable, nothing is clipped or requires horizontal
|
||||
scroll.
|
||||
- What happens in dark mode? Every figure, bar, and status color remains legible and uses this
|
||||
project's existing dark-mode tokens — not a separate, unvalidated color set.
|
||||
- What happens with a very long category/error-code name in a ranked list? It truncates with an
|
||||
accessible full-text affordance (title attribute at minimum), never breaking the layout or
|
||||
overlapping the value.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST present all four dashboards (Management, Product, Support, AI) from
|
||||
`specs/002-reporting-dashboards-ui`'s own admin reports area, each independently loadable.
|
||||
- **FR-002**: The Management, Product, and AI dashboards MUST support a date-range filter
|
||||
(`from`/`to`), re-fetching on change; the Support dashboard's workload/risk figures are
|
||||
current-state (per the backend contract) and are not range-filtered, matching
|
||||
`supporthub-api`'s own `SupportDashboard.generatedAt` framing.
|
||||
- **FR-003**: The Product dashboard MUST require a product to be selected before fetching, and
|
||||
MUST show the backend's own error when an unknown product is requested.
|
||||
- **FR-004**: Every rate/average field that the backend returns as `null` MUST render as an
|
||||
explicit "No data" treatment — never `NaN`, a blank cell, or a `0` indistinguishable from a
|
||||
real zero value.
|
||||
- **FR-005**: Every data view MUST follow the project's established `getQueryState` discipline
|
||||
(loading/empty/error/ready), never conflating any two of those states.
|
||||
- **FR-006**: Ranked lists (problem categories, top errors, agent workload) MUST be sorted
|
||||
descending by the backend's own ordering and MUST visually encode magnitude (not just list the
|
||||
numbers as plain text) per this project's data-visualization standard.
|
||||
- **FR-007**: Status-flavored distributions (AI confidence bands, tool success/failure, SLA
|
||||
met/breached) MUST use this project's existing status colors (success/warning/destructive)
|
||||
consistently with their real-world meaning, MUST include a visible legend/label (never color
|
||||
alone), and MUST remain legible and validated in both light and dark mode.
|
||||
- **FR-008**: This feature MUST NOT introduce any new backend endpoint or change any existing
|
||||
one — it is a pure presentation layer over `supporthub-api`'s already-complete
|
||||
015-reporting-dashboards contract.
|
||||
- **FR-009**: The reports area MUST remain reachable only to an authenticated admin session,
|
||||
consistent with every other admin surface in this application.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Dashboard view-model**: The frontend-side shape each dashboard's API response is mapped into
|
||||
for rendering — never persisted, recomputed on every fetch.
|
||||
- **Date range selection**: UI-local state (`from`/`to`) driving the Management/Product/AI
|
||||
dashboards' queries; not synced to a URL param in this first cut (Assumptions).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An admin can answer "how is support doing," "how is this product doing," "who's
|
||||
overloaded," and "is the AI helping" each within one screen, with no figure requiring a
|
||||
separate lookup to interpret.
|
||||
- **SC-002**: Every figure on every dashboard is independently verifiable against the backend's
|
||||
own response for the same request — no discrepancy, no client-side recomputation that could
|
||||
drift from what the API actually returned.
|
||||
- **SC-003**: The reports area is fully usable — legible, correctly laid out, no clipped or
|
||||
overlapping content — from a narrow mobile viewport up through a large desktop screen, and in
|
||||
both light and dark mode.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Scope is presentation only, per the user's own explicit direction to build the backend first
|
||||
(015-reporting-dashboards, already complete) and the frontend as a distinct follow-on —
|
||||
consistent with every prior feature this session that spanned both repos.
|
||||
- Date-range selection is local component state, not persisted to the URL or local storage in
|
||||
this first cut — a shareable/bookmarkable link to a specific range is a reasonable future
|
||||
enhancement, not required here.
|
||||
- The Product dashboard's product picker reuses the existing product list already available to
|
||||
the admin UI (`GET /admin/products`, 012-admin-list-views) rather than introducing a new
|
||||
lookup endpoint.
|
||||
- "Visually encode magnitude" (FR-006) means a simple proportional bar behind/beside each ranked
|
||||
row — not a full charting library. This project has no charting dependency today, and
|
||||
introducing one for a handful of ranked lists and status splits would be disproportionate to
|
||||
the need (this session's own "don't add complexity beyond what the task requires" standard).
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
description: "Task list for 002-reporting-dashboards-ui"
|
||||
---
|
||||
|
||||
# Tasks: Reporting and Analytics Dashboards UI
|
||||
|
||||
**Input**: Design documents from `specs/002-reporting-dashboards-ui/`
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 Management, US2 = P1 Product,
|
||||
US3 = P2 Support, US4 = P2 AI), sharing one Foundational phase (types, format helpers, the four
|
||||
new `components/ui` primitives, the shared date-range control).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-web/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 [P] Add `lib/api/types/reports.ts` per data-model.md's four DTO interfaces
|
||||
- [x] T002 [P] Add `lib/format/duration.ts` — `formatDurationSeconds`, `formatRate`,
|
||||
`formatCount` (depends on nothing — pure functions)
|
||||
- [x] T003 [P] Add `components/ui/stat-tile.tsx` — label/value/optional-delta contract per
|
||||
`dataviz` marks-and-anatomy.md's "figures" section; sans semibold value, sentence-case
|
||||
label
|
||||
- [x] T004 [P] Add `components/ui/ranked-bar-list.tsx` — takes `RankedBarListItem[]`, renders
|
||||
each as a label + proportional bar (single sequential hue = `primary`) + value, sorted as
|
||||
given (backend already sorts), truncating long labels with a `title` tooltip
|
||||
- [x] T005 [P] Add `components/ui/status-distribution.tsx` — takes
|
||||
`StatusDistributionSegment[]`, renders a segmented bar (2px surface gaps between segments
|
||||
per `dataviz` marks-and-anatomy.md) plus a visible legend row (label + swatch + value for
|
||||
each segment — never color alone)
|
||||
- [x] T006 [P] Add `components/ui/meter.tsx` — a single-ratio track/fill pair (SLA compliance
|
||||
rate), fill tone success/warning/destructive by value, unfilled track a lighter step of
|
||||
the same tone
|
||||
- [x] T007 Export T003-T006 from `components/ui/index.ts`
|
||||
- [x] T008 Add `features/reports/date-range-control.tsx` — two date inputs + an explicit
|
||||
"Apply" action (plan.md: no fetch-per-keystroke), shared by Management/Product/AI
|
||||
- [x] T009 Add `lib/api/reports.ts` — `getManagementDashboard(range)`,
|
||||
`getProductDashboard(externalProductId, range)`, `getSupportDashboard()`,
|
||||
`getAiDashboard(range)`, each a thin typed wrapper over `apiClient` per Constitution
|
||||
Principle IV (depends on T001)
|
||||
|
||||
**Checkpoint**: Primitives and typed client in place. Each dashboard can now be built
|
||||
independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Management dashboard (Priority: P1)
|
||||
|
||||
- [x] T010 [US1] Add `features/reports/management-dashboard.tsx` — `useQuery` +
|
||||
`getQueryState`, stat tiles (T003) for totals, `Meter` (T006) for SLA compliance,
|
||||
`formatDurationSeconds` for the two averages, `DateRangeControl` (T008) wired to
|
||||
`queryKey` (depends on T003, T006, T008, T009)
|
||||
- [x] T011 [US1] Unit tests: `formatDurationSeconds`/`formatRate` null -> "No data" guard, a
|
||||
real-value case each, in `tests/unit/lib/format/duration.test.ts` (depends on T002)
|
||||
- [x] T012 [US1] Component test: Management dashboard renders loading/empty/error/ready
|
||||
correctly against a mocked query client, in
|
||||
`tests/unit/features/reports/management-dashboard.test.tsx` (depends on T010)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes against a real backend.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - Product dashboard (Priority: P1)
|
||||
|
||||
- [x] T013 [US2] Add a product picker reusing the existing `GET /admin/products` list (spec.md
|
||||
Assumptions) — check for an existing products-list hook/component from
|
||||
012-admin-list-views' own admin/products page before adding a new one
|
||||
- [x] T014 [US2] Add `features/reports/product-dashboard.tsx` — `RankedBarList` (T004) for
|
||||
problem categories and top errors, stat tiles for support volume/rates, wired to T013's
|
||||
picker + `DateRangeControl` (depends on T004, T008, T009, T013)
|
||||
- [x] T015 [US2] Component test: switching products fully replaces the rendered figures, an
|
||||
unknown-product error state renders correctly, in
|
||||
`tests/unit/features/reports/product-dashboard.test.tsx` (depends on T014)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes against a real backend.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Support dashboard (Priority: P2)
|
||||
|
||||
- [x] T016 [US3] Add `features/reports/support-dashboard.tsx` — `RankedBarList` (T004) for
|
||||
per-agent workload, stat tiles for at-risk/breached/escalation count and performance
|
||||
averages — no date-range control (current-state, per data-model.md) (depends on T003,
|
||||
T004, T009)
|
||||
- [x] T017 [US3] Component test: at-risk and breached render as visually distinct figures, in
|
||||
`tests/unit/features/reports/support-dashboard.test.tsx` (depends on T016)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes against a real backend.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - AI dashboard (Priority: P2)
|
||||
|
||||
- [x] T018 [US4] Add `features/reports/ai-dashboard.tsx` — stat tiles for the four rates,
|
||||
`StatusDistribution` (T005) for confidence bands and tool invocations, `DateRangeControl`
|
||||
(depends on T003, T005, T008, T009)
|
||||
- [x] T019 [US4] Component test: confidence/tool distributions map to the correct tone per
|
||||
data-model.md's fixed mapping table, in
|
||||
`tests/unit/features/reports/ai-dashboard.test.tsx` (depends on T018)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes against a real backend.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T020 Wire all four dashboards into `app/(admin)/admin/reports/page.tsx` as tabs
|
||||
(`components/ui/tabs`) alongside the existing SLA Monitor/Escalation Matrix sections
|
||||
(depends on T010, T014, T016, T018)
|
||||
- [x] T021 Playwright E2E: tab-switching, date-range change, and product-selection flow against
|
||||
a real running backend + frontend pair, in `tests/e2e/reports-dashboards.spec.ts`
|
||||
(depends on T020)
|
||||
- [x] T022 Manually verify Quickstart Scenario 5 (responsive + dark mode) directly in a browser
|
||||
- [x] T023 Update `specs/002-reporting-dashboards-ui/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T024 `npm run typecheck`/`npm run lint` clean; full existing Vitest + Playwright suite
|
||||
re-run to confirm no regression in 001-agent-admin-ui's own coverage
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS all four user stories
|
||||
- **User Stories 2-5**: Each depends only on Foundational — independent of each other
|
||||
- **Polish (Phase 6)**: Depends on all four user stories
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import { EscalationPolicyEditor } from '@/features/escalation/escalation-policy-editor';
|
||||
|
||||
export default function EscalationPolicyBuilderPage() {
|
||||
export default function EscalationPoliciesPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Escalation Policy Builder</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="p-6">
|
||||
<EscalationPolicyEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import React from 'react';
|
||||
import { HierarchyEditor } from '@/features/orchestration/hierarchy-editor';
|
||||
|
||||
export default function OrganizationalHierarchyPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Organizational Hierarchy</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
</div>
|
||||
);
|
||||
return <HierarchyEditor />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import { KnowledgeGovernance } from '@/features/knowledge/knowledge-governance';
|
||||
|
||||
export default function KnowledgeGovernancePage() {
|
||||
export default function KnowledgeBaseGovernancePage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Knowledge Governance</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="p-6">
|
||||
<KnowledgeGovernance />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import { ProductCatalog } from '@/features/products/product-catalog';
|
||||
|
||||
export default function ProductsCatalogAdminPage() {
|
||||
export default function ProductCatalogPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Products Catalog Admin</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="p-6">
|
||||
<ProductCatalog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
export default function AnalyticsReportsPage() {
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui';
|
||||
import { SlaMonitor } from '@/features/reports/sla-monitor';
|
||||
import { EscalationMatrix } from '@/features/reports/escalation-matrix';
|
||||
import { ManagementDashboard } from '@/features/reports/management-dashboard';
|
||||
import { ProductDashboard } from '@/features/reports/product-dashboard';
|
||||
import { SupportDashboard } from '@/features/reports/support-dashboard';
|
||||
import { AiDashboard } from '@/features/reports/ai-dashboard';
|
||||
|
||||
export default function AnalyticsAndReportsPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Analytics Reports</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Analytics & Reports</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Organization, product, team, and AI performance at a glance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="management">
|
||||
<TabsList>
|
||||
<TabsTrigger value="management">Management</TabsTrigger>
|
||||
<TabsTrigger value="product">Product</TabsTrigger>
|
||||
<TabsTrigger value="support">Support</TabsTrigger>
|
||||
<TabsTrigger value="ai">AI</TabsTrigger>
|
||||
<TabsTrigger value="sla">SLA Monitor</TabsTrigger>
|
||||
<TabsTrigger value="escalations">Escalation Matrix</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="management">
|
||||
<ManagementDashboard />
|
||||
</TabsContent>
|
||||
<TabsContent value="product">
|
||||
<ProductDashboard />
|
||||
</TabsContent>
|
||||
<TabsContent value="support">
|
||||
<SupportDashboard />
|
||||
</TabsContent>
|
||||
<TabsContent value="ai">
|
||||
<AiDashboard />
|
||||
</TabsContent>
|
||||
<TabsContent value="sla">
|
||||
<SlaMonitor />
|
||||
</TabsContent>
|
||||
<TabsContent value="escalations">
|
||||
<EscalationMatrix />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import React from 'react';
|
||||
import { CalendarEditor } from '@/features/settings/calendar-editor';
|
||||
import { SlaPolicyEditor } from '@/features/settings/sla-policy-editor';
|
||||
|
||||
export default function SLAPolicyBuilderPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>SLA Policy Builder</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="p-6 flex flex-col gap-8">
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Business Calendars</h2>
|
||||
<CalendarEditor />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">SLA Policies</h2>
|
||||
<SlaPolicyEditor />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import React from 'react';
|
||||
import { TeamRoster } from '@/features/teams/team-roster';
|
||||
|
||||
export default function TeamsAdminPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Teams Admin</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
</div>
|
||||
);
|
||||
return <TeamRoster />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PortalShell } from '@/features/auth/portal-shell';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="adminlayout">{children}</div>;
|
||||
return <PortalShell title="SupportHub — Admin">{children}</PortalShell>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PortalShell } from '@/features/auth/portal-shell';
|
||||
|
||||
export default function SupportLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="supportlayout">{children}</div>;
|
||||
return <PortalShell title="SupportHub — Agent Workspace">{children}</PortalShell>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ ticketId: 'sample-ticket-id' }];
|
||||
}
|
||||
import { TicketHeader } from '@/features/tickets/ticket-header';
|
||||
import { MessageThread } from '@/features/tickets/message-thread';
|
||||
import { ProblemResolutionPanel } from '@/features/problems/problem-resolution-panel';
|
||||
import { useTicket } from '@/features/tickets/use-ticket';
|
||||
|
||||
export default function AgentTicketDetailPage({ params }: { params: { ticketId: string } }) {
|
||||
const { ticketId } = params;
|
||||
const ticket = useTicket(ticketId);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Agent Ticket Detail</h1>
|
||||
<p>Ticket ID: {params?.ticketId || 'N/A'}</p>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<TicketHeader ticketId={ticketId} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-border">
|
||||
<MessageThread ticketId={ticketId} />
|
||||
{ticket.data && (
|
||||
<ProblemResolutionPanel problemId={ticket.data.problemId} ticketId={ticketId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import React from 'react';
|
||||
import { AgentDashboard } from '@/features/tickets/agent-dashboard';
|
||||
|
||||
export default function SupportAgentDashboardPage() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>Support Agent Dashboard</h1>
|
||||
<p>Module loaded under SupportHub Web.</p>
|
||||
</div>
|
||||
);
|
||||
return <AgentDashboard />;
|
||||
}
|
||||
|
||||
+12
-4
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import '../styles/globals.css';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { SessionProvider } from '@/providers/session-provider';
|
||||
|
||||
export const metadata = {
|
||||
title: 'SupportHub',
|
||||
description: 'Enterprise Support Platform',
|
||||
title: 'SupportHub | Customer Support Platform',
|
||||
description: 'Enterprise AI-powered customer support and ticketing platform for modern teams.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -11,8 +14,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" className="h-full scroll-smooth">
|
||||
<body className="min-h-full flex flex-col font-sans bg-background text-foreground antialiased">
|
||||
<QueryProvider>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SignInForm } from '@/features/auth/sign-in-form';
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm flex flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sign in to SupportHub</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Agent and admin access for the support workspace.
|
||||
</p>
|
||||
</div>
|
||||
<SignInForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './ui';
|
||||
export * from './marketing';
|
||||
export * from './layout';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as React from 'react';
|
||||
import { Headphones, Github, Twitter, Linkedin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Container } from '@/components/marketing/container';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
|
||||
export interface FooterLink {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface FooterColumn {
|
||||
title: string;
|
||||
links: FooterLink[];
|
||||
}
|
||||
|
||||
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
|
||||
brandName?: string;
|
||||
description?: string;
|
||||
columns?: FooterColumn[];
|
||||
copyright?: string;
|
||||
}
|
||||
|
||||
const defaultColumns: FooterColumn[] = [
|
||||
{
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'AI Support Co-Pilot', href: '#co-pilot' },
|
||||
{ label: 'Omnichannel Ticketing', href: '#ticketing' },
|
||||
{ label: 'Knowledge Base', href: '#knowledge-base' },
|
||||
{ label: 'Analytics & SLA', href: '#analytics' },
|
||||
{ label: 'Integrations', href: '#integrations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Solutions',
|
||||
links: [
|
||||
{ label: 'Enterprise SaaS', href: '#enterprise' },
|
||||
{ label: 'E-Commerce', href: '#ecommerce' },
|
||||
{ label: 'FinTech', href: '#fintech' },
|
||||
{ label: 'Startups', href: '#startups' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ label: 'Documentation', href: '#docs' },
|
||||
{ label: 'API Reference', href: '#api' },
|
||||
{ label: 'Guides & Case Studies', href: '#guides' },
|
||||
{ label: 'Community', href: '#community' },
|
||||
{ label: 'Status Page', href: '#status' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About Us', href: '#about' },
|
||||
{ label: 'Careers', href: '#careers' },
|
||||
{ label: 'Blog', href: '#blog' },
|
||||
{ label: 'Press Kit', href: '#press' },
|
||||
{ label: 'Contact Sales', href: '#contact' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{ label: 'Privacy Policy', href: '#privacy' },
|
||||
{ label: 'Terms of Service', href: '#terms' },
|
||||
{ label: 'Security & SOC 2', href: '#security' },
|
||||
{ label: 'GDPR Compliance', href: '#gdpr' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
brandName = 'SupportHub',
|
||||
description = 'The enterprise customer support platform built for modern high-growth SaaS teams.',
|
||||
columns = defaultColumns,
|
||||
copyright = `© ${new Date().getFullYear()} SupportHub, Inc. All rights reserved.`,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<footer
|
||||
ref={ref}
|
||||
className={cn('w-full border-t border-border bg-card/50 text-foreground pt-16 pb-12', className)}
|
||||
{...props}
|
||||
>
|
||||
<Container size="wide">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-6 mb-16">
|
||||
{/* Brand Information Column */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||
<a href="/" className="flex items-center gap-2.5">
|
||||
<div className="h-9 w-9 rounded-xl bg-primary text-primary-foreground flex items-center justify-center font-bold shadow-sm">
|
||||
<Headphones className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||
{brandName}
|
||||
</span>
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<IconButton aria-label="GitHub" variant="ghost" size="sm" asChild>
|
||||
<a href="https://github.com" target="_blank" rel="noopener noreferrer">
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
</IconButton>
|
||||
<IconButton aria-label="Twitter" variant="ghost" size="sm" asChild>
|
||||
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer">
|
||||
<Twitter className="h-4 w-4" />
|
||||
</a>
|
||||
</IconButton>
|
||||
<IconButton aria-label="LinkedIn" variant="ghost" size="sm" asChild>
|
||||
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</a>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Columns */}
|
||||
<div className="lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-8">
|
||||
{columns.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col gap-3">
|
||||
<h4 className="text-xs font-semibold text-foreground uppercase tracking-wider">
|
||||
{col.title}
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{col.links.map((link, linkIdx) => (
|
||||
<li key={linkIdx}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="pt-8 border-t border-border/60 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<p>{copyright}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="success" size="sm" dot>
|
||||
All Systems Operational
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Footer.displayName = 'Footer';
|
||||
|
||||
export { Footer };
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './navbar';
|
||||
export * from './mobile-menu';
|
||||
export * from './footer';
|
||||
export * from './page-container';
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
|
||||
export interface NavLinkItem {
|
||||
label: string;
|
||||
href: string;
|
||||
badge?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface MobileMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
brand: React.ReactNode;
|
||||
navLinks: NavLinkItem[];
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
brand,
|
||||
navLinks,
|
||||
actions,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
{/* Backdrop Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Slide-over Drawer Panel */}
|
||||
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-xs bg-background p-6 shadow-dialog border-l border-border animate-slide-in-right flex flex-col justify-between overflow-y-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div onClick={onClose}>{brand}</div>
|
||||
<IconButton
|
||||
aria-label="Close mobile menu"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex flex-col gap-2">
|
||||
{navLinks.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.href}
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-between py-2.5 px-3 rounded-lg text-base font-semibold text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
{link.badge && (
|
||||
<Badge variant="primary" size="sm">
|
||||
{link.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{actions && (
|
||||
<div className="pt-6 border-t border-border flex flex-col gap-3">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { MobileMenu };
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as React from 'react';
|
||||
import { Menu, Headphones } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Container } from '@/components/marketing/container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MobileMenu, NavLinkItem } from './mobile-menu';
|
||||
|
||||
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
|
||||
brandName?: string;
|
||||
brandLogo?: React.ReactNode;
|
||||
navLinks?: NavLinkItem[];
|
||||
actions?: React.ReactNode;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
const defaultNavLinks: NavLinkItem[] = [
|
||||
{ label: 'Features', href: '#features' },
|
||||
{ label: 'Solutions', href: '#solutions' },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Resources', href: '#resources' },
|
||||
];
|
||||
|
||||
const Navbar = React.forwardRef<HTMLElement, NavbarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
brandName = 'SupportHub',
|
||||
brandLogo,
|
||||
navLinks = defaultNavLinks,
|
||||
actions,
|
||||
sticky = true,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY > 20) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const logoNode = brandLogo || (
|
||||
<a href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="h-9 w-9 rounded-xl bg-primary text-primary-foreground flex items-center justify-center font-bold shadow-sm group-hover:scale-105 transition-transform">
|
||||
<Headphones className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors">
|
||||
{brandName}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
const actionNode = actions || (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/login">Sign In</a>
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" asChild>
|
||||
<a href="/get-started">Start Free Trial</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full z-40 transition-all duration-200 border-b',
|
||||
sticky ? 'sticky top-0' : 'relative',
|
||||
isScrolled
|
||||
? 'bg-background/80 backdrop-blur-md border-border shadow-subtle py-3'
|
||||
: 'bg-background/40 backdrop-blur-sm border-transparent py-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Container size="wide">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Brand Logo */}
|
||||
<div className="flex items-center gap-8">
|
||||
{logoNode}
|
||||
|
||||
{/* Desktop Nav Links */}
|
||||
<nav className="hidden lg:flex items-center gap-6">
|
||||
{navLinks.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
{link.badge && (
|
||||
<Badge variant="primary" size="sm">
|
||||
{link.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center gap-3">{actionNode}</div>
|
||||
|
||||
{/* Mobile Menu Trigger */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<IconButton
|
||||
aria-label="Open navigation menu"
|
||||
variant="ghost"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* Mobile Slide-over Menu */}
|
||||
<MobileMenu
|
||||
open={mobileMenuOpen}
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
brand={logoNode}
|
||||
navLinks={navLinks}
|
||||
actions={actionNode}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Navbar.displayName = 'Navbar';
|
||||
|
||||
export { Navbar };
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ToastProvider } from '@/components/ui/toast';
|
||||
import { Navbar, NavbarProps } from './navbar';
|
||||
import { Footer, FooterProps } from './footer';
|
||||
|
||||
export interface PageContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showNavbar?: boolean;
|
||||
showFooter?: boolean;
|
||||
navbarProps?: NavbarProps;
|
||||
footerProps?: FooterProps;
|
||||
}
|
||||
|
||||
const PageContainer = React.forwardRef<HTMLDivElement, PageContainerProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
showNavbar = true,
|
||||
showFooter = true,
|
||||
navbarProps,
|
||||
footerProps,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('min-h-screen flex flex-col bg-background text-foreground', className)}
|
||||
{...props}
|
||||
>
|
||||
{showNavbar && <Navbar {...navbarProps} />}
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
{showFooter && <Footer {...footerProps} />}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PageContainer.displayName = 'PageContainer';
|
||||
|
||||
export { PageContainer };
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BrowserMockupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
url?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BrowserMockup = React.forwardRef<HTMLDivElement, BrowserMockupProps>(
|
||||
({ className, url = 'https://app.supporthub.io', children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-border bg-card shadow-dialog overflow-hidden flex flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Browser Header Bar */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/60 px-4 py-3 select-none">
|
||||
{/* Window Traffic Lights */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-rose-500/80 inline-block" />
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500/80 inline-block" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/80 inline-block" />
|
||||
</div>
|
||||
|
||||
{/* Address Bar */}
|
||||
<div className="flex items-center justify-center gap-2 rounded-lg bg-background/80 px-4 py-1 text-xs text-muted-foreground border border-border/50 max-w-md w-full mx-4 shadow-subtle truncate font-mono">
|
||||
<Lock className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||
<span className="truncate">{url}</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="w-12 hidden sm:block" />
|
||||
</div>
|
||||
|
||||
{/* Viewport Content */}
|
||||
<div className="relative w-full overflow-hidden bg-background">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BrowserMockup.displayName = 'BrowserMockup';
|
||||
|
||||
export { BrowserMockup };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: 'narrow' | 'default' | 'wide' | 'full';
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
narrow: 'max-w-4xl',
|
||||
default: 'max-w-7xl',
|
||||
wide: 'max-w-7xl lg:max-w-[90rem]',
|
||||
full: 'max-w-none',
|
||||
};
|
||||
|
||||
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
|
||||
({ className, size = 'default', as: Component = 'div', children, ...props }, ref) => {
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mx-auto w-full px-4 sm:px-6 lg:px-8',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Container.displayName = 'Container';
|
||||
|
||||
export { Container };
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Container } from './container';
|
||||
import { Eyebrow } from './eyebrow';
|
||||
|
||||
export interface CTASectionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
primaryAction?: React.ReactNode;
|
||||
secondaryAction?: React.ReactNode;
|
||||
eyebrow?: string;
|
||||
variant?: 'default' | 'card' | 'dark';
|
||||
}
|
||||
|
||||
const CTASection = React.forwardRef<HTMLDivElement, CTASectionProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
eyebrow,
|
||||
variant = 'card',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div ref={ref} className={cn('w-full py-12', className)} {...props}>
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-3xl p-8 sm:p-12 lg:p-16 text-center flex flex-col items-center gap-6 shadow-float',
|
||||
variant === 'card' && 'bg-gradient-to-b from-primary/10 via-background to-accent/20 border border-primary/20',
|
||||
variant === 'dark' && 'bg-slate-950 text-slate-50 border border-slate-800',
|
||||
variant === 'default' && 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<div className="absolute -top-24 left-1/2 -translate-x-1/2 h-48 w-96 rounded-full bg-primary/20 blur-3xl pointer-events-none" />
|
||||
|
||||
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold tracking-tight max-w-3xl leading-tight">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-4 w-full sm:w-auto">
|
||||
{primaryAction}
|
||||
{secondaryAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CTASection.displayName = 'CTASection';
|
||||
|
||||
export { CTASection };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface EyebrowProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'default' | 'primary' | 'outline';
|
||||
}
|
||||
|
||||
const Eyebrow = React.forwardRef<HTMLDivElement, EyebrowProps>(
|
||||
({ className, icon, variant = 'primary', children, ...props }, ref) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-muted text-muted-foreground border-border/60',
|
||||
primary: 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/15',
|
||||
outline: 'bg-background text-foreground border-border shadow-subtle',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wide uppercase transition-colors shrink-0',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Eyebrow.displayName = 'Eyebrow';
|
||||
|
||||
export { Eyebrow };
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
linkText?: string;
|
||||
onLinkClick?: () => void;
|
||||
variant?: 'default' | 'bordered' | 'flat' | 'gradient';
|
||||
}
|
||||
|
||||
const FeatureCard = React.forwardRef<HTMLDivElement, FeatureCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
linkText,
|
||||
onLinkClick,
|
||||
variant = 'default',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-card border-border hover:border-primary/30 hover:shadow-float',
|
||||
bordered: 'bg-background border-2 border-border hover:border-primary/50',
|
||||
flat: 'bg-muted/40 border-transparent shadow-none hover:bg-muted/70',
|
||||
gradient: 'bg-gradient-to-b from-card to-accent/20 border-border/80 hover:border-primary/40',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
hoverable
|
||||
className={cn('group relative overflow-hidden transition-all duration-300', variantClasses[variant], className)}
|
||||
{...props}
|
||||
>
|
||||
<CardContent className="p-6 sm:p-8 flex flex-col h-full gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center font-bold text-xl group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-300 shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
{badge && <Badge variant="secondary" size="sm">{badge}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-2 mt-2">
|
||||
<h3 className="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{linkText && (
|
||||
<div className="pt-2 mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLinkClick}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<span>{linkText}</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FeatureCard.displayName = 'FeatureCard';
|
||||
|
||||
export { FeatureCard };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GradientTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
gradient?: 'primary' | 'brand' | 'emerald' | 'amber' | 'subtle';
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const gradientClasses = {
|
||||
primary: 'bg-gradient-to-r from-indigo-600 via-indigo-500 to-purple-600 dark:from-indigo-400 dark:via-indigo-300 dark:to-purple-400',
|
||||
brand: 'bg-gradient-to-r from-primary via-indigo-500 to-violet-600 dark:from-indigo-400 dark:to-violet-400',
|
||||
emerald: 'bg-gradient-to-r from-emerald-600 to-teal-500 dark:from-emerald-400 dark:to-teal-300',
|
||||
amber: 'bg-gradient-to-r from-amber-600 to-orange-500 dark:from-amber-400 dark:to-orange-300',
|
||||
subtle: 'bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70',
|
||||
};
|
||||
|
||||
const GradientText = React.forwardRef<HTMLSpanElement, GradientTextProps>(
|
||||
({ className, gradient = 'primary', as: Component = 'span', children, ...props }, ref) => {
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-clip-text text-transparent inline-block',
|
||||
gradientClasses[gradient],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GradientText.displayName = 'GradientText';
|
||||
|
||||
export { GradientText };
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from './section';
|
||||
export * from './section-header';
|
||||
export * from './container';
|
||||
export * from './gradient-text';
|
||||
export * from './eyebrow';
|
||||
export * from './feature-card';
|
||||
export * from './step-card';
|
||||
export * from './metric-card';
|
||||
export * from './logo-cloud';
|
||||
export * from './testimonial-card';
|
||||
export * from './cta-section';
|
||||
export * from './product-mockup';
|
||||
export * from './browser-mockup';
|
||||
export * from './screenshot-frame';
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LogoItem {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
svg?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface LogoCloudProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
logos: LogoItem[];
|
||||
}
|
||||
|
||||
const LogoCloud = React.forwardRef<HTMLDivElement, LogoCloudProps>(
|
||||
({ className, title, logos, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('w-full flex flex-col items-center gap-8 py-6', className)}
|
||||
{...props}
|
||||
>
|
||||
{title && (
|
||||
<p className="text-center text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4 lg:grid-cols-6 items-center justify-items-center w-full opacity-75 hover:opacity-100 transition-opacity">
|
||||
{logos.map((logo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center p-2 text-muted-foreground hover:text-foreground grayscale hover:grayscale-0 transition-all duration-200"
|
||||
title={logo.name}
|
||||
>
|
||||
{logo.svg ? (
|
||||
logo.svg
|
||||
) : logo.logoUrl ? (
|
||||
<img
|
||||
src={logo.logoUrl}
|
||||
alt={logo.name}
|
||||
className="h-8 w-auto object-contain max-w-[120px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-bold text-lg tracking-tight">{logo.name}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LogoCloud.displayName = 'LogoCloud';
|
||||
|
||||
export { LogoCloud };
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
label: string;
|
||||
trend?: string;
|
||||
trendDirection?: 'up' | 'down' | 'neutral';
|
||||
subtext?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value,
|
||||
label,
|
||||
trend,
|
||||
trendDirection = 'up',
|
||||
subtext,
|
||||
icon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const trendIcons = {
|
||||
up: <TrendingUp className="h-3.5 w-3.5" />,
|
||||
down: <TrendingDown className="h-3.5 w-3.5" />,
|
||||
neutral: <Minus className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const trendVariants = {
|
||||
up: 'success' as const,
|
||||
down: 'destructive' as const,
|
||||
neutral: 'secondary' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={cn('bg-card border-border hover:shadow-float transition-all duration-200', className)}
|
||||
{...props}
|
||||
>
|
||||
<CardContent className="p-6 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-3 my-1">
|
||||
<span className="text-3xl sm:text-4xl font-extrabold tracking-tight text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
{trend && (
|
||||
<Badge variant={trendVariants[trendDirection]} size="sm" className="gap-1">
|
||||
{trendIcons[trendDirection]}
|
||||
<span>{trend}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtext && (
|
||||
<p className="text-xs text-muted-foreground leading-normal">{subtext}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MetricCard.displayName = 'MetricCard';
|
||||
|
||||
export { MetricCard };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BrowserMockup } from './browser-mockup';
|
||||
|
||||
export interface ProductMockupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
url?: string;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
const ProductMockup = React.forwardRef<HTMLDivElement, ProductMockupProps>(
|
||||
({ className, url, glow = true, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative w-full max-w-5xl mx-auto my-8', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Subtle Ambient Radial Glow behind the mockup */}
|
||||
{glow && (
|
||||
<div
|
||||
className="absolute -inset-4 bg-gradient-to-tr from-primary/30 to-purple-500/20 blur-3xl opacity-50 rounded-[2.5rem] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<BrowserMockup url={url}>{children}</BrowserMockup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ProductMockup.displayName = 'ProductMockup';
|
||||
|
||||
export { ProductMockup };
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export interface ScreenshotFrameProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
aspectRatio?: '16/9' | '4/3' | '1/1' | 'auto';
|
||||
}
|
||||
|
||||
const ScreenshotFrame = React.forwardRef<HTMLDivElement, ScreenshotFrameProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
src,
|
||||
alt = 'App Screenshot',
|
||||
caption,
|
||||
aspectRatio = '16/9',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
|
||||
const aspectClasses = {
|
||||
'16/9': 'aspect-video',
|
||||
'4/3': 'aspect-[4/3]',
|
||||
'1/1': 'aspect-square',
|
||||
auto: 'aspect-auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<figure
|
||||
ref={ref}
|
||||
className={cn('w-full flex flex-col gap-2 group', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-xl border border-border bg-muted shadow-card transition-all duration-300 group-hover:shadow-float',
|
||||
aspectClasses[aspectRatio]
|
||||
)}
|
||||
>
|
||||
{src ? (
|
||||
<>
|
||||
{!loaded && (
|
||||
<Skeleton className="absolute inset-0 h-full w-full rounded-xl" />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-opacity duration-300',
|
||||
loaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="text-center text-xs text-muted-foreground mt-1 font-medium">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenshotFrame.displayName = 'ScreenshotFrame';
|
||||
|
||||
export { ScreenshotFrame };
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Eyebrow } from './eyebrow';
|
||||
|
||||
export interface SectionHeaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
eyebrow?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
align?: 'left' | 'center';
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SectionHeader = React.forwardRef<HTMLDivElement, SectionHeaderProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
align = 'center',
|
||||
actions,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col gap-4 max-w-3xl mb-12 sm:mb-16',
|
||||
align === 'center' && 'mx-auto text-center items-center',
|
||||
align === 'left' && 'items-start text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{eyebrow && (
|
||||
typeof eyebrow === 'string' ? (
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
) : (
|
||||
eyebrow
|
||||
)
|
||||
)}
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-foreground leading-[1.15]">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-base sm:text-lg text-muted-foreground leading-relaxed max-w-2xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{actions && <div className="mt-2 flex flex-wrap gap-4 items-center">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SectionHeader.displayName = 'SectionHeader';
|
||||
|
||||
export { SectionHeader };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
|
||||
spacing?: 'sm' | 'md' | 'lg' | 'none';
|
||||
background?: 'default' | 'muted' | 'dark' | 'gradient';
|
||||
}
|
||||
|
||||
const spacingClasses = {
|
||||
none: 'py-0',
|
||||
sm: 'py-12 sm:py-16',
|
||||
md: 'py-16 sm:py-24',
|
||||
lg: 'py-20 sm:py-32',
|
||||
};
|
||||
|
||||
const backgroundClasses = {
|
||||
default: 'bg-background text-foreground',
|
||||
muted: 'bg-muted/50 text-foreground border-y border-border/50',
|
||||
dark: 'bg-slate-950 text-slate-50 dark:bg-slate-950 dark:text-slate-50',
|
||||
gradient: 'bg-gradient-to-b from-background via-accent/30 to-background text-foreground',
|
||||
};
|
||||
|
||||
const Section = React.forwardRef<HTMLElement, SectionProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
spacing = 'md',
|
||||
background = 'default',
|
||||
children,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
'relative overflow-hidden w-full',
|
||||
spacingClasses[spacing],
|
||||
backgroundClasses[background],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Section.displayName = 'Section';
|
||||
|
||||
export { Section };
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface StepCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
stepNumber: number | string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const StepCard = React.forwardRef<HTMLDivElement, StepCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
stepNumber,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
isLast = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const formattedStep =
|
||||
typeof stepNumber === 'number' && stepNumber < 10
|
||||
? `0${stepNumber}`
|
||||
: stepNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative flex flex-col p-6 rounded-2xl border border-border bg-card shadow-card hover:border-primary/30 transition-all duration-300', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-3xl font-black tracking-tight text-primary/30 font-mono">
|
||||
{formattedStep}
|
||||
</span>
|
||||
{icon && (
|
||||
<div className="h-10 w-10 rounded-lg bg-accent text-accent-foreground flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="text-lg font-bold text-foreground mb-2 tracking-tight">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StepCard.displayName = 'StepCard';
|
||||
|
||||
export { StepCard };
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from 'react';
|
||||
import { Star, Quote } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
export interface TestimonialCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
quote: string;
|
||||
authorName: string;
|
||||
authorTitle: string;
|
||||
authorCompany?: string;
|
||||
avatarUrl?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
const TestimonialCard = React.forwardRef<HTMLDivElement, TestimonialCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
quote,
|
||||
authorName,
|
||||
authorTitle,
|
||||
authorCompany,
|
||||
avatarUrl,
|
||||
rating = 5,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const initials = authorName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.substring(0, 2);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
hoverable
|
||||
className={cn('bg-card border-border hover:shadow-float transition-all duration-300 flex flex-col justify-between', className)}
|
||||
{...props}
|
||||
>
|
||||
<CardContent className="p-6 sm:p-8 flex flex-col h-full justify-between gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 text-amber-400">
|
||||
{Array.from({ length: rating }).map((_, i) => (
|
||||
<Star key={i} className="h-4 w-4 fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<Quote className="h-6 w-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
<p className="text-sm sm:text-base text-foreground leading-relaxed italic">
|
||||
“{quote}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-border/60">
|
||||
<Avatar size="md">
|
||||
<AvatarImage src={avatarUrl} alt={authorName} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<h5 className="text-sm font-bold text-foreground leading-tight">{authorName}</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authorTitle}
|
||||
{authorCompany && <span className="font-medium text-foreground/80"> • {authorCompany}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TestimonialCard.displayName = 'TestimonialCard';
|
||||
|
||||
export { TestimonialCard };
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: string[];
|
||||
toggleItem: (value: string) => void;
|
||||
}
|
||||
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||
|
||||
export interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
type?: 'single' | 'multiple';
|
||||
defaultValue?: string | string[];
|
||||
}
|
||||
|
||||
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
({ type = 'single', defaultValue, children, className, ...props }, ref) => {
|
||||
const [openItems, setOpenItems] = React.useState<string[]>(() => {
|
||||
if (!defaultValue) return [];
|
||||
return Array.isArray(defaultValue) ? defaultValue : [defaultValue];
|
||||
});
|
||||
|
||||
const toggleItem = React.useCallback(
|
||||
(value: string) => {
|
||||
setOpenItems((prev) => {
|
||||
if (type === 'single') {
|
||||
return prev.includes(value) ? [] : [value];
|
||||
} else {
|
||||
return prev.includes(value)
|
||||
? prev.filter((v) => v !== value)
|
||||
: [...prev, value];
|
||||
}
|
||||
});
|
||||
},
|
||||
[type]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={{ openItems, toggleItem }}>
|
||||
<div ref={ref} className={cn('divide-y divide-border border-b border-t border-border', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Accordion.displayName = 'Accordion';
|
||||
|
||||
interface AccordionItemContextValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
({ value, children, className, ...props }, ref) => (
|
||||
<AccordionItemContext.Provider value={{ value }}>
|
||||
<div ref={ref} className={cn('py-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
)
|
||||
);
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
export interface AccordionTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
|
||||
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
const accContext = React.useContext(AccordionContext);
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
|
||||
if (!accContext || !itemContext) {
|
||||
throw new Error('AccordionTrigger must be used inside Accordion and AccordionItem');
|
||||
}
|
||||
|
||||
const isOpen = accContext.openItems.includes(itemContext.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => accContext.toggleItem(itemContext.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between py-4 font-medium text-foreground transition-all hover:underline text-left text-base',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span>{children}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground',
|
||||
isOpen && 'rotate-180 text-primary'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||
|
||||
export interface AccordionContentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
const accContext = React.useContext(AccordionContext);
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
|
||||
if (!accContext || !itemContext) {
|
||||
throw new Error('AccordionContent must be used inside Accordion and AccordionItem');
|
||||
}
|
||||
|
||||
const isOpen = accContext.openItems.includes(itemContext.value);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pb-4 pt-0 text-sm text-muted-foreground leading-relaxed animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionContent.displayName = 'AccordionContent';
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'info' | 'success' | 'warning' | 'destructive';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantMap = {
|
||||
default: 'bg-background text-foreground border-border',
|
||||
info: 'bg-sky-50 dark:bg-sky-950/30 text-sky-900 dark:text-sky-200 border-sky-200 dark:border-sky-800',
|
||||
success: 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-900 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800',
|
||||
warning: 'bg-amber-50 dark:bg-amber-950/30 text-amber-900 dark:text-amber-200 border-amber-200 dark:border-amber-800',
|
||||
destructive: 'bg-rose-50 dark:bg-rose-950/30 text-rose-900 dark:text-rose-200 border-rose-200 dark:border-rose-800',
|
||||
};
|
||||
|
||||
const defaultIcons = {
|
||||
default: <Info className="h-5 w-5 text-primary" />,
|
||||
info: <Info className="h-5 w-5 text-sky-600 dark:text-sky-400" />,
|
||||
success: <CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
|
||||
warning: <AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />,
|
||||
destructive: <AlertCircle className="h-5 w-5 text-rose-600 dark:text-rose-400" />,
|
||||
};
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'default', icon, children, ...props }, ref) => {
|
||||
const displayIcon = icon !== undefined ? icon : defaultIcons[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
'relative w-full rounded-xl border p-4 flex gap-3 items-start shadow-subtle',
|
||||
variantMap[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayIcon && <div className="shrink-0 mt-0.5">{displayIcon}</div>}
|
||||
<div className="flex-1 flex flex-col gap-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('font-semibold text-sm leading-tight tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-xs opacity-90 leading-relaxed [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
status?: 'online' | 'offline' | 'busy' | 'away';
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-12 w-12 text-base',
|
||||
xl: 'h-16 w-16 text-lg',
|
||||
};
|
||||
|
||||
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ className, size = 'md', status, children, ...props }, ref) => {
|
||||
const statusColors = {
|
||||
online: 'bg-emerald-500',
|
||||
offline: 'bg-slate-400',
|
||||
busy: 'bg-rose-500',
|
||||
away: 'bg-amber-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 rounded-full overflow-visible',
|
||||
sizeMap[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-full border border-border bg-muted items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background ring-1 ring-background',
|
||||
statusColors[status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
export interface AvatarImageProps
|
||||
extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
onLoadingStatusChange?: (status: 'loading' | 'loaded' | 'error') => void;
|
||||
}
|
||||
|
||||
const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
|
||||
({ className, src, alt = '', ...props }, ref) => {
|
||||
const [hasError, setHasError] = React.useState(false);
|
||||
|
||||
if (!src || hasError) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setHasError(true)}
|
||||
className={cn('aspect-square h-full w-full object-cover', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
AvatarImage.displayName = 'AvatarImage';
|
||||
|
||||
export interface AvatarFallbackProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const AvatarFallback = React.forwardRef<HTMLDivElement, AvatarFallbackProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-muted-foreground uppercase',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
AvatarFallback.displayName = 'AvatarFallback';
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'outline'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'destructive'
|
||||
| 'info';
|
||||
size?: 'sm' | 'md';
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
dot = false,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const variants = {
|
||||
default:
|
||||
'border-transparent bg-primary/10 text-primary hover:bg-primary/20',
|
||||
primary:
|
||||
'border-transparent bg-primary/10 text-primary hover:bg-primary/20',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border-border text-foreground bg-background',
|
||||
success:
|
||||
'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20',
|
||||
warning:
|
||||
'border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20',
|
||||
destructive:
|
||||
'border-transparent bg-destructive/10 text-destructive hover:bg-destructive/20',
|
||||
info: 'border-transparent bg-sky-500/10 text-sky-600 dark:text-sky-400 hover:bg-sky-500/20',
|
||||
};
|
||||
|
||||
const dotColors = {
|
||||
default: 'bg-primary',
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary-foreground',
|
||||
outline: 'bg-foreground',
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500',
|
||||
destructive: 'bg-destructive',
|
||||
info: 'bg-sky-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-0.5 text-xs font-medium',
|
||||
md: 'px-2.5 py-1 text-xs font-semibold',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn('h-1.5 w-1.5 rounded-full', dotColors[variant])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
|
||||
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
disabled,
|
||||
children,
|
||||
asChild = false,
|
||||
type = 'button',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98] select-none';
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md border border-transparent',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-transparent',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground text-foreground shadow-subtle',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground text-foreground',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||
link: 'text-primary underline-offset-4 hover:underline p-0 h-auto',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-8 px-3 text-xs gap-1.5',
|
||||
md: 'h-10 px-4 text-sm gap-2',
|
||||
lg: 'h-12 px-6 text-base gap-2.5',
|
||||
icon: 'h-10 w-10 p-0 text-sm justify-center',
|
||||
};
|
||||
|
||||
const combinedClassName = cn(baseStyles, variants[variant], sizes[size], className);
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<{ className?: string }>, {
|
||||
className: cn(combinedClassName, (children.props as { className?: string }).className),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled || isLoading}
|
||||
className={combinedClassName}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-current shrink-0" />
|
||||
) : (
|
||||
leftIcon && <span className="shrink-0">{leftIcon}</span>
|
||||
)}
|
||||
{children && <span>{children}</span>}
|
||||
{!isLoading && rightIcon && <span className="shrink-0">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, hoverable = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card text-card-foreground shadow-card transition-all duration-200',
|
||||
hoverable && 'hover:shadow-float hover:-translate-y-0.5 hover:border-primary/20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-semibold text-lg leading-none tracking-tight text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogContextValue {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
export interface DialogProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dialog: React.FC<DialogProps> = ({
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setUncontrolledOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[controlledOpen, onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open: isOpen, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DialogTriggerProps {
|
||||
asChild?: boolean;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
const DialogTrigger: React.FC<DialogTriggerProps> = ({ children }) => {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) throw new Error('DialogTrigger must be used within Dialog');
|
||||
|
||||
return React.cloneElement(children, {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
children.props.onClick?.(e);
|
||||
context.onOpenChange(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, children, showClose = true, ...props }, ref) => {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) throw new Error('DialogContent must be used within Dialog');
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && context.open) {
|
||||
context.onOpenChange(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [context]);
|
||||
|
||||
if (!context.open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => context.onOpenChange(false)}
|
||||
/>
|
||||
{/* Modal Panel */}
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'relative z-50 w-full max-w-lg rounded-xl border border-border bg-background p-6 shadow-dialog animate-scale-in flex flex-col gap-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-2 mt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogClose: React.FC<{ children: React.ReactElement }> = ({ children }) => {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) throw new Error('DialogClose must be used within Dialog');
|
||||
|
||||
return React.cloneElement(children, {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
children.props.onClick?.(e);
|
||||
context.onOpenChange(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DropdownContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({ children }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={{ open, setOpen }}>
|
||||
<div ref={dropdownRef} className="relative inline-block text-left">
|
||||
{children}
|
||||
</div>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DropdownTriggerProps {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
const DropdownTrigger: React.FC<DropdownTriggerProps> = ({ children }) => {
|
||||
const context = React.useContext(DropdownContext);
|
||||
if (!context) throw new Error('DropdownTrigger must be used within Dropdown');
|
||||
|
||||
return React.cloneElement(children, {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
children.props.onClick?.(e);
|
||||
context.setOpen(!context.open);
|
||||
},
|
||||
'aria-expanded': context.open,
|
||||
});
|
||||
};
|
||||
|
||||
export interface DropdownContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
const DropdownContent = React.forwardRef<HTMLDivElement, DropdownContentProps>(
|
||||
({ className, align = 'left', children, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownContext);
|
||||
if (!context) throw new Error('DropdownContent must be used within Dropdown');
|
||||
|
||||
if (!context.open) return null;
|
||||
|
||||
const alignClasses = {
|
||||
left: 'left-0 origin-top-left',
|
||||
right: 'right-0 origin-top-right',
|
||||
center: 'left-1/2 -translate-x-1/2 origin-top',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 min-w-[12rem] rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-dropdown animate-scale-in focus:outline-none',
|
||||
alignClasses[align],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
DropdownContent.displayName = 'DropdownContent';
|
||||
|
||||
export interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||
({ className, disabled, destructive, icon, children, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownContext);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled) return;
|
||||
onClick?.(e);
|
||||
context?.setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menuitem"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50',
|
||||
destructive && 'text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="h-4 w-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
<span className="flex-1">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
DropdownItem.displayName = 'DropdownItem';
|
||||
|
||||
const DropdownLabel: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('px-2.5 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DropdownLabel.displayName = 'DropdownLabel';
|
||||
|
||||
const DropdownSeparator: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||
);
|
||||
DropdownSeparator.displayName = 'DropdownSeparator';
|
||||
|
||||
export {
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownLabel,
|
||||
DropdownSeparator,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user