@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 +18 -2
- package/package.json +2 -2
- package/src/cli.js +125 -12
- package/src/commands/change-advice.js +8 -27
- package/src/commands/change-finish.js +16 -87
- package/src/commands/change-next-command.js +5 -1
- package/src/commands/change-review-gates.js +16 -0
- package/src/commands/change-start.js +0 -35
- package/src/commands/change-status-command.js +4 -13
- package/src/commands/change-status-context.js +6 -35
- package/src/commands/change.js +194 -452
- package/src/commands/init.js +8 -0
- package/src/commands/opencode-auto.js +167 -0
- package/src/commands/opencode-status.js +44 -2
- package/src/commands/opencode-supervise.js +200 -0
- package/src/commands/update.js +17 -5
- package/src/commands/validate.js +7 -1
- package/src/commands/ws-commit.js +44 -0
- package/src/commands/ws-deliver.js +67 -0
- package/src/commands/ws-finish.js +83 -0
- package/src/commands/ws-handoff.js +40 -0
- package/src/commands/ws-migrate.js +41 -0
- package/src/commands/ws-plan-verify.js +44 -0
- package/src/commands/ws-pull.js +30 -0
- package/src/commands/ws-push.js +71 -0
- package/src/commands/ws-submodule-setup.js +65 -0
- package/src/commands/ws-verify-before-complete.js +39 -0
- package/src/manifest.js +25 -1
- package/src/opencode-env.js +132 -0
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
|
|
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.
|
|
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.
|
|
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]
|
|
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]
|
|
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
|
|
558
|
-
- finish --push is submodule-aware: when .gitmodules exists it pushes submodules first, then the target branch
|
|
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
|
|
198
|
-
details: `target_repo: ${targetRepoPath}\
|
|
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
|
|
204
|
-
details: `target_repo: ${targetRepoPath}\
|
|
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,
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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}
|
|
271
|
+
details: `path: ${spec.path}\ncommit: ${spec.sha}`,
|
|
333
272
|
});
|
|
334
273
|
}
|
|
335
274
|
|
|
336
|
-
const pushResult = await pushSubmoduleBranch(
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|