@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.
Files changed (35) hide show
  1. package/README.md +30 -2
  2. package/package.json +2 -2
  3. package/src/cli.js +45 -2
  4. package/src/codex-skills.js +4 -4
  5. package/src/commands/change-advice.js +188 -0
  6. package/src/commands/change-evidence-command.js +242 -0
  7. package/src/commands/change-evidence-entry.js +40 -0
  8. package/src/commands/change-evidence.js +338 -0
  9. package/src/commands/change-finish.js +484 -0
  10. package/src/commands/change-git-status.js +53 -0
  11. package/src/commands/change-lifecycle-entry.js +60 -0
  12. package/src/commands/change-lifecycle.js +219 -0
  13. package/src/commands/change-metrics-command.js +110 -0
  14. package/src/commands/change-next-command.js +127 -0
  15. package/src/commands/change-review-gates.js +315 -0
  16. package/src/commands/change-scope-gate.js +34 -0
  17. package/src/commands/change-start.js +138 -0
  18. package/src/commands/change-state-command.js +40 -0
  19. package/src/commands/change-status-command.js +126 -0
  20. package/src/commands/change-status-context.js +202 -0
  21. package/src/commands/change-validate-entry.js +45 -0
  22. package/src/commands/change-validate.js +127 -0
  23. package/src/commands/change.js +436 -1291
  24. package/src/commands/dashboard.js +38 -12
  25. package/src/commands/hooks-install.js +1 -2
  26. package/src/commands/hooks-status.js +9 -1
  27. package/src/commands/opencode-status.js +61 -0
  28. package/src/commands/validate.js +1 -1
  29. package/src/dashboard/app.js +205 -2
  30. package/src/dashboard/index.html +5 -2
  31. package/src/dashboard/style.css +86 -0
  32. package/src/governance.js +157 -0
  33. package/src/json-schema-lite.js +164 -0
  34. package/src/opencode-env.js +76 -0
  35. 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 到 `~/.codex/skills/`(或 `$CODEX_HOME/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.23",
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.23"
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: ~/.codex/skills (or $CODEX_HOME/skills)
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.
@@ -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 `~/.codex/skills`.
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(), ".codex", "skills");
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
+ }