@aipper/aiws 0.0.25 → 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/README.md CHANGED
@@ -1,37 +1,68 @@
1
1
  # @aipper/aiws
2
2
 
3
- AI Workspace CLI:把 Claude Code / OpenCode / Codex / iFlow 对齐到同一套“真值文件 + 可审计工作流”,降低规则漂移。
3
+ `@aipper/aiws` 是面向 AI coding workflow CLI。
4
4
 
5
- 核心能力:
6
- - 初始化/更新模板:`aiws init` / `aiws update`
7
- - 强门禁校验:`aiws validate`(可选证据落盘 `--stamp`)
8
- - 回滚:`aiws rollback`(从 `.aiws/backups/` 恢复)
9
- - 变更工件工作流(脱离 dotfiles):`aiws change ...`
10
- - 本地治理看板:`aiws dashboard serve`
11
- - OpenCode/oMo 运行时探测:`aiws opencode status`
12
- - Git hooks:`aiws hooks install/status`
13
- - 跨工具 native 投影:Codex repo skills、Claude skills、OpenCode skills/commands
14
- - Codex:repo skills(推荐)+ 可选全局 skills 安装
5
+ 它负责把真值文件、变更工件、门禁和跨工具入口落到仓库里,让 Claude Code、OpenCode、Codex 可以共享同一套项目约束,而不是每个工具各写一份 prompt。
15
6
 
16
- 真值来源(SSOT):`@aipper/aiws-spec`(模板与契约)。
7
+ ## 当前能力
8
+
9
+ - `aiws init` / `aiws update`:初始化或升级 workspace 模板
10
+ - `aiws validate`:校验模板漂移与门禁,可选 `--stamp`
11
+ - `aiws rollback`:从 `.aiws/backups/` 回滚
12
+ - `aiws change ...`:变更工件工作流
13
+ - `aiws dashboard serve`:本地 change/dashboard API
14
+ - `aiws hooks install/status`
15
+ - `aiws opencode status`
16
+ - `aiws codex install-skills/status-skills/uninstall-skills`
17
+
18
+ 支持工具:
19
+ - Claude Code
20
+ - OpenCode
21
+ - Codex
22
+
23
+ 说明:
24
+ - `ws-dev-lite` 是当前推荐的小改动入口
17
25
 
18
26
  ## 安装
19
27
 
20
- 二选一:
21
- - 全局安装(推荐,适合长期使用):`npm i -g @aipper/aiws`
22
- - 临时运行(不污染全局版本):`npx @aipper/aiws <command>`
28
+ ```bash
29
+ npm i -g @aipper/aiws
30
+ #
31
+ npx @aipper/aiws --help
32
+ ```
23
33
 
24
34
  ## 快速开始
25
35
 
26
- 在任意 git 仓库根目录:
27
36
  ```bash
28
37
  aiws init .
29
38
  aiws hooks install .
30
39
 
31
- # 建议:用 change 工作流生成可审计工件并切分支
32
40
  aiws change start demo-change --no-design --hooks
41
+ aiws change validate demo-change --strict
42
+ aiws validate . --stamp
43
+ ```
44
+
45
+ 初始化后会生成:
46
+ - 真值文件:`AI_PROJECT.md`、`REQUIREMENTS.md`、`AI_WORKSPACE.md`
47
+ - 工件目录:`changes/`
48
+ - AI 入口:`.agents/skills/`、`.claude/skills/`、`.claude/commands/`、`.opencode/skills/`、`.opencode/commands/`
49
+ - hooks:`.githooks/`
50
+
51
+ ## 推荐用法
52
+
53
+ AI 工具内:
54
+ - 不确定该从哪里开始:`$using-aiws`
55
+ - simple/local 单点修复:`$ws-dev-lite`
56
+ - 常规实现:`$ws-dev`
57
+ - 中大型任务:`$ws-plan` → `$ws-plan-verify` → `$ws-dev`
58
+ - 提交前:`$ws-review` / `$ws-commit`
59
+ - 交付前:`$ws-deliver` / `$ws-finish`
33
60
 
34
- # 提交前门禁(并落盘证据)
61
+ shell 内:
62
+
63
+ ```bash
64
+ aiws change start <change-id> --no-design
65
+ aiws change validate <change-id> --strict
35
66
  aiws validate . --stamp
36
67
  ```
37
68
 
@@ -43,54 +74,51 @@ aiws update [path]
43
74
  aiws validate [path] [--stamp]
44
75
  aiws rollback [path] <timestamp|latest>
45
76
 
46
- aiws change <list|new|start|status|next|sync|validate|archive|templates>
77
+ aiws change new <change-id> [--no-design]
78
+ aiws change start <change-id> [--switch|--no-switch|--worktree]
79
+ aiws change status [change-id]
80
+ aiws change next [change-id]
81
+ aiws change sync [change-id]
82
+ aiws change validate [change-id] [--strict] [--check-evidence] [--check-scope]
83
+ aiws change evidence [change-id]
84
+ aiws change archive [change-id]
85
+
47
86
  aiws dashboard serve [--host 127.0.0.1] [--port 3456]
87
+ aiws hooks install [path]
88
+ aiws hooks status [path]
48
89
  aiws opencode status [path]
49
- aiws hooks <install|status>
50
- aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status|uninstall-prompts>
90
+ aiws codex install-skills
91
+ aiws codex status-skills
92
+ aiws codex uninstall-skills
51
93
  ```
52
94
 
53
- Dashboard 说明:
54
- - 读取本地 `changes/<id>/`、`.agentdocs/tmp/...` 与 spec 中的阶段契约 / governance / review-gate 真值,展示 phase、collaboration、strict blockers、review gates、next advice
55
- - 阶段治理视图会基于共享 change 状态细分 `ws-review` / `ws-commit` / `ws-deliver` / `ws-finish` / `ws-handoff`
56
- - 本地 API:
57
- - `/api/changes`
58
- - `/api/workflow-stages`
59
- - `/api/change/<id>/validate?strict=1`
60
-
61
- ## Codex(repo skills 优先)
62
-
63
- - 若你不想先判断当前该走 plan、dev、review 还是 finish:先用 `$using-aiws`
64
- - `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$p-tasks-plan` / `$ws-handoff` / `$ws-dev` / `$ws-review` / `$ws-commit`
65
- - 现在也会生成默认 router 入口:`$using-aiws`(Claude/OpenCode 对应 `/using-aiws`)
66
- - `aiws init .` 也会同步投影 `Claude Code` native skills(`.claude/skills/`)与 `OpenCode` native skills / commands(`.opencode/skills/` / `.opencode/commands/`),并保留 `.claude/commands/`、`.opencode/command/` 兼容入口
67
- - 高风险改动或准备 finish 时,建议补跑:`$ws-spec-review`、`$ws-quality-review`、`$ws-verify-before-complete`
68
- - 交付收尾(submodules+superproject 分步提交 + 安全合并):`$ws-deliver`
69
- - 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward;现在会硬阻断缺失的 review gate)
70
- - 可选:安装全局 skills 到 `~/.agents/skills/`:
71
- - `aiws codex install-skills`
72
- - `aiws codex status-skills`
73
- - `aiws codex uninstall-skills`
74
- - 测试/沙箱场景可用 `--dir <path>`;若设置 `CODEX_HOME`,全局 skills 会解析到 `$CODEX_HOME/.agents/skills`
75
- - legacy prompts(deprecated,仅兼容):`aiws codex install-prompts`
76
-
77
- ## OpenCode / oMo
78
-
79
- - `aiws init .` 会生成 `.opencode/skills/`、`.opencode/commands/` 与 `.opencode/oh-my-opencode.json.example`
80
- - `aiws opencode status .` 会检查当前项目是否真正启用了 `.opencode/oh-my-opencode.json`,并输出 `oMo-enabled` 或 `standard-opencode`
81
- - 若只存在 `.opencode/oh-my-opencode.json.example`,说明项目已具备建议配置,但还没有真正启用 oMo
82
- - 启用后,`using-aiws` / `ws-preflight` / `ws-plan` / `ws-review` 会优先按 `planner-sisyphus` / `explore` / `librarian` / `oracle` 路由;缺失时按 fallback 退回标准 OpenCode
83
-
84
- ## 证据落盘与协作
85
-
86
- - `aiws validate --stamp` 会写入:`.agentdocs/tmp/aiws-validate/*.json`(默认被 `.gitignore` 忽略)
87
- - `$ws-review` 会落盘:`.agentdocs/tmp/review/codex-review.md`(默认被 `.gitignore` 忽略)
88
- - `change status` / `change next` / dashboard 会显式显示:`spec_review` / `quality_review` / `validate_stamp` / `verify_before_complete`
89
- - `aiws change validate <id> --strict --check-scope` 会按计划文件的 `## Scope` / `### In Scope` 检查越界改动;最新结果也会显示在 `change status` / `change next` / dashboard
90
- - 交付前建议执行:`aiws change validate <id> --strict --check-evidence`;`aiws change finish <id>` 也会对同一套 gate 做硬阻断
91
- - 建议把“可协作/可审计”的内容放进 `changes/<change-id>/`(proposal/tasks/design)并提交;`.agentdocs/tmp/` 作为本地缓存更稳
95
+ ## Dashboard
96
+
97
+ `aiws dashboard serve` 会显示:
98
+ - change phase
99
+ - strict blockers
100
+ - review gates
101
+ - scope gate
102
+ - collaboration 统计
103
+ - next advice
104
+
105
+ 本地 API:
106
+ - `/api/changes`
107
+ - `/api/workflow-stages`
108
+ - `/api/change/<id>/validate?strict=1`
109
+
110
+ ## 跨工具入口
111
+
112
+ `aiws init .` 会把入口投影到:
113
+ - Codex:`.agents/skills/*`
114
+ - Claude:`.claude/skills/*` + `.claude/commands/*`
115
+ - OpenCode:`.opencode/skills/*` + `.opencode/commands/*` + `.opencode/command/*`
116
+
117
+ 目标是统一 workflow 入口,不保证工具自动发现或自动加载。
92
118
 
93
119
  ## 运行要求
94
120
 
95
- - Node.js:>= 20
96
- - `aiws validate`:需要 `python3`(用于执行 `tools/ws_change_check.py` 与 `tools/requirements_contract.py`)
121
+ - Node.js >= 20
122
+ - `aiws validate` 需要 `python3`
123
+
124
+ 更多仓库级说明见根 README:[`../../README.md`](../../README.md)
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.25",
4
- "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
3
+ "version": "0.0.27",
4
+ "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aiws": "./bin/aiws.js"
8
8
  },
9
9
  "dependencies": {
10
- "@aipper/aiws-spec": "0.0.25"
10
+ "@aipper/aiws-spec": "0.0.27"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/cli.js CHANGED
@@ -553,7 +553,7 @@ Notes:
553
553
  you can omit <change-id> for status/next/validate/sync/archive/finish.
554
554
  - Execution quality gate first: aiws change validate [change-id] --strict
555
555
  (equivalent to running $ws-plan-verify in AI tools).
556
- - status/next now prioritize quality-gate guidance before coding ($ws-dev).
556
+ - status/next now prioritize quality-gate guidance before coding ($ws-dev-lite / $ws-dev).
557
557
  - start refuses to switch/create worktree with a dirty working tree (unless --allow-dirty).
558
558
  - finish --push is submodule-aware: when .gitmodules exists it pushes submodules first, then the target branch, then safely cleans the separate change worktree if possible.
559
559
  - If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
@@ -71,6 +71,13 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
71
71
  advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
72
72
  advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
73
73
  }
74
+ if (status.terminatedReuse) {
75
+ advice.actions.push(status.terminatedReuse.message);
76
+ advice.prohibitions.push(`不要继续在 change/${changeId} 上开发或提交新改动`);
77
+ advice.recommended.push(`优先续跑收尾:\`aiws change finish ${changeId} --push\``);
78
+ advice.recommended.push(`仅在你明确只需要本地归档恢复时,才手工运行 \`aiws change archive ${changeId}\``);
79
+ return advice;
80
+ }
74
81
 
75
82
  const proposalPath = path.join(deps.changeDirAbs(gitRoot, changeId), "proposal.md");
76
83
  const tasksPath = path.join(deps.changeDirAbs(gitRoot, changeId), "tasks.md");
@@ -96,6 +103,9 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
96
103
  advice.actions.push(deps.scopeGateFailureAction(changeId));
97
104
  }
98
105
  if (status.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${status.tasks.unchecked} 项)`);
106
+ if (status.blockersStrict.length === 0 && status.tasks.unchecked > 0) {
107
+ advice.actions.push("质量门通过后继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
108
+ }
99
109
  if ((status.collaboration?.counts?.analysis || 0) > 0 && (status.collaboration?.counts?.review || 0) === 0) {
100
110
  advice.actions.push("已有委托分析但还没有 review 汇总结论:建议在 `changes/<id>/review/` 落盘结论后再交付");
101
111
  }
@@ -104,7 +114,7 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
104
114
  }
105
115
 
106
116
  if (status.blockersStrict.length > 0 && advice.actions.length === 0) {
107
- advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
117
+ advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
108
118
  }
109
119
 
110
120
  if (status.blockersStrict.length === 0 && status.tasks.unchecked === 0) {
@@ -122,7 +132,7 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
122
132
  }
123
133
  if ((status.collaboration?.counts?.review || 0) > 1) advice.recommended.push("存在多份 review:交付前建议在 handoff 或 review 汇总里收敛最终结论");
124
134
  } else if (advice.actions.length === 0) {
125
- advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
135
+ advice.recommended.push("继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`(小步实现 + 可复现验证)");
126
136
  if ((status.collaboration?.counts?.analysis || 0) > 0) advice.recommended.push("已有委托分析:进入实现前先收敛 `changes/<id>/analysis/` 结论");
127
137
  if ((status.collaboration?.counts?.patches || 0) > 0) advice.recommended.push("已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
128
138
  }
@@ -153,7 +163,9 @@ export function renderChangeStateMarkdown(input) {
153
163
  lines.push("");
154
164
  lines.push("## Phase");
155
165
  lines.push(`- Phase: \`${input.status.phase}\``);
156
- lines.push(`- Tasks: ${input.status.tasks.done}/${input.status.tasks.total} (unchecked=${input.status.tasks.unchecked})`);
166
+ lines.push(
167
+ `- Tasks: ${input.status.tasks.done}/${input.status.tasks.total} (unchecked=${input.status.tasks.unchecked}, optional_open=${input.status.tasks.optionalUnchecked || 0}, n_a=${input.status.tasks.naCount || 0})`,
168
+ );
157
169
  lines.push("");
158
170
  lines.push("## Bindings");
159
171
  if (input.status.reqId) lines.push(`- Req_ID: \`${input.status.reqId}\``);
@@ -293,7 +293,9 @@ export function buildDeliverySummary(options) {
293
293
  lines.push("");
294
294
  lines.push("## Status");
295
295
  lines.push(`- Phase: \`${status.phase}\``);
296
- lines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
296
+ lines.push(
297
+ `- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked}, optional_open=${status.tasks.optionalUnchecked || 0}, n_a=${status.tasks.naCount || 0})`,
298
+ );
297
299
  lines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
298
300
  lines.push("");
299
301
  lines.push("## Collaboration");
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
+ import { checkboxStats } from "./change-task-stats.js";
3
4
 
4
5
  /**
5
6
  * @param {{
@@ -144,16 +145,17 @@ export async function runChangeSyncWorkflow(input, deps) {
144
145
  */
145
146
  export async function runChangeArchiveWorkflow(input, deps) {
146
147
  const { gitRoot, changeId, changeDir } = input;
147
- await deps.changeValidateCommand({ changeId, strict: true, allowTruthDrift: input.force });
148
+ await deps.changeValidateCommand({ changeId, strict: true, allowTruthDrift: input.force, allowFinishedUnarchived: true });
148
149
 
149
150
  const tasksAbs = path.join(changeDir, "tasks.md");
150
151
  if (await deps.pathExists(tasksAbs)) {
151
152
  const tasksText = await deps.readText(tasksAbs);
152
- if (/- \[ \]/.test(tasksText)) {
153
+ const taskStats = checkboxStats(tasksText);
154
+ if (taskStats.unchecked > 0) {
153
155
  if (!input.force) {
154
- throw new deps.UserError("tasks.md still has unchecked tasks; complete them or pass --force");
156
+ throw new deps.UserError("tasks.md still has unchecked required tasks; complete them or pass --force");
155
157
  }
156
- console.error("warn: tasks.md still has unchecked tasks; continuing due to --force");
158
+ console.error("warn: tasks.md still has unchecked required tasks; continuing due to --force");
157
159
  }
158
160
  }
159
161
 
@@ -208,12 +210,27 @@ export async function runChangeArchiveWorkflow(input, deps) {
208
210
  ) + "\n",
209
211
  );
210
212
 
213
+ const archivedToRel = path.relative(gitRoot, dest);
214
+ const handoffRel = path.relative(gitRoot, handoffPath);
215
+ const metaRel = path.relative(gitRoot, metaPath);
216
+ const stampRel = path.relative(gitRoot, stampPath);
217
+
211
218
  /** @type {string[]} */
212
219
  const outputLines = [];
213
220
  outputLines.push(`✓ aiws change archive: ${changeId}`);
214
- outputLines.push(`archived_to: ${path.relative(gitRoot, dest)}`);
215
- outputLines.push(`handoff: ${path.relative(gitRoot, handoffPath)}`);
216
- if (metaUpdated) outputLines.push(`meta_updated: ${path.relative(gitRoot, metaPath)}`);
217
- outputLines.push(`stamp: ${path.relative(gitRoot, stampPath)}`);
218
- return { output: outputLines.join("\n") + "\n" };
221
+ outputLines.push(`archived_to: ${archivedToRel}`);
222
+ outputLines.push(`handoff: ${handoffRel}`);
223
+ if (metaUpdated) outputLines.push(`meta_updated: ${metaRel}`);
224
+ outputLines.push(`stamp: ${stampRel}`);
225
+ return {
226
+ output: outputLines.join("\n") + "\n",
227
+ archivedTo: dest,
228
+ archivedToRel,
229
+ handoffPath,
230
+ handoffRel,
231
+ metaPath: metaUpdated ? metaPath : "",
232
+ metaRel: metaUpdated ? metaRel : "",
233
+ stampPath,
234
+ stampRel,
235
+ };
219
236
  }
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { checkboxStats } from "./change-task-stats.js";
2
3
 
3
4
  /**
4
5
  * @param {{
@@ -90,15 +91,21 @@ export async function renderChangeNextOutput(input, deps) {
90
91
 
91
92
  /** @type {string[]} */
92
93
  const lines = [];
94
+ if (status.terminatedReuse) {
95
+ for (const action of advice.actions) lines.push(`- ${action}`);
96
+ for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
97
+ for (const prohibition of advice.prohibitions) lines.push(`- ${prohibition}`);
98
+ return lines.join("\n") + "\n";
99
+ }
93
100
  if (advice.actions.length > 0) {
94
101
  lines.push(`- ${deps.planVerifyHint(changeId)}`);
95
102
  for (const action of advice.actions) lines.push(`- ${action}`);
96
103
  if (status.blockersStrict.length > 0) {
97
104
  lines.push(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
98
- lines.push("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
105
+ lines.push("- 质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
99
106
  } else if (status.tasks.unchecked > 0) {
100
107
  lines.push(`- 处理完上述事项后复跑质量门:\`aiws change validate ${changeId} --strict\``);
101
- lines.push("- 质量门通过后继续开发:在 AI 工具中运行 `$ws-dev`");
108
+ lines.push("- 质量门通过后继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
102
109
  } else {
103
110
  for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
104
111
  }
@@ -107,21 +114,16 @@ export async function renderChangeNextOutput(input, deps) {
107
114
 
108
115
  lines.push(`- ${deps.planVerifyHint(changeId)}`);
109
116
  if (prog.unchecked > 0) {
110
- lines.push("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
117
+ lines.push("- 若仍需继续编码,先通过质量门;simple/local 单点修复优先 `$ws-dev-lite`,否则 `$ws-dev`");
111
118
  } else {
112
119
  for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
113
120
  }
114
121
  lines.push("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
115
- lines.push(`- 归档:\`aiws change archive ${changeId}\``);
122
+ const recommendedStage = String(status.governance?.recommendedStage || "");
123
+ if (recommendedStage === "ws-finish") {
124
+ lines.push(`- 收尾:\`aiws change finish ${changeId} --push\`(成功后会自动归档并生成 handoff)`);
125
+ } else if (recommendedStage === "ws-handoff") {
126
+ lines.push("- 交接:运行 `$ws-handoff` 检查归档 handoff;当前已无额外 finish 工作,无需重复执行 `aiws change finish`");
127
+ }
116
128
  return lines.join("\n") + "\n";
117
129
  }
118
-
119
- /**
120
- * @param {string} text
121
- */
122
- function checkboxStats(text) {
123
- const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
124
- const done = (text.match(/^- \[[xX]\]/gm) || []).length;
125
- const unchecked = (text.match(/^- \[ \]/gm) || []).length;
126
- return { total, done, unchecked, hasCheckboxes: total > 0 };
127
- }
@@ -20,14 +20,6 @@ export async function checkWorktreePrereqs(gitRoot, deps) {
20
20
  return { ok: true };
21
21
  }
22
22
 
23
- /**
24
- * @param {{ stdout: string, stderr: string }} res
25
- */
26
- export function outputMentionsRecurseSubmodules(res) {
27
- const out = `${res.stderr || ""}\n${res.stdout || ""}`;
28
- return out.includes("recurse-submodules");
29
- }
30
-
31
23
  /**
32
24
  * @param {string} changeDir
33
25
  * @param {string} baseBranch
@@ -85,34 +77,24 @@ export function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
85
77
  */
86
78
  export async function switchOrCreateStartBranch(gitRoot, options, deps) {
87
79
  if (options.hasBranch) {
88
- let sw = await deps.runCommand("git", ["switch", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), options.branch], { cwd: gitRoot });
89
- if (sw.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
90
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
91
- sw = await deps.runCommand("git", ["switch", options.branch], { cwd: gitRoot });
80
+ if (options.hasGitmodules) {
81
+ console.error("warn: .gitmodules detected; switching only the superproject branch (submodules stay on their current/pin branches).");
92
82
  }
83
+ let sw = await deps.runCommand("git", ["switch", options.branch], { cwd: gitRoot });
93
84
  if (sw.code === 0) return;
94
85
 
95
- let co = await deps.runCommand("git", ["checkout", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), options.branch], { cwd: gitRoot });
96
- if (co.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(co)) {
97
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
98
- co = await deps.runCommand("git", ["checkout", options.branch], { cwd: gitRoot });
99
- }
86
+ let co = await deps.runCommand("git", ["checkout", options.branch], { cwd: gitRoot });
100
87
  if (co.code !== 0) throw new deps.UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
101
88
  return;
102
89
  }
103
90
 
104
- let sw = await deps.runCommand("git", ["switch", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), "-c", options.branch], { cwd: gitRoot });
105
- if (sw.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
106
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
107
- sw = await deps.runCommand("git", ["switch", "-c", options.branch], { cwd: gitRoot });
91
+ if (options.hasGitmodules) {
92
+ console.error("warn: .gitmodules detected; creating and switching only the superproject branch (submodules stay on their current/pin branches).");
108
93
  }
94
+ let sw = await deps.runCommand("git", ["switch", "-c", options.branch], { cwd: gitRoot });
109
95
  if (sw.code === 0) return;
110
96
 
111
- let co = await deps.runCommand("git", ["checkout", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), "-b", options.branch], { cwd: gitRoot });
112
- if (co.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(co)) {
113
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
114
- co = await deps.runCommand("git", ["checkout", "-b", options.branch], { cwd: gitRoot });
115
- }
97
+ let co = await deps.runCommand("git", ["checkout", "-b", options.branch], { cwd: gitRoot });
116
98
  if (co.code !== 0) throw new deps.UserError("Failed to create branch.", { details: co.stderr || co.stdout });
117
99
  }
118
100
 
@@ -32,7 +32,9 @@ export async function renderChangeStatusOutput(input, deps) {
32
32
  lines.push(`meta: ${status.metaState}`);
33
33
  if (status.reqId) lines.push(`Req_ID: ${status.reqId}`);
34
34
  if (status.probId) lines.push(`Problem_ID: ${status.probId}`);
35
- lines.push(`tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
35
+ lines.push(
36
+ `tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked}, optional_open=${status.tasks.optionalUnchecked || 0}, n_a=${status.tasks.naCount || 0})`,
37
+ );
36
38
  if (status.reviewSignals) {
37
39
  lines.push(`review_signal: effective=${status.reviewSignals.effectiveCount} source=${status.reviewSignals.source}`);
38
40
  }
@@ -89,15 +91,21 @@ export async function renderChangeStatusOutput(input, deps) {
89
91
 
90
92
  lines.push("");
91
93
  lines.push("Next (recommended):");
94
+ if (status.terminatedReuse) {
95
+ lines.push(`- ${status.terminatedReuse.message}`);
96
+ lines.push(`- rerun \`aiws change finish ${status.changeId} --push\` to resume the unfinished finish/archive flow`);
97
+ lines.push(`- do not continue development or commit on \`change/${status.changeId}\``);
98
+ return lines.join("\n") + "\n";
99
+ }
92
100
  lines.push(`- ${deps.planVerifyHint(status.changeId)}`);
93
101
  if (status.blockersStrict.length > 0) {
94
- lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
102
+ lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
95
103
  if (changeWorktreeHint) lines.push(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${changeWorktreeHint}\``);
96
104
  return lines.join("\n") + "\n";
97
105
  }
98
106
 
99
107
  if (status.tasks.unchecked > 0) {
100
- lines.push("- 继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
108
+ lines.push("- 继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`(小步实现 + 可复现验证)");
101
109
  if ((status.collaboration?.counts?.analysis || 0) > 0) lines.push("- 已有委托分析:在编码前先收敛 `changes/<id>/analysis/` 中的结论");
102
110
  if ((status.collaboration?.counts?.patches || 0) > 0) lines.push("- 已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
103
111
  lines.push("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Parse checkbox tasks with AIWS-specific control markers.
3
+ *
4
+ * Supported markers:
5
+ * - `[optional]`: unchecked optional tasks do not block archive/finish.
6
+ * - `[n/a: ...]` / `[na: ...]` / `[not-applicable: ...]`: task is treated as closed.
7
+ *
8
+ * Returned `total/done/unchecked` are the effective gate counts. Optional open
9
+ * tasks are reported separately via `optionalUnchecked`.
10
+ *
11
+ * @param {string} text
12
+ */
13
+ export function checkboxStats(text) {
14
+ const lines = String(text || "").split(/\r?\n/);
15
+ let total = 0;
16
+ let done = 0;
17
+ let unchecked = 0;
18
+ let totalAll = 0;
19
+ let doneAll = 0;
20
+ let uncheckedAll = 0;
21
+ let optionalTotal = 0;
22
+ let optionalUnchecked = 0;
23
+ let naCount = 0;
24
+
25
+ for (const rawLine of lines) {
26
+ const match = rawLine.match(/^\s*-\s+\[([ xX])\]\s+(.*)$/);
27
+ if (!match) continue;
28
+ totalAll += 1;
29
+ const checked = String(match[1] || "").toLowerCase() === "x";
30
+ const body = String(match[2] || "");
31
+ const isOptional = /\[(?:optional|Optional)\]/.test(body);
32
+ const isNa = /\[(?:n\/a|na|not-applicable)\s*:[^\]]+\]/i.test(body);
33
+
34
+ if (checked) doneAll += 1;
35
+ else uncheckedAll += 1;
36
+
37
+ if (isOptional) {
38
+ optionalTotal += 1;
39
+ if (!checked && !isNa) optionalUnchecked += 1;
40
+ continue;
41
+ }
42
+
43
+ total += 1;
44
+ if (checked || isNa) {
45
+ done += 1;
46
+ if (isNa) naCount += 1;
47
+ } else {
48
+ unchecked += 1;
49
+ }
50
+ }
51
+
52
+ return {
53
+ total,
54
+ done,
55
+ unchecked,
56
+ hasCheckboxes: totalAll > 0,
57
+ totalAll,
58
+ doneAll,
59
+ uncheckedAll,
60
+ optionalTotal,
61
+ optionalUnchecked,
62
+ naCount,
63
+ };
64
+ }
@@ -21,6 +21,7 @@ import { runChangeArchiveWorkflow, runChangeSyncWorkflow } from "./change-lifecy
21
21
  import { renderMetricsSummaryOutput } from "./change-metrics-command.js";
22
22
  import { renderChangeNextOutput } from "./change-next-command.js";
23
23
  import { runChangeStateCommand } from "./change-state-command.js";
24
+ import { checkboxStats } from "./change-task-stats.js";
24
25
  import { runChangeValidateCommand } from "./change-validate-entry.js";
25
26
  import {
26
27
  computeChangeNextAdvice as computeChangeNextAdviceModel,
@@ -350,6 +351,22 @@ function isMeaningfulBullet(bulletLine) {
350
351
  return true;
351
352
  }
352
353
 
354
+ /**
355
+ * @param {string} entryName
356
+ * @returns {{ datePrefix: string, changeId: string, stamp: string } | null}
357
+ */
358
+ function parseArchivedChangeDirName(entryName) {
359
+ const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$/.exec(String(entryName || ""));
360
+ if (!m) return null;
361
+ const changeId = m[2] || "";
362
+ if (!CHANGE_ID_RE.test(changeId)) return null;
363
+ return {
364
+ datePrefix: m[1] || "",
365
+ changeId,
366
+ stamp: m[3] || "",
367
+ };
368
+ }
369
+
353
370
  /**
354
371
  * Find an archived change by ID in the archive directory.
355
372
  * @param {string} archiveDir
@@ -360,19 +377,86 @@ async function findArchivedChange(archiveDir, changeId) {
360
377
  if (!(await pathExists(archiveDir))) return null;
361
378
  try {
362
379
  const entries = await fs.readdir(archiveDir, { withFileTypes: true });
363
- for (const e of entries) {
364
- if (!e.isDirectory()) continue;
365
- // Archive dirs are named like: 2026-03-12-<change-id> or 2026-03-12-<change-id>-<timestamp>
366
- if (e.name.endsWith(`-${changeId}`) || e.name.includes(`-${changeId}-`)) {
367
- return path.join(archiveDir, e.name);
368
- }
369
- }
380
+ const matches = entries
381
+ .filter((e) => e.isDirectory())
382
+ .map((e) => ({ entry: e, parsed: parseArchivedChangeDirName(e.name) }))
383
+ .filter((item) => item.parsed?.changeId === changeId)
384
+ .sort((a, b) => a.entry.name.localeCompare(b.entry.name));
385
+ if (matches.length > 0) return path.join(archiveDir, matches.at(-1)?.entry.name || "");
370
386
  } catch {
371
387
  // ignore
372
388
  }
373
389
  return null;
374
390
  }
375
391
 
392
+ /**
393
+ * @param {string} gitRoot
394
+ * @param {string} changeId
395
+ * @returns {Promise<null | { kind: "archived" | "finished_unarchived", archivedChangeDir?: string, archivedHandoffRel?: string, lifecycle?: any }>}
396
+ */
397
+ async function detectTerminatedChange(gitRoot, changeId) {
398
+ const changeDir = changeDirAbs(gitRoot, changeId);
399
+ const archiveRoot = path.join(gitRoot, "changes", "archive");
400
+ const activeExists = await pathExists(changeDir);
401
+ if (!activeExists) {
402
+ const archivedChangeDir = await findArchivedChange(archiveRoot, changeId);
403
+ if (!archivedChangeDir) return null;
404
+ const archivedHandoffPath = path.join(archivedChangeDir, "handoff.md");
405
+ return {
406
+ kind: "archived",
407
+ archivedChangeDir,
408
+ archivedHandoffRel: (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "",
409
+ };
410
+ }
411
+
412
+ const lifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDir, { pathExists, readText }));
413
+ if (lifecycle.finishState !== "done") return null;
414
+ return { kind: "finished_unarchived", lifecycle };
415
+ }
416
+
417
+ /**
418
+ * @param {string} gitRoot
419
+ * @param {string} changeId
420
+ * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
421
+ * @param {string} action
422
+ */
423
+ function terminatedChangeError(gitRoot, changeId, terminated, action) {
424
+ if (terminated?.kind === "archived") {
425
+ return new UserError(`Change ${changeId} is already archived.`, {
426
+ details:
427
+ `change: ${changeId}\n` +
428
+ `archived: ${path.relative(gitRoot, terminated.archivedChangeDir || "")}\n` +
429
+ (terminated.archivedHandoffRel ? `handoff: ${terminated.archivedHandoffRel}\n` : "") +
430
+ `next: create a new follow-up change instead of reusing change/${changeId}` +
431
+ (action ? `\nblocked: ${action}` : ""),
432
+ });
433
+ }
434
+ return new UserError(`Change ${changeId} has unfinished finish closeout; resume finish before any follow-up work.`, {
435
+ details:
436
+ `change: ${changeId}\n` +
437
+ `branch: change/${changeId}\n` +
438
+ `next: rerun \`aiws change finish ${changeId} --push\` to complete push/archive closeout` +
439
+ `\nfallback: if you intentionally only need local archive recovery, run \`aiws change archive ${changeId}\`` +
440
+ (action ? `\nblocked: ${action}` : ""),
441
+ });
442
+ }
443
+
444
+ /**
445
+ * @param {string} command
446
+ * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
447
+ * @param {{ allowFinishedUnarchived?: boolean }} [options]
448
+ */
449
+ function isTerminatedAllowed(command, terminated, options = {}) {
450
+ if (!terminated) return true;
451
+ if (terminated.kind === "finished_unarchived") {
452
+ if (command === "archive") return true;
453
+ if (command === "finish") return true;
454
+ if (command === "status" || command === "next") return true;
455
+ if (options.allowFinishedUnarchived === true) return true;
456
+ }
457
+ return false;
458
+ }
459
+
376
460
  /**
377
461
  * Extract a brief summary from handoff.md content.
378
462
  * @param {string} text
@@ -648,7 +732,21 @@ export async function computeChangeStatus(gitRoot, changeId) {
648
732
  const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
649
733
  const evidencePaths = splitDeclaredValues(evidencePath);
650
734
  const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
651
- const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
735
+ const taskProgress =
736
+ tasks.state === "ok"
737
+ ? checkboxStats(tasks.text)
738
+ : {
739
+ total: 0,
740
+ done: 0,
741
+ unchecked: 0,
742
+ hasCheckboxes: false,
743
+ totalAll: 0,
744
+ doneAll: 0,
745
+ uncheckedAll: 0,
746
+ optionalTotal: 0,
747
+ optionalUnchecked: 0,
748
+ naCount: 0,
749
+ };
652
750
 
653
751
  const curTruth = await snapshotTruthShaOnly(gitRoot);
654
752
  const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
@@ -703,10 +801,25 @@ export async function computeChangeStatus(gitRoot, changeId) {
703
801
  if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
704
802
  if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
705
803
 
804
+ const lifecycle = summarizeLifecycleSignals(metrics);
805
+ const terminatedReuse =
806
+ lifecycle.finishState === "done"
807
+ ? {
808
+ kind: "finished_unarchived",
809
+ message: `change/${changeId} has unfinished finish closeout; rerun \`aiws change finish ${changeId} --push\` to complete archive/push`,
810
+ }
811
+ : null;
812
+ if (terminatedReuse) {
813
+ blockersStrict.push(terminatedReuse.message);
814
+ }
815
+
706
816
  blockersArchive.push(...blockersStrict);
707
- if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
817
+ if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked required tasks (${taskProgress.unchecked} items)`);
708
818
  const phase = inferChangePhase(blockersStrict, taskProgress);
709
- const lifecycle = summarizeLifecycleSignals(metrics);
819
+ if (terminatedReuse) {
820
+ lifecycle.finishState = "resume_required";
821
+ lifecycle.finishStateReason = "archive_pending";
822
+ }
710
823
  const scopeGate = computeScopeGate(lifecycle);
711
824
 
712
825
  const baseStatus = {
@@ -739,9 +852,10 @@ export async function computeChangeStatus(gitRoot, changeId) {
739
852
  driftFiles,
740
853
  blockersStrict,
741
854
  blockersArchive,
855
+ terminatedReuse,
742
856
  hints: {
743
857
  planVerify: planVerifyHint(changeId),
744
- dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
858
+ dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
745
859
  },
746
860
  };
747
861
  const governance = await inferChangeGovernance(baseStatus);
@@ -772,16 +886,6 @@ export async function validateChangeArtifacts(gitRoot, changeId, options) {
772
886
  });
773
887
  }
774
888
 
775
- /**
776
- * @param {string} text
777
- */
778
- function checkboxStats(text) {
779
- const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
780
- const done = (text.match(/^- \[[xX]\]/gm) || []).length;
781
- const unchecked = (text.match(/^- \[ \]/gm) || []).length;
782
- return { total, done, unchecked, hasCheckboxes: total > 0 };
783
- }
784
-
785
889
  /**
786
890
  * @param {string} text
787
891
  */
@@ -817,8 +921,7 @@ async function fileState(changeDir, rel) {
817
921
  * @param {string} type
818
922
  * @param {any} payload
819
923
  */
820
- async function appendMetricsEvent(gitRoot, changeId, type, payload) {
821
- const changeDir = changeDirAbs(gitRoot, changeId);
924
+ async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
822
925
  const metricsAbs = path.join(changeDir, "metrics.json");
823
926
  /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
824
927
  let cur = null;
@@ -839,6 +942,11 @@ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
839
942
  await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
840
943
  }
841
944
 
945
+ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
946
+ const changeDir = changeDirAbs(gitRoot, changeId);
947
+ await appendMetricsEventAtDir(changeDir, changeId, type, payload);
948
+ }
949
+
842
950
  /**
843
951
  * @param {string} gitRoot
844
952
  */
@@ -922,13 +1030,17 @@ async function runPython(gitRoot, args) {
922
1030
  /**
923
1031
  * @param {string} gitRoot
924
1032
  * @param {string | undefined} changeId
925
- * @param {{ command: string }} options
1033
+ * @param {{ command: string, allowFinishedUnarchived?: boolean }} options
926
1034
  */
927
1035
  async function resolveChangeId(gitRoot, changeId, options) {
928
- if (changeId) return changeId;
929
- const branch = await currentBranch(gitRoot);
930
- const inferred = inferChangeIdFromBranch(branch);
931
- if (inferred) return inferred;
1036
+ const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
1037
+ if (resolved) {
1038
+ const terminated = await detectTerminatedChange(gitRoot, resolved);
1039
+ if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
1040
+ throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
1041
+ }
1042
+ return resolved;
1043
+ }
932
1044
  throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
933
1045
  }
934
1046
 
@@ -1098,6 +1210,8 @@ async function changeNewAtGitRoot(gitRoot, options) {
1098
1210
 
1099
1211
  const changeId = String(options.changeId || "").trim();
1100
1212
  assertValidChangeId(changeId);
1213
+ const terminated = await detectTerminatedChange(gitRoot, changeId);
1214
+ if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change new");
1101
1215
 
1102
1216
  const title = (options.title ? String(options.title) : changeId).trim() || changeId;
1103
1217
  const createdAt = nowIsoUtc();
@@ -1183,6 +1297,8 @@ export async function changeStartCommand(options) {
1183
1297
 
1184
1298
  const changeId = String(options.changeId || "").trim();
1185
1299
  assertValidChangeId(changeId);
1300
+ const terminated = await detectTerminatedChange(gitRoot, changeId);
1301
+ if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change start");
1186
1302
 
1187
1303
  const startFromBranch = await currentBranch(gitRoot);
1188
1304
  const branch = `change/${changeId}`;
@@ -1453,10 +1569,15 @@ export async function changeFinishCommand(options) {
1453
1569
 
1454
1570
  const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1455
1571
  assertValidChangeId(resolvedChangeId);
1456
- const existingLifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDirAbs(gitRoot, resolvedChangeId), { pathExists, readText }));
1457
- if (existingLifecycle.finishState === "done") {
1458
- throw new UserError("Change finish already completed.", {
1459
- details: `change: ${resolvedChangeId}\nnext: run \`aiws change archive ${resolvedChangeId}\` or continue with \`$ws-handoff\``,
1572
+ const terminated = await detectTerminatedChange(gitRoot, resolvedChangeId);
1573
+ if (terminated?.kind === "archived") throw terminatedChangeError(gitRoot, resolvedChangeId, terminated, "aiws change finish");
1574
+ const resumeFinishedUnarchived = terminated?.kind === "finished_unarchived";
1575
+ if (resumeFinishedUnarchived && options.push !== true) {
1576
+ throw new UserError("Change finish closeout is pending; rerun with --push.", {
1577
+ details:
1578
+ `change: ${resolvedChangeId}\n` +
1579
+ `next: rerun \`aiws change finish ${resolvedChangeId} --push\` to complete archive/push closeout\n` +
1580
+ `fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
1460
1581
  });
1461
1582
  }
1462
1583
  const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
@@ -1486,6 +1607,21 @@ export async function changeFinishCommand(options) {
1486
1607
  });
1487
1608
  }
1488
1609
  const finishBasePayload = { into, from: changeBranch, push: options.push === true };
1610
+ const archiveWorkflowDeps = {
1611
+ appendMetricsEvent,
1612
+ changeValidateCommand,
1613
+ ensureDir,
1614
+ generateHandoffContent,
1615
+ nowIsoUtc,
1616
+ nowStampUtc,
1617
+ nowUnixSeconds,
1618
+ pathExists,
1619
+ readText,
1620
+ snapshotTruthFiles,
1621
+ todayLocal,
1622
+ writeText,
1623
+ UserError,
1624
+ };
1489
1625
 
1490
1626
  if (cur && cur !== into) {
1491
1627
  const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
@@ -1505,9 +1641,14 @@ export async function changeFinishCommand(options) {
1505
1641
  }
1506
1642
 
1507
1643
  await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
1644
+ if (resumeFinishedUnarchived) {
1645
+ console.log(`resume: continuing previously finished change ${changeId} to complete remaining finish steps`);
1646
+ }
1508
1647
 
1509
1648
  let cleanupNote = "not_requested";
1510
1649
  let finishCompleted = false;
1650
+ let archivedToRel = "";
1651
+ let handoffRel = "";
1511
1652
  if (options.push === true) {
1512
1653
  try {
1513
1654
  const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
@@ -1591,7 +1732,50 @@ export async function changeFinishCommand(options) {
1591
1732
  }
1592
1733
 
1593
1734
  if (finishCompleted) {
1594
- await appendMetricsEvent(gitRoot, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1735
+ const archiveResult = await runChangeArchiveWorkflow(
1736
+ {
1737
+ gitRoot,
1738
+ changeId,
1739
+ changeDir: changeDirAbs(gitRoot, changeId),
1740
+ force: false,
1741
+ },
1742
+ archiveWorkflowDeps,
1743
+ );
1744
+ archivedToRel = archiveResult.archivedToRel;
1745
+ handoffRel = archiveResult.handoffRel;
1746
+ await appendMetricsEventAtDir(archiveResult.archivedTo, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1747
+
1748
+ if (options.push === true) {
1749
+ const stageArchive = await runCommand("git", ["add", "-A", "--", "changes"], { cwd: gitRoot });
1750
+ if (stageArchive.code !== 0) {
1751
+ throw new UserError("Finish completed, but failed to stage archived change artifacts.", {
1752
+ details: stageArchive.stderr || stageArchive.stdout,
1753
+ });
1754
+ }
1755
+
1756
+ const stagedArchive = await runCommand("git", ["diff", "--cached", "--quiet", "--", "changes"], { cwd: gitRoot });
1757
+ if (stagedArchive.code !== 0) {
1758
+ const archiveCommit = await runCommand("git", ["commit", "-m", `归档: ${changeId}`], { cwd: gitRoot });
1759
+ if (archiveCommit.code !== 0) {
1760
+ throw new UserError("Finish completed, but failed to commit archived change artifacts.", {
1761
+ details: `archived_to: ${archivedToRel}\nhandoff: ${handoffRel}\n\n${archiveCommit.stderr || archiveCommit.stdout}`,
1762
+ });
1763
+ }
1764
+
1765
+ const archivePushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1766
+ const archivePush = await runCommand("git", ["push", archivePushTarget.remote, archivePushTarget.refspec], { cwd: gitRoot });
1767
+ if (archivePush.code !== 0) {
1768
+ throw new UserError("Finish completed, but failed to push archived change artifacts.", {
1769
+ details:
1770
+ `archived_to: ${archivedToRel}\n` +
1771
+ `handoff: ${handoffRel}\n` +
1772
+ `remote: ${archivePushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${archivePushTarget.remoteBranch}\n` +
1773
+ (archivePushTarget.trackedMergeRef ? `tracked_merge: ${archivePushTarget.trackedMergeRef}\n` : "") +
1774
+ `\n${archivePush.stderr || archivePush.stdout}`,
1775
+ });
1776
+ }
1777
+ }
1778
+ }
1595
1779
  }
1596
1780
 
1597
1781
  const outcomeLabel =
@@ -1599,6 +1783,8 @@ export async function changeFinishCommand(options) {
1599
1783
  console.log(`ok: ${outcomeLabel}: ${changeId}`);
1600
1784
  console.log(`into: ${into}`);
1601
1785
  console.log(`from: ${changeBranch}`);
1786
+ if (archivedToRel) console.log(`archived_to: ${archivedToRel}`);
1787
+ if (handoffRel) console.log(`handoff: ${handoffRel}`);
1602
1788
 
1603
1789
  if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
1604
1790
  console.log("next:");
@@ -1793,13 +1979,16 @@ export async function changeEvidenceCommand(options) {
1793
1979
  /**
1794
1980
  * aiws change validate
1795
1981
  *
1796
- * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean }} options
1982
+ * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean, allowFinishedUnarchived?: boolean }} options
1797
1983
  */
1798
1984
  export async function changeValidateCommand(options) {
1799
1985
  const gitRoot = await resolveGitRoot(process.cwd());
1800
1986
  await ensureTruthFiles(gitRoot);
1801
1987
 
1802
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
1988
+ const changeId = await resolveChangeId(gitRoot, options.changeId, {
1989
+ command: "validate",
1990
+ allowFinishedUnarchived: options.allowFinishedUnarchived === true,
1991
+ });
1803
1992
  assertValidChangeId(changeId);
1804
1993
  const result = await runChangeValidateCommand(
1805
1994
  {
@@ -19,7 +19,7 @@ export async function initCommand(options) {
19
19
 
20
20
  const defaults = tpl.manifest.defaults || {};
21
21
  const includeOptional = defaults.include_optional !== false;
22
- const tools = Array.isArray(defaults.tools) ? defaults.tools.map(String) : ["claude", "opencode", "codex", "iflow"];
22
+ const tools = Array.isArray(defaults.tools) ? defaults.tools.map(String) : ["claude", "opencode", "codex"];
23
23
 
24
24
  const required = await expandManifestEntries(tpl.templateDir, tpl.manifest.required || []);
25
25
  const optional = includeOptional ? await expandManifestEntries(tpl.templateDir, tpl.manifest.optional || []) : [];
@@ -31,6 +31,9 @@ export async function updateCommand(options) {
31
31
  .filter((entry) => entry && typeof entry === "object")
32
32
  .map((entry) => [normalizeRel(String(entry.path || "")), entry]),
33
33
  );
34
+ const defaults = tpl.manifest.defaults || {};
35
+ const defaultTools = Array.isArray(defaults.tools) ? defaults.tools.map(String) : ["claude", "opencode", "codex"];
36
+ const allowedTools = new Set(defaultTools);
34
37
 
35
38
  const update = tpl.manifest.update || {};
36
39
  const replaceFiles = (update.replace_file || []).map(normalizeRel);
@@ -112,7 +115,8 @@ export async function updateCommand(options) {
112
115
 
113
116
  const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
114
117
  const installedAt = String(stored.installed_at || now);
115
- const tools = Array.isArray(stored.tools) ? stored.tools.map(String) : ["claude", "opencode", "codex", "iflow"];
118
+ const storedTools = Array.isArray(stored.tools) ? stored.tools.map(String).filter((tool) => allowedTools.has(tool)) : [];
119
+ const tools = storedTools.length > 0 ? storedTools : defaultTools;
116
120
 
117
121
  await writeWorkspaceManifest({
118
122
  workspaceRoot,
@@ -42,7 +42,7 @@
42
42
  </table>
43
43
  </div>
44
44
  <div class="hint">
45
- 建议:先看 Details 里的 <code>Next</code>,优先通过 <code>aiws change validate &lt;id&gt; --strict</code>,再按阶段进入 <code>$ws-dev</code> 或 <code>$ws-deliver</code>。
45
+ 建议:先看 Details 里的 <code>Next</code>,优先通过 <code>aiws change validate &lt;id&gt; --strict</code>;simple/local 单点修复优先 <code>$ws-dev-lite</code>,否则按阶段进入 <code>$ws-dev</code> 或 <code>$ws-deliver</code>。
46
46
  </div>
47
47
  </section>
48
48
 
package/src/governance.js CHANGED
@@ -26,7 +26,9 @@ function collectGovernanceSignals(status, governance) {
26
26
  const validateStampReady = reviewGates?.validateStamp?.ready === true;
27
27
 
28
28
  return {
29
+ change_id: String(status?.changeId || ""),
29
30
  blockers_strict_count: Array.isArray(status?.blockersStrict) ? status.blockersStrict.length : 0,
31
+ finish_resume_required: status?.terminatedReuse?.kind === "finished_unarchived",
30
32
  tasks_unchecked: Number(status?.tasks?.unchecked || 0),
31
33
  review_effective_count: reviewEffectiveCount,
32
34
  evidence_persistent_count: Number(status?.evidence?.counts?.persistent || 0),
package/src/template.js CHANGED
@@ -6,6 +6,45 @@ import { normalizeNewlines } from "./hash.js";
6
6
  import { extractTemplateBlock, upsertManagedBlock } from "./managed-blocks.js";
7
7
  import { UserError } from "./errors.js";
8
8
 
9
+ /**
10
+ * Extract a leading YAML frontmatter chunk when present.
11
+ *
12
+ * @param {string} text normalized
13
+ * @returns {{ chunk: string, rest: string } | null}
14
+ */
15
+ function extractLeadingFrontmatter(text) {
16
+ const t = normalizeNewlines(text);
17
+ if (!t.startsWith("---\n")) return null;
18
+ const end = t.indexOf("\n---\n", 4);
19
+ if (end === -1) return null;
20
+ const closeEnd = end + "\n---\n".length;
21
+ return {
22
+ chunk: t.slice(0, closeEnd),
23
+ rest: t.slice(closeEnd),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Keep command metadata in sync without overwriting managed-block files wholesale.
29
+ *
30
+ * @param {string} currentText normalized
31
+ * @param {string} templateText normalized
32
+ * @returns {{ nextText: string, changed: boolean }}
33
+ */
34
+ function syncLeadingFrontmatter(currentText, templateText) {
35
+ const templateFm = extractLeadingFrontmatter(templateText);
36
+ if (!templateFm) return { nextText: currentText, changed: false };
37
+
38
+ const currentFm = extractLeadingFrontmatter(currentText);
39
+ if (!currentFm) {
40
+ const nextText = `${templateFm.chunk}${currentText}`;
41
+ return { nextText, changed: nextText !== currentText };
42
+ }
43
+
44
+ const nextText = `${templateFm.chunk}${currentFm.rest}`;
45
+ return { nextText, changed: nextText !== currentText };
46
+ }
47
+
9
48
  /**
10
49
  * Template file candidates for a given manifest/workspace path.
11
50
  *
@@ -116,8 +155,9 @@ export async function applyManagedBlocksFromTemplate(options) {
116
155
  }
117
156
 
118
157
  const currentText = normalizeNewlines(await readText(dest));
119
- let nextText = currentText;
120
- let anyChanged = false;
158
+ const syncedFrontmatter = syncLeadingFrontmatter(currentText, templateText);
159
+ let nextText = syncedFrontmatter.nextText;
160
+ let anyChanged = syncedFrontmatter.changed;
121
161
 
122
162
  for (const blockId of options.blockIds) {
123
163
  const { blockChunk, innerText } = extractTemplateBlock(templateText, blockId);