@aipper/aiws 0.0.27 → 0.0.29

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 CHANGED
@@ -12,7 +12,7 @@
12
12
  - `aiws change ...`:变更工件工作流
13
13
  - `aiws dashboard serve`:本地 change/dashboard API
14
14
  - `aiws hooks install/status`
15
- - `aiws opencode status`
15
+ - `aiws opencode status/supervise`
16
16
  - `aiws codex install-skills/status-skills/uninstall-skills`
17
17
 
18
18
  支持工具:
@@ -55,6 +55,8 @@ AI 工具内:
55
55
  - simple/local 单点修复:`$ws-dev-lite`
56
56
  - 常规实现:`$ws-dev`
57
57
  - 中大型任务:`$ws-plan` → `$ws-plan-verify` → `$ws-dev`
58
+ - OpenCode/oMo 自动 bootstrap:`/ws-auto`
59
+ - OpenCode/oMo 自主协作实验:`/ws-autonomy`
58
60
  - 提交前:`$ws-review` / `$ws-commit`
59
61
  - 交付前:`$ws-deliver` / `$ws-finish`
60
62
 
@@ -75,7 +77,7 @@ aiws validate [path] [--stamp]
75
77
  aiws rollback [path] <timestamp|latest>
76
78
 
77
79
  aiws change new <change-id> [--no-design]
78
- aiws change start <change-id> [--switch|--no-switch|--worktree]
80
+ aiws change start <change-id> [--switch|--no-switch]
79
81
  aiws change status [change-id]
80
82
  aiws change next [change-id]
81
83
  aiws change sync [change-id]
@@ -87,6 +89,8 @@ aiws dashboard serve [--host 127.0.0.1] [--port 3456]
87
89
  aiws hooks install [path]
88
90
  aiws hooks status [path]
89
91
  aiws opencode status [path]
92
+ aiws opencode auto [path] [--session <name>] [--window <name>] [--once] [--poll-ms <ms>] [--no-update]
93
+ aiws opencode supervise [path] [--session <name>] [--window <name>] [--once] [--poll-ms <ms>]
90
94
  aiws codex install-skills
91
95
  aiws codex status-skills
92
96
  aiws codex uninstall-skills
@@ -116,6 +120,18 @@ aiws codex uninstall-skills
116
120
 
117
121
  目标是统一 workflow 入口,不保证工具自动发现或自动加载。
118
122
 
123
+ OpenCode/oMo 补充:
124
+ - `.opencode/oh-my-opencode.json.example` 提供 autonomy 示例配置(`prompt_append` / `backgroundTasks` / `experimental.auto_resume`)
125
+ - 同一份 oMo 示例配置还会声明 approval whitelist policy(只读命令 + evidence 写路径;宿主权限保持 `manual-only`)
126
+ - `.claude/settings.json.example` 提供 hook 兼容层示例
127
+ - `.opencode/helpers/approval-whitelist-check.sh` 把 whitelist policy 转成 `allow/deny/manual` 判定,并落审计日志
128
+ - `.opencode/helpers/approval-whitelist-run.sh` 在 `allow` 时执行简单命令,并落执行摘要
129
+ - `.opencode/helpers/approval-whitelist-watchdog.sh` 轮询队列并调用 runner,适合外部蜂群/守护进程接入
130
+ - `/ws-auto` 会先触发 `aiws opencode auto .`:必要时刷新托管内容,再按条件确保 watchdog 已在 tmux 中运行
131
+ - `aiws opencode auto .` 会在需要时自动执行 `aiws update .`;若 oMo 已启用且 tmux 可用,再继续启动 watchdog
132
+ - `aiws opencode supervise .` 会显式确保 tmux 中已有一个 watchdog window;若当前不在 tmux,则会为该工作区起一个 detached session
133
+ - `.opencode/helpers/tmux-swarm-scan.sh` / `tmux-swarm-rescue.sh` 提供可选的 tmux 巡检与安全救援 helper
134
+
119
135
  ## 运行要求
120
136
 
121
137
  - Node.js >= 20
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aiws": "./bin/aiws.js"
8
8
  },
9
9
  "dependencies": {
10
- "@aipper/aiws-spec": "0.0.27"
10
+ "@aipper/aiws-spec": "0.0.29"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/cli.js CHANGED
@@ -2,6 +2,16 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { UserError } from "./errors.js";
5
+ import { wsPullCommand } from "./commands/ws-pull.js";
6
+ import { wsPushCommand } from "./commands/ws-push.js";
7
+ import { wsMigrateCommand } from "./commands/ws-migrate.js";
8
+ import { wsSubmoduleSetupCommand } from "./commands/ws-submodule-setup.js";
9
+ import { wsCommitCommand } from "./commands/ws-commit.js";
10
+ import { wsPlanVerifyCommand } from "./commands/ws-plan-verify.js";
11
+ import { wsVerifyBeforeCompleteCommand } from "./commands/ws-verify-before-complete.js";
12
+ import { wsHandoffCommand } from "./commands/ws-handoff.js";
13
+ import { wsFinishCommand } from "./commands/ws-finish.js";
14
+ import { wsDeliverCommand } from "./commands/ws-deliver.js";
5
15
  import { initCommand } from "./commands/init.js";
6
16
  import { updateCommand } from "./commands/update.js";
7
17
  import { validateCommand } from "./commands/validate.js";
@@ -14,6 +24,8 @@ import { codexStatusSkillsCommand } from "./commands/codex-status-skills.js";
14
24
  import { codexUninstallSkillsCommand } from "./commands/codex-uninstall-skills.js";
15
25
  import { hooksInstallCommand } from "./commands/hooks-install.js";
16
26
  import { hooksStatusCommand } from "./commands/hooks-status.js";
27
+ import { opencodeAutoCommand } from "./commands/opencode-auto.js";
28
+ import { opencodeSuperviseCommand } from "./commands/opencode-supervise.js";
17
29
  import { opencodeStatusCommand } from "./commands/opencode-status.js";
18
30
  import { dashboardServeCommand } from "./commands/dashboard.js";
19
31
  import {
@@ -260,6 +272,42 @@ export async function cliMain(argv) {
260
272
  await opencodeStatusCommand({ targetPath });
261
273
  return 0;
262
274
  }
275
+ case "auto": {
276
+ const { positionals, options } = parseArgs(args, {
277
+ session: { type: "string" },
278
+ window: { type: "string" },
279
+ once: { type: "boolean" },
280
+ "poll-ms": { type: "string" },
281
+ "no-update": { type: "boolean" },
282
+ });
283
+ const targetPath = positionals[0] ?? ".";
284
+ await opencodeAutoCommand({
285
+ targetPath,
286
+ sessionName: options.session,
287
+ windowName: options.window,
288
+ once: options.once === true,
289
+ pollMs: options["poll-ms"],
290
+ noUpdate: options["no-update"] === true,
291
+ });
292
+ return 0;
293
+ }
294
+ case "supervise": {
295
+ const { positionals, options } = parseArgs(args, {
296
+ session: { type: "string" },
297
+ window: { type: "string" },
298
+ once: { type: "boolean" },
299
+ "poll-ms": { type: "string" },
300
+ });
301
+ const targetPath = positionals[0] ?? ".";
302
+ await opencodeSuperviseCommand({
303
+ targetPath,
304
+ sessionName: options.session,
305
+ windowName: options.window,
306
+ once: options.once === true,
307
+ pollMs: options["poll-ms"],
308
+ });
309
+ return 0;
310
+ }
263
311
  default:
264
312
  throw new UserError(`Unknown opencode subcommand: ${sub}`, { details: "Use `aiws opencode --help` to see available subcommands." });
265
313
  }
@@ -283,15 +331,12 @@ export async function cliMain(argv) {
283
331
  "no-switch": { type: "boolean" },
284
332
  switch: { type: "boolean" },
285
333
  "allow-dirty": { type: "boolean" },
286
- worktree: { type: "boolean" },
287
- "worktree-dir": { type: "string" },
288
- submodules: { type: "boolean" },
289
334
  });
290
335
  const changeId = positionals[0] ?? "";
291
336
  if (!changeId)
292
337
  throw new UserError("change start requires <change-id>", {
293
338
  details:
294
- "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>] [--submodules]",
339
+ "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty]",
295
340
  });
296
341
  await changeStartCommand({
297
342
  changeId,
@@ -301,9 +346,6 @@ export async function cliMain(argv) {
301
346
  noSwitch: options["no-switch"] === true,
302
347
  forceSwitch: options.switch === true,
303
348
  allowDirty: options["allow-dirty"] === true,
304
- worktree: options.worktree === true,
305
- worktreeDir: options["worktree-dir"],
306
- submodules: options.submodules === true,
307
349
  });
308
350
  return 0;
309
351
  }
@@ -439,6 +481,65 @@ export async function cliMain(argv) {
439
481
  }
440
482
  throw new UserError(`Unknown metrics subcommand: ${sub}`, { details: "Use `aiws metrics --help` to see available subcommands." });
441
483
  }
484
+ case "pull":
485
+ await wsPullCommand({ cwd: args[0] || "." });
486
+ return 0;
487
+ case "push": {
488
+ const { positionals, options } = parseArgs(args, { remote: { type: "string" } });
489
+ await wsPushCommand({ cwd: positionals[0] || ".", remote: options.remote || "origin" });
490
+ return 0;
491
+ }
492
+ case "migrate":
493
+ await wsMigrateCommand({ cwd: args[0] || "." });
494
+ return 0;
495
+ case "submodule-setup": {
496
+ const { positionals, options } = parseArgs(args, { branch: { type: "string" } });
497
+ await wsSubmoduleSetupCommand({ cwd: positionals[0] || ".", branch: options.branch || "main" });
498
+ return 0;
499
+ }
500
+ case "commit": {
501
+ const { positionals, options } = parseArgs(args, { message: { type: "string" }, m: { type: "string" } });
502
+ const message = options.message || options.m;
503
+ await wsCommitCommand({ cwd: positionals[0] || ".", message });
504
+ return 0;
505
+ }
506
+ case "plan-verify":
507
+ await wsPlanVerifyCommand({ cwd: args[0] || "." });
508
+ return 0;
509
+ case "verify-bc":
510
+ await wsVerifyBeforeCompleteCommand({ cwd: args[0] || "." });
511
+ return 0;
512
+ case "handoff":
513
+ await wsHandoffCommand({ cwd: args[0] || "." });
514
+ return 0;
515
+ case "finish": {
516
+ const { positionals, options } = parseArgs(args, {
517
+ into: { type: "string" },
518
+ push: { type: "boolean" },
519
+ remote: { type: "string" },
520
+ });
521
+ await wsFinishCommand({
522
+ cwd: ".",
523
+ changeId: positionals[0],
524
+ into: options.into,
525
+ push: options.push === true,
526
+ remote: options.remote || "origin",
527
+ });
528
+ return 0;
529
+ }
530
+ case "deliver": {
531
+ const { positionals, options } = parseArgs(args, {
532
+ message: { type: "string" },
533
+ m: { type: "string" },
534
+ });
535
+ const message = options.message || options.m;
536
+ await wsDeliverCommand({
537
+ cwd: ".",
538
+ changeId: positionals[0],
539
+ message,
540
+ });
541
+ return 0;
542
+ }
442
543
  default:
443
544
  throw new UserError(`Unknown command: ${cmd}`, { details: "Use --help to see available commands." });
444
545
  }
@@ -452,6 +553,16 @@ Usage:
452
553
  aiws update [path]
453
554
  aiws validate [path] [--stamp]
454
555
  aiws rollback [path] <timestamp|latest>
556
+ aiws pull [path]
557
+ aiws push [path] [--remote <name>]
558
+ aiws migrate [path]
559
+ aiws commit [path] [-m <message>]
560
+ aiws plan-verify [path]
561
+ aiws verify-bc [path]
562
+ aiws handoff [path]
563
+ aiws submodule-setup [path] [--branch <name>]
564
+ aiws finish [change-id] [--into <branch>] [--push] [--remote <name>]
565
+ aiws deliver [change-id] [-m <message>]
455
566
  aiws change <subcommand>
456
567
  aiws metrics <subcommand>
457
568
  aiws dashboard <subcommand>
@@ -507,10 +618,14 @@ function printOpenCodeHelp() {
507
618
 
508
619
  Usage:
509
620
  aiws opencode status [path]
621
+ aiws opencode auto [path] [--session <name>] [--window <name>] [--once] [--poll-ms <ms>] [--no-update]
622
+ aiws opencode supervise [path] [--session <name>] [--window <name>] [--once] [--poll-ms <ms>]
510
623
 
511
624
  Notes:
512
625
  - Detects project-local .opencode/ and oh-my-opencode (oMo) config.
626
+ - auto can refresh managed OpenCode autonomy files via \`aiws update .\` when needed, then ensure watchdog is running in tmux.
513
627
  - Helps verify whether AIWS OpenCode skills should prefer oMo agents or fallback to standard-opencode.
628
+ - supervise explicitly ensures the approval watchdog is running in tmux; it does not auto-approve host permissions.
514
629
  `);
515
630
  }
516
631
 
@@ -534,7 +649,7 @@ function printChangeHelp() {
534
649
 
535
650
  Usage:
536
651
  aiws change list
537
- aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>]
652
+ aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty]
538
653
  aiws change finish [change-id] [--into <branch> | --base <branch>] [--push] [--remote <name>]
539
654
  aiws change new <change-id> [--title <title>] [--no-design]
540
655
  aiws change status [change-id]
@@ -554,10 +669,8 @@ Notes:
554
669
  - Execution quality gate first: aiws change validate [change-id] --strict
555
670
  (equivalent to running $ws-plan-verify in AI tools).
556
671
  - status/next now prioritize quality-gate guidance before coding ($ws-dev-lite / $ws-dev).
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.
559
- - If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
560
- start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
672
+ - start refuses to switch branches with a dirty working tree (unless --allow-dirty).
673
+ - finish --push is submodule-aware: when .gitmodules exists it pushes submodules first, then the target branch.
561
674
  - archive runs strict validation and (by default) requires all tasks checked.
562
675
  - archive --force also bypasses truth drift gating (not recommended).
563
676
  `);
@@ -1,23 +1,3 @@
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
1
  /**
22
2
  * @param {string} gitRoot
23
3
  * @param {string} changeId
@@ -25,7 +5,6 @@ export async function resolveChangeWorktreeHint(gitRoot, changeId, deps) {
25
5
  * computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
26
6
  * currentBranch: (gitRoot: string) => Promise<string>,
27
7
  * inferChangeIdFromBranch: (branch: string) => string,
28
- * listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>,
29
8
  * changeDirAbs: (gitRoot: string, changeId: string) => string,
30
9
  * governanceGuidanceLines: (status: any) => Promise<string[]>,
31
10
  * scopeGateFailureAction: (changeId: string) => string,
@@ -60,12 +39,6 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
60
39
  advice.actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
61
40
  }
62
41
 
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
42
  if (status.metaState !== "ok") advice.actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
70
43
  if (status.driftFiles.length > 0) {
71
44
  advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
@@ -106,6 +79,14 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
106
79
  if (status.blockersStrict.length === 0 && status.tasks.unchecked > 0) {
107
80
  advice.actions.push("质量门通过后继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
108
81
  }
82
+ if (
83
+ status.blockersStrict.length === 0 &&
84
+ status.tasks.unchecked === 0 &&
85
+ Number(status?.reviewSignals?.effectiveCount || 0) > 0 &&
86
+ Number(status?.reviewGates?.summary?.dualReviewMissingCount || 0) > 0
87
+ ) {
88
+ advice.actions.push("已有通用 review,但 dual review gate 未完成:继续补齐 `$ws-spec-review` / `$ws-quality-review`,不要把 `codex-review.md` 当成 finish gate");
89
+ }
109
90
  if ((status.collaboration?.counts?.analysis || 0) > 0 && (status.collaboration?.counts?.review || 0) === 0) {
110
91
  advice.actions.push("已有委托分析但还没有 review 汇总结论:建议在 `changes/<id>/review/` 落盘结论后再交付");
111
92
  }
@@ -22,61 +22,6 @@ export async function resolvePushTarget(gitRoot, branch, preferredRemote, deps)
22
22
  return { remote, remoteBranch, refspec, trackedRemote, trackedMergeRef };
23
23
  }
24
24
 
25
- /**
26
- * @param {string} gitRoot
27
- * @param {string} worktreePath
28
- * @param {{
29
- * checkGitClean: (gitRoot: string) => Promise<{ clean: true } | { clean: false, details: string }>,
30
- * listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, locked: boolean, lockReason: string }>>,
31
- * pathExists: (path: string) => Promise<boolean>,
32
- * runCommand: typeof import("../exec.js").runCommand,
33
- * UserError: typeof import("../errors.js").UserError
34
- * }} deps
35
- */
36
- export async function cleanupWorktreePath(gitRoot, worktreePath, deps) {
37
- const abs = path.resolve(worktreePath);
38
- if (abs === path.resolve(gitRoot)) {
39
- return { status: "skipped", reason: "current_worktree", worktree: abs };
40
- }
41
-
42
- const worktrees = await deps.listGitWorktrees(gitRoot);
43
- const worktreeEntry = worktrees.find((worktree) => path.resolve(worktree.worktree) === abs);
44
- if (worktreeEntry?.locked) {
45
- return { status: "skipped", reason: "locked", worktree: abs, details: worktreeEntry.lockReason };
46
- }
47
-
48
- if (!(await deps.pathExists(abs))) {
49
- const prune = await deps.runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
50
- if (prune.code !== 0) {
51
- throw new deps.UserError("Failed to prune stale git worktree metadata.", { details: prune.stderr || prune.stdout });
52
- }
53
- return { status: "pruned-missing", worktree: abs };
54
- }
55
-
56
- const clean = await deps.checkGitClean(abs);
57
- if (!clean.clean) {
58
- return { status: "skipped", reason: "dirty", worktree: abs, details: clean.details };
59
- }
60
-
61
- let rm = await deps.runCommand("git", ["worktree", "remove", abs], { cwd: gitRoot });
62
- if (rm.code !== 0) {
63
- const out = `${rm.stderr || ""}\n${rm.stdout || ""}`;
64
- if (out.includes("working trees containing submodules cannot be moved or removed")) {
65
- rm = await deps.runCommand("git", ["worktree", "remove", "--force", abs], { cwd: gitRoot });
66
- }
67
- }
68
- if (rm.code !== 0) {
69
- throw new deps.UserError("Failed to remove change worktree.", { details: rm.stderr || rm.stdout });
70
- }
71
-
72
- const prune = await deps.runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
73
- if (prune.code !== 0) {
74
- throw new deps.UserError("Failed to prune git worktree metadata after remove.", { details: prune.stderr || prune.stdout });
75
- }
76
-
77
- return { status: "removed", worktree: abs };
78
- }
79
-
80
25
  /**
81
26
  * @param {string} gitRoot
82
27
  * @param {{ pathExists: (path: string) => Promise<boolean>, runCommand: typeof import("../exec.js").runCommand }} deps
@@ -194,14 +139,14 @@ async function ensureRevisionAvailable(targetRepoPath, sha, fallbackRepoPath, de
194
139
  });
195
140
  }
196
141
  if (!(await gitHasRevision(fallback, sha, deps))) {
197
- throw new deps.UserError("Required submodule commit is missing in both target and change worktrees.", {
198
- details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}`,
142
+ throw new deps.UserError("Required submodule commit is missing in both target and fallback repos.", {
143
+ details: `target_repo: ${targetRepoPath}\nfallback_repo: ${fallback}\ncommit: ${sha}`,
199
144
  });
200
145
  }
201
146
  const fetch = await deps.runCommand("git", ["-C", targetRepoPath, "fetch", fallback, sha], { cwd: targetRepoPath });
202
147
  if (fetch.code !== 0 || !(await gitHasRevision(targetRepoPath, sha, deps))) {
203
- throw new deps.UserError("Failed to import submodule commit from change worktree.", {
204
- details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
148
+ throw new deps.UserError("Failed to import submodule commit from fallback repo.", {
149
+ details: `target_repo: ${targetRepoPath}\nfallback_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
205
150
  });
206
151
  }
207
152
  }
@@ -260,7 +205,6 @@ async function pushSubmoduleBranch(repoPath, remote, branch, sha, deps) {
260
205
  * @param {string} gitRoot
261
206
  * @param {string} changeId
262
207
  * @param {string} baseBranch
263
- * @param {string | undefined} changeWorktreePath
264
208
  * @param {{
265
209
  * pathExists: (path: string) => Promise<boolean>,
266
210
  * readText: (path: string) => Promise<string>,
@@ -268,7 +212,7 @@ async function pushSubmoduleBranch(repoPath, remote, branch, sha, deps) {
268
212
  * UserError: typeof import("../errors.js").UserError
269
213
  * }} deps
270
214
  */
271
- export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, changeWorktreePath, deps) {
215
+ export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, deps) {
272
216
  const subs = await listSubmodulesFromGitmodules(gitRoot, deps);
273
217
  if (subs.length === 0) return { processed: 0 };
274
218
 
@@ -316,24 +260,19 @@ export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, cha
316
260
 
317
261
  for (const spec of specs) {
318
262
  const targetRepoPath = path.join(gitRoot, spec.path);
319
- const changeRepoPath = changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "";
320
- const targetRepoReady = await isGitRepository(targetRepoPath, deps);
321
- const changeRepoReady = changeRepoPath ? await isGitRepository(changeRepoPath, deps) : false;
322
- if (!targetRepoReady && !changeRepoReady) {
323
- throw new deps.UserError("Submodule repository is not initialized in either target or change worktree.", {
324
- details: `path: ${spec.path}\nHint: initialize submodules before finish, or keep using worktree mode for changes involving submodules.`,
263
+ if (!(await isGitRepository(targetRepoPath, deps))) {
264
+ throw new deps.UserError("Submodule repository is not initialized.", {
265
+ details: `path: ${spec.path}\nHint: initialize submodules before finish.`,
325
266
  });
326
267
  }
327
268
 
328
- const sourceRepoPath =
329
- (targetRepoReady && (await gitHasRevision(targetRepoPath, spec.sha, deps))) ? targetRepoPath : changeRepoReady ? changeRepoPath : targetRepoPath;
330
- if (!(await gitHasRevision(sourceRepoPath, spec.sha, deps))) {
269
+ if (!(await gitHasRevision(targetRepoPath, spec.sha, deps))) {
331
270
  throw new deps.UserError("Merged submodule gitlink commit is not available locally.", {
332
- details: `path: ${spec.path}\ncommit: ${spec.sha}\nsource_repo: ${sourceRepoPath}`,
271
+ details: `path: ${spec.path}\ncommit: ${spec.sha}`,
333
272
  });
334
273
  }
335
274
 
336
- const pushResult = await pushSubmoduleBranch(sourceRepoPath, spec.remote, spec.branch, spec.sha, deps);
275
+ const pushResult = await pushSubmoduleBranch(targetRepoPath, spec.remote, spec.branch, spec.sha, deps);
337
276
  console.log(
338
277
  `submodule push: ${spec.path} (${spec.remote} ${spec.branch}${pushResult.status === "pushed" ? ` <= ${spec.sha}` : ", already contained"})`,
339
278
  );
@@ -341,7 +280,7 @@ export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, cha
341
280
 
342
281
  const sync = await deps.runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: gitRoot });
343
282
  if (sync.code !== 0) {
344
- throw new deps.UserError("Submodule push succeeded, but failed to sync target worktree submodules.", {
283
+ throw new deps.UserError("Submodule push succeeded, but failed to sync submodules in target repo.", {
345
284
  details: sync.stderr || sync.stdout,
346
285
  });
347
286
  }
@@ -351,16 +290,16 @@ export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, cha
351
290
  if (!(await isGitRepository(targetRepoPath, deps))) {
352
291
  throw new deps.UserError("Submodule repo missing after sync.", { details: `path: ${spec.path}` });
353
292
  }
354
- await ensureRevisionAvailable(targetRepoPath, spec.sha, changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "", deps);
293
+ await ensureRevisionAvailable(targetRepoPath, spec.sha, "", deps);
355
294
  const fetch = await deps.runCommand("git", ["-C", targetRepoPath, "fetch", spec.remote, "--prune"], { cwd: targetRepoPath });
356
295
  if (fetch.code !== 0) {
357
- throw new deps.UserError("Failed to refresh target worktree submodule remote.", {
296
+ throw new deps.UserError("Failed to refresh target submodule remote.", {
358
297
  details: `path: ${spec.path}\nremote: ${spec.remote}\n\n${fetch.stderr || fetch.stdout}`,
359
298
  });
360
299
  }
361
300
  const checkout = await deps.runCommand("git", ["-C", targetRepoPath, "checkout", "-B", spec.pinBranch, spec.sha], { cwd: targetRepoPath });
362
301
  if (checkout.code !== 0) {
363
- throw new deps.UserError("Failed to attach target worktree submodule to pin branch.", {
302
+ throw new deps.UserError("Failed to attach target submodule to pin branch.", {
364
303
  details: `path: ${spec.path}\npin_branch: ${spec.pinBranch}\ncommit: ${spec.sha}\n\n${checkout.stderr || checkout.stdout}`,
365
304
  });
366
305
  }
@@ -379,7 +318,6 @@ export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, cha
379
318
  * assertValidChangeId: (changeId: string) => void,
380
319
  * checkGitClean: (gitRoot: string) => Promise<{ clean: true } | { clean: false, details: string }>,
381
320
  * currentBranch: (gitRoot: string) => Promise<string>,
382
- * listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>,
383
321
  * pathExists: (path: string) => Promise<boolean>,
384
322
  * readText: (path: string) => Promise<string>,
385
323
  * resolveChangeId: (gitRoot: string, changeId: string | undefined, options: { command: string }) => Promise<string>,
@@ -465,20 +403,11 @@ export async function resolveFinishContext(gitRoot, options, deps) {
465
403
  throw new deps.UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
466
404
  }
467
405
 
468
- const worktrees = await deps.listGitWorktrees(gitRoot);
469
406
  const intoRef = `refs/heads/${into}`;
470
- const intoWt = worktrees.find((worktree) => String(worktree.branch || "") === intoRef);
471
- if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
472
- throw new deps.UserError("Target branch is checked out in another worktree.", {
473
- details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
474
- });
475
- }
476
-
477
407
  const hasIntoBranch = await deps.runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((result) => result.code === 0);
478
408
  if (!hasIntoBranch) {
479
409
  throw new deps.UserError("Target branch does not exist.", { details: `branch: ${into}` });
480
410
  }
481
411
 
482
- const changeWt = worktrees.find((worktree) => String(worktree.branch || "") === changeBranchRef);
483
- return { changeId, changeBranch, changeBranchRef, into, cur, worktrees, changeWt };
412
+ return { changeId, changeBranch, changeBranchRef, into, cur };
484
413
  }
@@ -118,7 +118,11 @@ export async function renderChangeNextOutput(input, deps) {
118
118
  } else {
119
119
  for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
120
120
  }
121
- lines.push("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
121
+ if (Number(status?.reviewSignals?.effectiveCount || 0) === 0) {
122
+ lines.push("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
123
+ } else if (Number(status?.reviewGates?.summary?.dualReviewMissingCount || 0) > 0) {
124
+ lines.push("- 已有通用 review,但 dual review gate 未齐:继续补 `$ws-spec-review` / `$ws-quality-review`,不要把 `codex-review.md` 当成 finish gate");
125
+ }
122
126
  const recommendedStage = String(status.governance?.recommendedStage || "");
123
127
  if (recommendedStage === "ws-finish") {
124
128
  lines.push(`- 收尾:\`aiws change finish ${changeId} --push\`(成功后会自动归档并生成 handoff)`);
@@ -51,6 +51,17 @@ function persistentEvidencePath(gitRoot, changeId, fileName) {
51
51
  return path.join(gitRoot, "changes", changeId, "evidence", fileName);
52
52
  }
53
53
 
54
+ /**
55
+ * @param {string} gitRoot
56
+ * @param {string} changeId
57
+ */
58
+ async function hasGenericReviewSignal(gitRoot, changeId) {
59
+ const persistentCodexReview = persistentReviewPath(gitRoot, changeId, "codex-review.md");
60
+ if (await pathExists(persistentCodexReview)) return true;
61
+ const tmpCodexReview = tmpReviewFallbackPath(gitRoot, "codex-review.md");
62
+ return await pathExists(tmpCodexReview);
63
+ }
64
+
54
65
  /**
55
66
  * @param {string} gitRoot
56
67
  * @param {string} changeId
@@ -177,6 +188,7 @@ export async function collectReviewSignals(gitRoot, changeId, context, collabora
177
188
  export async function collectReviewGates(gitRoot, changeId, context) {
178
189
  const spec = await loadWorkflowReviewGates();
179
190
  const canAttributeTmpFallback = await canAttributeTmpReviewFallback(gitRoot, changeId, context);
191
+ const genericReviewSignal = canAttributeTmpFallback ? await hasGenericReviewSignal(gitRoot, changeId) : await pathExists(persistentReviewPath(gitRoot, changeId, "codex-review.md"));
180
192
 
181
193
  /** @type {Array<any>} */
182
194
  const gates = [];
@@ -239,6 +251,8 @@ export async function collectReviewGates(gitRoot, changeId, context) {
239
251
  if (!qualityReview?.ready) finishGateMissingIds.push("quality_review");
240
252
  if (!validateStamp.ready) finishGateMissingIds.push("validate_stamp");
241
253
  if (!verifyBeforeComplete?.ready) finishGateMissingIds.push("verify_before_complete");
254
+ const genericReviewOnly = genericReviewSignal && dualReviewMissingIds.length === 2;
255
+ const dualReviewPending = dualReviewMissingIds.length > 0;
242
256
 
243
257
  return {
244
258
  source: spec.source,
@@ -252,6 +266,8 @@ export async function collectReviewGates(gitRoot, changeId, context) {
252
266
  summary: {
253
267
  gateCount: gates.length,
254
268
  gateReadyCount: gates.filter((gate) => gate.ready).length,
269
+ genericReviewOnly,
270
+ dualReviewPending,
255
271
  dualReviewReady: dualReviewMissingIds.length === 0,
256
272
  dualReviewMissingIds,
257
273
  dualReviewMissingCount: dualReviewMissingIds.length,
@@ -1,25 +1,5 @@
1
1
  import path from "node:path";
2
2
 
3
- /**
4
- * @param {string} gitRoot
5
- * @param {{
6
- * hasHeadCommit: (gitRoot: string) => Promise<boolean>,
7
- * runCommand: typeof import("../exec.js").runCommand
8
- * }} deps
9
- * @returns {Promise<{ ok: true } | { ok: false, reason: "no_commit" | "missing_truth", missing?: string[] }>}
10
- */
11
- export async function checkWorktreePrereqs(gitRoot, deps) {
12
- if (!(await deps.hasHeadCommit(gitRoot))) return { ok: false, reason: "no_commit" };
13
- /** @type {string[]} */
14
- const missingInHead = [];
15
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
16
- const ok = await deps.runCommand("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: gitRoot }).then((result) => result.code === 0);
17
- if (!ok) missingInHead.push(rel);
18
- }
19
- if (missingInHead.length > 0) return { ok: false, reason: "missing_truth", missing: missingInHead };
20
- return { ok: true };
21
- }
22
-
23
3
  /**
24
4
  * @param {string} changeDir
25
5
  * @param {string} baseBranch
@@ -52,21 +32,6 @@ export async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch,
52
32
  await deps.writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
53
33
  }
54
34
 
55
- /**
56
- * @param {string} gitRoot
57
- * @param {string | undefined} worktreeDir
58
- * @param {string} changeId
59
- */
60
- export function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
61
- const parent = path.dirname(gitRoot);
62
- const raw = String(worktreeDir || "").trim();
63
- if (raw) {
64
- return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
65
- }
66
- const repoName = path.basename(gitRoot);
67
- return path.resolve(parent, `${repoName}-${changeId}`);
68
- }
69
-
70
35
  /**
71
36
  * @param {string} gitRoot
72
37
  * @param {{ branch: string, hasBranch: boolean, hasGitmodules: boolean }} options