@zhushanwen/pi-cw-tool 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-cw-tool",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -750,4 +750,33 @@ describe("cw 路径解析", () => {
750
750
  expect(result.stderr).toContain("spawn cw ENOENT");
751
751
  expect(result.stdout).toBe("");
752
752
  });
753
+
754
+ it("[worktree-reaper-fix] cwd 不存在 → 不 spawn,exitCode:-1 + 可操作错误(含 cwd 路径与恢复指引)", async () => {
755
+ // worktree 被 orphan reaper 误删后,子进程 cwd 指向虚空。spawn 前检查必须拦截并返回
756
+ // 含完整 cwd + 恢复指引的错误(否则 Node ENOENT 只报 command 名,误导诊断为"node 被卸载")。
757
+ const result = await defaultCwSpawner(
758
+ ["status", "--unitId", "u1"],
759
+ undefined,
760
+ "/nonexistent-cwd-for-reaper-test",
761
+ );
762
+
763
+ expect(result.exitCode).toBe(-1);
764
+ expect(result.stderr).toContain("/nonexistent-cwd-for-reaper-test");
765
+ expect(result.stderr).toContain("worktrees.json");
766
+ // 前置检查拦截:不进入 spawn
767
+ expect(spawnMock).not.toHaveBeenCalled();
768
+ });
769
+
770
+ it("[worktree-reaper-fix] spawn error ENOENT → stderr 拼 cwd 路径(TOCTOU 兜底)", async () => {
771
+ // existsSync 检查通过后目录被删(TOCTOU):error handler 必须兜底拼 cwd。
772
+ const child = makeFakeChild();
773
+ spawnMock.mockImplementation(() => child as unknown as cp.ChildProcess);
774
+ const err = Object.assign(new Error("spawn cw ENOENT"), { code: "ENOENT" });
775
+ queueMicrotask(() => child.emit("error", err));
776
+
777
+ const result = await defaultCwSpawner(["status", "--unitId", "u1"], undefined, "/tmp");
778
+
779
+ expect(result.exitCode).toBe(-1);
780
+ expect(result.stderr).toContain("cwd: /tmp");
781
+ });
753
782
  });
@@ -40,6 +40,24 @@ function createGitRepo(parentDir: string, name: string): string {
40
40
  return realpathSync(repo);
41
41
  }
42
42
 
43
+ /** 建 bare repo + worktree workspace(xyz-agent 模式:.bare + worktree 子目录)。
44
+ * 返回 realpath 规范化的 worktree 路径。bare repo worktree 内 git-common-dir basename 是 .bare(非 .git),
45
+ * 是 detectRepoWorkspace 加固分支的核心场景(设计文档 §2.4 / 决策 2)。 */
46
+ function createBareRepoWorkspace(parentDir: string, name: string): string {
47
+ const wsRoot = path.join(parentDir, name);
48
+ mkdirSync(wsRoot);
49
+ // 先建普通 seed repo(含初始 commit,bare repo 不能直接 commit)
50
+ const seed = path.join(parentDir, `${name}-seed`);
51
+ mkdirSync(seed);
52
+ execSync("git init -q", { cwd: seed });
53
+ execSync("git -c user.name=test -c user.email=test@test.local commit -q --allow-empty -m init", { cwd: seed });
54
+ // clone --bare 成 .bare,再 worktree add
55
+ execSync(`git clone -q --bare ${seed} .bare`, { cwd: wsRoot });
56
+ const worktree = path.join(wsRoot, "main");
57
+ execSync('git --git-dir=.bare worktree add -q main', { cwd: wsRoot });
58
+ return realpathSync(worktree);
59
+ }
60
+
43
61
  afterEach(() => {
44
62
  for (const dir of tmpDirs.splice(0)) {
45
63
  rmSync(dir, { recursive: true, force: true });
@@ -93,6 +111,37 @@ describe("detectRepoWorkspace(真实 git)", () => {
93
111
  });
94
112
  });
95
113
 
114
+ // ── detectRepoWorkspace(bare repo + worktree 模式)──────────────
115
+
116
+ describe("detectRepoWorkspace(bare repo + worktree 模式)", () => {
117
+ it("bare repo worktree(.bare)→ undefined(dirname(.bare)=容器根非 git 目录,不传 --workspace)", () => {
118
+ const base = makeTempDir("cw-bare-");
119
+ const worktree = createBareRepoWorkspace(base, "ws");
120
+ expect(detectRepoWorkspace(worktree)).toBeUndefined();
121
+ });
122
+
123
+ it("防误用:--is-bare-repository 在 worktree 内返回 false(不可作 bare 判据)", () => {
124
+ const base = makeTempDir("cw-bare-isbare-");
125
+ const worktree = createBareRepoWorkspace(base, "ws");
126
+ const isBare = execSync("git rev-parse --is-bare-repository", { cwd: worktree })
127
+ .toString()
128
+ .trim();
129
+ expect(isBare).toBe("false");
130
+ });
131
+
132
+ it("bare repo worktree:common-dir basename 是 .bare(非 .git)", () => {
133
+ const base = makeTempDir("cw-bare-commondir-");
134
+ const worktree = createBareRepoWorkspace(base, "ws");
135
+ const commonDir = execSync(
136
+ "git -C . rev-parse --path-format=absolute --git-common-dir",
137
+ { cwd: worktree },
138
+ )
139
+ .toString()
140
+ .trim();
141
+ expect(path.basename(commonDir)).toBe(".bare");
142
+ });
143
+ });
144
+
96
145
  // ── buildCwArgs 纯函数(workspace 参数)─────────────────────────
97
146
 
98
147
  describe("buildCwArgs(workspace 参数)", () => {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * workspace 门控测试:probeCwCliNormalization 纯函数 + executeCwAction 门控决策。
3
3
  *
4
- * 门控语义(差异文档 §3 §4 + ADR-0045 Superseded):cw-cli 支持 store 内部归一化
4
+ * 门控语义(cw-cli ADR-0014 store-workspace decoupling):cw-cli 支持 store 内部归一化
5
5
  * (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ write action 不传 --workspace(纯封装);
6
- * 不支持 → 兜底 ADR-0045 的 detectRepoWorkspace + --workspace。只读 action 始终不传(S-3)。
6
+ * 不支持 → 兜底 cw-cli ADR-0014 的 detectRepoWorkspace + --workspace。只读 action 始终不传(S-3)。
7
7
  *
8
8
  * detectRepoWorkspace 探测纯函数 + buildCwArgs 构造的测试在 detect-repo-workspace.test.ts,
9
9
  * 本文件只测门控决策(probe 三态 × action 两类 × git 环境)。
@@ -62,6 +62,8 @@ interface GateSpawnerOpts {
62
62
  versionExitCode?: number;
63
63
  /** cw --version spawn 抛异常(模拟 cw 不在 PATH)。 */
64
64
  versionThrow?: boolean;
65
+ /** action 调用的返回(默认成功 `{ stdout: "{}", exitCode: 0 }`,覆盖以测失败分支)。 */
66
+ actionResult?: CwSpawnResult;
65
67
  }
66
68
 
67
69
  /**
@@ -84,7 +86,7 @@ function gateSpawner(opts: GateSpawnerOpts = {}): {
84
86
  exitCode: opts.versionExitCode ?? 0,
85
87
  };
86
88
  }
87
- return { stdout: "{}", stderr: "", exitCode: 0 };
89
+ return opts.actionResult ?? { stdout: "{}", stderr: "", exitCode: 0 };
88
90
  });
89
91
  return { spawner, calls };
90
92
  }
@@ -109,13 +111,13 @@ describe("probeCwCliNormalization", () => {
109
111
 
110
112
  it("版本 === MIN(等号边界)→ supported:true(门控用 >=,锁定等号语义,S-7)", async () => {
111
113
  const cwd = makeTempDir("probe-eq-");
112
- const { spawner } = gateSpawner({ versionStdout: "cw 99.0.0" });
114
+ const { spawner } = gateSpawner({ versionStdout: "cw 1.6.2" });
113
115
  const cap = await probeCwCliNormalization(spawner, cwd);
114
116
  expect(cap.supported).toBe(true);
115
- expect(cap.version).toBe("99.0.0");
117
+ expect(cap.version).toBe("1.6.2");
116
118
  });
117
119
 
118
- it("版本 < MIN → supported:false(当前 placeholder 99.0.0,真实 cw 1.6.1 不支持)", async () => {
120
+ it("版本 < MIN → supported:false(1.6.2 是归一化首版,1.6.1 不支持)", async () => {
119
121
  const cwd = makeTempDir("probe-unsupported-");
120
122
  const { spawner } = gateSpawner({ versionStdout: "cw 1.6.1" });
121
123
  const cap = await probeCwCliNormalization(spawner, cwd);
@@ -196,7 +198,7 @@ describe("executeCwAction workspace 门控", () => {
196
198
  expect(actionCall(calls).args).not.toContain("--workspace");
197
199
  });
198
200
 
199
- it("TC2: probe 不支持 → write action 兜底传 --workspace(ADR-0045 行为)", async () => {
201
+ it("TC2: probe 不支持 → write action 兜底传 --workspace(cw-cli ADR-0014 行为)", async () => {
200
202
  const base = makeTempDir("gate-tc2-");
201
203
  const repo = createGitRepo(base, "repo");
202
204
  const { spawner, calls } = gateSpawner({ versionStdout: "cw 1.6.1" });
@@ -230,4 +232,30 @@ describe("executeCwAction workspace 门控", () => {
230
232
  await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
231
233
  expect(actionCall(calls).args).not.toContain("--workspace");
232
234
  });
235
+
236
+ it("TC6: 非 git 目录 + probe 不支持 + action 失败(exitCode 1)→ error 含升级指引(degradedNoWorkspace 分支回归)", async () => {
237
+ const plain = makeTempDir("gate-tc6-plain-");
238
+ const { spawner } = gateSpawner({
239
+ versionStdout: "cw 1.6.1",
240
+ actionResult: { stdout: "", stderr: "boom", exitCode: 1 },
241
+ });
242
+ const result = await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
243
+ expect(result.ok).toBe(false);
244
+ // 升级指引文案锚点(准则 6:错误指向恢复动作)
245
+ expect(result.error).toContain("cw-cli 版本过低");
246
+ expect(result.error).toContain("@zhushanwen/coding-workflow@latest");
247
+ });
248
+
249
+ it("TC7: git 目录 + probe 不支持 + action 失败 → error 不含升级指引(非降级路径不追加)", async () => {
250
+ const base = makeTempDir("gate-tc7-");
251
+ const repo = createGitRepo(base, "repo");
252
+ const { spawner } = gateSpawner({
253
+ versionStdout: "cw 1.6.1",
254
+ actionResult: { stdout: "", stderr: "boom", exitCode: 1 },
255
+ });
256
+ const result = await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
257
+ expect(result.ok).toBe(false);
258
+ expect(result.error).toContain("exit code 1");
259
+ expect(result.error).not.toContain("cw-cli 版本过低");
260
+ });
233
261
  });
package/src/cw-runner.ts CHANGED
@@ -84,13 +84,19 @@ export function rejectDisallowedAction(
84
84
  const GIT_PROBE_TIMEOUT_MS = 5000;
85
85
 
86
86
  /**
87
- * 探测 cwd 所属 repo 的主目录(repo 级 workspace)。
87
+ * 探测 cwd 所属 repo 的主目录(repo 级 workspace),供老 cw-cli(<1.6.2,无 store 归一化)兜底。
88
88
  *
89
89
  * 用 `git rev-parse --path-format=absolute --git-common-dir` 取 git common dir:
90
90
  * 同一 repo 的所有 worktree 返回相同路径,dirname 即 repo 主目录。cw store 键控
91
- * 从 per-cwd 升级为 repo 级(ADR-0045)后,spawn cw 时附带 --workspace 让 cw
91
+ * 从 per-cwd 升级为 repo 级(cw-cli ADR-0014)后,spawn cw 时附带 --workspace 让 cw
92
92
  * 在 repo 主目录解析/共享状态,避免同一 repo 的 worktree 间状态各自为政。
93
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
+ *
94
100
  * 任何失败(非 git 目录、git 不在 PATH、路径不存在、超时)→ undefined(不抛)。
95
101
  */
96
102
  export function detectRepoWorkspace(cwd: string): string | undefined {
@@ -103,6 +109,11 @@ export function detectRepoWorkspace(cwd: string): string | undefined {
103
109
  if (result.status !== 0) return undefined;
104
110
  const gitCommonDir = result.stdout.trim();
105
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;
106
117
  return path.dirname(gitCommonDir);
107
118
  } catch {
108
119
  return undefined;
@@ -114,14 +125,16 @@ export function detectRepoWorkspace(cwd: string): string | undefined {
114
125
  *
115
126
  * cw-tool 与 cw-cli 是两个独立 npm 包(cw-tool 经 PATH 裸调 cw、零依赖声明),
116
127
  * 两包独立升级。cw-tool 退回纯封装前需探测 cw-cli 是否已落地 store 归一化
117
- * (coding-workflow S1:getCwJsonPath git-common-dir),支持则不传 --workspace
118
- * (纯封装),不支持则兜底 ADR-0045 detectRepoWorkspace + --workspace。
128
+ * (cw-cli commit a90e8e8 / 首个 tag v1.6.2:getCwJsonPath 改用 detectCommonDir 归一化
129
+ * git-common-dir),支持则不传 --workspace(纯封装),不支持则兜底 cw-cli ADR-0014
130
+ * 的 detectRepoWorkspace + --workspace。
119
131
  *
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。
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。
123
136
  */
124
- const MIN_CW_CLI_VERSION_FOR_NORMALIZATION = "99.0.0";
137
+ const MIN_CW_CLI_VERSION_FOR_NORMALIZATION = "1.6.2";
125
138
 
126
139
  /** probe 超时(ms):cw --version 卡死时 fail-safe 为「不支持」。 */
127
140
  const CW_VERSION_PROBE_TIMEOUT_MS = 5000;
@@ -170,7 +183,7 @@ function compareSemver(a: number[], b: number[]): number {
170
183
  * 探测 cw-cli 是否支持 store 内部归一化(门控)。
171
184
  *
172
185
  * 用 spawner 跑 `cw --version`,parse 版本号与 {@link MIN_CW_CLI_VERSION_FOR_NORMALIZATION}
173
- * 比较。失败(spawn 失败/parse 不到/超时)→ supported:false(fail-safe,兜底 ADR-0045 行为)。
186
+ * 比较。失败(spawn 失败/parse 不到/超时)→ supported:false(fail-safe,兜底 cw-cli ADR-0014 行为)。
174
187
  * 进程内 memoize(全局单值,cw 版本 cwd 无关):首次探测后缓存,消除重复 spawn。
175
188
  *
176
189
  * @param parentSignal 调用方 SDK abort signal,与内部 5s 超时合并转发给 spawner(S-4):
@@ -185,7 +198,7 @@ export async function probeCwCliNormalization(
185
198
 
186
199
  const minVersion = parseCwVersion(MIN_CW_CLI_VERSION_FOR_NORMALIZATION);
187
200
  if (!minVersion) {
188
- // placeholder 本身非法(不应发生)——保守不支持
201
+ // MIN_CW_CLI_VERSION_FOR_NORMALIZATION 本身非法(不应发生)——保守不支持
189
202
  const fallback: CwCliCapability = { supported: false, version: undefined, reason: "MIN_CW_CLI_VERSION_FOR_NORMALIZATION 非法" };
190
203
  cachedCapability = fallback;
191
204
  return fallback;
@@ -336,16 +349,24 @@ export async function executeCwAction(
336
349
  const unitIdErr = rejectMissingUnitId(action, unitId);
337
350
  if (unitIdErr) return { ok: false, ...base, error: unitIdErr };
338
351
 
339
- // workspace 门控(差异文档 §3 §4 + ADR-0045 Superseded):cw-cli 支持 store 内部归一化
352
+ // workspace 门控(cw-cli ADR-0014 store-workspace decoupling):cw-cli 支持 store 内部归一化
340
353
  // (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ 纯封装不传 --workspace;
341
- // 不支持 → 兜底 ADR-0045 的 detectRepoWorkspace + --workspace(保持向后兼容)。
354
+ // 不支持 → 兜底 cw-cli ADR-0014 的 detectRepoWorkspace + --workspace(保持向后兼容)。
342
355
  // 只读 action 始终不传(S-3:保守避免 cw 子命令拒收未知选项导致 readonly 查询失败)。
343
356
  let workspace: string | undefined;
357
+ // 降级标记:老 cw-cli(不支持归一化)+ bare repo / 非 git(detectRepoWorkspace 返回 undefined)。
358
+ // 写动作若失败,错误消息追加升级指引(准则 6:错误指向恢复动作)。
359
+ let degradedNoWorkspace = false;
344
360
  if (isReadonlyAction(action)) {
345
361
  workspace = undefined;
346
362
  } else {
347
363
  const capability = await probeCwCliNormalization(spawner, cwd, signal);
348
- workspace = capability.supported ? undefined : detectRepoWorkspace(cwd);
364
+ if (capability.supported) {
365
+ workspace = undefined;
366
+ } else {
367
+ workspace = detectRepoWorkspace(cwd);
368
+ degradedNoWorkspace = workspace === undefined;
369
+ }
349
370
  }
350
371
  const args = buildCwArgs(action, unitId, opts, workspace);
351
372
  const stdinPayload = opts.input !== undefined ? opts.input : undefined;
@@ -388,7 +409,15 @@ export async function executeCwAction(
388
409
  if (exitCode !== 0) {
389
410
  const parts: string[] = [`exit code ${exitCode ?? "null"}`];
390
411
  if (stderr.trim()) parts.push(stderr.trim());
391
- return { ok: false, ...base, error: parts.join(" | ") };
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 };
392
421
  }
393
422
 
394
423
  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)。 */
@@ -47,6 +66,13 @@ export type CwSpawner = (
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,
@@ -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 });