Compare commits
75 Commits
agent_team
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 83d9d8c200 | |||
| f07ce019fe | |||
| a65e59fcb6 | |||
| a9b830d11e | |||
| 0ac3cce6f3 | |||
| 54bced4251 | |||
| a34b1219bc | |||
| c9e6c37b5c | |||
| 994710e232 | |||
| 094dde0b81 | |||
| 41b45e0423 | |||
| 7020f2d67f | |||
| 2cacff4a0f | |||
| 29845657f5 | |||
| b736fc9c81 | |||
| aadbe80a23 | |||
| 66f1f089c5 | |||
| 06971dc673 | |||
| beddf12bc0 | |||
| 4b0bf65ace | |||
| 8aeb97a5fc | |||
| fc9fd93c36 | |||
| 9cc3334ea7 | |||
| dc4c6f313d | |||
| 9e2c02a333 | |||
| b9171998b9 | |||
| 64d789a3d0 | |||
| cc1bf85517 | |||
| 4c8bc53d33 | |||
| 70014c0f70 | |||
| eb69bb168a | |||
| 7287e93f87 | |||
| a925f0e77f | |||
| 6dc580ab26 | |||
| 3a16dc283d | |||
| 0fd4df3c17 | |||
| f46a435bab | |||
| a28254c6b8 | |||
| 0cf4f44346 | |||
| e0bc6c55b0 | |||
| 2c5205b06e | |||
| 236ac19789 | |||
| ba2417c7f2 | |||
| f3655c86c9 | |||
| c3d84b904a | |||
| ffa1249403 | |||
| ee972441f5 | |||
| d335199a64 | |||
| feeaccc0e3 | |||
| cf35edb4ca | |||
| e0a4862af8 | |||
| c3a0aef104 | |||
| b25713a141 | |||
| d74a1c9c12 | |||
| 834d4e1e2f | |||
| 6a6ddc21c0 | |||
| 826db8ec2e | |||
| 33a9845566 | |||
| 55b39563a0 | |||
| 41ac87e322 | |||
| 542b23ef6e | |||
| 9002d1206f | |||
| dd9f40b38c | |||
| 96562877cc | |||
| f58a57e5b8 | |||
| 362aae9b12 | |||
| 29d175222d | |||
| 2e4f8541ee | |||
| a1164dc49a | |||
| 7b638b083a | |||
| 6e9e74d1ee | |||
| 16347caf5e | |||
| 030bce8a60 | |||
| c671b66043 | |||
| e061961a79 |
31
.agents/skills/speckit-agent-context-update/SKILL.md
Normal file
31
.agents/skills/speckit-agent-context-update/SKILL.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: speckit-agent-context-update
|
||||||
|
description: Refresh the managed Spec Kit section in the coding agent context file
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: agent-context:commands/speckit.agent-context.update.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Coding Agent Context
|
||||||
|
|
||||||
|
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
The script reads the agent-context extension config at
|
||||||
|
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
|
||||||
|
|
||||||
|
- `context_file` — the path of the coding agent context file to manage.
|
||||||
|
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||||
|
|
||||||
|
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||||
|
|
||||||
|
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||||
|
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||||
|
|
||||||
|
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||||
257
.agents/skills/speckit-analyze/SKILL.md
Normal file
257
.agents/skills/speckit-analyze/SKILL.md
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/analyze.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- 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/bash/check-prerequisites.sh --json --require-tasks --include-tasks` 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 slash commands 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}
|
||||||
|
```
|
||||||
|
- 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
|
||||||
371
.agents/skills/speckit-checklist/SKILL.md
Normal file
371
.agents/skills/speckit-checklist/SKILL.md
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-checklist"
|
||||||
|
description: "Generate a custom checklist for the current feature based on user requirements."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/checklist.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||||
|
- 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** - 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
|
||||||
|
|
||||||
|
**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, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||||
|
|
||||||
|
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 slash commands 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}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
283
.agents/skills/speckit-clarify/SKILL.md
Normal file
283
.agents/skills/speckit-clarify/SKILL.md
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/clarify.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- 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/bash/check-prerequisites.sh --json --paths-only` 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.
|
||||||
|
- 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 slash commands 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}
|
||||||
|
```
|
||||||
|
- **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
|
||||||
154
.agents/skills/speckit-constitution/SKILL.md
Normal file
154
.agents/skills/speckit-constitution/SKILL.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-constitution"
|
||||||
|
description: "Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/constitution.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before constitution update)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||||
|
|
||||||
|
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||||
|
|
||||||
|
Follow this execution flow:
|
||||||
|
|
||||||
|
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||||
|
- 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:
|
||||||
|
- 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. Consistency propagation checklist (convert prior checklist into active validations):
|
||||||
|
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||||
|
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||||
|
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||||
|
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||||
|
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||||
|
|
||||||
|
5. 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
|
||||||
|
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||||
|
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||||
|
|
||||||
|
6. 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).
|
||||||
|
|
||||||
|
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||||
|
|
||||||
|
8. Output a final summary to the user with:
|
||||||
|
- New version and bump rationale.
|
||||||
|
- Any files flagged for manual follow-up.
|
||||||
|
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||||
|
|
||||||
|
## Post-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (after constitution update)**:
|
||||||
|
Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands 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}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
53
.agents/skills/speckit-git-commit/SKILL.md
Normal file
53
.agents/skills/speckit-git-commit/SKILL.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: speckit-git-commit
|
||||||
|
description: Auto-commit changes after a Spec Kit command completes
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: git:commands/speckit.git.commit.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Auto-Commit Changes
|
||||||
|
|
||||||
|
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
This command is invoked as a hook after (or before) core commands. It:
|
||||||
|
|
||||||
|
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||||
|
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||||
|
3. Looks up the specific event key to see if auto-commit is enabled
|
||||||
|
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||||
|
5. Uses the per-command `message` if configured, otherwise a default message
|
||||||
|
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Determine the event name from the hook that triggered this command, then run the script:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||||
|
|
||||||
|
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In `.specify/extensions/git/git-config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto_commit:
|
||||||
|
default: false # Global toggle — set true to enable for all commands
|
||||||
|
after_specify:
|
||||||
|
enabled: true # Override per-command
|
||||||
|
message: "[Spec Kit] Add specification"
|
||||||
|
after_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add implementation plan"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||||
|
- If no config file exists: skips (disabled by default)
|
||||||
|
- If no changes to commit: skips with a message
|
||||||
72
.agents/skills/speckit-git-feature/SKILL.md
Normal file
72
.agents/skills/speckit-git-feature/SKILL.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: speckit-git-feature
|
||||||
|
description: Create a feature branch with sequential or timestamp numbering
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: git:commands/speckit.git.feature.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Feature Branch
|
||||||
|
|
||||||
|
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit-specify` workflow.
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Environment Variable Override
|
||||||
|
|
||||||
|
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||||
|
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||||
|
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||||
|
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, warn the user and skip branch creation
|
||||||
|
|
||||||
|
## Branch Numbering Mode
|
||||||
|
|
||||||
|
Determine the branch numbering strategy by checking configuration in this order:
|
||||||
|
|
||||||
|
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||||
|
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||||
|
3. Default to `sequential` if neither exists
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Generate a concise short name (2-4 words) for the branch:
|
||||||
|
- Analyze the feature description and extract the most meaningful keywords
|
||||||
|
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||||
|
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||||
|
|
||||||
|
Run the appropriate script based on your platform:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||||
|
|
||||||
|
**IMPORTANT**:
|
||||||
|
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||||
|
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||||
|
- You must only ever run this script once per feature
|
||||||
|
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the current directory is not a Git repository:
|
||||||
|
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||||
|
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The script outputs JSON with:
|
||||||
|
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
|
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||||
54
.agents/skills/speckit-git-initialize/SKILL.md
Normal file
54
.agents/skills/speckit-git-initialize/SKILL.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: speckit-git-initialize
|
||||||
|
description: Initialize a Git repository with an initial commit
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: git:commands/speckit.git.initialize.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Initialize Git Repository
|
||||||
|
|
||||||
|
Initialize a Git repository in the current project directory if one does not already exist.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Run the appropriate script from the project root:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||||
|
|
||||||
|
If the extension scripts are not found, fall back to:
|
||||||
|
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||||
|
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||||
|
|
||||||
|
The script handles all checks internally:
|
||||||
|
- Skips if Git is not available
|
||||||
|
- Skips if already inside a Git repository
|
||||||
|
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
Replace the script to add project-specific Git initialization steps:
|
||||||
|
- Custom `.gitignore` templates
|
||||||
|
- Default branch naming (`git config init.defaultBranch`)
|
||||||
|
- Git LFS setup
|
||||||
|
- Git hooks installation
|
||||||
|
- Commit signing configuration
|
||||||
|
- Git Flow initialization
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
On success:
|
||||||
|
- `[OK] Git repository initialized`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed:
|
||||||
|
- Warn the user
|
||||||
|
- Skip repository initialization
|
||||||
|
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||||
|
|
||||||
|
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||||
|
- Surface the error to the user
|
||||||
|
- Stop this command rather than continuing with a partially initialized repository
|
||||||
50
.agents/skills/speckit-git-remote/SKILL.md
Normal file
50
.agents/skills/speckit-git-remote/SKILL.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: speckit-git-remote
|
||||||
|
description: Detect Git remote URL for GitHub integration
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: git:commands/speckit.git.remote.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Detect Git Remote URL
|
||||||
|
|
||||||
|
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and return empty:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Run the following command to get the remote URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Parse the remote URL and determine:
|
||||||
|
|
||||||
|
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||||
|
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||||
|
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||||
|
|
||||||
|
Supported URL formats:
|
||||||
|
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||||
|
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||||
|
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||||
|
- Return an empty result
|
||||||
|
- Do NOT error — other workflows should continue without Git remote information
|
||||||
54
.agents/skills/speckit-git-validate/SKILL.md
Normal file
54
.agents/skills/speckit-git-validate/SKILL.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: speckit-git-validate
|
||||||
|
description: Validate current branch follows feature branch naming conventions
|
||||||
|
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||||
|
metadata:
|
||||||
|
author: github-spec-kit
|
||||||
|
source: git:commands/speckit.git.validate.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validate Feature Branch
|
||||||
|
|
||||||
|
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and skip validation:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; skipped branch validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Get the current branch name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch name must match one of these patterns:
|
||||||
|
|
||||||
|
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||||
|
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
If on a feature branch (matches either pattern):
|
||||||
|
- Output: `✓ On feature branch: <branch-name>`
|
||||||
|
- Check if the corresponding spec directory exists under `specs/`:
|
||||||
|
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||||
|
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||||
|
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||||
|
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||||
|
|
||||||
|
If NOT on a feature branch:
|
||||||
|
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||||
|
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the directory is not a Git repository:
|
||||||
|
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||||
|
- If set, validate that value against the naming patterns
|
||||||
|
- If not set, skip validation with a warning
|
||||||
221
.agents/skills/speckit-implement/SKILL.md
Normal file
221
.agents/skills/speckit-implement/SKILL.md
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-implement"
|
||||||
|
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/implement.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` 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):
|
||||||
|
- Scan all checklist files in the checklists/ directory
|
||||||
|
- For each checklist, count:
|
||||||
|
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||||
|
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||||
|
- Incomplete items: Lines matching `- [ ]`
|
||||||
|
- Create a status table:
|
||||||
|
|
||||||
|
```text
|
||||||
|
| Checklist | Total | Completed | Incomplete | 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 incomplete items
|
||||||
|
- **FAIL**: One or more checklists have incomplete items
|
||||||
|
|
||||||
|
- **If any checklist is incomplete**:
|
||||||
|
- Display the table with incomplete item counts
|
||||||
|
- **STOP** and ask: "Some checklists are incomplete. 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 complete**:
|
||||||
|
- 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 slash commands 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}
|
||||||
|
```
|
||||||
|
- **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
|
||||||
168
.agents/skills/speckit-plan/SKILL.md
Normal file
168
.agents/skills/speckit-plan/SKILL.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-plan"
|
||||||
|
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/plan.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --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
|
||||||
|
- Phase 1: Update agent context by running the agent script
|
||||||
|
- 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 slash commands 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}
|
||||||
|
```
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion Report
|
||||||
|
|
||||||
|
Command ends after Phase 2 planning. 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
|
||||||
|
|
||||||
|
4. **Agent context update**:
|
||||||
|
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `AGENTS.md` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||||
|
|
||||||
|
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||||
|
|
||||||
|
## Key rules
|
||||||
|
|
||||||
|
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
|
||||||
|
- 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
|
||||||
342
.agents/skills/speckit-specify/SKILL.md
Normal file
342
.agents/skills/speckit-specify/SKILL.md
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-specify"
|
||||||
|
description: "Create or update the feature specification from a natural language feature description."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/specify.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- 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 `branch_numbering`
|
||||||
|
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||||
|
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||||
|
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
|
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||||
|
|
||||||
|
**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)
|
||||||
|
|
||||||
|
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||||
|
|
||||||
|
7. **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 slash commands 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}
|
||||||
|
```
|
||||||
|
- **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
|
||||||
212
.agents/skills/speckit-tasks/SKILL.md
Normal file
212
.agents/skills/speckit-tasks/SKILL.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-tasks"
|
||||||
|
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/tasks.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/setup-tasks.sh --json` from repo root and parse FEATURE_DIR, 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**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. 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 slash commands 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}
|
||||||
|
```
|
||||||
|
- **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
|
||||||
104
.agents/skills/speckit-taskstoissues/SKILL.md
Normal file
104
.agents/skills/speckit-taskstoissues/SKILL.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-taskstoissues"
|
||||||
|
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/taskstoissues.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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 slash commands 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.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` 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. 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.
|
||||||
|
|
||||||
|
> [!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 slash commands 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}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
30
.env.example
30
.env.example
@ -7,8 +7,8 @@ BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
|
|||||||
BEAVER_DEPLOY_TOKEN=change-me
|
BEAVER_DEPLOY_TOKEN=change-me
|
||||||
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
|
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
|
||||||
|
|
||||||
BEAVER_SERVER_IP=203.0.113.10
|
BEAVER_SERVER_IP=127.0.0.1
|
||||||
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io
|
BEAVER_BASE_DOMAIN=localhost
|
||||||
|
|
||||||
BEAVER_PROVIDER=openai
|
BEAVER_PROVIDER=openai
|
||||||
BEAVER_MODEL=openai/gpt-5
|
BEAVER_MODEL=openai/gpt-5
|
||||||
@ -26,5 +26,31 @@ BEAVER_AUTHZ_URL=http://beaver-authz-service:19090
|
|||||||
BEAVER_OUTLOOK_MCP_URL=
|
BEAVER_OUTLOOK_MCP_URL=
|
||||||
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
|
||||||
|
# User file system backed by MinIO/S3.
|
||||||
|
BEAVER_MINIO_ROOT_USER=
|
||||||
|
BEAVER_MINIO_ROOT_PASSWORD=
|
||||||
|
BEAVER_USER_FILES_BUCKET=beaver-user-files
|
||||||
|
BEAVER_USER_FILES_MINIO_ENDPOINT=
|
||||||
|
BEAVER_USER_FILES_MAX_UPLOAD_BYTES=5368709120
|
||||||
|
|
||||||
# Must be reachable from auth-portal and authz-service containers.
|
# Must be reachable from auth-portal and authz-service containers.
|
||||||
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
||||||
|
|
||||||
|
# External connector sidecar
|
||||||
|
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
|
||||||
|
# Required for connector management API authentication.
|
||||||
|
EXTERNAL_CONNECTOR_TOKEN=change-me-connector-token
|
||||||
|
# Required for sidecar -> Beaver bridge authentication.
|
||||||
|
BEAVER_BRIDGE_TOKEN=change-me-bridge-token
|
||||||
|
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
|
||||||
|
EXTERNAL_CONNECTOR_PORT=8787
|
||||||
|
CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787
|
||||||
|
# fake | official | vendor_cli | weixin_ilink | feishu_bot
|
||||||
|
CONNECTOR_PROVIDER=official
|
||||||
|
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
|
||||||
|
WEIXIN_CONNECT_COMMAND=
|
||||||
|
WEIXIN_STATUS_COMMAND=
|
||||||
|
WEIXIN_SEND_COMMAND=
|
||||||
|
FEISHU_CONNECT_COMMAND=
|
||||||
|
FEISHU_STATUS_COMMAND=
|
||||||
|
FEISHU_SEND_COMMAND=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@ sessions/
|
|||||||
**/.ruff_cache/
|
**/.ruff_cache/
|
||||||
**/.mypy_cache/
|
**/.mypy_cache/
|
||||||
**/.cache/
|
**/.cache/
|
||||||
|
**/.codegraph/
|
||||||
**/.venv/
|
**/.venv/
|
||||||
**/dist/
|
**/dist/
|
||||||
**/build/
|
**/build/
|
||||||
|
|||||||
164
.specify/extensions.yml
Normal file
164
.specify/extensions.yml
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
installed:
|
||||||
|
- agent-context
|
||||||
|
- git
|
||||||
|
settings:
|
||||||
|
auto_execute_hooks: true
|
||||||
|
hooks:
|
||||||
|
before_constitution:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.initialize
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.git.initialize?
|
||||||
|
description: Initialize Git repository before constitution setup
|
||||||
|
condition: null
|
||||||
|
before_specify:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.feature
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.git.feature?
|
||||||
|
description: Create feature branch before specification
|
||||||
|
condition: null
|
||||||
|
before_clarify:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before clarification?
|
||||||
|
description: Auto-commit before spec clarification
|
||||||
|
condition: null
|
||||||
|
before_plan:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before planning?
|
||||||
|
description: Auto-commit before implementation planning
|
||||||
|
condition: null
|
||||||
|
before_tasks:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before task generation?
|
||||||
|
description: Auto-commit before task generation
|
||||||
|
condition: null
|
||||||
|
before_implement:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before implementation?
|
||||||
|
description: Auto-commit before implementation
|
||||||
|
condition: null
|
||||||
|
before_checklist:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before checklist?
|
||||||
|
description: Auto-commit before checklist generation
|
||||||
|
condition: null
|
||||||
|
before_analyze:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before analysis?
|
||||||
|
description: Auto-commit before analysis
|
||||||
|
condition: null
|
||||||
|
before_taskstoissues:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit outstanding changes before issue sync?
|
||||||
|
description: Auto-commit before tasks-to-issues conversion
|
||||||
|
condition: null
|
||||||
|
after_constitution:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit constitution changes?
|
||||||
|
description: Auto-commit after constitution update
|
||||||
|
condition: null
|
||||||
|
after_specify:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit specification changes?
|
||||||
|
description: Auto-commit after specification
|
||||||
|
condition: null
|
||||||
|
- extension: agent-context
|
||||||
|
command: speckit.agent-context.update
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Execute speckit.agent-context.update?
|
||||||
|
description: Refresh agent context after specification
|
||||||
|
condition: null
|
||||||
|
after_clarify:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit clarification changes?
|
||||||
|
description: Auto-commit after spec clarification
|
||||||
|
condition: null
|
||||||
|
after_plan:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit plan changes?
|
||||||
|
description: Auto-commit after implementation planning
|
||||||
|
condition: null
|
||||||
|
- extension: agent-context
|
||||||
|
command: speckit.agent-context.update
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Execute speckit.agent-context.update?
|
||||||
|
description: Refresh agent context after planning
|
||||||
|
condition: null
|
||||||
|
after_tasks:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit task changes?
|
||||||
|
description: Auto-commit after task generation
|
||||||
|
condition: null
|
||||||
|
after_implement:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit implementation changes?
|
||||||
|
description: Auto-commit after implementation
|
||||||
|
condition: null
|
||||||
|
after_checklist:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit checklist changes?
|
||||||
|
description: Auto-commit after checklist generation
|
||||||
|
condition: null
|
||||||
|
after_analyze:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit analysis results?
|
||||||
|
description: Auto-commit after analysis
|
||||||
|
condition: null
|
||||||
|
after_taskstoissues:
|
||||||
|
- extension: git
|
||||||
|
command: speckit.git.commit
|
||||||
|
enabled: true
|
||||||
|
optional: true
|
||||||
|
prompt: Commit after syncing issues?
|
||||||
|
description: Auto-commit after tasks-to-issues conversion
|
||||||
|
condition: null
|
||||||
47
.specify/extensions/.registry
Normal file
47
.specify/extensions/.registry
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extensions": {
|
||||||
|
"git": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local",
|
||||||
|
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"registered_commands": {
|
||||||
|
"agy": [
|
||||||
|
"speckit.git.feature",
|
||||||
|
"speckit.git.validate",
|
||||||
|
"speckit.git.remote",
|
||||||
|
"speckit.git.initialize",
|
||||||
|
"speckit.git.commit"
|
||||||
|
],
|
||||||
|
"codex": [
|
||||||
|
"speckit.git.feature",
|
||||||
|
"speckit.git.validate",
|
||||||
|
"speckit.git.remote",
|
||||||
|
"speckit.git.initialize",
|
||||||
|
"speckit.git.commit"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"registered_skills": [],
|
||||||
|
"installed_at": "2026-06-08T01:33:59.604628+00:00"
|
||||||
|
},
|
||||||
|
"agent-context": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local",
|
||||||
|
"manifest_hash": "sha256:9a1dc02d2d0139bb03860392ecacef79183be2c442feda2f9ccaa4e5907b1e47",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"registered_commands": {
|
||||||
|
"agy": [
|
||||||
|
"speckit.agent-context.update"
|
||||||
|
],
|
||||||
|
"codex": [
|
||||||
|
"speckit.agent-context.update"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"registered_skills": [],
|
||||||
|
"installed_at": "2026-06-08T01:33:59.640587+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
.specify/extensions/agent-context/README.md
Normal file
57
.specify/extensions/agent-context/README.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Coding Agent Context Extension
|
||||||
|
|
||||||
|
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||||
|
|
||||||
|
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||||
|
|
||||||
|
## Why an extension?
|
||||||
|
|
||||||
|
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
|
||||||
|
|
||||||
|
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
|
||||||
|
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
|
||||||
|
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration flows through the extension's own config file at
|
||||||
|
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Path to the coding agent context file managed by this extension
|
||||||
|
context_file: CLAUDE.md
|
||||||
|
|
||||||
|
# Delimiters for the managed Spec Kit section
|
||||||
|
context_markers:
|
||||||
|
start: "<!-- SPECKIT START -->"
|
||||||
|
end: "<!-- SPECKIT END -->"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
|
||||||
|
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||||
|
|
||||||
|
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyyaml
|
||||||
|
# or target the specific interpreter Spec Kit uses:
|
||||||
|
/path/to/speckit-python -m pip install pyyaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
specify extension disable agent-context
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
context_file: AGENTS.md
|
||||||
|
context_markers:
|
||||||
|
start: <!-- SPECKIT START -->
|
||||||
|
end: <!-- SPECKIT END -->
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Coding Agent Context
|
||||||
|
|
||||||
|
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
The script reads the agent-context extension config at
|
||||||
|
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
|
||||||
|
|
||||||
|
- `context_file` — the path of the coding agent context file to manage.
|
||||||
|
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||||
|
|
||||||
|
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||||
|
|
||||||
|
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||||
|
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||||
|
|
||||||
|
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||||
34
.specify/extensions/agent-context/extension.yml
Normal file
34
.specify/extensions/agent-context/extension.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
extension:
|
||||||
|
id: agent-context
|
||||||
|
name: "Coding Agent Context"
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
|
||||||
|
author: spec-kit-core
|
||||||
|
repository: https://github.com/github/spec-kit
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.2.0"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
commands:
|
||||||
|
- name: speckit.agent-context.update
|
||||||
|
file: commands/speckit.agent-context.update.md
|
||||||
|
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
after_specify:
|
||||||
|
command: speckit.agent-context.update
|
||||||
|
optional: true
|
||||||
|
description: "Refresh agent context after specification"
|
||||||
|
after_plan:
|
||||||
|
command: speckit.agent-context.update
|
||||||
|
optional: true
|
||||||
|
description: "Refresh agent context after planning"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- "agent"
|
||||||
|
- "context"
|
||||||
|
- "core"
|
||||||
200
.specify/extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
200
.specify/extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# update-agent-context.sh
|
||||||
|
#
|
||||||
|
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||||
|
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||||
|
#
|
||||||
|
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||||
|
# agent-context extension config:
|
||||||
|
# .specify/extensions/agent-context/agent-context-config.yml
|
||||||
|
#
|
||||||
|
# Usage: update-agent-context.sh [plan_path]
|
||||||
|
#
|
||||||
|
# When `plan_path` is omitted, the script picks the most recently modified
|
||||||
|
# `specs/*/plan.md` if any exist, otherwise emits the section without a
|
||||||
|
# concrete plan path.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(pwd)"
|
||||||
|
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
|
||||||
|
DEFAULT_START="<!-- SPECKIT START -->"
|
||||||
|
DEFAULT_END="<!-- SPECKIT END -->"
|
||||||
|
|
||||||
|
if [[ ! -f "$EXT_CONFIG" ]]; then
|
||||||
|
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Locate a suitable Python interpreter (python3, then python).
|
||||||
|
_python=""
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
_python="python3"
|
||||||
|
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
|
||||||
|
_python="python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$_python" ]]; then
|
||||||
|
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse extension config once; emit three newline-separated fields:
|
||||||
|
# context_file, context_markers.start, context_markers.end
|
||||||
|
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"agent-context: PyYAML is required to parse extension config but is not available "
|
||||||
|
"in the current Python environment.\n"
|
||||||
|
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
|
||||||
|
" Context file will not be updated until PyYAML is importable.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
def get_str(obj, *keys):
|
||||||
|
node = obj
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(node, dict) and k in node:
|
||||||
|
node = node[k]
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
return node if isinstance(node, str) else ""
|
||||||
|
print(get_str(data, "context_file"))
|
||||||
|
print(get_str(data, "context_markers", "start"))
|
||||||
|
print(get_str(data, "context_markers", "end"))
|
||||||
|
PY
|
||||||
|
)"; then
|
||||||
|
echo "agent-context: skipping update (see above for details)." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_opts_lines=()
|
||||||
|
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||||
|
_opts_lines+=("$_line")
|
||||||
|
done < <(printf '%s\n' "$_raw_opts")
|
||||||
|
if (( ${#_opts_lines[@]} < 3 )); then
|
||||||
|
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
CONTEXT_FILE="${_opts_lines[0]}"
|
||||||
|
MARKER_START="${_opts_lines[1]}"
|
||||||
|
MARKER_END="${_opts_lines[2]}"
|
||||||
|
|
||||||
|
if [[ -z "$CONTEXT_FILE" ]]; then
|
||||||
|
echo "agent-context: context_file not set in extension config; nothing to do." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reject absolute paths, backslash separators, and '..' path segments in context_file
|
||||||
|
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
|
||||||
|
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$CONTEXT_FILE" == *\\* ]]; then
|
||||||
|
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
|
||||||
|
for _seg in "${_cf_parts[@]}"; do
|
||||||
|
if [[ "$_seg" == ".." ]]; then
|
||||||
|
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
unset _cf_parts _seg
|
||||||
|
|
||||||
|
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
|
||||||
|
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
|
||||||
|
|
||||||
|
PLAN_PATH="${1:-}"
|
||||||
|
if [[ -z "$PLAN_PATH" ]]; then
|
||||||
|
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
|
||||||
|
# Use find + sort by modification time to avoid ls/head fragility with
|
||||||
|
# spaces in paths or SIGPIPE from pipefail.
|
||||||
|
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||||
|
import sys, os
|
||||||
|
from pathlib import Path
|
||||||
|
specs = Path(sys.argv[1]) / "specs"
|
||||||
|
plans = sorted(
|
||||||
|
specs.glob("*/plan.md"),
|
||||||
|
key=lambda p: p.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
print(plans[0] if plans else "")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "$_plan_abs" ]]; then
|
||||||
|
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
|
||||||
|
mkdir -p "$(dirname "$CTX_PATH")"
|
||||||
|
|
||||||
|
# Build the managed section
|
||||||
|
TMP_SECTION="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP_SECTION"' EXIT
|
||||||
|
{
|
||||||
|
echo "$MARKER_START"
|
||||||
|
echo "For additional context about technologies to be used, project structure,"
|
||||||
|
echo "shell commands, and other important information, read the current plan"
|
||||||
|
if [[ -n "$PLAN_PATH" ]]; then
|
||||||
|
echo "at $PLAN_PATH"
|
||||||
|
fi
|
||||||
|
echo "$MARKER_END"
|
||||||
|
} > "$TMP_SECTION"
|
||||||
|
|
||||||
|
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
|
||||||
|
import sys, os
|
||||||
|
ctx_path, start, end, section_path = sys.argv[1:5]
|
||||||
|
with open(section_path, "r", encoding="utf-8") as fh:
|
||||||
|
section = fh.read().rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
if os.path.exists(ctx_path):
|
||||||
|
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
s = content.find(start)
|
||||||
|
e = content.find(end, s if s != -1 else 0)
|
||||||
|
if s != -1 and e != -1 and e > s:
|
||||||
|
end_of_marker = e + len(end)
|
||||||
|
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||||
|
end_of_marker += 1
|
||||||
|
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||||
|
end_of_marker += 1
|
||||||
|
new_content = content[:s] + section + content[end_of_marker:]
|
||||||
|
elif s != -1:
|
||||||
|
new_content = content[:s] + section
|
||||||
|
elif e != -1:
|
||||||
|
end_of_marker = e + len(end)
|
||||||
|
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||||
|
end_of_marker += 1
|
||||||
|
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||||
|
end_of_marker += 1
|
||||||
|
new_content = section + content[end_of_marker:]
|
||||||
|
else:
|
||||||
|
if content and not content.endswith("\n"):
|
||||||
|
content += "\n"
|
||||||
|
new_content = (content + "\n" + section) if content else section
|
||||||
|
else:
|
||||||
|
new_content = section
|
||||||
|
|
||||||
|
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
with open(ctx_path, "wb") as fh:
|
||||||
|
fh.write(new_content.encode("utf-8"))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "agent-context: updated $CONTEXT_FILE"
|
||||||
@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# update-agent-context.ps1
|
||||||
|
#
|
||||||
|
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||||
|
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||||
|
#
|
||||||
|
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||||
|
# agent-context extension config:
|
||||||
|
# .specify/extensions/agent-context/agent-context-config.yml
|
||||||
|
#
|
||||||
|
# Usage: update-agent-context.ps1 [plan_path]
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
[string]$PlanPath
|
||||||
|
)
|
||||||
|
|
||||||
|
function Get-ConfigValue {
|
||||||
|
param(
|
||||||
|
[AllowNull()][object]$Object,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $Object) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
if ($Object -is [System.Collections.IDictionary]) {
|
||||||
|
return $Object[$Key]
|
||||||
|
}
|
||||||
|
$prop = $Object.PSObject.Properties[$Key]
|
||||||
|
if ($prop) {
|
||||||
|
return $prop.Value
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ConfigObject {
|
||||||
|
param(
|
||||||
|
[AllowNull()][object]$Object
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $Object) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
if ($Object -is [System.Collections.IDictionary]) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if ($Object -is [System.Management.Automation.PSCustomObject]) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$DefaultStart = '<!-- SPECKIT START -->'
|
||||||
|
$DefaultEnd = '<!-- SPECKIT END -->'
|
||||||
|
$ProjectRoot = (Get-Location).Path
|
||||||
|
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $ExtConfig)) {
|
||||||
|
Write-Warning "agent-context: $ExtConfig not found; nothing to do."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$Options = $null
|
||||||
|
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
|
||||||
|
try {
|
||||||
|
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
# fall through to Python fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $Options) {
|
||||||
|
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
|
||||||
|
$pythonCmd = $null
|
||||||
|
foreach ($candidate in @('python3', 'python')) {
|
||||||
|
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
|
||||||
|
# Verify it is Python 3
|
||||||
|
$verOut = & $candidate --version 2>&1
|
||||||
|
if ($verOut -match 'Python 3') {
|
||||||
|
$pythonCmd = $candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pythonCmd) {
|
||||||
|
try {
|
||||||
|
$jsonOut = & $pythonCmd -c @'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"agent-context: PyYAML is required to parse extension config; cannot update context.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
print(json.dumps(data))
|
||||||
|
'@ $ExtConfig
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
|
||||||
|
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$Options = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Options) {
|
||||||
|
Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-ConfigObject -Object $Options)) {
|
||||||
|
Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
|
||||||
|
if (-not $ContextFile) {
|
||||||
|
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reject absolute paths and '..' path segments in context_file
|
||||||
|
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
|
||||||
|
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$cfSegments = $ContextFile -split '[/\\]'
|
||||||
|
if ($cfSegments -contains '..') {
|
||||||
|
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$MarkerStart = $DefaultStart
|
||||||
|
$MarkerEnd = $DefaultEnd
|
||||||
|
$cm = Get-ConfigValue -Object $Options -Key 'context_markers'
|
||||||
|
if ($cm) {
|
||||||
|
$cmStart = Get-ConfigValue -Object $cm -Key 'start'
|
||||||
|
if ($cmStart -is [string] -and $cmStart) {
|
||||||
|
$MarkerStart = $cmStart
|
||||||
|
}
|
||||||
|
$cmEnd = Get-ConfigValue -Object $cm -Key 'end'
|
||||||
|
if ($cmEnd -is [string] -and $cmEnd) {
|
||||||
|
$MarkerEnd = $cmEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PlanPath) {
|
||||||
|
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
|
||||||
|
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
|
||||||
|
# $ErrorActionPreference = 'Stop' don't abort the script.
|
||||||
|
try {
|
||||||
|
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||||
|
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||||
|
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||||
|
Where-Object { $_ } |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($candidate) {
|
||||||
|
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Non-fatal: continue without a plan path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$CtxPath = Join-Path $ProjectRoot $ContextFile
|
||||||
|
$CtxDir = Split-Path -Parent $CtxPath
|
||||||
|
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = @($MarkerStart,
|
||||||
|
'For additional context about technologies to be used, project structure,',
|
||||||
|
'shell commands, and other important information, read the current plan')
|
||||||
|
if ($PlanPath) {
|
||||||
|
$lines += "at $PlanPath"
|
||||||
|
}
|
||||||
|
$lines += $MarkerEnd
|
||||||
|
$Section = ($lines -join "`n") + "`n"
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $CtxPath) {
|
||||||
|
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
|
||||||
|
# Strip UTF-8 BOM if present
|
||||||
|
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
|
||||||
|
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
|
||||||
|
} else {
|
||||||
|
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
$s = $content.IndexOf($MarkerStart)
|
||||||
|
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
|
||||||
|
|
||||||
|
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
|
||||||
|
$endOfMarker = $e + $MarkerEnd.Length
|
||||||
|
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||||
|
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||||
|
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
|
||||||
|
} elseif ($s -ge 0) {
|
||||||
|
$newContent = $content.Substring(0, $s) + $Section
|
||||||
|
} elseif ($e -ge 0) {
|
||||||
|
$endOfMarker = $e + $MarkerEnd.Length
|
||||||
|
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||||
|
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||||
|
$newContent = $Section + $content.Substring($endOfMarker)
|
||||||
|
} else {
|
||||||
|
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
|
||||||
|
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$newContent = $Section
|
||||||
|
}
|
||||||
|
|
||||||
|
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||||
|
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
|
||||||
|
Write-Host "agent-context: updated $ContextFile"
|
||||||
100
.specify/extensions/git/README.md
Normal file
100
.specify/extensions/git/README.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Git Branching Workflow Extension
|
||||||
|
|
||||||
|
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||||
|
|
||||||
|
- **Repository initialization** with configurable commit messages
|
||||||
|
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||||
|
- **Branch validation** to ensure branches follow naming conventions
|
||||||
|
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||||
|
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
|
||||||
|
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
||||||
|
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
||||||
|
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
||||||
|
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
| Event | Command | Optional | Description |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
|
||||||
|
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
||||||
|
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
|
||||||
|
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
|
||||||
|
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
|
||||||
|
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
|
||||||
|
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
|
||||||
|
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
|
||||||
|
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
|
||||||
|
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
|
||||||
|
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
|
||||||
|
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
|
||||||
|
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
|
||||||
|
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
|
||||||
|
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
|
||||||
|
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
|
||||||
|
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
|
||||||
|
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Branch numbering strategy: "sequential" or "timestamp"
|
||||||
|
branch_numbering: sequential
|
||||||
|
|
||||||
|
# Custom commit message for git init
|
||||||
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
|
# Auto-commit per command (all disabled by default)
|
||||||
|
# Example: enable auto-commit after specify
|
||||||
|
auto_commit:
|
||||||
|
default: false
|
||||||
|
after_specify:
|
||||||
|
enabled: true
|
||||||
|
message: "[Spec Kit] Add specification"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the bundled git extension (no network required)
|
||||||
|
specify extension add git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Disable the git extension (spec creation continues without branching)
|
||||||
|
specify extension disable git
|
||||||
|
|
||||||
|
# Re-enable it
|
||||||
|
specify extension enable git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
When Git is not installed or the directory is not a Git repository:
|
||||||
|
- Spec directories are still created under `specs/`
|
||||||
|
- Branch creation is skipped with a warning
|
||||||
|
- Branch validation is skipped with a warning
|
||||||
|
- Remote detection returns empty results
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
The extension bundles cross-platform scripts:
|
||||||
|
|
||||||
|
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||||
|
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||||
|
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||||
|
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||||
48
.specify/extensions/git/commands/speckit.git.commit.md
Normal file
48
.specify/extensions/git/commands/speckit.git.commit.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
description: "Auto-commit changes after a Spec Kit command completes"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Auto-Commit Changes
|
||||||
|
|
||||||
|
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
This command is invoked as a hook after (or before) core commands. It:
|
||||||
|
|
||||||
|
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||||
|
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||||
|
3. Looks up the specific event key to see if auto-commit is enabled
|
||||||
|
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||||
|
5. Uses the per-command `message` if configured, otherwise a default message
|
||||||
|
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Determine the event name from the hook that triggered this command, then run the script:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||||
|
|
||||||
|
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In `.specify/extensions/git/git-config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto_commit:
|
||||||
|
default: false # Global toggle — set true to enable for all commands
|
||||||
|
after_specify:
|
||||||
|
enabled: true # Override per-command
|
||||||
|
message: "[Spec Kit] Add specification"
|
||||||
|
after_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add implementation plan"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||||
|
- If no config file exists: skips (disabled by default)
|
||||||
|
- If no changes to commit: skips with a message
|
||||||
67
.specify/extensions/git/commands/speckit.git.feature.md
Normal file
67
.specify/extensions/git/commands/speckit.git.feature.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
description: "Create a feature branch with sequential or timestamp numbering"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Feature Branch
|
||||||
|
|
||||||
|
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Environment Variable Override
|
||||||
|
|
||||||
|
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||||
|
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||||
|
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||||
|
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, warn the user and skip branch creation
|
||||||
|
|
||||||
|
## Branch Numbering Mode
|
||||||
|
|
||||||
|
Determine the branch numbering strategy by checking configuration in this order:
|
||||||
|
|
||||||
|
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||||
|
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||||
|
3. Default to `sequential` if neither exists
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Generate a concise short name (2-4 words) for the branch:
|
||||||
|
- Analyze the feature description and extract the most meaningful keywords
|
||||||
|
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||||
|
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||||
|
|
||||||
|
Run the appropriate script based on your platform:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||||
|
|
||||||
|
**IMPORTANT**:
|
||||||
|
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||||
|
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||||
|
- You must only ever run this script once per feature
|
||||||
|
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the current directory is not a Git repository:
|
||||||
|
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||||
|
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The script outputs JSON with:
|
||||||
|
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
|
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||||
49
.specify/extensions/git/commands/speckit.git.initialize.md
Normal file
49
.specify/extensions/git/commands/speckit.git.initialize.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: "Initialize a Git repository with an initial commit"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Initialize Git Repository
|
||||||
|
|
||||||
|
Initialize a Git repository in the current project directory if one does not already exist.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Run the appropriate script from the project root:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||||
|
|
||||||
|
If the extension scripts are not found, fall back to:
|
||||||
|
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||||
|
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||||
|
|
||||||
|
The script handles all checks internally:
|
||||||
|
- Skips if Git is not available
|
||||||
|
- Skips if already inside a Git repository
|
||||||
|
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
Replace the script to add project-specific Git initialization steps:
|
||||||
|
- Custom `.gitignore` templates
|
||||||
|
- Default branch naming (`git config init.defaultBranch`)
|
||||||
|
- Git LFS setup
|
||||||
|
- Git hooks installation
|
||||||
|
- Commit signing configuration
|
||||||
|
- Git Flow initialization
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
On success:
|
||||||
|
- `[OK] Git repository initialized`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed:
|
||||||
|
- Warn the user
|
||||||
|
- Skip repository initialization
|
||||||
|
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||||
|
|
||||||
|
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||||
|
- Surface the error to the user
|
||||||
|
- Stop this command rather than continuing with a partially initialized repository
|
||||||
45
.specify/extensions/git/commands/speckit.git.remote.md
Normal file
45
.specify/extensions/git/commands/speckit.git.remote.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
description: "Detect Git remote URL for GitHub integration"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Detect Git Remote URL
|
||||||
|
|
||||||
|
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and return empty:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Run the following command to get the remote URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Parse the remote URL and determine:
|
||||||
|
|
||||||
|
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||||
|
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||||
|
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||||
|
|
||||||
|
Supported URL formats:
|
||||||
|
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||||
|
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||||
|
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||||
|
- Return an empty result
|
||||||
|
- Do NOT error — other workflows should continue without Git remote information
|
||||||
49
.specify/extensions/git/commands/speckit.git.validate.md
Normal file
49
.specify/extensions/git/commands/speckit.git.validate.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: "Validate current branch follows feature branch naming conventions"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validate Feature Branch
|
||||||
|
|
||||||
|
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and skip validation:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; skipped branch validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Get the current branch name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch name must match one of these patterns:
|
||||||
|
|
||||||
|
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||||
|
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
If on a feature branch (matches either pattern):
|
||||||
|
- Output: `✓ On feature branch: <branch-name>`
|
||||||
|
- Check if the corresponding spec directory exists under `specs/`:
|
||||||
|
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||||
|
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||||
|
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||||
|
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||||
|
|
||||||
|
If NOT on a feature branch:
|
||||||
|
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||||
|
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the directory is not a Git repository:
|
||||||
|
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||||
|
- If set, validate that value against the naming patterns
|
||||||
|
- If not set, skip validation with a warning
|
||||||
62
.specify/extensions/git/config-template.yml
Normal file
62
.specify/extensions/git/config-template.yml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Git Branching Workflow Extension Configuration
|
||||||
|
# Copied to .specify/extensions/git/git-config.yml on install
|
||||||
|
|
||||||
|
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||||
|
branch_numbering: sequential
|
||||||
|
|
||||||
|
# Commit message used by `git commit` during repository initialization
|
||||||
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
|
# Auto-commit before/after core commands.
|
||||||
|
# Set "default" to enable for all commands, then override per-command.
|
||||||
|
# Each key can be true/false. Message is customizable per-command.
|
||||||
|
auto_commit:
|
||||||
|
default: false
|
||||||
|
before_clarify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before clarification"
|
||||||
|
before_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before planning"
|
||||||
|
before_tasks:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before task generation"
|
||||||
|
before_implement:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before implementation"
|
||||||
|
before_checklist:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before checklist"
|
||||||
|
before_analyze:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before analysis"
|
||||||
|
before_taskstoissues:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before issue sync"
|
||||||
|
after_constitution:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add project constitution"
|
||||||
|
after_specify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add specification"
|
||||||
|
after_clarify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Clarify specification"
|
||||||
|
after_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add implementation plan"
|
||||||
|
after_tasks:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add tasks"
|
||||||
|
after_implement:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Implementation progress"
|
||||||
|
after_checklist:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add checklist"
|
||||||
|
after_analyze:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add analysis report"
|
||||||
|
after_taskstoissues:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Sync tasks to issues"
|
||||||
140
.specify/extensions/git/extension.yml
Normal file
140
.specify/extensions/git/extension.yml
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
extension:
|
||||||
|
id: git
|
||||||
|
name: "Git Branching Workflow"
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||||
|
author: spec-kit-core
|
||||||
|
repository: https://github.com/github/spec-kit
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.2.0"
|
||||||
|
tools:
|
||||||
|
- name: git
|
||||||
|
required: false
|
||||||
|
|
||||||
|
provides:
|
||||||
|
commands:
|
||||||
|
- name: speckit.git.feature
|
||||||
|
file: commands/speckit.git.feature.md
|
||||||
|
description: "Create a feature branch with sequential or timestamp numbering"
|
||||||
|
- name: speckit.git.validate
|
||||||
|
file: commands/speckit.git.validate.md
|
||||||
|
description: "Validate current branch follows feature branch naming conventions"
|
||||||
|
- name: speckit.git.remote
|
||||||
|
file: commands/speckit.git.remote.md
|
||||||
|
description: "Detect Git remote URL for GitHub integration"
|
||||||
|
- name: speckit.git.initialize
|
||||||
|
file: commands/speckit.git.initialize.md
|
||||||
|
description: "Initialize a Git repository with an initial commit"
|
||||||
|
- name: speckit.git.commit
|
||||||
|
file: commands/speckit.git.commit.md
|
||||||
|
description: "Auto-commit changes after a Spec Kit command completes"
|
||||||
|
|
||||||
|
config:
|
||||||
|
- name: "git-config.yml"
|
||||||
|
template: "config-template.yml"
|
||||||
|
description: "Git branching configuration"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
before_constitution:
|
||||||
|
command: speckit.git.initialize
|
||||||
|
optional: false
|
||||||
|
description: "Initialize Git repository before constitution setup"
|
||||||
|
before_specify:
|
||||||
|
command: speckit.git.feature
|
||||||
|
optional: false
|
||||||
|
description: "Create feature branch before specification"
|
||||||
|
before_clarify:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before clarification?"
|
||||||
|
description: "Auto-commit before spec clarification"
|
||||||
|
before_plan:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before planning?"
|
||||||
|
description: "Auto-commit before implementation planning"
|
||||||
|
before_tasks:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before task generation?"
|
||||||
|
description: "Auto-commit before task generation"
|
||||||
|
before_implement:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before implementation?"
|
||||||
|
description: "Auto-commit before implementation"
|
||||||
|
before_checklist:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before checklist?"
|
||||||
|
description: "Auto-commit before checklist generation"
|
||||||
|
before_analyze:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before analysis?"
|
||||||
|
description: "Auto-commit before analysis"
|
||||||
|
before_taskstoissues:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit outstanding changes before issue sync?"
|
||||||
|
description: "Auto-commit before tasks-to-issues conversion"
|
||||||
|
after_constitution:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit constitution changes?"
|
||||||
|
description: "Auto-commit after constitution update"
|
||||||
|
after_specify:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit specification changes?"
|
||||||
|
description: "Auto-commit after specification"
|
||||||
|
after_clarify:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit clarification changes?"
|
||||||
|
description: "Auto-commit after spec clarification"
|
||||||
|
after_plan:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit plan changes?"
|
||||||
|
description: "Auto-commit after implementation planning"
|
||||||
|
after_tasks:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit task changes?"
|
||||||
|
description: "Auto-commit after task generation"
|
||||||
|
after_implement:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit implementation changes?"
|
||||||
|
description: "Auto-commit after implementation"
|
||||||
|
after_checklist:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit checklist changes?"
|
||||||
|
description: "Auto-commit after checklist generation"
|
||||||
|
after_analyze:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit analysis results?"
|
||||||
|
description: "Auto-commit after analysis"
|
||||||
|
after_taskstoissues:
|
||||||
|
command: speckit.git.commit
|
||||||
|
optional: true
|
||||||
|
prompt: "Commit after syncing issues?"
|
||||||
|
description: "Auto-commit after tasks-to-issues conversion"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- "git"
|
||||||
|
- "branching"
|
||||||
|
- "workflow"
|
||||||
|
|
||||||
|
config:
|
||||||
|
defaults:
|
||||||
|
branch_numbering: sequential
|
||||||
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
62
.specify/extensions/git/git-config.yml
Normal file
62
.specify/extensions/git/git-config.yml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Git Branching Workflow Extension Configuration
|
||||||
|
# Copied to .specify/extensions/git/git-config.yml on install
|
||||||
|
|
||||||
|
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||||
|
branch_numbering: sequential
|
||||||
|
|
||||||
|
# Commit message used by `git commit` during repository initialization
|
||||||
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
|
# Auto-commit before/after core commands.
|
||||||
|
# Set "default" to enable for all commands, then override per-command.
|
||||||
|
# Each key can be true/false. Message is customizable per-command.
|
||||||
|
auto_commit:
|
||||||
|
default: false
|
||||||
|
before_clarify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before clarification"
|
||||||
|
before_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before planning"
|
||||||
|
before_tasks:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before task generation"
|
||||||
|
before_implement:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before implementation"
|
||||||
|
before_checklist:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before checklist"
|
||||||
|
before_analyze:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before analysis"
|
||||||
|
before_taskstoissues:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Save progress before issue sync"
|
||||||
|
after_constitution:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add project constitution"
|
||||||
|
after_specify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add specification"
|
||||||
|
after_clarify:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Clarify specification"
|
||||||
|
after_plan:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add implementation plan"
|
||||||
|
after_tasks:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add tasks"
|
||||||
|
after_implement:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Implementation progress"
|
||||||
|
after_checklist:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add checklist"
|
||||||
|
after_analyze:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Add analysis report"
|
||||||
|
after_taskstoissues:
|
||||||
|
enabled: false
|
||||||
|
message: "[Spec Kit] Sync tasks to issues"
|
||||||
140
.specify/extensions/git/scripts/bash/auto-commit.sh
Executable file
140
.specify/extensions/git/scripts/bash/auto-commit.sh
Executable file
@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Git extension: auto-commit.sh
|
||||||
|
# Automatically commit changes after a Spec Kit command completes.
|
||||||
|
# Checks per-command config keys in git-config.yml before committing.
|
||||||
|
#
|
||||||
|
# Usage: auto-commit.sh <event_name>
|
||||||
|
# e.g.: auto-commit.sh after_specify
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
EVENT_NAME="${1:-}"
|
||||||
|
if [ -z "$EVENT_NAME" ]; then
|
||||||
|
echo "Usage: $0 <event_name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
_find_project_root() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Check if git is available
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read per-command config from git-config.yml
|
||||||
|
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||||
|
_enabled=false
|
||||||
|
_commit_msg=""
|
||||||
|
|
||||||
|
if [ -f "$_config_file" ]; then
|
||||||
|
# Parse the auto_commit section for this event.
|
||||||
|
# Look for auto_commit.<event_name>.enabled and .message
|
||||||
|
# Also check auto_commit.default as fallback.
|
||||||
|
_in_auto_commit=false
|
||||||
|
_in_event=false
|
||||||
|
_default_enabled=false
|
||||||
|
|
||||||
|
while IFS= read -r _line; do
|
||||||
|
# Detect auto_commit: section
|
||||||
|
if echo "$_line" | grep -q '^auto_commit:'; then
|
||||||
|
_in_auto_commit=true
|
||||||
|
_in_event=false
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Exit auto_commit section on next top-level key
|
||||||
|
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $_in_auto_commit; then
|
||||||
|
# Check default key
|
||||||
|
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
||||||
|
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||||
|
[ "$_val" = "true" ] && _default_enabled=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect our event subsection
|
||||||
|
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
||||||
|
_in_event=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inside our event subsection
|
||||||
|
if $_in_event; then
|
||||||
|
# Exit on next sibling key (same indent level as event name)
|
||||||
|
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
||||||
|
_in_event=false
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
||||||
|
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||||
|
[ "$_val" = "true" ] && _enabled=true
|
||||||
|
[ "$_val" = "false" ] && _enabled=false
|
||||||
|
fi
|
||||||
|
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||||
|
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "$_config_file"
|
||||||
|
|
||||||
|
# If event-specific key not found, use default
|
||||||
|
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
||||||
|
# Only use default if the event wasn't explicitly set to false
|
||||||
|
# Check if event section existed at all
|
||||||
|
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
||||||
|
_enabled=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No config file — auto-commit disabled by default
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_enabled" != "true" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if there are changes to commit
|
||||||
|
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
||||||
|
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derive a human-readable command name from the event
|
||||||
|
# e.g., after_specify -> specify, before_plan -> plan
|
||||||
|
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||||
|
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
||||||
|
|
||||||
|
# Use custom message if configured, otherwise default
|
||||||
|
if [ -z "$_commit_msg" ]; then
|
||||||
|
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage and commit
|
||||||
|
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||||
|
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
||||||
453
.specify/extensions/git/scripts/bash/create-new-feature.sh
Executable file
453
.specify/extensions/git/scripts/bash/create-new-feature.sh
Executable file
@ -0,0 +1,453 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Git extension: create-new-feature.sh
|
||||||
|
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||||
|
# Sources common.sh from the project's installed scripts, falling back to
|
||||||
|
# git-common.sh for minimal git helpers.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JSON_MODE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
ALLOW_EXISTING=false
|
||||||
|
SHORT_NAME=""
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
|
ARGS=()
|
||||||
|
i=1
|
||||||
|
while [ $i -le $# ]; do
|
||||||
|
arg="${!i}"
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
;;
|
||||||
|
--allow-existing-branch)
|
||||||
|
ALLOW_EXISTING=true
|
||||||
|
;;
|
||||||
|
--short-name)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SHORT_NAME="$next_arg"
|
||||||
|
;;
|
||||||
|
--number)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BRANCH_NUMBER="$next_arg"
|
||||||
|
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo 'Error: --number must be a non-negative integer' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
--timestamp)
|
||||||
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --json Output in JSON format"
|
||||||
|
echo " --dry-run Compute branch name without creating the branch"
|
||||||
|
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||||
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
echo " --help, -h Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
|
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim whitespace and validate description is not empty
|
||||||
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to get highest number from specs directory
|
||||||
|
get_highest_from_specs() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
if [ -d "$specs_dir" ]; then
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
dirname=$(basename "$dir")
|
||||||
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||||
|
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from git branches
|
||||||
|
get_highest_from_branches() {
|
||||||
|
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||||
|
_extract_highest_number() {
|
||||||
|
local highest=0
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[ -z "$name" ] && continue
|
||||||
|
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||||
|
get_highest_from_remote_refs() {
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
for remote in $(git remote 2>/dev/null); do
|
||||||
|
local remote_highest
|
||||||
|
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||||
|
if [ "$remote_highest" -gt "$highest" ]; then
|
||||||
|
highest=$remote_highest
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check existing branches and return next available number.
|
||||||
|
check_existing_branches() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local skip_fetch="${2:-false}"
|
||||||
|
|
||||||
|
if [ "$skip_fetch" = true ]; then
|
||||||
|
local highest_remote=$(get_highest_from_remote_refs)
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||||
|
highest_branch=$highest_remote
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
fi
|
||||||
|
|
||||||
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
|
|
||||||
|
local max_num=$highest_branch
|
||||||
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||||
|
max_num=$highest_spec
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo $((max_num + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean and format a branch name
|
||||||
|
clean_branch_name() {
|
||||||
|
local name="$1"
|
||||||
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
|
||||||
|
#
|
||||||
|
# Search locations in priority order:
|
||||||
|
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
|
||||||
|
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
|
||||||
|
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Find project root by walking up from the script location
|
||||||
|
_find_project_root() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_common_loaded=false
|
||||||
|
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
|
||||||
|
|
||||||
|
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
|
||||||
|
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
|
||||||
|
source "$_PROJECT_ROOT/scripts/bash/common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
||||||
|
source "$SCRIPT_DIR/git-common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_common_loaded" != "true" ]; then
|
||||||
|
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve repository root
|
||||||
|
if type get_repo_root >/dev/null 2>&1; then
|
||||||
|
REPO_ROOT=$(get_repo_root)
|
||||||
|
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
elif [ -n "$_PROJECT_ROOT" ]; then
|
||||||
|
REPO_ROOT="$_PROJECT_ROOT"
|
||||||
|
else
|
||||||
|
echo "Error: Could not determine repository root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if git is available at this repo root
|
||||||
|
if type has_git >/dev/null 2>&1; then
|
||||||
|
if has_git "$REPO_ROOT"; then
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
SPECS_DIR="$REPO_ROOT/specs"
|
||||||
|
|
||||||
|
# Function to generate branch name with stop word filtering
|
||||||
|
generate_branch_name() {
|
||||||
|
local description="$1"
|
||||||
|
|
||||||
|
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||||
|
|
||||||
|
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||||
|
|
||||||
|
local meaningful_words=()
|
||||||
|
for word in $clean_name; do
|
||||||
|
[ -z "$word" ] && continue
|
||||||
|
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||||
|
if [ ${#word} -ge 3 ]; then
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
elif echo "$description" | grep -qw -- "${word^^}"; then
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||||
|
local max_words=3
|
||||||
|
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||||
|
|
||||||
|
local result=""
|
||||||
|
local count=0
|
||||||
|
for word in "${meaningful_words[@]}"; do
|
||||||
|
if [ $count -ge $max_words ]; then break; fi
|
||||||
|
if [ -n "$result" ]; then result="$result-"; fi
|
||||||
|
result="$result$word"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "$result"
|
||||||
|
else
|
||||||
|
local cleaned=$(clean_branch_name "$description")
|
||||||
|
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||||
|
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||||
|
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||||
|
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||||
|
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||||
|
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||||
|
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||||
|
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||||
|
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||||
|
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||||
|
else
|
||||||
|
FEATURE_NUM="$BRANCH_NAME"
|
||||||
|
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Generate branch name
|
||||||
|
if [ -n "$SHORT_NAME" ]; then
|
||||||
|
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||||
|
else
|
||||||
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if --number and --timestamp are both specified
|
||||||
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
else
|
||||||
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
|
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||||
|
elif [ "$DRY_RUN" = true ]; then
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
elif [ "$HAS_GIT" = true ]; then
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
|
else
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
|
MAX_BRANCH_LENGTH=244
|
||||||
|
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
||||||
|
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||||
|
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
|
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||||
|
exit 1
|
||||||
|
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||||
|
|
||||||
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||||
|
|
||||||
|
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||||
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
if [ "$HAS_GIT" = true ]; then
|
||||||
|
branch_create_error=""
|
||||||
|
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||||
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$ALLOW_EXISTING" = true ]; then
|
||||||
|
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||||
|
:
|
||||||
|
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||||
|
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||||
|
if [ -n "$switch_branch_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$switch_branch_error"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||||
|
if [ -n "$branch_create_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$branch_create_error"
|
||||||
|
else
|
||||||
|
>&2 echo "Please check your git configuration and try again."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||||
|
else
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if type json_escape >/dev/null 2>&1; then
|
||||||
|
_je_branch=$(json_escape "$BRANCH_NAME")
|
||||||
|
_je_num=$(json_escape "$FEATURE_NUM")
|
||||||
|
else
|
||||||
|
_je_branch="$BRANCH_NAME"
|
||||||
|
_je_num="$FEATURE_NUM"
|
||||||
|
fi
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
||||||
|
else
|
||||||
|
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||||
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
54
.specify/extensions/git/scripts/bash/git-common.sh
Executable file
54
.specify/extensions/git/scripts/bash/git-common.sh
Executable file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Git-specific common functions for the git extension.
|
||||||
|
# Extracted from scripts/bash/common.sh — contains only git-specific
|
||||||
|
# branch validation and detection logic.
|
||||||
|
|
||||||
|
# Check if we have git available at the repo root
|
||||||
|
has_git() {
|
||||||
|
local repo_root="${1:-$(pwd)}"
|
||||||
|
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
|
||||||
|
command -v git >/dev/null 2>&1 && \
|
||||||
|
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||||
|
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||||
|
spec_kit_effective_branch_name() {
|
||||||
|
local raw="$1"
|
||||||
|
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||||
|
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$raw"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate that a branch name matches the expected feature branch pattern.
|
||||||
|
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||||
|
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||||
|
check_feature_branch() {
|
||||||
|
local raw="$1"
|
||||||
|
local has_git_repo="$2"
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if [[ "$has_git_repo" != "true" ]]; then
|
||||||
|
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(spec_kit_effective_branch_name "$raw")
|
||||||
|
|
||||||
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
|
local is_sequential=false
|
||||||
|
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||||
|
is_sequential=true
|
||||||
|
fi
|
||||||
|
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
|
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||||
|
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
54
.specify/extensions/git/scripts/bash/initialize-repo.sh
Executable file
54
.specify/extensions/git/scripts/bash/initialize-repo.sh
Executable file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Git extension: initialize-repo.sh
|
||||||
|
# Initialize a Git repository with an initial commit.
|
||||||
|
# Customizable — replace this script to add .gitignore templates,
|
||||||
|
# default branch config, git-flow, LFS, signing, etc.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Find project root
|
||||||
|
_find_project_root() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Read commit message from extension config, fall back to default
|
||||||
|
COMMIT_MSG="[Spec Kit] Initial commit"
|
||||||
|
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||||
|
if [ -f "$_config_file" ]; then
|
||||||
|
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||||
|
if [ -n "$_msg" ]; then
|
||||||
|
COMMIT_MSG="$_msg"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if git is available
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if already a git repo
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "[specify] Git repository already initialized; skipping" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
|
||||||
|
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||||
|
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "✓ Git repository initialized" >&2
|
||||||
169
.specify/extensions/git/scripts/powershell/auto-commit.ps1
Normal file
169
.specify/extensions/git/scripts/powershell/auto-commit.ps1
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Git extension: auto-commit.ps1
|
||||||
|
# Automatically commit changes after a Spec Kit command completes.
|
||||||
|
# Checks per-command config keys in git-config.yml before committing.
|
||||||
|
#
|
||||||
|
# Usage: auto-commit.ps1 <event_name>
|
||||||
|
# e.g.: auto-commit.ps1 after_specify
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0, Mandatory = $true)]
|
||||||
|
[string]$EventName
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Find-ProjectRoot {
|
||||||
|
param([string]$StartDir)
|
||||||
|
$current = Resolve-Path $StartDir
|
||||||
|
while ($true) {
|
||||||
|
foreach ($marker in @('.specify', '.git')) {
|
||||||
|
if (Test-Path (Join-Path $current $marker)) {
|
||||||
|
return $current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$parent = Split-Path $current -Parent
|
||||||
|
if ($parent -eq $current) { return $null }
|
||||||
|
$current = $parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||||
|
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
# Check if git is available
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Temporarily relax ErrorActionPreference so git stderr warnings
|
||||||
|
# (e.g. CRLF notices on Windows) do not become terminating errors.
|
||||||
|
$savedEAP = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||||
|
$isRepo = $LASTEXITCODE -eq 0
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $savedEAP
|
||||||
|
}
|
||||||
|
if (-not $isRepo) {
|
||||||
|
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read per-command config from git-config.yml
|
||||||
|
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||||
|
$enabled = $false
|
||||||
|
$commitMsg = ""
|
||||||
|
|
||||||
|
if (Test-Path $configFile) {
|
||||||
|
# Parse YAML to find auto_commit section
|
||||||
|
$inAutoCommit = $false
|
||||||
|
$inEvent = $false
|
||||||
|
$defaultEnabled = $false
|
||||||
|
|
||||||
|
foreach ($line in Get-Content $configFile) {
|
||||||
|
# Detect auto_commit: section
|
||||||
|
if ($line -match '^auto_commit:') {
|
||||||
|
$inAutoCommit = $true
|
||||||
|
$inEvent = $false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exit auto_commit section on next top-level key
|
||||||
|
if ($inAutoCommit -and $line -match '^[a-z]') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inAutoCommit) {
|
||||||
|
# Check default key
|
||||||
|
if ($line -match '^\s+default:\s*(.+)$') {
|
||||||
|
$val = $matches[1].Trim().ToLower()
|
||||||
|
if ($val -eq 'true') { $defaultEnabled = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect our event subsection
|
||||||
|
if ($line -match "^\s+${EventName}:") {
|
||||||
|
$inEvent = $true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inside our event subsection
|
||||||
|
if ($inEvent) {
|
||||||
|
# Exit on next sibling key (2-space indent, not 4+)
|
||||||
|
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
|
||||||
|
$inEvent = $false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($line -match '\s+enabled:\s*(.+)$') {
|
||||||
|
$val = $matches[1].Trim().ToLower()
|
||||||
|
if ($val -eq 'true') { $enabled = $true }
|
||||||
|
if ($val -eq 'false') { $enabled = $false }
|
||||||
|
}
|
||||||
|
if ($line -match '\s+message:\s*(.+)$') {
|
||||||
|
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If event-specific key not found, use default
|
||||||
|
if (-not $enabled -and $defaultEnabled) {
|
||||||
|
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
|
||||||
|
if (-not $hasEventKey) {
|
||||||
|
$enabled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# No config file -- auto-commit disabled by default
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $enabled) {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if there are changes to commit
|
||||||
|
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
|
||||||
|
$savedEAP = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
||||||
|
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
||||||
|
$untracked = git ls-files --others --exclude-standard 2>$null
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $savedEAP
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||||
|
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Derive a human-readable command name from the event
|
||||||
|
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||||
|
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||||
|
|
||||||
|
# Use custom message if configured, otherwise default
|
||||||
|
if (-not $commitMsg) {
|
||||||
|
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage and commit
|
||||||
|
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
|
||||||
|
# while still allowing redirected error output to be captured for diagnostics.
|
||||||
|
$savedEAP = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
$out = git add . 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||||
|
$out = git commit -q -m $commitMsg 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||||
|
} catch {
|
||||||
|
Write-Warning "[specify] Error: $_"
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $savedEAP
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[OK] Changes committed $phase $commandName"
|
||||||
@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Git extension: create-new-feature.ps1
|
||||||
|
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||||
|
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||||
|
# git-common.ps1 for minimal git helpers.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Json,
|
||||||
|
[switch]$AllowExistingBranch,
|
||||||
|
[switch]$DryRun,
|
||||||
|
[string]$ShortName,
|
||||||
|
[Parameter()]
|
||||||
|
[long]$Number = 0,
|
||||||
|
[switch]$Timestamp,
|
||||||
|
[switch]$Help,
|
||||||
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
|
[string[]]$FeatureDescription
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if ($Help) {
|
||||||
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Options:"
|
||||||
|
Write-Host " -Json Output in JSON format"
|
||||||
|
Write-Host " -DryRun Compute branch name without creating the branch"
|
||||||
|
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||||
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
Write-Host " -Help Show this help message"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Environment variables:"
|
||||||
|
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||||
|
Write-Host ""
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||||
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||||
|
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromSpecs {
|
||||||
|
param([string]$SpecsDir)
|
||||||
|
|
||||||
|
[long]$highest = 0
|
||||||
|
if (Test-Path $SpecsDir) {
|
||||||
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||||
|
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
[long]$num = 0
|
||||||
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromNames {
|
||||||
|
param([string[]]$Names)
|
||||||
|
|
||||||
|
[long]$highest = 0
|
||||||
|
foreach ($name in $Names) {
|
||||||
|
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
[long]$num = 0
|
||||||
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromBranches {
|
||||||
|
param()
|
||||||
|
|
||||||
|
try {
|
||||||
|
$branches = git branch -a 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||||
|
$cleanNames = $branches | ForEach-Object {
|
||||||
|
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
}
|
||||||
|
return Get-HighestNumberFromNames -Names $cleanNames
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Verbose "Could not check Git branches: $_"
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromRemoteRefs {
|
||||||
|
[long]$highest = 0
|
||||||
|
try {
|
||||||
|
$remotes = git remote 2>$null
|
||||||
|
if ($remotes) {
|
||||||
|
foreach ($remote in $remotes) {
|
||||||
|
$env:GIT_TERMINAL_PROMPT = '0'
|
||||||
|
$refs = git ls-remote --heads $remote 2>$null
|
||||||
|
$env:GIT_TERMINAL_PROMPT = $null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||||
|
$refNames = $refs | ForEach-Object {
|
||||||
|
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||||
|
} | Where-Object { $_ }
|
||||||
|
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||||
|
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Verbose "Could not query remote refs: $_"
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NextBranchNumber {
|
||||||
|
param(
|
||||||
|
[string]$SpecsDir,
|
||||||
|
[switch]$SkipFetch
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($SkipFetch) {
|
||||||
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
|
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||||
|
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
git fetch --all --prune 2>$null | Out-Null
|
||||||
|
} catch { }
|
||||||
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
|
}
|
||||||
|
|
||||||
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||||
|
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||||
|
return $maxNum + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanBranchName {
|
||||||
|
param([string]$Name)
|
||||||
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Source common.ps1 from the project's installed scripts.
|
||||||
|
# Search locations in priority order:
|
||||||
|
# 1. .specify/scripts/powershell/common.ps1 under the project root
|
||||||
|
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
||||||
|
# 3. git-common.ps1 next to this script (minimal fallback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function Find-ProjectRoot {
|
||||||
|
param([string]$StartDir)
|
||||||
|
$current = Resolve-Path $StartDir
|
||||||
|
while ($true) {
|
||||||
|
foreach ($marker in @('.specify', '.git')) {
|
||||||
|
if (Test-Path (Join-Path $current $marker)) {
|
||||||
|
return $current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$parent = Split-Path $current -Parent
|
||||||
|
if ($parent -eq $current) { return $null }
|
||||||
|
$current = $parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||||
|
$commonLoaded = $false
|
||||||
|
|
||||||
|
if ($projectRoot) {
|
||||||
|
$candidates = @(
|
||||||
|
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
|
||||||
|
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
||||||
|
)
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-Path $candidate) {
|
||||||
|
. $candidate
|
||||||
|
$commonLoaded = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
||||||
|
. "$PSScriptRoot/git-common.ps1"
|
||||||
|
$commonLoaded = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $commonLoaded) {
|
||||||
|
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repository root
|
||||||
|
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
||||||
|
$repoRoot = Get-RepoRoot
|
||||||
|
} elseif ($projectRoot) {
|
||||||
|
$repoRoot = $projectRoot
|
||||||
|
} else {
|
||||||
|
throw "Could not determine repository root."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if git is available
|
||||||
|
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||||
|
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
||||||
|
# and git-common.ps1 (has -RepoRoot param with default).
|
||||||
|
$hasGit = Test-HasGit
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||||
|
$hasGit = ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
$hasGit = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
$specsDir = Join-Path $repoRoot 'specs'
|
||||||
|
|
||||||
|
function Get-BranchName {
|
||||||
|
param([string]$Description)
|
||||||
|
|
||||||
|
$stopWords = @(
|
||||||
|
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||||
|
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||||
|
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||||
|
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||||
|
'want', 'need', 'add', 'get', 'set'
|
||||||
|
)
|
||||||
|
|
||||||
|
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||||
|
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||||
|
|
||||||
|
$meaningfulWords = @()
|
||||||
|
foreach ($word in $words) {
|
||||||
|
if ($stopWords -contains $word) { continue }
|
||||||
|
if ($word.Length -ge 3) {
|
||||||
|
$meaningfulWords += $word
|
||||||
|
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||||
|
$meaningfulWords += $word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($meaningfulWords.Count -gt 0) {
|
||||||
|
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||||
|
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||||
|
return $result
|
||||||
|
} else {
|
||||||
|
$result = ConvertTo-CleanBranchName -Name $Description
|
||||||
|
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||||
|
return [string]::Join('-', $fallbackWords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||||
|
if ($env:GIT_BRANCH_NAME) {
|
||||||
|
$branchName = $env:GIT_BRANCH_NAME
|
||||||
|
# Check 244-byte limit (UTF-8) for override names
|
||||||
|
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||||
|
if ($branchNameUtf8ByteCount -gt 244) {
|
||||||
|
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||||
|
}
|
||||||
|
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||||
|
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||||
|
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||||
|
$featureNum = $matches[1]
|
||||||
|
} elseif ($branchName -match '^(\d+)-') {
|
||||||
|
$featureNum = $matches[1]
|
||||||
|
} else {
|
||||||
|
$featureNum = $branchName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($ShortName) {
|
||||||
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||||
|
} else {
|
||||||
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Timestamp -and $Number -ne 0) {
|
||||||
|
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||||
|
$Number = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Timestamp) {
|
||||||
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
} else {
|
||||||
|
if ($Number -eq 0) {
|
||||||
|
if ($DryRun -and $hasGit) {
|
||||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||||
|
} elseif ($DryRun) {
|
||||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
|
} elseif ($hasGit) {
|
||||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||||
|
} else {
|
||||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureNum = ('{0:000}' -f $Number)
|
||||||
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxBranchLength = 244
|
||||||
|
if ($branchName.Length -gt $maxBranchLength) {
|
||||||
|
$prefixLength = $featureNum.Length + 1
|
||||||
|
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||||
|
|
||||||
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||||
|
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||||
|
|
||||||
|
$originalBranchName = $branchName
|
||||||
|
$branchName = "$featureNum-$truncatedSuffix"
|
||||||
|
|
||||||
|
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||||
|
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $DryRun) {
|
||||||
|
if ($hasGit) {
|
||||||
|
$branchCreated = $false
|
||||||
|
$branchCreateError = ''
|
||||||
|
try {
|
||||||
|
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$branchCreated = $true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$branchCreateError = $_.Exception.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $branchCreated) {
|
||||||
|
$currentBranch = ''
|
||||||
|
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||||
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
|
if ($existingBranch) {
|
||||||
|
if ($AllowExistingBranch) {
|
||||||
|
if ($currentBranch -eq $branchName) {
|
||||||
|
# Already on the target branch
|
||||||
|
} else {
|
||||||
|
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
if ($switchBranchError) {
|
||||||
|
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($Timestamp) {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($branchCreateError) {
|
||||||
|
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($Json) {
|
||||||
|
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
||||||
|
} else {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:SPECIFY_FEATURE = $branchName
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Json) {
|
||||||
|
$obj = [PSCustomObject]@{
|
||||||
|
BRANCH_NAME = $branchName
|
||||||
|
FEATURE_NUM = $featureNum
|
||||||
|
HAS_GIT = $hasGit
|
||||||
|
}
|
||||||
|
if ($DryRun) {
|
||||||
|
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||||
|
}
|
||||||
|
$obj | ConvertTo-Json -Compress
|
||||||
|
} else {
|
||||||
|
Write-Output "BRANCH_NAME: $branchName"
|
||||||
|
Write-Output "FEATURE_NUM: $featureNum"
|
||||||
|
Write-Output "HAS_GIT: $hasGit"
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
.specify/extensions/git/scripts/powershell/git-common.ps1
Normal file
51
.specify/extensions/git/scripts/powershell/git-common.ps1
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Git-specific common functions for the git extension.
|
||||||
|
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
|
||||||
|
# branch validation and detection logic.
|
||||||
|
|
||||||
|
function Test-HasGit {
|
||||||
|
param([string]$RepoRoot = (Get-Location))
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
||||||
|
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SpecKitEffectiveBranchName {
|
||||||
|
param([string]$Branch)
|
||||||
|
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||||
|
return $Matches[2]
|
||||||
|
}
|
||||||
|
return $Branch
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FeatureBranch {
|
||||||
|
param(
|
||||||
|
[string]$Branch,
|
||||||
|
[bool]$HasGit = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if (-not $HasGit) {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $Branch
|
||||||
|
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||||
|
|
||||||
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
|
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||||
|
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||||
|
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||||
|
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Git extension: initialize-repo.ps1
|
||||||
|
# Initialize a Git repository with an initial commit.
|
||||||
|
# Customizable -- replace this script to add .gitignore templates,
|
||||||
|
# default branch config, git-flow, LFS, signing, etc.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Find project root
|
||||||
|
function Find-ProjectRoot {
|
||||||
|
param([string]$StartDir)
|
||||||
|
$current = Resolve-Path $StartDir
|
||||||
|
while ($true) {
|
||||||
|
foreach ($marker in @('.specify', '.git')) {
|
||||||
|
if (Test-Path (Join-Path $current $marker)) {
|
||||||
|
return $current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$parent = Split-Path $current -Parent
|
||||||
|
if ($parent -eq $current) { return $null }
|
||||||
|
$current = $parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||||
|
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
# Read commit message from extension config, fall back to default
|
||||||
|
$commitMsg = "[Spec Kit] Initial commit"
|
||||||
|
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||||
|
if (Test-Path $configFile) {
|
||||||
|
foreach ($line in Get-Content $configFile) {
|
||||||
|
if ($line -match '^init_commit_message:\s*(.+)$') {
|
||||||
|
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||||
|
if ($val) { $commitMsg = $val }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if git is available
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if already a git repo
|
||||||
|
try {
|
||||||
|
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Warning "[specify] Git repository already initialized; skipping"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
try {
|
||||||
|
$out = git init -q 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
|
||||||
|
$out = git add . 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||||
|
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||||
|
} catch {
|
||||||
|
Write-Warning "[specify] Error: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[OK] Git repository initialized"
|
||||||
9
.specify/init-options.json
Normal file
9
.specify/init-options.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ai": "codex",
|
||||||
|
"ai_skills": true,
|
||||||
|
"branch_numbering": "sequential",
|
||||||
|
"here": true,
|
||||||
|
"integration": "codex",
|
||||||
|
"script": "sh",
|
||||||
|
"speckit_version": "0.9.5"
|
||||||
|
}
|
||||||
19
.specify/integration.json
Normal file
19
.specify/integration.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "0.9.5",
|
||||||
|
"integration_state_schema": 1,
|
||||||
|
"installed_integrations": [
|
||||||
|
"codex"
|
||||||
|
],
|
||||||
|
"integration_settings": {
|
||||||
|
"codex": {
|
||||||
|
"script": "sh",
|
||||||
|
"raw_options": "--skills",
|
||||||
|
"parsed_options": {
|
||||||
|
"skills": true
|
||||||
|
},
|
||||||
|
"invoke_separator": "-"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration": "codex",
|
||||||
|
"default_integration": "codex"
|
||||||
|
}
|
||||||
16
.specify/integrations/codex.manifest.json
Normal file
16
.specify/integrations/codex.manifest.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"integration": "codex",
|
||||||
|
"version": "0.9.5",
|
||||||
|
"installed_at": "2026-06-08T01:33:59.539838+00:00",
|
||||||
|
"files": {
|
||||||
|
".agents/skills/speckit-analyze/SKILL.md": "753f1d49d830abc130132ad2864c780ea61fd57bbc71aa9888be24fdf0774800",
|
||||||
|
".agents/skills/speckit-clarify/SKILL.md": "08e643cb56c88adf1f4b28821d490360186f6bc0dfb1f21a059e16e4b8e89b91",
|
||||||
|
".agents/skills/speckit-constitution/SKILL.md": "e2cbe859958c5a05be52a44d63821e6a84d39f3d37acc05b550cc7ad85da0dab",
|
||||||
|
".agents/skills/speckit-implement/SKILL.md": "796ab9a7f04281fee7d390087e89438f4215cbe2396a8a0118dafd12c0268894",
|
||||||
|
".agents/skills/speckit-plan/SKILL.md": "67bfc751600f8ba46c8cf6fd32e609e1b5a468ab7eb62e26aaae70009ecb89f0",
|
||||||
|
".agents/skills/speckit-checklist/SKILL.md": "734393e5698b390283db49135e1140d6ad529b65eae439bb0a53bc5acab2b529",
|
||||||
|
".agents/skills/speckit-specify/SKILL.md": "e74c7b705bebbdf457d0b01e928a4d4f25bd3f77b8c650a2ef3c463e706550ec",
|
||||||
|
".agents/skills/speckit-tasks/SKILL.md": "bb461317a2b17eda72250202197a8307e519a24cd22758d3091389c70d869af1",
|
||||||
|
".agents/skills/speckit-taskstoissues/SKILL.md": "a3efcf92cf532420c10abf7b9253204ade34b7329888c28e271fa3da7750c584"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
.specify/integrations/speckit.manifest.json
Normal file
17
.specify/integrations/speckit.manifest.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"integration": "speckit",
|
||||||
|
"version": "0.9.5",
|
||||||
|
"installed_at": "2026-06-08T01:33:59.545362+00:00",
|
||||||
|
"files": {
|
||||||
|
".specify/scripts/bash/check-prerequisites.sh": "f4541a00257f035aa55a9fede6d964e51e6851c3dc2f81d0a6f367db18944765",
|
||||||
|
".specify/scripts/bash/setup-tasks.sh": "7aeee15192a5ab3ba9ff3c3ae450d9994043bf0493c1eabc840da72a9742fc87",
|
||||||
|
".specify/scripts/bash/setup-plan.sh": "b23cca3d769a217ab812a6059adb549622471f6893af234cf98ca2019ac4e1a1",
|
||||||
|
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
|
||||||
|
".specify/scripts/bash/common.sh": "1b52fdc114424b83784d59477256e1854c23ee3135273625904eb0231cc0c37e",
|
||||||
|
".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7",
|
||||||
|
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||||
|
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
|
||||||
|
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
|
||||||
|
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
.specify/memory/constitution.md
Normal file
50
.specify/memory/constitution.md
Normal file
@ -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 -->
|
||||||
192
.specify/scripts/bash/check-prerequisites.sh
Executable file
192
.specify/scripts/bash/check-prerequisites.sh
Executable file
@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Consolidated prerequisite checking script
|
||||||
|
#
|
||||||
|
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||||
|
# It replaces the functionality previously spread across multiple scripts.
|
||||||
|
#
|
||||||
|
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# OPTIONS:
|
||||||
|
# --json Output in JSON format
|
||||||
|
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
|
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
|
# --paths-only Only output path variables (no validation)
|
||||||
|
# --help, -h Show help message
|
||||||
|
#
|
||||||
|
# OUTPUTS:
|
||||||
|
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||||
|
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||||
|
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
REQUIRE_TASKS=false
|
||||||
|
INCLUDE_TASKS=false
|
||||||
|
PATHS_ONLY=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--require-tasks)
|
||||||
|
REQUIRE_TASKS=true
|
||||||
|
;;
|
||||||
|
--include-tasks)
|
||||||
|
INCLUDE_TASKS=true
|
||||||
|
;;
|
||||||
|
--paths-only)
|
||||||
|
PATHS_ONLY=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat << 'EOF'
|
||||||
|
Usage: check-prerequisites.sh [OPTIONS]
|
||||||
|
|
||||||
|
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--json Output in JSON format
|
||||||
|
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
|
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
|
--paths-only Only output path variables (no prerequisite validation)
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
# Check task prerequisites (plan.md required)
|
||||||
|
./check-prerequisites.sh --json
|
||||||
|
|
||||||
|
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||||
|
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
|
|
||||||
|
# Get feature paths only (no validation)
|
||||||
|
./check-prerequisites.sh --paths-only
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get feature paths
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# If paths-only mode, output paths and exit (no validation)
|
||||||
|
if $PATHS_ONLY; then
|
||||||
|
if $JSON_MODE; then
|
||||||
|
# Minimal JSON paths payload (no validation performed)
|
||||||
|
if has_jq; then
|
||||||
|
jq -cn \
|
||||||
|
--arg repo_root "$REPO_ROOT" \
|
||||||
|
--arg branch "$CURRENT_BRANCH" \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--arg feature_spec "$FEATURE_SPEC" \
|
||||||
|
--arg impl_plan "$IMPL_PLAN" \
|
||||||
|
--arg tasks "$TASKS" \
|
||||||
|
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
|
||||||
|
else
|
||||||
|
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||||
|
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "REPO_ROOT: $REPO_ROOT"
|
||||||
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
|
echo "TASKS: $TASKS"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate branch name
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
|
||||||
|
# Validate required directories and files
|
||||||
|
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||||
|
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit-specify first to create the feature structure." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit-plan first to create the implementation plan." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for tasks.md if required
|
||||||
|
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||||
|
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit-tasks first to create the task list." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build list of available documents
|
||||||
|
docs=()
|
||||||
|
|
||||||
|
# Always check these optional docs
|
||||||
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
|
||||||
|
# Check contracts directory (only if it exists and has files)
|
||||||
|
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||||
|
docs+=("contracts/")
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
|
# Include tasks.md if requested and it exists
|
||||||
|
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||||
|
docs+=("tasks.md")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
# Build JSON array of documents
|
||||||
|
if has_jq; then
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--argjson docs "$json_docs" \
|
||||||
|
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||||
|
else
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
|
json_docs="[${json_docs%,}]"
|
||||||
|
fi
|
||||||
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Text output
|
||||||
|
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||||
|
echo "AVAILABLE_DOCS:"
|
||||||
|
|
||||||
|
# Show status of each potential document
|
||||||
|
check_file "$RESEARCH" "research.md"
|
||||||
|
check_file "$DATA_MODEL" "data-model.md"
|
||||||
|
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||||
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
|
||||||
|
if $INCLUDE_TASKS; then
|
||||||
|
check_file "$TASKS" "tasks.md"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
721
.specify/scripts/bash/common.sh
Executable file
721
.specify/scripts/bash/common.sh
Executable file
@ -0,0 +1,721 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Common functions and variables for all scripts
|
||||||
|
|
||||||
|
# Find repository root by searching upward for .specify directory
|
||||||
|
# This is the primary marker for spec-kit projects
|
||||||
|
find_specify_root() {
|
||||||
|
local dir="${1:-$(pwd)}"
|
||||||
|
# Normalize to absolute path to prevent infinite loop with relative paths
|
||||||
|
# Use -- to handle paths starting with - (e.g., -P, -L)
|
||||||
|
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
||||||
|
local prev_dir=""
|
||||||
|
while true; do
|
||||||
|
if [ -d "$dir/.specify" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Stop if we've reached filesystem root or dirname stops changing
|
||||||
|
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
prev_dir="$dir"
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get repository root, prioritizing .specify directory over git
|
||||||
|
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||||
|
get_repo_root() {
|
||||||
|
# First, look for .specify directory (spec-kit's own marker)
|
||||||
|
local specify_root
|
||||||
|
if specify_root=$(find_specify_root); then
|
||||||
|
echo "$specify_root"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to git if no .specify found
|
||||||
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final fallback to script location for non-git repos
|
||||||
|
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
(cd "$script_dir/../../.." && pwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current branch, with fallback for non-git repositories
|
||||||
|
get_current_branch() {
|
||||||
|
# First check if SPECIFY_FEATURE environment variable is set
|
||||||
|
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||||
|
echo "$SPECIFY_FEATURE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
if has_git; then
|
||||||
|
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For non-git repos, try to find the latest feature directory
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
local latest_feature=""
|
||||||
|
local highest=0
|
||||||
|
local latest_timestamp=""
|
||||||
|
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
local dirname=$(basename "$dir")
|
||||||
|
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
local ts="${BASH_REMATCH[1]}"
|
||||||
|
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||||
|
latest_timestamp="$ts"
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
local number=${BASH_REMATCH[1]}
|
||||||
|
number=$((10#$number))
|
||||||
|
if [[ "$number" -gt "$highest" ]]; then
|
||||||
|
highest=$number
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if [[ -z "$latest_timestamp" ]]; then
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$latest_feature" ]]; then
|
||||||
|
echo "$latest_feature"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "main" # Final fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we have git available at the spec-kit root level
|
||||||
|
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||||
|
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||||
|
has_git() {
|
||||||
|
# First check if git command is available (before calling get_repo_root which may use git)
|
||||||
|
command -v git >/dev/null 2>&1 || return 1
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
# Check if .git exists (directory or file for worktrees/submodules)
|
||||||
|
[ -e "$repo_root/.git" ] || return 1
|
||||||
|
# Verify it's actually a valid git work tree
|
||||||
|
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||||
|
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||||
|
spec_kit_effective_branch_name() {
|
||||||
|
local raw="$1"
|
||||||
|
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||||
|
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$raw"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_feature_branch() {
|
||||||
|
local raw="$1"
|
||||||
|
local has_git_repo="$2"
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if [[ "$has_git_repo" != "true" ]]; then
|
||||||
|
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(spec_kit_effective_branch_name "$raw")
|
||||||
|
|
||||||
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
|
local is_sequential=false
|
||||||
|
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||||
|
is_sequential=true
|
||||||
|
fi
|
||||||
|
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
|
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||||
|
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Safely read .specify/feature.json's "feature_directory" value.
|
||||||
|
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
||||||
|
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
||||||
|
# under `set -e` cannot be aborted by parser failure.
|
||||||
|
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
||||||
|
read_feature_json_feature_directory() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local fj="$repo_root/.specify/feature.json"
|
||||||
|
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||||
|
|
||||||
|
local _fd=''
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||||
|
_fd=''
|
||||||
|
fi
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||||
|
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||||
|
_fd=''
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||||
|
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||||
|
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||||
|
| head -n 1 \
|
||||||
|
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$_fd"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||||
|
# and matches the resolved active FEATURE_DIR (so /speckit-plan can skip git branch pattern checks).
|
||||||
|
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||||
|
feature_json_matches_feature_dir() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local active_feature_dir="$2"
|
||||||
|
|
||||||
|
local _fd
|
||||||
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
|
|
||||||
|
[[ -n "$_fd" ]] || return 1
|
||||||
|
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||||
|
[[ -d "$_fd" ]] || return 1
|
||||||
|
|
||||||
|
local norm_json norm_active
|
||||||
|
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
|
||||||
|
[[ "$norm_json" == "$norm_active" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find feature directory by numeric prefix instead of exact branch match
|
||||||
|
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||||
|
find_feature_dir_by_prefix() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local branch_name
|
||||||
|
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||||
|
local prefix=""
|
||||||
|
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Search for directories in specs/ that start with this prefix
|
||||||
|
local matches=()
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
for dir in "$specs_dir"/"$prefix"-*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
matches+=("$(basename "$dir")")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle results
|
||||||
|
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||||
|
# No match found - return the branch name path (will fail later with clear error)
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||||
|
# Exactly one match - perfect!
|
||||||
|
echo "$specs_dir/${matches[0]}"
|
||||||
|
else
|
||||||
|
# Multiple matches - this shouldn't happen with proper naming convention
|
||||||
|
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||||
|
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_feature_paths() {
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
local current_branch=$(get_current_branch)
|
||||||
|
local has_git_repo="false"
|
||||||
|
|
||||||
|
if has_git; then
|
||||||
|
has_git_repo="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve feature directory. Priority:
|
||||||
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
|
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit-specify)
|
||||||
|
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||||
|
local feature_dir
|
||||||
|
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||||
|
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||||
|
# Normalize relative paths to absolute under repo root
|
||||||
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
|
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||||
|
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||||
|
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||||
|
local _fd
|
||||||
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
|
if [[ -n "$_fd" ]]; then
|
||||||
|
feature_dir="$_fd"
|
||||||
|
# Normalize relative paths to absolute under repo root
|
||||||
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use printf '%q' to safely quote values, preventing shell injection
|
||||||
|
# via crafted branch names or paths containing special characters
|
||||||
|
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||||
|
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||||
|
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||||
|
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||||
|
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||||
|
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||||
|
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||||||
|
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||||||
|
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||||||
|
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||||||
|
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if jq is available for safe JSON construction
|
||||||
|
has_jq() {
|
||||||
|
command -v jq >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
get_invoke_separator() {
|
||||||
|
local repo_root="${1:-$(get_repo_root)}"
|
||||||
|
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||||
|
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local integration_json="$repo_root/.specify/integration.json"
|
||||||
|
local separator="."
|
||||||
|
local parsed_with_jq=0
|
||||||
|
|
||||||
|
if [[ -f "$integration_json" ]]; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
local jq_separator
|
||||||
|
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
||||||
|
parsed_with_jq=1
|
||||||
|
case "$jq_separator" in
|
||||||
|
"."|"-") separator="$jq_separator" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||||
|
state = json.load(fh)
|
||||||
|
key = state.get("default_integration") or state.get("integration") or ""
|
||||||
|
settings = state.get("integration_settings")
|
||||||
|
separator = "."
|
||||||
|
if isinstance(key, str) and isinstance(settings, dict):
|
||||||
|
entry = settings.get(key)
|
||||||
|
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||||
|
separator = entry["invoke_separator"]
|
||||||
|
print(separator)
|
||||||
|
except Exception:
|
||||||
|
print(".")
|
||||||
|
PY
|
||||||
|
); then
|
||||||
|
case "$separator" in
|
||||||
|
"."|"-") ;;
|
||||||
|
*) separator="." ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
separator="."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||||
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||||
|
printf '%s\n' "$separator"
|
||||||
|
}
|
||||||
|
|
||||||
|
format_speckit_command() {
|
||||||
|
local command_name="$1"
|
||||||
|
local repo_root="${2:-$(get_repo_root)}"
|
||||||
|
local separator
|
||||||
|
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||||
|
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||||
|
else
|
||||||
|
separator=$(get_invoke_separator "$repo_root")
|
||||||
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||||
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||||
|
fi
|
||||||
|
|
||||||
|
command_name="${command_name#/}"
|
||||||
|
command_name="${command_name#speckit.}"
|
||||||
|
command_name="${command_name#speckit-}"
|
||||||
|
command_name="${command_name//./$separator}"
|
||||||
|
|
||||||
|
printf '/speckit%s%s\n' "$separator" "$command_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||||
|
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||||
|
json_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\\/\\\\}"
|
||||||
|
s="${s//\"/\\\"}"
|
||||||
|
s="${s//$'\n'/\\n}"
|
||||||
|
s="${s//$'\t'/\\t}"
|
||||||
|
s="${s//$'\r'/\\r}"
|
||||||
|
s="${s//$'\b'/\\b}"
|
||||||
|
s="${s//$'\f'/\\f}"
|
||||||
|
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
||||||
|
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
||||||
|
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
||||||
|
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
||||||
|
local LC_ALL=C
|
||||||
|
local i char code
|
||||||
|
for (( i=0; i<${#s}; i++ )); do
|
||||||
|
char="${s:$i:1}"
|
||||||
|
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
||||||
|
if (( code >= 1 && code <= 31 )); then
|
||||||
|
printf '\\u%04x' "$code"
|
||||||
|
else
|
||||||
|
printf '%s' "$char"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
|
||||||
|
# Resolve a template name to a file path using the priority stack:
|
||||||
|
# 1. .specify/templates/overrides/
|
||||||
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||||
|
# 3. .specify/extensions/<ext-id>/templates/
|
||||||
|
# 4. .specify/templates/ (core)
|
||||||
|
resolve_template() {
|
||||||
|
local template_name="$1"
|
||||||
|
local repo_root="$2"
|
||||||
|
local base="$repo_root/.specify/templates"
|
||||||
|
|
||||||
|
# Priority 1: Project overrides
|
||||||
|
local override="$base/overrides/${template_name}.md"
|
||||||
|
[ -f "$override" ] && echo "$override" && return 0
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
local presets_dir="$repo_root/.specify/presets"
|
||||||
|
if [ -d "$presets_dir" ]; then
|
||||||
|
local registry_file="$presets_dir/.registry"
|
||||||
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||||
|
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||||
|
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||||
|
local sorted_presets=""
|
||||||
|
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
|
import json, sys, os
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
presets = data.get('presets', {})
|
||||||
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||||
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||||
|
print(pid)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null); then
|
||||||
|
if [ -n "$sorted_presets" ]; then
|
||||||
|
# python3 succeeded and returned preset IDs — search in priority order
|
||||||
|
while IFS= read -r preset_id; do
|
||||||
|
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
fi
|
||||||
|
# python3 succeeded but registry has no presets — nothing to search
|
||||||
|
else
|
||||||
|
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fallback: alphabetical directory order (no python3 available)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates
|
||||||
|
local ext_dir="$repo_root/.specify/extensions"
|
||||||
|
if [ -d "$ext_dir" ]; then
|
||||||
|
for ext in "$ext_dir"/*/; do
|
||||||
|
[ -d "$ext" ] || continue
|
||||||
|
# Skip hidden directories (e.g. .backup, .cache)
|
||||||
|
case "$(basename "$ext")" in .*) continue;; esac
|
||||||
|
local candidate="$ext/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 4: Core templates
|
||||||
|
local core="$base/${template_name}.md"
|
||||||
|
[ -f "$core" ] && echo "$core" && return 0
|
||||||
|
|
||||||
|
# Template not found in any location.
|
||||||
|
# Return 1 so callers can distinguish "not found" from "found".
|
||||||
|
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve a template name to composed content using composition strategies.
|
||||||
|
# Reads strategy metadata from preset manifests and composes content
|
||||||
|
# from multiple layers using prepend, append, or wrap strategies.
|
||||||
|
#
|
||||||
|
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
||||||
|
# Returns composed content string on stdout; exit code 1 if not found.
|
||||||
|
resolve_template_content() {
|
||||||
|
local template_name="$1"
|
||||||
|
local repo_root="$2"
|
||||||
|
local base="$repo_root/.specify/templates"
|
||||||
|
|
||||||
|
# Collect all layers (highest priority first)
|
||||||
|
local -a layer_paths=()
|
||||||
|
local -a layer_strategies=()
|
||||||
|
|
||||||
|
# Priority 1: Project overrides (always "replace")
|
||||||
|
local override="$base/overrides/${template_name}.md"
|
||||||
|
if [ -f "$override" ]; then
|
||||||
|
layer_paths+=("$override")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
local presets_dir="$repo_root/.specify/presets"
|
||||||
|
if [ -d "$presets_dir" ]; then
|
||||||
|
local registry_file="$presets_dir/.registry"
|
||||||
|
local sorted_presets=""
|
||||||
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
|
import json, sys, os
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
presets = data.get('presets', {})
|
||||||
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||||
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||||
|
print(pid)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null); then
|
||||||
|
if [ -n "$sorted_presets" ]; then
|
||||||
|
local yaml_warned=false
|
||||||
|
while IFS= read -r preset_id; do
|
||||||
|
# Read strategy and file path from preset manifest
|
||||||
|
local strategy="replace"
|
||||||
|
local manifest_file=""
|
||||||
|
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||||
|
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Requires PyYAML; falls back to replace/convention if unavailable
|
||||||
|
local result
|
||||||
|
local py_stderr
|
||||||
|
py_stderr=$(mktemp)
|
||||||
|
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
||||||
|
import sys, os
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print('yaml_missing', file=sys.stderr)
|
||||||
|
print('replace\t')
|
||||||
|
sys.exit(0)
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
for t in data.get('provides', {}).get('templates', []):
|
||||||
|
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||||
|
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||||
|
sys.exit(0)
|
||||||
|
print('replace\t')
|
||||||
|
except Exception:
|
||||||
|
print('replace\t')
|
||||||
|
" 2>"$py_stderr")
|
||||||
|
local parse_status=$?
|
||||||
|
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
||||||
|
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
||||||
|
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||||
|
fi
|
||||||
|
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
||||||
|
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
||||||
|
yaml_warned=true
|
||||||
|
fi
|
||||||
|
rm -f "$py_stderr"
|
||||||
|
fi
|
||||||
|
# Try manifest file path first, then convention path
|
||||||
|
local candidate=""
|
||||||
|
if [ -n "$manifest_file" ]; then
|
||||||
|
# Reject absolute paths and parent traversal
|
||||||
|
case "$manifest_file" in
|
||||||
|
/*|*../*|../*) manifest_file="" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ -n "$manifest_file" ]; then
|
||||||
|
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||||
|
[ -f "$mf" ] && candidate="$mf"
|
||||||
|
fi
|
||||||
|
if [ -z "$candidate" ]; then
|
||||||
|
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
|
[ -f "$cf" ] && candidate="$cf"
|
||||||
|
fi
|
||||||
|
if [ -n "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("$strategy")
|
||||||
|
fi
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# python3 failed — fall back to unordered directory scan (replace only)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No python3 or registry — fall back to unordered directory scan (replace only)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates (always "replace")
|
||||||
|
local ext_dir="$repo_root/.specify/extensions"
|
||||||
|
if [ -d "$ext_dir" ]; then
|
||||||
|
for ext in "$ext_dir"/*/; do
|
||||||
|
[ -d "$ext" ] || continue
|
||||||
|
case "$(basename "$ext")" in .*) continue;; esac
|
||||||
|
local candidate="$ext/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 4: Core templates (always "replace")
|
||||||
|
local core="$base/${template_name}.md"
|
||||||
|
if [ -f "$core" ]; then
|
||||||
|
layer_paths+=("$core")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count=${#layer_paths[@]}
|
||||||
|
[ "$count" -eq 0 ] && return 1
|
||||||
|
|
||||||
|
# Check if any layer uses a non-replace strategy
|
||||||
|
local has_composition=false
|
||||||
|
for s in "${layer_strategies[@]}"; do
|
||||||
|
[ "$s" != "replace" ] && has_composition=true && break
|
||||||
|
done
|
||||||
|
|
||||||
|
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||||
|
# lower layers are irrelevant regardless of their strategies.
|
||||||
|
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||||
|
cat "${layer_paths[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_composition" = false ]; then
|
||||||
|
cat "${layer_paths[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the effective base: scan from highest priority (index 0) downward
|
||||||
|
# to find the nearest replace layer. Only compose layers above that base.
|
||||||
|
local base_idx=-1
|
||||||
|
local i
|
||||||
|
for (( i=0; i<count; i++ )); do
|
||||||
|
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
||||||
|
base_idx=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $base_idx -lt 0 ]; then
|
||||||
|
return 1 # no base layer found
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read the base content; compose layers above the base (higher priority)
|
||||||
|
local content
|
||||||
|
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||||
|
content="${content%x}"
|
||||||
|
|
||||||
|
for (( i=base_idx-1; i>=0; i-- )); do
|
||||||
|
local path="${layer_paths[$i]}"
|
||||||
|
local strat="${layer_strategies[$i]}"
|
||||||
|
local layer_content
|
||||||
|
# Preserve trailing newlines
|
||||||
|
layer_content=$(cat "$path"; printf x)
|
||||||
|
layer_content="${layer_content%x}"
|
||||||
|
|
||||||
|
case "$strat" in
|
||||||
|
replace) content="$layer_content" ;;
|
||||||
|
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||||
|
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
||||||
|
wrap)
|
||||||
|
case "$layer_content" in
|
||||||
|
*'{CORE_TEMPLATE}'*) ;;
|
||||||
|
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||||
|
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||||
|
local after="${layer_content#*\{CORE_TEMPLATE\}}"
|
||||||
|
layer_content="${before}${content}${after}"
|
||||||
|
done
|
||||||
|
content="$layer_content"
|
||||||
|
;;
|
||||||
|
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s' "$content"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
413
.specify/scripts/bash/create-new-feature.sh
Executable file
413
.specify/scripts/bash/create-new-feature.sh
Executable file
@ -0,0 +1,413 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JSON_MODE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
ALLOW_EXISTING=false
|
||||||
|
SHORT_NAME=""
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
|
ARGS=()
|
||||||
|
i=1
|
||||||
|
while [ $i -le $# ]; do
|
||||||
|
arg="${!i}"
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
;;
|
||||||
|
--allow-existing-branch)
|
||||||
|
ALLOW_EXISTING=true
|
||||||
|
;;
|
||||||
|
--short-name)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
# Check if the next argument is another option (starts with --)
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SHORT_NAME="$next_arg"
|
||||||
|
;;
|
||||||
|
--number)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BRANCH_NUMBER="$next_arg"
|
||||||
|
;;
|
||||||
|
--timestamp)
|
||||||
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --json Output in JSON format"
|
||||||
|
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||||
|
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||||
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
echo " --help, -h Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||||
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to get highest number from specs directory
|
||||||
|
get_highest_from_specs() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
if [ -d "$specs_dir" ]; then
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
dirname=$(basename "$dir")
|
||||||
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||||
|
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from git branches
|
||||||
|
get_highest_from_branches() {
|
||||||
|
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||||
|
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||||
|
_extract_highest_number() {
|
||||||
|
local highest=0
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[ -z "$name" ] && continue
|
||||||
|
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||||
|
get_highest_from_remote_refs() {
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
for remote in $(git remote 2>/dev/null); do
|
||||||
|
local remote_highest
|
||||||
|
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||||
|
if [ "$remote_highest" -gt "$highest" ]; then
|
||||||
|
highest=$remote_highest
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check existing branches (local and remote) and return next available number.
|
||||||
|
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||||
|
check_existing_branches() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local skip_fetch="${2:-false}"
|
||||||
|
|
||||||
|
if [ "$skip_fetch" = true ]; then
|
||||||
|
# Side-effect-free: query remotes via ls-remote
|
||||||
|
local highest_remote=$(get_highest_from_remote_refs)
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||||
|
highest_branch=$highest_remote
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
local max_num=$highest_branch
|
||||||
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||||
|
max_num=$highest_spec
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
echo $((max_num + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean and format a branch name
|
||||||
|
clean_branch_name() {
|
||||||
|
local name="$1"
|
||||||
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
REPO_ROOT=$(get_repo_root)
|
||||||
|
|
||||||
|
# Check if git is available at this repo root (not a parent)
|
||||||
|
if has_git; then
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
SPECS_DIR="$REPO_ROOT/specs"
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
mkdir -p "$SPECS_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to generate branch name with stop word filtering and length filtering
|
||||||
|
generate_branch_name() {
|
||||||
|
local description="$1"
|
||||||
|
|
||||||
|
# Common stop words to filter out
|
||||||
|
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||||
|
|
||||||
|
# Convert to lowercase and split into words
|
||||||
|
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||||
|
|
||||||
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||||
|
local meaningful_words=()
|
||||||
|
for word in $clean_name; do
|
||||||
|
# Skip empty words
|
||||||
|
[ -z "$word" ] && continue
|
||||||
|
|
||||||
|
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||||
|
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||||
|
if [ ${#word} -ge 3 ]; then
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||||
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we have meaningful words, use first 3-4 of them
|
||||||
|
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||||
|
local max_words=3
|
||||||
|
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||||
|
|
||||||
|
local result=""
|
||||||
|
local count=0
|
||||||
|
for word in "${meaningful_words[@]}"; do
|
||||||
|
if [ $count -ge $max_words ]; then break; fi
|
||||||
|
if [ -n "$result" ]; then result="$result-"; fi
|
||||||
|
result="$result$word"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "$result"
|
||||||
|
else
|
||||||
|
# Fallback to original logic if no meaningful words found
|
||||||
|
local cleaned=$(clean_branch_name "$description")
|
||||||
|
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate branch name
|
||||||
|
if [ -n "$SHORT_NAME" ]; then
|
||||||
|
# Use provided short name, just clean it up
|
||||||
|
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||||
|
else
|
||||||
|
# Generate from description with smart filtering
|
||||||
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if --number and --timestamp are both specified
|
||||||
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
else
|
||||||
|
# Determine branch number
|
||||||
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
|
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||||
|
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||||
|
elif [ "$DRY_RUN" = true ]; then
|
||||||
|
# Dry-run without git: local spec dirs only
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
elif [ "$HAS_GIT" = true ]; then
|
||||||
|
# Check existing branches on remotes
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
|
else
|
||||||
|
# Fall back to local directory check
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||||
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
|
# Validate and truncate if necessary
|
||||||
|
MAX_BRANCH_LENGTH=244
|
||||||
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
|
# Calculate how much we need to trim from suffix
|
||||||
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
|
# Truncate suffix at word boundary if possible
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||||
|
# Remove trailing hyphen if truncation created one
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||||
|
|
||||||
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||||
|
|
||||||
|
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||||
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
if [ "$HAS_GIT" = true ]; then
|
||||||
|
branch_create_error=""
|
||||||
|
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||||
|
# Check if branch already exists
|
||||||
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$ALLOW_EXISTING" = true ]; then
|
||||||
|
# If we're already on the branch, continue without another checkout.
|
||||||
|
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||||
|
:
|
||||||
|
# Otherwise switch to the existing branch instead of failing.
|
||||||
|
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||||
|
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||||
|
if [ -n "$switch_branch_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$switch_branch_error"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||||
|
if [ -n "$branch_create_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$branch_create_error"
|
||||||
|
else
|
||||||
|
>&2 echo "Please check your git configuration and try again."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$SPEC_FILE" ]; then
|
||||||
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||||
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||||
|
cp "$TEMPLATE" "$SPEC_FILE"
|
||||||
|
else
|
||||||
|
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||||
|
touch "$SPEC_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg spec_file "$SPEC_FILE" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||||
|
else
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg spec_file "$SPEC_FILE" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||||
|
else
|
||||||
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||||
|
echo "SPEC_FILE: $SPEC_FILE"
|
||||||
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
91
.specify/scripts/bash/setup-plan.sh
Executable file
91
.specify/scripts/bash/setup-plan.sh
Executable file
@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
ARGS=()
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json]"
|
||||||
|
echo " --json Output results in JSON format"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get script directory and load common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get all paths and variables from common functions
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the feature directory exists
|
||||||
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
|
# Copy plan template if plan doesn't already exist
|
||||||
|
if [[ -f "$IMPL_PLAN" ]]; then
|
||||||
|
if $JSON_MODE; then
|
||||||
|
echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2
|
||||||
|
else
|
||||||
|
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||||
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
|
if $JSON_MODE; then
|
||||||
|
echo "Copied plan template to $IMPL_PLAN" >&2
|
||||||
|
else
|
||||||
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if $JSON_MODE; then
|
||||||
|
echo "Warning: Plan template not found" >&2
|
||||||
|
else
|
||||||
|
echo "Warning: Plan template not found"
|
||||||
|
fi
|
||||||
|
# Create a basic plan file if template doesn't exist
|
||||||
|
touch "$IMPL_PLAN"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if has_jq; then
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_spec "$FEATURE_SPEC" \
|
||||||
|
--arg impl_plan "$IMPL_PLAN" \
|
||||||
|
--arg specs_dir "$FEATURE_DIR" \
|
||||||
|
--arg branch "$CURRENT_BRANCH" \
|
||||||
|
--arg has_git "$HAS_GIT" \
|
||||||
|
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||||
|
else
|
||||||
|
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||||
|
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
|
echo "SPECS_DIR: $FEATURE_DIR"
|
||||||
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
|
echo "HAS_GIT: $HAS_GIT"
|
||||||
|
fi
|
||||||
|
|
||||||
96
.specify/scripts/bash/setup-tasks.sh
Executable file
96
.specify/scripts/bash/setup-tasks.sh
Executable file
@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json) JSON_MODE=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json]"
|
||||||
|
echo " --json Output results in JSON format"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get feature paths
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# Validate branch
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit-plan first to create the implementation plan." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||||
|
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit-specify first to create the feature structure." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build available docs list
|
||||||
|
docs=()
|
||||||
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||||
|
docs+=("contracts/")
|
||||||
|
fi
|
||||||
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
|
# Resolve tasks template through override stack
|
||||||
|
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||||
|
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||||
|
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||||
|
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if has_jq; then
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--argjson docs "$json_docs" \
|
||||||
|
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||||
|
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||||
|
else
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
|
json_docs="[${json_docs%,}]"
|
||||||
|
fi
|
||||||
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||||
|
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
|
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||||
|
echo "AVAILABLE_DOCS:"
|
||||||
|
check_file "$RESEARCH" "research.md"
|
||||||
|
check_file "$DATA_MODEL" "data-model.md"
|
||||||
|
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||||
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
fi
|
||||||
40
.specify/templates/checklist-template.md
Normal file
40
.specify/templates/checklist-template.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Purpose**: [Brief description of what this checklist covers]
|
||||||
|
**Created**: [DATE]
|
||||||
|
**Feature**: [Link to spec.md or relevant documentation]
|
||||||
|
|
||||||
|
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============================================================================
|
||||||
|
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
|
||||||
|
|
||||||
|
- Check items off as completed: `[x]`
|
||||||
|
- Add comments or findings inline
|
||||||
|
- Link to relevant resources or documentation
|
||||||
|
- Items are numbered sequentially for easy reference
|
||||||
50
.specify/templates/constitution-template.md
Normal file
50
.specify/templates/constitution-template.md
Normal file
@ -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 -->
|
||||||
113
.specify/templates/plan-template.md
Normal file
113
.specify/templates/plan-template.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Implementation Plan: [FEATURE]
|
||||||
|
|
||||||
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
|
|
||||||
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[Extract from feature spec: primary requirement + technical approach from research]
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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] |
|
||||||
131
.specify/templates/spec-template.md
Normal file
131
.specify/templates/spec-template.md
Normal file
@ -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"]
|
||||||
252
.specify/templates/tasks-template.md
Normal file
252
.specify/templates/tasks-template.md
Normal file
@ -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
|
||||||
77
.specify/workflows/speckit/workflow.yml
Normal file
77
.specify/workflows/speckit/workflow.yml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "speckit"
|
||||||
|
name: "Full SDD Cycle"
|
||||||
|
version: "1.0.0"
|
||||||
|
author: "GitHub"
|
||||||
|
description: "Runs specify → plan → tasks → implement with review gates"
|
||||||
|
|
||||||
|
requires:
|
||||||
|
# 0.8.5 is the first release with engine-side resolution of the
|
||||||
|
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||||
|
# as a literal integration key and fail at dispatch.
|
||||||
|
speckit_version: ">=0.8.5"
|
||||||
|
integrations:
|
||||||
|
# The four commands below (specify, plan, tasks, implement) are core
|
||||||
|
# spec-kit commands provided by every integration. The list here is an
|
||||||
|
# advisory, non-exhaustive compatibility hint following the documented
|
||||||
|
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||||
|
# against any integration the project was initialized with, including
|
||||||
|
# ones not listed below, as long as that integration provides the four
|
||||||
|
# core commands referenced in ``steps``.
|
||||||
|
any:
|
||||||
|
- "claude"
|
||||||
|
- "copilot"
|
||||||
|
- "gemini"
|
||||||
|
- "opencode"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
spec:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
prompt: "Describe what you want to build"
|
||||||
|
integration:
|
||||||
|
type: string
|
||||||
|
default: "auto"
|
||||||
|
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
default: "full"
|
||||||
|
enum: ["full", "backend-only", "frontend-only"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: specify
|
||||||
|
command: speckit.specify
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: review-spec
|
||||||
|
type: gate
|
||||||
|
message: "Review the generated spec before planning."
|
||||||
|
options: [approve, reject]
|
||||||
|
on_reject: abort
|
||||||
|
|
||||||
|
- id: plan
|
||||||
|
command: speckit.plan
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: review-plan
|
||||||
|
type: gate
|
||||||
|
message: "Review the plan before generating tasks."
|
||||||
|
options: [approve, reject]
|
||||||
|
on_reject: abort
|
||||||
|
|
||||||
|
- id: tasks
|
||||||
|
command: speckit.tasks
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: implement
|
||||||
|
command: speckit.implement
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
13
.specify/workflows/workflow-registry.json
Normal file
13
.specify/workflows/workflow-registry.json
Normal file
@ -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-06-08T01:33:59.624631+00:00",
|
||||||
|
"updated_at": "2026-06-08T01:33:59.624637+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
458
2026-06-01-hermes-gateway-llm-design.md
Normal file
458
2026-06-01-hermes-gateway-llm-design.md
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
# Beaver Terminal WebSocket Integration Guide
|
||||||
|
|
||||||
|
Date: 2026-06-01
|
||||||
|
|
||||||
|
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Connect the small terminal device to Beaver through a text-only WebSocket channel.
|
||||||
|
|
||||||
|
The first acceptance target is simple:
|
||||||
|
|
||||||
|
1. The terminal opens a WebSocket connection to Beaver.
|
||||||
|
2. The terminal sends a `connect` frame with a stable `peer_id`.
|
||||||
|
3. The terminal sends one text `message` frame.
|
||||||
|
4. The terminal receives an `ack`.
|
||||||
|
5. The terminal receives the final assistant text response from Beaver.
|
||||||
|
6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session.
|
||||||
|
|
||||||
|
This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not implement audio streaming.
|
||||||
|
- Do not implement camera, screen, image, or multimodal frames.
|
||||||
|
- Do not implement token streaming.
|
||||||
|
- Do not implement terminal-side tools.
|
||||||
|
- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass.
|
||||||
|
- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint.
|
||||||
|
- Do not build an OpenAI-compatible proxy.
|
||||||
|
- Do not implement Hermes Agent or LiveKit changes on the terminal side.
|
||||||
|
|
||||||
|
## Beaver Endpoint
|
||||||
|
|
||||||
|
The terminal connects to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://<beaver-host>/api/channels/<channel_id>/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development through the Beaver app instance nginx port:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct backend development without nginx:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://127.0.0.1:18080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `wss://` when Beaver is deployed behind TLS.
|
||||||
|
|
||||||
|
The expected first channel id is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
terminal-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The terminal implementation should make the URL configurable, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
TERMINAL_PEER_ID=device-001
|
||||||
|
TERMINAL_DEVICE_NAME=desk-terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Overview
|
||||||
|
|
||||||
|
The transport is JSON over WebSocket.
|
||||||
|
|
||||||
|
All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid.
|
||||||
|
|
||||||
|
The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
|
||||||
|
|
||||||
|
Required frame flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
terminal -> Beaver: connect
|
||||||
|
Beaver -> terminal: connected
|
||||||
|
terminal -> Beaver: message
|
||||||
|
Beaver -> terminal: ack
|
||||||
|
Beaver -> terminal: message
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional heartbeat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
terminal -> Beaver: ping
|
||||||
|
Beaver -> terminal: pong
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connect Frame
|
||||||
|
|
||||||
|
The terminal must send `connect` immediately after the WebSocket opens.
|
||||||
|
|
||||||
|
Terminal to Beaver:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connect",
|
||||||
|
"peer_id": "device-001",
|
||||||
|
"device_name": "desk-terminal",
|
||||||
|
"capabilities": ["text"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required fields:
|
||||||
|
|
||||||
|
- `type`: must be `"connect"`.
|
||||||
|
- `peer_id`: stable terminal identity. Reuse this value across reconnects.
|
||||||
|
|
||||||
|
Recommended fields:
|
||||||
|
|
||||||
|
- `device_name`: human-readable terminal name.
|
||||||
|
- `capabilities`: include `"text"`.
|
||||||
|
|
||||||
|
Optional fields:
|
||||||
|
|
||||||
|
- `thread_id`: optional sub-session key. Omit it for the first pass.
|
||||||
|
- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id.
|
||||||
|
|
||||||
|
Beaver to terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connected",
|
||||||
|
"channel_id": "terminal-dev",
|
||||||
|
"session_id": "terminal-dev:local:device-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames.
|
||||||
|
|
||||||
|
## Message Frame
|
||||||
|
|
||||||
|
Terminal to Beaver:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message_id": "m-001",
|
||||||
|
"text": "hello"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required fields:
|
||||||
|
|
||||||
|
- `type`: must be `"message"`.
|
||||||
|
- `message_id`: unique id for this user message.
|
||||||
|
- `text`: non-empty user text.
|
||||||
|
|
||||||
|
Recommended `message_id` format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<peer_id>-<monotonic-counter>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
device-001-000001
|
||||||
|
device-001-000002
|
||||||
|
```
|
||||||
|
|
||||||
|
The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate.
|
||||||
|
|
||||||
|
Optional fields:
|
||||||
|
|
||||||
|
- `thread_id`: use only when the terminal intentionally wants a separate Beaver session.
|
||||||
|
- `user_id`: use only when the terminal has a stable user id.
|
||||||
|
|
||||||
|
## Ack Frame
|
||||||
|
|
||||||
|
Beaver sends an ack after accepting or deduplicating the inbound message.
|
||||||
|
|
||||||
|
Accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Duplicate still processing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": false,
|
||||||
|
"duplicate": true,
|
||||||
|
"pending": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Duplicate already completed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": false,
|
||||||
|
"duplicate": true,
|
||||||
|
"pending": false,
|
||||||
|
"reply": "cached assistant reply"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- If `accepted` is true, wait for the assistant `message`.
|
||||||
|
- If `duplicate` and `reply` is present, display the cached reply.
|
||||||
|
- If `duplicate` and `pending` is true, keep waiting on the socket.
|
||||||
|
- If `error` is present, display or log the error.
|
||||||
|
|
||||||
|
## Assistant Message Frame
|
||||||
|
|
||||||
|
Beaver to terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"run_id": "run-id",
|
||||||
|
"text": "assistant reply",
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `type`: `"message"`.
|
||||||
|
- `role`: `"assistant"`.
|
||||||
|
- `message_id`: the user message id this response belongs to.
|
||||||
|
- `run_id`: Beaver run id for diagnostics.
|
||||||
|
- `text`: final assistant response.
|
||||||
|
- `finish_reason`: usually `"stop"`, or `"error"` when the run failed.
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- Render or speak `text`.
|
||||||
|
- Treat `finish_reason == "error"` as a failed turn.
|
||||||
|
- Do not expect token-level streaming in this phase.
|
||||||
|
|
||||||
|
## Ping And Pong
|
||||||
|
|
||||||
|
Terminal to Beaver:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "ping"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver to terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "pong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended heartbeat interval:
|
||||||
|
|
||||||
|
```text
|
||||||
|
30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
If no pong or other frame is received after a reasonable timeout, reconnect.
|
||||||
|
|
||||||
|
## Error Frame
|
||||||
|
|
||||||
|
Beaver may send:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": "human readable error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- Log the error.
|
||||||
|
- Keep the connection open unless the WebSocket closes.
|
||||||
|
- If the error is for a user message, allow the user to retry with a new `message_id`.
|
||||||
|
|
||||||
|
Common first-pass errors:
|
||||||
|
|
||||||
|
- `connect` is required before `message`.
|
||||||
|
- `peer_id` is required.
|
||||||
|
- `message_id` is required.
|
||||||
|
- `text` is required.
|
||||||
|
- Unsupported websocket frame type.
|
||||||
|
|
||||||
|
## Terminal State Machine
|
||||||
|
|
||||||
|
Implement the terminal client as a small state machine.
|
||||||
|
|
||||||
|
```text
|
||||||
|
DISCONNECTED
|
||||||
|
-> connect websocket
|
||||||
|
CONNECTING
|
||||||
|
-> websocket open, send connect frame
|
||||||
|
WAIT_CONNECTED
|
||||||
|
-> receive connected
|
||||||
|
READY
|
||||||
|
-> send message frame
|
||||||
|
WAIT_ACK
|
||||||
|
-> receive ack
|
||||||
|
WAIT_REPLY
|
||||||
|
-> receive assistant message
|
||||||
|
READY
|
||||||
|
```
|
||||||
|
|
||||||
|
On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff.
|
||||||
|
|
||||||
|
Recommended reconnect policy:
|
||||||
|
|
||||||
|
- Start at 1 second.
|
||||||
|
- Double up to 30 seconds.
|
||||||
|
- Reset backoff after a successful `connected` frame.
|
||||||
|
|
||||||
|
On reconnect, use the same `peer_id`.
|
||||||
|
|
||||||
|
## Terminal Implementation Requirements
|
||||||
|
|
||||||
|
The terminal-side code should provide:
|
||||||
|
|
||||||
|
- A configurable Beaver WebSocket URL.
|
||||||
|
- A stable `peer_id`.
|
||||||
|
- A configurable `device_name`.
|
||||||
|
- A monotonic or otherwise unique `message_id` generator.
|
||||||
|
- JSON encoding and decoding.
|
||||||
|
- Connect frame on socket open.
|
||||||
|
- Ping/pong heartbeat.
|
||||||
|
- Reconnect with backoff.
|
||||||
|
- A queue or guard so only one user text turn is in flight at a time for the first pass.
|
||||||
|
- Logging for `session_id`, `message_id`, `run_id`, and errors.
|
||||||
|
|
||||||
|
The terminal-side code does not need:
|
||||||
|
|
||||||
|
- Multi-room session logic.
|
||||||
|
- Hermes session management.
|
||||||
|
- LiveKit `AgentSession`.
|
||||||
|
- Audio chunking.
|
||||||
|
- Tool calls.
|
||||||
|
- OAuth or token refresh.
|
||||||
|
|
||||||
|
## Example Client Pseudocode
|
||||||
|
|
||||||
|
```python
|
||||||
|
peer_id = load_or_create_peer_id()
|
||||||
|
counter = load_counter()
|
||||||
|
|
||||||
|
async def run_terminal_client():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with connect(BEAVER_WS_URL) as ws:
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "connect",
|
||||||
|
"peer_id": peer_id,
|
||||||
|
"device_name": DEVICE_NAME,
|
||||||
|
"capabilities": ["text"],
|
||||||
|
})
|
||||||
|
|
||||||
|
connected = await ws.receive_json()
|
||||||
|
assert connected["type"] == "connected"
|
||||||
|
log("session_id", connected["session_id"])
|
||||||
|
|
||||||
|
await read_send_receive_loop(ws)
|
||||||
|
except Exception as exc:
|
||||||
|
log("websocket disconnected", exc)
|
||||||
|
await sleep(next_backoff())
|
||||||
|
|
||||||
|
async def send_user_text(ws, text):
|
||||||
|
global counter
|
||||||
|
counter += 1
|
||||||
|
save_counter(counter)
|
||||||
|
message_id = f"{peer_id}-{counter:06d}"
|
||||||
|
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "message",
|
||||||
|
"message_id": message_id,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = await ws.receive_json()
|
||||||
|
if frame["type"] == "ack" and frame.get("message_id") == message_id:
|
||||||
|
if frame.get("reply"):
|
||||||
|
return frame["reply"]
|
||||||
|
continue
|
||||||
|
if frame["type"] == "message" and frame.get("role") == "assistant":
|
||||||
|
if frame.get("message_id") == message_id:
|
||||||
|
return frame.get("text", "")
|
||||||
|
if frame["type"] == "error":
|
||||||
|
raise RuntimeError(frame.get("error", "unknown error"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt the pseudocode to the terminal runtime language and WebSocket library.
|
||||||
|
|
||||||
|
## Manual Test With websocat
|
||||||
|
|
||||||
|
If `websocat` is available, a developer can manually test the protocol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Then paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"message","message_id":"device-001-000001","text":"hello"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, after Beaver finishes the run:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Checklist For Terminal-Side Codex
|
||||||
|
|
||||||
|
- The terminal opens the configured Beaver WebSocket URL.
|
||||||
|
- The terminal sends `connect` immediately after open.
|
||||||
|
- The terminal receives and logs `connected.session_id`.
|
||||||
|
- The terminal sends text using a unique `message_id`.
|
||||||
|
- The terminal receives `ack`.
|
||||||
|
- The terminal receives and displays assistant `message.text`.
|
||||||
|
- The terminal handles `ping`/`pong`.
|
||||||
|
- The terminal reconnects with the same `peer_id`.
|
||||||
|
- The terminal does not use REST chat or `/ws/{session_id}`.
|
||||||
|
- The terminal implementation remains text-only for the first pass.
|
||||||
|
|
||||||
|
When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side.
|
||||||
4
AGENTS.md
Normal file
4
AGENTS.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<!-- SPECKIT START -->
|
||||||
|
For additional context about technologies to be used, project structure,
|
||||||
|
shell commands, and other important information, read the current plan
|
||||||
|
<!-- SPECKIT END -->
|
||||||
@ -72,7 +72,7 @@ export BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
|
|||||||
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||||
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
export BEAVER_BASE_DOMAIN=localhost
|
||||||
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
||||||
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
||||||
|
|
||||||
@ -110,14 +110,14 @@ http://beaver-authz-service:19090
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DEPLOY_PUBLIC_SCHEME=http
|
DEPLOY_PUBLIC_SCHEME=http
|
||||||
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io
|
DEPLOY_PUBLIC_BASE_DOMAIN=localhost
|
||||||
DEPLOY_PUBLIC_PORT=8088
|
DEPLOY_PUBLIC_PORT=8088
|
||||||
```
|
```
|
||||||
|
|
||||||
本机测试时实例 URL 形如:
|
本机测试时实例 URL 形如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://alice.127.0.0.1.nip.io:8088
|
http://alice.localhost:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
正式 HTTPS 域名通常改成:
|
正式 HTTPS 域名通常改成:
|
||||||
|
|||||||
@ -1,145 +1,4 @@
|
|||||||
{
|
{
|
||||||
"agents": [
|
"agents": [],
|
||||||
{
|
|
||||||
"agent_id": "researcher",
|
|
||||||
"capabilities": [
|
|
||||||
"research",
|
|
||||||
"analysis",
|
|
||||||
"source review",
|
|
||||||
"requirements"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-11T03:13:06.912240+00:00",
|
|
||||||
"description": "Finds facts, references, constraints, and implementation options.",
|
|
||||||
"display_name": "Researcher",
|
|
||||||
"metadata": {},
|
|
||||||
"model": null,
|
|
||||||
"name": "researcher",
|
|
||||||
"priority": 50,
|
|
||||||
"provider_name": null,
|
|
||||||
"role": "research",
|
|
||||||
"skill_names": [],
|
|
||||||
"source": "builtin",
|
|
||||||
"status": "active",
|
|
||||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
|
||||||
"tags": [
|
|
||||||
"planning",
|
|
||||||
"research"
|
|
||||||
],
|
|
||||||
"tool_hints": [],
|
|
||||||
"updated_at": "2026-05-11T03:13:06.912247+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent_id": "implementer",
|
|
||||||
"capabilities": [
|
|
||||||
"implementation",
|
|
||||||
"coding",
|
|
||||||
"refactor",
|
|
||||||
"integration"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-11T03:13:06.912250+00:00",
|
|
||||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
|
||||||
"display_name": "Implementer",
|
|
||||||
"metadata": {},
|
|
||||||
"model": null,
|
|
||||||
"name": "implementer",
|
|
||||||
"priority": 45,
|
|
||||||
"provider_name": null,
|
|
||||||
"role": "implementation",
|
|
||||||
"skill_names": [],
|
|
||||||
"source": "builtin",
|
|
||||||
"status": "active",
|
|
||||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
|
||||||
"tags": [
|
|
||||||
"coding",
|
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"tool_hints": [],
|
|
||||||
"updated_at": "2026-05-11T03:13:06.912251+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent_id": "reviewer",
|
|
||||||
"capabilities": [
|
|
||||||
"review",
|
|
||||||
"quality",
|
|
||||||
"risk",
|
|
||||||
"verification"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-11T03:13:06.912252+00:00",
|
|
||||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
|
||||||
"display_name": "Reviewer",
|
|
||||||
"metadata": {},
|
|
||||||
"model": null,
|
|
||||||
"name": "reviewer",
|
|
||||||
"priority": 45,
|
|
||||||
"provider_name": null,
|
|
||||||
"role": "review",
|
|
||||||
"skill_names": [],
|
|
||||||
"source": "builtin",
|
|
||||||
"status": "active",
|
|
||||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
|
||||||
"tags": [
|
|
||||||
"review",
|
|
||||||
"quality"
|
|
||||||
],
|
|
||||||
"tool_hints": [],
|
|
||||||
"updated_at": "2026-05-11T03:13:06.912253+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent_id": "tester",
|
|
||||||
"capabilities": [
|
|
||||||
"testing",
|
|
||||||
"verification",
|
|
||||||
"regression",
|
|
||||||
"qa"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-11T03:13:06.912255+00:00",
|
|
||||||
"description": "Designs and executes verification checks for task outputs.",
|
|
||||||
"display_name": "Tester",
|
|
||||||
"metadata": {},
|
|
||||||
"model": null,
|
|
||||||
"name": "tester",
|
|
||||||
"priority": 40,
|
|
||||||
"provider_name": null,
|
|
||||||
"role": "testing",
|
|
||||||
"skill_names": [],
|
|
||||||
"source": "builtin",
|
|
||||||
"status": "active",
|
|
||||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
|
||||||
"tags": [
|
|
||||||
"test",
|
|
||||||
"quality"
|
|
||||||
],
|
|
||||||
"tool_hints": [],
|
|
||||||
"updated_at": "2026-05-11T03:13:06.912256+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent_id": "documenter",
|
|
||||||
"capabilities": [
|
|
||||||
"documentation",
|
|
||||||
"explanation",
|
|
||||||
"migration notes",
|
|
||||||
"release notes"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-11T03:13:06.912257+00:00",
|
|
||||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
|
||||||
"display_name": "Documenter",
|
|
||||||
"metadata": {},
|
|
||||||
"model": null,
|
|
||||||
"name": "documenter",
|
|
||||||
"priority": 35,
|
|
||||||
"provider_name": null,
|
|
||||||
"role": "documentation",
|
|
||||||
"skill_names": [],
|
|
||||||
"source": "builtin",
|
|
||||||
"status": "active",
|
|
||||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
|
||||||
"tags": [
|
|
||||||
"docs",
|
|
||||||
"communication"
|
|
||||||
],
|
|
||||||
"tool_hints": [],
|
|
||||||
"updated_at": "2026-05-11T03:13:06.912258+00:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
|
|||||||
ARG NPM_FETCH_RETRIES="5"
|
ARG NPM_FETCH_RETRIES="5"
|
||||||
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
||||||
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
||||||
|
ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian"
|
||||||
|
ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \
|
||||||
|
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
||||||
mkdir -p /etc/apt/keyrings && \
|
mkdir -p /etc/apt/keyrings && \
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||||
@ -63,7 +67,7 @@ WORKDIR /opt/app/backend
|
|||||||
|
|
||||||
COPY backend/pyproject.toml backend/README.md ./
|
COPY backend/pyproject.toml backend/README.md ./
|
||||||
COPY backend/beaver/ ./beaver/
|
COPY backend/beaver/ ./beaver/
|
||||||
RUN uv pip install --system --no-cache .
|
RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]"
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
||||||
|
|||||||
@ -72,6 +72,8 @@ docker build -t beaver/app-instance:latest .
|
|||||||
- `--client-secret`
|
- `--client-secret`
|
||||||
- `--network`
|
- `--network`
|
||||||
- `--host-bind-ip`
|
- `--host-bind-ip`
|
||||||
|
- `--initial-skills-dir`
|
||||||
|
- `--skip-initial-skills`
|
||||||
- `--build`
|
- `--build`
|
||||||
- `--replace`
|
- `--replace`
|
||||||
|
|
||||||
@ -127,6 +129,8 @@ BEAVER_WORKSPACE=/root/.beaver/workspace
|
|||||||
|
|
||||||
所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。
|
所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。
|
||||||
|
|
||||||
|
`create-instance.sh` 默认会把仓库根目录的 `skills/` 非覆盖式复制到实例 workspace,并把同一个目录只读挂载到实例容器的 `/opt/app/initial-skills`。`entrypoint.sh` 每次启动都会用该目录补齐缺失的 published 初始 skills;已有 skill 目录不会被覆盖,index 只做并集追加。
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
这层已经支持:
|
这层已经支持:
|
||||||
@ -144,6 +148,12 @@ BEAVER_WORKSPACE=/root/.beaver/workspace
|
|||||||
- 实例容器的宿主机端口默认只绑定 `127.0.0.1`
|
- 实例容器的宿主机端口默认只绑定 `127.0.0.1`
|
||||||
- 外部访问应统一走 `router-proxy`
|
- 外部访问应统一走 `router-proxy`
|
||||||
- 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0`
|
- 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0`
|
||||||
|
- 使用共享 `external-connector` sidecar 时,每个实例容器都必须带自己的内部回调地址:
|
||||||
|
`EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://<app-instance-container-name>:8080`
|
||||||
|
- 通过 `create-instance.sh --network <docker-network>` 创建实例时,脚本会默认使用
|
||||||
|
`http://<container-name>:8080` 作为回调地址;生产部署也可以用
|
||||||
|
`--external-connector-callback-base-url <url>` 显式覆盖
|
||||||
|
- `BEAVER_BRIDGE_BASE_URL` 只作为 sidecar 的旧连接或兜底地址;多实例部署不能依赖它路由所有入站事件
|
||||||
|
|
||||||
下一步可以继续接:
|
下一步可以继续接:
|
||||||
|
|
||||||
|
|||||||
4
app-instance/backend/agents/registry.json
Normal file
4
app-instance/backend/agents/registry.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"agents": [],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@ -15,7 +15,9 @@ class AgentRegistry:
|
|||||||
self.path = self.workspace / "agents" / "registry.json"
|
self.path = self.workspace / "agents" / "registry.json"
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
self._write_agents(_builtin_agents())
|
self._write_agents([])
|
||||||
|
else:
|
||||||
|
self._drop_legacy_builtin_agents()
|
||||||
|
|
||||||
def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]:
|
def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]:
|
||||||
agents = self._read_agents()
|
agents = self._read_agents()
|
||||||
@ -125,72 +127,14 @@ class AgentRegistry:
|
|||||||
payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]}
|
payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]}
|
||||||
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def _drop_legacy_builtin_agents(self) -> None:
|
||||||
|
agents = self._read_agents()
|
||||||
|
migrated = [agent for agent in agents if agent.source != "builtin"]
|
||||||
|
if len(migrated) != len(agents):
|
||||||
|
self._write_agents(migrated)
|
||||||
|
|
||||||
|
|
||||||
def _terms(text: str) -> set[str]:
|
def _terms(text: str) -> set[str]:
|
||||||
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
|
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
|
||||||
return {part for part in normalized.split() if part}
|
return {part for part in normalized.split() if part}
|
||||||
|
|
||||||
|
|
||||||
def _builtin_agents() -> list[RegisteredAgent]:
|
|
||||||
return [
|
|
||||||
RegisteredAgent(
|
|
||||||
agent_id="researcher",
|
|
||||||
name="researcher",
|
|
||||||
display_name="Researcher",
|
|
||||||
role="research",
|
|
||||||
description="Finds facts, references, constraints, and implementation options.",
|
|
||||||
system_prompt="You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
|
||||||
capabilities=["research", "analysis", "source review", "requirements"],
|
|
||||||
tags=["planning", "research"],
|
|
||||||
priority=50,
|
|
||||||
source="builtin",
|
|
||||||
),
|
|
||||||
RegisteredAgent(
|
|
||||||
agent_id="implementer",
|
|
||||||
name="implementer",
|
|
||||||
display_name="Implementer",
|
|
||||||
role="implementation",
|
|
||||||
description="Builds scoped implementation slices and proposes concrete changes.",
|
|
||||||
system_prompt="You are an implementation specialist. Produce practical, scoped implementation output.",
|
|
||||||
capabilities=["implementation", "coding", "refactor", "integration"],
|
|
||||||
tags=["coding", "build"],
|
|
||||||
priority=45,
|
|
||||||
source="builtin",
|
|
||||||
),
|
|
||||||
RegisteredAgent(
|
|
||||||
agent_id="reviewer",
|
|
||||||
name="reviewer",
|
|
||||||
display_name="Reviewer",
|
|
||||||
role="review",
|
|
||||||
description="Reviews plans, code, outputs, and risks before final synthesis.",
|
|
||||||
system_prompt="You are a review specialist. Focus on defects, missing requirements, and risks.",
|
|
||||||
capabilities=["review", "quality", "risk", "verification"],
|
|
||||||
tags=["review", "quality"],
|
|
||||||
priority=45,
|
|
||||||
source="builtin",
|
|
||||||
),
|
|
||||||
RegisteredAgent(
|
|
||||||
agent_id="tester",
|
|
||||||
name="tester",
|
|
||||||
display_name="Tester",
|
|
||||||
role="testing",
|
|
||||||
description="Designs and executes verification checks for task outputs.",
|
|
||||||
system_prompt="You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
|
||||||
capabilities=["testing", "verification", "regression", "qa"],
|
|
||||||
tags=["test", "quality"],
|
|
||||||
priority=40,
|
|
||||||
source="builtin",
|
|
||||||
),
|
|
||||||
RegisteredAgent(
|
|
||||||
agent_id="documenter",
|
|
||||||
name="documenter",
|
|
||||||
display_name="Documenter",
|
|
||||||
role="documentation",
|
|
||||||
description="Writes and reconciles user-facing and internal documentation updates.",
|
|
||||||
system_prompt="You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
|
||||||
capabilities=["documentation", "explanation", "migration notes", "release notes"],
|
|
||||||
tags=["docs", "communication"],
|
|
||||||
priority=35,
|
|
||||||
source="builtin",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from .builder import (
|
|||||||
ContextBuildInput,
|
ContextBuildInput,
|
||||||
ContextBuildResult,
|
ContextBuildResult,
|
||||||
ContextBuilder,
|
ContextBuilder,
|
||||||
|
RuntimeContext,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
SkillContext,
|
SkillContext,
|
||||||
)
|
)
|
||||||
@ -12,6 +13,7 @@ __all__ = [
|
|||||||
"ContextBuildInput",
|
"ContextBuildInput",
|
||||||
"ContextBuildResult",
|
"ContextBuildResult",
|
||||||
"ContextBuilder",
|
"ContextBuilder",
|
||||||
|
"RuntimeContext",
|
||||||
"SessionContext",
|
"SessionContext",
|
||||||
"SkillContext",
|
"SkillContext",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -27,13 +27,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from beaver.memory.curated.snapshot import MemorySnapshot
|
from beaver.memory.curated.snapshot import MemorySnapshot
|
||||||
|
from beaver.prompts import get_main_agent_prompt
|
||||||
|
|
||||||
BEAVER_USER_ASSISTANT_IDENTITY_PROMPT = (
|
|
||||||
"You are 海狸 (Beaver), an AI assistant developed by 博维资讯系统有限公司. "
|
|
||||||
"When communicating with users, keep this identity consistent. "
|
|
||||||
"If users ask who you are, say that you are 海狸 (Beaver), 博维资讯系统有限公司研发的 AI 助手."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -76,10 +70,25 @@ class SessionContext:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
channel: str | None = None
|
channel: str | None = None
|
||||||
|
channel_kind: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
peer_id: str | None = None
|
||||||
|
peer_type: str | None = None
|
||||||
chat_id: str | None = None
|
chat_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
parent_session_id: str | None = None
|
parent_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RuntimeContext:
|
||||||
|
"""Per-run runtime facts that should be visible to the model."""
|
||||||
|
|
||||||
|
utc_datetime: str
|
||||||
|
local_datetime: str
|
||||||
|
timezone: str | None = None
|
||||||
|
utc_offset: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ContextBuildInput:
|
class ContextBuildInput:
|
||||||
"""一次上下文构建所需的全部输入。
|
"""一次上下文构建所需的全部输入。
|
||||||
@ -98,11 +107,13 @@ class ContextBuildInput:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
base_system_prompt: str = ""
|
base_system_prompt: str = ""
|
||||||
|
prompt_locale: str | None = None
|
||||||
history: list[dict[str, Any]] = field(default_factory=list)
|
history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
current_user_input: str | list[dict[str, Any]] | None = None
|
current_user_input: str | list[dict[str, Any]] | None = None
|
||||||
memory_snapshot: MemorySnapshot | None = None
|
memory_snapshot: MemorySnapshot | None = None
|
||||||
activated_skills: list[SkillContext] = field(default_factory=list)
|
activated_skills: list[SkillContext] = field(default_factory=list)
|
||||||
session_context: SessionContext | None = None
|
session_context: SessionContext | None = None
|
||||||
|
runtime_context: RuntimeContext | None = None
|
||||||
execution_context: str | None = None
|
execution_context: str | None = None
|
||||||
extra_sections: list[str] = field(default_factory=list)
|
extra_sections: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@ -143,9 +154,10 @@ class ContextBuilder:
|
|||||||
1. Beaver user-facing assistant identity
|
1. Beaver user-facing assistant identity
|
||||||
2. base system prompt
|
2. base system prompt
|
||||||
3. session metadata
|
3. session metadata
|
||||||
4. execution context
|
4. runtime date/time
|
||||||
5. frozen memory snapshot
|
5. execution context
|
||||||
6. extra sections
|
6. frozen memory snapshot
|
||||||
|
7. extra sections
|
||||||
|
|
||||||
这样设计的原因:
|
这样设计的原因:
|
||||||
- 身份与总规则要最靠前
|
- 身份与总规则要最靠前
|
||||||
@ -154,7 +166,7 @@ class ContextBuilder:
|
|||||||
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
|
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
|
sections: list[str] = [get_main_agent_prompt(build_input.prompt_locale)]
|
||||||
|
|
||||||
base_system_prompt = (build_input.base_system_prompt or "").strip()
|
base_system_prompt = (build_input.base_system_prompt or "").strip()
|
||||||
if base_system_prompt:
|
if base_system_prompt:
|
||||||
@ -164,6 +176,10 @@ class ContextBuilder:
|
|||||||
if session_section:
|
if session_section:
|
||||||
sections.append(session_section)
|
sections.append(session_section)
|
||||||
|
|
||||||
|
runtime_section = self._render_runtime_section(build_input.runtime_context)
|
||||||
|
if runtime_section:
|
||||||
|
sections.append(runtime_section)
|
||||||
|
|
||||||
execution_context = (build_input.execution_context or "").strip()
|
execution_context = (build_input.execution_context or "").strip()
|
||||||
if execution_context:
|
if execution_context:
|
||||||
sections.append(f"# Execution Context\n\n{execution_context}")
|
sections.append(f"# Execution Context\n\n{execution_context}")
|
||||||
@ -338,8 +354,18 @@ class ContextBuilder:
|
|||||||
rows.append(f"User ID: {session_context.user_id}")
|
rows.append(f"User ID: {session_context.user_id}")
|
||||||
if session_context.channel:
|
if session_context.channel:
|
||||||
rows.append(f"Channel: {session_context.channel}")
|
rows.append(f"Channel: {session_context.channel}")
|
||||||
|
if session_context.channel_kind:
|
||||||
|
rows.append(f"Channel Kind: {session_context.channel_kind}")
|
||||||
|
if session_context.account_id:
|
||||||
|
rows.append(f"Account ID: {session_context.account_id}")
|
||||||
|
if session_context.peer_id:
|
||||||
|
rows.append(f"Peer ID: {session_context.peer_id}")
|
||||||
|
if session_context.peer_type:
|
||||||
|
rows.append(f"Peer Type: {session_context.peer_type}")
|
||||||
if session_context.chat_id:
|
if session_context.chat_id:
|
||||||
rows.append(f"Chat ID: {session_context.chat_id}")
|
rows.append(f"Chat ID: {session_context.chat_id}")
|
||||||
|
if session_context.thread_id:
|
||||||
|
rows.append(f"Thread ID: {session_context.thread_id}")
|
||||||
if session_context.parent_session_id:
|
if session_context.parent_session_id:
|
||||||
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
|
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
|
||||||
|
|
||||||
@ -347,6 +373,31 @@ class ContextBuilder:
|
|||||||
return None
|
return None
|
||||||
return "# Current Session\n\n" + "\n".join(rows)
|
return "# Current Session\n\n" + "\n".join(rows)
|
||||||
|
|
||||||
|
def _render_runtime_section(self, runtime_context: RuntimeContext | None) -> str | None:
|
||||||
|
"""Render date/time facts captured for the current model run."""
|
||||||
|
|
||||||
|
if runtime_context is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rows: list[str] = []
|
||||||
|
if runtime_context.utc_datetime:
|
||||||
|
rows.append(f"Current UTC time: {runtime_context.utc_datetime}")
|
||||||
|
if runtime_context.local_datetime:
|
||||||
|
rows.append(f"Current local time: {runtime_context.local_datetime}")
|
||||||
|
if runtime_context.timezone:
|
||||||
|
rows.append(f"Local timezone: {runtime_context.timezone}")
|
||||||
|
if runtime_context.utc_offset:
|
||||||
|
rows.append(f"Local UTC offset: {runtime_context.utc_offset}")
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"# Current Date and Time\n\n"
|
||||||
|
+ "\n".join(rows)
|
||||||
|
+ "\n\nUse this section as authoritative for relative date/time references such as "
|
||||||
|
'"today", "tomorrow", "now", "this week", and "next month".'
|
||||||
|
)
|
||||||
|
|
||||||
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
|
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
|
||||||
"""把已激活 skill 转成显式消息。
|
"""把已激活 skill 转成显式消息。
|
||||||
|
|
||||||
|
|||||||
@ -12,10 +12,14 @@ from beaver.coordinator.registry import AgentRegistry
|
|||||||
from beaver.engine.context import ContextBuilder
|
from beaver.engine.context import ContextBuilder
|
||||||
from beaver.engine.session import SessionManager
|
from beaver.engine.session import SessionManager
|
||||||
from beaver.foundation.config import BeaverConfig, load_config
|
from beaver.foundation.config import BeaverConfig, load_config
|
||||||
|
from beaver.foundation.utils.file_lock import WorkspaceWriteLock, WorkspaceWriteLockBusy
|
||||||
from beaver.integrations.mcp import MCPConnectionManager
|
from beaver.integrations.mcp import MCPConnectionManager
|
||||||
from beaver.memory.curated.store import MemoryStore
|
from beaver.memory.curated.store import MemoryStore
|
||||||
from beaver.memory.runs import RunMemoryStore
|
from beaver.memory.runs import RunMemoryStore
|
||||||
from beaver.memory.skills import SkillLearningStore
|
from beaver.memory.skills import SkillLearningStore
|
||||||
|
from beaver.plugins.discovery import discover_plugins
|
||||||
|
from beaver.plugins.skills import PluginManager
|
||||||
|
from beaver.plugins.state import PluginStateStore
|
||||||
from beaver.services.memory_service import MemoryService
|
from beaver.services.memory_service import MemoryService
|
||||||
from beaver.skills.drafts import DraftService
|
from beaver.skills.drafts import DraftService
|
||||||
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService
|
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService
|
||||||
@ -24,7 +28,7 @@ from beaver.skills.learning.eval import SkillDraftEvaluator
|
|||||||
from beaver.skills.publisher import SkillPublisher
|
from beaver.skills.publisher import SkillPublisher
|
||||||
from beaver.skills.reviews import ReviewService
|
from beaver.skills.reviews import ReviewService
|
||||||
from beaver.skills.specs import SkillSpecStore
|
from beaver.skills.specs import SkillSpecStore
|
||||||
from beaver.tasks import TaskExecutionPlanner, TaskService, ValidationService
|
from beaver.tasks import TaskExecutionPlanner, TaskService
|
||||||
from beaver.tasks.skill_resolver import TaskSkillResolver
|
from beaver.tasks.skill_resolver import TaskSkillResolver
|
||||||
from beaver.skills import SkillAssembler, SkillsLoader
|
from beaver.skills import SkillAssembler, SkillsLoader
|
||||||
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
||||||
@ -44,9 +48,16 @@ from beaver.tools.builtins import (
|
|||||||
SpawnTool,
|
SpawnTool,
|
||||||
SessionSearchTool,
|
SessionSearchTool,
|
||||||
SkillManageTool,
|
SkillManageTool,
|
||||||
|
SkillViewTool,
|
||||||
SkillsListTool,
|
SkillsListTool,
|
||||||
TerminalTool,
|
TerminalTool,
|
||||||
TodoTool,
|
TodoTool,
|
||||||
|
UserFilesCopyToWorkspaceTool,
|
||||||
|
UserFilesListTool,
|
||||||
|
UserFilesMkdirTool,
|
||||||
|
UserFilesPublishOutputTool,
|
||||||
|
UserFilesReadTool,
|
||||||
|
UserFilesWriteTool,
|
||||||
WebFetchTool,
|
WebFetchTool,
|
||||||
WebSearchTool,
|
WebSearchTool,
|
||||||
WriteFileTool,
|
WriteFileTool,
|
||||||
@ -87,11 +98,12 @@ class EngineLoadResult:
|
|||||||
skill_publisher: SkillPublisher | None = None
|
skill_publisher: SkillPublisher | None = None
|
||||||
skill_learning_service: SkillLearningService | None = None
|
skill_learning_service: SkillLearningService | None = None
|
||||||
skill_learning_pipeline: SkillLearningPipelineService | None = None
|
skill_learning_pipeline: SkillLearningPipelineService | None = None
|
||||||
|
plugin_manager: PluginManager | None = None
|
||||||
|
plugins: list[dict] = field(default_factory=list)
|
||||||
agent_registry: AgentRegistry | None = None
|
agent_registry: AgentRegistry | None = None
|
||||||
task_skill_resolver: TaskSkillResolver | None = None
|
task_skill_resolver: TaskSkillResolver | None = None
|
||||||
task_service: TaskService | None = None
|
task_service: TaskService | None = None
|
||||||
task_execution_planner: TaskExecutionPlanner | None = None
|
task_execution_planner: TaskExecutionPlanner | None = None
|
||||||
validation_service: ValidationService | None = None
|
|
||||||
mcp_manager: MCPConnectionManager | None = None
|
mcp_manager: MCPConnectionManager | None = None
|
||||||
mcp_report: dict[str, dict] = field(default_factory=dict)
|
mcp_report: dict[str, dict] = field(default_factory=dict)
|
||||||
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
||||||
@ -162,11 +174,11 @@ class EngineLoader:
|
|||||||
skill_publisher: SkillPublisher | None = None,
|
skill_publisher: SkillPublisher | None = None,
|
||||||
skill_learning_service: SkillLearningService | None = None,
|
skill_learning_service: SkillLearningService | None = None,
|
||||||
skill_learning_pipeline: SkillLearningPipelineService | None = None,
|
skill_learning_pipeline: SkillLearningPipelineService | None = None,
|
||||||
|
plugin_manager: PluginManager | None = None,
|
||||||
agent_registry: AgentRegistry | None = None,
|
agent_registry: AgentRegistry | None = None,
|
||||||
task_skill_resolver: TaskSkillResolver | None = None,
|
task_skill_resolver: TaskSkillResolver | None = None,
|
||||||
task_service: TaskService | None = None,
|
task_service: TaskService | None = None,
|
||||||
task_execution_planner: TaskExecutionPlanner | None = None,
|
task_execution_planner: TaskExecutionPlanner | None = None,
|
||||||
validation_service: ValidationService | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
||||||
configured_workspace = self.config.agents_defaults.workspace
|
configured_workspace = self.config.agents_defaults.workspace
|
||||||
@ -188,11 +200,11 @@ class EngineLoader:
|
|||||||
self._skill_publisher = skill_publisher
|
self._skill_publisher = skill_publisher
|
||||||
self._skill_learning_service = skill_learning_service
|
self._skill_learning_service = skill_learning_service
|
||||||
self._skill_learning_pipeline = skill_learning_pipeline
|
self._skill_learning_pipeline = skill_learning_pipeline
|
||||||
|
self._plugin_manager = plugin_manager
|
||||||
self._agent_registry = agent_registry
|
self._agent_registry = agent_registry
|
||||||
self._task_skill_resolver = task_skill_resolver
|
self._task_skill_resolver = task_skill_resolver
|
||||||
self._task_service = task_service
|
self._task_service = task_service
|
||||||
self._task_execution_planner = task_execution_planner
|
self._task_execution_planner = task_execution_planner
|
||||||
self._validation_service = validation_service
|
|
||||||
|
|
||||||
def load(self) -> EngineLoadResult:
|
def load(self) -> EngineLoadResult:
|
||||||
"""装配当前主链需要的最小 runtime 对象。"""
|
"""装配当前主链需要的最小 runtime 对象。"""
|
||||||
@ -205,7 +217,11 @@ class EngineLoader:
|
|||||||
memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store)
|
memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store)
|
||||||
memory_service.initialize()
|
memory_service.initialize()
|
||||||
run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs")
|
run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs")
|
||||||
skill_learning_store = self._skill_learning_store or SkillLearningStore(workspace / "memory" / "skills")
|
write_lock = WorkspaceWriteLock(workspace)
|
||||||
|
skill_learning_store = self._skill_learning_store or SkillLearningStore(
|
||||||
|
workspace / "memory" / "skills",
|
||||||
|
write_lock=write_lock,
|
||||||
|
)
|
||||||
|
|
||||||
tool_registry = self._tool_registry or ToolRegistry()
|
tool_registry = self._tool_registry or ToolRegistry()
|
||||||
skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace)
|
skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace)
|
||||||
@ -222,6 +238,12 @@ class EngineLoader:
|
|||||||
ObjectBackedTool(SearchFilesTool()),
|
ObjectBackedTool(SearchFilesTool()),
|
||||||
ObjectBackedTool(WriteFileTool()),
|
ObjectBackedTool(WriteFileTool()),
|
||||||
ObjectBackedTool(PatchFileTool()),
|
ObjectBackedTool(PatchFileTool()),
|
||||||
|
ObjectBackedTool(UserFilesListTool()),
|
||||||
|
ObjectBackedTool(UserFilesReadTool()),
|
||||||
|
ObjectBackedTool(UserFilesWriteTool()),
|
||||||
|
ObjectBackedTool(UserFilesMkdirTool()),
|
||||||
|
ObjectBackedTool(UserFilesCopyToWorkspaceTool()),
|
||||||
|
ObjectBackedTool(UserFilesPublishOutputTool()),
|
||||||
ObjectBackedTool(WebFetchTool()),
|
ObjectBackedTool(WebFetchTool()),
|
||||||
ObjectBackedTool(WebSearchTool()),
|
ObjectBackedTool(WebSearchTool()),
|
||||||
ObjectBackedTool(TerminalTool()),
|
ObjectBackedTool(TerminalTool()),
|
||||||
@ -233,6 +255,7 @@ class EngineLoader:
|
|||||||
ObjectBackedTool(DelegateTool()),
|
ObjectBackedTool(DelegateTool()),
|
||||||
ObjectBackedTool(SpawnTool()),
|
ObjectBackedTool(SpawnTool()),
|
||||||
SkillsListTool(),
|
SkillsListTool(),
|
||||||
|
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||||
SkillManageTool(),
|
SkillManageTool(),
|
||||||
CronTool(),
|
CronTool(),
|
||||||
]
|
]
|
||||||
@ -253,21 +276,40 @@ class EngineLoader:
|
|||||||
evidence_selector=evidence_selector,
|
evidence_selector=evidence_selector,
|
||||||
synthesizer=SkillDraftSynthesizer(),
|
synthesizer=SkillDraftSynthesizer(),
|
||||||
)
|
)
|
||||||
skill_learning_pipeline = self._skill_learning_pipeline or SkillLearningPipelineService(
|
safety_checker = SkillDraftSafetyChecker(
|
||||||
learning_store=skill_learning_store,
|
|
||||||
learning_service=skill_learning_service,
|
|
||||||
draft_service=draft_service,
|
|
||||||
review_service=review_service,
|
|
||||||
publisher=skill_publisher,
|
|
||||||
safety_checker=SkillDraftSafetyChecker(
|
|
||||||
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
|
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
|
||||||
allowed_tool_prefixes={
|
allowed_tool_prefixes={
|
||||||
f"mcp_{server_id}_"
|
f"mcp_{server_id}_"
|
||||||
for server_id in self.config.tools.mcp_servers
|
for server_id in self.config.tools.mcp_servers
|
||||||
if str(server_id).strip()
|
if str(server_id).strip()
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
|
discovery = discover_plugins(workspace, search_paths=self.config.plugins.search_paths)
|
||||||
|
plugin_manager = self._plugin_manager or PluginManager(
|
||||||
|
workspace=workspace,
|
||||||
|
manifests=discovery.manifests,
|
||||||
|
discovery_errors=discovery.errors,
|
||||||
|
state_store=PluginStateStore(workspace),
|
||||||
|
skill_store=skill_spec_store,
|
||||||
|
learning_store=skill_learning_store,
|
||||||
|
publisher=skill_publisher,
|
||||||
|
safety_checker=safety_checker,
|
||||||
|
write_lock=write_lock,
|
||||||
|
)
|
||||||
|
if self.config.plugins.auto_sync:
|
||||||
|
try:
|
||||||
|
plugin_manager.sync_enabled(blocking=False)
|
||||||
|
except WorkspaceWriteLockBusy:
|
||||||
|
pass
|
||||||
|
skill_learning_pipeline = self._skill_learning_pipeline or SkillLearningPipelineService(
|
||||||
|
learning_store=skill_learning_store,
|
||||||
|
learning_service=skill_learning_service,
|
||||||
|
draft_service=draft_service,
|
||||||
|
review_service=review_service,
|
||||||
|
publisher=skill_publisher,
|
||||||
|
safety_checker=safety_checker,
|
||||||
evaluator=SkillDraftEvaluator(run_memory_store),
|
evaluator=SkillDraftEvaluator(run_memory_store),
|
||||||
|
publish_observer=plugin_manager.on_skill_published,
|
||||||
)
|
)
|
||||||
agent_registry = self._agent_registry or AgentRegistry(workspace)
|
agent_registry = self._agent_registry or AgentRegistry(workspace)
|
||||||
task_skill_resolver = self._task_skill_resolver or TaskSkillResolver(
|
task_skill_resolver = self._task_skill_resolver or TaskSkillResolver(
|
||||||
@ -276,7 +318,6 @@ class EngineLoader:
|
|||||||
)
|
)
|
||||||
task_service = self._task_service or TaskService(workspace / "tasks")
|
task_service = self._task_service or TaskService(workspace / "tasks")
|
||||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
||||||
validation_service = self._validation_service or ValidationService()
|
|
||||||
mcp_manager = MCPConnectionManager(
|
mcp_manager = MCPConnectionManager(
|
||||||
self.config.tools.mcp_servers,
|
self.config.tools.mcp_servers,
|
||||||
authz_config=self.config.authz,
|
authz_config=self.config.authz,
|
||||||
@ -307,11 +348,12 @@ class EngineLoader:
|
|||||||
skill_publisher=skill_publisher,
|
skill_publisher=skill_publisher,
|
||||||
skill_learning_service=skill_learning_service,
|
skill_learning_service=skill_learning_service,
|
||||||
skill_learning_pipeline=skill_learning_pipeline,
|
skill_learning_pipeline=skill_learning_pipeline,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
plugins=_plugin_summaries(plugin_manager),
|
||||||
agent_registry=agent_registry,
|
agent_registry=agent_registry,
|
||||||
task_skill_resolver=task_skill_resolver,
|
task_skill_resolver=task_skill_resolver,
|
||||||
task_service=task_service,
|
task_service=task_service,
|
||||||
task_execution_planner=task_execution_planner,
|
task_execution_planner=task_execution_planner,
|
||||||
validation_service=validation_service,
|
|
||||||
mcp_manager=mcp_manager,
|
mcp_manager=mcp_manager,
|
||||||
)
|
)
|
||||||
if self._session_manager is None:
|
if self._session_manager is None:
|
||||||
@ -327,3 +369,35 @@ def _close_mcp_manager(manager: MCPConnectionManager) -> None:
|
|||||||
asyncio.run(manager.close())
|
asyncio.run(manager.close())
|
||||||
return
|
return
|
||||||
loop.create_task(manager.close())
|
loop.create_task(manager.close())
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_summaries(manager: PluginManager) -> list[dict]:
|
||||||
|
summaries: list[dict] = []
|
||||||
|
for state in manager.list_plugins():
|
||||||
|
manifest = manager.manifests.get(state.plugin_id)
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"id": state.plugin_id,
|
||||||
|
"name": manifest.name if manifest is not None else state.plugin_id,
|
||||||
|
"discovered_version": manifest.version if manifest is not None else None,
|
||||||
|
"installed_version": state.installed_version,
|
||||||
|
"enabled": state.enabled,
|
||||||
|
"status": state.status,
|
||||||
|
"last_error": state.last_error,
|
||||||
|
"manifest_path": manifest.display_path if manifest is not None else state.manifest_path,
|
||||||
|
"updates_paused": state.updates_paused,
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"status": binding.status,
|
||||||
|
"current_beaver_version": binding.current_beaver_version,
|
||||||
|
"accepted_upstream_tree_hash": binding.accepted_upstream_tree_hash,
|
||||||
|
"observed_upstream_tree_hash": binding.observed_upstream_tree_hash,
|
||||||
|
"accepted_beaver_version": binding.accepted_beaver_version,
|
||||||
|
"pending_candidate_id": binding.pending_candidate_id,
|
||||||
|
}
|
||||||
|
for name, binding in sorted(state.skills.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|||||||
@ -4,12 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from beaver.engine.context import ContextBuildInput, SessionContext, SkillContext
|
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
|
||||||
|
from beaver.foundation.events import ChannelIdentity
|
||||||
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
||||||
from beaver.skills.learning import RunReceiptContext
|
from beaver.skills.learning import RunReceiptContext
|
||||||
from beaver.skills.catalog.utils import strip_frontmatter
|
from beaver.skills.catalog.utils import strip_frontmatter
|
||||||
@ -26,6 +30,17 @@ TOOL_FAILURE_GUIDANCE_PROMPT = (
|
|||||||
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
|
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RAW_TOOL_CALL_FALLBACK = (
|
||||||
|
"The run reached the configured tool-call limit before producing a reliable final answer. "
|
||||||
|
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
|
||||||
|
"Please request a revision to continue the task."
|
||||||
|
)
|
||||||
|
|
||||||
|
_RAW_TOOL_CALL_RE = re.compile(
|
||||||
|
r"^\s*<tool_call\b[\s\S]*?</tool_call>\s*$|^\s*<function=[^>]+>[\s\S]*?</function>\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AgentProfile:
|
class AgentProfile:
|
||||||
@ -34,9 +49,10 @@ class AgentProfile:
|
|||||||
name: str = "default"
|
name: str = "default"
|
||||||
system_prompt: str = ""
|
system_prompt: str = ""
|
||||||
default_model: str = "gpt-4.1-mini"
|
default_model: str = "gpt-4.1-mini"
|
||||||
max_tokens: int = 4096
|
max_tokens: int | None = None
|
||||||
|
max_context_messages: int = 1000
|
||||||
temperature: float = 0.2
|
temperature: float = 0.2
|
||||||
max_tool_iterations: int = 8
|
max_tool_iterations: int = 30
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -74,6 +90,7 @@ class AgentLoop:
|
|||||||
self.loaded: EngineLoadResult | None = None
|
self.loaded: EngineLoadResult | None = None
|
||||||
self.runtime_services: dict[str, Any] = {}
|
self.runtime_services: dict[str, Any] = {}
|
||||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||||
|
self._active_direct_task: asyncio.Task[Any] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
|
|
||||||
@ -115,6 +132,8 @@ class AgentLoop:
|
|||||||
if item.future.cancelled():
|
if item.future.cancelled():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
previous_direct_task = self._active_direct_task
|
||||||
|
self._active_direct_task = asyncio.current_task()
|
||||||
try:
|
try:
|
||||||
result = await self._process_direct_impl(item.task, **item.kwargs)
|
result = await self._process_direct_impl(item.task, **item.kwargs)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@ -127,6 +146,8 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
if not item.future.done():
|
if not item.future.done():
|
||||||
item.future.set_result(result)
|
item.future.set_result(result)
|
||||||
|
finally:
|
||||||
|
self._active_direct_task = previous_direct_task
|
||||||
finally:
|
finally:
|
||||||
if self._run_queue is not None:
|
if self._run_queue is not None:
|
||||||
while True:
|
while True:
|
||||||
@ -168,6 +189,9 @@ class AgentLoop:
|
|||||||
if self._stop_requested:
|
if self._stop_requested:
|
||||||
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
||||||
|
|
||||||
|
if asyncio.current_task() is self._active_direct_task:
|
||||||
|
return await self._process_direct_impl(task, **kwargs)
|
||||||
|
|
||||||
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
||||||
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
||||||
return await future
|
return await future
|
||||||
@ -200,6 +224,7 @@ class AgentLoop:
|
|||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
execution_context: str | None = None,
|
execution_context: str | None = None,
|
||||||
skill_selection_context: str | None = None,
|
skill_selection_context: str | None = None,
|
||||||
|
prompt_locale: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
@ -223,8 +248,10 @@ class AgentLoop:
|
|||||||
attempt_index: int | None = None,
|
attempt_index: int | None = None,
|
||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
|
tool_executor_override: Any = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""跑通最小 direct run 主链。
|
"""跑通最小 direct run 主链。
|
||||||
|
|
||||||
@ -249,6 +276,7 @@ class AgentLoop:
|
|||||||
title=title,
|
title=title,
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
skill_selection_context=skill_selection_context,
|
skill_selection_context=skill_selection_context,
|
||||||
|
prompt_locale=prompt_locale,
|
||||||
model=model,
|
model=model,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@ -272,8 +300,10 @@ class AgentLoop:
|
|||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
pinned_skill_names=pinned_skill_names,
|
pinned_skill_names=pinned_skill_names,
|
||||||
pinned_skill_contexts=pinned_skill_contexts,
|
pinned_skill_contexts=pinned_skill_contexts,
|
||||||
|
tool_executor_override=tool_executor_override,
|
||||||
allow_candidate_generation=allow_candidate_generation,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
intent_agent_decision=intent_agent_decision,
|
intent_agent_decision=intent_agent_decision,
|
||||||
|
channel_identity=channel_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_direct_impl(
|
async def _process_direct_impl(
|
||||||
@ -286,6 +316,7 @@ class AgentLoop:
|
|||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
execution_context: str | None = None,
|
execution_context: str | None = None,
|
||||||
skill_selection_context: str | None = None,
|
skill_selection_context: str | None = None,
|
||||||
|
prompt_locale: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
@ -309,8 +340,10 @@ class AgentLoop:
|
|||||||
attempt_index: int | None = None,
|
attempt_index: int | None = None,
|
||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
|
tool_executor_override: Any = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""真正执行一轮 direct run 的内部实现。
|
"""真正执行一轮 direct run 的内部实现。
|
||||||
|
|
||||||
@ -327,6 +360,7 @@ class AgentLoop:
|
|||||||
tool_registry = self._require_loaded("tool_registry")
|
tool_registry = self._require_loaded("tool_registry")
|
||||||
tool_assembler = self._require_loaded("tool_assembler")
|
tool_assembler = self._require_loaded("tool_assembler")
|
||||||
tool_executor = self._require_loaded("tool_executor")
|
tool_executor = self._require_loaded("tool_executor")
|
||||||
|
effective_tool_executor = tool_executor_override or tool_executor
|
||||||
skills_loader = self._require_loaded("skills_loader")
|
skills_loader = self._require_loaded("skills_loader")
|
||||||
skill_assembler = self._require_loaded("skill_assembler")
|
skill_assembler = self._require_loaded("skill_assembler")
|
||||||
skill_learning_service = self._require_loaded("skill_learning_service")
|
skill_learning_service = self._require_loaded("skill_learning_service")
|
||||||
@ -348,7 +382,7 @@ class AgentLoop:
|
|||||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
||||||
resolved_max_tokens = max_tokens or self.profile.max_tokens
|
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
|
||||||
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
||||||
resolved_max_tool_iterations = (
|
resolved_max_tool_iterations = (
|
||||||
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
|
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
|
||||||
@ -446,7 +480,7 @@ class AgentLoop:
|
|||||||
*(pinned_skill_contexts or []),
|
*(pinned_skill_contexts or []),
|
||||||
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
|
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
|
||||||
]
|
]
|
||||||
if not include_skill_assembly or thinking_enabled is False:
|
if not include_skill_assembly:
|
||||||
activated_skills = self._merge_skill_contexts(pinned_skills, [])
|
activated_skills = self._merge_skill_contexts(pinned_skills, [])
|
||||||
else:
|
else:
|
||||||
skill_query = skill_selection_context or task
|
skill_query = skill_selection_context or task
|
||||||
@ -512,8 +546,6 @@ class AgentLoop:
|
|||||||
|
|
||||||
if not include_tools:
|
if not include_tools:
|
||||||
selected_tool_specs = []
|
selected_tool_specs = []
|
||||||
elif thinking_enabled is False:
|
|
||||||
selected_tool_specs = tool_registry.list_specs()
|
|
||||||
else:
|
else:
|
||||||
selected_tool_specs = await tool_assembler.assemble(
|
selected_tool_specs = await tool_assembler.assemble(
|
||||||
task_description=task,
|
task_description=task,
|
||||||
@ -543,7 +575,11 @@ class AgentLoop:
|
|||||||
|
|
||||||
build_input = ContextBuildInput(
|
build_input = ContextBuildInput(
|
||||||
base_system_prompt=self.profile.system_prompt,
|
base_system_prompt=self.profile.system_prompt,
|
||||||
history=session_manager.get_history(resolved_session_id),
|
prompt_locale=prompt_locale,
|
||||||
|
history=session_manager.get_history(
|
||||||
|
resolved_session_id,
|
||||||
|
max_messages=max(1, self.profile.max_context_messages),
|
||||||
|
),
|
||||||
current_user_input=task,
|
current_user_input=task,
|
||||||
memory_snapshot=memory_snapshot,
|
memory_snapshot=memory_snapshot,
|
||||||
activated_skills=activated_skills,
|
activated_skills=activated_skills,
|
||||||
@ -552,8 +588,16 @@ class AgentLoop:
|
|||||||
source=source,
|
source=source,
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
channel=channel_identity.channel_id if channel_identity else None,
|
||||||
|
channel_kind=channel_identity.kind if channel_identity else None,
|
||||||
|
account_id=channel_identity.account_id if channel_identity else None,
|
||||||
|
peer_id=channel_identity.peer_id if channel_identity else None,
|
||||||
|
peer_type=channel_identity.peer_type if channel_identity else None,
|
||||||
|
chat_id=channel_identity.peer_id if channel_identity else None,
|
||||||
|
thread_id=channel_identity.thread_id if channel_identity else None,
|
||||||
parent_session_id=parent_session_id,
|
parent_session_id=parent_session_id,
|
||||||
),
|
),
|
||||||
|
runtime_context=self._current_runtime_context(),
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
||||||
)
|
)
|
||||||
@ -621,11 +665,17 @@ class AgentLoop:
|
|||||||
"tool_registry": tool_registry,
|
"tool_registry": tool_registry,
|
||||||
"skills_loader": skills_loader,
|
"skills_loader": skills_loader,
|
||||||
"draft_service": getattr(loaded, "draft_service", None),
|
"draft_service": getattr(loaded, "draft_service", None),
|
||||||
|
"beaver_config": loaded.config,
|
||||||
|
"task_id": task_id,
|
||||||
|
"run_id": resolved_run_id,
|
||||||
**self.runtime_services,
|
**self.runtime_services,
|
||||||
},
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"source": source,
|
"source": source,
|
||||||
"agent_name": self.profile.name,
|
"agent_name": self.profile.name,
|
||||||
|
"session_id": resolved_session_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"run_id": resolved_run_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -693,20 +743,23 @@ class AgentLoop:
|
|||||||
tool_calls=assistant_tool_calls or None,
|
tool_calls=assistant_tool_calls or None,
|
||||||
finish_reason=response.finish_reason,
|
finish_reason=response.finish_reason,
|
||||||
reasoning=response.reasoning_content,
|
reasoning=response.reasoning_content,
|
||||||
|
context_visible=not bool(assistant_tool_calls),
|
||||||
source=source,
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
model=final_model,
|
model=final_model,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
if not response.has_tool_calls:
|
||||||
context_builder.add_assistant_message(
|
context_builder.add_assistant_message(
|
||||||
messages,
|
messages,
|
||||||
content=response.content,
|
content=response.content,
|
||||||
tool_calls=assistant_tool_calls or None,
|
|
||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.has_tool_calls:
|
|
||||||
final_text = response.content or ""
|
final_text = response.content or ""
|
||||||
|
if self._looks_like_raw_tool_call(final_text):
|
||||||
|
final_text = RAW_TOOL_CALL_FALLBACK
|
||||||
|
final_finish_reason = "invalid_tool_call_text"
|
||||||
|
else:
|
||||||
final_finish_reason = response.finish_reason or "stop"
|
final_finish_reason = response.finish_reason or "stop"
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -719,10 +772,7 @@ class AgentLoop:
|
|||||||
temperature=resolved_temperature,
|
temperature=resolved_temperature,
|
||||||
thinking_enabled=thinking_enabled,
|
thinking_enabled=thinking_enabled,
|
||||||
)
|
)
|
||||||
final_text = finalized or (
|
final_text = finalized or RAW_TOOL_CALL_FALLBACK
|
||||||
"Tool loop stopped after reaching the configured iteration limit, "
|
|
||||||
"and no final answer was produced."
|
|
||||||
)
|
|
||||||
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
|
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
resolved_session_id,
|
resolved_session_id,
|
||||||
@ -743,9 +793,15 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
context_builder.add_assistant_message(
|
||||||
|
messages,
|
||||||
|
content=response.content,
|
||||||
|
tool_calls=assistant_tool_calls or None,
|
||||||
|
reasoning_content=response.reasoning_content,
|
||||||
|
)
|
||||||
iterations += 1
|
iterations += 1
|
||||||
for tool_call in response.tool_calls:
|
for tool_call in response.tool_calls:
|
||||||
result = await tool_executor.execute_tool_call(tool_call, context=tool_context)
|
result = await effective_tool_executor.execute_tool_call(tool_call, context=tool_context)
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
resolved_session_id,
|
resolved_session_id,
|
||||||
run_id=resolved_run_id,
|
run_id=resolved_run_id,
|
||||||
@ -873,21 +929,18 @@ class AgentLoop:
|
|||||||
provider: Any,
|
provider: Any,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
model: str,
|
model: str,
|
||||||
max_tokens: int,
|
max_tokens: int | None,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
thinking_enabled: bool | None,
|
thinking_enabled: bool | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
final_messages = [
|
final_messages = AgentLoop._with_system_guidance(
|
||||||
*messages,
|
messages,
|
||||||
{
|
(
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"The configured tool iteration budget is exhausted. Do not call tools. "
|
"The configured tool iteration budget is exhausted. Do not call tools. "
|
||||||
"Produce the best final answer from the existing conversation and tool results. "
|
"Produce the best final answer from the existing conversation and tool results. "
|
||||||
"State uncertainty explicitly."
|
"State uncertainty explicitly."
|
||||||
),
|
),
|
||||||
},
|
)
|
||||||
]
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"messages": final_messages,
|
"messages": final_messages,
|
||||||
"tools": None,
|
"tools": None,
|
||||||
@ -898,7 +951,27 @@ class AgentLoop:
|
|||||||
if thinking_enabled is not None:
|
if thinking_enabled is not None:
|
||||||
kwargs["thinking_enabled"] = thinking_enabled
|
kwargs["thinking_enabled"] = thinking_enabled
|
||||||
response = await provider.chat(**kwargs)
|
response = await provider.chat(**kwargs)
|
||||||
return (response.content or "").strip()
|
if response.has_tool_calls:
|
||||||
|
return ""
|
||||||
|
content = (response.content or "").strip()
|
||||||
|
if AgentLoop._looks_like_raw_tool_call(content):
|
||||||
|
return ""
|
||||||
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _looks_like_raw_tool_call(content: str | None) -> bool:
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
return bool(_RAW_TOOL_CALL_RE.match(content))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _with_system_guidance(messages: list[dict[str, Any]], guidance: str) -> list[dict[str, Any]]:
|
||||||
|
copied = [dict(message) for message in messages]
|
||||||
|
if copied and copied[0].get("role") == "system":
|
||||||
|
existing = str(copied[0].get("content") or "").strip()
|
||||||
|
copied[0]["content"] = "\n\n".join(part for part in (existing, guidance.strip()) if part)
|
||||||
|
return copied
|
||||||
|
return [{"role": "system", "content": guidance.strip()}, *copied]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
|
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
|
||||||
@ -1133,3 +1206,49 @@ class AgentLoop:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _current_runtime_context() -> RuntimeContext:
|
||||||
|
utc_now = datetime.now(timezone.utc)
|
||||||
|
timezone_name = AgentLoop._configured_timezone_name()
|
||||||
|
local_now = datetime.now().astimezone()
|
||||||
|
rendered_timezone = local_now.tzname()
|
||||||
|
|
||||||
|
if timezone_name:
|
||||||
|
try:
|
||||||
|
local_now = utc_now.astimezone(ZoneInfo(timezone_name))
|
||||||
|
rendered_timezone = timezone_name
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
rendered_timezone = local_now.tzname() or timezone_name
|
||||||
|
|
||||||
|
return RuntimeContext(
|
||||||
|
utc_datetime=utc_now.isoformat(),
|
||||||
|
local_datetime=local_now.isoformat(),
|
||||||
|
timezone=rendered_timezone,
|
||||||
|
utc_offset=AgentLoop._format_utc_offset(local_now),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _configured_timezone_name() -> str | None:
|
||||||
|
for value in (os.getenv("BEAVER_RUNTIME_TIMEZONE"), os.getenv("TZ")):
|
||||||
|
cleaned = (value or "").strip()
|
||||||
|
if cleaned:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
try:
|
||||||
|
timezone_file = "/etc/timezone"
|
||||||
|
if os.path.exists(timezone_file):
|
||||||
|
with open(timezone_file, encoding="utf-8") as file:
|
||||||
|
cleaned = file.read().strip()
|
||||||
|
if cleaned:
|
||||||
|
return cleaned
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_utc_offset(value: datetime) -> str | None:
|
||||||
|
raw = value.strftime("%z")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return f"{raw[:3]}:{raw[3:]}"
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
|
|||||||
"model": model or self.default_model,
|
"model": model or self.default_model,
|
||||||
"system": system_prompt or "",
|
"system": system_prompt or "",
|
||||||
"messages": anthropic_messages,
|
"messages": anthropic_messages,
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
|
resolved_max_tokens = (
|
||||||
|
_default_max_tokens_for_model(model or self.default_model)
|
||||||
|
if max_tokens is None
|
||||||
|
else max(1, max_tokens)
|
||||||
|
)
|
||||||
|
kwargs["max_tokens"] = resolved_max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = _convert_tools(tools)
|
kwargs["tools"] = _convert_tools(tools)
|
||||||
|
|
||||||
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
|
|||||||
return self.default_model
|
return self.default_model
|
||||||
|
|
||||||
|
|
||||||
|
def _default_max_tokens_for_model(model: str) -> int:
|
||||||
|
"""Return a conservative native output ceiling for Anthropic Messages."""
|
||||||
|
|
||||||
|
normalized = model.lower().replace("_", "-")
|
||||||
|
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
|
||||||
|
return 64_000
|
||||||
|
if "haiku" in normalized:
|
||||||
|
return 4_096
|
||||||
|
return 8_192
|
||||||
|
|
||||||
|
|
||||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
converted: list[dict[str, Any]] = []
|
converted: list[dict[str, Any]] = []
|
||||||
|
|||||||
@ -88,7 +88,7 @@ class LLMProvider(ABC):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
tools: list[dict] | None = None,
|
tools: list[dict] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
tools: list[dict] | None,
|
tools: list[dict] | None,
|
||||||
model: str,
|
model: str,
|
||||||
max_tokens: int,
|
max_tokens: int | None,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
thinking_enabled: bool | None,
|
thinking_enabled: bool | None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model or self.default_model,
|
"model": model or self.default_model,
|
||||||
"messages": self.sanitize_empty_content(messages),
|
"messages": self.sanitize_empty_content(messages),
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
kwargs["max_tokens"] = max(1, max_tokens)
|
||||||
if tools:
|
if tools:
|
||||||
kwargs.update(tools=tools, tool_choice="auto")
|
kwargs.update(tools=tools, tool_choice="auto")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from ipaddress import ip_address
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from .base import LLMProvider, LLMResponse, ToolCallRequest
|
from .base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from .registry import find_by_model, find_by_name, find_gateway
|
from .registry import find_by_model, find_by_name, find_gateway
|
||||||
@ -23,7 +25,24 @@ except ModuleNotFoundError: # pragma: no cover
|
|||||||
litellm = None # type: ignore[assignment]
|
litellm = None # type: ignore[assignment]
|
||||||
acompletion = None # type: ignore[assignment]
|
acompletion = None # type: ignore[assignment]
|
||||||
|
|
||||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
|
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_local_vllm_api_base(api_base: str | None) -> bool:
|
||||||
|
if not api_base:
|
||||||
|
return False
|
||||||
|
lowered = api_base.lower()
|
||||||
|
if "vllm" in lowered or "localhost" in lowered:
|
||||||
|
return True
|
||||||
|
|
||||||
|
host = urlsplit(lowered).hostname or ""
|
||||||
|
if host in {"127.0.0.1", "::1", "0.0.0.0"}:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
parsed_host = ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return parsed_host.is_private or parsed_host.is_loopback
|
||||||
|
|
||||||
|
|
||||||
class LiteLLMProvider(LLMProvider):
|
class LiteLLMProvider(LLMProvider):
|
||||||
@ -119,13 +138,23 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
sanitized = []
|
sanitized = []
|
||||||
|
system_contents: list[str] = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
||||||
|
if clean.get("role") == "system":
|
||||||
|
content = clean.get("content")
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
system_contents.append(content.strip())
|
||||||
|
elif content is not None:
|
||||||
|
system_contents.append(str(content))
|
||||||
|
continue
|
||||||
if clean.get("role") == "assistant" and "content" not in clean:
|
if clean.get("role") == "assistant" and "content" not in clean:
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
if isinstance(clean.get("tool_calls"), list):
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
||||||
sanitized.append(clean)
|
sanitized.append(clean)
|
||||||
|
if system_contents:
|
||||||
|
sanitized.insert(0, {"role": "system", "content": "\n\n".join(system_contents)})
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -175,23 +204,34 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
kwargs["provider"] = provider_payload
|
kwargs["provider"] = provider_payload
|
||||||
|
|
||||||
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
|
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
|
||||||
if enabled is None:
|
if self._uses_mistral_reasoning_parser(original_model, resolved_model):
|
||||||
return
|
if enabled is not None:
|
||||||
model_key = f"{original_model} {resolved_model}".lower()
|
extra_body = dict(kwargs.get("extra_body") or {})
|
||||||
if "qwen" not in model_key:
|
extra_body["reasoning_effort"] = "high" if enabled else "none"
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
return
|
return
|
||||||
|
|
||||||
extra_body = dict(kwargs.get("extra_body") or {})
|
extra_body = dict(kwargs.get("extra_body") or {})
|
||||||
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
||||||
chat_template_kwargs["enable_thinking"] = bool(enabled)
|
chat_template_kwargs["enable_thinking"] = False
|
||||||
extra_body["chat_template_kwargs"] = chat_template_kwargs
|
extra_body["chat_template_kwargs"] = chat_template_kwargs
|
||||||
|
extra_body["thinking"] = {"type": "disabled"}
|
||||||
kwargs["extra_body"] = extra_body
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
|
def _uses_mistral_reasoning_parser(self, original_model: str, resolved_model: str) -> bool:
|
||||||
|
model_names = f"{original_model} {resolved_model}".lower()
|
||||||
|
if "mistral" not in model_names:
|
||||||
|
return False
|
||||||
|
if self.provider_name == "vllm":
|
||||||
|
return True
|
||||||
|
return self.provider_name in {"openai", "custom"} and _looks_like_local_vllm_api_base(self.api_base)
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -204,10 +244,11 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": resolved_model,
|
"model": resolved_model,
|
||||||
"messages": sanitized_messages,
|
"messages": sanitized_messages,
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"timeout": self.request_timeout_seconds or 45.0,
|
"timeout": self.request_timeout_seconds or 45.0,
|
||||||
}
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
kwargs["max_tokens"] = max(1, max_tokens)
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
kwargs["api_key"] = self.api_key
|
kwargs["api_key"] = self.api_key
|
||||||
if self.api_base:
|
if self.api_base:
|
||||||
|
|||||||
@ -84,8 +84,10 @@ class MessageRecord:
|
|||||||
payload["task_id"] = self.event_payload.get("task_id")
|
payload["task_id"] = self.event_payload.get("task_id")
|
||||||
if self.event_payload.get("task_status"):
|
if self.event_payload.get("task_status"):
|
||||||
payload["task_status"] = self.event_payload.get("task_status")
|
payload["task_status"] = self.event_payload.get("task_status")
|
||||||
if self.event_payload.get("validation_status"):
|
if self.event_payload.get("evidence_status"):
|
||||||
payload["validation_status"] = self.event_payload.get("validation_status")
|
payload["evidence_status"] = self.event_payload.get("evidence_status")
|
||||||
|
if self.event_payload.get("acceptance_state"):
|
||||||
|
payload["acceptance_state"] = self.event_payload.get("acceptance_state")
|
||||||
if self.event_payload.get("feedback_state"):
|
if self.event_payload.get("feedback_state"):
|
||||||
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
||||||
if self.event_payload.get("feedback_error"):
|
if self.event_payload.get("feedback_error"):
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@ -110,6 +111,12 @@ END;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_journal_mode() -> str:
|
||||||
|
requested = os.getenv("BEAVER_SQLITE_JOURNAL_MODE", "DELETE").strip().upper()
|
||||||
|
allowed = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "OFF", "WAL"}
|
||||||
|
return requested if requested in allowed else "DELETE"
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
class SessionStore:
|
||||||
"""SQLite-backed session store."""
|
"""SQLite-backed session store."""
|
||||||
|
|
||||||
@ -119,7 +126,9 @@ class SessionStore:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None)
|
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None)
|
||||||
self._conn.row_factory = sqlite3.Row
|
self._conn.row_factory = sqlite3.Row
|
||||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
self._conn.execute("PRAGMA mmap_size=0")
|
||||||
|
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
self._conn.execute(f"PRAGMA journal_mode={_sqlite_journal_mode()}")
|
||||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
self._init_schema()
|
self._init_schema()
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from .schema import (
|
|||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
PluginsConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"BeaverConfig",
|
"BeaverConfig",
|
||||||
"EmbeddingConfig",
|
"EmbeddingConfig",
|
||||||
"MCPServerConfig",
|
"MCPServerConfig",
|
||||||
|
"PluginsConfig",
|
||||||
"ProviderConfig",
|
"ProviderConfig",
|
||||||
"ToolsConfig",
|
"ToolsConfig",
|
||||||
"default_config_path",
|
"default_config_path",
|
||||||
|
|||||||
@ -13,14 +13,16 @@ from .schema import (
|
|||||||
AuthzConfig,
|
AuthzConfig,
|
||||||
BackendIdentityConfig,
|
BackendIdentityConfig,
|
||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
|
ChannelConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
PluginsConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
||||||
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
|
"local_filesystem_mcp": {"category": "filesystem", "display_name": "个人智能体文件系统工具"},
|
||||||
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
|
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
|
||||||
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
|
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
|
||||||
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
|
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
|
||||||
@ -72,7 +74,9 @@ def load_config(
|
|||||||
providers=_parse_providers(data.get("providers")),
|
providers=_parse_providers(data.get("providers")),
|
||||||
embedding=_parse_embedding(data),
|
embedding=_parse_embedding(data),
|
||||||
tools=_parse_tools(data.get("tools")),
|
tools=_parse_tools(data.get("tools")),
|
||||||
|
plugins=_parse_plugins(data.get("plugins")),
|
||||||
authz=_parse_authz(data.get("authz")),
|
authz=_parse_authz(data.get("authz")),
|
||||||
|
channels=_parse_channels(data.get("channels")),
|
||||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||||
config_path=path,
|
config_path=path,
|
||||||
)
|
)
|
||||||
@ -86,6 +90,25 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
|
|||||||
model=_string(defaults.get("model") or data.get("model")),
|
model=_string(defaults.get("model") or data.get("model")),
|
||||||
provider=_string(defaults.get("provider") or data.get("provider")),
|
provider=_string(defaults.get("provider") or data.get("provider")),
|
||||||
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
||||||
|
max_tokens=_int(_first_config_value(
|
||||||
|
defaults.get("maxTokens"),
|
||||||
|
defaults.get("max_tokens"),
|
||||||
|
data.get("maxTokens"),
|
||||||
|
data.get("max_tokens"),
|
||||||
|
)),
|
||||||
|
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
|
||||||
|
max_context_messages=_int(
|
||||||
|
defaults.get("maxContextMessages")
|
||||||
|
or defaults.get("max_context_messages")
|
||||||
|
or data.get("maxContextMessages")
|
||||||
|
or data.get("max_context_messages")
|
||||||
|
),
|
||||||
|
max_tool_iterations=_int(_first_config_value(
|
||||||
|
defaults.get("maxToolIterations"),
|
||||||
|
defaults.get("max_tool_iterations"),
|
||||||
|
data.get("maxToolIterations"),
|
||||||
|
data.get("max_tool_iterations"),
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -167,6 +190,17 @@ def _parse_tools(raw: Any) -> ToolsConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_plugins(raw: Any) -> PluginsConfig:
|
||||||
|
data = _as_dict(raw)
|
||||||
|
return PluginsConfig(
|
||||||
|
search_paths=_string_list(data.get("searchPaths") or data.get("search_paths")),
|
||||||
|
auto_sync=_bool(
|
||||||
|
data.get("autoSync") if "autoSync" in data else data.get("auto_sync"),
|
||||||
|
default=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_authz(raw: Any) -> AuthzConfig:
|
def _parse_authz(raw: Any) -> AuthzConfig:
|
||||||
data = _as_dict(raw)
|
data = _as_dict(raw)
|
||||||
return AuthzConfig(
|
return AuthzConfig(
|
||||||
@ -177,6 +211,48 @@ def _parse_authz(raw: Any) -> AuthzConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channels(raw: Any) -> dict[str, ChannelConfig]:
|
||||||
|
channels: dict[str, ChannelConfig] = {}
|
||||||
|
for channel_id, payload in _as_dict(raw).items():
|
||||||
|
cleaned_id = str(channel_id).strip()
|
||||||
|
if not cleaned_id:
|
||||||
|
continue
|
||||||
|
channels[cleaned_id] = _parse_channel_config(payload)
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channel_config(payload: Any) -> ChannelConfig:
|
||||||
|
data = _as_dict(payload)
|
||||||
|
return ChannelConfig(
|
||||||
|
enabled=_bool(data.get("enabled"), default=False),
|
||||||
|
kind=_string(data.get("kind")) or "",
|
||||||
|
mode=_string(data.get("mode")) or "webhook",
|
||||||
|
account_id=_string(data.get("accountId") or data.get("account_id")) or "",
|
||||||
|
display_name=_string(data.get("displayName") or data.get("display_name")) or "",
|
||||||
|
config=_normalize_config_map(data.get("config")),
|
||||||
|
secrets=_string_dict(data.get("secrets")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config_map(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
_camel_to_snake_key(str(key)): item
|
||||||
|
for key, item in value.items()
|
||||||
|
if str(key).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake_key(value: str) -> str:
|
||||||
|
result: list[str] = []
|
||||||
|
for char in value:
|
||||||
|
if char.isupper() and result:
|
||||||
|
result.append("_")
|
||||||
|
result.append(char.lower())
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
||||||
data = _as_dict(raw)
|
data = _as_dict(raw)
|
||||||
return BackendIdentityConfig(
|
return BackendIdentityConfig(
|
||||||
@ -192,6 +268,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
|||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_config_value(*values: Any) -> Any:
|
||||||
|
for value in values:
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _string(value: Any) -> str | None:
|
def _string(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -217,6 +300,13 @@ def _float(value: Any) -> float | None:
|
|||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: Any) -> int | None:
|
||||||
|
parsed = _float(value)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
return int(parsed)
|
||||||
|
|
||||||
|
|
||||||
def _bool(value: Any, *, default: bool) -> bool:
|
def _bool(value: Any, *, default: bool) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -25,6 +25,10 @@ class AgentDefaultsConfig:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
provider: str | None = None
|
provider: str | None = None
|
||||||
embedding_model: str | None = None
|
embedding_model: str | None = None
|
||||||
|
max_tokens: int | None = None
|
||||||
|
temperature: float | None = None
|
||||||
|
max_context_messages: int | None = None
|
||||||
|
max_tool_iterations: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -77,6 +81,14 @@ class ToolsConfig:
|
|||||||
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PluginsConfig:
|
||||||
|
"""Declarative plugin discovery settings."""
|
||||||
|
|
||||||
|
search_paths: list[str] = field(default_factory=list)
|
||||||
|
auto_sync: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AuthzConfig:
|
class AuthzConfig:
|
||||||
"""External AuthZ service configuration."""
|
"""External AuthZ service configuration."""
|
||||||
@ -87,6 +99,19 @@ class AuthzConfig:
|
|||||||
outlook_mcp_url: str = ""
|
outlook_mcp_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelConfig:
|
||||||
|
"""One configured channel adapter instance."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
kind: str = ""
|
||||||
|
mode: str = "webhook"
|
||||||
|
account_id: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BackendIdentityConfig:
|
class BackendIdentityConfig:
|
||||||
"""This backend's AuthZ client identity."""
|
"""This backend's AuthZ client identity."""
|
||||||
@ -106,7 +131,9 @@ class BeaverConfig:
|
|||||||
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
||||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||||
|
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
||||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
|
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
config_path: Path | None = None
|
config_path: Path | None = None
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Event contracts and dispatch helpers."""
|
"""Event contracts and dispatch helpers."""
|
||||||
|
|
||||||
from .message_bus import InboundMessage, MessageBus, OutboundMessage
|
from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage
|
||||||
|
|
||||||
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
|
__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"]
|
||||||
|
|||||||
@ -9,12 +9,58 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelIdentity:
|
||||||
|
"""Normalized channel routing identity.
|
||||||
|
|
||||||
|
`channel_id` is the Beaver adapter instance id, not the platform kind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
account_id: str
|
||||||
|
peer_id: str
|
||||||
|
thread_id: str | None = None
|
||||||
|
peer_type: str = "unknown"
|
||||||
|
user_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
|
def validation_error(self) -> str | None:
|
||||||
|
if not self.channel_id.strip():
|
||||||
|
return "channel_id is required"
|
||||||
|
if not self.account_id.strip():
|
||||||
|
return "account_id is required"
|
||||||
|
if not self.peer_id.strip():
|
||||||
|
return "peer_id is required"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def session_id(self) -> str:
|
||||||
|
parts = [self.channel_id, self.account_id, self.peer_id]
|
||||||
|
if self.thread_id:
|
||||||
|
parts.append(self.thread_id)
|
||||||
|
return ":".join(_clean_session_part(part) for part in parts)
|
||||||
|
|
||||||
|
def dedupe_key(self) -> str | None:
|
||||||
|
if not self.message_id:
|
||||||
|
return None
|
||||||
|
return f"{self.session_id()}:{_clean_session_part(self.message_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_session_part(value: str) -> str:
|
||||||
|
cleaned = str(value).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return "unknown"
|
||||||
|
return cleaned.replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
"""A minimal inbound message accepted by the gateway bridge."""
|
"""A minimal inbound message accepted by the gateway bridge."""
|
||||||
|
|
||||||
channel: str
|
channel: str
|
||||||
content: str
|
content: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@ -35,6 +81,8 @@ class OutboundMessage:
|
|||||||
content: str
|
content: str
|
||||||
session_id: str | None
|
session_id: str | None
|
||||||
finish_reason: str
|
finish_reason: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
message_id: str = field(default_factory=lambda: str(uuid4()))
|
message_id: str = field(default_factory=lambda: str(uuid4()))
|
||||||
run_id: str | None = None
|
run_id: str | None = None
|
||||||
provider_name: str | None = None
|
provider_name: str | None = None
|
||||||
|
|||||||
@ -6,6 +6,7 @@ normal Task instead of a detached agent turn.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -37,13 +38,18 @@ class CronSchedule:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule":
|
def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule":
|
||||||
|
kind = str(payload.get("kind") or "every")
|
||||||
|
display = _optional_str(payload.get("display"))
|
||||||
|
every_ms = _optional_int(payload.get("every_ms") or payload.get("everyMs"))
|
||||||
|
if kind == "every" and every_ms is None:
|
||||||
|
every_ms = _every_ms_from_display(display)
|
||||||
return cls(
|
return cls(
|
||||||
kind=str(payload.get("kind") or "every"), # type: ignore[arg-type]
|
kind=kind, # type: ignore[arg-type]
|
||||||
at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")),
|
at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")),
|
||||||
every_ms=_optional_int(payload.get("every_ms") or payload.get("everyMs")),
|
every_ms=every_ms,
|
||||||
expr=_optional_str(payload.get("expr")),
|
expr=_optional_str(payload.get("expr")),
|
||||||
tz=_optional_str(payload.get("tz")),
|
tz=_optional_str(payload.get("tz")),
|
||||||
display=_optional_str(payload.get("display")),
|
display=display,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -250,6 +256,17 @@ def _optional_str(value: Any) -> str | None:
|
|||||||
def _optional_int(value: Any) -> int | None:
|
def _optional_int(value: Any) -> int | None:
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _every_ms_from_display(display: str | None) -> int | None:
|
||||||
|
match = re.fullmatch(r"every\s+(\d+)s", (display or "").strip(), re.IGNORECASE)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return int(match.group(1)) * 1000
|
||||||
|
|
||||||
|
|
||||||
def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode:
|
def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode:
|
||||||
@ -259,7 +276,3 @@ def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> C
|
|||||||
if cleaned == "task":
|
if cleaned == "task":
|
||||||
return "task"
|
return "task"
|
||||||
return "notification"
|
return "notification"
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|||||||
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Cross-process workspace write lock with in-process reentrancy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
if os.name == "nt": # pragma: no cover - exercised on Windows only
|
||||||
|
import msvcrt
|
||||||
|
else: # pragma: no cover - import branch is platform-specific
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceWriteLockBusy(RuntimeError):
|
||||||
|
"""Raised when the shared workspace write lock cannot be acquired."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _HeldLock:
|
||||||
|
rlock: threading.RLock
|
||||||
|
handle: object | None = None
|
||||||
|
owner_thread: int | None = None
|
||||||
|
depth: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY_GUARD = threading.Lock()
|
||||||
|
_HELD_BY_PATH: dict[Path, _HeldLock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceWriteLock:
|
||||||
|
def __init__(self, workspace: str | Path) -> None:
|
||||||
|
self.workspace = Path(workspace)
|
||||||
|
self.path = self.workspace / ".beaver" / "locks" / "plugin-skill-write.lock"
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def acquire(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
blocking: bool = True,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
held = self._held_lock()
|
||||||
|
thread_id = threading.get_ident()
|
||||||
|
with held.rlock:
|
||||||
|
if held.owner_thread == thread_id and held.depth > 0:
|
||||||
|
held.depth += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
held.depth -= 1
|
||||||
|
return
|
||||||
|
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = self.path.open("a+b")
|
||||||
|
try:
|
||||||
|
self._acquire_os_lock(handle, timeout_seconds=timeout_seconds, blocking=blocking)
|
||||||
|
held.handle = handle
|
||||||
|
held.owner_thread = thread_id
|
||||||
|
held.depth = 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
held.depth = 0
|
||||||
|
held.owner_thread = None
|
||||||
|
held.handle = None
|
||||||
|
self._release_os_lock(handle)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
def _held_lock(self) -> _HeldLock:
|
||||||
|
resolved = self.path.resolve()
|
||||||
|
with _REGISTRY_GUARD:
|
||||||
|
held = _HELD_BY_PATH.get(resolved)
|
||||||
|
if held is None:
|
||||||
|
held = _HeldLock(rlock=threading.RLock())
|
||||||
|
_HELD_BY_PATH[resolved] = held
|
||||||
|
return held
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _acquire_os_lock(handle: object, *, timeout_seconds: float | None, blocking: bool) -> None:
|
||||||
|
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if os.name == "nt": # pragma: no cover
|
||||||
|
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||||
|
msvcrt.locking(handle.fileno(), mode, 1) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
flags = fcntl.LOCK_EX
|
||||||
|
if not blocking:
|
||||||
|
flags |= fcntl.LOCK_NB
|
||||||
|
fcntl.flock(handle.fileno(), flags) # type: ignore[attr-defined]
|
||||||
|
return
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
if not blocking:
|
||||||
|
raise WorkspaceWriteLockBusy("plugin_write_busy")
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
raise WorkspaceWriteLockBusy("plugin_write_busy")
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _release_os_lock(handle: object) -> None:
|
||||||
|
if os.name == "nt": # pragma: no cover
|
||||||
|
handle.seek(0) # type: ignore[attr-defined]
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN) # type: ignore[attr-defined]
|
||||||
@ -109,3 +109,15 @@ class AuthzClient:
|
|||||||
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
|
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
|
||||||
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
|
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
async def get_minio_settings(self, backend_id: str) -> dict[str, Any]:
|
||||||
|
data = await self._request("GET", f"/backends/{backend_id}/settings/minio")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
async def set_minio_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = await self._request("POST", f"/backends/{backend_id}/settings/minio", json_body=payload)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
async def delete_minio_settings(self, backend_id: str) -> dict[str, Any]:
|
||||||
|
data = await self._request("DELETE", f"/backends/{backend_id}/settings/minio")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|||||||
@ -73,9 +73,9 @@ OUTLOOK_TOOL_NAMES = [
|
|||||||
def _call_timeout_seconds() -> float:
|
def _call_timeout_seconds() -> float:
|
||||||
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
|
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
|
||||||
try:
|
try:
|
||||||
return max(1.0, float(raw)) if raw else 10.0
|
return max(1.0, float(raw)) if raw else 180.0
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 10.0
|
return 180.0
|
||||||
|
|
||||||
|
|
||||||
def _use_authz_mode(config: BeaverConfig) -> bool:
|
def _use_authz_mode(config: BeaverConfig) -> bool:
|
||||||
@ -340,7 +340,7 @@ async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]:
|
|||||||
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
|
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
|
||||||
|
|
||||||
|
|
||||||
async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
async def outlook_status(config: BeaverConfig, workspace: Path, *, verify: bool = False) -> dict[str, Any]:
|
||||||
meta = _load_meta(workspace)
|
meta = _load_meta(workspace)
|
||||||
if not _use_authz_mode(config):
|
if not _use_authz_mode(config):
|
||||||
return {
|
return {
|
||||||
@ -364,7 +364,7 @@ async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any
|
|||||||
connected = False
|
connected = False
|
||||||
auth_status: dict[str, Any] | None = None
|
auth_status: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
if configured:
|
if configured and verify:
|
||||||
try:
|
try:
|
||||||
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
|
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
|
||||||
connected = bool(auth_status.get("authenticated"))
|
connected = bool(auth_status.get("authenticated"))
|
||||||
@ -403,8 +403,7 @@ async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
|||||||
warnings.append(f"{label} unavailable: {exc}")
|
warnings.append(f"{label} unavailable: {exc}")
|
||||||
return {"value": []}
|
return {"value": []}
|
||||||
|
|
||||||
inbox, sent, calendar = await asyncio.gather(
|
inbox = await _load_section(
|
||||||
_load_section(
|
|
||||||
"inbox",
|
"inbox",
|
||||||
_call_outlook_mcp_tool(
|
_call_outlook_mcp_tool(
|
||||||
config,
|
config,
|
||||||
@ -412,8 +411,8 @@ async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
|||||||
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||||
scopes=["list_tools", "tool:mail_list_messages"],
|
scopes=["list_tools", "tool:mail_list_messages"],
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
_load_section(
|
sent = await _load_section(
|
||||||
"sent items",
|
"sent items",
|
||||||
_call_outlook_mcp_tool(
|
_call_outlook_mcp_tool(
|
||||||
config,
|
config,
|
||||||
@ -421,8 +420,8 @@ async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
|||||||
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||||
scopes=["list_tools", "tool:mail_list_messages"],
|
scopes=["list_tools", "tool:mail_list_messages"],
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
_load_section(
|
calendar = await _load_section(
|
||||||
"calendar",
|
"calendar",
|
||||||
_call_outlook_mcp_tool(
|
_call_outlook_mcp_tool(
|
||||||
config,
|
config,
|
||||||
@ -435,7 +434,6 @@ async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
scopes=["list_tools", "tool:calendar_list_events"],
|
scopes=["list_tools", "tool:calendar_list_events"],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
|
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
"""Channel interfaces."""
|
"""Channel interfaces."""
|
||||||
|
|
||||||
from .base import ChannelAdapter
|
from .base import ChannelAdapter
|
||||||
|
from .base import ChannelInboundSink
|
||||||
|
from .external_connector import ExternalConnectorChannel
|
||||||
from .manager import ChannelManager
|
from .manager import ChannelManager
|
||||||
from .memory import MemoryChannelAdapter
|
from .memory import MemoryChannelAdapter
|
||||||
|
from .terminal_websocket import TerminalWebSocketAdapter
|
||||||
|
|
||||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
__all__ = [
|
||||||
|
"ChannelAdapter",
|
||||||
|
"ChannelInboundSink",
|
||||||
|
"ExternalConnectorChannel",
|
||||||
|
"ChannelManager",
|
||||||
|
"MemoryChannelAdapter",
|
||||||
|
"TerminalWebSocketAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@ -2,16 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
class ChannelAdapter(Protocol):
|
class ChannelAdapter(Protocol):
|
||||||
"""Minimal contract every gateway channel must implement."""
|
"""Minimal contract every runtime channel adapter must implement."""
|
||||||
|
|
||||||
name: str
|
channel_id: str
|
||||||
bus: MessageBus
|
kind: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Prepare the channel before messages are routed."""
|
"""Prepare the channel before messages are routed."""
|
||||||
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
|
|||||||
async def send(self, message: OutboundMessage) -> None:
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
"""Deliver an outbound message to the concrete channel."""
|
"""Deliver an outbound message to the concrete channel."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelInboundSink(Protocol):
|
||||||
|
"""Runtime callback used by adapters to submit normalized inbound messages."""
|
||||||
|
|
||||||
|
async def accept_inbound(self, message: InboundMessage) -> Any:
|
||||||
|
"""Accept a normalized inbound message from an adapter."""
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
"""Channel connection setup layer."""
|
||||||
|
|
||||||
|
from .connectors import ChannelConnector, ChannelConnectorRegistry
|
||||||
|
from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore
|
||||||
|
from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector
|
||||||
|
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
|
||||||
|
from .sidecar_client import ConnectorSidecarClient
|
||||||
|
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
|
||||||
|
from .telegram import TelegramConnector
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChannelConnector",
|
||||||
|
"ChannelConnectorRegistry",
|
||||||
|
"ConnectorMessageDedupeRecord",
|
||||||
|
"DedupeBeginResult",
|
||||||
|
"MessageDedupeStore",
|
||||||
|
"ExternalConnectorBase",
|
||||||
|
"FeishuConnector",
|
||||||
|
"WeixinConnector",
|
||||||
|
"ConnectorSidecarClient",
|
||||||
|
"ChannelConnection",
|
||||||
|
"ChannelRuntimeSpec",
|
||||||
|
"PairingSession",
|
||||||
|
"ValidationResult",
|
||||||
|
"ChannelConnectionStore",
|
||||||
|
"CredentialStore",
|
||||||
|
"PairingTokenStore",
|
||||||
|
"TelegramConnector",
|
||||||
|
]
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
"""Channel connector registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from beaver.foundation.config.schema import ChannelConfig
|
||||||
|
|
||||||
|
from .models import ChannelRuntimeSpec, ValidationResult
|
||||||
|
from .store import ChannelConnectionStore, CredentialStore
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConnector(Protocol):
|
||||||
|
kind: str
|
||||||
|
|
||||||
|
async def validate(self, connection_id: str) -> ValidationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def revoke(self, connection_id: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConnectorRegistry:
|
||||||
|
def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None:
|
||||||
|
self.connection_store = connection_store
|
||||||
|
self.credential_store = credential_store
|
||||||
|
self._connectors: dict[str, ChannelConnector] = {}
|
||||||
|
|
||||||
|
def register(self, connector: ChannelConnector) -> None:
|
||||||
|
kind = connector.kind.strip()
|
||||||
|
if not kind:
|
||||||
|
raise ValueError("Connector kind is required")
|
||||||
|
if kind in self._connectors:
|
||||||
|
raise ValueError(f"Connector already registered: {kind}")
|
||||||
|
self._connectors[kind] = connector
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, str]]:
|
||||||
|
return [{"kind": kind} for kind in sorted(self._connectors)]
|
||||||
|
|
||||||
|
def connector_for_kind(self, kind: str) -> ChannelConnector:
|
||||||
|
return self._connector(kind)
|
||||||
|
|
||||||
|
async def validate(self, connection_id: str) -> ValidationResult:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
connector = self._connector(connection.kind)
|
||||||
|
result = await connector.validate(connection_id)
|
||||||
|
self.connection_store.update_status(
|
||||||
|
connection_id,
|
||||||
|
status=result.status,
|
||||||
|
last_error=result.error,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
return await self._connector(connection.kind).materialize_runtime(connection_id)
|
||||||
|
|
||||||
|
async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]:
|
||||||
|
specs: list[ChannelRuntimeSpec] = []
|
||||||
|
for connection in self.connection_store.list():
|
||||||
|
if connection.status not in {"connected", "running"}:
|
||||||
|
continue
|
||||||
|
specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id))
|
||||||
|
return specs
|
||||||
|
|
||||||
|
async def materialize_channel_configs(self) -> dict[str, ChannelConfig]:
|
||||||
|
channels: dict[str, ChannelConfig] = {}
|
||||||
|
for spec in await self.materialize_connected_runtime_specs():
|
||||||
|
secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {}
|
||||||
|
channels[spec.channel_id] = ChannelConfig(
|
||||||
|
enabled=True,
|
||||||
|
kind=spec.kind,
|
||||||
|
mode=spec.mode,
|
||||||
|
account_id=spec.account_id,
|
||||||
|
display_name=spec.display_name,
|
||||||
|
config=dict(spec.config),
|
||||||
|
secrets=secrets,
|
||||||
|
)
|
||||||
|
return channels
|
||||||
|
|
||||||
|
async def revoke(self, connection_id: str) -> None:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
await self._connector(connection.kind).revoke(connection_id)
|
||||||
|
self.connection_store.revoke(connection_id)
|
||||||
|
|
||||||
|
def _connector(self, kind: str) -> ChannelConnector:
|
||||||
|
connector = self._connectors.get(kind)
|
||||||
|
if connector is None:
|
||||||
|
raise KeyError(f"Connector not registered: {kind}")
|
||||||
|
return connector
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
"""Bridge event dedupe store for external connector retries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ConnectorMessageDedupeRecord:
|
||||||
|
dedupe_key: str
|
||||||
|
connection_id: str
|
||||||
|
event_id: str
|
||||||
|
status: str
|
||||||
|
first_seen_at: str
|
||||||
|
updated_at: str
|
||||||
|
delivery_attempts: int
|
||||||
|
message_id: str | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord":
|
||||||
|
return cls(
|
||||||
|
dedupe_key=str(data.get("dedupe_key") or ""),
|
||||||
|
connection_id=str(data.get("connection_id") or ""),
|
||||||
|
event_id=str(data.get("event_id") or ""),
|
||||||
|
status=str(data.get("status") or "processing"),
|
||||||
|
first_seen_at=str(data.get("first_seen_at") or _iso_now()),
|
||||||
|
updated_at=str(data.get("updated_at") or _iso_now()),
|
||||||
|
delivery_attempts=int(data.get("delivery_attempts") or 0),
|
||||||
|
message_id=str(data["message_id"]) if data.get("message_id") is not None else None,
|
||||||
|
last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DedupeBeginResult:
|
||||||
|
should_process: bool
|
||||||
|
dedupe_key: str
|
||||||
|
status: str
|
||||||
|
http_status: int
|
||||||
|
retry_after_seconds: int | None
|
||||||
|
record: ConnectorMessageDedupeRecord
|
||||||
|
|
||||||
|
|
||||||
|
class MessageDedupeStore:
|
||||||
|
def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.processing_ttl_seconds = int(processing_ttl_seconds)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult:
|
||||||
|
dedupe_key = f"{connection_id}:{event_id}"
|
||||||
|
now = _iso_now()
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
raw = data["records"].get(dedupe_key)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
record = ConnectorMessageDedupeRecord.from_dict(raw)
|
||||||
|
if record.status == "completed":
|
||||||
|
return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record)
|
||||||
|
if record.status == "processing" and not self._is_stale(record, now):
|
||||||
|
return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record)
|
||||||
|
record.status = "processing"
|
||||||
|
record.updated_at = now
|
||||||
|
record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt))
|
||||||
|
record.last_error = None
|
||||||
|
else:
|
||||||
|
record = ConnectorMessageDedupeRecord(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
connection_id=connection_id,
|
||||||
|
event_id=event_id,
|
||||||
|
status="processing",
|
||||||
|
first_seen_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
delivery_attempts=max(1, int(delivery_attempt)),
|
||||||
|
)
|
||||||
|
data["records"][dedupe_key] = record.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record)
|
||||||
|
|
||||||
|
def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord:
|
||||||
|
return self._mark(dedupe_key, status="completed", message_id=message_id, error=None)
|
||||||
|
|
||||||
|
def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord:
|
||||||
|
return self._mark(dedupe_key, status="failed", message_id=None, error=error)
|
||||||
|
|
||||||
|
def _mark(
|
||||||
|
self,
|
||||||
|
dedupe_key: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
message_id: str | None,
|
||||||
|
error: str | None,
|
||||||
|
) -> ConnectorMessageDedupeRecord:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
raw = data["records"].get(dedupe_key)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise KeyError(dedupe_key)
|
||||||
|
record = ConnectorMessageDedupeRecord.from_dict(raw)
|
||||||
|
record.status = status
|
||||||
|
record.updated_at = _iso_now()
|
||||||
|
record.message_id = message_id or record.message_id
|
||||||
|
record.last_error = error
|
||||||
|
data["records"][dedupe_key] = record.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool:
|
||||||
|
age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds()
|
||||||
|
return age >= self.processing_ttl_seconds
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"records": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"records": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
|
||||||
|
return {"records": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(self.path)
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
"""Sidecar-backed channel connectors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import ChannelRuntimeSpec, ValidationResult
|
||||||
|
from .sidecar_client import ConnectorSidecarClient
|
||||||
|
from .store import ChannelConnectionStore, CredentialStore
|
||||||
|
|
||||||
|
POLICY_CONFIG_KEYS = {
|
||||||
|
"allowFrom",
|
||||||
|
"groupAllowFrom",
|
||||||
|
"requireMentionInGroups",
|
||||||
|
"respondToMentionAll",
|
||||||
|
"dmMode",
|
||||||
|
"maxMessageChars",
|
||||||
|
"textBatchDelayMs",
|
||||||
|
"textBatchMaxMessages",
|
||||||
|
"textBatchMaxChars",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalConnectorBase:
|
||||||
|
kind = ""
|
||||||
|
capabilities: list[str] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_store: ChannelConnectionStore,
|
||||||
|
credential_store: CredentialStore,
|
||||||
|
sidecar_client: ConnectorSidecarClient | Any,
|
||||||
|
sidecar_base_url: str,
|
||||||
|
) -> None:
|
||||||
|
self.connection_store = connection_store
|
||||||
|
self.credential_store = credential_store
|
||||||
|
self.sidecar_client = sidecar_client
|
||||||
|
self.sidecar_base_url = sidecar_base_url
|
||||||
|
self.callback_base_url = _callback_base_url()
|
||||||
|
|
||||||
|
async def start_session(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
display_name: str,
|
||||||
|
owner_user_id: str | None,
|
||||||
|
options: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
runtime_config = {"sidecarBaseUrl": self.sidecar_base_url}
|
||||||
|
runtime_config.update(_policy_runtime_config(options))
|
||||||
|
connection = self.connection_store.create(
|
||||||
|
kind=self.kind,
|
||||||
|
mode="sidecar",
|
||||||
|
display_name=display_name or self.kind,
|
||||||
|
account_id="",
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
auth_type="connector_session",
|
||||||
|
runtime_config=runtime_config,
|
||||||
|
capabilities=list(self.capabilities),
|
||||||
|
)
|
||||||
|
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
|
||||||
|
payload = {
|
||||||
|
"kind": self.kind,
|
||||||
|
"connectionId": connection.connection_id,
|
||||||
|
"channelId": connection.channel_id,
|
||||||
|
"displayName": connection.display_name,
|
||||||
|
"callbackBaseUrl": self.callback_base_url,
|
||||||
|
"options": dict(options),
|
||||||
|
}
|
||||||
|
view = dict(await self.sidecar_client.start_session(payload))
|
||||||
|
connection.pairing_session_id = str(view.get("sessionId") or "")
|
||||||
|
connection = self.connection_store.update(connection)
|
||||||
|
connection = self._apply_session_view(connection, view)
|
||||||
|
view["connectionId"] = connection.connection_id
|
||||||
|
view["channelId"] = connection.channel_id
|
||||||
|
return view
|
||||||
|
|
||||||
|
async def poll_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
view = dict(await self.sidecar_client.get_session(session_id))
|
||||||
|
connection = self._connection_for_session(session_id)
|
||||||
|
connection = self._apply_session_view(connection, view)
|
||||||
|
view["connectionId"] = connection.connection_id
|
||||||
|
view["channelId"] = connection.channel_id
|
||||||
|
return view
|
||||||
|
|
||||||
|
def _apply_session_view(self, connection: Any, view: dict[str, Any]) -> Any:
|
||||||
|
status = str(view.get("status") or "")
|
||||||
|
if status == "connected":
|
||||||
|
connection.account_id = str(view.get("accountId") or connection.account_id)
|
||||||
|
connection.display_name = str(view.get("displayName") or connection.display_name)
|
||||||
|
metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {}
|
||||||
|
state_ref = metadata.get("stateRef")
|
||||||
|
if state_ref:
|
||||||
|
connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref})
|
||||||
|
self.connection_store.update(connection)
|
||||||
|
self.connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||||
|
elif status in {"expired", "error", "cancelled"}:
|
||||||
|
self.connection_store.update_status(
|
||||||
|
connection.connection_id,
|
||||||
|
status="error",
|
||||||
|
last_error=str(view.get("error") or status),
|
||||||
|
)
|
||||||
|
return self.connection_store.get(connection.connection_id)
|
||||||
|
|
||||||
|
async def validate(self, connection_id: str) -> ValidationResult:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
if connection.status in {"connected", "running"}:
|
||||||
|
return ValidationResult(
|
||||||
|
ok=True,
|
||||||
|
status="connected",
|
||||||
|
account_id=connection.account_id,
|
||||||
|
display_name=connection.display_name,
|
||||||
|
)
|
||||||
|
return ValidationResult(ok=False, status=connection.status, error=connection.last_error)
|
||||||
|
|
||||||
|
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
if connection.status not in {"connected", "running"}:
|
||||||
|
raise ValueError(f"Connection is not connected: {connection.connection_id}")
|
||||||
|
return ChannelRuntimeSpec(
|
||||||
|
channel_id=connection.channel_id,
|
||||||
|
kind="external_connector",
|
||||||
|
mode="http",
|
||||||
|
account_id=connection.account_id,
|
||||||
|
display_name=connection.display_name,
|
||||||
|
config={
|
||||||
|
"platformKind": self.kind,
|
||||||
|
"connectionId": connection.connection_id,
|
||||||
|
**dict(connection.runtime_config),
|
||||||
|
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
|
||||||
|
},
|
||||||
|
secrets_ref=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke(self, connection_id: str) -> None:
|
||||||
|
await self.sidecar_client.logout(connection_id)
|
||||||
|
|
||||||
|
def _connection_for_session(self, session_id: str):
|
||||||
|
for connection in self.connection_store.list():
|
||||||
|
if connection.pairing_session_id == session_id:
|
||||||
|
return connection
|
||||||
|
raise KeyError(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
class WeixinConnector(ExternalConnectorBase):
|
||||||
|
kind = "weixin"
|
||||||
|
capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConnector(ExternalConnectorBase):
|
||||||
|
kind = "feishu"
|
||||||
|
capabilities = ["receive_text", "send_text", "receive_media", "groups"]
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key in POLICY_CONFIG_KEYS:
|
||||||
|
if key not in options:
|
||||||
|
continue
|
||||||
|
value = options[key]
|
||||||
|
if key in {"allowFrom", "groupAllowFrom"}:
|
||||||
|
items = _string_list(value)
|
||||||
|
if items:
|
||||||
|
result[key] = items
|
||||||
|
continue
|
||||||
|
if key in {"maxMessageChars", "textBatchDelayMs", "textBatchMaxMessages", "textBatchMaxChars"}:
|
||||||
|
number = _positive_int(value)
|
||||||
|
if number is not None:
|
||||||
|
result[key] = number
|
||||||
|
continue
|
||||||
|
if key in {"requireMentionInGroups", "respondToMentionAll"}:
|
||||||
|
result[key] = _bool(value)
|
||||||
|
continue
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text:
|
||||||
|
result[key] = text
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_base_url() -> str:
|
||||||
|
for name in ("EXTERNAL_CONNECTOR_CALLBACK_BASE_URL", "BEAVER_CONNECTOR_CALLBACK_BASE_URL"):
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value:
|
||||||
|
return value.rstrip("/")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
raw_items = value.replace("\n", ",").split(",")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
raw_items = value
|
||||||
|
else:
|
||||||
|
raw_items = []
|
||||||
|
return [str(item).strip() for item in raw_items if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
"""Channel connection setup models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"}
|
||||||
|
|
||||||
|
|
||||||
|
def iso_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelConnection:
|
||||||
|
connection_id: str
|
||||||
|
owner_user_id: str | None
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
display_name: str
|
||||||
|
account_id: str
|
||||||
|
status: str
|
||||||
|
auth_type: str
|
||||||
|
credentials_ref: str | None = None
|
||||||
|
connector_ref: str | None = None
|
||||||
|
pairing_session_id: str | None = None
|
||||||
|
runtime_config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
capabilities: list[str] = field(default_factory=list)
|
||||||
|
created_at: str = field(default_factory=iso_now)
|
||||||
|
updated_at: str = field(default_factory=iso_now)
|
||||||
|
last_seen_at: str | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection":
|
||||||
|
return cls(
|
||||||
|
connection_id=str(data.get("connection_id") or ""),
|
||||||
|
owner_user_id=_optional_string(data.get("owner_user_id")),
|
||||||
|
channel_id=str(data.get("channel_id") or ""),
|
||||||
|
kind=str(data.get("kind") or ""),
|
||||||
|
mode=str(data.get("mode") or ""),
|
||||||
|
display_name=str(data.get("display_name") or ""),
|
||||||
|
account_id=str(data.get("account_id") or ""),
|
||||||
|
status=str(data.get("status") or "draft"),
|
||||||
|
auth_type=str(data.get("auth_type") or ""),
|
||||||
|
credentials_ref=_optional_string(data.get("credentials_ref")),
|
||||||
|
connector_ref=_optional_string(data.get("connector_ref")),
|
||||||
|
pairing_session_id=_optional_string(data.get("pairing_session_id")),
|
||||||
|
runtime_config=dict(data.get("runtime_config") or {}),
|
||||||
|
capabilities=[str(item) for item in data.get("capabilities") or []],
|
||||||
|
created_at=str(data.get("created_at") or iso_now()),
|
||||||
|
updated_at=str(data.get("updated_at") or iso_now()),
|
||||||
|
last_seen_at=_optional_string(data.get("last_seen_at")),
|
||||||
|
last_error=_optional_string(data.get("last_error")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PairingSession:
|
||||||
|
pairing_session_id: str
|
||||||
|
kind: str
|
||||||
|
scope: str
|
||||||
|
token: str
|
||||||
|
status: str
|
||||||
|
expires_at_ms: int
|
||||||
|
created_at_ms: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "PairingSession":
|
||||||
|
return cls(
|
||||||
|
pairing_session_id=str(data.get("pairing_session_id") or ""),
|
||||||
|
kind=str(data.get("kind") or ""),
|
||||||
|
scope=str(data.get("scope") or ""),
|
||||||
|
token=str(data.get("token") or ""),
|
||||||
|
status=str(data.get("status") or "pending"),
|
||||||
|
expires_at_ms=int(data.get("expires_at_ms") or 0),
|
||||||
|
created_at_ms=int(data.get("created_at_ms") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelRuntimeSpec:
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
account_id: str
|
||||||
|
display_name: str
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets_ref: str | None = None
|
||||||
|
external_endpoint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ValidationResult:
|
||||||
|
ok: bool
|
||||||
|
status: str
|
||||||
|
account_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
"""HTTP client for the generic external connector sidecar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorSidecarClient:
|
||||||
|
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.timeout_seconds = float(timeout_seconds)
|
||||||
|
|
||||||
|
async def get_connectors(self) -> list[dict[str, Any]]:
|
||||||
|
return await self._request("GET", "/connectors")
|
||||||
|
|
||||||
|
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await self._request("POST", "/connector-sessions", json=payload)
|
||||||
|
|
||||||
|
async def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/connector-sessions/{session_id}")
|
||||||
|
|
||||||
|
async def cancel_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
|
||||||
|
|
||||||
|
async def logout(self, connection_id: str) -> dict[str, Any]:
|
||||||
|
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
|
||||||
|
|
||||||
|
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await self._request("POST", "/send", json=payload)
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
||||||
|
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
"""Persistent channel connection stores."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConnectionStore:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
mode: str,
|
||||||
|
display_name: str,
|
||||||
|
account_id: str,
|
||||||
|
owner_user_id: str | None,
|
||||||
|
auth_type: str,
|
||||||
|
runtime_config: dict[str, Any] | None = None,
|
||||||
|
capabilities: list[str] | None = None,
|
||||||
|
credentials_ref: str | None = None,
|
||||||
|
) -> ChannelConnection:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
connection_id = f"conn_{uuid4().hex}"
|
||||||
|
channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}"
|
||||||
|
now = iso_now()
|
||||||
|
connection = ChannelConnection(
|
||||||
|
connection_id=connection_id,
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=kind,
|
||||||
|
mode=mode,
|
||||||
|
display_name=display_name or channel_id,
|
||||||
|
account_id=account_id,
|
||||||
|
status="draft",
|
||||||
|
auth_type=auth_type,
|
||||||
|
credentials_ref=credentials_ref,
|
||||||
|
runtime_config=runtime_config or {},
|
||||||
|
capabilities=capabilities or [],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
data["connections"][connection_id] = connection.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def get(self, connection_id: str) -> ChannelConnection:
|
||||||
|
data = self._load()
|
||||||
|
raw = data["connections"].get(connection_id)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise KeyError(connection_id)
|
||||||
|
return ChannelConnection.from_dict(raw)
|
||||||
|
|
||||||
|
def list(self) -> list[ChannelConnection]:
|
||||||
|
data = self._load()
|
||||||
|
return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)]
|
||||||
|
|
||||||
|
def update(self, connection: ChannelConnection) -> ChannelConnection:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
if connection.connection_id not in data["connections"]:
|
||||||
|
raise KeyError(connection.connection_id)
|
||||||
|
connection.updated_at = iso_now()
|
||||||
|
data["connections"][connection.connection_id] = connection.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection:
|
||||||
|
if status not in CONNECTION_STATUSES:
|
||||||
|
raise ValueError(f"Unsupported connection status: {status}")
|
||||||
|
connection = self.get(connection_id)
|
||||||
|
connection.status = status
|
||||||
|
connection.last_error = last_error
|
||||||
|
if status in {"connected", "running"}:
|
||||||
|
connection.last_seen_at = iso_now()
|
||||||
|
return self.update(connection)
|
||||||
|
|
||||||
|
def revoke(self, connection_id: str) -> ChannelConnection:
|
||||||
|
return self.update_status(connection_id, status="revoked", last_error=None)
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"connections": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"connections": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("connections"), dict):
|
||||||
|
return {"connections": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialStore:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def put(self, *, kind: str, values: dict[str, Any]) -> str:
|
||||||
|
cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()}
|
||||||
|
ref = f"cred_{uuid4().hex}"
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()}
|
||||||
|
self._save(data)
|
||||||
|
return ref
|
||||||
|
|
||||||
|
def get(self, ref: str) -> dict[str, str]:
|
||||||
|
data = self._load()
|
||||||
|
item = data["credentials"].get(ref)
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise KeyError(ref)
|
||||||
|
values = item.get("values")
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): str(value) for key, value in values.items()}
|
||||||
|
|
||||||
|
def redacted(self, ref: str | None) -> dict[str, str]:
|
||||||
|
if not ref:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
values = self.get(ref)
|
||||||
|
except KeyError:
|
||||||
|
return {}
|
||||||
|
return {key: "***" for key in values}
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"credentials": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"credentials": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict):
|
||||||
|
return {"credentials": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class PairingTokenStore:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession:
|
||||||
|
now_ms = _now_ms()
|
||||||
|
session = PairingSession(
|
||||||
|
pairing_session_id=f"pair_{uuid4().hex}",
|
||||||
|
kind=kind,
|
||||||
|
scope=scope,
|
||||||
|
token=f"pair_{uuid4().hex}",
|
||||||
|
status="pending",
|
||||||
|
expires_at_ms=now_ms + int(ttl_seconds * 1000),
|
||||||
|
created_at_ms=now_ms,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
data["sessions"][session.pairing_session_id] = session.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def consume(self, token: str, *, expected_kind: str) -> PairingSession | None:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
for key, raw in data["sessions"].items():
|
||||||
|
session = PairingSession.from_dict(raw)
|
||||||
|
if session.token != token or session.kind != expected_kind:
|
||||||
|
continue
|
||||||
|
if session.status != "pending" or session.expires_at_ms <= _now_ms():
|
||||||
|
return None
|
||||||
|
session.status = "consumed"
|
||||||
|
data["sessions"][key] = session.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return session
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"sessions": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"sessions": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
|
||||||
|
return {"sessions": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(value: str) -> str:
|
||||||
|
text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower())
|
||||||
|
return "-".join(part for part in text.split("-") if part) or "channel"
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
"""Telegram channel connector."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import ChannelRuntimeSpec, ValidationResult
|
||||||
|
from .store import ChannelConnectionStore, CredentialStore
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConnector:
|
||||||
|
kind = "telegram"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_store: ChannelConnectionStore,
|
||||||
|
credential_store: CredentialStore,
|
||||||
|
client_factory: Callable[[str], Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.connection_store = connection_store
|
||||||
|
self.credential_store = credential_store
|
||||||
|
self.client_factory = client_factory or _default_client_factory
|
||||||
|
|
||||||
|
async def validate(self, connection_id: str) -> ValidationResult:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
token = self._bot_token(connection.credentials_ref)
|
||||||
|
try:
|
||||||
|
client = self.client_factory(token)
|
||||||
|
raw = await client.get_me()
|
||||||
|
bot_id = _value(raw, "id")
|
||||||
|
username = _value(raw, "username")
|
||||||
|
first_name = _value(raw, "first_name") or "Telegram Bot"
|
||||||
|
account_id = f"telegram:{bot_id}" if bot_id else connection.account_id
|
||||||
|
display_name = f"{first_name} (@{username})" if username else first_name
|
||||||
|
connection.account_id = account_id
|
||||||
|
connection.display_name = display_name
|
||||||
|
connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"]
|
||||||
|
self.connection_store.update(connection)
|
||||||
|
return ValidationResult(
|
||||||
|
ok=True,
|
||||||
|
status="connected",
|
||||||
|
account_id=account_id,
|
||||||
|
display_name=display_name,
|
||||||
|
metadata={"username": username} if username else {},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ValidationResult(ok=False, status="error", error=str(exc))
|
||||||
|
|
||||||
|
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||||
|
connection = self.connection_store.get(connection_id)
|
||||||
|
if connection.status not in {"connected", "running"}:
|
||||||
|
raise ValueError(f"Connection is not connected: {connection.connection_id}")
|
||||||
|
return ChannelRuntimeSpec(
|
||||||
|
channel_id=connection.channel_id,
|
||||||
|
kind=connection.kind,
|
||||||
|
mode=connection.mode,
|
||||||
|
account_id=connection.account_id,
|
||||||
|
display_name=connection.display_name,
|
||||||
|
config=dict(connection.runtime_config),
|
||||||
|
secrets_ref=connection.credentials_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke(self, connection_id: str) -> None:
|
||||||
|
# Telegram bot tokens do not have a Beaver-managed platform revoke action.
|
||||||
|
# The registry owns local connection state transitions.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _bot_token(self, credentials_ref: str | None) -> str:
|
||||||
|
if not credentials_ref:
|
||||||
|
raise ValueError("Telegram credentials are missing")
|
||||||
|
token = self.credential_store.get(credentials_ref).get("botToken")
|
||||||
|
if not token:
|
||||||
|
raise ValueError("botToken is required")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _value(raw: Any, key: str) -> str:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
value = raw.get(key)
|
||||||
|
else:
|
||||||
|
value = getattr(raw, key, None)
|
||||||
|
return str(value).strip() if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _default_client_factory(token: str) -> Any:
|
||||||
|
try:
|
||||||
|
from telegram import Bot
|
||||||
|
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||||
|
raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc
|
||||||
|
return Bot(token=token)
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
"""Generic runtime channel backed by an external connector sidecar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from beaver.foundation.events import OutboundMessage
|
||||||
|
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalConnectorChannel:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: str,
|
||||||
|
platform_kind: str,
|
||||||
|
connection_id: str,
|
||||||
|
account_id: str,
|
||||||
|
display_name: str,
|
||||||
|
sidecar_client: ConnectorSidecarClient | Any,
|
||||||
|
) -> None:
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.kind = "external_connector"
|
||||||
|
self.mode = "http"
|
||||||
|
self.platform_kind = platform_kind
|
||||||
|
self.connection_id = connection_id
|
||||||
|
self.account_id = account_id
|
||||||
|
self.display_name = display_name or channel_id
|
||||||
|
self.sidecar_client = sidecar_client
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
|
identity = message.channel_identity
|
||||||
|
if identity is None:
|
||||||
|
raise ValueError("channel_identity is required for external connector sends")
|
||||||
|
metadata = {
|
||||||
|
"inboundMessageId": identity.message_id,
|
||||||
|
"sessionId": message.session_id,
|
||||||
|
}
|
||||||
|
context_token = _context_token(message)
|
||||||
|
if context_token:
|
||||||
|
metadata["contextToken"] = context_token
|
||||||
|
payload = {
|
||||||
|
"requestId": _request_id(message),
|
||||||
|
"connectionId": self.connection_id,
|
||||||
|
"channelId": self.channel_id,
|
||||||
|
"kind": self.platform_kind,
|
||||||
|
"target": {
|
||||||
|
"peerId": identity.peer_id,
|
||||||
|
"peerType": identity.peer_type,
|
||||||
|
"threadId": identity.thread_id,
|
||||||
|
},
|
||||||
|
"content": message.content,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
await self.sidecar_client.send(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id(message: OutboundMessage) -> str:
|
||||||
|
identity = message.channel_identity
|
||||||
|
channel = message.channel or (identity.channel_id if identity else "unknown")
|
||||||
|
session_id = message.session_id or (identity.session_id() if identity else "unknown")
|
||||||
|
message_id = str(message.message_id or "").strip()
|
||||||
|
if not message_id:
|
||||||
|
basis = "|".join(
|
||||||
|
[
|
||||||
|
message.content,
|
||||||
|
identity.message_id if identity and identity.message_id else "",
|
||||||
|
identity.peer_id if identity else "",
|
||||||
|
message.finish_reason,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
|
||||||
|
return f"out_{channel}:{session_id}:{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _context_token(message: OutboundMessage) -> str | None:
|
||||||
|
inbound_metadata = message.metadata.get("inbound_metadata")
|
||||||
|
if isinstance(inbound_metadata, dict):
|
||||||
|
value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token"))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token"))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_optional(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
"""Generic fixed-schema text webhook channel adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||||
|
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||||
|
|
||||||
|
|
||||||
|
class GenericWebhookAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: str,
|
||||||
|
kind: str,
|
||||||
|
mode: str,
|
||||||
|
account_id: str,
|
||||||
|
display_name: str = "",
|
||||||
|
inbound_sink: ChannelInboundSink,
|
||||||
|
response_timeout_seconds: float = 1800,
|
||||||
|
) -> None:
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.kind = kind
|
||||||
|
self.mode = mode
|
||||||
|
self.account_id = account_id
|
||||||
|
self.display_name = display_name or channel_id
|
||||||
|
self.inbound_sink = inbound_sink
|
||||||
|
self.response_timeout_seconds = max(1.0, float(response_timeout_seconds))
|
||||||
|
self.started = False
|
||||||
|
self._pending: dict[str, asyncio.Future[OutboundMessage]] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.started = False
|
||||||
|
for future in list(self._pending.values()):
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = str(payload.get("text") or "").strip()
|
||||||
|
peer_id = str(payload.get("peer_id") or "").strip()
|
||||||
|
message_id = str(payload.get("message_id") or "").strip()
|
||||||
|
thread_id = str(payload.get("thread_id") or "").strip() or None
|
||||||
|
peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown"
|
||||||
|
user_id = str(payload.get("user_id") or "").strip() or None
|
||||||
|
if not text:
|
||||||
|
return {"ok": False, "error": "text is required"}
|
||||||
|
if not peer_id:
|
||||||
|
return {"ok": False, "error": "peer_id is required"}
|
||||||
|
if not message_id:
|
||||||
|
return {"ok": False, "error": "message_id is required"}
|
||||||
|
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
kind=self.kind,
|
||||||
|
account_id=self.account_id,
|
||||||
|
peer_id=peer_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
peer_type=peer_type,
|
||||||
|
user_id=user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel=self.channel_id,
|
||||||
|
content=text,
|
||||||
|
user_id=user_id,
|
||||||
|
channel_identity=identity,
|
||||||
|
metadata={"webhook": {"peer_type": peer_type}},
|
||||||
|
)
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._pending[inbound.message_id] = future
|
||||||
|
accept = await self.inbound_sink.accept_inbound(inbound)
|
||||||
|
if not accept.accepted:
|
||||||
|
self._pending.pop(inbound.message_id, None)
|
||||||
|
record = accept.record or {}
|
||||||
|
return {
|
||||||
|
"ok": accept.error is None,
|
||||||
|
"duplicate": accept.duplicate,
|
||||||
|
"pending": accept.pending,
|
||||||
|
"session_id": accept.session_id,
|
||||||
|
"status": record.get("status"),
|
||||||
|
"run_id": record.get("run_id"),
|
||||||
|
"reply": record.get("reply"),
|
||||||
|
"error": accept.error or record.get("error"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending.pop(inbound.message_id, None)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"duplicate": False,
|
||||||
|
"pending": True,
|
||||||
|
"session_id": accept.session_id,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": outbound.finish_reason != "error",
|
||||||
|
"duplicate": False,
|
||||||
|
"pending": False,
|
||||||
|
"session_id": outbound.session_id,
|
||||||
|
"run_id": outbound.run_id,
|
||||||
|
"reply": outbound.content,
|
||||||
|
"error": outbound.metadata.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
|
future = self._pending.pop(message.message_id, None)
|
||||||
|
if future is None or future.done():
|
||||||
|
message.metadata["delivery_status"] = "unclaimed"
|
||||||
|
return
|
||||||
|
future.set_result(message)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user