@aipper/aiws 0.0.23 → 0.0.25
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 +30 -2
- package/package.json +2 -2
- package/src/cli.js +45 -2
- package/src/codex-skills.js +4 -4
- package/src/commands/change-advice.js +188 -0
- package/src/commands/change-evidence-command.js +242 -0
- package/src/commands/change-evidence-entry.js +40 -0
- package/src/commands/change-evidence.js +338 -0
- package/src/commands/change-finish.js +484 -0
- package/src/commands/change-git-status.js +53 -0
- package/src/commands/change-lifecycle-entry.js +60 -0
- package/src/commands/change-lifecycle.js +219 -0
- package/src/commands/change-metrics-command.js +110 -0
- package/src/commands/change-next-command.js +127 -0
- package/src/commands/change-review-gates.js +315 -0
- package/src/commands/change-scope-gate.js +34 -0
- package/src/commands/change-start.js +138 -0
- package/src/commands/change-state-command.js +40 -0
- package/src/commands/change-status-command.js +126 -0
- package/src/commands/change-status-context.js +202 -0
- package/src/commands/change-validate-entry.js +45 -0
- package/src/commands/change-validate.js +127 -0
- package/src/commands/change.js +436 -1291
- package/src/commands/dashboard.js +38 -12
- package/src/commands/hooks-install.js +1 -2
- package/src/commands/hooks-status.js +9 -1
- package/src/commands/opencode-status.js +61 -0
- package/src/commands/validate.js +1 -1
- package/src/dashboard/app.js +205 -2
- package/src/dashboard/index.html +5 -2
- package/src/dashboard/style.css +86 -0
- package/src/governance.js +157 -0
- package/src/json-schema-lite.js +164 -0
- package/src/opencode-env.js +76 -0
- package/src/spec.js +131 -0
package/README.md
CHANGED
|
@@ -7,7 +7,10 @@ AI Workspace CLI:把 Claude Code / OpenCode / Codex / iFlow 对齐到同一套
|
|
|
7
7
|
- 强门禁校验:`aiws validate`(可选证据落盘 `--stamp`)
|
|
8
8
|
- 回滚:`aiws rollback`(从 `.aiws/backups/` 恢复)
|
|
9
9
|
- 变更工件工作流(脱离 dotfiles):`aiws change ...`
|
|
10
|
+
- 本地治理看板:`aiws dashboard serve`
|
|
11
|
+
- OpenCode/oMo 运行时探测:`aiws opencode status`
|
|
10
12
|
- Git hooks:`aiws hooks install/status`
|
|
13
|
+
- 跨工具 native 投影:Codex repo skills、Claude skills、OpenCode skills/commands
|
|
11
14
|
- Codex:repo skills(推荐)+ 可选全局 skills 安装
|
|
12
15
|
|
|
13
16
|
真值来源(SSOT):`@aipper/aiws-spec`(模板与契约)。
|
|
@@ -41,25 +44,50 @@ aiws validate [path] [--stamp]
|
|
|
41
44
|
aiws rollback [path] <timestamp|latest>
|
|
42
45
|
|
|
43
46
|
aiws change <list|new|start|status|next|sync|validate|archive|templates>
|
|
47
|
+
aiws dashboard serve [--host 127.0.0.1] [--port 3456]
|
|
48
|
+
aiws opencode status [path]
|
|
44
49
|
aiws hooks <install|status>
|
|
45
50
|
aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status|uninstall-prompts>
|
|
46
51
|
```
|
|
47
52
|
|
|
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
|
+
|
|
48
61
|
## Codex(repo skills 优先)
|
|
49
62
|
|
|
63
|
+
- 若你不想先判断当前该走 plan、dev、review 还是 finish:先用 `$using-aiws`
|
|
50
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`
|
|
51
68
|
- 交付收尾(submodules+superproject 分步提交 + 安全合并):`$ws-deliver`
|
|
52
|
-
- 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward)
|
|
53
|
-
- 可选:安装全局 skills 到 `~/.
|
|
69
|
+
- 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward;现在会硬阻断缺失的 review gate)
|
|
70
|
+
- 可选:安装全局 skills 到 `~/.agents/skills/`:
|
|
54
71
|
- `aiws codex install-skills`
|
|
55
72
|
- `aiws codex status-skills`
|
|
56
73
|
- `aiws codex uninstall-skills`
|
|
74
|
+
- 测试/沙箱场景可用 `--dir <path>`;若设置 `CODEX_HOME`,全局 skills 会解析到 `$CODEX_HOME/.agents/skills`
|
|
57
75
|
- legacy prompts(deprecated,仅兼容):`aiws codex install-prompts`
|
|
58
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
|
+
|
|
59
84
|
## 证据落盘与协作
|
|
60
85
|
|
|
61
86
|
- `aiws validate --stamp` 会写入:`.agentdocs/tmp/aiws-validate/*.json`(默认被 `.gitignore` 忽略)
|
|
62
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 做硬阻断
|
|
63
91
|
- 建议把“可协作/可审计”的内容放进 `changes/<change-id>/`(proposal/tasks/design)并提交;`.agentdocs/tmp/` 作为本地缓存更稳
|
|
64
92
|
|
|
65
93
|
## 运行要求
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/aiws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
|
|
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.25"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { codexStatusSkillsCommand } from "./commands/codex-status-skills.js";
|
|
|
14
14
|
import { codexUninstallSkillsCommand } from "./commands/codex-uninstall-skills.js";
|
|
15
15
|
import { hooksInstallCommand } from "./commands/hooks-install.js";
|
|
16
16
|
import { hooksStatusCommand } from "./commands/hooks-status.js";
|
|
17
|
+
import { opencodeStatusCommand } from "./commands/opencode-status.js";
|
|
17
18
|
import { dashboardServeCommand } from "./commands/dashboard.js";
|
|
18
19
|
import {
|
|
19
20
|
changeArchiveCommand,
|
|
@@ -46,6 +47,10 @@ export async function cliMain(argv) {
|
|
|
46
47
|
printHooksHelp();
|
|
47
48
|
return 0;
|
|
48
49
|
}
|
|
50
|
+
if (args[0] === "opencode" && (args.includes("-h") || args.includes("--help"))) {
|
|
51
|
+
printOpenCodeHelp();
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
49
54
|
if (args[0] === "dashboard" && (args.includes("-h") || args.includes("--help"))) {
|
|
50
55
|
printDashboardHelp();
|
|
51
56
|
return 0;
|
|
@@ -242,6 +247,23 @@ export async function cliMain(argv) {
|
|
|
242
247
|
throw new UserError(`Unknown dashboard subcommand: ${sub}`, { details: "Use `aiws dashboard --help` to see available subcommands." });
|
|
243
248
|
}
|
|
244
249
|
}
|
|
250
|
+
case "opencode": {
|
|
251
|
+
const sub = args.shift();
|
|
252
|
+
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
253
|
+
printOpenCodeHelp();
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
switch (sub) {
|
|
257
|
+
case "status": {
|
|
258
|
+
const { positionals } = parseArgs(args, {});
|
|
259
|
+
const targetPath = positionals[0] ?? ".";
|
|
260
|
+
await opencodeStatusCommand({ targetPath });
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
default:
|
|
264
|
+
throw new UserError(`Unknown opencode subcommand: ${sub}`, { details: "Use `aiws opencode --help` to see available subcommands." });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
245
267
|
case "change": {
|
|
246
268
|
const sub = args.shift();
|
|
247
269
|
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
@@ -370,12 +392,16 @@ export async function cliMain(argv) {
|
|
|
370
392
|
const { positionals, options } = parseArgs(args, {
|
|
371
393
|
into: { type: "string" },
|
|
372
394
|
base: { type: "string" },
|
|
395
|
+
push: { type: "boolean" },
|
|
396
|
+
remote: { type: "string" },
|
|
373
397
|
});
|
|
374
398
|
const changeId = positionals[0];
|
|
375
399
|
await changeFinishCommand({
|
|
376
400
|
changeId,
|
|
377
401
|
into: options.into,
|
|
378
402
|
base: options.base,
|
|
403
|
+
push: options.push === true,
|
|
404
|
+
remote: options.remote,
|
|
379
405
|
});
|
|
380
406
|
return 0;
|
|
381
407
|
}
|
|
@@ -429,6 +455,7 @@ Usage:
|
|
|
429
455
|
aiws change <subcommand>
|
|
430
456
|
aiws metrics <subcommand>
|
|
431
457
|
aiws dashboard <subcommand>
|
|
458
|
+
aiws opencode <subcommand>
|
|
432
459
|
aiws codex <subcommand>
|
|
433
460
|
aiws hooks <subcommand>
|
|
434
461
|
|
|
@@ -454,7 +481,8 @@ Usage:
|
|
|
454
481
|
Notes:
|
|
455
482
|
- Prefer repo skills: .agents/skills (no install needed)
|
|
456
483
|
- Prefer global skills over legacy prompts
|
|
457
|
-
- Default skills dir: ~/.
|
|
484
|
+
- Default skills dir: ~/.agents/skills
|
|
485
|
+
- Compatibility/test override: $CODEX_HOME/.agents/skills or --dir <path>
|
|
458
486
|
- install-prompts is legacy (prompts are deprecated)
|
|
459
487
|
- Default target dir: ~/.codex/prompts
|
|
460
488
|
- If CODEX_HOME is set: $CODEX_HOME/prompts
|
|
@@ -474,6 +502,18 @@ Notes:
|
|
|
474
502
|
`);
|
|
475
503
|
}
|
|
476
504
|
|
|
505
|
+
function printOpenCodeHelp() {
|
|
506
|
+
console.log(`aiws opencode
|
|
507
|
+
|
|
508
|
+
Usage:
|
|
509
|
+
aiws opencode status [path]
|
|
510
|
+
|
|
511
|
+
Notes:
|
|
512
|
+
- Detects project-local .opencode/ and oh-my-opencode (oMo) config.
|
|
513
|
+
- Helps verify whether AIWS OpenCode skills should prefer oMo agents or fallback to standard-opencode.
|
|
514
|
+
`);
|
|
515
|
+
}
|
|
516
|
+
|
|
477
517
|
function printDashboardHelp() {
|
|
478
518
|
console.log(`aiws dashboard
|
|
479
519
|
|
|
@@ -482,6 +522,8 @@ Usage:
|
|
|
482
522
|
|
|
483
523
|
Notes:
|
|
484
524
|
- Local-only dashboard, no external dependencies.
|
|
525
|
+
- Dashboard also exposes workflow governance data sourced from @aipper/aiws-spec:
|
|
526
|
+
/api/changes, /api/workflow-stages, /api/change/<id>/validate?strict=1
|
|
485
527
|
- Recommended: run change quality gate first (AI tools: $ws-plan-verify):
|
|
486
528
|
aiws change validate <change-id> --strict
|
|
487
529
|
`);
|
|
@@ -493,7 +535,7 @@ function printChangeHelp() {
|
|
|
493
535
|
Usage:
|
|
494
536
|
aiws change list
|
|
495
537
|
aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>]
|
|
496
|
-
aiws change finish [change-id] [--into <branch> | --base <branch>]
|
|
538
|
+
aiws change finish [change-id] [--into <branch> | --base <branch>] [--push] [--remote <name>]
|
|
497
539
|
aiws change new <change-id> [--title <title>] [--no-design]
|
|
498
540
|
aiws change status [change-id]
|
|
499
541
|
aiws change next [change-id]
|
|
@@ -513,6 +555,7 @@ Notes:
|
|
|
513
555
|
(equivalent to running $ws-plan-verify in AI tools).
|
|
514
556
|
- status/next now prioritize quality-gate guidance before coding ($ws-dev).
|
|
515
557
|
- start refuses to switch/create worktree with a dirty working tree (unless --allow-dirty).
|
|
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.
|
|
516
559
|
- If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
|
|
517
560
|
start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
|
|
518
561
|
- archive runs strict validation and (by default) requires all tasks checked.
|
package/src/codex-skills.js
CHANGED
|
@@ -7,16 +7,16 @@ import { UserError } from "./errors.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Resolve Codex global skills directory:
|
|
9
9
|
* - If `--dir` provided, use it directly.
|
|
10
|
-
* - Else if `CODEX_HOME` is set, use `$CODEX_HOME/skills
|
|
11
|
-
* - Else use `~/.
|
|
10
|
+
* - Else if `CODEX_HOME` is set, use `$CODEX_HOME/.agents/skills` as a compatibility/test override root.
|
|
11
|
+
* - Else use `~/.agents/skills`.
|
|
12
12
|
*
|
|
13
13
|
* @param {string | undefined} dirOption
|
|
14
14
|
*/
|
|
15
15
|
export function resolveCodexSkillsDir(dirOption) {
|
|
16
16
|
if (dirOption) return path.resolve(dirOption);
|
|
17
17
|
const envHome = String(process.env.CODEX_HOME || "").trim();
|
|
18
|
-
if (envHome) return path.join(path.resolve(envHome), "skills");
|
|
19
|
-
return path.join(os.homedir(), ".
|
|
18
|
+
if (envHome) return path.join(path.resolve(envHome), ".agents", "skills");
|
|
19
|
+
return path.join(os.homedir(), ".agents", "skills");
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function timestamp() {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} gitRoot
|
|
5
|
+
* @param {string} changeId
|
|
6
|
+
* @param {{
|
|
7
|
+
* listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>
|
|
8
|
+
* }} deps
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveChangeWorktreeHint(gitRoot, changeId, deps) {
|
|
11
|
+
try {
|
|
12
|
+
const worktrees = await deps.listGitWorktrees(gitRoot);
|
|
13
|
+
const changeRef = `refs/heads/change/${changeId}`;
|
|
14
|
+
const changeWorktree = worktrees.find((worktree) => String(worktree.branch || "") === changeRef)?.worktree || "";
|
|
15
|
+
return changeWorktree && path.resolve(changeWorktree) !== path.resolve(gitRoot) ? changeWorktree : "";
|
|
16
|
+
} catch {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} gitRoot
|
|
23
|
+
* @param {string} changeId
|
|
24
|
+
* @param {{
|
|
25
|
+
* computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
|
|
26
|
+
* currentBranch: (gitRoot: string) => Promise<string>,
|
|
27
|
+
* inferChangeIdFromBranch: (branch: string) => string,
|
|
28
|
+
* listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>,
|
|
29
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
30
|
+
* governanceGuidanceLines: (status: any) => Promise<string[]>,
|
|
31
|
+
* scopeGateFailureAction: (changeId: string) => string,
|
|
32
|
+
* scopeGatePendingRecommendation: (changeId: string) => string,
|
|
33
|
+
* scopeGatePassedRecommendation: () => string
|
|
34
|
+
* }} deps
|
|
35
|
+
*/
|
|
36
|
+
export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
|
|
37
|
+
const status = await deps.computeChangeStatus(gitRoot, changeId);
|
|
38
|
+
const branch = await deps.currentBranch(gitRoot);
|
|
39
|
+
const inferred = deps.inferChangeIdFromBranch(branch);
|
|
40
|
+
const advice = {
|
|
41
|
+
changeId,
|
|
42
|
+
branch: branch || "",
|
|
43
|
+
inferredChangeId: inferred || "",
|
|
44
|
+
phase: status.phase,
|
|
45
|
+
governance: status.governance || null,
|
|
46
|
+
scopeGate: status.scopeGate || null,
|
|
47
|
+
blockersStrict: status.blockersStrict,
|
|
48
|
+
evidence: status.evidence?.counts || null,
|
|
49
|
+
collaboration: status.collaboration?.counts || null,
|
|
50
|
+
actions: [],
|
|
51
|
+
prohibitions: [],
|
|
52
|
+
recommended: [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (!branch) {
|
|
56
|
+
advice.actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
|
|
57
|
+
} else if (inferred && inferred !== changeId) {
|
|
58
|
+
advice.actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
|
|
59
|
+
} else if (!inferred) {
|
|
60
|
+
advice.actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const changeWorktree = await resolveChangeWorktreeHint(gitRoot, changeId, deps);
|
|
64
|
+
if (changeWorktree) {
|
|
65
|
+
advice.actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWorktree}\`(建议在该目录执行开发/交付相关命令)`);
|
|
66
|
+
advice.prohibitions.push("不要在当前 worktree 继续写代码(很可能不在 change 分支上)");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (status.metaState !== "ok") advice.actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
70
|
+
if (status.driftFiles.length > 0) {
|
|
71
|
+
advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
|
|
72
|
+
advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const proposalPath = path.join(deps.changeDirAbs(gitRoot, changeId), "proposal.md");
|
|
76
|
+
const tasksPath = path.join(deps.changeDirAbs(gitRoot, changeId), "tasks.md");
|
|
77
|
+
if (status.blockersStrict.some((item) => item.includes("proposal.md") && item.includes("WS:TODO"))) {
|
|
78
|
+
advice.actions.push(`完善 proposal:\`$EDITOR ${proposalPath}\``);
|
|
79
|
+
}
|
|
80
|
+
if (status.blockersStrict.some((item) => item.includes("tasks.md") && item.includes("WS:TODO"))) {
|
|
81
|
+
advice.actions.push(`完善 tasks:\`$EDITOR ${tasksPath}\``);
|
|
82
|
+
}
|
|
83
|
+
if (status.blockersStrict.some((item) => item.includes("proposal.md missing attribution"))) {
|
|
84
|
+
advice.actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
85
|
+
}
|
|
86
|
+
if (status.blockersStrict.some((item) => item.includes("missing Contract_Row"))) {
|
|
87
|
+
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
|
|
88
|
+
}
|
|
89
|
+
if (status.blockersStrict.some((item) => item.includes("Plan_File"))) {
|
|
90
|
+
advice.actions.push("补齐绑定:修正 proposal.md 的 `Plan_File`(严格校验需要;必须存在且仅 1 个)");
|
|
91
|
+
}
|
|
92
|
+
if (status.blockersStrict.some((item) => item.includes("missing Evidence_Path"))) {
|
|
93
|
+
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
|
|
94
|
+
}
|
|
95
|
+
if (status.scopeGate?.status === "failed") {
|
|
96
|
+
advice.actions.push(deps.scopeGateFailureAction(changeId));
|
|
97
|
+
}
|
|
98
|
+
if (status.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${status.tasks.unchecked} 项)`);
|
|
99
|
+
if ((status.collaboration?.counts?.analysis || 0) > 0 && (status.collaboration?.counts?.review || 0) === 0) {
|
|
100
|
+
advice.actions.push("已有委托分析但还没有 review 汇总结论:建议在 `changes/<id>/review/` 落盘结论后再交付");
|
|
101
|
+
}
|
|
102
|
+
if ((status.collaboration?.counts?.patches || 0) > 0) {
|
|
103
|
+
advice.actions.push("存在委托 patch 草案:提交/交付前先人工审查 `changes/<id>/patches/`,不要直接无审查应用");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status.blockersStrict.length > 0 && advice.actions.length === 0) {
|
|
107
|
+
advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (status.blockersStrict.length === 0 && status.tasks.unchecked === 0) {
|
|
111
|
+
for (const line of await deps.governanceGuidanceLines(status)) advice.recommended.push(line);
|
|
112
|
+
if (advice.evidence && advice.evidence.persistent === 0) {
|
|
113
|
+
advice.recommended.push("交付前建议生成持久证据并回填:`aiws change evidence <change-id>`");
|
|
114
|
+
}
|
|
115
|
+
if (status.scopeGate?.status === "not_run") {
|
|
116
|
+
advice.recommended.push(deps.scopeGatePendingRecommendation(changeId));
|
|
117
|
+
} else if (status.scopeGate?.status === "passed") {
|
|
118
|
+
advice.recommended.push(deps.scopeGatePassedRecommendation());
|
|
119
|
+
}
|
|
120
|
+
if ((status.governance?.recommendedStage || "") === "ws-finish" || (status.governance?.currentStage || "") === "ws-commit") {
|
|
121
|
+
advice.recommended.push("交付前门禁(推荐落盘):`aiws validate . --stamp`");
|
|
122
|
+
}
|
|
123
|
+
if ((status.collaboration?.counts?.review || 0) > 1) advice.recommended.push("存在多份 review:交付前建议在 handoff 或 review 汇总里收敛最终结论");
|
|
124
|
+
} else if (advice.actions.length === 0) {
|
|
125
|
+
advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
126
|
+
if ((status.collaboration?.counts?.analysis || 0) > 0) advice.recommended.push("已有委托分析:进入实现前先收敛 `changes/<id>/analysis/` 结论");
|
|
127
|
+
if ((status.collaboration?.counts?.patches || 0) > 0) advice.recommended.push("已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return advice;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {{
|
|
135
|
+
* changeId: string,
|
|
136
|
+
* gitRoot: string,
|
|
137
|
+
* now: string,
|
|
138
|
+
* status: any,
|
|
139
|
+
* advice: any,
|
|
140
|
+
* planVerifyLine: string
|
|
141
|
+
* }} input
|
|
142
|
+
*/
|
|
143
|
+
export function renderChangeStateMarkdown(input) {
|
|
144
|
+
/** @type {string[]} */
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push(`# AIWS STATE: ${input.changeId}`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
lines.push(`Generated: ${input.now}`);
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push("## Context");
|
|
151
|
+
lines.push(`- Worktree: \`${input.gitRoot}\``);
|
|
152
|
+
lines.push(`- Branch: \`${input.advice.branch || "(detached)"}\``);
|
|
153
|
+
lines.push("");
|
|
154
|
+
lines.push("## Phase");
|
|
155
|
+
lines.push(`- Phase: \`${input.status.phase}\``);
|
|
156
|
+
lines.push(`- Tasks: ${input.status.tasks.done}/${input.status.tasks.total} (unchecked=${input.status.tasks.unchecked})`);
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push("## Bindings");
|
|
159
|
+
if (input.status.reqId) lines.push(`- Req_ID: \`${input.status.reqId}\``);
|
|
160
|
+
if (input.status.probId) lines.push(`- Problem_ID: \`${input.status.probId}\``);
|
|
161
|
+
if (input.status.bindings?.contractRow) lines.push(`- Contract_Row: \`${input.status.bindings.contractRow}\``);
|
|
162
|
+
if (input.status.bindings?.planFile) lines.push(`- Plan_File: \`${input.status.bindings.planFile}\``);
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push("## Evidence");
|
|
165
|
+
if (input.status.evidence?.counts) {
|
|
166
|
+
const counts = input.status.evidence.counts;
|
|
167
|
+
lines.push(
|
|
168
|
+
`- Declared: \`${counts.total}\` (exists=\`${counts.exists}\`, missing=\`${counts.missing}\`, persistent=\`${counts.persistent}\`, tmp=\`${counts.tmp}\`)`,
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push("- (unknown)");
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push("## Blockers (strict)");
|
|
175
|
+
if (input.status.blockersStrict.length === 0) lines.push("- (none)");
|
|
176
|
+
else for (const blocker of input.status.blockersStrict) lines.push(`- ${blocker}`);
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push("## Next");
|
|
179
|
+
lines.push(`- ${input.planVerifyLine}`);
|
|
180
|
+
for (const action of input.advice.actions) lines.push(`- ${action}`);
|
|
181
|
+
for (const recommendation of input.advice.recommended) lines.push(`- ${recommendation}`);
|
|
182
|
+
if (input.advice.prohibitions.length > 0) {
|
|
183
|
+
lines.push("");
|
|
184
|
+
lines.push("## Do Not");
|
|
185
|
+
for (const prohibition of input.advice.prohibitions) lines.push(`- ${prohibition}`);
|
|
186
|
+
}
|
|
187
|
+
return lines.join("\n") + "\n";
|
|
188
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
buildDeliverySummary,
|
|
5
|
+
collectCollaborationArtifacts,
|
|
6
|
+
findLatestFileByMtime,
|
|
7
|
+
formatDeclaredValues,
|
|
8
|
+
mergeDeclaredValues,
|
|
9
|
+
relFromRoot,
|
|
10
|
+
upsertIdLine,
|
|
11
|
+
upsertProposalEvidencePath,
|
|
12
|
+
} from "./change-evidence.js";
|
|
13
|
+
import { TMP_REVIEW_FALLBACKS, tmpReviewFallbackPath } from "./change-review-gates.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* gitRoot: string,
|
|
18
|
+
* changeId: string,
|
|
19
|
+
* changeDir: string,
|
|
20
|
+
* proposalText: string,
|
|
21
|
+
* options: { noValidate: boolean, allowFail: boolean }
|
|
22
|
+
* }} input
|
|
23
|
+
* @param {{
|
|
24
|
+
* appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
|
|
25
|
+
* computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
|
|
26
|
+
* currentBranch: (gitRoot: string) => Promise<string>,
|
|
27
|
+
* ensureDir: (path: string) => Promise<void>,
|
|
28
|
+
* extractId: (label: string, text: string) => string,
|
|
29
|
+
* nowIsoUtc: () => string,
|
|
30
|
+
* nowStampUtc: () => string,
|
|
31
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
32
|
+
* planVerifyHint: (changeId: string) => string,
|
|
33
|
+
* readText: (path: string) => Promise<string>,
|
|
34
|
+
* splitDeclaredValues: (s: string) => string[],
|
|
35
|
+
* validateChangeArtifacts: (gitRoot: string, changeId: string, options: any) => Promise<any>,
|
|
36
|
+
* writeText: (path: string, content: string) => Promise<void>,
|
|
37
|
+
* UserError: typeof import("../errors.js").UserError
|
|
38
|
+
* }} deps
|
|
39
|
+
*/
|
|
40
|
+
export async function runChangeEvidenceWorkflow(input, deps) {
|
|
41
|
+
const { gitRoot, changeId, changeDir, proposalText, options } = input;
|
|
42
|
+
const planDecl = deps.extractId("Plan_File", proposalText) || deps.extractId("Plan file", proposalText);
|
|
43
|
+
const planFiles = deps.splitDeclaredValues(planDecl).map((item) => String(item || "").replace(/^\.\//, ""));
|
|
44
|
+
const planRel = planFiles.length === 1 ? planFiles[0] : "";
|
|
45
|
+
const planAbs = planRel ? path.join(gitRoot, planRel) : "";
|
|
46
|
+
const planText = planAbs && (await deps.pathExists(planAbs)) ? await deps.readText(planAbs) : "";
|
|
47
|
+
|
|
48
|
+
const now = deps.nowStampUtc();
|
|
49
|
+
const evidenceDir = path.join(changeDir, "evidence");
|
|
50
|
+
const reviewDir = path.join(changeDir, "review");
|
|
51
|
+
const analysisDir = path.join(changeDir, "analysis");
|
|
52
|
+
const patchesDir = path.join(changeDir, "patches");
|
|
53
|
+
await deps.ensureDir(evidenceDir);
|
|
54
|
+
await deps.ensureDir(reviewDir);
|
|
55
|
+
await deps.ensureDir(analysisDir);
|
|
56
|
+
await deps.ensureDir(patchesDir);
|
|
57
|
+
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const created = [];
|
|
60
|
+
const branch = await deps.currentBranch(gitRoot);
|
|
61
|
+
const status = await deps.computeChangeStatus(gitRoot, changeId);
|
|
62
|
+
|
|
63
|
+
const statusPath = path.join(evidenceDir, `change-status-${now}.json`);
|
|
64
|
+
await deps.writeText(
|
|
65
|
+
statusPath,
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
{
|
|
68
|
+
generated_at: deps.nowIsoUtc(),
|
|
69
|
+
ws_root: gitRoot,
|
|
70
|
+
change_id: changeId,
|
|
71
|
+
branch: branch || "",
|
|
72
|
+
phase: status.phase,
|
|
73
|
+
tasks: status.tasks,
|
|
74
|
+
bindings: status.bindings,
|
|
75
|
+
truth: {
|
|
76
|
+
baseline: { label: status.baselineLabel, at: status.baselineAt },
|
|
77
|
+
drift_files: status.driftFiles,
|
|
78
|
+
},
|
|
79
|
+
evidence: status.evidence?.counts || null,
|
|
80
|
+
collaboration: status.collaboration?.counts || null,
|
|
81
|
+
note: "aiws change evidence status snapshot; does not contain secrets.",
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
) + "\n",
|
|
86
|
+
);
|
|
87
|
+
created.push(relFromRoot(gitRoot, statusPath));
|
|
88
|
+
|
|
89
|
+
/** @type {any | null} */
|
|
90
|
+
let validateRes = null;
|
|
91
|
+
let validateFailed = false;
|
|
92
|
+
let validatePathForError = "";
|
|
93
|
+
if (!options.noValidate) {
|
|
94
|
+
validateRes = await deps.validateChangeArtifacts(gitRoot, changeId, { strict: true, allowTruthDrift: false });
|
|
95
|
+
const validatePath = path.join(evidenceDir, `change-validate-strict-${now}.json`);
|
|
96
|
+
await deps.writeText(validatePath, JSON.stringify(validateRes, null, 2) + "\n");
|
|
97
|
+
created.push(relFromRoot(gitRoot, validatePath));
|
|
98
|
+
validateFailed = validateRes.ok !== true;
|
|
99
|
+
validatePathForError = validatePath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const validateStampDir = path.join(gitRoot, ".agentdocs", "tmp", "aiws-validate");
|
|
103
|
+
const latestValidateStamp = await findLatestFileByMtime(validateStampDir, (name) => name.endsWith(".json"));
|
|
104
|
+
if (latestValidateStamp) {
|
|
105
|
+
const dest = path.join(evidenceDir, `aiws-validate-stamp-${now}.json`);
|
|
106
|
+
await fs.copyFile(latestValidateStamp, dest);
|
|
107
|
+
created.push(relFromRoot(gitRoot, dest));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const syncStampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
111
|
+
const latestSyncStamp = await findLatestFileByMtime(syncStampDir, (name) => name.endsWith(`-${changeId}.json`));
|
|
112
|
+
if (latestSyncStamp) {
|
|
113
|
+
const dest = path.join(evidenceDir, `change-sync-stamp-${now}.json`);
|
|
114
|
+
await fs.copyFile(latestSyncStamp, dest);
|
|
115
|
+
created.push(relFromRoot(gitRoot, dest));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const { fileName, persistentKind } of TMP_REVIEW_FALLBACKS) {
|
|
119
|
+
const src = tmpReviewFallbackPath(gitRoot, fileName);
|
|
120
|
+
if (!(await deps.pathExists(src))) continue;
|
|
121
|
+
const dest = persistentKind === "evidence" ? path.join(evidenceDir, fileName) : path.join(reviewDir, fileName);
|
|
122
|
+
if (!(await deps.pathExists(dest))) {
|
|
123
|
+
await fs.copyFile(src, dest);
|
|
124
|
+
}
|
|
125
|
+
const rel = relFromRoot(gitRoot, dest);
|
|
126
|
+
if (!created.includes(rel)) created.push(rel);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (await deps.pathExists(reviewDir)) {
|
|
130
|
+
try {
|
|
131
|
+
const reviewEntries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
132
|
+
for (const entry of reviewEntries) {
|
|
133
|
+
if (!entry.isFile()) continue;
|
|
134
|
+
if (entry.name === "codex-review.md") continue;
|
|
135
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
136
|
+
const rel = relFromRoot(gitRoot, path.join(reviewDir, entry.name));
|
|
137
|
+
if (!created.includes(rel)) created.push(rel);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
|
|
145
|
+
const collaborationPath = path.join(evidenceDir, `collaboration-summary-${now}.json`);
|
|
146
|
+
await deps.writeText(
|
|
147
|
+
collaborationPath,
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
generated_at: deps.nowIsoUtc(),
|
|
151
|
+
ws_root: gitRoot,
|
|
152
|
+
change_id: changeId,
|
|
153
|
+
counts: collaboration.counts,
|
|
154
|
+
dirs: collaboration.dirs,
|
|
155
|
+
note: "aiws collaboration summary; patch files are drafts and must be reviewed before apply.",
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
) + "\n",
|
|
160
|
+
);
|
|
161
|
+
created.push(relFromRoot(gitRoot, collaborationPath));
|
|
162
|
+
|
|
163
|
+
const summaryAbs = path.join(evidenceDir, `delivery-summary-${now}.md`);
|
|
164
|
+
await deps.writeText(
|
|
165
|
+
summaryAbs,
|
|
166
|
+
buildDeliverySummary({
|
|
167
|
+
gitRoot,
|
|
168
|
+
changeId,
|
|
169
|
+
branch: branch || "",
|
|
170
|
+
status,
|
|
171
|
+
collaboration,
|
|
172
|
+
created,
|
|
173
|
+
validateRes,
|
|
174
|
+
validateFailed,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
created.push(relFromRoot(gitRoot, summaryAbs));
|
|
178
|
+
|
|
179
|
+
const declaredEvidence = deps.extractId("Evidence_Path", proposalText) || deps.extractId("Evidence_Path(s)", proposalText);
|
|
180
|
+
const existingProposalEvidence = deps.splitDeclaredValues(declaredEvidence).map((item) => String(item || "").replace(/^\.\//, ""));
|
|
181
|
+
const mergedEvidence = mergeDeclaredValues(existingProposalEvidence, created);
|
|
182
|
+
const mergedEvidenceText = formatDeclaredValues(mergedEvidence);
|
|
183
|
+
|
|
184
|
+
const updatedProposal = upsertProposalEvidencePath(proposalText, mergedEvidenceText);
|
|
185
|
+
if (updatedProposal.changed) {
|
|
186
|
+
await deps.writeText(path.join(changeDir, "proposal.md"), updatedProposal.text);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let planUpdated = false;
|
|
190
|
+
if (planAbs && planText) {
|
|
191
|
+
const existingPlanEvidence = deps
|
|
192
|
+
.splitDeclaredValues(deps.extractId("Evidence_Path", planText) || deps.extractId("Evidence_Path(s)", planText))
|
|
193
|
+
.map((item) => String(item || "").replace(/^\.\//, ""));
|
|
194
|
+
const mergedPlanEvidence = mergeDeclaredValues(existingPlanEvidence, mergedEvidence);
|
|
195
|
+
const mergedPlanEvidenceText = formatDeclaredValues(mergedPlanEvidence);
|
|
196
|
+
const updatedPlan = upsertIdLine(planText, ["Evidence_Path", "Evidence_Path(s)"], mergedPlanEvidenceText, { insertAfterLabels: ["Plan_File", "Contract_Row"] });
|
|
197
|
+
if (updatedPlan.changed) {
|
|
198
|
+
await deps.writeText(planAbs, updatedPlan.text);
|
|
199
|
+
planUpdated = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await deps.appendMetricsEvent(gitRoot, changeId, "evidence", {
|
|
204
|
+
generated_at: deps.nowIsoUtc(),
|
|
205
|
+
created: created.length,
|
|
206
|
+
strict_validate: options.noValidate ? "skipped" : validateRes && validateRes.ok === true ? "ok" : "failed",
|
|
207
|
+
allow_fail: options.allowFail === true,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/** @type {string[]} */
|
|
211
|
+
const outputLines = [];
|
|
212
|
+
outputLines.push(`✓ aiws change evidence: ${changeId}`);
|
|
213
|
+
outputLines.push(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
214
|
+
if (planRel) outputLines.push(`Plan_File: ${planRel}`);
|
|
215
|
+
outputLines.push("created:");
|
|
216
|
+
if (created.length > 0) {
|
|
217
|
+
for (const item of created) outputLines.push(`- ${item}`);
|
|
218
|
+
} else {
|
|
219
|
+
outputLines.push("- (none)");
|
|
220
|
+
}
|
|
221
|
+
outputLines.push("updated:");
|
|
222
|
+
outputLines.push(`- ${path.relative(gitRoot, path.join(changeDir, "proposal.md"))} (Evidence_Path)`);
|
|
223
|
+
if (planRel && planText && planUpdated) outputLines.push(`- ${planRel} (Evidence_Path)`);
|
|
224
|
+
else if (planRel && planText) outputLines.push(`- ${planRel} (Evidence_Path)`);
|
|
225
|
+
outputLines.push("");
|
|
226
|
+
outputLines.push("next:");
|
|
227
|
+
outputLines.push(`- ${deps.planVerifyHint(changeId)}`);
|
|
228
|
+
if (validateFailed) outputLines.push("- fix validation errors, then re-run evidence (or run with --allow-fail to proceed)");
|
|
229
|
+
else outputLines.push(`- verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
230
|
+
|
|
231
|
+
let errorToThrow = null;
|
|
232
|
+
if (validateFailed && !options.allowFail) {
|
|
233
|
+
const details = (validateRes && validateRes.raw ? String(validateRes.raw) : "").trim() || "Validation failed (see evidence JSON for details).";
|
|
234
|
+
const hint = validatePathForError ? `\n\nevidence:\n- ${path.relative(gitRoot, validatePathForError)}` : "";
|
|
235
|
+
errorToThrow = new deps.UserError("Failed to generate evidence due to strict validation errors.", { details: `${details}${hint}` });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
output: outputLines.join("\n") + "\n",
|
|
240
|
+
errorToThrow,
|
|
241
|
+
};
|
|
242
|
+
}
|