@aipper/aiws 0.0.25 → 0.0.26

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.26",
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.26"
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,
@@ -96,6 +96,9 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
96
96
  advice.actions.push(deps.scopeGateFailureAction(changeId));
97
97
  }
98
98
  if (status.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${status.tasks.unchecked} 项)`);
99
+ if (status.blockersStrict.length === 0 && status.tasks.unchecked > 0) {
100
+ advice.actions.push("质量门通过后继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
101
+ }
99
102
  if ((status.collaboration?.counts?.analysis || 0) > 0 && (status.collaboration?.counts?.review || 0) === 0) {
100
103
  advice.actions.push("已有委托分析但还没有 review 汇总结论:建议在 `changes/<id>/review/` 落盘结论后再交付");
101
104
  }
@@ -104,7 +107,7 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
104
107
  }
105
108
 
106
109
  if (status.blockersStrict.length > 0 && advice.actions.length === 0) {
107
- advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
110
+ advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
108
111
  }
109
112
 
110
113
  if (status.blockersStrict.length === 0 && status.tasks.unchecked === 0) {
@@ -122,7 +125,7 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
122
125
  }
123
126
  if ((status.collaboration?.counts?.review || 0) > 1) advice.recommended.push("存在多份 review:交付前建议在 handoff 或 review 汇总里收敛最终结论");
124
127
  } else if (advice.actions.length === 0) {
125
- advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
128
+ advice.recommended.push("继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`(小步实现 + 可复现验证)");
126
129
  if ((status.collaboration?.counts?.analysis || 0) > 0) advice.recommended.push("已有委托分析:进入实现前先收敛 `changes/<id>/analysis/` 结论");
127
130
  if ((status.collaboration?.counts?.patches || 0) > 0) advice.recommended.push("已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
128
131
  }
@@ -208,12 +208,27 @@ export async function runChangeArchiveWorkflow(input, deps) {
208
208
  ) + "\n",
209
209
  );
210
210
 
211
+ const archivedToRel = path.relative(gitRoot, dest);
212
+ const handoffRel = path.relative(gitRoot, handoffPath);
213
+ const metaRel = path.relative(gitRoot, metaPath);
214
+ const stampRel = path.relative(gitRoot, stampPath);
215
+
211
216
  /** @type {string[]} */
212
217
  const outputLines = [];
213
218
  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" };
219
+ outputLines.push(`archived_to: ${archivedToRel}`);
220
+ outputLines.push(`handoff: ${handoffRel}`);
221
+ if (metaUpdated) outputLines.push(`meta_updated: ${metaRel}`);
222
+ outputLines.push(`stamp: ${stampRel}`);
223
+ return {
224
+ output: outputLines.join("\n") + "\n",
225
+ archivedTo: dest,
226
+ archivedToRel,
227
+ handoffPath,
228
+ handoffRel,
229
+ metaPath: metaUpdated ? metaPath : "",
230
+ metaRel: metaUpdated ? metaRel : "",
231
+ stampPath,
232
+ stampRel,
233
+ };
219
234
  }
@@ -95,10 +95,10 @@ export async function renderChangeNextOutput(input, deps) {
95
95
  for (const action of advice.actions) lines.push(`- ${action}`);
96
96
  if (status.blockersStrict.length > 0) {
97
97
  lines.push(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
98
- lines.push("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
98
+ lines.push("- 质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
99
99
  } else if (status.tasks.unchecked > 0) {
100
100
  lines.push(`- 处理完上述事项后复跑质量门:\`aiws change validate ${changeId} --strict\``);
101
- lines.push("- 质量门通过后继续开发:在 AI 工具中运行 `$ws-dev`");
101
+ lines.push("- 质量门通过后继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
102
102
  } else {
103
103
  for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
104
104
  }
@@ -107,12 +107,17 @@ export async function renderChangeNextOutput(input, deps) {
107
107
 
108
108
  lines.push(`- ${deps.planVerifyHint(changeId)}`);
109
109
  if (prog.unchecked > 0) {
110
- lines.push("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
110
+ lines.push("- 若仍需继续编码,先通过质量门;simple/local 单点修复优先 `$ws-dev-lite`,否则 `$ws-dev`");
111
111
  } else {
112
112
  for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
113
113
  }
114
114
  lines.push("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
115
- lines.push(`- 归档:\`aiws change archive ${changeId}\``);
115
+ const recommendedStage = String(status.governance?.recommendedStage || "");
116
+ if (recommendedStage === "ws-finish") {
117
+ lines.push(`- 收尾:\`aiws change finish ${changeId} --push\`(成功后会自动归档并生成 handoff)`);
118
+ } else if (recommendedStage === "ws-handoff") {
119
+ lines.push("- 交接:运行 `$ws-handoff` 检查归档 handoff;当前已无额外 finish 工作,无需重复执行 `aiws change finish`");
120
+ }
116
121
  return lines.join("\n") + "\n";
117
122
  }
118
123
 
@@ -91,13 +91,13 @@ export async function renderChangeStatusOutput(input, deps) {
91
91
  lines.push("Next (recommended):");
92
92
  lines.push(`- ${deps.planVerifyHint(status.changeId)}`);
93
93
  if (status.blockersStrict.length > 0) {
94
- lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
94
+ lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
95
95
  if (changeWorktreeHint) lines.push(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${changeWorktreeHint}\``);
96
96
  return lines.join("\n") + "\n";
97
97
  }
98
98
 
99
99
  if (status.tasks.unchecked > 0) {
100
- lines.push("- 继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
100
+ lines.push("- 继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`(小步实现 + 可复现验证)");
101
101
  if ((status.collaboration?.counts?.analysis || 0) > 0) lines.push("- 已有委托分析:在编码前先收敛 `changes/<id>/analysis/` 中的结论");
102
102
  if ((status.collaboration?.counts?.patches || 0) > 0) lines.push("- 已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
103
103
  lines.push("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
@@ -350,6 +350,22 @@ function isMeaningfulBullet(bulletLine) {
350
350
  return true;
351
351
  }
352
352
 
353
+ /**
354
+ * @param {string} entryName
355
+ * @returns {{ datePrefix: string, changeId: string, stamp: string } | null}
356
+ */
357
+ function parseArchivedChangeDirName(entryName) {
358
+ const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$/.exec(String(entryName || ""));
359
+ if (!m) return null;
360
+ const changeId = m[2] || "";
361
+ if (!CHANGE_ID_RE.test(changeId)) return null;
362
+ return {
363
+ datePrefix: m[1] || "",
364
+ changeId,
365
+ stamp: m[3] || "",
366
+ };
367
+ }
368
+
353
369
  /**
354
370
  * Find an archived change by ID in the archive directory.
355
371
  * @param {string} archiveDir
@@ -360,13 +376,12 @@ async function findArchivedChange(archiveDir, changeId) {
360
376
  if (!(await pathExists(archiveDir))) return null;
361
377
  try {
362
378
  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
- }
379
+ const matches = entries
380
+ .filter((e) => e.isDirectory())
381
+ .map((e) => ({ entry: e, parsed: parseArchivedChangeDirName(e.name) }))
382
+ .filter((item) => item.parsed?.changeId === changeId)
383
+ .sort((a, b) => a.entry.name.localeCompare(b.entry.name));
384
+ if (matches.length > 0) return path.join(archiveDir, matches.at(-1)?.entry.name || "");
370
385
  } catch {
371
386
  // ignore
372
387
  }
@@ -741,7 +756,7 @@ export async function computeChangeStatus(gitRoot, changeId) {
741
756
  blockersArchive,
742
757
  hints: {
743
758
  planVerify: planVerifyHint(changeId),
744
- dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
759
+ dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
745
760
  },
746
761
  };
747
762
  const governance = await inferChangeGovernance(baseStatus);
@@ -817,8 +832,7 @@ async function fileState(changeDir, rel) {
817
832
  * @param {string} type
818
833
  * @param {any} payload
819
834
  */
820
- async function appendMetricsEvent(gitRoot, changeId, type, payload) {
821
- const changeDir = changeDirAbs(gitRoot, changeId);
835
+ async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
822
836
  const metricsAbs = path.join(changeDir, "metrics.json");
823
837
  /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
824
838
  let cur = null;
@@ -839,6 +853,11 @@ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
839
853
  await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
840
854
  }
841
855
 
856
+ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
857
+ const changeDir = changeDirAbs(gitRoot, changeId);
858
+ await appendMetricsEventAtDir(changeDir, changeId, type, payload);
859
+ }
860
+
842
861
  /**
843
862
  * @param {string} gitRoot
844
863
  */
@@ -1453,10 +1472,27 @@ export async function changeFinishCommand(options) {
1453
1472
 
1454
1473
  const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1455
1474
  assertValidChangeId(resolvedChangeId);
1456
- const existingLifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDirAbs(gitRoot, resolvedChangeId), { pathExists, readText }));
1475
+ const activeChangeDir = changeDirAbs(gitRoot, resolvedChangeId);
1476
+ const activeChangeExists = await pathExists(activeChangeDir);
1477
+ const archiveRoot = path.join(gitRoot, "changes", "archive");
1478
+ const archivedChangeDir = activeChangeExists ? null : await findArchivedChange(archiveRoot, resolvedChangeId);
1479
+ const existingLifecycle = summarizeLifecycleSignals(await readChangeMetrics(activeChangeDir, { pathExists, readText }));
1480
+ const archivedHandoffPath = archivedChangeDir ? path.join(archivedChangeDir, "handoff.md") : "";
1481
+ const archivedHandoffRel = archivedHandoffPath && (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "";
1482
+ if (archivedChangeDir) {
1483
+ throw new UserError("No finish work remaining for this change.", {
1484
+ details:
1485
+ `change: ${resolvedChangeId}\n` +
1486
+ `archived: ${path.relative(gitRoot, archivedChangeDir)}\n` +
1487
+ (archivedHandoffRel ? `handoff: ${archivedHandoffRel}\n` : "") +
1488
+ "next: continue with `$ws-handoff`",
1489
+ });
1490
+ }
1457
1491
  if (existingLifecycle.finishState === "done") {
1458
1492
  throw new UserError("Change finish already completed.", {
1459
- details: `change: ${resolvedChangeId}\nnext: run \`aiws change archive ${resolvedChangeId}\` or continue with \`$ws-handoff\``,
1493
+ details:
1494
+ `change: ${resolvedChangeId}\n` +
1495
+ `next: legacy finish is already done; run \`aiws change archive ${resolvedChangeId}\` once, then continue with \`$ws-handoff\``,
1460
1496
  });
1461
1497
  }
1462
1498
  const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
@@ -1486,6 +1522,21 @@ export async function changeFinishCommand(options) {
1486
1522
  });
1487
1523
  }
1488
1524
  const finishBasePayload = { into, from: changeBranch, push: options.push === true };
1525
+ const archiveWorkflowDeps = {
1526
+ appendMetricsEvent,
1527
+ changeValidateCommand,
1528
+ ensureDir,
1529
+ generateHandoffContent,
1530
+ nowIsoUtc,
1531
+ nowStampUtc,
1532
+ nowUnixSeconds,
1533
+ pathExists,
1534
+ readText,
1535
+ snapshotTruthFiles,
1536
+ todayLocal,
1537
+ writeText,
1538
+ UserError,
1539
+ };
1489
1540
 
1490
1541
  if (cur && cur !== into) {
1491
1542
  const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
@@ -1508,6 +1559,8 @@ export async function changeFinishCommand(options) {
1508
1559
 
1509
1560
  let cleanupNote = "not_requested";
1510
1561
  let finishCompleted = false;
1562
+ let archivedToRel = "";
1563
+ let handoffRel = "";
1511
1564
  if (options.push === true) {
1512
1565
  try {
1513
1566
  const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
@@ -1591,7 +1644,50 @@ export async function changeFinishCommand(options) {
1591
1644
  }
1592
1645
 
1593
1646
  if (finishCompleted) {
1594
- await appendMetricsEvent(gitRoot, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1647
+ const archiveResult = await runChangeArchiveWorkflow(
1648
+ {
1649
+ gitRoot,
1650
+ changeId,
1651
+ changeDir: changeDirAbs(gitRoot, changeId),
1652
+ force: false,
1653
+ },
1654
+ archiveWorkflowDeps,
1655
+ );
1656
+ archivedToRel = archiveResult.archivedToRel;
1657
+ handoffRel = archiveResult.handoffRel;
1658
+ await appendMetricsEventAtDir(archiveResult.archivedTo, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1659
+
1660
+ if (options.push === true) {
1661
+ const stageArchive = await runCommand("git", ["add", "-A", "--", "changes"], { cwd: gitRoot });
1662
+ if (stageArchive.code !== 0) {
1663
+ throw new UserError("Finish completed, but failed to stage archived change artifacts.", {
1664
+ details: stageArchive.stderr || stageArchive.stdout,
1665
+ });
1666
+ }
1667
+
1668
+ const stagedArchive = await runCommand("git", ["diff", "--cached", "--quiet", "--", "changes"], { cwd: gitRoot });
1669
+ if (stagedArchive.code !== 0) {
1670
+ const archiveCommit = await runCommand("git", ["commit", "-m", `归档: ${changeId}`], { cwd: gitRoot });
1671
+ if (archiveCommit.code !== 0) {
1672
+ throw new UserError("Finish completed, but failed to commit archived change artifacts.", {
1673
+ details: `archived_to: ${archivedToRel}\nhandoff: ${handoffRel}\n\n${archiveCommit.stderr || archiveCommit.stdout}`,
1674
+ });
1675
+ }
1676
+
1677
+ const archivePushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1678
+ const archivePush = await runCommand("git", ["push", archivePushTarget.remote, archivePushTarget.refspec], { cwd: gitRoot });
1679
+ if (archivePush.code !== 0) {
1680
+ throw new UserError("Finish completed, but failed to push archived change artifacts.", {
1681
+ details:
1682
+ `archived_to: ${archivedToRel}\n` +
1683
+ `handoff: ${handoffRel}\n` +
1684
+ `remote: ${archivePushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${archivePushTarget.remoteBranch}\n` +
1685
+ (archivePushTarget.trackedMergeRef ? `tracked_merge: ${archivePushTarget.trackedMergeRef}\n` : "") +
1686
+ `\n${archivePush.stderr || archivePush.stdout}`,
1687
+ });
1688
+ }
1689
+ }
1690
+ }
1595
1691
  }
1596
1692
 
1597
1693
  const outcomeLabel =
@@ -1599,6 +1695,8 @@ export async function changeFinishCommand(options) {
1599
1695
  console.log(`ok: ${outcomeLabel}: ${changeId}`);
1600
1696
  console.log(`into: ${into}`);
1601
1697
  console.log(`from: ${changeBranch}`);
1698
+ if (archivedToRel) console.log(`archived_to: ${archivedToRel}`);
1699
+ if (handoffRel) console.log(`handoff: ${handoffRel}`);
1602
1700
 
1603
1701
  if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
1604
1702
  console.log("next:");
@@ -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/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);