Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fee0f2630 | ||
|
|
028195ab2b |
No files matched your search
@@ -21,9 +21,9 @@ data class Issue(
|
||||
val profilePath: String? = null,
|
||||
/** 测试会话专用的 Claude 配置文件,独立于实施会话的 profilePath;留空回退到 profilePath 再回退默认。 */
|
||||
val testProfilePath: String? = null,
|
||||
/** spec 文档路径,相对 workspace,位于 docs/superpowers/specs 下的 .md 文件。 */
|
||||
/** spec 文档路径,相对 workspace,即 ECC 的 PRD(.claude/prds/<slug>.prd.md)。 */
|
||||
val specFile: String? = null,
|
||||
/** plan 文档路径,相对 workspace,位于 docs/superpowers/plans 下的 .md 文件。 */
|
||||
/** plan 文档路径,相对 workspace,即 ECC 的实施计划(.claude/plans/<slug>.plan.md)。 */
|
||||
val planFile: String? = null,
|
||||
/** PR 变更摘要文件路径,相对 workspace。 */
|
||||
val prDiffFile: String? = null,
|
||||
|
||||
@@ -22,15 +22,16 @@ object DefaultPrompts {
|
||||
}
|
||||
|
||||
private const val FALLBACK_BRAINSTORM =
|
||||
"/goal 我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。" +
|
||||
"我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。" +
|
||||
"工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。"
|
||||
|
||||
private const val FALLBACK_BRAINSTORM_CONTINUE =
|
||||
"/superpowers:brainstorming 讨论下 {issueNumber} 号工单。注意:不要 git checkout / 不要写代码 / 不要建 PR," +
|
||||
"只讨论需求与 spec/plan,用 spx issue marker 更新 marker。"
|
||||
"/ecc:plan-prd 讨论下 {issueNumber} 号工单。PRD 写到 .claude/prds/<slug>.prd.md," +
|
||||
"我确认后用 ecc:plan 出 .claude/plans/<slug>.plan.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。" +
|
||||
"不要 git checkout / 不要写代码 / 不要建 PR。"
|
||||
|
||||
private const val FALLBACK_IMPLEMENT_PLAN =
|
||||
"/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时在 body 中包含 \"Closes #{issueNumber}\"。严禁合并 PR。"
|
||||
"/ecc:tdd-workflow @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 \"Closes #{issueNumber}\"。严禁合并 PR。"
|
||||
|
||||
private const val FALLBACK_REVIEW =
|
||||
"/review 用 tea 拿到 #{prNumber} PR 审查。审查意见用 spx pr review-comment 发评论," +
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
|
||||
/ecc:plan-prd 讨论下 {issueNumber} 号工单
|
||||
|
||||
用tea命令找工单
|
||||
用 tea 命令找工单,用 grill-me skill 讨论细节,用表单形式和我交互,每次只问一个问题(`/ecc:plan-prd` 里成组的问题也拆成一次一问)。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 产物与顺序
|
||||
|
||||
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
|
||||
1. 需求讨论清楚后,按 `/ecc:plan-prd` 的模板写 PRD 到 `.claude/prds/<slug>.prd.md`,让我确认。
|
||||
2. PRD 我确认后,用 Skill 工具调 `ecc:plan`,参数是 PRD 路径,产出 `.claude/plans/<slug>.plan.md`;计划出来后停下等我确认。
|
||||
3. `<slug>` 取工单标题的英文短横线名,PRD 与计划共用;工单已有 marker 就沿用其中的 slug。
|
||||
|
||||
## marker 维护(本会话有效)
|
||||
|
||||
PRD / 计划文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
如果你创建/更新了 PRD 或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 不要修改任何代码文件
|
||||
- 除 PRD / 计划文件外不要修改任何文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
|
||||
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
|
||||
@@ -25,13 +25,20 @@ opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
|
||||
|
||||
spx 返回工单号 + html_url。记下工单号,后续命令用。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 后续需求文档 / 计划维护(本会话有效)
|
||||
|
||||
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
|
||||
需求与计划走 ECC 工作流:`/ecc:plan-prd` 产出需求文档(PRD),`/ecc:plan <PRD 路径>` 产出实施计划。文件路径固定:
|
||||
|
||||
- PRD:`.claude/prds/<slug>.prd.md`
|
||||
- 计划:`.claude/plans/<slug>.plan.md`
|
||||
|
||||
`<slug>` 取工单标题的英文短横线名(如 `app-entity`),PRD 与计划用同一个 slug。两个文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`);实施 worktree 从 main 当前提交切出,没提交的文件在那边看不到。
|
||||
|
||||
**只有**当你真正创建了 PRD 或计划文件后才追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
|
||||
@@ -44,5 +51,4 @@ spx 自动找到对应行替换或追加,保留所有其他 marker。**不要
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 除 PRD / 计划文件外不要改动或提交任何文件
|
||||
@@ -1,4 +1,6 @@
|
||||
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
/ecc:tdd-workflow @{planFile}
|
||||
|
||||
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先 `/ecc:code-review` 自审并修掉严重问题。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
|
||||
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
|
||||
|
||||
@@ -10,4 +12,4 @@
|
||||
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
|
||||
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
|
||||
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
|
||||
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
|
||||
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
|
||||
@@ -1,28 +1,30 @@
|
||||
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
|
||||
/ecc:plan-prd 讨论下 {issueNumber} 号工单
|
||||
|
||||
用tea命令找工单
|
||||
用 tea 命令找工单,用 grill-me skill 讨论细节,用表单形式和我交互,每次只问一个问题(`/ecc:plan-prd` 里成组的问题也拆成一次一问)。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 产物与顺序
|
||||
|
||||
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
|
||||
1. 需求讨论清楚后,按 `/ecc:plan-prd` 的模板写 PRD 到 `.claude/prds/<slug>.prd.md`,让我确认。
|
||||
2. PRD 我确认后,用 Skill 工具调 `ecc:plan`,参数是 PRD 路径,产出 `.claude/plans/<slug>.plan.md`;计划出来后停下等我确认。
|
||||
3. `<slug>` 取工单标题的英文短横线名,PRD 与计划共用;工单已有 marker 就沿用其中的 slug。
|
||||
|
||||
## marker 维护(本会话有效)
|
||||
|
||||
PRD / 计划文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
如果你创建/更新了 PRD 或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 不要修改任何代码文件
|
||||
- 除 PRD / 计划文件外不要修改任何文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
|
||||
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
|
||||
@@ -25,13 +25,20 @@ opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
|
||||
|
||||
spx 返回工单号 + html_url。记下工单号,后续命令用。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 后续需求文档 / 计划维护(本会话有效)
|
||||
|
||||
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
|
||||
需求与计划走 ECC 工作流:`/ecc:plan-prd` 产出需求文档(PRD),`/ecc:plan <PRD 路径>` 产出实施计划。文件路径固定:
|
||||
|
||||
- PRD:`.claude/prds/<slug>.prd.md`
|
||||
- 计划:`.claude/plans/<slug>.plan.md`
|
||||
|
||||
`<slug>` 取工单标题的英文短横线名(如 `app-entity`),PRD 与计划用同一个 slug。两个文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`);实施 worktree 从 main 当前提交切出,没提交的文件在那边看不到。
|
||||
|
||||
**只有**当你真正创建了 PRD 或计划文件后才追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
|
||||
@@ -44,5 +51,4 @@ spx 自动找到对应行替换或追加,保留所有其他 marker。**不要
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 除 PRD / 计划文件外不要改动或提交任何文件
|
||||
@@ -1,4 +1,6 @@
|
||||
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
/ecc:tdd-workflow @{planFile}
|
||||
|
||||
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先 `/ecc:code-review` 自审并修掉严重问题。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
|
||||
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
|
||||
|
||||
@@ -10,4 +12,4 @@
|
||||
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
|
||||
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
|
||||
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
|
||||
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
|
||||
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
|
||||
+6
-5
@@ -17,8 +17,8 @@
|
||||
+ feature 分支 测试会话可随时开
|
||||
```
|
||||
|
||||
1. **新建工单**:待办列头的 `+` → 写需求(可粘贴截图)→ 开一个 `claude` 头脑风暴会话,它用 `spx issue create` 建工单并回填 spec / plan 路径。
|
||||
2. **实施**:把卡拖到「进行中」(或点计划文件行的 ▶)。插件创建 `feature/<slug>` 分支 + git worktree,在里面开 `issue-N-实施` 终端并发实施 prompt;实施方在 PR body 里写 `Closes #N`。
|
||||
1. **新建工单**:待办列头的 `+` → 写需求(可粘贴截图)→ 开一个 `claude` 头脑风暴会话,它用 `spx issue create` 建工单,再经 `/ecc:plan-prd` + `/ecc:plan` 写出 PRD / 计划并回填两者路径。
|
||||
2. **实施**:把卡拖到「进行中」(或点计划文件行的 ▶)。插件创建 `feature/<slug>` 分支 + git worktree,在里面开 `issue-N-实施` 终端并发实施 prompt,实施方对计划跑 `/ecc:tdd-workflow`,在 PR body 里写 `Closes #N`。
|
||||
3. **审查**:PR opened / synchronize 的 webhook 到达后,插件自动开 `issue-N-审查`(codex);审查意见通过 `spx pr review-comment` 发到 PR,webhook 再把意见注入实施终端形成闭环。
|
||||
4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。
|
||||
5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。
|
||||
@@ -41,6 +41,7 @@
|
||||
|---|---|
|
||||
| `git` | worktree、分支、fetch / merge |
|
||||
| `claude` | 规划、实施、测试、冲突解决、提交、PR 摘要(找不到时会提示常见位置 `~/.local/bin`) |
|
||||
| ECC 插件(`ecc@ecc`) | Claude Code 插件,头脑风暴 / 实施会话用它的 `/ecc:plan-prd` `/ecc:plan` `/ecc:tdd-workflow` `/ecc:code-review` |
|
||||
| `codex` | 审查会话(开启自动审查时必需) |
|
||||
| `tea` | `spx` 从 `~/.config/tea/config.yml` 读取 Gitea host + token;审查 prompt 也用 `tea pulls` 看 PR |
|
||||
| `spx` | 本仓库 `cli/` 里的 Go CLI,见下文 |
|
||||
@@ -171,8 +172,8 @@ prompts 在 `prompts/*.md`,随 VSIX 一起安装,改完即生效,也可在
|
||||
## 工作区约定
|
||||
|
||||
```
|
||||
docs/superpowers/specs/<slug>/spec.md
|
||||
docs/superpowers/plans/<slug>/plan.md
|
||||
.claude/prds/<slug>.prd.md # PRD,即工单的 spec marker
|
||||
.claude/plans/<slug>.plan.md # 实施计划,即 plan marker
|
||||
docs/pr-diff/pr-<pr>-issue-<n>.md # PR 变更摘要
|
||||
.spx/profiles.json # Profile tab
|
||||
.spx/session-names.json # 会话 tab
|
||||
@@ -183,7 +184,7 @@ docs/pr-diff/pr-<pr>-issue-<n>.md # PR 变更摘要
|
||||
.spx/*.sh # 钩子脚本
|
||||
```
|
||||
|
||||
实施分支名 `feature/<slug>`,slug 取 plan 路径里 `plans/` 后的一段。
|
||||
实施分支名 `feature/<slug>`,slug 取 plan 路径里 `plans/` 后的一段(去掉 `.plan.md`)。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.121",
|
||||
"version": "0.2.123",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
|
||||
/ecc:plan-prd 讨论下 {issueNumber} 号工单
|
||||
|
||||
用tea命令找工单,用grill-me skill讨论细节,用表单形式和我交互,每次只问一个问题
|
||||
用 tea 命令找工单,用 grill-me skill 讨论细节,用表单形式和我交互,每次只问一个问题(`/ecc:plan-prd` 里成组的问题也拆成一次一问)。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 产物与顺序
|
||||
|
||||
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
|
||||
1. 需求讨论清楚后,按 `/ecc:plan-prd` 的模板写 PRD 到 `.claude/prds/<slug>.prd.md`,让我确认。
|
||||
2. PRD 我确认后,用 Skill 工具调 `ecc:plan`,参数是 PRD 路径,产出 `.claude/plans/<slug>.plan.md`;计划出来后停下等我确认。
|
||||
3. `<slug>` 取工单标题的英文短横线名,PRD 与计划共用;工单已有 marker 就沿用其中的 slug。
|
||||
|
||||
## marker 维护(本会话有效)
|
||||
|
||||
PRD / 计划文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
如果你创建/更新了 PRD 或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 不要修改任何代码文件
|
||||
- 除 PRD / 计划文件外不要修改任何文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
|
||||
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
|
||||
@@ -25,13 +25,20 @@ opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
|
||||
|
||||
spx 返回工单号 + html_url。记下工单号,后续命令用。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
## 后续需求文档 / 计划维护(本会话有效)
|
||||
|
||||
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
|
||||
需求与计划走 ECC 工作流:`/ecc:plan-prd` 产出需求文档(PRD),`/ecc:plan <PRD 路径>` 产出实施计划。文件路径固定:
|
||||
|
||||
- PRD:`.claude/prds/<slug>.prd.md`
|
||||
- 计划:`.claude/plans/<slug>.plan.md`
|
||||
|
||||
`<slug>` 取工单标题的英文短横线名(如 `app-entity`),PRD 与计划用同一个 slug。两个文件**一律在当前主 worktree(main 分支)创建并提交**,每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 需求文档` / `📝 docs: #<工单号> 实施计划`);实施 worktree 从 main 当前提交切出,没提交的文件在那边看不到。
|
||||
|
||||
**只有**当你真正创建了 PRD 或计划文件后才追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/prds/<slug>.prd.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<slug>.plan.md
|
||||
```
|
||||
|
||||
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
|
||||
@@ -44,5 +51,4 @@ spx 自动找到对应行替换或追加,保留所有其他 marker。**不要
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 除 PRD / 计划文件外不要改动或提交任何文件
|
||||
@@ -1,4 +1,6 @@
|
||||
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
/ecc:tdd-workflow @{planFile}
|
||||
|
||||
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先 `/ecc:code-review` 自审并修掉严重问题。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
|
||||
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
|
||||
|
||||
|
||||
+144
-1
@@ -2,6 +2,7 @@
|
||||
* 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。
|
||||
* 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。
|
||||
*/
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { access, mkdir, realpath } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
@@ -28,6 +29,20 @@ export interface UnreleasedCommit {
|
||||
/** Gitea 查到的所属 PR;没有就不分组。 */
|
||||
pr?: number
|
||||
prTitle?: string
|
||||
/** 带 alembic 迁移文件时才有;blocked 有值就不能摘。 */
|
||||
migration?: MigrationVerdict
|
||||
}
|
||||
|
||||
/** 提交带的 alembic 迁移文件,以及能否跟着摘到发布分支。 */
|
||||
export interface MigrationVerdict {
|
||||
files: string[]
|
||||
/** 不能摘的原因;没有就可以摘 */
|
||||
blocked?: string
|
||||
}
|
||||
|
||||
export interface AlembicRevision {
|
||||
revision: string
|
||||
downRevisions: string[]
|
||||
}
|
||||
|
||||
export interface ReleaseCommitGroup {
|
||||
@@ -310,10 +325,138 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string,
|
||||
for (const key of keys)
|
||||
released.add(key)
|
||||
}
|
||||
return commits.filter((c) => {
|
||||
const kept = commits.filter((c) => {
|
||||
const keys = perCommit.get(c.sha) ?? []
|
||||
return keys.length === 0 || keys.some(k => !released.has(k))
|
||||
})
|
||||
const filesOf = new Map(kept.map(c => [c.sha, (perCommit.get(c.sha) ?? []).map(k => k.slice(0, k.indexOf('\0')))]))
|
||||
return attachMigrationVerdicts(o.cwd, o.current, kept, filesOf)
|
||||
}
|
||||
|
||||
export function parseAlembicRevision(text: string): AlembicRevision | undefined {
|
||||
const rev = /^revision[^=\n]*=\s*(['"])([^'"]+)\1/m.exec(text)
|
||||
if (!rev)
|
||||
return undefined
|
||||
const down = /^down_revision[^=\n]*=(.*)$/m.exec(text)
|
||||
const downRevisions = down ? Array.from(down[1].matchAll(/['"]([^'"]+)['"]/g), m => m[1]) : []
|
||||
return { revision: rev[2], downRevisions }
|
||||
}
|
||||
|
||||
function isMigrationPath(file: string): boolean {
|
||||
return /(?:^|\/)versions\/[^/]+\.py$/.test(file) && !file.endsWith('__init__.py')
|
||||
}
|
||||
|
||||
/** 一次 cat-file --batch 读多个 `<ref>:<path>`;对象不存在时值为 undefined。按字节切内容,避免多字节字符错位。 */
|
||||
async function catFileBatch(cwd: string, specs: string[]): Promise<Map<string, string | undefined>> {
|
||||
const out = new Map<string, string | undefined>()
|
||||
if (specs.length === 0)
|
||||
return out
|
||||
const buf = await new Promise<Buffer>((resolve, reject) => {
|
||||
const child = execFile('git', ['-C', cwd, 'cat-file', '--batch'], { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (err)
|
||||
reject(new ReleaseError('读取迁移文件失败', String(err)))
|
||||
else
|
||||
resolve(stdout)
|
||||
})
|
||||
child.stdin?.end(`${specs.join('\n')}\n`)
|
||||
})
|
||||
let pos = 0
|
||||
for (const spec of specs) {
|
||||
const eol = buf.indexOf(0x0A, pos)
|
||||
const header = buf.toString('utf8', pos, eol).split(' ')
|
||||
pos = eol + 1
|
||||
if (header[1] === 'missing') {
|
||||
out.set(spec, undefined)
|
||||
continue
|
||||
}
|
||||
const size = Number(header[2])
|
||||
out.set(spec, buf.toString('utf8', pos, pos + size))
|
||||
pos += size + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface ReleaseMigrations {
|
||||
byPath: Map<string, AlembicRevision>
|
||||
revisions: Set<string>
|
||||
children: Map<string, Set<string>>
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
/** 按 writing-alembic-migrations 的规矩:迁移只能接在发布分支已有的链尾上,前任缺失或已有别的后继都不能摘。 */
|
||||
function judgeMigration(branch: string, own: Map<string, AlembicRevision | undefined>, rel: ReleaseMigrations): string | undefined {
|
||||
const added = new Set(Array.from(own.values()).flatMap(r => r ? [r.revision] : []))
|
||||
for (const [file, r] of own) {
|
||||
if (!r) {
|
||||
const gone = rel.byPath.get(file)!
|
||||
if ((rel.children.get(gone.revision)?.size ?? 0) > 0)
|
||||
return `删掉 ${gone.revision} 后 ${branch} 上断链`
|
||||
continue
|
||||
}
|
||||
if (r.downRevisions.length === 0 && !rel.revisions.has(r.revision) && rel.dirs.has(path.posix.dirname(file)))
|
||||
return `根迁移接不上 ${branch} 已有的迁移链`
|
||||
for (const d of r.downRevisions) {
|
||||
if (added.has(d))
|
||||
continue
|
||||
if (!rel.revisions.has(d))
|
||||
return `上游迁移 ${d} 不在 ${branch}`
|
||||
if (Array.from(rel.children.get(d) ?? []).some(x => x !== r.revision))
|
||||
return `${branch} 上 ${d} 已有后继,会出现多个 head`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function attachMigrationVerdicts(cwd: string, current: string, commits: UnreleasedCommit[], filesOf: Map<string, string[]>): Promise<UnreleasedCommit[]> {
|
||||
const candidates = new Map<string, string[]>()
|
||||
for (const c of commits) {
|
||||
const files = (filesOf.get(c.sha) ?? []).filter(isMigrationPath)
|
||||
if (files.length > 0)
|
||||
candidates.set(c.sha, files)
|
||||
}
|
||||
if (candidates.size === 0)
|
||||
return commits
|
||||
const ref = `origin/${current}`
|
||||
const dirs = new Set(Array.from(candidates.values()).flat().map(f => path.posix.dirname(f)))
|
||||
const listed = await mustGit(cwd, ['ls-tree', '-r', '--name-only', ref, '--', ...dirs], '读取发布分支迁移目录失败')
|
||||
const releasePaths = listed.split('\n').filter(isMigrationPath)
|
||||
const specs = Array.from(candidates, ([sha, files]) => files.map(f => `${sha}:${f}`)).flat()
|
||||
const blobs = await catFileBatch(cwd, [...specs, ...releasePaths.map(p => `${ref}:${p}`)])
|
||||
const rel: ReleaseMigrations = { byPath: new Map(), revisions: new Set(), children: new Map(), dirs: new Set() }
|
||||
for (const p of releasePaths) {
|
||||
const parsed = parseAlembicRevision(blobs.get(`${ref}:${p}`) ?? '')
|
||||
if (!parsed)
|
||||
continue
|
||||
rel.byPath.set(p, parsed)
|
||||
rel.revisions.add(parsed.revision)
|
||||
rel.dirs.add(path.posix.dirname(p))
|
||||
for (const d of parsed.downRevisions) {
|
||||
if (!rel.children.has(d))
|
||||
rel.children.set(d, new Set())
|
||||
rel.children.get(d)!.add(parsed.revision)
|
||||
}
|
||||
}
|
||||
return commits.map((c) => {
|
||||
const files = candidates.get(c.sha)
|
||||
if (!files)
|
||||
return c
|
||||
const own = new Map<string, AlembicRevision | undefined>()
|
||||
for (const f of files) {
|
||||
const text = blobs.get(`${c.sha}:${f}`)
|
||||
if (text === undefined) {
|
||||
if (rel.byPath.has(f))
|
||||
own.set(f, undefined)
|
||||
continue
|
||||
}
|
||||
const parsed = parseAlembicRevision(text)
|
||||
if (parsed)
|
||||
own.set(f, parsed)
|
||||
}
|
||||
if (own.size === 0)
|
||||
return c
|
||||
const blocked = judgeMigration(current, own, rel)
|
||||
return { ...c, migration: blocked ? { files: [...own.keys()], blocked } : { files: [...own.keys()] } }
|
||||
})
|
||||
}
|
||||
|
||||
/** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */
|
||||
|
||||
@@ -5,14 +5,19 @@ import * as path from 'node:path'
|
||||
export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'
|
||||
|
||||
/**
|
||||
* spec/plan 以目录形式存放(docs/superpowers/{specs,plans}/<slug>/…),slug 取
|
||||
* `plans`/`specs` 后一段。兜底父目录名,再兜底路径 hash,保证目录名不为空。
|
||||
* PRD / 计划按 ECC 布局存放:`.claude/prds/<slug>.prd.md`、`.claude/plans/<slug>.plan.md`。
|
||||
* slug 取 `plans` / `prds` / `specs` 后一段并去掉 `.plan.md` / `.prd.md` / `.md` 后缀。
|
||||
* 兜底父目录名,再兜底路径 hash,保证目录名不为空。
|
||||
*/
|
||||
export function deriveSlug(planFile: string): string {
|
||||
const segments = planFile.split('/').filter(Boolean)
|
||||
const anchor = segments.findIndex(s => s === 'plans' || s === 'specs')
|
||||
if (anchor >= 0 && segments[anchor + 1])
|
||||
return segments[anchor + 1]
|
||||
const anchor = segments.findIndex(s => s === 'plans' || s === 'prds' || s === 'specs')
|
||||
const next = anchor >= 0 ? segments[anchor + 1] : undefined
|
||||
if (next) {
|
||||
const slug = next.replace(/\.(?:plan|prd)\.md$|\.md$/, '')
|
||||
if (slug)
|
||||
return slug
|
||||
}
|
||||
const parent = path.basename(path.dirname(planFile))
|
||||
if (parent && parent !== '.' && parent !== '/')
|
||||
return parent
|
||||
|
||||
@@ -48,13 +48,13 @@ export interface Issue {
|
||||
/**
|
||||
* Optional workspace-relative path to the spec file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
* `docs/superpowers/specs/*.md`.
|
||||
* `.claude/prds/<slug>.prd.md`.
|
||||
*/
|
||||
specFile?: string
|
||||
/**
|
||||
* Optional workspace-relative path to the plan file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
* `docs/superpowers/plans/*.md`.
|
||||
* `.claude/plans/<slug>.plan.md`.
|
||||
*/
|
||||
planFile?: string
|
||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||
|
||||
@@ -791,7 +791,7 @@ export class KanbanWebviewPanel {
|
||||
* 3. Confirm the always-on webhook coordinator is bound to the current
|
||||
* port, then register a gitea webhook scoped to the feature branch
|
||||
* and hand the bookkeeping to the coordinator.
|
||||
* 4. Spawn `claude` in an editor-tab terminal with a /goal prompt that
|
||||
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
||||
* tells cc to create the worktree, implement the plan, and emit
|
||||
* `<request_review>$pr_no</request_review>` when done.
|
||||
*
|
||||
|
||||
@@ -145,6 +145,10 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas:
|
||||
if (!current)
|
||||
throw new Error('尚无发布分支,先切新发布')
|
||||
const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current })
|
||||
const wanted = new Set(shas)
|
||||
const blocked = commits.find(c => wanted.has(c.sha) && c.migration?.blocked)
|
||||
if (blocked)
|
||||
throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`)
|
||||
const order = new Map(commits.map((c, i) => [c.sha, i]))
|
||||
const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
|
||||
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
|
||||
|
||||
@@ -632,7 +632,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
||||
* 3. Confirm the always-on webhook coordinator is bound to the current
|
||||
* port, then register a gitea webhook scoped to the feature branch
|
||||
* and hand the bookkeeping to the coordinator.
|
||||
* 4. Spawn `claude` in an editor-tab terminal with a /goal prompt that
|
||||
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
||||
* tells cc to create the worktree, implement the plan, and emit
|
||||
* `<request_review>$pr_no</request_review>` when done.
|
||||
*
|
||||
|
||||
@@ -24,11 +24,11 @@ import { normalizeReleasePrefix } from '../git/release'
|
||||
* users can tweak them without rebuilding. These short fallbacks just keep
|
||||
* the extension usable if the file is missing/corrupt.
|
||||
*/
|
||||
const FALLBACK_BRAINSTORM_PROMPT = `/goal 我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
||||
const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
||||
|
||||
const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/superpowers:brainstorming 讨论下 {issueNumber} 号工单。注意:不要 git checkout / 不要写代码 / 不要建 PR,只讨论需求与 spec/plan,用 spx issue marker 更新 marker。`
|
||||
const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:plan-prd 讨论下 {issueNumber} 号工单。PRD 写到 .claude/prds/<slug>.prd.md,我确认后用 ecc:plan 出 .claude/plans/<slug>.plan.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。`
|
||||
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd-workflow @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
||||
|
||||
const FALLBACK_REVIEW_PROMPT = `/review 用 tea 拿到 #{prNumber} PR 审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, promises as fsp, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
listReleaseBranches,
|
||||
listUnreleasedCommits,
|
||||
normalizeReleasePrefix,
|
||||
parseAlembicRevision,
|
||||
parseGiteaMergeSubject,
|
||||
pickCurrentRelease,
|
||||
todayReleaseBranch,
|
||||
@@ -39,6 +40,10 @@ function commit(cwd: string, file: string, content: string, msg: string): string
|
||||
git(cwd, ['commit', '-m', msg])
|
||||
return git(cwd, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
function migration(cwd: string, file: string, rev: string, down: string | null, msg: string): string {
|
||||
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true })
|
||||
return commit(cwd, file, `revision = '${rev}'\ndown_revision = ${down === null ? 'None' : `'${down}'`}\n`, msg)
|
||||
}
|
||||
let fx: Fx | undefined
|
||||
afterEach(() => {
|
||||
if (fx)
|
||||
@@ -66,6 +71,13 @@ describe('纯函数', () => {
|
||||
})
|
||||
expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined()
|
||||
})
|
||||
it('parseAlembicRevision 认裸赋值、带类型标注、元组和 None', () => {
|
||||
expect(parseAlembicRevision('revision = \'a1\'\ndown_revision = \'a0\'\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
|
||||
expect(parseAlembicRevision('revision: str = "a1"\ndown_revision: Union[str, None] = "a0"\nbranch_labels = None\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
|
||||
expect(parseAlembicRevision('revision = \'m\'\ndown_revision = (\'a\', \'b\')\n')).toEqual({ revision: 'm', downRevisions: ['a', 'b'] })
|
||||
expect(parseAlembicRevision('revision: str = \'root\'\ndown_revision: Union[str, None] = None\n')).toEqual({ revision: 'root', downRevisions: [] })
|
||||
expect(parseAlembicRevision('print(1)\n')).toBeUndefined()
|
||||
})
|
||||
it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => {
|
||||
const groups = groupCommitsByPr([
|
||||
{ sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
|
||||
@@ -237,4 +249,37 @@ describe('集成', () => {
|
||||
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
|
||||
expect(list.map(c => c.subject)).toEqual(['fix(jobs): 一次性命令收口'])
|
||||
})
|
||||
it('带迁移的提交:前任在发布分支上才能摘,前任缺失的标不可摘,不带迁移的不打标', async () => {
|
||||
fx = await setup()
|
||||
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
|
||||
git(fx.peer, ['push', 'origin', 'main'])
|
||||
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
|
||||
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
|
||||
migration(fx.peer, 'alembic/versions/0003_y.py', '0003', '0002', 'feat: m3')
|
||||
commit(fx.peer, 'b.txt', 'b', 'fix: plain')
|
||||
git(fx.peer, ['push', 'origin', 'main'])
|
||||
git(fx.repo, ['fetch', 'origin'])
|
||||
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
|
||||
expect(list.map(c => c.subject)).toEqual(['fix: plain', 'feat: m3', 'feat: m2'])
|
||||
expect(list[0].migration).toBeUndefined()
|
||||
expect(list[1].migration?.files).toEqual(['alembic/versions/0003_y.py'])
|
||||
expect(list[1].migration?.blocked).toContain('0002')
|
||||
expect(list[2].migration).toEqual({ files: ['alembic/versions/0002_x.py'] })
|
||||
})
|
||||
it('发布分支上同一前任已有后继 → 多 head,不可摘', async () => {
|
||||
fx = await setup()
|
||||
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
|
||||
git(fx.peer, ['push', 'origin', 'main'])
|
||||
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
|
||||
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
|
||||
git(fx.peer, ['push', 'origin', 'main'])
|
||||
git(fx.peer, ['fetch'])
|
||||
git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31'])
|
||||
migration(fx.peer, 'alembic/versions/0002_hotfix.py', '0002b', '0001', 'fix: hotfix schema')
|
||||
git(fx.peer, ['push', 'origin', 'release/2026-08-31'])
|
||||
git(fx.repo, ['fetch', 'origin'])
|
||||
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
|
||||
expect(list.map(c => c.subject)).toEqual(['feat: m2'])
|
||||
expect(list[0].migration?.blocked).toContain('多个 head')
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,29 @@
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from './worktreePath'
|
||||
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from '../../src/git/worktreePath'
|
||||
|
||||
describe('deriveSlug', () => {
|
||||
it('取 plans/ 后一段', () => {
|
||||
expect(deriveSlug('docs/superpowers/plans/foo-bar/plan.md')).toBe('foo-bar')
|
||||
it('plans/ 后一段去掉 .plan.md', () => {
|
||||
expect(deriveSlug('.claude/plans/app-entity.plan.md')).toBe('app-entity')
|
||||
})
|
||||
it('没有 plans/specs 锚点时退回父目录名', () => {
|
||||
expect(deriveSlug('docs/x/baz/plan.md')).toBe('baz')
|
||||
it('prds/ 后一段去掉 .prd.md', () => {
|
||||
expect(deriveSlug('.claude/prds/app-entity.prd.md')).toBe('app-entity')
|
||||
})
|
||||
it('目录形式取 plans/ 后的目录名', () => {
|
||||
expect(deriveSlug('docs/superpowers/plans/x/plan.md')).toBe('x')
|
||||
})
|
||||
it('没有 plans/prds/specs 锚点时退回父目录名', () => {
|
||||
expect(deriveSlug('foo/bar/notes.md')).toBe('bar')
|
||||
})
|
||||
it('没有父目录时退回路径 hash 前 8 位', () => {
|
||||
expect(deriveSlug('notes.md')).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugFromBranch', () => {
|
||||
it('去掉 feature/ 前缀', () => {
|
||||
expect(slugFromBranch('feature/foo-bar')).toBe('foo-bar')
|
||||
expect(slugFromBranch('feature/app-entity')).toBe('app-entity')
|
||||
})
|
||||
it('无前缀原样返回', () => {
|
||||
expect(slugFromBranch('hotfix-1')).toBe('hotfix-1')
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReleaseCommitGroup } from '../lib/messages'
|
||||
import { ChevronRight, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { filterCherryPickGroups, groupContainingSha, uniqueAuthors } from '../lib/cherryPickView'
|
||||
import { filterCherryPickGroups, groupContainingSha, pickableShas, uniqueAuthors } from '../lib/cherryPickView'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -78,7 +78,7 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
|
||||
}
|
||||
|
||||
function toggleGroupSelected(g: ReleaseCommitGroup): void {
|
||||
const shas = g.commits.map(c => c.sha)
|
||||
const shas = pickableShas(g.commits)
|
||||
const allOn = shas.every(s => selected.has(s))
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -150,7 +150,8 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
|
||||
: visible.map((g) => {
|
||||
const key = groupKey(g)
|
||||
const openGroup = query.trim() !== '' || expanded.has(key)
|
||||
const shas = g.commits.map(c => c.sha)
|
||||
const shas = pickableShas(g.commits)
|
||||
const blockedCount = g.commits.length - shas.length
|
||||
const allOn = shas.length > 0 && shas.every(s => selected.has(s))
|
||||
const someOn = shas.some(s => selected.has(s))
|
||||
return (
|
||||
@@ -171,11 +172,13 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] opacity-60">
|
||||
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
|
||||
{blockedCount > 0 ? ` · ${blockedCount} 条带迁移不可摘` : ''}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOn}
|
||||
disabled={shas.length === 0}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate = someOn && !allOn
|
||||
@@ -187,25 +190,47 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
|
||||
</div>
|
||||
{openGroup && (
|
||||
<ul className="border-t border-[var(--vscode-panel-border)] py-1">
|
||||
{g.commits.map(c => (
|
||||
<li key={c.sha}>
|
||||
<label className={`flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5 ${c.sha === firstAfterReleaseSha ? 'bg-[var(--vscode-editor-findMatchHighlightBackground)]' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.sha)}
|
||||
onChange={() => toggleSha(c.sha)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{c.subject}</span>
|
||||
<span className="block text-[11px] opacity-60">
|
||||
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
||||
{c.sha === firstAfterReleaseSha ? ' · 发布点后第一条' : ''}
|
||||
{g.commits.map((c) => {
|
||||
const blocked = c.migration?.blocked
|
||||
const rowBg = blocked
|
||||
? 'bg-[var(--vscode-inputValidation-warningBackground)]'
|
||||
: c.sha === firstAfterReleaseSha ? 'bg-[var(--vscode-editor-findMatchHighlightBackground)]' : ''
|
||||
return (
|
||||
<li key={c.sha}>
|
||||
<label className={`flex items-start gap-2 px-3 py-1.5 ${blocked ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-white/5'} ${rowBg}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.sha)}
|
||||
disabled={!!blocked}
|
||||
onChange={() => toggleSha(c.sha)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{c.migration && (
|
||||
<span
|
||||
title={c.migration.files.join('\n')}
|
||||
className="shrink-0 rounded border border-current px-1 text-[10px] leading-4 opacity-80"
|
||||
>
|
||||
迁移
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{c.subject}</span>
|
||||
</span>
|
||||
<span className="block text-[11px] opacity-60">
|
||||
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
||||
{c.sha === firstAfterReleaseSha ? ' · 发布点后第一条' : ''}
|
||||
</span>
|
||||
{blocked && (
|
||||
<span className="block text-[11px] text-[var(--vscode-editorWarning-foreground)]">
|
||||
{`不可摘:${blocked}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReleaseCommitGroup } from './messages'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterCherryPickGroups, uniqueAuthors } from './cherryPickView'
|
||||
import { filterCherryPickGroups, pickableShas, uniqueAuthors } from './cherryPickView'
|
||||
|
||||
const groups: ReleaseCommitGroup[] = [
|
||||
{
|
||||
@@ -35,3 +35,14 @@ describe('uniqueAuthors', () => {
|
||||
expect(uniqueAuthors(groups)).toEqual(['cruldra', 'chehongweo'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickableShas', () => {
|
||||
it('带不兼容迁移的提交不能勾;兼容的迁移照常', () => {
|
||||
const commits = [
|
||||
{ sha: 'a', subject: 'ok', author: 'x', date: '', body: '' },
|
||||
{ sha: 'b', subject: 'mig ok', author: 'x', date: '', body: '', migration: { files: ['alembic/versions/0002.py'] } },
|
||||
{ sha: 'c', subject: 'mig bad', author: 'x', date: '', body: '', migration: { files: ['alembic/versions/0003.py'], blocked: '上游迁移 0002 不在 release/x' } },
|
||||
]
|
||||
expect(pickableShas(commits)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ReleaseCommitGroup } from './messages'
|
||||
import type { ReleaseCommit, ReleaseCommitGroup } from './messages'
|
||||
|
||||
/** 能勾选的提交:带不兼容迁移的不算 */
|
||||
export function pickableShas(commits: ReleaseCommit[]): string[] {
|
||||
return commits.filter(c => !c.migration?.blocked).map(c => c.sha)
|
||||
}
|
||||
|
||||
export function uniqueAuthors(groups: ReleaseCommitGroup[]): string[] {
|
||||
const out: string[] = []
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ReleaseCommit {
|
||||
author: string
|
||||
date: string
|
||||
body: string
|
||||
/** 带 alembic 迁移文件时才有;blocked 有值就不能摘 */
|
||||
migration?: { files: string[], blocked?: string }
|
||||
}
|
||||
|
||||
export interface ReleaseCommitGroup {
|
||||
|
||||
@@ -29,13 +29,13 @@ export interface Issue {
|
||||
/**
|
||||
* Optional workspace-relative path to the spec file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
* `docs/superpowers/specs/*.md`.
|
||||
* `.claude/prds/<slug>.prd.md`.
|
||||
*/
|
||||
specFile?: string
|
||||
/**
|
||||
* Optional workspace-relative path to the plan file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
* `docs/superpowers/plans/*.md`.
|
||||
* `.claude/plans/<slug>.plan.md`.
|
||||
*/
|
||||
planFile?: string
|
||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||
|
||||
Reference in new issue
Block a user