@zhushanwen/pi-cw-tool 0.4.2 → 0.5.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/README.md +10 -36
- package/package.json +5 -9
- package/skills/pi-cw/SKILL.md +33 -71
- package/src/__tests__/cw-tool.test.ts +240 -611
- package/src/cw-runner.ts +79 -292
- package/src/cw-spawn.ts +36 -5
- package/src/index.ts +56 -147
- package/agents/dev-agent.md +0 -92
- package/agents/merge-agent.md +0 -55
- package/agents/planning-agent.md +0 -133
- package/agents/review-agent.md +0 -95
- package/agents/wave-agent.md +0 -115
- package/skills/pi-cw/design-v4.md +0 -226
- package/src/__tests__/detect-repo-workspace.test.ts +0 -163
- package/src/__tests__/workspace-gate.test.ts +0 -261
package/src/cw-runner.ts
CHANGED
|
@@ -1,66 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cw
|
|
2
|
+
* cw 只读查询核心:白名单校验 + 参数构造 + spawn + 输出解析。
|
|
3
|
+
*
|
|
4
|
+
* cw 2.0 适配(Phase 2-B):cw 2.0 删除了 1.x 的声明推进命令面(design/execute/
|
|
5
|
+
* handoff 等),编排智能收进引擎(`cw run` runner + 账本 gate)。本扩展不再包写
|
|
6
|
+
* 命令——写操作(create / evidence submit / review submit / verify / run)由
|
|
7
|
+
* agent 经 bash 直接调 `cw`(用法见 cw-cli skill),本扩展只提供只读查询的结构化
|
|
8
|
+
* 工具入口(cw_query:status / frontier / tree / report)。
|
|
9
|
+
*
|
|
10
|
+
* 1.x 的 workspace 门控(detectRepoWorkspace + --workspace 透传 + cw 版本探测)
|
|
11
|
+
* 已随 2.0 适配删除:cw 2.0 store 布局为 `~/.cw/<encoded-cwd>/`(per-cwd,无
|
|
12
|
+
* --workspace 参数),spawn 时传 cwd 即可。
|
|
3
13
|
*
|
|
4
14
|
* 与 Pi SDK 解耦(不 import pi 类型),纯逻辑 + 可注入 spawner,便于单测。
|
|
5
15
|
* 所有错误路径返回 `{ ok: false, error }`,不抛异常(由调用方映射为 tool 返回)。
|
|
6
16
|
*/
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
|
-
import * as path from "node:path";
|
|
9
|
-
|
|
10
17
|
import type { CwSpawner } from "./cw-spawn.ts";
|
|
11
18
|
|
|
12
|
-
/** cw 全部 action 名(E1 后:clarify 已删、plan→design)。透传 cw,与 cw-cli ALL_ACTIONS 对齐。 */
|
|
13
|
-
export const CW_ACTIONS = [
|
|
14
|
-
"create",
|
|
15
|
-
"design",
|
|
16
|
-
"design-review",
|
|
17
|
-
"execute",
|
|
18
|
-
"test",
|
|
19
|
-
"exec-review",
|
|
20
|
-
"retrospect",
|
|
21
|
-
"closeout",
|
|
22
|
-
"replan",
|
|
23
|
-
"abort",
|
|
24
|
-
"list",
|
|
25
|
-
"tree",
|
|
26
|
-
"status",
|
|
27
|
-
"handoff",
|
|
28
|
-
"frontier",
|
|
29
|
-
] as const;
|
|
30
|
-
export type CwAction = (typeof CW_ACTIONS)[number];
|
|
31
|
-
|
|
32
|
-
/** 只读 action(不推进状态机,query only)。 */
|
|
33
|
-
export const READONLY_ACTIONS = ["list", "tree", "status", "handoff", "frontier"] as const;
|
|
34
|
-
|
|
35
19
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* 只读 action 的两个边界复用此判定(S-3/S-5):
|
|
39
|
-
* - 不附加 `--workspace`(保守避免 cw 子命令拒收未知选项导致 readonly 查询失败,S-3);
|
|
40
|
-
* - 不强制 unitId(list/tree/frontier 等全局查询不需要具体 unit,S-5)。
|
|
20
|
+
* cw_query 工具的 action 白名单 = cw 2.0 只读命令面(dist/readonly/index.ts,
|
|
21
|
+
* 2026-08-22 以 coding-workflow@2.0.1 核实)。
|
|
41
22
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
23
|
+
* 写命令(create / evidence submit / review submit / verify / run)不在工具面——
|
|
24
|
+
* 需要推进流程时经 bash 调 `cw`,用法以 cw-cli skill 为 SSOT。
|
|
44
25
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
export const CW_ACTIONS = ["status", "frontier", "tree", "report"] as const;
|
|
27
|
+
export type CwAction = (typeof CW_ACTIONS)[number];
|
|
28
|
+
|
|
29
|
+
/** 支持 `--json` 的 action(cw 2.0 规格锁定:仅 status / frontier)。 */
|
|
30
|
+
const JSON_ACTIONS = ["status", "frontier"] as const;
|
|
48
31
|
|
|
49
|
-
/**
|
|
32
|
+
/** 支持 `--unit` 选择器的 action(status / report;tree / frontier 为全局视图)。 */
|
|
33
|
+
const UNIT_ACTIONS = ["status", "report"] as const;
|
|
34
|
+
|
|
35
|
+
/** 支持 `--root` 子树选择器的 action(仅 report;2.0 frontier 无 --root)。 */
|
|
36
|
+
const ROOT_ACTIONS = ["report"] as const;
|
|
37
|
+
|
|
38
|
+
/** 透传给 cw 的可选查询参数。 */
|
|
50
39
|
export interface CwToolOptions {
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
|
|
40
|
+
/** unit id → `--unit <id>`(status 单 unit 详情 / report 单 unit 证据链)。 */
|
|
41
|
+
unitId?: string;
|
|
42
|
+
/** 子树根 id → `--root <id>`(report 子树汇总;与 unitId 互斥)。 */
|
|
43
|
+
rootId?: string;
|
|
44
|
+
/** 结构化输出 → `--json`(仅 status / frontier)。 */
|
|
45
|
+
json?: boolean;
|
|
57
46
|
}
|
|
58
47
|
|
|
59
48
|
/** 工具返回的 details 结构(结构化成功/失败,调用方按 `ok` 区分)。 */
|
|
60
49
|
export type CwDetails =
|
|
61
|
-
| { ok: true; action: string;
|
|
62
|
-
| { ok: true; action: string;
|
|
63
|
-
| { ok: false; action: string;
|
|
50
|
+
| { ok: true; action: string; stdout: string; parsed: true; data: unknown }
|
|
51
|
+
| { ok: true; action: string; stdout: string; parsed: false }
|
|
52
|
+
| { ok: false; action: string; error: string };
|
|
64
53
|
|
|
65
54
|
/**
|
|
66
55
|
* 白名单校验。返回错误消息(string)或 undefined(放行)。
|
|
@@ -75,224 +64,56 @@ export function rejectDisallowedAction(
|
|
|
75
64
|
toolName: string,
|
|
76
65
|
): string | undefined {
|
|
77
66
|
if (!allowed.includes(action)) {
|
|
78
|
-
return `action "${action}" 不在 ${toolName} 白名单。允许的 action: ${allowed.join(", ")}
|
|
67
|
+
return `action "${action}" 不在 ${toolName} 白名单。允许的 action: ${allowed.join(", ")}。写命令(create / evidence / review / verify / run)请经 bash 调 cw,用法见 cw-cli skill。`;
|
|
79
68
|
}
|
|
80
69
|
return undefined;
|
|
81
70
|
}
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
const GIT_PROBE_TIMEOUT_MS = 5000;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* 探测 cwd 所属 repo 的主目录(repo 级 workspace),供老 cw-cli(<1.6.2,无 store 归一化)兜底。
|
|
88
|
-
*
|
|
89
|
-
* 用 `git rev-parse --path-format=absolute --git-common-dir` 取 git common dir:
|
|
90
|
-
* 同一 repo 的所有 worktree 返回相同路径,dirname 即 repo 主目录。cw store 键控
|
|
91
|
-
* 从 per-cwd 升级为 repo 级(cw-cli ADR-0014)后,spawn cw 时附带 --workspace 让 cw
|
|
92
|
-
* 在 repo 主目录解析/共享状态,避免同一 repo 的 worktree 间状态各自为政。
|
|
93
|
-
*
|
|
94
|
-
* **bare repo + worktree 模式(.bare)**:common-dir basename 是 `.bare` 而非 `.git`,
|
|
95
|
-
* dirname 指向 workspace 容器根(非 git 目录)——传给 cw 会让它 fallback 到错误的 store-key
|
|
96
|
-
* (unit not found)。检测到 basename 非 `.git` 时返回 undefined(不传 --workspace),让老 cw-cli
|
|
97
|
-
* 退回 per-cwd store(读写一致但无 repo 级共享)。新 cw-cli(≥1.6.2)自己归一化,门控支持→不调本函数。
|
|
98
|
-
* 不可用 `--is-bare-repository` 判据——worktree 内它永远返回 false(bare 是 .bare 目录本身)。
|
|
99
|
-
*
|
|
100
|
-
* 任何失败(非 git 目录、git 不在 PATH、路径不存在、超时)→ undefined(不抛)。
|
|
101
|
-
*/
|
|
102
|
-
export function detectRepoWorkspace(cwd: string): string | undefined {
|
|
103
|
-
try {
|
|
104
|
-
const result = spawnSync(
|
|
105
|
-
"git",
|
|
106
|
-
["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
107
|
-
{ encoding: "utf8", timeout: GIT_PROBE_TIMEOUT_MS },
|
|
108
|
-
);
|
|
109
|
-
if (result.status !== 0) return undefined;
|
|
110
|
-
const gitCommonDir = result.stdout.trim();
|
|
111
|
-
if (gitCommonDir.length === 0) return undefined;
|
|
112
|
-
// 非标准 git-dir 一律退回 per-cwd(fail-safe):bare repo worktree(basename=.bare,
|
|
113
|
-
// dirname 指向容器根,非 git 目录)、submodule(common-dir=.git/modules/<name>)、
|
|
114
|
-
// --separate-git-dir / GIT_DIR 等。把 dirname 传给 cw 会导致 store-key fallback 错误
|
|
115
|
-
// → unit not found。返回 undefined 退回 per-cwd(读写一致)。
|
|
116
|
-
if (path.basename(gitCommonDir) !== ".git") return undefined;
|
|
117
|
-
return path.dirname(gitCommonDir);
|
|
118
|
-
} catch {
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* cw-cli 支持 store 内部归一化的最低版本(门控阈值)。
|
|
125
|
-
*
|
|
126
|
-
* cw-tool 与 cw-cli 是两个独立 npm 包(cw-tool 经 PATH 裸调 cw、零依赖声明),
|
|
127
|
-
* 两包独立升级。cw-tool 退回纯封装前需探测 cw-cli 是否已落地 store 归一化
|
|
128
|
-
* (cw-cli commit a90e8e8 / 首个 tag v1.6.2:getCwJsonPath 改用 detectCommonDir 归一化
|
|
129
|
-
* 到 git-common-dir),支持则不传 --workspace(纯封装),不支持则兜底 cw-cli ADR-0014
|
|
130
|
-
* 的 detectRepoWorkspace + --workspace。
|
|
131
|
-
*
|
|
132
|
-
* 门控激活(1.6.2):cw-cli ≥1.6.2 自我用 detectCommonDir 归一化 store-key,同一 repo 的
|
|
133
|
-
* 所有 worktree(含 bare repo)共享 store,cw-tool 无需传 --workspace。旧值 "99.0.0"(placeholder)
|
|
134
|
-
* 使门控永远判定「不支持」→ 永远走兜底,在 bare repo worktree 下 detectRepoWorkspace 返回容器根
|
|
135
|
-
* → cw 定位到不存在的 store → unit not found。
|
|
136
|
-
*/
|
|
137
|
-
const MIN_CW_CLI_VERSION_FOR_NORMALIZATION = "1.6.2";
|
|
138
|
-
|
|
139
|
-
/** probe 超时(ms):cw --version 卡死时 fail-safe 为「不支持」。 */
|
|
140
|
-
const CW_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
141
|
-
|
|
142
|
-
/** 版本 parse 失败时 reason 截断长度(避免 stdout 过长污染日志)。 */
|
|
143
|
-
const VERSION_REASON_MAX_LEN = 60;
|
|
144
|
-
|
|
145
|
-
/** cw-cli 能力探测结果。 */
|
|
146
|
-
export interface CwCliCapability {
|
|
147
|
-
/** true = cw-cli 支持 store 内部归一化(cw-tool 应纯封装,不传 --workspace)。 */
|
|
148
|
-
supported: boolean;
|
|
149
|
-
/** parse 到的 cw 版本号(如 "1.6.1"),parse 失败 = undefined。 */
|
|
150
|
-
version: string | undefined;
|
|
151
|
-
/** 不支持/失败的简要原因(日志/调试用)。 */
|
|
152
|
-
reason?: string;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* 进程内 memoize 缓存:全局单值(cw --version 输出与 cwd 无关,cw-cli 安装版本在进程
|
|
157
|
-
* 生命周期内不变,按 cwd 做 key 多余)。首次探测后整个进程复用;测试用
|
|
158
|
-
* {@link _resetCapabilityCacheForTest} 在 beforeEach 隔离。
|
|
159
|
-
*/
|
|
160
|
-
let cachedCapability: CwCliCapability | undefined;
|
|
161
|
-
|
|
162
|
-
/** 测试用:重置 capability 缓存(全局单值,测试 beforeEach 隔离避免串台)。 */
|
|
163
|
-
export function _resetCapabilityCacheForTest(): void {
|
|
164
|
-
cachedCapability = undefined;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** 从 cw --version stdout 提取版本号(如 "cw 1.6.1" → [1,6,1])。失败返回 undefined。 */
|
|
168
|
-
function parseCwVersion(stdout: string): number[] | undefined {
|
|
169
|
-
const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
170
|
-
if (!match) return undefined;
|
|
171
|
-
return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** semver 三段比较:a < b → -1,a === b → 0,a > b → 1。 */
|
|
175
|
-
function compareSemver(a: number[], b: number[]): number {
|
|
176
|
-
for (let i = 0; i < a.length; i++) {
|
|
177
|
-
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
178
|
-
}
|
|
179
|
-
return 0;
|
|
180
|
-
}
|
|
72
|
+
const includesStr = (list: readonly string[], v: string): boolean => list.some((a) => a === v);
|
|
181
73
|
|
|
182
74
|
/**
|
|
183
|
-
*
|
|
75
|
+
* 查询参数合法性校验(action × flag 匹配 + 互斥)。返回错误消息或 undefined(放行)。
|
|
184
76
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* 进程内 memoize(全局单值,cw 版本 cwd 无关):首次探测后缓存,消除重复 spawn。
|
|
188
|
-
*
|
|
189
|
-
* @param parentSignal 调用方 SDK abort signal,与内部 5s 超时合并转发给 spawner(S-4):
|
|
190
|
-
* 任一 abort 即 abort,让卡死的 `cw --version` 尽快收尾。
|
|
77
|
+
* report 的 `--unit` / `--root` 在 cw 2.0 互斥;非 report 传 rootId、非 status/frontier
|
|
78
|
+
* 传 json 属于本工具面错误(cw 2.0 对应命令不接受该 flag),前置拦截给出可操作错误。
|
|
191
79
|
*/
|
|
192
|
-
export
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const minVersion = parseCwVersion(MIN_CW_CLI_VERSION_FOR_NORMALIZATION);
|
|
200
|
-
if (!minVersion) {
|
|
201
|
-
// MIN_CW_CLI_VERSION_FOR_NORMALIZATION 本身非法(不应发生)——保守不支持
|
|
202
|
-
const fallback: CwCliCapability = { supported: false, version: undefined, reason: "MIN_CW_CLI_VERSION_FOR_NORMALIZATION 非法" };
|
|
203
|
-
cachedCapability = fallback;
|
|
204
|
-
return fallback;
|
|
80
|
+
export function rejectInvalidQueryOptions(
|
|
81
|
+
action: string,
|
|
82
|
+
opts: CwToolOptions,
|
|
83
|
+
): string | undefined {
|
|
84
|
+
if (opts.unitId !== undefined && !includesStr(UNIT_ACTIONS, action)) {
|
|
85
|
+
return `action "${action}" 不支持 unitId(仅 ${UNIT_ACTIONS.join(" / ")} 接受 --unit)。`;
|
|
205
86
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const timer = setTimeout(() => controller.abort(), CW_VERSION_PROBE_TIMEOUT_MS);
|
|
209
|
-
// 合并调用方 SDK signal:任一 abort(5s 超时 fail-safe 或调用方主动 abort)即 abort(S-4)。
|
|
210
|
-
const onParentAbort = (): void => controller.abort();
|
|
211
|
-
if (parentSignal) {
|
|
212
|
-
if (parentSignal.aborted) controller.abort();
|
|
213
|
-
else parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
87
|
+
if (opts.rootId !== undefined && !includesStr(ROOT_ACTIONS, action)) {
|
|
88
|
+
return `action "${action}" 不支持 rootId(仅 ${ROOT_ACTIONS.join(" / ")} 接受 --root)。`;
|
|
214
89
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const spawnResult = await spawner(["--version"], undefined, cwd, controller.signal);
|
|
218
|
-
if (spawnResult.exitCode !== 0) {
|
|
219
|
-
result = { supported: false, version: undefined, reason: `cw --version exit ${spawnResult.exitCode ?? "null"}` };
|
|
220
|
-
} else {
|
|
221
|
-
const v = parseCwVersion(spawnResult.stdout);
|
|
222
|
-
if (!v) {
|
|
223
|
-
result = { supported: false, version: undefined, reason: `version parse fail: ${spawnResult.stdout.trim().slice(0, VERSION_REASON_MAX_LEN)}` };
|
|
224
|
-
} else {
|
|
225
|
-
const supported = compareSemver(v, minVersion) >= 0;
|
|
226
|
-
result = { supported, version: v.join("."), reason: supported ? undefined : `version ${v.join(".")} < ${MIN_CW_CLI_VERSION_FOR_NORMALIZATION}` };
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
} catch (err) {
|
|
230
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
-
result = { supported: false, version: undefined, reason: `probe failed: ${msg}` };
|
|
232
|
-
} finally {
|
|
233
|
-
clearTimeout(timer);
|
|
234
|
-
if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
|
|
90
|
+
if (opts.json === true && !includesStr(JSON_ACTIONS, action)) {
|
|
91
|
+
return `action "${action}" 不支持 json(cw 2.0 仅 ${JSON_ACTIONS.join(" / ")} 提供 --json)。`;
|
|
235
92
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return result;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** input / inputFile 互斥校验。 */
|
|
242
|
-
export function rejectConflictingInput(opts: CwToolOptions): string | undefined {
|
|
243
|
-
if (opts.input !== undefined && opts.inputFile !== undefined) {
|
|
244
|
-
return "'input' 和 'inputFile' 互斥,只能传其中一个。";
|
|
93
|
+
if (opts.unitId !== undefined && opts.rootId !== undefined) {
|
|
94
|
+
return "unitId 与 rootId 互斥(cw report 的 --unit / --root 只能传其中一个)。";
|
|
245
95
|
}
|
|
246
96
|
return undefined;
|
|
247
97
|
}
|
|
248
98
|
|
|
249
99
|
/**
|
|
250
|
-
*
|
|
100
|
+
* 构建 cw 命令行参数(action 后接 flags),flag 顺序 = 上方各 action 的注释序。
|
|
251
101
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
102
|
+
* - status:`[--unit <id>] [--json]`
|
|
103
|
+
* - frontier:`[--json]`
|
|
104
|
+
* - tree:无 flag
|
|
105
|
+
* - report:`--unit <id>` 或 `--root <id>`(互斥,均省略 = 全账本)
|
|
254
106
|
*/
|
|
255
|
-
export function
|
|
256
|
-
if (unitId === undefined && !isReadonlyAction(action)) {
|
|
257
|
-
return `action "${action}" 需要 unitId(只读 action ${READONLY_ACTIONS.join("/")} 可省略)`;
|
|
258
|
-
}
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* 构建 cw 命令行参数(action 后接 flags)。
|
|
264
|
-
*
|
|
265
|
-
* - unitId(若提供)→ `--unitId <id>`;undefined 则省略(只读 action 不需要,S-5)
|
|
266
|
-
* - input 内容 → `--input -`(经 stdin,见 executeCwAction)
|
|
267
|
-
* - inputFile 路径 → `--input <path>`
|
|
268
|
-
* - commitHash → `--commitHash <sha>`
|
|
269
|
-
* - workspace(repo 主目录,由调用方经 detectRepoWorkspace 探测)→ `--workspace <path>`,位于 --commitHash 之后
|
|
270
|
-
*/
|
|
271
|
-
export function buildCwArgs(
|
|
272
|
-
action: string,
|
|
273
|
-
unitId: string | undefined,
|
|
274
|
-
opts: CwToolOptions,
|
|
275
|
-
workspace?: string,
|
|
276
|
-
): string[] {
|
|
107
|
+
export function buildCwArgs(action: string, opts: CwToolOptions): string[] {
|
|
277
108
|
const args: string[] = [action];
|
|
278
|
-
if (unitId !== undefined) {
|
|
279
|
-
args.push("--
|
|
109
|
+
if (opts.unitId !== undefined) {
|
|
110
|
+
args.push("--unit", opts.unitId);
|
|
111
|
+
} else if (opts.rootId !== undefined) {
|
|
112
|
+
args.push("--root", opts.rootId);
|
|
280
113
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
args.push("--input", opts.inputFile);
|
|
284
|
-
} else if (opts.input !== undefined) {
|
|
285
|
-
args.push("--input", "-");
|
|
114
|
+
if (opts.json === true) {
|
|
115
|
+
args.push("--json");
|
|
286
116
|
}
|
|
287
|
-
|
|
288
|
-
if (opts.commitHash) {
|
|
289
|
-
args.push("--commitHash", opts.commitHash);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (workspace) {
|
|
293
|
-
args.push("--workspace", workspace);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
117
|
return args;
|
|
297
118
|
}
|
|
298
119
|
|
|
@@ -311,68 +132,42 @@ function tryParseJson(text: string): unknown | undefined {
|
|
|
311
132
|
const DEFAULT_CW_TIMEOUT_MS = 300_000;
|
|
312
133
|
|
|
313
134
|
/**
|
|
314
|
-
* 执行 cw
|
|
135
|
+
* 执行 cw 只读查询的核心逻辑:白名单校验 → 参数校验 → spawn → 解析。
|
|
315
136
|
*
|
|
316
|
-
* 失败判定:非零退出码(含被信号终止的 null)→ ok:false(stderr
|
|
137
|
+
* 失败判定:非零退出码(含被信号终止的 null)→ ok:false(stderr 折进错误消息)。
|
|
317
138
|
* 成功后 stdout 尝试 JSON.parse:成功 → parsed:true + data;失败 → parsed:false + 原样 stdout。
|
|
318
139
|
*
|
|
319
140
|
* @param action 调用方请求的 action(运行时再校验白名单)。
|
|
320
141
|
* @param allowed 该工具允许的 action 白名单。
|
|
321
142
|
* @param toolName 工具名(错误消息归属用)。
|
|
322
|
-
* @param unitId
|
|
323
|
-
* @param
|
|
324
|
-
* @param
|
|
325
|
-
* @param
|
|
326
|
-
* @param signal 可选 SDK abort signal;与超时合并后透传给 spawner,abort 时 spawner kill 子进程。
|
|
143
|
+
* @param opts 查询参数(unitId / rootId / json)。
|
|
144
|
+
* @param spawner spawn 实现(默认走真实 cw,测试注入 fake)。
|
|
145
|
+
* @param cwd 子进程工作目录(cw 2.0 以 cwd 定位 `~/.cw/<encoded-cwd>/` 账本)。
|
|
146
|
+
* @param signal 可选 SDK abort signal;与超时合并后透传给 spawner,abort 时 spawner kill 子进程。
|
|
327
147
|
* @param timeoutMs spawn 超时(ms),默认 5 分钟;0 表示不限时。超时返回 ok:false "cw 超时"。
|
|
328
148
|
*/
|
|
329
149
|
export async function executeCwAction(
|
|
330
150
|
action: string,
|
|
331
151
|
allowed: readonly string[],
|
|
332
152
|
toolName: string,
|
|
333
|
-
unitId: string | undefined,
|
|
334
153
|
opts: CwToolOptions,
|
|
335
154
|
spawner: CwSpawner,
|
|
336
155
|
cwd: string,
|
|
337
156
|
signal?: AbortSignal,
|
|
338
157
|
timeoutMs: number = DEFAULT_CW_TIMEOUT_MS,
|
|
339
158
|
): Promise<CwDetails> {
|
|
340
|
-
const base = { action
|
|
159
|
+
const base = { action };
|
|
341
160
|
|
|
342
161
|
const actionErr = rejectDisallowedAction(action, allowed, toolName);
|
|
343
162
|
if (actionErr) return { ok: false, ...base, error: actionErr };
|
|
344
163
|
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
164
|
+
const queryErr = rejectInvalidQueryOptions(action, opts);
|
|
165
|
+
if (queryErr) return { ok: false, ...base, error: queryErr };
|
|
347
166
|
|
|
348
|
-
|
|
349
|
-
const unitIdErr = rejectMissingUnitId(action, unitId);
|
|
350
|
-
if (unitIdErr) return { ok: false, ...base, error: unitIdErr };
|
|
351
|
-
|
|
352
|
-
// workspace 门控(cw-cli ADR-0014 store-workspace decoupling):cw-cli 支持 store 内部归一化
|
|
353
|
-
// (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ 纯封装不传 --workspace;
|
|
354
|
-
// 不支持 → 兜底 cw-cli ADR-0014 的 detectRepoWorkspace + --workspace(保持向后兼容)。
|
|
355
|
-
// 只读 action 始终不传(S-3:保守避免 cw 子命令拒收未知选项导致 readonly 查询失败)。
|
|
356
|
-
let workspace: string | undefined;
|
|
357
|
-
// 降级标记:老 cw-cli(不支持归一化)+ bare repo / 非 git(detectRepoWorkspace 返回 undefined)。
|
|
358
|
-
// 写动作若失败,错误消息追加升级指引(准则 6:错误指向恢复动作)。
|
|
359
|
-
let degradedNoWorkspace = false;
|
|
360
|
-
if (isReadonlyAction(action)) {
|
|
361
|
-
workspace = undefined;
|
|
362
|
-
} else {
|
|
363
|
-
const capability = await probeCwCliNormalization(spawner, cwd, signal);
|
|
364
|
-
if (capability.supported) {
|
|
365
|
-
workspace = undefined;
|
|
366
|
-
} else {
|
|
367
|
-
workspace = detectRepoWorkspace(cwd);
|
|
368
|
-
degradedNoWorkspace = workspace === undefined;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const args = buildCwArgs(action, unitId, opts, workspace);
|
|
372
|
-
const stdinPayload = opts.input !== undefined ? opts.input : undefined;
|
|
167
|
+
const args = buildCwArgs(action, opts);
|
|
373
168
|
|
|
374
169
|
// 合并 SDK abort signal 与超时为单个 signal 传给 spawner:spawner(默认实现)在 abort
|
|
375
|
-
// 时 kill 子进程,避免 abort/超时后僵尸 cw
|
|
170
|
+
// 时 kill 子进程,避免 abort/超时后僵尸 cw 进程。
|
|
376
171
|
const combined = new AbortController();
|
|
377
172
|
let timedOut = false;
|
|
378
173
|
const onSdkAbort = (): void => combined.abort();
|
|
@@ -390,7 +185,7 @@ export async function executeCwAction(
|
|
|
390
185
|
|
|
391
186
|
let result;
|
|
392
187
|
try {
|
|
393
|
-
result = await spawner(args,
|
|
188
|
+
result = await spawner(args, undefined, cwd, combined.signal);
|
|
394
189
|
} catch (err) {
|
|
395
190
|
const msg = err instanceof Error ? err.message : String(err);
|
|
396
191
|
return { ok: false, ...base, error: `cw spawn 失败: ${msg}` };
|
|
@@ -404,20 +199,12 @@ export async function executeCwAction(
|
|
|
404
199
|
|
|
405
200
|
const { stdout, stderr, exitCode } = result;
|
|
406
201
|
|
|
407
|
-
//
|
|
202
|
+
// 按 exitCode 判定失败(防御性,不依赖 cw 是否向 stderr 写非错误诊断信息)。
|
|
408
203
|
// 非零退出码(含 null=被信号终止)→ 失败;stderr 折进错误消息(成功时 stderr 不导致失败)。
|
|
409
204
|
if (exitCode !== 0) {
|
|
410
205
|
const parts: string[] = [`exit code ${exitCode ?? "null"}`];
|
|
411
206
|
if (stderr.trim()) parts.push(stderr.trim());
|
|
412
|
-
|
|
413
|
-
// 老 cw-cli(<1.6.2)+ 探测不到 repo 级 workspace(非 git 目录 / bare repo worktree /
|
|
414
|
-
// git 不可用):写动作退回 per-cwd store(读写一致但无 repo 级共享),多数情况能跑通;
|
|
415
|
-
// 若仍失败,追加升级指引帮用户切到归一化 cw-cli(准则 6:错误指向恢复动作)。
|
|
416
|
-
// 措辞同时覆盖两种降级原因(非 git 场景不误导为 bare repo 问题)。
|
|
417
|
-
if (degradedNoWorkspace) {
|
|
418
|
-
error += "\n👉 cw-cli 版本过低(<1.6.2 不支持 store-key 归一化),且当前目录未探测到 repo 级 workspace(非 git 目录 / bare repo worktree / git 不可用),写动作退回 per-cwd store(读写一致但无 repo 级共享)。建议升级:npm i -g @zhushanwen/coding-workflow@latest";
|
|
419
|
-
}
|
|
420
|
-
return { ok: false, ...base, error };
|
|
207
|
+
return { ok: false, ...base, error: parts.join(" | ") };
|
|
421
208
|
}
|
|
422
209
|
|
|
423
210
|
const data = tryParseJson(stdout);
|
package/src/cw-spawn.ts
CHANGED
|
@@ -6,9 +6,28 @@
|
|
|
6
6
|
*
|
|
7
7
|
* cw 路径解析:spawn 裸命令名 `cw`,由 OS execvp 语义在 `process.env.PATH` 中
|
|
8
8
|
* 查找(架构约定 #16:禁止写死绝对路径)。env 继承自 process.env,确保 PATH 可用。
|
|
9
|
+
*
|
|
10
|
+
* [worktree-reaper-fix] cwd 可能已被 orphan reaper 清理(pi-subagent-workflow 误删活
|
|
11
|
+
* worktree 后子进程 cwd 指向虚空)。Node spawn 对不存在的 cwd 报 ENOENT,错误消息只含
|
|
12
|
+
* command 名("cw")不含 cwd——2026-08-11 事故中导致 AI 误诊"node 被卸载"。故 spawn 前
|
|
13
|
+
* 检查 cwd 存在性,失败时返回可操作错误(含完整 cwd + 恢复指引)。
|
|
9
14
|
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
10
16
|
import { spawn } from "node:child_process";
|
|
11
17
|
|
|
18
|
+
/**
|
|
19
|
+
* cwd 不存在时的可操作错误文案(错误 → 权威源 worktrees.json → 重试闭环)。
|
|
20
|
+
* @param cwd 不存在的路径
|
|
21
|
+
*/
|
|
22
|
+
function cwdMissingError(cwd: string): string {
|
|
23
|
+
return [
|
|
24
|
+
`cwd 不存在:${cwd}`,
|
|
25
|
+
"该 worktree 可能已被 orphan reaper 清理,或子 agent 已结束。",
|
|
26
|
+
"恢复:1) 检查 pi agent 目录下 subagents/worktrees.json 中该 branch 的 pid 是否已补全;",
|
|
27
|
+
" 2) 若子 agent 仍在运行,重新派发(worktree 重建);3) 若已结束,忽略此错误。",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
/** cw 子进程执行结果。 */
|
|
13
32
|
export interface CwSpawnResult {
|
|
14
33
|
/** stdout 内容(cw action 通常把结果 JSON 输出到 stdout)。 */
|
|
@@ -26,7 +45,7 @@ export interface CwSpawnResult {
|
|
|
26
45
|
* @param input 要写入子进程 stdin 的内容;undefined 表示不写(cw 不读 stdin)。
|
|
27
46
|
* @param cwd 子进程工作目录。
|
|
28
47
|
* @param signal 可选 abort signal;实现应在 abort 时 kill 子进程(见 defaultCwSpawner),
|
|
29
|
-
* 避免 abort 后僵尸 cw
|
|
48
|
+
* 避免 abort 后僵尸 cw 子进程继续运行(executeCwAction 把 SDK signal +
|
|
30
49
|
* 超时合并为此 signal 传入)。
|
|
31
50
|
*/
|
|
32
51
|
export type CwSpawner = (
|
|
@@ -42,11 +61,18 @@ export type CwSpawner = (
|
|
|
42
61
|
* - stdout/stderr 设 utf8 编码后全量捕获(data 回调收 string,无需 Buffer 处理)。
|
|
43
62
|
* - input(若提供)写入 stdin 后关闭;未提供则直接 end(cw 不阻塞等待 stdin)。
|
|
44
63
|
* - spawn 自身失败(如 cw 不在 PATH)走 'error' 事件,拼进 stderr、exitCode=-1 标记异常。
|
|
45
|
-
* - signal abort 时 kill 子进程(SIGTERM),避免 abort 后僵尸 cw
|
|
64
|
+
* - signal abort 时 kill 子进程(SIGTERM),避免 abort 后僵尸 cw 继续运行;
|
|
46
65
|
* signal 进入时已 aborted 则立即 kill。listener 在 settle 时移除防泄漏。
|
|
47
66
|
*/
|
|
48
67
|
export const defaultCwSpawner: CwSpawner = (args, input, cwd, signal) =>
|
|
49
68
|
new Promise<CwSpawnResult>((resolve) => {
|
|
69
|
+
// [worktree-reaper-fix] spawn 前检查 cwd 存在性(TOCTOU 兜底见下方 error handler)。
|
|
70
|
+
// 与现有 error 路径返回形态一致:resolve + exitCode=-1 + stderr,不 reject。
|
|
71
|
+
if (!existsSync(cwd)) {
|
|
72
|
+
resolve({ stdout: "", stderr: cwdMissingError(cwd), exitCode: -1 });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
const child = spawn("cw", args, {
|
|
51
77
|
cwd,
|
|
52
78
|
env: process.env,
|
|
@@ -65,7 +91,7 @@ export const defaultCwSpawner: CwSpawner = (args, input, cwd, signal) =>
|
|
|
65
91
|
stderr += chunk;
|
|
66
92
|
});
|
|
67
93
|
|
|
68
|
-
// abort → kill
|
|
94
|
+
// abort → kill 子进程,防止已 abort 的调用仍留下运行中的 cw 子进程。
|
|
69
95
|
const onAbort = (): void => {
|
|
70
96
|
child.kill("SIGTERM");
|
|
71
97
|
};
|
|
@@ -92,8 +118,13 @@ export const defaultCwSpawner: CwSpawner = (args, input, cwd, signal) =>
|
|
|
92
118
|
};
|
|
93
119
|
|
|
94
120
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
95
|
-
// spawn 失败(cw 不在 PATH /
|
|
96
|
-
|
|
121
|
+
// spawn 失败(cw 不在 PATH / 无执行权限 / TOCTOU:检查后 cwd 被删等)。
|
|
122
|
+
// [worktree-reaper-fix] 拼 cwd 进错误消息:ENOENT 的 err.message 只有 command 名,
|
|
123
|
+
// 无 cwd 线索会导致误诊(2026-08-11 事故 AI 误判"node 被卸载")。
|
|
124
|
+
// exitCode=-1 区分于正常退出码。
|
|
125
|
+
const errCwd = err.code === "ENOENT" ? `\ncwd: ${cwd}` : "";
|
|
126
|
+
const hint = err.code === "ENOENT" && !existsSync(cwd) ? `\n${cwdMissingError(cwd)}` : "";
|
|
127
|
+
finish({ stdout, stderr: `${stderr}\n[spawn error] ${err.message}${errCwd}${hint}`, exitCode: -1 });
|
|
97
128
|
});
|
|
98
129
|
child.on("close", (code: number | null) => {
|
|
99
130
|
finish({ stdout, stderr, exitCode: code });
|