@aipper/aiws-spec 0.0.26 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/cli-interface.md +4 -2
- package/docs/workflow-governance-rules.json +15 -1
- package/docs/workflow-governance-rules.md +5 -3
- package/package.json +1 -1
- package/templates/workspace/.agents/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/.claude/commands/ws-finish.md +8 -4
- package/templates/workspace/.claude/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/.opencode/command/ws-finish.md +8 -4
- package/templates/workspace/.opencode/commands/ws-finish.md +8 -4
- package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/changes/README.md +2 -1
- package/templates/workspace/tools/ws_change_check.py +224 -7
package/docs/cli-interface.md
CHANGED
|
@@ -274,14 +274,15 @@ Dashboard API(本地只读):
|
|
|
274
274
|
### `aiws change start`
|
|
275
275
|
|
|
276
276
|
语义:
|
|
277
|
-
- 默认行为:切分支到 `change/<change-id
|
|
277
|
+
- 默认行为:切分支到 `change/<change-id>`(存在则切换;不存在则创建);若仓库存在 `.gitmodules`,只切 superproject,本次不会递归切换 submodules。
|
|
278
278
|
- 若检测到 `.gitmodules`(git submodules)且未显式指定 `--switch/--no-switch/--worktree`:默认优先采用 `--worktree`(避免切走 superproject 分支导致 submodule 状态混乱);若不满足 `--worktree` 前置条件则回退为 `--no-switch`。
|
|
279
279
|
- 若 `changes/<change-id>/` 不存在:等价执行 `aiws change new ...`。
|
|
280
280
|
- 可选:`--hooks` 等价于 `aiws hooks install .`。
|
|
281
281
|
- 可选:`--switch` 显式允许切换 superproject 分支(仅在存在 `.gitmodules` 时有意义;否则等价于默认行为)。
|
|
282
|
-
- 实现细节:当存在 `.gitmodules` 且使用 `--switch` 时,CLI
|
|
282
|
+
- 实现细节:当存在 `.gitmodules` 且使用 `--switch` 时,CLI 只切 superproject 分支;submodules 保持当前状态,由 `ws-pull` / `ws-finish` 再统一挂回 `aiws/pin/<target-branch>`。
|
|
283
283
|
- 可选:`--no-switch` 不切换当前分支(仅确保目标分支存在,并初始化变更工件);适用于 superproject + submodule 场景,避免意外切换 A 目录分支导致 submodule 状态混乱。
|
|
284
284
|
- 可选:`--worktree` 使用 `git worktree` 创建一个独立工作区并在其中 checkout `change/<change-id>`,当前目录分支保持不变(推荐用于 superproject + submodule)。
|
|
285
|
+
- 当 `.gitmodules` 存在时,submodule 的 detached HEAD 恢复应统一使用 `aiws/pin/<target-branch>`;不要把 submodule 切到 `change/<change-id>`。
|
|
285
286
|
- 默认 worktree 目录:`../<repo-name>-<change-id>`(相对于 git root)
|
|
286
287
|
- 可选:`--worktree-dir <path>` 覆盖 worktree 目录(推荐放在仓库外;相对路径按 `../` 作为基准理解)
|
|
287
288
|
- 可选:`--submodules` 在 worktree 内执行 `git submodule update --init --recursive`(避免新 worktree 下 submodule 目录为空)
|
|
@@ -311,6 +312,7 @@ Dashboard API(本地只读):
|
|
|
311
312
|
- 只有完整 finish 成功(兼容旧事件 `finish`,新事件 `finish_done`)后,治理阶段才可进入 `ws-handoff`
|
|
312
313
|
- 当 `--push` 完整成功时,CLI 应自动执行 archive:将 `changes/<id>/` 移动到 `changes/archive/<YYYY-MM-DD>-<id>/`,生成 `handoff.md`,提交 `归档: <change-id>`,并将该 archive commit push 到同一目标分支
|
|
313
314
|
- 若 auto-archive commit/push 失败,应报错并保留已归档工件,提示用户继续提交/推送 archive 结果;不应要求重新做 merge
|
|
315
|
+
- 若发现“已 finish 但 active `changes/<id>/` 仍存在”的 `finished_unarchived` 状态,应视为 finish closeout 未完成:默认恢复动作是重跑 `aiws change finish <id> --push`,而不是继续开发或要求从头再做 merge
|
|
314
316
|
|
|
315
317
|
接口(仅定义):
|
|
316
318
|
- `aiws change finish [<change-id>] [--into <branch> | --base <branch>]`
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"description": "Machine-readable governance inference rules for aiws change status/next/dashboard.",
|
|
4
4
|
"governanceRules": [
|
|
5
|
+
{
|
|
6
|
+
"id": "finish_resume_required",
|
|
7
|
+
"when": { "signal": "finish_resume_required", "truthy": true },
|
|
8
|
+
"currentStage": "ws-finish",
|
|
9
|
+
"recommendedStage": "ws-finish",
|
|
10
|
+
"rationale": "finish already completed locally, but archive/push closeout is still pending; rerun finish to resume"
|
|
11
|
+
},
|
|
5
12
|
{
|
|
6
13
|
"id": "strict_blockers",
|
|
7
14
|
"when": { "signal": "blockers_strict_count", "gt": 0 },
|
|
@@ -269,9 +276,16 @@
|
|
|
269
276
|
"收尾已完成且 change 应已归档:下一步运行 `$ws-handoff` 检查/补充归档 handoff"
|
|
270
277
|
]
|
|
271
278
|
},
|
|
279
|
+
{
|
|
280
|
+
"id": "guidance_finish_resume_required",
|
|
281
|
+
"when": { "signal": "governance_rule_id", "eq": "finish_resume_required" },
|
|
282
|
+
"lines": [
|
|
283
|
+
"finish closeout 未完成:直接重跑 `aiws change finish {change_id} --push`(不要先在目标分支 worktree 运行 `aiws validate . --stamp`)"
|
|
284
|
+
]
|
|
285
|
+
},
|
|
272
286
|
{
|
|
273
287
|
"id": "guidance_finish_retry",
|
|
274
|
-
"when": { "signal": "governance_rule_id", "in": ["finish_failed", "finish_local"] },
|
|
288
|
+
"when": { "signal": "governance_rule_id", "in": ["finish_failed", "finish_local", "finish_resume_required"] },
|
|
275
289
|
"lines": [
|
|
276
290
|
"收尾未完整完成:检查 push / cleanup / submodule 状态后重跑 `$ws-finish`"
|
|
277
291
|
]
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
- version: `1`
|
|
13
13
|
- description: Machine-readable governance inference rules for aiws change status/next/dashboard.
|
|
14
|
-
- governance rules:
|
|
15
|
-
- guidance rules:
|
|
14
|
+
- governance rules: 17
|
|
15
|
+
- guidance rules: 17
|
|
16
16
|
|
|
17
17
|
## 阶段推断规则
|
|
18
18
|
|
|
19
19
|
| Rule ID | Match | Current | Next | Rationale |
|
|
20
20
|
| --- | --- | --- | --- | --- |
|
|
21
|
+
| `finish_resume_required` | `finish_resume_required` is truthy | `ws-finish` | `ws-finish` | finish already completed locally, but archive/push closeout is still pending; rerun finish to resume |
|
|
21
22
|
| `strict_blockers` | `blockers_strict_count` > 0 | `ws-plan-verify` | `ws-plan-verify` | strict blockers remain unresolved |
|
|
22
23
|
| `unchecked_tasks` | `tasks_unchecked` > 0 | `ws-dev` | `ws-dev` | {tasks_unchecked} unchecked task(s) remain |
|
|
23
24
|
| `finish_completed` | `finish_state` == `done` | `ws-finish` | `ws-handoff` | finish completed; archive should already exist and handoff is ready for follow-up |
|
|
@@ -52,7 +53,8 @@
|
|
|
52
53
|
| `guidance_deliver_finish` | (`governance_current_stage` == `ws-deliver`) AND (`governance_recommended_stage` == `ws-finish`) | 交付工件齐全后,进入 `$ws-finish`(安全合并 + push + cleanup) |
|
|
53
54
|
| `guidance_finish_cleanup_pending` | `governance_rule_id` == `finish_cleanup_pending` | push 已完成,但 cleanup 仍未完成({finish_state_reason});清理对应 worktree 后重跑 `$ws-finish` |
|
|
54
55
|
| `guidance_finish_handoff` | `governance_rule_id` == `finish_completed` | 收尾已完成且 change 应已归档:下一步运行 `$ws-handoff` 检查/补充归档 handoff |
|
|
55
|
-
| `
|
|
56
|
+
| `guidance_finish_resume_required` | `governance_rule_id` == `finish_resume_required` | finish closeout 未完成:直接重跑 `aiws change finish {change_id} --push`(不要先在目标分支 worktree 运行 `aiws validate . --stamp`) |
|
|
57
|
+
| `guidance_finish_retry` | `governance_rule_id` in [`finish_failed`, `finish_local`, `finish_resume_required`] | 收尾未完整完成:检查 push / cleanup / submodule 状态后重跑 `$ws-finish` |
|
|
56
58
|
| `guidance_handoff` | `governance_current_stage` == `ws-handoff` | 进入交接:在 AI 工具中运行 `$ws-handoff`,并检查归档 handoff 是否足够支撑下一次接力 |
|
|
57
59
|
| `guidance_default` | `governance_current_stage` == `` | 按阶段契约继续推进当前 change |
|
|
58
60
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,8 @@ description: 收尾(门禁 + 安全合并 + submodule→主仓库顺序 push
|
|
|
14
14
|
前置(必须):
|
|
15
15
|
- 工作区是干净的:`git status --porcelain` 无输出(若有未提交改动:先 commit 或 stash)
|
|
16
16
|
- change 分支已存在:`change/<change-id>`(也支持 `changes/`、`ws/`、`ws-change/`)
|
|
17
|
-
- 若使用 worktree
|
|
17
|
+
- 若使用 worktree:普通 finish 的 `validate/evidence/state` 先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree(命令会提示正确的 worktree)
|
|
18
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只剩 push / archive / cleanup closeout;直接在该 worktree 运行 `aiws change finish <change-id> --push`,不要先跑 `aiws validate . --stamp`
|
|
18
19
|
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则无法确定性减少 detached;先运行 `$ws-submodule-setup` 并提交 `.gitmodules`)
|
|
19
20
|
|
|
20
21
|
阶段定位:
|
|
@@ -60,7 +61,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
60
61
|
fi
|
|
61
62
|
fi
|
|
62
63
|
```
|
|
63
|
-
|
|
64
|
+
0.5) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`:
|
|
65
|
+
- 若为 `finish_resume_required`:直接跳到步骤 2 执行 `aiws change finish <change-id> --push`
|
|
66
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
67
|
+
|
|
68
|
+
1) (推荐,仅适用于普通 finish,且应在 `change/<change-id>` worktree 执行)先跑一次门禁并落盘证据:
|
|
64
69
|
```bash
|
|
65
70
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
66
71
|
./node_modules/.bin/aiws validate . --stamp
|
|
@@ -70,7 +75,10 @@ else
|
|
|
70
75
|
npx @aipper/aiws validate . --stamp
|
|
71
76
|
fi
|
|
72
77
|
```
|
|
73
|
-
|
|
78
|
+
若你已经位于目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`:
|
|
79
|
+
- 不要在这里跑 `aiws validate . --stamp`
|
|
80
|
+
- 先回 `change/<change-id>` worktree 补齐 finish gate,或先跑 `$ws-verify-before-complete`
|
|
81
|
+
1.1) (强烈建议,仅适用于普通 finish)收敛持久证据并回填 `Evidence_Path`:
|
|
74
82
|
```bash
|
|
75
83
|
change_id="<change-id>"
|
|
76
84
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
@@ -81,7 +89,7 @@ else
|
|
|
81
89
|
npx @aipper/aiws change evidence "${change_id}"
|
|
82
90
|
fi
|
|
83
91
|
```
|
|
84
|
-
1.2)
|
|
92
|
+
1.2) (可选,仅适用于普通 finish)生成状态快照(建议):
|
|
85
93
|
```bash
|
|
86
94
|
aiws change state "${change_id}" --write
|
|
87
95
|
```
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
前置(必须):
|
|
10
10
|
- 工作区干净:`git status --porcelain` 无输出(否则先 commit 或 stash)
|
|
11
11
|
- change 分支存在(`change/<change-id>`;也支持 `changes/`、`ws/`、`ws-change/`)
|
|
12
|
-
|
|
12
|
+
- 普通 finish 的 `validate/evidence/state` 应先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree
|
|
13
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只需继续 push / archive / cleanup closeout;直接在提示的 worktree 运行 `aiws change finish <change-id> --push`
|
|
14
|
+
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
|
|
13
15
|
|
|
14
16
|
步骤(建议):
|
|
15
17
|
0) 若存在 `.gitmodules`,先检查 submodule branch 配置是否齐全(缺失则停止并提示 setup):
|
|
@@ -31,9 +33,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
31
33
|
fi
|
|
32
34
|
```
|
|
33
35
|
1) 先运行 `/ws-preflight`(确保真值文件齐全)。
|
|
34
|
-
2)
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
2) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`。
|
|
37
|
+
- 若为 `finish_resume_required`:直接跳到步骤 3 执行 `aiws change finish <change-id> --push`
|
|
38
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
39
|
+
2.1) 普通 finish 时,`aiws validate . --stamp` / `aiws change evidence <change-id>` / `aiws change state <change-id> --write` 应在 `change/<change-id>` worktree 完成。
|
|
40
|
+
- 如果已经在目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`,不要在这里跑 `aiws validate . --stamp`;先回 `change/<change-id>` worktree,或先跑 `/ws-verify-before-complete`
|
|
37
41
|
3) 若不存在 `.gitmodules`,或 submodules 已按顺序处理完成,优先直接执行最小收尾闭环:
|
|
38
42
|
- `aiws change finish <change-id> --push`
|
|
39
43
|
- 若当前就在 `change/<change-id>` 分支上,也可省略 `<change-id>`
|
|
@@ -14,7 +14,8 @@ description: 收尾(门禁 + 安全合并 + submodule→主仓库顺序 push
|
|
|
14
14
|
前置(必须):
|
|
15
15
|
- 工作区是干净的:`git status --porcelain` 无输出(若有未提交改动:先 commit 或 stash)
|
|
16
16
|
- change 分支已存在:`change/<change-id>`(也支持 `changes/`、`ws/`、`ws-change/`)
|
|
17
|
-
- 若使用 worktree
|
|
17
|
+
- 若使用 worktree:普通 finish 的 `validate/evidence/state` 先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree(命令会提示正确的 worktree)
|
|
18
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只剩 push / archive / cleanup closeout;直接在该 worktree 运行 `aiws change finish <change-id> --push`,不要先跑 `aiws validate . --stamp`
|
|
18
19
|
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则无法确定性减少 detached;先运行 `$ws-submodule-setup` 并提交 `.gitmodules`)
|
|
19
20
|
|
|
20
21
|
阶段定位:
|
|
@@ -60,7 +61,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
60
61
|
fi
|
|
61
62
|
fi
|
|
62
63
|
```
|
|
63
|
-
|
|
64
|
+
0.5) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`:
|
|
65
|
+
- 若为 `finish_resume_required`:直接跳到步骤 2 执行 `aiws change finish <change-id> --push`
|
|
66
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
67
|
+
|
|
68
|
+
1) (推荐,仅适用于普通 finish,且应在 `change/<change-id>` worktree 执行)先跑一次门禁并落盘证据:
|
|
64
69
|
```bash
|
|
65
70
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
66
71
|
./node_modules/.bin/aiws validate . --stamp
|
|
@@ -70,7 +75,10 @@ else
|
|
|
70
75
|
npx @aipper/aiws validate . --stamp
|
|
71
76
|
fi
|
|
72
77
|
```
|
|
73
|
-
|
|
78
|
+
若你已经位于目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`:
|
|
79
|
+
- 不要在这里跑 `aiws validate . --stamp`
|
|
80
|
+
- 先回 `change/<change-id>` worktree 补齐 finish gate,或先跑 `$ws-verify-before-complete`
|
|
81
|
+
1.1) (强烈建议,仅适用于普通 finish)收敛持久证据并回填 `Evidence_Path`:
|
|
74
82
|
```bash
|
|
75
83
|
change_id="<change-id>"
|
|
76
84
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
@@ -81,7 +89,7 @@ else
|
|
|
81
89
|
npx @aipper/aiws change evidence "${change_id}"
|
|
82
90
|
fi
|
|
83
91
|
```
|
|
84
|
-
1.2)
|
|
92
|
+
1.2) (可选,仅适用于普通 finish)生成状态快照(建议):
|
|
85
93
|
```bash
|
|
86
94
|
aiws change state "${change_id}" --write
|
|
87
95
|
```
|
|
@@ -12,7 +12,9 @@ description: 收尾:fast-forward 合并并把 submodule 并回目标分支后
|
|
|
12
12
|
前置(必须):
|
|
13
13
|
- 工作区干净:`git status --porcelain` 无输出(否则先 commit 或 stash)
|
|
14
14
|
- change 分支存在(`change/<change-id>`;也支持 `changes/`、`ws/`、`ws-change/`)
|
|
15
|
-
|
|
15
|
+
- 普通 finish 的 `validate/evidence/state` 应先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree
|
|
16
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只需继续 push / archive / cleanup closeout;直接在提示的 worktree 运行 `aiws change finish <change-id> --push`
|
|
17
|
+
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
|
|
16
18
|
|
|
17
19
|
步骤(建议):
|
|
18
20
|
0) 若存在 `.gitmodules`,先检查 submodule branch 配置是否齐全(缺失则停止并提示 setup):
|
|
@@ -34,9 +36,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
34
36
|
fi
|
|
35
37
|
```
|
|
36
38
|
1) 先运行 `/ws-preflight`(确保真值文件齐全)。
|
|
37
|
-
2)
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
2) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`。
|
|
40
|
+
- 若为 `finish_resume_required`:直接跳到步骤 3 执行 `aiws change finish <change-id> --push`
|
|
41
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
42
|
+
2.1) 普通 finish 时,`aiws validate . --stamp` / `aiws change evidence <change-id>` / `aiws change state <change-id> --write` 应在 `change/<change-id>` worktree 完成。
|
|
43
|
+
- 如果已经在目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`,不要在这里跑 `aiws validate . --stamp`;先回 `change/<change-id>` worktree,或先跑 `/ws-verify-before-complete`
|
|
40
44
|
3) 若不存在 `.gitmodules`,或 submodules 已按顺序处理完成,优先直接执行最小收尾闭环:
|
|
41
45
|
- `aiws change finish <change-id> --push`
|
|
42
46
|
- 若当前就在 `change/<change-id>` 分支上,也可省略 `<change-id>`
|
|
@@ -12,7 +12,9 @@ description: 收尾:fast-forward 合并并把 submodule 并回目标分支后
|
|
|
12
12
|
前置(必须):
|
|
13
13
|
- 工作区干净:`git status --porcelain` 无输出(否则先 commit 或 stash)
|
|
14
14
|
- change 分支存在(`change/<change-id>`;也支持 `changes/`、`ws/`、`ws-change/`)
|
|
15
|
-
|
|
15
|
+
- 普通 finish 的 `validate/evidence/state` 应先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree
|
|
16
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只需继续 push / archive / cleanup closeout;直接在提示的 worktree 运行 `aiws change finish <change-id> --push`
|
|
17
|
+
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
|
|
16
18
|
|
|
17
19
|
步骤(建议):
|
|
18
20
|
0) 若存在 `.gitmodules`,先检查 submodule branch 配置是否齐全(缺失则停止并提示 setup):
|
|
@@ -34,9 +36,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
34
36
|
fi
|
|
35
37
|
```
|
|
36
38
|
1) 先运行 `/ws-preflight`(确保真值文件齐全)。
|
|
37
|
-
2)
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
2) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`。
|
|
40
|
+
- 若为 `finish_resume_required`:直接跳到步骤 3 执行 `aiws change finish <change-id> --push`
|
|
41
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
42
|
+
2.1) 普通 finish 时,`aiws validate . --stamp` / `aiws change evidence <change-id>` / `aiws change state <change-id> --write` 应在 `change/<change-id>` worktree 完成。
|
|
43
|
+
- 如果已经在目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`,不要在这里跑 `aiws validate . --stamp`;先回 `change/<change-id>` worktree,或先跑 `/ws-verify-before-complete`
|
|
40
44
|
3) 若不存在 `.gitmodules`,或 submodules 已按顺序处理完成,优先直接执行最小收尾闭环:
|
|
41
45
|
- `aiws change finish <change-id> --push`
|
|
42
46
|
- 若当前就在 `change/<change-id>` 分支上,也可省略 `<change-id>`
|
|
@@ -14,7 +14,8 @@ description: 收尾(门禁 + 安全合并 + submodule→主仓库顺序 push
|
|
|
14
14
|
前置(必须):
|
|
15
15
|
- 工作区是干净的:`git status --porcelain` 无输出(若有未提交改动:先 commit 或 stash)
|
|
16
16
|
- change 分支已存在:`change/<change-id>`(也支持 `changes/`、`ws/`、`ws-change/`)
|
|
17
|
-
- 若使用 worktree
|
|
17
|
+
- 若使用 worktree:普通 finish 的 `validate/evidence/state` 先在 `change/<change-id>` worktree 完成;真正执行 `aiws change finish` 时再切到目标分支所在 worktree(命令会提示正确的 worktree)
|
|
18
|
+
- 若 `aiws change status <change-id>` 输出 `governance_rule: finish_resume_required`:说明 merge 已发生,只剩 push / archive / cleanup closeout;直接在该 worktree 运行 `aiws change finish <change-id> --push`,不要先跑 `aiws validate . --stamp`
|
|
18
19
|
- 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则无法确定性减少 detached;先运行 `$ws-submodule-setup` 并提交 `.gitmodules`)
|
|
19
20
|
|
|
20
21
|
阶段定位:
|
|
@@ -60,7 +61,11 @@ if [[ -f .gitmodules ]]; then
|
|
|
60
61
|
fi
|
|
61
62
|
fi
|
|
62
63
|
```
|
|
63
|
-
|
|
64
|
+
0.5) 先运行 `aiws change status <change-id>`,优先看稳定字段 `governance_rule:`:
|
|
65
|
+
- 若为 `finish_resume_required`:直接跳到步骤 2 执行 `aiws change finish <change-id> --push`
|
|
66
|
+
- 若不是该值:按普通 finish 继续;不要依赖自然语言提示句做分支判断
|
|
67
|
+
|
|
68
|
+
1) (推荐,仅适用于普通 finish,且应在 `change/<change-id>` worktree 执行)先跑一次门禁并落盘证据:
|
|
64
69
|
```bash
|
|
65
70
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
66
71
|
./node_modules/.bin/aiws validate . --stamp
|
|
@@ -70,7 +75,10 @@ else
|
|
|
70
75
|
npx @aipper/aiws validate . --stamp
|
|
71
76
|
fi
|
|
72
77
|
```
|
|
73
|
-
|
|
78
|
+
若你已经位于目标分支 worktree,且 `governance_rule` 不是 `finish_resume_required`:
|
|
79
|
+
- 不要在这里跑 `aiws validate . --stamp`
|
|
80
|
+
- 先回 `change/<change-id>` worktree 补齐 finish gate,或先跑 `$ws-verify-before-complete`
|
|
81
|
+
1.1) (强烈建议,仅适用于普通 finish)收敛持久证据并回填 `Evidence_Path`:
|
|
74
82
|
```bash
|
|
75
83
|
change_id="<change-id>"
|
|
76
84
|
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
@@ -81,7 +89,7 @@ else
|
|
|
81
89
|
npx @aipper/aiws change evidence "${change_id}"
|
|
82
90
|
fi
|
|
83
91
|
```
|
|
84
|
-
1.2)
|
|
92
|
+
1.2) (可选,仅适用于普通 finish)生成状态快照(建议):
|
|
85
93
|
```bash
|
|
86
94
|
aiws change state "${change_id}" --write
|
|
87
95
|
```
|
|
@@ -34,12 +34,13 @@ changes/
|
|
|
34
34
|
常用命令(推荐使用 `aiws`;不依赖 dotfiles):
|
|
35
35
|
- `aiws change start <change-id>`(默认:切到 `change/<change-id>` 并初始化工件目录;若检测到 `.gitmodules` 则默认优先使用 worktree,失败则回退为不切分支,避免 submodule 状态混乱)
|
|
36
36
|
- `aiws change start <change-id> --no-switch`(superproject + submodule 场景:不切分支,仅准备 `change/<change-id>` 分支与工件目录)
|
|
37
|
-
- `aiws change start <change-id> --switch`(显式允许切换 superproject
|
|
37
|
+
- `aiws change start <change-id> --switch`(显式允许切换 superproject 分支;仅切 superproject,不递归切换 submodule;仅在存在 `.gitmodules` 时有意义)
|
|
38
38
|
- `aiws change start <change-id> --worktree`(推荐用于 superproject + submodule:创建独立 worktree;当前目录分支保持不变)
|
|
39
39
|
- 可选:`--worktree-dir <path>` 覆盖 worktree 目录
|
|
40
40
|
- 可选:`--submodules` 在 worktree 内执行 `git submodule update --init --recursive`
|
|
41
41
|
- `aiws change finish <change-id>`(安全合并:fast-forward 合并回目标分支;在 `change/<change-id>` 分支上执行时会尝试使用 `.ws-change.json` 的 `base_branch` 作为目标分支)
|
|
42
42
|
- `aiws change finish <change-id> --push [--remote <name>]`(最小收尾闭环:若存在 `.gitmodules` 则先按 `changes/<id>/submodules.targets` 顺序 push submodule,再 push 目标分支;默认优先遵循 upstream 配置;若 `change/<change-id>` 位于独立 worktree,且该 worktree 干净,则在 push 成功后自动执行 worktree cleanup,并把 `changes/<id>/` 自动归档到 `changes/archive/...`,同时生成 `handoff.md` 与 archive commit)
|
|
43
|
+
- 已 archive 的 `change/<change-id>` 才算真正终态;若只是“已 finish 但仍有 active `changes/<id>/` 未归档”,默认应重跑 `aiws change finish <change-id> --push` 续完收尾,而不是继续开发或复用旧 branch 做新需求。
|
|
43
44
|
- `aiws change new <change-id>`
|
|
44
45
|
- `aiws change list`
|
|
45
46
|
- `aiws change status <change-id>`
|
|
@@ -16,6 +16,13 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
16
16
|
|
|
17
17
|
CHANGE_BRANCH_RE = re.compile(r"^(change|changes|ws|ws-change)/([a-z0-9]+(?:-[a-z0-9]+)*)$")
|
|
18
18
|
CHANGE_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
19
|
+
PIN_BRANCH_RE = re.compile(r"^aiws/pin/.+$")
|
|
20
|
+
FINISH_DONE_COMPLETED_CLEANUPS = {
|
|
21
|
+
"removed",
|
|
22
|
+
"pruned-missing",
|
|
23
|
+
"skipped:no_separate_change_worktree",
|
|
24
|
+
"skipped:current_worktree",
|
|
25
|
+
}
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def eprint(msg: str) -> None:
|
|
@@ -93,6 +100,53 @@ def parse_submodule_targets(path: Path) -> Tuple[Dict[str, Tuple[str, str]], Lis
|
|
|
93
100
|
return parsed, perr
|
|
94
101
|
|
|
95
102
|
|
|
103
|
+
def git_text(root: Path, args: List[str]) -> Tuple[int, str, str]:
|
|
104
|
+
try:
|
|
105
|
+
res = subprocess.run(
|
|
106
|
+
["git", "-C", str(root), *args],
|
|
107
|
+
check=False,
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
text=True,
|
|
111
|
+
)
|
|
112
|
+
return (res.returncode, (res.stdout or "").strip(), (res.stderr or "").strip())
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
return (1, "", str(exc))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_change_gitlink_commit(root: Path, change_id: str, sub_path: str) -> str:
|
|
118
|
+
for spec in (f"change/{change_id}:{sub_path}", f"HEAD:{sub_path}"):
|
|
119
|
+
code, out, _ = git_text(root, ["rev-parse", spec])
|
|
120
|
+
if code == 0 and out:
|
|
121
|
+
return out
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def git_ref_exists(root: Path, ref: str) -> bool:
|
|
126
|
+
code, _, _ = git_text(root, ["show-ref", "--verify", "--quiet", ref])
|
|
127
|
+
return code == 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def git_revision_exists(root: Path, rev: str) -> bool:
|
|
131
|
+
code, _, _ = git_text(root, ["cat-file", "-e", f"{rev}^{{commit}}"])
|
|
132
|
+
return code == 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def git_is_ancestor(root: Path, older: str, newer: str) -> bool:
|
|
136
|
+
code, _, _ = git_text(root, ["merge-base", "--is-ancestor", older, newer])
|
|
137
|
+
return code == 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_branch_ref(repo_root: Path, remote: str, branch: str) -> str:
|
|
141
|
+
remote_ref = f"refs/remotes/{remote}/{branch}"
|
|
142
|
+
if git_ref_exists(repo_root, remote_ref):
|
|
143
|
+
return remote_ref
|
|
144
|
+
local_ref = f"refs/heads/{branch}"
|
|
145
|
+
if git_ref_exists(repo_root, local_ref):
|
|
146
|
+
return local_ref
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
96
150
|
def sha256(path: Path) -> str:
|
|
97
151
|
h = hashlib.sha256()
|
|
98
152
|
with path.open("rb") as f:
|
|
@@ -128,6 +182,17 @@ def current_branch(root: Path) -> Optional[str]:
|
|
|
128
182
|
return None
|
|
129
183
|
|
|
130
184
|
|
|
185
|
+
def is_submodule_repo(root: Path) -> bool:
|
|
186
|
+
try:
|
|
187
|
+
parent = subprocess.check_output(
|
|
188
|
+
["git", "-C", str(root), "rev-parse", "--show-superproject-working-tree"],
|
|
189
|
+
text=True,
|
|
190
|
+
).strip()
|
|
191
|
+
return bool(parent)
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
131
196
|
def infer_change_id_from_branch(branch: Optional[str]) -> Optional[str]:
|
|
132
197
|
if not branch:
|
|
133
198
|
return None
|
|
@@ -137,6 +202,103 @@ def infer_change_id_from_branch(branch: Optional[str]) -> Optional[str]:
|
|
|
137
202
|
return m.group(2)
|
|
138
203
|
|
|
139
204
|
|
|
205
|
+
def parse_archived_change_dir_name(entry_name: str) -> Optional[Tuple[str, str, str]]:
|
|
206
|
+
m = re.match(r"^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$", entry_name or "")
|
|
207
|
+
if not m:
|
|
208
|
+
return None
|
|
209
|
+
change_id = (m.group(2) or "").strip()
|
|
210
|
+
if not CHANGE_ID_RE.match(change_id):
|
|
211
|
+
return None
|
|
212
|
+
return (m.group(1) or "", change_id, m.group(3) or "")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def find_archived_change(root: Path, change_id: str) -> Optional[Path]:
|
|
216
|
+
archive_root = root / "changes" / "archive"
|
|
217
|
+
if not archive_root.exists():
|
|
218
|
+
return None
|
|
219
|
+
matches: List[Path] = []
|
|
220
|
+
for entry in archive_root.iterdir():
|
|
221
|
+
if not entry.is_dir():
|
|
222
|
+
continue
|
|
223
|
+
parsed = parse_archived_change_dir_name(entry.name)
|
|
224
|
+
if parsed and parsed[1] == change_id:
|
|
225
|
+
matches.append(entry)
|
|
226
|
+
if not matches:
|
|
227
|
+
return None
|
|
228
|
+
return sorted(matches, key=lambda item: item.name)[-1]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def classify_finish_lifecycle_event(ev: Dict[str, Any]) -> Optional[Tuple[str, str]]:
|
|
232
|
+
event_type = str((ev or {}).get("type") or "").strip()
|
|
233
|
+
if not event_type:
|
|
234
|
+
return None
|
|
235
|
+
if event_type == "finish_local":
|
|
236
|
+
return ("local", "")
|
|
237
|
+
if event_type == "finish_failed":
|
|
238
|
+
return ("failed", str((ev or {}).get("payload", {}).get("error") or ""))
|
|
239
|
+
if event_type == "finish_cleanup_pending":
|
|
240
|
+
payload = (ev or {}).get("payload") or {}
|
|
241
|
+
return ("cleanup_pending", str(payload.get("reason") or payload.get("cleanup") or ""))
|
|
242
|
+
if event_type == "finish":
|
|
243
|
+
return ("done", "")
|
|
244
|
+
if event_type != "finish_done":
|
|
245
|
+
return None
|
|
246
|
+
payload = (ev or {}).get("payload")
|
|
247
|
+
if not isinstance(payload, dict):
|
|
248
|
+
payload = {}
|
|
249
|
+
push_present = "push" in payload
|
|
250
|
+
push_completed = payload.get("push") is True if push_present else None
|
|
251
|
+
cleanup = str(payload.get("cleanup") or "").strip()
|
|
252
|
+
if push_completed is False or cleanup == "not_requested":
|
|
253
|
+
return ("local", "")
|
|
254
|
+
if not cleanup or cleanup in FINISH_DONE_COMPLETED_CLEANUPS:
|
|
255
|
+
return ("done", "")
|
|
256
|
+
reason = cleanup[len("skipped:") :] if cleanup.startswith("skipped:") else cleanup
|
|
257
|
+
return ("cleanup_pending", reason)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def summarize_finish_state(change_dir: Path) -> str:
|
|
261
|
+
metrics_path = change_dir / "metrics.json"
|
|
262
|
+
if not metrics_path.exists():
|
|
263
|
+
return ""
|
|
264
|
+
try:
|
|
265
|
+
metrics = json.loads(read_text(metrics_path))
|
|
266
|
+
except Exception:
|
|
267
|
+
return ""
|
|
268
|
+
events = metrics.get("events")
|
|
269
|
+
if not isinstance(events, list):
|
|
270
|
+
return ""
|
|
271
|
+
finish_state = ""
|
|
272
|
+
for ev in events:
|
|
273
|
+
event_type = str((ev or {}).get("type") or "").strip()
|
|
274
|
+
if not event_type:
|
|
275
|
+
continue
|
|
276
|
+
if finish_state == "done" and event_type not in ("finish_done", "finish"):
|
|
277
|
+
continue
|
|
278
|
+
classified = classify_finish_lifecycle_event(ev)
|
|
279
|
+
if not classified:
|
|
280
|
+
continue
|
|
281
|
+
finish_state = classified[0]
|
|
282
|
+
return finish_state
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def terminated_change_message(root: Path, change_id: str) -> Optional[str]:
|
|
286
|
+
change_dir = root / "changes" / change_id
|
|
287
|
+
if change_dir.exists() and summarize_finish_state(change_dir) == "done":
|
|
288
|
+
return (
|
|
289
|
+
f"change/{change_id} already reached finish, but archive/push closeout is still pending; "
|
|
290
|
+
f"rerun `aiws change finish {change_id} --push` instead of continuing development on this branch"
|
|
291
|
+
)
|
|
292
|
+
archived = find_archived_change(root, change_id)
|
|
293
|
+
if archived:
|
|
294
|
+
handoff = archived / "handoff.md"
|
|
295
|
+
details = f"change/{change_id} is already archived at {archived.relative_to(root)}"
|
|
296
|
+
if handoff.exists():
|
|
297
|
+
details += f" (handoff: {handoff.relative_to(root)})"
|
|
298
|
+
return details + "; create a new follow-up change instead of reusing this branch"
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
140
302
|
def has_truth_files(root: Path) -> Tuple[bool, List[str]]:
|
|
141
303
|
required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]
|
|
142
304
|
missing = [f for f in required if not (root / f).exists()]
|
|
@@ -487,6 +649,45 @@ def validate_change(
|
|
|
487
649
|
else:
|
|
488
650
|
warnings.append(msg)
|
|
489
651
|
|
|
652
|
+
if subs:
|
|
653
|
+
targets_path = change_dir / "submodules.targets"
|
|
654
|
+
if targets_path.exists() and targets_path.stat().st_size > 0:
|
|
655
|
+
targets, _ = parse_submodule_targets(targets_path)
|
|
656
|
+
base_branch = str(meta.get("base_branch") or "").strip() or "main"
|
|
657
|
+
for _, sub_path in subs:
|
|
658
|
+
target = targets.get(sub_path)
|
|
659
|
+
if not target:
|
|
660
|
+
continue
|
|
661
|
+
target_branch = target[0] if target[0] != "." else base_branch
|
|
662
|
+
remote = target[1] or "origin"
|
|
663
|
+
gitlink_sha = resolve_change_gitlink_commit(root, change_id, sub_path)
|
|
664
|
+
if not gitlink_sha:
|
|
665
|
+
warnings.append(f"unable to resolve gitlink commit for submodule path: {sub_path}")
|
|
666
|
+
continue
|
|
667
|
+
sub_root = root / sub_path
|
|
668
|
+
if not sub_root.exists():
|
|
669
|
+
warnings.append(f"submodule path not initialized locally (skip target consistency check): {sub_path}")
|
|
670
|
+
continue
|
|
671
|
+
if not git_revision_exists(sub_root, gitlink_sha):
|
|
672
|
+
warnings.append(
|
|
673
|
+
f"{sub_path}: gitlink commit {gitlink_sha} is not available in local submodule clone; run `git submodule update --init --recursive`"
|
|
674
|
+
)
|
|
675
|
+
continue
|
|
676
|
+
branch_ref = resolve_branch_ref(sub_root, remote, target_branch)
|
|
677
|
+
if not branch_ref:
|
|
678
|
+
warnings.append(
|
|
679
|
+
f"{sub_path}: cannot verify target branch history locally (missing {remote}/{target_branch} and local branch {target_branch})"
|
|
680
|
+
)
|
|
681
|
+
continue
|
|
682
|
+
if git_is_ancestor(sub_root, gitlink_sha, branch_ref):
|
|
683
|
+
continue
|
|
684
|
+
errors.append(
|
|
685
|
+
f"{sub_path}: gitlink commit {gitlink_sha} is not contained in declared target branch {remote}/{target_branch}"
|
|
686
|
+
)
|
|
687
|
+
errors.append(
|
|
688
|
+
f"hint: fix `.gitmodules` / `changes/{change_id}/submodules.targets`, or move the submodule gitlink onto a commit reachable from {remote}/{target_branch}"
|
|
689
|
+
)
|
|
690
|
+
|
|
490
691
|
def placeholder_scan(rel: str, text: str) -> None:
|
|
491
692
|
if "{{CHANGE_ID}}" in text or "{{TITLE}}" in text or "{{CREATED_AT}}" in text:
|
|
492
693
|
errors.append(f"unrendered template placeholders in {rel}")
|
|
@@ -942,6 +1143,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
942
1143
|
|
|
943
1144
|
branch_arg = (args.branch or "").strip()
|
|
944
1145
|
branch = branch_arg or current_branch(root)
|
|
1146
|
+
inferred_from_branch = False
|
|
1147
|
+
submodule_repo = is_submodule_repo(root)
|
|
945
1148
|
if not change_id:
|
|
946
1149
|
# Detached HEAD during rebase/merge; do not block unless CI passes --branch.
|
|
947
1150
|
if not branch:
|
|
@@ -950,26 +1153,40 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
950
1153
|
allow = {b.strip() for b in (args.allow_branches or "").split(",") if b.strip()}
|
|
951
1154
|
if branch in allow:
|
|
952
1155
|
return 0
|
|
1156
|
+
if submodule_repo and PIN_BRANCH_RE.match(branch):
|
|
1157
|
+
return 0
|
|
953
1158
|
inferred = infer_change_id_from_branch(branch)
|
|
954
1159
|
if not inferred:
|
|
955
1160
|
eprint(f"error: branch must be change/<change-id> (current: {branch})")
|
|
956
1161
|
eprint("hint: switch/create: git switch -c change/<change-id>")
|
|
957
1162
|
return 2
|
|
958
1163
|
change_id = inferred
|
|
1164
|
+
inferred_from_branch = True
|
|
959
1165
|
else:
|
|
960
1166
|
# If CI provides --branch, cross-check branch naming even when --change-id is provided.
|
|
961
1167
|
if branch_arg:
|
|
962
1168
|
allow = {b.strip() for b in (args.allow_branches or "").split(",") if b.strip()}
|
|
963
1169
|
if branch_arg not in allow:
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1170
|
+
if submodule_repo and PIN_BRANCH_RE.match(branch_arg):
|
|
1171
|
+
branch_arg = ""
|
|
1172
|
+
else:
|
|
1173
|
+
inferred = infer_change_id_from_branch(branch_arg)
|
|
1174
|
+
if not inferred:
|
|
1175
|
+
eprint(f"error: branch must be change/<change-id> (current: {branch_arg})")
|
|
1176
|
+
return 2
|
|
1177
|
+
if inferred != change_id:
|
|
1178
|
+
eprint(f"error: change-id does not match branch (branch={branch_arg}, change_id={change_id})")
|
|
1179
|
+
return 2
|
|
971
1180
|
|
|
972
1181
|
change_dir = root / "changes" / change_id
|
|
1182
|
+
terminated = terminated_change_message(root, change_id) if (inferred_from_branch or bool(branch_arg)) else None
|
|
1183
|
+
if terminated:
|
|
1184
|
+
eprint(f"error: {terminated}")
|
|
1185
|
+
eprint(
|
|
1186
|
+
f"hint: stop using change/{change_id} for new work; rerun `aiws change finish {change_id} --push` "
|
|
1187
|
+
"or archive manually only for local recovery"
|
|
1188
|
+
)
|
|
1189
|
+
return 2
|
|
973
1190
|
if not change_dir.exists():
|
|
974
1191
|
eprint(f"error: missing change dir: {change_dir}")
|
|
975
1192
|
eprint(f"hint: create: aiws change new {change_id} --no-design")
|