@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.
@@ -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 会尝试使用 `git switch/checkout --recurse-submodules` submodules 工作区跟随 superproject 指针;若 git 不支持该选项,会降级为普通切分支并提示手工更新 submodules。
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: 16
15
- - guidance rules: 16
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
- | `guidance_finish_retry` | `governance_rule_id` in [`finish_failed`, `finish_local`] | 收尾未完整完成:检查 push / cleanup / submodule 状态后重跑 `$ws-finish` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipper/aiws-spec",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "AIWS spec and templates (single source of truth).",
5
5
  "type": "module",
6
6
  "files": [
@@ -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:在目标分支所在 worktree 执行(`aiws change finish` 会提示正确的 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
- 1) (推荐)先跑一次门禁并落盘证据:
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
- 1.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:
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
- - 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
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) (推荐)门禁校验并落盘证据:`aiws validate . --stamp`(未安装全局 aiws 时可用 `npx @aipper/aiws validate . --stamp`)。
35
- 2.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:`aiws change evidence <change-id>`(未安装全局 aiws 时可用 `npx @aipper/aiws change evidence <change-id>`)。
36
- 2.2) (可选)生成状态快照(建议):`aiws change state <change-id> --write`。
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:在目标分支所在 worktree 执行(`aiws change finish` 会提示正确的 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
- 1) (推荐)先跑一次门禁并落盘证据:
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
- 1.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:
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
- - 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
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) (推荐)门禁校验并落盘证据:`aiws validate . --stamp`(未安装全局 aiws 时可用 `npx @aipper/aiws validate . --stamp`)。
38
- 2.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:`aiws change evidence <change-id>`(未安装全局 aiws 时可用 `npx @aipper/aiws change evidence <change-id>`)。
39
- 2.2) (可选)生成状态快照(建议):`aiws change state <change-id> --write`。
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
- - 若存在 `.gitmodules`:必须为每个 submodule 配置 `submodule.<name>.branch`(否则先运行 `/ws-submodule-setup` 并提交 `.gitmodules`)
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) (推荐)门禁校验并落盘证据:`aiws validate . --stamp`(未安装全局 aiws 时可用 `npx @aipper/aiws validate . --stamp`)。
38
- 2.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:`aiws change evidence <change-id>`(未安装全局 aiws 时可用 `npx @aipper/aiws change evidence <change-id>`)。
39
- 2.2) (可选)生成状态快照(建议):`aiws change state <change-id> --write`。
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:在目标分支所在 worktree 执行(`aiws change finish` 会提示正确的 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
- 1) (推荐)先跑一次门禁并落盘证据:
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
- 1.1) (强烈建议)收敛持久证据并回填 `Evidence_Path`:
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 分支;仅在存在 `.gitmodules` 时有意义)
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
- inferred = infer_change_id_from_branch(branch_arg)
965
- if not inferred:
966
- eprint(f"error: branch must be change/<change-id> (current: {branch_arg})")
967
- return 2
968
- if inferred != change_id:
969
- eprint(f"error: change-id does not match branch (branch={branch_arg}, change_id={change_id})")
970
- return 2
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")