75 Commits

Author SHA1 Message Date
83d9d8c200 ```
feat(learning): 添加技能学习候选者合成锁定机制

添加了 DraftSynthesisInProgress 和 DraftHasNoChanges 异常来处理并发场景,
确保同一技能学习候选者的合成过程不会重复执行。实现了 claim_learning_candidate_for_synthesis
方法来原子性地锁定候选者进行合成。

fix(web): 为技能草案创建端点添加适当的HTTP状态码

当草案没有变化或正在合成时,现在正确返回409状态码而不是内部错误。

feat(skills): 实现技能修订内容比较以检测无变化情况

添加了 _is_noop_revision 方法来比较基础技能和提议的修订,
如果内容没有实际变化则抛出 NoDraftChanges 异常。

refactor(process): 修复任务证据记录后根运行状态更新逻辑

将任务证据记录事件后的状态从 waiting 更改为 done,并设置 finished_at 时间戳。

feat(tools): 防止在同一运行中重复执行外部写入操作

为邮件发送、日历创建等外部写入工具添加去重机制,避免重复的外部操作。

test: 添加技能学习和工具执行的单元测试

增加测试用例验证并发草案合成、重复外部写入抑制和无变化修订检测等功能。
```
2026-06-16 15:58:42 +08:00
f07ce019fe docs(plugins): mark skill mirroring plan complete 2026-06-16 12:24:47 +08:00
a65e59fcb6 test(plugins): cover skill mirror lifecycle 2026-06-16 12:24:19 +08:00
a9b830d11e feat(skills-ui): manage plugin skill mirrors 2026-06-16 12:12:19 +08:00
0ac3cce6f3 feat(api): manage declarative plugins 2026-06-16 12:01:12 +08:00
54bced4251 feat(runtime): sync declarative plugins at boot 2026-06-16 11:58:01 +08:00
a34b1219bc feat(skill-learning): merge plugin skill updates 2026-06-16 11:55:55 +08:00
c9e6c37b5c feat(plugins): enqueue skill upgrade candidates 2026-06-16 11:47:15 +08:00
994710e232 feat(plugins): mirror enabled plugin skills 2026-06-16 11:44:55 +08:00
094dde0b81 feat(skills): store immutable plugin upstream snapshots 2026-06-16 11:42:46 +08:00
41b45e0423 feat(plugins): discover packages and persist state 2026-06-16 11:40:31 +08:00
7020f2d67f feat(agent-service): 添加直接模式下的消息处理支持
当代理服务处于非运行状态时,现在会使用process_direct方法来处理入站消息,
而不是依赖submit_direct方法。这使得服务能够在两种模式下都能正确处理消息。

添加了新的DirectModeInboundService和RunningInboundService测试类来验证
不同模式下的行为,并增加了相应的集成测试用例。
2026-06-16 11:05:08 +08:00
2cacff4a0f feat(external-connector): 默认连接器提供者改为官方版本
将环境变量 CONNECTOR_PROVIDER 的默认值从 "fake" 改为 "official",
以便在没有明确指定提供者时使用官方的连接器实现。
2026-06-16 10:48:45 +08:00
29845657f5 feat(deploy-control): 添加直接IP绑定功能支持
新增ipaddress模块导入以支持IP地址处理,
添加DEPLOY_DIRECT_PUBLIC_HOST_BIND_IP环境变量配置,
实现IP地址验证、直接URL构建和端口分配功能,
当基础域名是IP地址时自动使用直接绑定模式,
支持IPv4和IPv6地址格式并添加相应参数传递
2026-06-16 10:29:45 +08:00
b736fc9c81 feat(auth-portal): 添加部署控制服务调用支持
- 导入callDeployControl和normalizeTokenResponse函数用于处理部署配置
- 新增hasTargetFrontendUrl函数检查响应中是否存在目标前端URL
- 在注册流程中添加部署路由解析逻辑,当缺少前端URL时调用部署控制服务获取配置
- 更新normalizeTokenResponse函数以支持从实例对象中提取URL配置

refactor(runtime-control): 增强令牌响应标准化功能

- 扩展normalizeTokenResponse函数支持从instance对象中获取URL配置
- 添加对instance字段的支持,优先级为routing > instance配置
- 支持从instance中提取frontend_base_url、api_base_url和public_url

build(tsconfig): 排除测试文件构建

- 在tsconfig.json中添加排除规则,排除**/*.test.ts和**/*.test.tsx文件
- 避免测试文件参与生产构建

refactor(authz-service): 优化Python后端令牌响应处理

- 更新_normalize_portal_token_response函数支持从实例对象中提取URL配置
- 重构URL优先级逻辑,支持routing和instance双重数据源
- 改进代码可读性,将复杂的URL赋值逻辑拆分为多行
2026-06-16 10:17:30 +08:00
aadbe80a23 fix(cron_service): 修复更新任务启用状态时的死锁问题
当定时任务服务正在运行时,更新任务的启用状态可能导致死锁。
现在通过改进锁的使用方式来避免这个问题。

在update_enabled方法中添加了正确的变量初始化,
并在循环逻辑中进行了优化以确保正确释放锁。
同时添加了专门的测试用例来验证在并发场景下不会发生死锁。
2026-06-16 09:40:57 +08:00
66f1f089c5 ```
feat: 增强URL基础地址验证功能

- 在app-instance/frontend/lib/api.ts中实现更严格的URL验证逻辑,
  包括检查是否以斜杠开头、包含空格字符,以及使用URL构造函数进行验证

- 在app-instance/frontend/lib/auth-portal.ts中应用相同的URL验证改进,
  提升认证门户的基础地址处理安全性

- 在auth-portal/src/lib/auth-client.ts中增强前端跳转URL构建功能,
  添加错误处理机制并在URL构造失败时抛出相应异常

- 统一三个文件中的normalizeBaseUrl函数实现,确保一致的输入验证行为
```
2026-06-16 09:26:55 +08:00
06971dc673 ```
feat(deploy-control): 添加命令执行异常处理

当subprocess.run执行失败时捕获OSError异常,并抛出带有详细错误信息的ApiError,
提供更好的错误提示和调试支持。
```
2026-06-15 18:09:25 +08:00
beddf12bc0 ```
feat(learning): 修复任务运行记录排序逻辑处理空attempt_index的情况

当RunRecord的attempt_index为None时,之前的排序逻辑会出现问题。
现在通过在排序键中显式处理None值来解决这个问题,
将None值排在前面,并将其转换为0进行比较。

同时添加了单元测试验证团队运行记录(没有attempt_index)的处理情况。
```
2026-06-15 18:00:59 +08:00
4b0bf65ace ```
feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
2026-06-15 14:48:16 +08:00
8aeb97a5fc feat(app): 移除内置agents并添加CORS支持和技能上传优化
移除了agents/registry.json中的所有内置agents配置,将agents数组清空。
为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。
重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。
新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。
更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。
修改了意图路由技能的说明,改进任务状态管理逻辑。
2026-06-12 13:25:20 +08:00
fc9fd93c36 feat: 支持多语言提示词本地化和界面优化
- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化
- 移除内置 agents 配置以简化系统架构
- 更新 ContextBuilder 使用动态提示词模板而非硬编码内容
- 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数
- 添加输出语言指令确保用户界面内容按指定语言生成
- 扩展前端 LanguageSwitcher 组件支持三种语言选项
- 优化 Header 和侧边栏组件的响应式布局和文本截断处理
- 更新测试用例验证不同语言环境下的提示词正确性
2026-06-10 16:11:05 +08:00
9cc3334ea7 ```
feat(app-instance): 添加Outlook MCP调用超时配置选项

新增OUTLOOK_MCP_CALL_TIMEOUT_SECONDS环境变量,默认值为60秒,
用于控制后端等待Outlook MCP调用的超时时间。

在create-instance.sh脚本中添加了相应的命令行参数解析和处理逻辑,
同时更新了deploy-control组件的相关配置和测试用例。

BREAKING CHANGE: 新增配置项可能需要现有部署进行相应调整。
```
2026-06-09 14:23:37 +08:00
dc4c6f313d fix(providers): avoid chat template body for vllm mistral 2026-06-09 13:19:09 +08:00
9e2c02a333 feat(skills-ui): show replay eval coverage 2026-06-08 13:38:10 +08:00
b9171998b9 feat(skill-learning): gate publish on replay confidence 2026-06-08 13:36:55 +08:00
64d789a3d0 feat(skill-learning): produce replay eval reports 2026-06-08 13:35:58 +08:00
cc1bf85517 feat(skill-learning): run replay arms through agent loop 2026-06-08 13:33:53 +08:00
4c8bc53d33 feat(skill-learning): add surrogate tool evaluator 2026-06-08 13:33:02 +08:00
70014c0f70 feat(engine): allow replay tool executor injection 2026-06-08 13:32:14 +08:00
eb69bb168a feat(skill-learning): add replay tool policy 2026-06-08 13:31:13 +08:00
7287e93f87 feat(skill-learning): select replay eval cases 2026-06-08 13:30:00 +08:00
a925f0e77f feat(skill-learning): preserve base skill during synthesis 2026-06-08 13:28:41 +08:00
6dc580ab26 feat(skill-learning): add draft preservation checks 2026-06-08 13:27:10 +08:00
3a16dc283d feat(skill-learning): extend eval report payload 2026-06-08 13:26:12 +08:00
0fd4df3c17 docs: plan skill replay eval implementation 2026-06-08 11:26:07 +08:00
f46a435bab docs: refine skill replay case selection 2026-06-08 10:46:35 +08:00
a28254c6b8 docs: design skill replay eval 2026-06-08 10:29:39 +08:00
0cf4f44346 提交修改 2026-06-08 10:19:57 +08:00
e0bc6c55b0 chore: update external connector deployment flow 2026-06-05 17:27:05 +08:00
2c5205b06e feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
2026-06-05 11:46:40 +08:00
236ac19789 test: add pytest asyncio dependency 2026-06-03 16:34:37 +08:00
ba2417c7f2 merge: personal user filesystem minio integration 2026-06-03 16:32:29 +08:00
f3655c86c9 merge: channel runtime v1 2026-06-03 16:23:07 +08:00
c3d84b904a feat: implement channel runtime connectors 2026-06-03 16:22:44 +08:00
ffa1249403 feat: integrate MinIO-backed user filesystem 2026-06-03 12:06:34 +08:00
ee972441f5 docs: harden external connector implementation plans 2026-06-03 10:32:50 +08:00
d335199a64 docs: add external connector implementation plans 2026-06-03 09:24:06 +08:00
feeaccc0e3 docs: tighten external connector contract 2026-06-03 09:12:30 +08:00
cf35edb4ca docs: decouple external connector sidecar design 2026-06-02 18:05:46 +08:00
e0a4862af8 docs: add openclaw sidecar connector design 2026-06-02 17:59:57 +08:00
c3a0aef104 docs: tighten channel connector API plan 2026-06-02 16:07:01 +08:00
b25713a141 docs: refine channel connectors foundation plan 2026-06-02 15:51:16 +08:00
d74a1c9c12 docs: add channel connectors foundation plan 2026-06-02 15:37:36 +08:00
834d4e1e2f docs: add channel connector pairing design 2026-06-02 15:17:46 +08:00
6a6ddc21c0 docs: design terminal websocket channel 2026-06-01 16:41:19 +08:00
826db8ec2e ```
feat(llm): 添加 Hermes Gateway LLM 设计文档
```
2026-06-01 16:05:15 +08:00
33a9845566 ```
feat(engine): 添加技能查看工具并优化异步任务管理

- 添加SkillViewTool到引擎加载器中,增强技能管理功能
- 在AgentLoop中引入_active_direct_task来跟踪活跃任务
- 实现直接任务执行时的同步处理逻辑
- 更新工具实例化方式以支持依赖注入

feat(config): 增加智能体运行时参数配置支持

- 扩展AgentDefaultsConfig添加max_tokens和temperature字段
- 实现配置解析函数_first_config_value处理多个配置源
- 支持通过Web API动态更新智能体运行时参数
- 添加前端页面配置表单和验证逻辑

refactor(provider): 统一最大令牌数参数类型为可选整型

- 将所有LLM提供者的max_tokens参数改为int | None类型
- 为AnthropicProvider实现模型特定的最大令牌数默认值
- 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值
- 移除硬编码的默认值,改用条件判断

feat(process): 增强事件投影功能

- 添加工具调用开始/结束事件的映射逻辑
- 实现技能激活事件的识别和展示
- 添加辅助函数处理工具调用名称和参数提取
- 优化运行记录关联逻辑,提升事件匹配准确性

fix(web): 更新网络请求客户端信任环境设置

- 将WebFetchTool和WebSearchTool的trust_env参数设为True
- 确保HTTP客户端能够正确使用系统代理配置
- 修复可能的网络连接问题

test: 添加配置加载和事件投影相关测试

- 新增智能体默认参数配置测试用例
- 实现API配置持久化和重载测试
- 添加技能卡片和工具事件的投影测试
```
2026-05-27 13:37:06 +08:00
55b39563a0 test: cover task detail live timeline updates 2026-05-26 13:49:31 +08:00
41ac87e322 feat: make task detail timeline first 2026-05-26 13:48:18 +08:00
542b23ef6e fix: tighten task detail component interactions 2026-05-26 12:48:27 +08:00
9002d1206f feat: add task detail timeline components 2026-05-26 12:30:22 +08:00
dd9f40b38c fix: allow user task timeline actor 2026-05-26 12:24:50 +08:00
96562877cc fix: align agent timeline event contract 2026-05-26 12:20:33 +08:00
f58a57e5b8 test: cover failed task team timeline projection 2026-05-26 12:13:20 +08:00
362aae9b12 feat: enrich task process timeline events 2026-05-26 12:09:57 +08:00
29d175222d fix: make acceptance timeline dedupe robust 2026-05-26 12:04:12 +08:00
2e4f8541ee fix: dedupe task timeline milestones 2026-05-26 11:59:49 +08:00
a1164dc49a fix: harden task timeline model 2026-05-26 11:52:46 +08:00
7b638b083a feat: add task timeline model 2026-05-26 11:28:57 +08:00
6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00
16347caf5e Add task detail live execution design 2026-05-26 10:55:16 +08:00
030bce8a60 feat(litellm): 添加 reasoning_content 支持并强制禁用思考模式
- 在 LiteLLMProvider 中添加 "reasoning_content" 到允许的消息键集合中
- 修改 _apply_thinking_mode 方法以强制禁用思考模式,不再基于模型名称判断
- 总是设置 enable_thinking 为 False 并添加 thinking.type: disabled 配置
- 更新相关测试用例验证新的思考模式行为

fix(web): 修复非运行状态下的直接处理逻辑

- 创建 _run_web_direct 辅助函数来处理代理服务的直接提交/处理逻辑
- 当代理服务未运行时使用 process_direct 而不是 submit_direct
- 更新 REST 和 WebSocket 接口以使用新的处理逻辑
- 添加相应的单元测试验证非运行状态下使用直接处理

test(config): 添加代理配置重载功能的测试

- 添加 test_reload_agent_config_updates_booted_loop_config 测试函数
- 验证配置文件更新后代理循环能够正确加载新配置
- 测试模型、API 基础地址和 API 密钥的更新

chore(frontend): 默认禁用前端思考模式偏好

- 将前端思考模式存储的默认值从 true 改为 false
- 确保窗口未定义时返回 false 而不是 true
- 更新本地存储缺失时的默认行为为禁用思考模式
2026-05-22 17:43:21 +08:00
c671b66043 feat(frontend): restore session progress sidebar 2026-05-22 14:34:45 +08:00
e061961a79 merge agent team evidence validation work 2026-05-22 11:51:05 +08:00
512 changed files with 81737 additions and 3798 deletions

View 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`.

View 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

View 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 AE 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 followups (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

View 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 multiplechoice selection (25 distinct, mutually exclusive options), OR
- A one-word / shortphrase 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 multiplechoice 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 shortanswer 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

View 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 nonnegotiable 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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
View File

@ -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
View 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

View 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"
}
}
}

View 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()`).

View File

@ -0,0 +1,4 @@
context_file: AGENTS.md
context_markers:
start: <!-- SPECKIT START -->
end: <!-- SPECKIT END -->

View File

@ -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`.

View 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"

View 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"

View 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"

View 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)

View 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

View 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

View 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

View 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

View 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

View 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"

View 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"

View 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"

View 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

View 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

View 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
}

View 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

View 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"

View File

@ -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"
}
}

View 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
}

View File

@ -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"

View 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
View 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"
}

View 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"
}
}

View 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"
}
}

View 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 -->

View 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
View 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
}

View 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

View 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

View 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

View 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

View 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 -->

View 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] |

View 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"]

View 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

View 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 }}"

View 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"
}
}
}

View 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
View 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 -->

View File

@ -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 域名通常改成:

View File

@ -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
} }

View File

@ -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 ./

View File

@ -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 的旧连接或兜底地址;多实例部署不能依赖它路由所有入站事件
下一步可以继续接: 下一步可以继续接:

View File

@ -0,0 +1,4 @@
{
"agents": [],
"version": 1
}

View File

@ -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",
),
]

View File

@ -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",
] ]

View File

@ -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 转成显式消息。

View File

@ -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

View File

@ -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:]}"

View File

@ -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]] = []

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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"):

View File

@ -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()

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View 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]

View File

@ -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 {}

View File

@ -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 {

View File

@ -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",
]

View File

@ -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."""

View File

@ -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",
]

View File

@ -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

View File

@ -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)

View File

@ -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"}

View File

@ -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

View File

@ -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()

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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