@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/src/cw-runner.ts CHANGED
@@ -1,66 +1,55 @@
1
1
  /**
2
- * cw action 执行核心:白名单校验 + 参数构造 + spawn + 输出解析。
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
- * 判断 action 是否为只读(属于 {@link READONLY_ACTIONS})。
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
- * 用 `.some(===)` 而非 `.includes()` 以保持 string 入参的类型安全(readonly tuple 的
43
- * `.includes()` 要求字面量联合类型,传 string 会报错,无需 `as` 宽化)。
23
+ * 写命令(create / evidence submit / review submit / verify / run)不在工具面——
24
+ * 需要推进流程时经 bash 调 `cw`,用法以 cw-cli skill 为 SSOT。
44
25
  */
45
- export function isReadonlyAction(action: string): boolean {
46
- return READONLY_ACTIONS.some((a) => a === action);
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
- /** 透传给 cw 的可选参数(flags)。 */
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
- /** JSON 内容字符串,经 stdin 传给 cw(`cw --input -`)。与 inputFile 互斥。 */
52
- input?: string;
53
- /** input 文件路径,直接传 `--input <path>`。与 input 互斥。 */
54
- inputFile?: string;
55
- /** execute 关联的 commit(wave 层),传 `--commitHash`。 */
56
- commitHash?: string;
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; unitId: string | undefined; stdout: string; parsed: true; data: unknown }
62
- | { ok: true; action: string; unitId: string | undefined; stdout: string; parsed: false }
63
- | { ok: false; action: string; unitId: string | undefined; error: 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
- /** git 探测超时(ms):git 卡死时避免阻塞 agent turn。 */
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
- * 探测 cw-cli 是否支持 store 内部归一化(门控)。
75
+ * 查询参数合法性校验(action × flag 匹配 + 互斥)。返回错误消息或 undefined(放行)。
184
76
  *
185
- * 用 spawner 跑 `cw --version`,parse 版本号与 {@link MIN_CW_CLI_VERSION_FOR_NORMALIZATION}
186
- * 比较。失败(spawn 失败/parse 不到/超时)→ supported:false(fail-safe,兜底 cw-cli ADR-0014 行为)。
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 async function probeCwCliNormalization(
193
- spawner: CwSpawner,
194
- cwd: string,
195
- parentSignal?: AbortSignal,
196
- ): Promise<CwCliCapability> {
197
- if (cachedCapability) return cachedCapability;
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
- const controller = new AbortController();
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
- let result: CwCliCapability;
216
- try {
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
- cachedCapability = result;
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
- * unitId 缺失校验(S-5)。写 action 缺 unitId → 返回错误消息;只读 action 或已传 unitId → undefined(放行)。
100
+ * 构建 cw 命令行参数(action 后接 flags),flag 顺序 = 上方各 action 的注释序。
251
101
  *
252
- * schema 已把 unitId 改为 Optional(只读 action 不需要),此函数是写 action 的运行时第二道约束
253
- * (直接/程序化调用、宽松 provider 兜底),与 rejectDisallowedAction / rejectConflictingInput 同族。
102
+ * - status:`[--unit <id>] [--json]`
103
+ * - frontier:`[--json]`
104
+ * - tree:无 flag
105
+ * - report:`--unit <id>` 或 `--root <id>`(互斥,均省略 = 全账本)
254
106
  */
255
- export function rejectMissingUnitId(action: string, unitId: string | undefined): string | undefined {
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("--unitId", unitId);
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
- if (opts.inputFile) {
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 action 的核心逻辑:白名单校验 → 参数冲突校验 → spawn → 解析。
135
+ * 执行 cw 只读查询的核心逻辑:白名单校验 → 参数校验 → spawn → 解析。
315
136
  *
316
- * 失败判定:非零退出码(含被信号终止的 null)→ ok:false(stderr 折进错误消息;S-2 按 exitCode 判定)。
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 cw unit id(写 action 必传;只读 action 可省略,见 rejectMissingUnitId)。
323
- * @param opts 可选 flags。
324
- * @param spawner spawn 实现(默认走真实 cw,测试注入 fake)。
325
- * @param cwd 子进程工作目录。
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, unitId };
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 inputErr = rejectConflictingInput(opts);
346
- if (inputErr) return { ok: false, ...base, error: inputErr };
164
+ const queryErr = rejectInvalidQueryOptions(action, opts);
165
+ if (queryErr) return { ok: false, ...base, error: queryErr };
347
166
 
348
- // unitId 运行时校验(S-5):写 action 缺 unitId → 清晰错误(schema 已把 unitId 改 Optional)。
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, stdinPayload, cwd, combined.signal);
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
- // S-2:按 exitCode 判定失败(防御性,不依赖 cw 是否向 stderr 写非错误诊断信息)。
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
- let error = parts.join(" | ");
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 子进程继续推进状态机(executeCwAction 把 SDK signal +
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 子进程,防止 cw 状态机被已 abort 的僵尸子进程推进。
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 / 无执行权限等)。exitCode=-1 区分于正常退出码。
96
- finish({ stdout, stderr: `${stderr}\n[spawn error] ${err.message}`, exitCode: -1 });
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 });