@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 +90 -62
- package/package.json +3 -3
- package/src/cli.js +1 -1
- package/src/commands/change-advice.js +5 -2
- package/src/commands/change-lifecycle.js +20 -5
- package/src/commands/change-next-command.js +9 -4
- package/src/commands/change-status-command.js +2 -2
- package/src/commands/change.js +111 -13
- package/src/commands/init.js +1 -1
- package/src/commands/update.js +5 -1
- package/src/dashboard/index.html +1 -1
- package/src/template.js +42 -2
package/README.md
CHANGED
|
@@ -1,37 +1,68 @@
|
|
|
1
1
|
# @aipper/aiws
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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 <
|
|
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
|
|
50
|
-
aiws codex
|
|
90
|
+
aiws codex install-skills
|
|
91
|
+
aiws codex status-skills
|
|
92
|
+
aiws codex uninstall-skills
|
|
51
93
|
```
|
|
52
94
|
|
|
53
|
-
Dashboard
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
96
|
-
- `aiws validate
|
|
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.
|
|
4
|
-
"description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex
|
|
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.
|
|
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)
|
|
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("
|
|
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: ${
|
|
215
|
-
outputLines.push(`handoff: ${
|
|
216
|
-
if (metaUpdated) outputLines.push(`meta_updated: ${
|
|
217
|
-
outputLines.push(`stamp: ${
|
|
218
|
-
return {
|
|
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("-
|
|
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("-
|
|
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("-
|
|
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
|
-
|
|
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)
|
|
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("-
|
|
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`)");
|
package/src/commands/change.js
CHANGED
|
@@ -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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:");
|
package/src/commands/init.js
CHANGED
|
@@ -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"
|
|
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 || []) : [];
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/dashboard/index.html
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
</table>
|
|
43
43
|
</div>
|
|
44
44
|
<div class="hint">
|
|
45
|
-
建议:先看 Details 里的 <code>Next</code>,优先通过 <code>aiws change validate <id> --strict</code
|
|
45
|
+
建议:先看 Details 里的 <code>Next</code>,优先通过 <code>aiws change validate <id> --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
|
-
|
|
120
|
-
let
|
|
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);
|