@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 +90 -62
- package/package.json +3 -3
- package/src/cli.js +1 -1
- package/src/commands/change-advice.js +15 -3
- package/src/commands/change-evidence.js +3 -1
- package/src/commands/change-lifecycle.js +26 -9
- package/src/commands/change-next-command.js +16 -14
- package/src/commands/change-start.js +8 -26
- package/src/commands/change-status-command.js +11 -3
- package/src/commands/change-task-stats.js +64 -0
- package/src/commands/change.js +224 -35
- package/src/commands/init.js +1 -1
- package/src/commands/update.js +5 -1
- package/src/dashboard/index.html +1 -1
- package/src/governance.js +2 -0
- 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.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.
|
|
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)
|
|
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("
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: ${
|
|
215
|
-
outputLines.push(`handoff: ${
|
|
216
|
-
if (metaUpdated) outputLines.push(`meta_updated: ${
|
|
217
|
-
outputLines.push(`stamp: ${
|
|
218
|
-
return {
|
|
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("-
|
|
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("-
|
|
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("-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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",
|
|
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
|
-
|
|
105
|
-
|
|
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",
|
|
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(
|
|
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)
|
|
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("-
|
|
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
|
+
}
|
package/src/commands/change.js
CHANGED
|
@@ -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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
1457
|
-
if (
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
{
|
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/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
|
-
|
|
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);
|