@zhushanwen/pi-cw-tool 0.3.0 → 0.4.0
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/agents/planning-agent.md +1 -1
- package/agents/wave-agent.md +1 -1
- package/package.json +1 -1
- package/skills/pi-cw/SKILL.md +10 -12
- package/skills/pi-cw/design-v4.md +1 -1
- package/src/__tests__/cw-tool.test.ts +5 -0
- package/src/__tests__/detect-repo-workspace.test.ts +7 -76
- package/src/__tests__/workspace-gate.test.ts +233 -0
- package/src/cw-runner.ts +127 -3
package/agents/planning-agent.md
CHANGED
package/agents/wave-agent.md
CHANGED
|
@@ -30,7 +30,7 @@ tools: cw_wave, subagent
|
|
|
30
30
|
|
|
31
31
|
## 生命周期(v4 §6)
|
|
32
32
|
|
|
33
|
-
你被父(slice/feature 层主)用 subagent 工具带 `worktree: true`
|
|
33
|
+
你被父(slice/feature 层主)用 subagent 工具带 `worktree: true` 后台派出,在专属 worktree 工作。
|
|
34
34
|
|
|
35
35
|
### turn 1
|
|
36
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-cw-tool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension wrapping the `cw` CLI as role-restricted tools (cw_planning / cw_wave / cw_dev / cw_review) with per-tool action whitelists — hard-guarantees no self-review by layer-owner agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
package/skills/pi-cw/SKILL.md
CHANGED
|
@@ -50,8 +50,10 @@ cw create epic --slug <kebab-slug> --objective "<一句话目标,含可验收的
|
|
|
50
50
|
用 `subagent` 工具**后台**派发(`planning-agent` 是 cw-tool 内置的 agent 模板):
|
|
51
51
|
|
|
52
52
|
```
|
|
53
|
-
subagent(action="start", agent="planning-agent",
|
|
53
|
+
subagent(action="start", agent="planning-agent", slug="<epic-slug>-planning", fork=false,
|
|
54
54
|
task="<背景>这是 cw epic <epicId> 的层主 agent,目标:<原 objective>。这是递归编排,你会自递归派 feature/slice/wave 层 planning-agent。<目标>先调 cw handoff --unitId <epicId> 拿上下文与 guidance,按 guidance 的派发指导自递归展开并合并子树。<验收>cw status --unitId <epicId> 显示该 epic 子树全部 closed。")
|
|
55
|
+
# 不传 model 参数——默认继承主 agent 模型,递归传给所有下层(见「模型派发」)。
|
|
56
|
+
# 用户特别指定时才传 model="provider/modelId",单个 subagent 生效或作为全树根模型。
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
task 三要素:
|
|
@@ -84,22 +86,18 @@ cw frontier --root <epicId> # 看 epic 子树 frontier
|
|
|
84
86
|
|
|
85
87
|
- **只派第一个 planning-agent**:主 agent 不自己 descend 到 feature / slice / wave 层。下层派发是 planning-agent 的职责(它调 cw execute 自动建子 unit,并按 guidance 派子 planning-agent / wave-agent)。
|
|
86
88
|
- **靠 cw 查进度,不信自报**:agent 汇报"我做完了"不等于 cw 状态 closed。以 `cw status` / `cw frontier` 为唯一真相。
|
|
87
|
-
- **worktree 隔离**:wave 层用 `worktree: true
|
|
89
|
+
- **worktree 隔离**:wave 层用 `worktree: true` 派出(各 wave 独立工作目录,并行不冲突;worktree 与 fork 正交,fork 默认 false);主 agent 派的 epic planning-agent 不需 worktree(它只编排不写码)。worktree 的合并与清理由 slice 层 planning-agent 派 chain workflow(merge-agent)处理,细节见 planning-agent 模板。
|
|
88
90
|
- **失败恢复靠 L0-L3**:cw gate fail / 审查 must-fix / 方案缺陷 / 父层拆错,各有恢复路径(L0 就地改重审 / L1 cw replan / L2 父 replan 级联 / L3 上报人),定义在 planning-agent 模板与 cw guidance,本 skill 不重复。
|
|
89
91
|
|
|
90
|
-
##
|
|
92
|
+
## 模型派发
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
**默认不指定 model —— 递归继承主 agent 模型**。pi subagent 的模型解析是三层(显式 `model` 参数 → agent 模板 frontmatter `model` → 父 agent 当前模型兜底直接透传),子进程启动时用 `--model` 固化该解析结果,其再派子时不指定则继续透传。因此**只要派发点不传 `model`(且 agent 模板 frontmatter 不写 `model`),整棵 cw 树所有 agent 自动同模型**——这是唯一能保证「递归传给所有 subagent」的机制,不需要也不应该逐层重复指定。
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
| wave 层主 | 本层 design + 调度,范围窄 | 中(glm-5.1) |
|
|
98
|
-
| dev | 写码 + 测试,量大 | 便宜 coding(ds-flash / glm-turbo) |
|
|
99
|
-
| review | 主观审查,需判断力 | 强推理(ds-pro / glm-5.1) |
|
|
100
|
-
| merge | git 操作,机械 | 便宜(glm-turbo) |
|
|
96
|
+
- **全树同模型**:用户在主 agent 切换模型,或根派发(第一个 planning-agent)时显式传一次 `model="provider/modelId"`,下层全部自动继承。模型在每次派发瞬间固化,中途切换只影响后续派发。
|
|
97
|
+
- **单点覆盖**:用户特别指定某 subagent 用特定模型时,只在该次派发传 `model` 参数。指定但模型不存在 / 鉴权未配置会**抛错不静默降级**(错误信息列出可用模型),不会出现「以为用了 X 实际用 Y」。
|
|
98
|
+
- **cw.config.json 不配置 model**:cw 引擎只读 `testRunner`,不读 model 字段;`perLayer.model` 放进去是无人消费的死字段。
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
各层职责性质仅作用户自主差异化时的参考(planning 强模型 / dev 便宜模型等),不作为默认行为——默认一律继承主 agent 模型。
|
|
103
101
|
|
|
104
102
|
## 标记说明
|
|
105
103
|
|
|
@@ -222,5 +222,5 @@ agent 不记流程,每 turn 调 cw 拿 guidance 照做。cw guidance 是流程
|
|
|
222
222
|
- **新增** 6 个 agent 模板:planning-agent / wave-agent(层主) / dev-agent / review-agent / merge-agent(+ 主 agent 用现有)
|
|
223
223
|
- **新增** cw-tool(pi 自定义工具,registerTool):包装 cw 命令,**按 role 限制可调 action**(层主不含审查命令),堵 bash 洞 + 硬保证独立 review。分配给 planning-agent / wave-agent(层主) / dev-agent(execute/test) / review-agent(审查) / 主 agent
|
|
224
224
|
- **cw guidance 增强**:每 action 返回四段(位置/下一步+派发指导/恢复指导/续turn指导),让接收 guidance 的 agent 按派发指导分情况派子(详见 §7)
|
|
225
|
-
-
|
|
225
|
+
- **~~cw.config.json:可能加 perLayer.model(planning 强模型/wave 便宜模型)~~** —— **已否决**:模型在派发点(subagent 工具 `model` 参数)决定,默认不指定 = 继承父 agent 模型(pi 三层解析:override → agent frontmatter → 父 agent 当前模型),递归全树同模型,无需也不应逐层配置;按层差异化只作参考,须用户显式指定才生效
|
|
226
226
|
- **不依赖 cw 引擎 E1-E6**(本项目用 cw 现状 action 名;cw-tool 包装层可屏蔽未来 E1 合并差异)
|
|
@@ -57,6 +57,11 @@ function fakeSpawner(responses: CwSpawnResult[]): { spawner: CwSpawner; calls: C
|
|
|
57
57
|
const calls: CapturedCall[] = [];
|
|
58
58
|
let i = 0;
|
|
59
59
|
const spawner: CwSpawner = vi.fn(async (args, input, cwd, _signal): Promise<CwSpawnResult> => {
|
|
60
|
+
// 门控 probe(['--version'])是内部细节:返回低版本(不支持归一化)让 write action
|
|
61
|
+
// 走兜底路径,且不入 calls(测试断言的是 action 调用,probe 透明)。
|
|
62
|
+
if (args[0] === "--version") {
|
|
63
|
+
return { stdout: "cw 1.0.0", stderr: "", exitCode: 0 };
|
|
64
|
+
}
|
|
60
65
|
calls.push({ args, input, cwd });
|
|
61
66
|
const r = responses[i] ?? { stdout: "", stderr: "", exitCode: 0 };
|
|
62
67
|
i += 1;
|
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* detectRepoWorkspace 真实 git
|
|
2
|
+
* detectRepoWorkspace 真实 git 探测测试 + buildCwArgs 纯函数构造测试。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* executeCwAction 的 workspace 门控行为测试已移到 workspace-gate.test.ts
|
|
5
|
+
* (门控后 spawner 被调两次:probe `cw --version` + action,calls[0] 语义变化,
|
|
6
|
+
* 集成测试在那里用区分 probe/action 的 gateSpawner 覆盖)。本文件只测两个纯函数。
|
|
6
7
|
*
|
|
7
|
-
* 测试框架:vitest(从 vitest 导入 describe/it/expect
|
|
8
|
+
* 测试框架:vitest(从 vitest 导入 describe/it/expect)。
|
|
8
9
|
*/
|
|
9
10
|
import { execSync } from "node:child_process";
|
|
10
11
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
import * as path from "node:path";
|
|
13
14
|
|
|
14
|
-
import { afterEach, describe, expect, it
|
|
15
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
16
|
|
|
16
|
-
import {
|
|
17
|
-
buildCwArgs,
|
|
18
|
-
detectRepoWorkspace,
|
|
19
|
-
executeCwAction,
|
|
20
|
-
} from "../cw-runner.ts";
|
|
21
|
-
import { type CwSpawner } from "../cw-spawn.ts";
|
|
22
|
-
import { DEV_ALLOWED } from "../index.ts";
|
|
17
|
+
import { buildCwArgs, detectRepoWorkspace } from "../cw-runner.ts";
|
|
23
18
|
|
|
24
19
|
// ── 临时目录管理 ────────────────────────────────────────────────
|
|
25
20
|
|
|
@@ -98,70 +93,6 @@ describe("detectRepoWorkspace(真实 git)", () => {
|
|
|
98
93
|
});
|
|
99
94
|
});
|
|
100
95
|
|
|
101
|
-
// ── executeCwAction 集成(真实 cwd,fake spawner 记录 args)─────
|
|
102
|
-
|
|
103
|
-
describe("executeCwAction 集成(真实 git cwd)", () => {
|
|
104
|
-
/** 记录 args 的 fake spawner(不真调 cw)。 */
|
|
105
|
-
function recordingSpawner(): { spawner: CwSpawner; calls: Array<{ args: string[] }> } {
|
|
106
|
-
const calls: Array<{ args: string[] }> = [];
|
|
107
|
-
const spawner: CwSpawner = vi.fn(async (args: string[]) => {
|
|
108
|
-
calls.push({ args });
|
|
109
|
-
return { stdout: "{}", stderr: "", exitCode: 0 };
|
|
110
|
-
}) as unknown as CwSpawner;
|
|
111
|
-
return { spawner, calls };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
it("cwd 在 git repo 内 → write action args 含 --workspace <repo 根>", async () => {
|
|
115
|
-
const base = makeTempDir("cw-int-");
|
|
116
|
-
const repo = createGitRepo(base, "repo");
|
|
117
|
-
const { spawner, calls } = recordingSpawner();
|
|
118
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
119
|
-
expect(calls[0].args).toContain("--workspace");
|
|
120
|
-
expect(calls[0].args[calls[0].args.indexOf("--workspace") + 1]).toBe(repo);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("cwd 在 linked worktree 内 → write action --workspace 指向 repo 主目录", async () => {
|
|
124
|
-
const base = makeTempDir("cw-int-");
|
|
125
|
-
const repo = createGitRepo(base, "repo");
|
|
126
|
-
const wtDir = path.join(base, "wt1");
|
|
127
|
-
execSync(`git worktree add -q ${wtDir}`, { cwd: repo });
|
|
128
|
-
const { spawner, calls } = recordingSpawner();
|
|
129
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, wtDir);
|
|
130
|
-
expect(calls[0].args).toContain("--workspace");
|
|
131
|
-
expect(calls[0].args[calls[0].args.indexOf("--workspace") + 1]).toBe(repo);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("read-only action(status)即使在 git repo 内也不附加 --workspace(S-3)", async () => {
|
|
135
|
-
const base = makeTempDir("cw-int-");
|
|
136
|
-
const repo = createGitRepo(base, "repo");
|
|
137
|
-
const { spawner, calls } = recordingSpawner();
|
|
138
|
-
await executeCwAction("status", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
139
|
-
expect(calls[0].args).not.toContain("--workspace");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("cwd 为非 git 目录 → write action 不含 --workspace", async () => {
|
|
143
|
-
const plain = makeTempDir("cw-int-plain-");
|
|
144
|
-
const { spawner, calls } = recordingSpawner();
|
|
145
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
|
|
146
|
-
expect(calls[0].args).not.toContain("--workspace");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("cwd 不存在 → write action 不含 --workspace(探测失败不抛)", async () => {
|
|
150
|
-
const base = makeTempDir("cw-int-");
|
|
151
|
-
const { spawner, calls } = recordingSpawner();
|
|
152
|
-
await executeCwAction(
|
|
153
|
-
"execute",
|
|
154
|
-
DEV_ALLOWED,
|
|
155
|
-
"cw_dev",
|
|
156
|
-
"u1",
|
|
157
|
-
{},
|
|
158
|
-
spawner,
|
|
159
|
-
path.join(base, "missing"),
|
|
160
|
-
);
|
|
161
|
-
expect(calls[0].args).not.toContain("--workspace");
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
96
|
// ── buildCwArgs 纯函数(workspace 参数)─────────────────────────
|
|
166
97
|
|
|
167
98
|
describe("buildCwArgs(workspace 参数)", () => {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace 门控测试:probeCwCliNormalization 纯函数 + executeCwAction 门控决策。
|
|
3
|
+
*
|
|
4
|
+
* 门控语义(差异文档 §3 §4 + ADR-0045 Superseded):cw-cli 支持 store 内部归一化
|
|
5
|
+
* (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ write action 不传 --workspace(纯封装);
|
|
6
|
+
* 不支持 → 兜底 ADR-0045 的 detectRepoWorkspace + --workspace。只读 action 始终不传(S-3)。
|
|
7
|
+
*
|
|
8
|
+
* detectRepoWorkspace 探测纯函数 + buildCwArgs 构造的测试在 detect-repo-workspace.test.ts,
|
|
9
|
+
* 本文件只测门控决策(probe 三态 × action 两类 × git 环境)。
|
|
10
|
+
*
|
|
11
|
+
* 测试框架:vitest(从 vitest 导入 describe/it/expect/vi)。
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { _resetCapabilityCacheForTest, executeCwAction, probeCwCliNormalization } from "../cw-runner.ts";
|
|
21
|
+
import { type CwSpawner, type CwSpawnResult } from "../cw-spawn.ts";
|
|
22
|
+
import { DEV_ALLOWED } from "../index.ts";
|
|
23
|
+
|
|
24
|
+
// ── 临时目录管理(同 detect-repo-workspace.test.ts 风格)─────────
|
|
25
|
+
|
|
26
|
+
const tmpDirs: string[] = [];
|
|
27
|
+
|
|
28
|
+
function makeTempDir(prefix: string): string {
|
|
29
|
+
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
|
30
|
+
tmpDirs.push(dir);
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 建含一次空 commit 的 git repo,返回 realpath 规范化的 repo 根(macOS /tmp→/private/tmp 对齐)。 */
|
|
35
|
+
function createGitRepo(parentDir: string, name: string): string {
|
|
36
|
+
const repo = path.join(parentDir, name);
|
|
37
|
+
mkdirSync(repo);
|
|
38
|
+
execSync("git init -q", { cwd: repo });
|
|
39
|
+
execSync("git -c user.name=test -c user.email=test@test.local commit -q --allow-empty -m init", {
|
|
40
|
+
cwd: repo,
|
|
41
|
+
});
|
|
42
|
+
return realpathSync(repo);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// capability 缓存是进程内全局单值(S-1),测试间必须重置避免串台。
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
_resetCapabilityCacheForTest();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── mock spawner:对 ['--version'] 可控,对 action 默认成功 ───────
|
|
57
|
+
|
|
58
|
+
interface GateSpawnerOpts {
|
|
59
|
+
/** cw --version 的 stdout(如 "cw 1.6.1")。 */
|
|
60
|
+
versionStdout?: string;
|
|
61
|
+
/** cw --version 的 exitCode(默认 0)。 */
|
|
62
|
+
versionExitCode?: number;
|
|
63
|
+
/** cw --version spawn 抛异常(模拟 cw 不在 PATH)。 */
|
|
64
|
+
versionThrow?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 记录所有调用的 fake spawner:对 `['--version']`(probe)返回可控结果,
|
|
69
|
+
* 对其他(action)返回默认成功 `{ stdout: "{}", exitCode: 0 }`。
|
|
70
|
+
*/
|
|
71
|
+
function gateSpawner(opts: GateSpawnerOpts = {}): {
|
|
72
|
+
spawner: CwSpawner;
|
|
73
|
+
calls: Array<{ args: string[] }>;
|
|
74
|
+
} {
|
|
75
|
+
const calls: Array<{ args: string[] }> = [];
|
|
76
|
+
// contextual typing:变量类型 CwSpawner 提供 4 参数完整签名,回调签名漂移会编译失败(消除假绿,S-5)。
|
|
77
|
+
const spawner: CwSpawner = vi.fn(async (args, _input, _cwd, _signal): Promise<CwSpawnResult> => {
|
|
78
|
+
calls.push({ args });
|
|
79
|
+
if (args[0] === "--version") {
|
|
80
|
+
if (opts.versionThrow) throw new Error("cw not in PATH (spawn error)");
|
|
81
|
+
return {
|
|
82
|
+
stdout: opts.versionStdout ?? "",
|
|
83
|
+
stderr: "",
|
|
84
|
+
exitCode: opts.versionExitCode ?? 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { stdout: "{}", stderr: "", exitCode: 0 };
|
|
88
|
+
});
|
|
89
|
+
return { spawner, calls };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 从 calls 中取 action 调用(非 `['--version']` 的最后一个)。 */
|
|
93
|
+
function actionCall(calls: Array<{ args: string[] }>): { args: string[] } {
|
|
94
|
+
const action = calls.filter((c) => c.args[0] !== "--version").pop();
|
|
95
|
+
if (!action) throw new Error("无 action 调用(只有 probe 或未触发)");
|
|
96
|
+
return action;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── probeCwCliNormalization 纯函数 ──────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("probeCwCliNormalization", () => {
|
|
102
|
+
it("版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION → supported:true", async () => {
|
|
103
|
+
const cwd = makeTempDir("probe-supported-");
|
|
104
|
+
const { spawner } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
105
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
106
|
+
expect(cap.supported).toBe(true);
|
|
107
|
+
expect(cap.version).toBe("99.9.9");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("版本 === MIN(等号边界)→ supported:true(门控用 >=,锁定等号语义,S-7)", async () => {
|
|
111
|
+
const cwd = makeTempDir("probe-eq-");
|
|
112
|
+
const { spawner } = gateSpawner({ versionStdout: "cw 99.0.0" });
|
|
113
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
114
|
+
expect(cap.supported).toBe(true);
|
|
115
|
+
expect(cap.version).toBe("99.0.0");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("版本 < MIN → supported:false(当前 placeholder 99.0.0,真实 cw 1.6.1 不支持)", async () => {
|
|
119
|
+
const cwd = makeTempDir("probe-unsupported-");
|
|
120
|
+
const { spawner } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
121
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
122
|
+
expect(cap.supported).toBe(false);
|
|
123
|
+
expect(cap.version).toBe("1.6.1");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("cw --version exitCode 非 0 → supported:false(fail-safe)", async () => {
|
|
127
|
+
const cwd = makeTempDir("probe-exit-");
|
|
128
|
+
const { spawner } = gateSpawner({ versionExitCode: 127 });
|
|
129
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
130
|
+
expect(cap.supported).toBe(false);
|
|
131
|
+
expect(cap.version).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("stdout 无版本号 → supported:false(parse 失败 fail-safe)", async () => {
|
|
135
|
+
const cwd = makeTempDir("probe-parse-");
|
|
136
|
+
const { spawner } = gateSpawner({ versionStdout: "garbage no version here" });
|
|
137
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
138
|
+
expect(cap.supported).toBe(false);
|
|
139
|
+
expect(cap.version).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("spawn 抛异常(cw 不在 PATH)→ supported:false(fail-safe)", async () => {
|
|
143
|
+
const cwd = makeTempDir("probe-throw-");
|
|
144
|
+
const { spawner } = gateSpawner({ versionThrow: true });
|
|
145
|
+
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
146
|
+
expect(cap.supported).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("probe 5s 超时 → supported:false(fail-safe,防御 cw --version 卡死,S-6)", async () => {
|
|
150
|
+
vi.useFakeTimers();
|
|
151
|
+
try {
|
|
152
|
+
const cwd = makeTempDir("probe-timeout-");
|
|
153
|
+
// spawner 模拟 cw --version 卡死:永不 resolve,signal abort 时 reject(走 probe catch 路径)。
|
|
154
|
+
const spawner: CwSpawner = vi.fn((_args, _input, _cwd, signal): Promise<CwSpawnResult> =>
|
|
155
|
+
new Promise<CwSpawnResult>((_resolve, reject) => {
|
|
156
|
+
signal?.addEventListener("abort", () => reject(new Error("aborted by timeout")), {
|
|
157
|
+
once: true,
|
|
158
|
+
});
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
const probePromise = probeCwCliNormalization(spawner, cwd);
|
|
162
|
+
await vi.advanceTimersByTimeAsync(5001);
|
|
163
|
+
const cap = await probePromise;
|
|
164
|
+
expect(cap.supported).toBe(false);
|
|
165
|
+
} finally {
|
|
166
|
+
vi.useRealTimers();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("memoize:同 cwd 第二次不调 spawner(进程内缓存)", async () => {
|
|
171
|
+
const cwd = makeTempDir("probe-memo-");
|
|
172
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
173
|
+
await probeCwCliNormalization(spawner, cwd);
|
|
174
|
+
await probeCwCliNormalization(spawner, cwd);
|
|
175
|
+
expect(calls.length).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("memoize:全局单值缓存,不同 cwd 也命中(cw 版本 cwd 无关,S-1)", async () => {
|
|
179
|
+
const cwdA = makeTempDir("probe-memo-a-");
|
|
180
|
+
const cwdB = makeTempDir("probe-memo-b-");
|
|
181
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
182
|
+
await probeCwCliNormalization(spawner, cwdA);
|
|
183
|
+
await probeCwCliNormalization(spawner, cwdB);
|
|
184
|
+
expect(calls.length).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── executeCwAction workspace 门控(TC1-5)──────────────────────
|
|
189
|
+
|
|
190
|
+
describe("executeCwAction workspace 门控", () => {
|
|
191
|
+
it("TC1: probe 支持 → write action 不传 --workspace(纯封装)", async () => {
|
|
192
|
+
const base = makeTempDir("gate-tc1-");
|
|
193
|
+
const repo = createGitRepo(base, "repo");
|
|
194
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
195
|
+
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
196
|
+
expect(actionCall(calls).args).not.toContain("--workspace");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("TC2: probe 不支持 → write action 兜底传 --workspace(ADR-0045 行为)", async () => {
|
|
200
|
+
const base = makeTempDir("gate-tc2-");
|
|
201
|
+
const repo = createGitRepo(base, "repo");
|
|
202
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
203
|
+
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
204
|
+
const args = actionCall(calls).args;
|
|
205
|
+
expect(args).toContain("--workspace");
|
|
206
|
+
expect(args[args.indexOf("--workspace") + 1]).toBe(repo);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("TC3: probe 失败(spawn error)→ fail-safe 兜底传 --workspace", async () => {
|
|
210
|
+
const base = makeTempDir("gate-tc3-");
|
|
211
|
+
const repo = createGitRepo(base, "repo");
|
|
212
|
+
const { spawner, calls } = gateSpawner({ versionThrow: true });
|
|
213
|
+
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
214
|
+
expect(actionCall(calls).args).toContain("--workspace");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("TC4: 只读 action(status)始终不传 --workspace(不 probe)", async () => {
|
|
218
|
+
const base = makeTempDir("gate-tc4-");
|
|
219
|
+
const repo = createGitRepo(base, "repo");
|
|
220
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
221
|
+
await executeCwAction("status", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
222
|
+
// 只读不 probe:仅 1 次调用(action),且不含 --workspace
|
|
223
|
+
expect(calls.length).toBe(1);
|
|
224
|
+
expect(calls[0].args).not.toContain("--workspace");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("TC5: 非 git 目录 + probe 不支持 → detectRepoWorkspace undefined → 不传", async () => {
|
|
228
|
+
const plain = makeTempDir("gate-tc5-plain-");
|
|
229
|
+
const { spawner, calls } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
230
|
+
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
|
|
231
|
+
expect(actionCall(calls).args).not.toContain("--workspace");
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/cw-runner.ts
CHANGED
|
@@ -109,6 +109,122 @@ export function detectRepoWorkspace(cwd: string): string | undefined {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* cw-cli 支持 store 内部归一化的最低版本(门控阈值)。
|
|
114
|
+
*
|
|
115
|
+
* cw-tool 与 cw-cli 是两个独立 npm 包(cw-tool 经 PATH 裸调 cw、零依赖声明),
|
|
116
|
+
* 两包独立升级。cw-tool 退回纯封装前需探测 cw-cli 是否已落地 store 归一化
|
|
117
|
+
* (coding-workflow S1:getCwJsonPath 用 git-common-dir),支持则不传 --workspace
|
|
118
|
+
* (纯封装),不支持则兜底 ADR-0045 的 detectRepoWorkspace + --workspace。
|
|
119
|
+
*
|
|
120
|
+
* TODO(S1): 当前 placeholder "99.0.0" 使门控永远判定为「不支持」→ 永远走兜底
|
|
121
|
+
* (=现状 dirname 行为,不引入回归)。cw-cli S1 落地并发布后,改为实际版本号
|
|
122
|
+
* (如 "1.7.0"),门控自动激活。详见 docs/architecture/cw-store-workspace-decoupling.md §3 §4。
|
|
123
|
+
*/
|
|
124
|
+
const MIN_CW_CLI_VERSION_FOR_NORMALIZATION = "99.0.0";
|
|
125
|
+
|
|
126
|
+
/** probe 超时(ms):cw --version 卡死时 fail-safe 为「不支持」。 */
|
|
127
|
+
const CW_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
128
|
+
|
|
129
|
+
/** 版本 parse 失败时 reason 截断长度(避免 stdout 过长污染日志)。 */
|
|
130
|
+
const VERSION_REASON_MAX_LEN = 60;
|
|
131
|
+
|
|
132
|
+
/** cw-cli 能力探测结果。 */
|
|
133
|
+
export interface CwCliCapability {
|
|
134
|
+
/** true = cw-cli 支持 store 内部归一化(cw-tool 应纯封装,不传 --workspace)。 */
|
|
135
|
+
supported: boolean;
|
|
136
|
+
/** parse 到的 cw 版本号(如 "1.6.1"),parse 失败 = undefined。 */
|
|
137
|
+
version: string | undefined;
|
|
138
|
+
/** 不支持/失败的简要原因(日志/调试用)。 */
|
|
139
|
+
reason?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 进程内 memoize 缓存:全局单值(cw --version 输出与 cwd 无关,cw-cli 安装版本在进程
|
|
144
|
+
* 生命周期内不变,按 cwd 做 key 多余)。首次探测后整个进程复用;测试用
|
|
145
|
+
* {@link _resetCapabilityCacheForTest} 在 beforeEach 隔离。
|
|
146
|
+
*/
|
|
147
|
+
let cachedCapability: CwCliCapability | undefined;
|
|
148
|
+
|
|
149
|
+
/** 测试用:重置 capability 缓存(全局单值,测试 beforeEach 隔离避免串台)。 */
|
|
150
|
+
export function _resetCapabilityCacheForTest(): void {
|
|
151
|
+
cachedCapability = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 从 cw --version stdout 提取版本号(如 "cw 1.6.1" → [1,6,1])。失败返回 undefined。 */
|
|
155
|
+
function parseCwVersion(stdout: string): number[] | undefined {
|
|
156
|
+
const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
157
|
+
if (!match) return undefined;
|
|
158
|
+
return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** semver 三段比较:a < b → -1,a === b → 0,a > b → 1。 */
|
|
162
|
+
function compareSemver(a: number[], b: number[]): number {
|
|
163
|
+
for (let i = 0; i < a.length; i++) {
|
|
164
|
+
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
165
|
+
}
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 探测 cw-cli 是否支持 store 内部归一化(门控)。
|
|
171
|
+
*
|
|
172
|
+
* 用 spawner 跑 `cw --version`,parse 版本号与 {@link MIN_CW_CLI_VERSION_FOR_NORMALIZATION}
|
|
173
|
+
* 比较。失败(spawn 失败/parse 不到/超时)→ supported:false(fail-safe,兜底 ADR-0045 行为)。
|
|
174
|
+
* 进程内 memoize(全局单值,cw 版本 cwd 无关):首次探测后缓存,消除重复 spawn。
|
|
175
|
+
*
|
|
176
|
+
* @param parentSignal 调用方 SDK abort signal,与内部 5s 超时合并转发给 spawner(S-4):
|
|
177
|
+
* 任一 abort 即 abort,让卡死的 `cw --version` 尽快收尾。
|
|
178
|
+
*/
|
|
179
|
+
export async function probeCwCliNormalization(
|
|
180
|
+
spawner: CwSpawner,
|
|
181
|
+
cwd: string,
|
|
182
|
+
parentSignal?: AbortSignal,
|
|
183
|
+
): Promise<CwCliCapability> {
|
|
184
|
+
if (cachedCapability) return cachedCapability;
|
|
185
|
+
|
|
186
|
+
const minVersion = parseCwVersion(MIN_CW_CLI_VERSION_FOR_NORMALIZATION);
|
|
187
|
+
if (!minVersion) {
|
|
188
|
+
// placeholder 本身非法(不应发生)——保守不支持
|
|
189
|
+
const fallback: CwCliCapability = { supported: false, version: undefined, reason: "MIN_CW_CLI_VERSION_FOR_NORMALIZATION 非法" };
|
|
190
|
+
cachedCapability = fallback;
|
|
191
|
+
return fallback;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), CW_VERSION_PROBE_TIMEOUT_MS);
|
|
196
|
+
// 合并调用方 SDK signal:任一 abort(5s 超时 fail-safe 或调用方主动 abort)即 abort(S-4)。
|
|
197
|
+
const onParentAbort = (): void => controller.abort();
|
|
198
|
+
if (parentSignal) {
|
|
199
|
+
if (parentSignal.aborted) controller.abort();
|
|
200
|
+
else parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
201
|
+
}
|
|
202
|
+
let result: CwCliCapability;
|
|
203
|
+
try {
|
|
204
|
+
const spawnResult = await spawner(["--version"], undefined, cwd, controller.signal);
|
|
205
|
+
if (spawnResult.exitCode !== 0) {
|
|
206
|
+
result = { supported: false, version: undefined, reason: `cw --version exit ${spawnResult.exitCode ?? "null"}` };
|
|
207
|
+
} else {
|
|
208
|
+
const v = parseCwVersion(spawnResult.stdout);
|
|
209
|
+
if (!v) {
|
|
210
|
+
result = { supported: false, version: undefined, reason: `version parse fail: ${spawnResult.stdout.trim().slice(0, VERSION_REASON_MAX_LEN)}` };
|
|
211
|
+
} else {
|
|
212
|
+
const supported = compareSemver(v, minVersion) >= 0;
|
|
213
|
+
result = { supported, version: v.join("."), reason: supported ? undefined : `version ${v.join(".")} < ${MIN_CW_CLI_VERSION_FOR_NORMALIZATION}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
218
|
+
result = { supported: false, version: undefined, reason: `probe failed: ${msg}` };
|
|
219
|
+
} finally {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
cachedCapability = result;
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
112
228
|
/** input / inputFile 互斥校验。 */
|
|
113
229
|
export function rejectConflictingInput(opts: CwToolOptions): string | undefined {
|
|
114
230
|
if (opts.input !== undefined && opts.inputFile !== undefined) {
|
|
@@ -220,9 +336,17 @@ export async function executeCwAction(
|
|
|
220
336
|
const unitIdErr = rejectMissingUnitId(action, unitId);
|
|
221
337
|
if (unitIdErr) return { ok: false, ...base, error: unitIdErr };
|
|
222
338
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
339
|
+
// workspace 门控(差异文档 §3 §4 + ADR-0045 Superseded):cw-cli 支持 store 内部归一化
|
|
340
|
+
// (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ 纯封装不传 --workspace;
|
|
341
|
+
// 不支持 → 兜底 ADR-0045 的 detectRepoWorkspace + --workspace(保持向后兼容)。
|
|
342
|
+
// 只读 action 始终不传(S-3:保守避免 cw 子命令拒收未知选项导致 readonly 查询失败)。
|
|
343
|
+
let workspace: string | undefined;
|
|
344
|
+
if (isReadonlyAction(action)) {
|
|
345
|
+
workspace = undefined;
|
|
346
|
+
} else {
|
|
347
|
+
const capability = await probeCwCliNormalization(spawner, cwd, signal);
|
|
348
|
+
workspace = capability.supported ? undefined : detectRepoWorkspace(cwd);
|
|
349
|
+
}
|
|
226
350
|
const args = buildCwArgs(action, unitId, opts, workspace);
|
|
227
351
|
const stdinPayload = opts.input !== undefined ? opts.input : undefined;
|
|
228
352
|
|