From 4e3c0f820ab4c4bf9d0d8762a805426237aa353d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 00:06:50 +0800 Subject: [PATCH 1/2] feat(agent): add subcommands and worktree --- AGENTS.md | 3 +- CLAUDE.md | 8 +- .../index.md" | 16 +- openspec/changes/update-agent-cli/proposal.md | 15 + .../update-agent-cli/specs/agent-cli/spec.md | 56 +++ openspec/changes/update-agent-cli/tasks.md | 6 + scripts/agent/cli.ts | 393 ++++++++++----- scripts/agent/readme.ts | 19 +- scripts/agent/roadmap.ts | 8 +- scripts/agent/utils.ts | 10 +- scripts/agent/worktree.ts | 450 ++++++++++++++++++ 11 files changed, 829 insertions(+), 155 deletions(-) create mode 100644 openspec/changes/update-agent-cli/proposal.md create mode 100644 openspec/changes/update-agent-cli/specs/agent-cli/spec.md create mode 100644 openspec/changes/update-agent-cli/tasks.md create mode 100644 scripts/agent/worktree.ts diff --git a/AGENTS.md b/AGENTS.md index 83bf9fab..2ec35438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,8 @@ AI 继续任务 ```bash pnpm agent # 获取项目知识和当前任务 -pnpm agent --chapter 00-必读 # 查看必读章节 +pnpm agent roadmap current # 查看当前 Roadmap +pnpm agent chapter 00-必读 # 查看必读章节 ``` 详细的项目信息、技术栈、开发流程请查阅白皮书。 diff --git a/CLAUDE.md b/CLAUDE.md index 33c17566..d23a426a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,10 +7,10 @@ pnpm agent # 2. 查看当前任务 -pnpm agent --roadmap current +pnpm agent roadmap current # 3. 查阅白皮书必读章节 -pnpm agent --chapter 00-必读 +pnpm agent chapter 00-必读 ``` 完整工作流和命令速查见 [AGENTS.md](./AGENTS.md) @@ -69,8 +69,8 @@ Keep this managed block so 'openspec update' can refresh the instructions. 所有编码工作必须在 `.git-worktree/` 目录下进行: ```bash -git worktree add .git-worktree/issue-28 -b feat/issue-28 -cd .git-worktree/issue-28 && pnpm install +pnpm agent worktree create issue-28 --branch feat/issue-28 +cd .git-worktree/issue-28 # 开发... gh pr create --body "Closes #28" ``` diff --git "a/docs/white-book/\351\231\204\345\275\225/H-\345\274\200\345\217\221\350\247\204\350\214\203/index.md" "b/docs/white-book/\351\231\204\345\275\225/H-\345\274\200\345\217\221\350\247\204\350\214\203/index.md" index 95187dc3..2f358169 100644 --- "a/docs/white-book/\351\231\204\345\275\225/H-\345\274\200\345\217\221\350\247\204\350\214\203/index.md" +++ "b/docs/white-book/\351\231\204\345\275\225/H-\345\274\200\345\217\221\350\247\204\350\214\203/index.md" @@ -14,11 +14,10 @@ ```bash # 1. 创建 worktree -git worktree add .git-worktree/ -b +pnpm agent worktree create --branch --base main # 2. 进入 worktree 开发 cd .git-worktree/ -pnpm install # 3. 开发完成后提交 git add -A @@ -36,9 +35,13 @@ cd ../.. git pull origin main # 7. 清理 worktree -git worktree remove .git-worktree/ +pnpm agent worktree delete ``` +> 注意:`pnpm agent worktree create` 会在仓库根目录查找 `.env.local`,缺失则直接报错并停止。 +> `pnpm agent worktree delete` 会通过 `gh` 校验 PR 合并状态,未合并需要显式 `--force`。 +> 分支命名必须以 `feat/`、`fix/`、`docs/`、`test/`、`refactor/`、`chore/`、`ci/`、`openspec/` 或 `release/` 开头。 + ### 为什么用 Worktree - 隔离开发环境,避免分支切换污染 @@ -76,9 +79,10 @@ Closes #28 ```bash pnpm agent # 项目知识 + 当前任务 -pnpm agent --claim 28 # 领取任务 -pnpm agent --chapter <路径> # 查阅白皮书 -pnpm agent --stats # 进度统计 +pnpm agent claim 28 # 领取任务 +pnpm agent chapter <路径> # 查阅白皮书 +pnpm agent stats # 进度统计 +pnpm agent worktree list # 查看 worktree 状态 pnpm dev # 启动开发服务器 pnpm test # 运行单元测试 diff --git a/openspec/changes/update-agent-cli/proposal.md b/openspec/changes/update-agent-cli/proposal.md new file mode 100644 index 00000000..0fc9c239 --- /dev/null +++ b/openspec/changes/update-agent-cli/proposal.md @@ -0,0 +1,15 @@ +# Change: Update agent CLI subcommands and worktree workflow + +## Why +当前 `pnpm agent` 仍以 flag 方式组织功能,命令结构可读性和可扩展性不足,同时缺少统一的 worktree 管理入口。 + +## What Changes +- 将 `pnpm agent` 主要功能调整为子命令形式(roadmap/claim/done/create/stats/toc/chapter/epic/worktree)。 +- 新增 `pnpm agent worktree` 子命令,支持 create/delete/list,并联动依赖安装与 `.env.local` 同步。 +- worktree list 输出包含 PR 编号、状态与 CI/CD 检查概况;create 缺失 `.env.local` 时直接失败。 +- worktree create 对分支前缀进行严格校验,避免不符合规范的命名。 +- 更新 AGENTS.md 与白皮书中与 agent/worktree 相关的工作流指引。 + +## Impact +- Affected specs: agent-cli (new) +- Affected code: scripts/agent/cli.ts, scripts/agent/readme.ts, scripts/agent/roadmap.ts, scripts/agent/whitebook.ts (usage text), AGENTS.md, docs/white-book/附录/H-开发规范/index.md diff --git a/openspec/changes/update-agent-cli/specs/agent-cli/spec.md b/openspec/changes/update-agent-cli/specs/agent-cli/spec.md new file mode 100644 index 00000000..64496068 --- /dev/null +++ b/openspec/changes/update-agent-cli/specs/agent-cli/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Agent CLI subcommands +`pnpm agent` SHALL expose primary functions as subcommands (roadmap/claim/done/create/stats/toc/chapter/epic/worktree) and provide usage help when requested. + +#### Scenario: Default usage +- **WHEN** the user runs `pnpm agent` without arguments +- **THEN** the CLI prints the agent index内容 + +#### Scenario: Help usage +- **WHEN** the user runs `pnpm agent --help` or `pnpm agent help` +- **THEN** the CLI prints subcommand usage说明 + +### Requirement: Worktree create workflow +`pnpm agent worktree create` SHALL create a new worktree under `.git-worktree/`, install dependencies, and copy `.env.local` from the repository root. The command MUST require `--branch` and print a detailed summary on success. + +#### Scenario: Create worktree +- **WHEN** the user runs `pnpm agent worktree create issue-28 --branch feat/issue-28` +- **THEN** the worktree directory is created, dependencies are installed, and `.env.local` is copied into the new worktree + +#### Scenario: Missing branch flag +- **WHEN** the user omits `--branch` +- **THEN** the command fails with an explicit error and does not proceed + +#### Scenario: Missing env file +- **WHEN** `.env.local` does not exist in the repository root +- **THEN** the command fails with an explicit error and does not proceed + +#### Scenario: Create summary +- **WHEN** worktree creation succeeds +- **THEN** the output includes worktree path, branch name, base reference, install result, env copy result, and next-step guidance + +### Requirement: Worktree branch naming validation +`pnpm agent worktree create` SHALL reject branch names that do not start with an approved prefix (feat/, fix/, docs/, test/, refactor/, chore/, ci/, openspec/, release/). + +#### Scenario: Invalid branch prefix +- **WHEN** the user provides a branch without an approved prefix +- **THEN** the command fails with an explicit error and does not proceed + +### Requirement: Worktree delete safety +`pnpm agent worktree delete` SHALL verify whether the associated branch has an open or unmerged PR via `gh` and refuse deletion unless `--force` is provided. + +#### Scenario: Delete with open PR +- **WHEN** a worktree’s branch has an open PR and the user runs delete without `--force` +- **THEN** the command stops with a warning and does not remove the worktree + +#### Scenario: Forced delete +- **WHEN** the user runs delete with `--force` +- **THEN** the worktree is removed regardless of PR status + +### Requirement: Worktree list visibility +`pnpm agent worktree list` SHALL enumerate worktrees under `.git-worktree` and display their branch, PR number, PR status, and CI/CD check status. + +#### Scenario: List worktrees +- **WHEN** the user runs `pnpm agent worktree list` +- **THEN** the output includes each worktree path with branch name, PR number, PR status, and CI/CD check information diff --git a/openspec/changes/update-agent-cli/tasks.md b/openspec/changes/update-agent-cli/tasks.md new file mode 100644 index 00000000..e28ffdbe --- /dev/null +++ b/openspec/changes/update-agent-cli/tasks.md @@ -0,0 +1,6 @@ +## 1. Implementation +- [x] 1.1 更新白皮书与 AGENTS.md 的 agent/worktree 指引为子命令格式 +- [x] 1.2 将 `pnpm agent` CLI 迁移为子命令解析结构 +- [x] 1.3 新增 `worktree` 子命令模块(create/delete/list) +- [x] 1.4 更新 agent 输出文案与关联提示(readme/roadmap 等) +- [x] 1.5 验证:运行 `pnpm agent help` 与 worktree 子命令基础流程 diff --git a/scripts/agent/cli.ts b/scripts/agent/cli.ts index 1622ed65..afd03049 100644 --- a/scripts/agent/cli.ts +++ b/scripts/agent/cli.ts @@ -3,19 +3,22 @@ * AI Agent CLI - 主入口 * * Usage: - * pnpm agent # 输出索引(最佳实践 + 知识地图) - * pnpm agent --roadmap [version] # 查看任务列表 - * pnpm agent --claim # 领取任务 - * pnpm agent --done # 完成任务 - * pnpm agent --create # 创建任务 - * pnpm agent --epic create <title> # 创建 Epic - * pnpm agent --epic list # 查看 Epic 列表 - * pnpm agent --epic view <#> # 查看 Epic 详情 - * pnpm agent --epic sync <#> # 同步 Epic 子任务状态 - * pnpm agent --epic add <epic#> <issue#> # 添加子任务到 Epic - * pnpm agent --stats # 进度统计 - * pnpm agent --toc # 白皮书目录 - * pnpm agent --chapter <name> # 查阅白皮书章节 + * pnpm agent # 输出索引(最佳实践 + 知识地图) + * pnpm agent roadmap [version] # 查看任务列表 + * pnpm agent claim <issue#> # 领取任务 + * pnpm agent done <issue#> # 完成任务 + * pnpm agent create <title> # 创建任务 + * pnpm agent epic create <title> # 创建 Epic + * pnpm agent epic list # 查看 Epic 列表 + * pnpm agent epic view <#> # 查看 Epic 详情 + * pnpm agent epic sync <#> # 同步 Epic 子任务状态 + * pnpm agent epic add <epic#> <issue#> # 添加子任务到 Epic + * pnpm agent stats # 进度统计 + * pnpm agent toc # 白皮书目录 + * pnpm agent chapter <name> # 查阅白皮书章节 + * pnpm agent worktree create <name> --branch <branch> [--base main] + * pnpm agent worktree delete <name> [--force] + * pnpm agent worktree list */ import { log } from './utils' @@ -35,183 +38,311 @@ import { addSubIssueToEpic, } from './epic' import { printWhiteBookToc, printChapterContent } from './whitebook' +import { createWorktree, deleteWorktree, listWorktrees } from './worktree' function printHelp(): void { console.log(` AI Agent CLI Usage: - pnpm agent # 输出索引(最佳实践 + 知识地图) - pnpm agent --roadmap [current|v1|v2|draft] # 查看任务列表 - pnpm agent --claim <issue#> # 领取任务(分配给自己) - pnpm agent --done <issue#> # 完成任务(关闭 Issue) - pnpm agent --create <title> # 创建新任务 + pnpm agent # 输出索引(最佳实践 + 知识地图) + pnpm agent roadmap [current|v1|v2|draft] # 查看任务列表 + pnpm agent claim <issue#> # 领取任务(分配给自己) + pnpm agent done <issue#> # 完成任务(关闭 Issue) + pnpm agent create <title> # 创建新任务 [--body <描述>] [--roadmap v1|v2|draft] [--category feature|bug|refactor] - pnpm agent --stats # 查看进度统计 - pnpm agent --toc # 白皮书目录结构 - pnpm agent --chapter <name> # 查阅白皮书章节 + pnpm agent stats # 查看进度统计 + pnpm agent toc # 白皮书目录结构 + pnpm agent chapter <name> # 查阅白皮书章节 Epic 管理: - pnpm agent --epic create <title> # 创建 Epic + pnpm agent epic create <title> # 创建 Epic [--desc <描述>] [--roadmap v1] [--issues 1,2,3] - pnpm agent --epic list # 查看所有 Epic - pnpm agent --epic view <#> # 查看 Epic 详情 - pnpm agent --epic sync <#> # 同步子任务状态 - pnpm agent --epic add <epic#> <issue#> # 添加子任务到 Epic + pnpm agent epic list # 查看所有 Epic + pnpm agent epic view <#> # 查看 Epic 详情 + pnpm agent epic sync <#> # 同步子任务状态 + pnpm agent epic add <epic#> <issue#> # 添加子任务到 Epic + +Worktree 管理: + pnpm agent worktree create <name> --branch <branch> [--base main] + pnpm agent worktree delete <name> [--force] + pnpm agent worktree list + 分支前缀仅允许: feat/, fix/, docs/, test/, refactor/, chore/, ci/, openspec/, release/ Aliases: CURRENT -> V1, NEXT -> V2 Examples: - pnpm agent --roadmap current - pnpm agent --claim 28 - pnpm agent --create "修复某个问题" --category bug --roadmap v1 - pnpm agent --epic create "大功能" --roadmap v1 --issues 44,45,46 + pnpm agent roadmap current + pnpm agent claim 28 + pnpm agent create "修复某个问题" --category bug --roadmap v1 + pnpm agent epic create "大功能" --roadmap v1 --issues 44,45,46 + pnpm agent worktree create issue-28 --branch feat/issue-28 `) } -function main(): void { - const args = process.argv.slice(2) +function getFlagValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag) + if (index === -1) return undefined + return args[index + 1] +} - // Help - if (args.includes('--help') || args.includes('-h')) { - printHelp() - process.exit(0) - } +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag) +} - // Roadmap - const roadmapIndex = args.indexOf('--roadmap') - if (roadmapIndex !== -1) { - const releaseFilter = args[roadmapIndex + 1] - printRoadmap(releaseFilter) - process.exit(0) - } +function runLegacy(args: string[]): boolean { + const knownFlags = new Set(['--roadmap', '--claim', '--done', '--create', '--stats', '--toc', '--chapter', '--epic']) + if (!args.some(arg => knownFlags.has(arg))) return false + + log.warn('检测到旧版参数,建议使用子命令形式(例如:pnpm agent roadmap current)') - // Claim const claimIndex = args.indexOf('--claim') if (claimIndex !== -1 && args[claimIndex + 1]) { claimIssue(args[claimIndex + 1]) - process.exit(0) + return true } - // Done const doneIndex = args.indexOf('--done') if (doneIndex !== -1 && args[doneIndex + 1]) { completeIssue(args[doneIndex + 1]) - process.exit(0) + return true } - // Stats if (args.includes('--stats')) { printStats() - process.exit(0) + return true } - // Create Issue const createIndex = args.indexOf('--create') if (createIndex !== -1 && args[createIndex + 1]) { const title = args[createIndex + 1] - const bodyIndex = args.indexOf('--body') - const roadmapIndex2 = args.indexOf('--roadmap', createIndex) - const categoryIndex = args.indexOf('--category') - + const body = getFlagValue(args, '--body') + const roadmap = getFlagValue(args, '--roadmap') + const category = getFlagValue(args, '--category') createIssue({ title, - body: bodyIndex !== -1 ? args[bodyIndex + 1] : undefined, - roadmap: roadmapIndex2 !== -1 ? args[roadmapIndex2 + 1] : 'DRAFT', - category: categoryIndex !== -1 ? args[categoryIndex + 1] : 'feature', + body, + roadmap: roadmap ?? 'DRAFT', + category: category ?? 'feature', }) - process.exit(0) + return true + } + + const roadmapIndex = args.indexOf('--roadmap') + if (roadmapIndex !== -1) { + printRoadmap(args[roadmapIndex + 1]) + return true } - // Epic commands const epicIndex = args.indexOf('--epic') if (epicIndex !== -1) { - const subCommand = args[epicIndex + 1] - - switch (subCommand) { - case 'create': { - const title = args[epicIndex + 2] - if (!title) { - log.error('请提供 Epic 标题') - process.exit(1) - } - const descIndex = args.indexOf('--desc') - const roadmapIndex3 = args.indexOf('--roadmap', epicIndex) - const issuesIndex = args.indexOf('--issues') - - const subIssues = issuesIndex !== -1 - ? args[issuesIndex + 1].split(',').map(n => parseInt(n.trim())) - : [] - - createEpic({ - title, - description: descIndex !== -1 ? args[descIndex + 1] : undefined, - roadmap: roadmapIndex3 !== -1 ? args[roadmapIndex3 + 1] : 'V1', - subIssues, - }) - break - } + handleEpic(args.slice(epicIndex + 1)) + return true + } + + if (args.includes('--toc')) { + printWhiteBookToc() + return true + } + + const chapterIndex = args.indexOf('--chapter') + if (chapterIndex !== -1 && args[chapterIndex + 1]) { + log.section(`章节: ${args[chapterIndex + 1]}`) + printChapterContent(args[chapterIndex + 1]) + return true + } - case 'list': - listEpics() - break - - case 'view': { - const epicNum = parseInt(args[epicIndex + 2]) - if (!epicNum) { - log.error('请提供 Epic 编号') - process.exit(1) - } - viewEpic(epicNum) - break + return false +} + +function handleEpic(args: string[]): void { + const subCommand = args[0] + + switch (subCommand) { + case 'create': { + const title = args[1] + if (!title) { + log.error('请提供 Epic 标题') + process.exit(1) } + const desc = getFlagValue(args, '--desc') + const roadmap = getFlagValue(args, '--roadmap') + const issuesRaw = getFlagValue(args, '--issues') + const subIssues = issuesRaw + ? issuesRaw.split(',').map(value => parseInt(value.trim(), 10)).filter(Boolean) + : [] + + createEpic({ + title, + description: desc, + roadmap: roadmap ?? 'V1', + subIssues, + }) + break + } + + case 'list': + listEpics() + break - case 'sync': { - const epicNum = parseInt(args[epicIndex + 2]) - if (!epicNum) { - log.error('请提供 Epic 编号') - process.exit(1) - } - syncEpicStatus(epicNum) - break + case 'view': { + const epicNum = parseInt(args[1] ?? '', 10) + if (!epicNum) { + log.error('请提供 Epic 编号') + process.exit(1) } + viewEpic(epicNum) + break + } - case 'add': { - const epicNum = parseInt(args[epicIndex + 2]) - const issueNum = parseInt(args[epicIndex + 3]) - if (!epicNum || !issueNum) { - log.error('请提供 Epic 编号和 Issue 编号') - process.exit(1) - } - addSubIssueToEpic(epicNum, issueNum) - break + case 'sync': { + const epicNum = parseInt(args[1] ?? '', 10) + if (!epicNum) { + log.error('请提供 Epic 编号') + process.exit(1) } + syncEpicStatus(epicNum) + break + } - default: - log.error(`未知的 Epic 子命令: ${subCommand}`) - console.log('可用命令: create, list, view, sync, add') + case 'add': { + const epicNum = parseInt(args[1] ?? '', 10) + const issueNum = parseInt(args[2] ?? '', 10) + if (!epicNum || !issueNum) { + log.error('请提供 Epic 编号和 Issue 编号') process.exit(1) + } + addSubIssueToEpic(epicNum, issueNum) + break } - process.exit(0) + + default: + log.error(`未知的 Epic 子命令: ${subCommand}`) + console.log('可用命令: create, list, view, sync, add') + process.exit(1) } +} - // TOC - if (args.includes('--toc')) { - printWhiteBookToc() - process.exit(0) +function handleWorktree(args: string[]): void { + const subCommand = args[0] + + switch (subCommand) { + case 'create': { + const name = args[1] + const branch = getFlagValue(args, '--branch') + const base = getFlagValue(args, '--base') + createWorktree({ name, branch, base }) + break + } + + case 'delete': { + const name = args[1] + const force = hasFlag(args, '--force') + deleteWorktree({ name, force }) + break + } + + case 'list': + listWorktrees() + break + + default: + log.error(`未知的 worktree 子命令: ${subCommand}`) + console.log('可用命令: create, delete, list') + process.exit(1) } +} - // Chapter - const chapterIndex = args.indexOf('--chapter') - if (chapterIndex !== -1 && args[chapterIndex + 1]) { - const chapterName = args[chapterIndex + 1] - log.section(`章节: ${chapterName}`) - printChapterContent(chapterName) - process.exit(0) +function main(): void { + const args = process.argv.slice(2) + + if (args.length === 0) { + printIndex() + return + } + + if (args.includes('--help') || args.includes('-h') || args[0] === 'help') { + printHelp() + return + } + + if (runLegacy(args)) { + return } - // Default: print index - printIndex() + const [command, ...rest] = args + + switch (command) { + case 'roadmap': + printRoadmap(rest[0]) + break + + case 'claim': + if (!rest[0]) { + log.error('请提供 Issue 编号') + process.exit(1) + } + claimIssue(rest[0]) + break + + case 'done': + if (!rest[0]) { + log.error('请提供 Issue 编号') + process.exit(1) + } + completeIssue(rest[0]) + break + + case 'create': { + const title = rest[0] + if (!title) { + log.error('请提供 Issue 标题') + process.exit(1) + } + const body = getFlagValue(rest, '--body') + const roadmap = getFlagValue(rest, '--roadmap') + const category = getFlagValue(rest, '--category') + createIssue({ + title, + body, + roadmap: roadmap ?? 'DRAFT', + category: category ?? 'feature', + }) + break + } + + case 'stats': + printStats() + break + + case 'toc': + printWhiteBookToc() + break + + case 'chapter': { + const chapterName = rest[0] + if (!chapterName) { + log.error('请提供章节路径') + process.exit(1) + } + log.section(`章节: ${chapterName}`) + printChapterContent(chapterName) + break + } + + case 'epic': + handleEpic(rest) + break + + case 'worktree': + handleWorktree(rest) + break + + default: + log.error(`未知命令: ${command}`) + printHelp() + process.exit(1) + } } main() diff --git a/scripts/agent/readme.ts b/scripts/agent/readme.ts index 0f6ea22c..8f593d9c 100644 --- a/scripts/agent/readme.ts +++ b/scripts/agent/readme.ts @@ -17,7 +17,7 @@ export function printBestPractices(): void { ❌ 安装新 UI 库 → ✅ shadcn/ui(已集成) ❌ 新建 CSS → ✅ Tailwind CSS -详见: pnpm agent --chapter 00-必读 +详见: pnpm agent chapter 00-必读 `) } @@ -33,7 +33,7 @@ export function printKnowledgeMap(): void { src/components/ → UI 组件 src/i18n/ → 国际化 -白皮书 (pnpm agent --chapter <路径>): +白皮书 (pnpm agent chapter <路径>): 03-架构篇/03-导航系统/ → 页面跳转、Tab 04-服务篇/01-服务架构/ → 服务分层、Adapter 05-组件篇/01-基础组件/ → Sheet、Dialog @@ -88,12 +88,15 @@ export function printWorkflow(): void { console.log(` # 工作流 -pnpm agent --claim <#> 领取任务 (自动分配+worktree指引) -pnpm agent --create "x" 创建任务 (--category bug --roadmap v1) -pnpm agent --epic create "x" 创建 Epic (--roadmap v1) -pnpm agent --epic list 查看所有 Epic -pnpm agent --chapter <x> 查阅白皮书 -pnpm agent --stats 进度统计 +pnpm agent claim <#> 领取任务 (自动分配+worktree指引) +pnpm agent create "x" 创建任务 (--category bug --roadmap v1) +pnpm agent epic create "x" 创建 Epic (--roadmap v1) +pnpm agent epic list 查看所有 Epic +pnpm agent chapter <x> 查阅白皮书 +pnpm agent stats 进度统计 +pnpm agent worktree create <name> --branch <branch> [--base main] +pnpm agent worktree list worktree 概览 +pnpm agent worktree delete <name> [--force] PR 规则: 描述中使用 Closes #issue编号 自动关联 `) diff --git a/scripts/agent/roadmap.ts b/scripts/agent/roadmap.ts index 4bf74807..0a129f64 100644 --- a/scripts/agent/roadmap.ts +++ b/scripts/agent/roadmap.ts @@ -122,10 +122,10 @@ export function claimIssue(issueNumber: string): void { ) log.success(`已领取 Issue #${issueNumber}`) console.log(`\n下一步:`) - console.log(` 1. git worktree add .git-worktree/issue-${issueNumber} -b feat/issue-${issueNumber}`) - console.log(` 2. cd .git-worktree/issue-${issueNumber} && pnpm install`) + console.log(` 1. pnpm agent worktree create issue-${issueNumber} --branch feat/issue-${issueNumber}`) + console.log(` 2. cd .git-worktree/issue-${issueNumber}`) console.log(` 3. 开始开发...`) - console.log(` 4. PR 描述中使用 "Closes #${issueNumber}" 自动关联`) + console.log(` 4. PR 描述中使用 \"Closes #${issueNumber}\" 自动关联`) } catch (e) { log.error(`领取失败: ${e}`) } @@ -183,7 +183,7 @@ export function createIssue(options: CreateIssueOptions): string | null { } console.log(`\n下一步:`) - console.log(` pnpm agent --claim ${issueNumber} # 领取任务`) + console.log(` pnpm agent claim ${issueNumber} # 领取任务`) return issueNumber } catch (e) { diff --git a/scripts/agent/utils.ts b/scripts/agent/utils.ts index 2dbf1489..4a42a006 100644 --- a/scripts/agent/utils.ts +++ b/scripts/agent/utils.ts @@ -2,10 +2,18 @@ * Agent 工具共享模块 */ -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' export const ROOT = resolve(import.meta.dirname, '../..') export const WHITE_BOOK_DIR = resolve(ROOT, 'docs/white-book') +export const WORKTREE_DIR = (() => { + const marker = `${sep}.git-worktree${sep}` + if (ROOT.includes(marker)) { + const base = ROOT.split(marker)[0] + return resolve(base, '.git-worktree') + } + return resolve(ROOT, '.git-worktree') +})() export const PROJECT_NUMBER = 5 export const PROJECT_OWNER = 'BioforestChain' diff --git a/scripts/agent/worktree.ts b/scripts/agent/worktree.ts new file mode 100644 index 00000000..74f82d74 --- /dev/null +++ b/scripts/agent/worktree.ts @@ -0,0 +1,450 @@ +/** + * Worktree 管理 + */ + +import { execFileSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { basename, join, relative, resolve, sep } from 'node:path' +import { ROOT, WORKTREE_DIR, log } from './utils' + +const ALLOWED_BRANCH_PREFIXES = [ + 'feat/', + 'fix/', + 'docs/', + 'test/', + 'refactor/', + 'chore/', + 'ci/', + 'openspec/', + 'release/', +] as const + +interface WorktreeEntry { + path: string + head: string | null + branch: string | null +} + +interface PrInfo { + number: number + state: string + mergedAt: string | null + url: string +} + +interface CheckSummary { + total: number + success: number + failure: number + pending: number + neutral: number + skipped: number + unknown: number +} + +interface CheckDetails { + summary: CheckSummary + failures: string[] + pending: string[] + neutral: string[] + skipped: string[] + unknown: string[] + success: string[] +} + +function runCommand(command: string, args: string[], options?: { cwd?: string; stdio?: 'pipe' | 'inherit' }): string { + return execFileSync(command, args, { + encoding: 'utf-8', + stdio: options?.stdio ?? 'pipe', + cwd: options?.cwd ?? ROOT, + }) +} + +function parseWorktrees(): WorktreeEntry[] { + const output = runCommand('git', ['worktree', 'list', '--porcelain']) + const lines = output.trim().split('\n') + const entries: WorktreeEntry[] = [] + let current: WorktreeEntry | null = null + + for (const line of lines) { + if (line.startsWith('worktree ')) { + if (current) entries.push(current) + current = { + path: line.slice('worktree '.length), + head: null, + branch: null, + } + continue + } + if (!current) continue + + if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length) + continue + } + if (line.startsWith('branch ')) { + const ref = line.slice('branch '.length) + current.branch = ref.replace('refs/heads/', '') + continue + } + if (line === 'detached') { + current.branch = null + } + } + + if (current) entries.push(current) + return entries +} + +function getWorktreeEntries(): WorktreeEntry[] { + const root = resolve(WORKTREE_DIR) + const prefix = root.endsWith(sep) ? root : root + sep + return parseWorktrees().filter(entry => resolve(entry.path).startsWith(prefix)) +} + +function resolveWorktreePath(input: string): string { + const directPath = resolve(ROOT, input) + if (existsSync(directPath)) { + return directPath + } + return join(WORKTREE_DIR, input) +} + +function ensureWorktreeDir(): void { + if (!existsSync(WORKTREE_DIR)) { + mkdirSync(WORKTREE_DIR, { recursive: true }) + } +} + +function ensureFileExists(filePath: string, label: string): void { + if (!existsSync(filePath)) { + log.error(`${label} 不存在: ${filePath}`) + process.exit(1) + } + const stat = statSync(filePath) + if (!stat.isFile()) { + log.error(`${label} 不是文件: ${filePath}`) + process.exit(1) + } +} + +function ensureBranchMissing(branch: string): void { + try { + runCommand('git', ['show-ref', '--verify', `refs/heads/${branch}`]) + log.error(`分支已存在: ${branch}`) + process.exit(1) + } catch { + // branch missing is ok + } +} + +function ensureBranchPrefix(branch: string): void { + if (/\s/.test(branch)) { + log.error(`分支名不能包含空白字符: ${branch}`) + process.exit(1) + } + const allowed = ALLOWED_BRANCH_PREFIXES.some(prefix => branch.startsWith(prefix)) + if (!allowed) { + log.error(`分支名必须使用允许的前缀: ${ALLOWED_BRANCH_PREFIXES.join(', ')}`) + log.info(`当前分支: ${branch}`) + process.exit(1) + } +} + +function ensureGitRefExists(ref: string): void { + try { + runCommand('git', ['rev-parse', '--verify', ref]) + } catch { + log.error(`找不到 base 引用: ${ref}`) + process.exit(1) + } +} + +function loadPrInfo(branch: string): PrInfo | null { + try { + const output = runCommand('gh', [ + 'pr', + 'list', + '--head', + branch, + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,state,mergedAt,url', + ]) + const parsed = JSON.parse(output) as PrInfo[] + return parsed[0] ?? null + } catch (error) { + log.error(`无法查询 PR 信息: ${String(error)}`) + process.exit(1) + } +} + +function resolveCheckState(check: unknown): string { + if (typeof check !== 'object' || check === null) return 'UNKNOWN' + const record = check as Record<string, unknown> + const raw = record.state ?? record.conclusion ?? record.status + return typeof raw === 'string' ? raw.toUpperCase() : 'UNKNOWN' +} + +function resolveCheckName(check: unknown, index: number): string { + if (typeof check !== 'object' || check === null) return `check-${index + 1}` + const record = check as Record<string, unknown> + const name = typeof record.name === 'string' ? record.name.trim() : '' + if (name) return name + const context = typeof record.context === 'string' ? record.context.trim() : '' + if (context) return context + return `check-${index + 1}` +} + +function summarizeChecks(prNumber: number): CheckDetails { + try { + const output = runCommand('gh', ['pr', 'view', String(prNumber), '--json', 'statusCheckRollup']) + const parsed = JSON.parse(output) as { statusCheckRollup?: Array<unknown> } + const summary: CheckSummary = { + total: 0, + success: 0, + failure: 0, + pending: 0, + neutral: 0, + skipped: 0, + unknown: 0, + } + const details: CheckDetails = { + summary, + failures: [], + pending: [], + neutral: [], + skipped: [], + unknown: [], + success: [], + } + + const rollups = parsed.statusCheckRollup ?? [] + rollups.forEach((check, index) => { + summary.total += 1 + const state = resolveCheckState(check) + const name = resolveCheckName(check, index) + if (state === 'SUCCESS') { + summary.success += 1 + details.success.push(name) + } else if (state === 'FAILURE' || state === 'ERROR') { + summary.failure += 1 + details.failures.push(name) + } else if (state === 'PENDING' || state === 'EXPECTED' || state === 'IN_PROGRESS' || state === 'QUEUED') { + summary.pending += 1 + details.pending.push(name) + } else if (state === 'NEUTRAL' || state === 'ACTION_REQUIRED' || state === 'STALE') { + summary.neutral += 1 + details.neutral.push(name) + } else if (state === 'SKIPPED') { + summary.skipped += 1 + details.skipped.push(name) + } else { + summary.unknown += 1 + details.unknown.push(name) + } + }) + + return details + } catch (error) { + log.error(`无法查询 CI/CD 状态: ${String(error)}`) + process.exit(1) + } +} + +function formatCheckSummary(summary: CheckSummary): string { + if (summary.total === 0) return 'none' + const parts = [] + if (summary.success) parts.push(`success:${summary.success}`) + if (summary.failure) parts.push(`failure:${summary.failure}`) + if (summary.pending) parts.push(`pending:${summary.pending}`) + if (summary.neutral) parts.push(`neutral:${summary.neutral}`) + if (summary.skipped) parts.push(`skipped:${summary.skipped}`) + if (summary.unknown) parts.push(`unknown:${summary.unknown}`) + let overall = 'mixed' + if (summary.failure > 0) overall = 'failure' + else if (summary.pending > 0) overall = 'pending' + else if (summary.success > 0 && summary.success === summary.total) overall = 'success' + return `${overall} (${parts.join(', ')})` +} + +function formatCheckNames(names: string[], limit = 3): string { + if (names.length <= limit) return names.join(', ') + return `${names.slice(0, limit).join(', ')} +${names.length - limit}` +} + +function formatCheckDetails(details: CheckDetails): string { + if (details.summary.total === 0) return 'none' + const summaryText = formatCheckSummary(details.summary) + const extra: string[] = [] + if (details.failures.length > 0) extra.push(`fail:${formatCheckNames(details.failures)}`) + if (details.pending.length > 0) extra.push(`pending:${formatCheckNames(details.pending)}`) + if (details.unknown.length > 0) extra.push(`unknown:${formatCheckNames(details.unknown)}`) + if (extra.length === 0) return summaryText + return `${summaryText} [${extra.join(' | ')}]` +} + +function formatPrStatus(info: PrInfo): string { + const merged = info.mergedAt ? 'MERGED' : info.state + return merged.toUpperCase() +} + +export function createWorktree(options: { name?: string; branch?: string; base?: string }): void { + const name = options.name?.trim() + const branch = options.branch?.trim() + const base = options.base?.trim() || 'main' + + if (!name) { + log.error('请提供 worktree 名称') + process.exit(1) + } + if (!branch) { + log.error('请提供 --branch') + process.exit(1) + } + + const envSource = join(ROOT, '.env.local') + ensureFileExists(envSource, '.env.local') + ensureBranchPrefix(branch) + ensureGitRefExists(base) + ensureBranchMissing(branch) + + ensureWorktreeDir() + const worktreePath = join(WORKTREE_DIR, name) + if (existsSync(worktreePath)) { + log.error(`worktree 已存在: ${worktreePath}`) + process.exit(1) + } + + log.section('创建 worktree') + runCommand('git', ['worktree', 'add', worktreePath, '-b', branch, base]) + + log.section('复制 .env.local') + const envTarget = join(worktreePath, '.env.local') + copyFileSync(envSource, envTarget) + + log.section('安装依赖') + runCommand('pnpm', ['install'], { cwd: worktreePath, stdio: 'inherit' }) + + log.success('worktree 创建完成') + + const relPath = relative(WORKTREE_DIR, worktreePath) + const displayPath = join('.git-worktree', relPath) + const relEnvSource = relative(ROOT, envSource) + const relEnvTarget = relative(ROOT, envTarget) + + console.log(`\n# worktree 信息\n`) + console.log(`- name: ${name}`) + console.log(`- path: ${displayPath}`) + console.log(`- abs: ${worktreePath}`) + console.log(`- branch: ${branch}`) + console.log(`- base: ${base}`) + console.log(`- env: ${relEnvSource} -> ${relEnvTarget}`) + console.log(`- install: pnpm install (ok)\n`) + + console.log(`# 下一步`) + console.log(` cd ${worktreePath}`) + console.log(` pnpm dev\n`) +} + +export function deleteWorktree(options: { name?: string; force?: boolean }): void { + const name = options.name?.trim() + if (!name) { + log.error('请提供 worktree 名称') + process.exit(1) + } + + const worktreePath = resolveWorktreePath(name) + if (!existsSync(worktreePath) || !statSync(worktreePath).isDirectory()) { + log.error(`worktree 不存在: ${worktreePath}`) + process.exit(1) + } + + const entries = getWorktreeEntries() + const entry = entries.find(item => resolve(item.path) === resolve(worktreePath)) + if (!entry) { + log.error(`无法识别 worktree 信息: ${worktreePath}`) + process.exit(1) + } + + if (entry.branch && !options.force) { + const prInfo = loadPrInfo(entry.branch) + if (prInfo && !prInfo.mergedAt) { + log.error(`PR 未合并,禁止删除: #${prInfo.number} ${formatPrStatus(prInfo)}`) + log.info(prInfo.url) + process.exit(1) + } + } + + const removeArgs = ['worktree', 'remove', worktreePath] + if (options.force) removeArgs.push('--force') + runCommand('git', removeArgs) + log.success(`已删除 worktree: ${relative(ROOT, worktreePath)}`) +} + +export function listWorktrees(): void { + const entries = getWorktreeEntries() + if (entries.length === 0) { + console.log('未发现 worktree') + return + } + + const rows = entries.map(entry => { + const name = basename(entry.path) + const relPath = relative(WORKTREE_DIR, entry.path) + const branch = entry.branch ?? '(detached)' + + let prText = 'none' + let checksText = 'n/a' + + if (entry.branch) { + const prInfo = loadPrInfo(entry.branch) + if (prInfo) { + prText = `#${prInfo.number} ${formatPrStatus(prInfo)}` + checksText = formatCheckDetails(summarizeChecks(prInfo.number)) + } + } + + return { + name, + path: `.git-worktree/${relPath}`, + branch, + pr: prText, + checks: checksText, + } + }) + + const headers = { + name: 'NAME', + path: 'PATH', + branch: 'BRANCH', + pr: 'PR', + checks: 'CHECKS', + } + + const widths = { + name: Math.max(headers.name.length, ...rows.map(row => row.name.length)), + path: Math.max(headers.path.length, ...rows.map(row => row.path.length)), + branch: Math.max(headers.branch.length, ...rows.map(row => row.branch.length)), + pr: Math.max(headers.pr.length, ...rows.map(row => row.pr.length)), + checks: Math.max(headers.checks.length, ...rows.map(row => row.checks.length)), + } + + const pad = (value: string, width: number) => value.padEnd(width) + console.log('# Worktrees\n') + console.log( + `${pad(headers.name, widths.name)} ${pad(headers.path, widths.path)} ${pad(headers.branch, widths.branch)} ${pad(headers.pr, widths.pr)} ${pad(headers.checks, widths.checks)}` + ) + console.log( + `${'-'.repeat(widths.name)} ${'-'.repeat(widths.path)} ${'-'.repeat(widths.branch)} ${'-'.repeat(widths.pr)} ${'-'.repeat(widths.checks)}` + ) + for (const row of rows) { + console.log( + `${pad(row.name, widths.name)} ${pad(row.path, widths.path)} ${pad(row.branch, widths.branch)} ${pad(row.pr, widths.pr)} ${pad(row.checks, widths.checks)}` + ) + } + console.log() +} From a6509dd91ca707b74e6d7c4d4295a08b0cff46d1 Mon Sep 17 00:00:00 2001 From: Gaubee <GaubeeBangeel@Gmail.com> Date: Thu, 25 Dec 2025 00:07:44 +0800 Subject: [PATCH 2/2] docs(agent): add closed-loop workflow --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2ec35438..a2ed6fc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,44 @@ pnpm agent chapter 00-必读 # 查看必读章节 详细的项目信息、技术栈、开发流程请查阅白皮书。 +--- + +## 开发闭环(必须遵循) + +```bash +# 1) 获取索引 + Roadmap +pnpm agent +pnpm agent roadmap current + +# 2) 领取任务(Issue) +pnpm agent claim <issue#> + +# 3) 创建 worktree(严格校验分支前缀与 .env.local) +pnpm agent worktree create issue-<id> --branch feat/issue-<id> --base main +cd .git-worktree/issue-<id> + +# 4) 阅读白皮书(先文档后编码) +pnpm agent toc +pnpm agent chapter <路径> + +# 5) 开发 + 测试 +pnpm dev +pnpm test +pnpm typecheck + +# 6) 提交 + 推送 + PR +git add -A +git commit -m "feat/fix: 描述" +git push -u origin <branch> +gh pr create --title "标题" --body "Closes #<issue#>" --base main + +# 7) 合并 + 清理 +gh pr merge --squash --delete-branch +pnpm agent worktree delete issue-<id> +``` + +> 白皮书是唯一权威来源:若用户补充/纠正知识,必须先更新 `docs/white-book/` 再继续开发。 + <!-- OPENSPEC:START --> # OpenSpec Instructions