@aipper/aiws 0.0.24 → 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 +39 -1
- 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 +423 -1673
- package/src/commands/dashboard.js +38 -12
- package/src/commands/opencode-status.js +61 -0
- 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") {
|
|
@@ -433,6 +455,7 @@ Usage:
|
|
|
433
455
|
aiws change <subcommand>
|
|
434
456
|
aiws metrics <subcommand>
|
|
435
457
|
aiws dashboard <subcommand>
|
|
458
|
+
aiws opencode <subcommand>
|
|
436
459
|
aiws codex <subcommand>
|
|
437
460
|
aiws hooks <subcommand>
|
|
438
461
|
|
|
@@ -458,7 +481,8 @@ Usage:
|
|
|
458
481
|
Notes:
|
|
459
482
|
- Prefer repo skills: .agents/skills (no install needed)
|
|
460
483
|
- Prefer global skills over legacy prompts
|
|
461
|
-
- Default skills dir: ~/.
|
|
484
|
+
- Default skills dir: ~/.agents/skills
|
|
485
|
+
- Compatibility/test override: $CODEX_HOME/.agents/skills or --dir <path>
|
|
462
486
|
- install-prompts is legacy (prompts are deprecated)
|
|
463
487
|
- Default target dir: ~/.codex/prompts
|
|
464
488
|
- If CODEX_HOME is set: $CODEX_HOME/prompts
|
|
@@ -478,6 +502,18 @@ Notes:
|
|
|
478
502
|
`);
|
|
479
503
|
}
|
|
480
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
|
+
|
|
481
517
|
function printDashboardHelp() {
|
|
482
518
|
console.log(`aiws dashboard
|
|
483
519
|
|
|
@@ -486,6 +522,8 @@ Usage:
|
|
|
486
522
|
|
|
487
523
|
Notes:
|
|
488
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
|
|
489
527
|
- Recommended: run change quality gate first (AI tools: $ws-plan-verify):
|
|
490
528
|
aiws change validate <change-id> --strict
|
|
491
529
|
`);
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* gitRoot: string,
|
|
6
|
+
* changeId: string,
|
|
7
|
+
* options: { noValidate: boolean, allowFail: boolean }
|
|
8
|
+
* }} input
|
|
9
|
+
* @param {{
|
|
10
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
11
|
+
* fileState: (changeDir: string, rel: string) => Promise<any>,
|
|
12
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
13
|
+
* runChangeEvidenceWorkflow: (input: any, deps: any) => Promise<{ output: string, errorToThrow: any }>,
|
|
14
|
+
* workflowDeps: Record<string, any>,
|
|
15
|
+
* UserError: typeof import("../errors.js").UserError
|
|
16
|
+
* }} deps
|
|
17
|
+
*/
|
|
18
|
+
export async function runChangeEvidenceCommand(input, deps) {
|
|
19
|
+
const { gitRoot, changeId, options } = input;
|
|
20
|
+
const changeDir = deps.changeDirAbs(gitRoot, changeId);
|
|
21
|
+
if (!(await deps.pathExists(changeDir))) {
|
|
22
|
+
throw new deps.UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const proposal = await deps.fileState(changeDir, "proposal.md");
|
|
26
|
+
if (proposal.state !== "ok") {
|
|
27
|
+
throw new deps.UserError("proposal.md is required for evidence generation.", { details: path.relative(gitRoot, proposal.abs) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return deps.runChangeEvidenceWorkflow(
|
|
31
|
+
{
|
|
32
|
+
gitRoot,
|
|
33
|
+
changeId,
|
|
34
|
+
changeDir,
|
|
35
|
+
proposalText: proposal.text,
|
|
36
|
+
options,
|
|
37
|
+
},
|
|
38
|
+
deps.workflowDeps,
|
|
39
|
+
);
|
|
40
|
+
}
|