patchwarden 0.6.4 → 1.1.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.
Files changed (212) hide show
  1. package/README.en.md +18 -18
  2. package/README.md +20 -20
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +15 -2
  8. package/dist/doctor.js +32 -1
  9. package/dist/goal/acceptanceEngine.d.ts +62 -0
  10. package/dist/goal/acceptanceEngine.js +103 -0
  11. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  12. package/dist/goal/acceptanceTemplate.js +104 -0
  13. package/dist/goal/goalGraph.d.ts +58 -0
  14. package/dist/goal/goalGraph.js +189 -0
  15. package/dist/goal/goalProgress.d.ts +81 -0
  16. package/dist/goal/goalProgress.js +216 -0
  17. package/dist/goal/goalStatus.d.ts +60 -0
  18. package/dist/goal/goalStatus.js +137 -0
  19. package/dist/goal/goalStore.d.ts +79 -0
  20. package/dist/goal/goalStore.js +211 -0
  21. package/dist/goal/handoffExport.d.ts +34 -0
  22. package/dist/goal/handoffExport.js +183 -0
  23. package/dist/goal/subgoalSync.d.ts +40 -0
  24. package/dist/goal/subgoalSync.js +72 -0
  25. package/dist/goal/worktreeManager.d.ts +96 -0
  26. package/dist/goal/worktreeManager.js +292 -0
  27. package/dist/logging.d.ts +44 -0
  28. package/dist/logging.js +68 -0
  29. package/dist/release/releaseGate.d.ts +99 -0
  30. package/dist/release/releaseGate.js +475 -0
  31. package/dist/runner/postTaskCleanup.d.ts +13 -0
  32. package/dist/runner/postTaskCleanup.js +147 -0
  33. package/dist/runner/runTask.js +50 -10
  34. package/dist/security/discoveryTokenStore.d.ts +62 -0
  35. package/dist/security/discoveryTokenStore.js +100 -0
  36. package/dist/security/toolInvocationGuard.d.ts +35 -0
  37. package/dist/security/toolInvocationGuard.js +153 -0
  38. package/dist/smoke-test.js +18 -10
  39. package/dist/taskRuntime.d.ts +17 -0
  40. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  41. package/dist/test/unit/acceptance-engine.test.js +228 -0
  42. package/dist/test/unit/agent-router.test.d.ts +1 -0
  43. package/dist/test/unit/agent-router.test.js +287 -0
  44. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  45. package/dist/test/unit/audit-checks.test.js +350 -0
  46. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  47. package/dist/test/unit/diagnose-task.test.js +457 -0
  48. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  49. package/dist/test/unit/discovery-token-store.test.js +139 -0
  50. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  51. package/dist/test/unit/goal-graph.test.js +298 -0
  52. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  53. package/dist/test/unit/goal-progress.test.js +381 -0
  54. package/dist/test/unit/goal-status.test.d.ts +1 -0
  55. package/dist/test/unit/goal-status.test.js +215 -0
  56. package/dist/test/unit/goal-store.test.d.ts +1 -0
  57. package/dist/test/unit/goal-store.test.js +253 -0
  58. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  59. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  60. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  61. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  62. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  63. package/dist/test/unit/handoff-export.test.js +263 -0
  64. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  65. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  66. package/dist/test/unit/logging.test.js +127 -5
  67. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  68. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  69. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  70. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  71. package/dist/test/unit/release-gate.test.d.ts +1 -0
  72. package/dist/test/unit/release-gate.test.js +242 -0
  73. package/dist/test/unit/safe-views.test.d.ts +1 -0
  74. package/dist/test/unit/safe-views.test.js +171 -0
  75. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  76. package/dist/test/unit/schema-drift-check.test.js +175 -0
  77. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  78. package/dist/test/unit/subgoal-sync.test.js +183 -0
  79. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  80. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  81. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  82. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  83. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  84. package/dist/test/unit/toolSearch.test.js +571 -0
  85. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  86. package/dist/test/unit/worktree-manager.test.js +176 -0
  87. package/dist/tools/auditTask.d.ts +103 -1
  88. package/dist/tools/auditTask.js +461 -8
  89. package/dist/tools/cancelTask.js +1 -1
  90. package/dist/tools/checkReleaseGate.d.ts +21 -0
  91. package/dist/tools/checkReleaseGate.js +22 -0
  92. package/dist/tools/createTask.d.ts +18 -2
  93. package/dist/tools/createTask.js +29 -1
  94. package/dist/tools/diagnoseTask.d.ts +45 -0
  95. package/dist/tools/diagnoseTask.js +347 -0
  96. package/dist/tools/discardWorktree.d.ts +24 -0
  97. package/dist/tools/discardWorktree.js +27 -0
  98. package/dist/tools/discoverTools.d.ts +11 -0
  99. package/dist/tools/discoverTools.js +39 -0
  100. package/dist/tools/explainTool.d.ts +11 -0
  101. package/dist/tools/explainTool.js +21 -0
  102. package/dist/tools/getTaskSummary.js +2 -1
  103. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  104. package/dist/tools/goalSubgoalTask.js +110 -0
  105. package/dist/tools/healthCheck.d.ts +1 -0
  106. package/dist/tools/healthCheck.js +5 -1
  107. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  108. package/dist/tools/invokeDiscoveredTool.js +112 -0
  109. package/dist/tools/listTasks.d.ts +3 -1
  110. package/dist/tools/listTasks.js +14 -1
  111. package/dist/tools/mergeWorktree.d.ts +24 -0
  112. package/dist/tools/mergeWorktree.js +27 -0
  113. package/dist/tools/reconcileTasks.d.ts +41 -0
  114. package/dist/tools/reconcileTasks.js +352 -0
  115. package/dist/tools/registry.js +550 -0
  116. package/dist/tools/safeStatus.d.ts +31 -1
  117. package/dist/tools/safeStatus.js +43 -1
  118. package/dist/tools/safeViews.d.ts +256 -0
  119. package/dist/tools/safeViews.js +250 -0
  120. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  121. package/dist/tools/schemaDriftCheck.js +80 -0
  122. package/dist/tools/toolCatalog.d.ts +4 -3
  123. package/dist/tools/toolCatalog.js +33 -11
  124. package/dist/tools/toolRegistry.d.ts +61 -0
  125. package/dist/tools/toolRegistry.js +724 -0
  126. package/dist/tools/toolSearch.d.ts +110 -0
  127. package/dist/tools/toolSearch.js +331 -0
  128. package/dist/tools/toolUsageStats.d.ts +41 -0
  129. package/dist/tools/toolUsageStats.js +116 -0
  130. package/dist/tools/waitForTask.js +1 -0
  131. package/dist/version.d.ts +2 -2
  132. package/dist/version.js +2 -2
  133. package/dist/watcherStatus.d.ts +29 -0
  134. package/dist/watcherStatus.js +92 -1
  135. package/package.json +3 -2
  136. package/scripts/checks/control-smoke.js +2 -2
  137. package/scripts/checks/doctor-smoke.js +1 -0
  138. package/scripts/checks/lifecycle-smoke.js +13 -9
  139. package/scripts/checks/mcp-manifest-check.js +53 -6
  140. package/scripts/checks/mcp-smoke.js +36 -9
  141. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  142. package/src/agents/agentRouter.ts +149 -0
  143. package/src/config.ts +9 -3
  144. package/src/controlCenter.ts +17 -3
  145. package/src/doctor.ts +36 -1
  146. package/src/goal/acceptanceEngine.ts +160 -0
  147. package/src/goal/acceptanceTemplate.ts +121 -0
  148. package/src/goal/goalGraph.ts +225 -0
  149. package/src/goal/goalProgress.ts +301 -0
  150. package/src/goal/goalStatus.ts +234 -0
  151. package/src/goal/goalStore.ts +306 -0
  152. package/src/goal/handoffExport.ts +211 -0
  153. package/src/goal/subgoalSync.ts +82 -0
  154. package/src/goal/worktreeManager.ts +404 -0
  155. package/src/logging.ts +91 -0
  156. package/src/release/releaseGate.ts +567 -0
  157. package/src/runner/postTaskCleanup.ts +154 -0
  158. package/src/runner/runTask.ts +49 -10
  159. package/src/security/discoveryTokenStore.ts +157 -0
  160. package/src/security/toolInvocationGuard.ts +251 -0
  161. package/src/smoke-test.ts +15 -7
  162. package/src/taskRuntime.ts +17 -0
  163. package/src/test/unit/acceptance-engine.test.ts +261 -0
  164. package/src/test/unit/agent-router.test.ts +342 -0
  165. package/src/test/unit/audit-checks.test.ts +567 -0
  166. package/src/test/unit/diagnose-task.test.ts +544 -0
  167. package/src/test/unit/discovery-token-store.test.ts +181 -0
  168. package/src/test/unit/goal-graph.test.ts +347 -0
  169. package/src/test/unit/goal-progress.test.ts +538 -0
  170. package/src/test/unit/goal-status.test.ts +270 -0
  171. package/src/test/unit/goal-store.test.ts +318 -0
  172. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  173. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  174. package/src/test/unit/handoff-export.test.ts +295 -0
  175. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  176. package/src/test/unit/logging.test.ts +177 -5
  177. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  178. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  179. package/src/test/unit/release-gate.test.ts +314 -0
  180. package/src/test/unit/safe-views.test.ts +184 -0
  181. package/src/test/unit/schema-drift-check.test.ts +258 -0
  182. package/src/test/unit/subgoal-sync.test.ts +236 -0
  183. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  184. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  185. package/src/test/unit/toolSearch.test.ts +756 -0
  186. package/src/test/unit/worktree-manager.test.ts +247 -0
  187. package/src/tools/auditTask.ts +831 -402
  188. package/src/tools/cancelTask.ts +1 -1
  189. package/src/tools/checkReleaseGate.ts +45 -0
  190. package/src/tools/createTask.ts +64 -6
  191. package/src/tools/diagnoseTask.ts +460 -0
  192. package/src/tools/discardWorktree.ts +42 -0
  193. package/src/tools/discoverTools.ts +51 -0
  194. package/src/tools/explainTool.ts +26 -0
  195. package/src/tools/getTaskSummary.ts +2 -1
  196. package/src/tools/goalSubgoalTask.ts +170 -0
  197. package/src/tools/healthCheck.ts +4 -1
  198. package/src/tools/invokeDiscoveredTool.ts +163 -0
  199. package/src/tools/listTasks.ts +16 -2
  200. package/src/tools/mergeWorktree.ts +42 -0
  201. package/src/tools/reconcileTasks.ts +464 -0
  202. package/src/tools/registry.ts +618 -1
  203. package/src/tools/safeStatus.ts +73 -2
  204. package/src/tools/safeViews.ts +271 -0
  205. package/src/tools/schemaDriftCheck.ts +120 -0
  206. package/src/tools/toolCatalog.ts +34 -11
  207. package/src/tools/toolRegistry.ts +786 -0
  208. package/src/tools/toolSearch.ts +464 -0
  209. package/src/tools/toolUsageStats.ts +151 -0
  210. package/src/tools/waitForTask.ts +1 -0
  211. package/src/version.ts +2 -2
  212. package/src/watcherStatus.ts +97 -1
@@ -0,0 +1,404 @@
1
+ /**
2
+ * v1.0.0 Part B: Worktree 隔离管理器 — 在 workspaceRoot 下管理 git worktree,
3
+ * 为每个 subgoal task 提供独立的代码副本,避免并发任务互相污染工作区。
4
+ *
5
+ * 目录结构:
6
+ * {workspaceRoot}/_workspacetrees/{worktree_id}/
7
+ * └── worktree_status.json 机器可读的状态文件(原子写)
8
+ * {workspaceRoot}/.patchwarden/worktree-archive/{worktree_id}.json
9
+ * 归档的已 discard 状态(worktree 目录被删除后保留审计记录)
10
+ *
11
+ * 安全约束:
12
+ * - 所有路径经 guardWorkspacePath + guardSensitivePath 校验
13
+ * - git 命令只用 child_process.execFileSync(不使用 shell),白名单仅
14
+ * git worktree add/remove/prune + git merge + git branch
15
+ * - createWorktree 失败时清理半成品 worktree 目录与临时 branch
16
+ * - 不暴露通用 shell,不 blanket-kill watcher(worktree 与 watcher 无关)
17
+ *
18
+ * 所有文件系统函数都接受可选的 workspaceRoot 参数用于测试;
19
+ * 默认从 getConfig().workspaceRoot 读取。
20
+ */
21
+
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ renameSync,
27
+ writeFileSync,
28
+ rmSync,
29
+ } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { execFileSync } from "node:child_process";
32
+ import { randomBytes } from "node:crypto";
33
+ import { getConfig } from "../config.js";
34
+ import { guardWorkspacePath } from "../security/pathGuard.js";
35
+ import { guardSensitivePath } from "../security/sensitiveGuard.js";
36
+ import { PatchWardenError } from "../errors.js";
37
+
38
+ // ── 类型定义 ──────────────────────────────────────────────────────
39
+
40
+ export interface WorktreeStatus {
41
+ worktree_id: string;
42
+ goal_id: string;
43
+ subgoal_id: string;
44
+ path: string;
45
+ created_at: string;
46
+ status: "active" | "merged" | "discarded";
47
+ branch: string;
48
+ merged_at?: string;
49
+ discarded_at?: string;
50
+ }
51
+
52
+ // ── 常量 ──────────────────────────────────────────────────────────
53
+
54
+ /** worktree 根目录名,放在 workspaceRoot 下。 */
55
+ export const WorktreeDir = "_workspacetrees";
56
+
57
+ /** 归档目录名(discard 后保留状态),位于 .patchwarden 下,始终为安全路径。 */
58
+ const WORKTREE_ARCHIVE_DIR = ".patchwarden/worktree-archive";
59
+
60
+ const GIT_TIMEOUT_MS = 30000;
61
+ const GIT_BRANCH_TIMEOUT_MS = 15000;
62
+
63
+ // ── 辅助:解析 workspaceRoot ──────────────────────────────────────
64
+
65
+ function resolveWorkspaceRoot(workspaceRoot?: string): string {
66
+ return workspaceRoot ?? getConfig().workspaceRoot;
67
+ }
68
+
69
+ // ── 目录路径解析 ──────────────────────────────────────────────────
70
+
71
+ /**
72
+ * 返回 `<workspaceRoot>/_workspacetrees` 目录路径。不自动创建目录。
73
+ */
74
+ export function getWorktreesDir(workspaceRoot?: string): string {
75
+ return join(resolveWorkspaceRoot(workspaceRoot), WorktreeDir);
76
+ }
77
+
78
+ /**
79
+ * 返回 `<getWorktreesDir()>/<worktreeId>` 路径。
80
+ */
81
+ export function getWorktreeDir(worktreeId: string, workspaceRoot?: string): string {
82
+ return join(getWorktreesDir(workspaceRoot), worktreeId);
83
+ }
84
+
85
+ // ── ID 与 branch 生成 ─────────────────────────────────────────────
86
+
87
+ /**
88
+ * 生成 `wt_<timestamp_base36>_<randomHex>` 格式的 worktree id。
89
+ * 内部生成,不接受调用方输入,避免路径注入。
90
+ */
91
+ function generateWorktreeId(): string {
92
+ const ts = Date.now().toString(36);
93
+ const rand = randomBytes(6).toString("hex");
94
+ return `wt_${ts}_${rand}`;
95
+ }
96
+
97
+ /**
98
+ * 将字符串清洗为合法 git branch 段:只保留 [a-zA-Z0-9_-],其余替换为 `_`。
99
+ * 注意 `.` 与 `/` 会被替换:`..` 路径穿越片段无法进入 branch 名;`/` 被排除
100
+ * 是因为 `git worktree add -b <name> <path>` 在 Windows 的 git 上对含 `/` 的
101
+ * 新分支名会报 `fatal: invalid reference`(即便分支名本身合法)。
102
+ */
103
+ function sanitizeBranchSegment(value: string): string {
104
+ const cleaned = value.replace(/[^a-zA-Z0-9_-]/g, "_");
105
+ return cleaned === "" ? "x" : cleaned;
106
+ }
107
+
108
+ // ── 原子写 ────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * 原子写入 status 文件:先写到 `.tmp` 文件,再 renameSync。
112
+ * 参考 goalStore.ts writeGoalStatus 的模式。
113
+ */
114
+ function writeStatusAtomic(statusFilePath: string, status: WorktreeStatus): void {
115
+ const tmpPath = statusFilePath + ".tmp";
116
+ writeFileSync(tmpPath, JSON.stringify(status, null, 2) + "\n", "utf-8");
117
+ renameSync(tmpPath, statusFilePath);
118
+ }
119
+
120
+ function runGit(args: string[], cwd: string, timeoutMs: number): void {
121
+ execFileSync("git", args, {
122
+ cwd,
123
+ timeout: timeoutMs,
124
+ stdio: ["pipe", "pipe", "pipe"],
125
+ });
126
+ }
127
+
128
+ function gitErrorMessage(err: unknown): string {
129
+ if (err && typeof err === "object" && "stderr" in err) {
130
+ const stderr = (err as { stderr?: Buffer | string }).stderr;
131
+ if (stderr) {
132
+ const text = Buffer.isBuffer(stderr) ? stderr.toString("utf-8") : String(stderr);
133
+ if (text.trim()) return text.trim();
134
+ }
135
+ }
136
+ return err instanceof Error ? err.message : String(err);
137
+ }
138
+
139
+ // ── 公共 API ──────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * 为指定 goal/subgoal 创建一个隔离的 git worktree。
143
+ *
144
+ * 流程:
145
+ * 1. 生成 worktreeId(内部随机),拼出 worktreePath 与 branch
146
+ * 2. guardWorkspacePath + guardSensitivePath 校验路径
147
+ * 3. `git worktree add -b <branch> <worktreePath>` 创建 worktree
148
+ * 4. 原子写入 worktree_status.json(status="active")
149
+ *
150
+ * 失败时清理:若 git worktree add 或写 status 失败,移除已创建的 worktree
151
+ * 目录与临时 branch,抛出 PatchWardenError("worktree_create_failed")。
152
+ *
153
+ * @returns { worktreeId, worktreePath, branch }
154
+ */
155
+ export function createWorktree(
156
+ goalId: string,
157
+ subgoalId: string,
158
+ workspaceRoot: string
159
+ ): { worktreeId: string; worktreePath: string; branch: string } {
160
+ const wsRoot = resolveWorkspaceRoot(workspaceRoot);
161
+
162
+ const worktreeId = generateWorktreeId();
163
+ const worktreePath = getWorktreeDir(worktreeId, workspaceRoot);
164
+ const branch = `pw-${sanitizeBranchSegment(goalId)}-${sanitizeBranchSegment(subgoalId)}`;
165
+
166
+ // 安全:校验 worktreePath 在 workspaceRoot 内且非敏感路径
167
+ guardWorkspacePath(worktreePath, wsRoot);
168
+ guardSensitivePath(worktreePath);
169
+
170
+ let worktreeCreated = false;
171
+ let branchCreated = false;
172
+
173
+ try {
174
+ runGit(["worktree", "add", "-b", branch, worktreePath], wsRoot, GIT_TIMEOUT_MS);
175
+ worktreeCreated = true;
176
+ branchCreated = true;
177
+
178
+ // 原子写入 worktree_status.json(worktreePath 是新目录,无旧 status,直接 tmp + rename)
179
+ const statusFilePath = join(worktreePath, "worktree_status.json");
180
+ const now = new Date().toISOString();
181
+ const status: WorktreeStatus = {
182
+ worktree_id: worktreeId,
183
+ goal_id: goalId,
184
+ subgoal_id: subgoalId,
185
+ path: worktreePath,
186
+ created_at: now,
187
+ status: "active",
188
+ branch,
189
+ };
190
+ writeStatusAtomic(statusFilePath, status);
191
+
192
+ return { worktreeId, worktreePath, branch };
193
+ } catch (err) {
194
+ // 清理半成品
195
+ if (worktreeCreated) {
196
+ try {
197
+ rmSync(worktreePath, { recursive: true, force: true });
198
+ } catch { /* ignore */ }
199
+ // rmSync 可能无法清除 git 的 worktree 元数据,再尝试 git worktree remove
200
+ try {
201
+ runGit(["worktree", "remove", "--force", worktreePath], wsRoot, GIT_BRANCH_TIMEOUT_MS);
202
+ } catch { /* ignore */ }
203
+ }
204
+ if (branchCreated) {
205
+ try {
206
+ runGit(["branch", "-D", branch], wsRoot, GIT_BRANCH_TIMEOUT_MS);
207
+ } catch { /* ignore — branch 可能未创建或已随 worktree remove 清理 */ }
208
+ }
209
+
210
+ if (err instanceof PatchWardenError) throw err;
211
+
212
+ throw new PatchWardenError(
213
+ "worktree_create_failed",
214
+ `Failed to create worktree for goal "${goalId}" / subgoal "${subgoalId}": ${gitErrorMessage(err)}`,
215
+ "Ensure workspaceRoot is a git repository with at least one commit, and the worktree path is writable.",
216
+ true,
217
+ {
218
+ goal_id: goalId,
219
+ subgoal_id: subgoalId,
220
+ branch,
221
+ worktree_path: worktreePath,
222
+ }
223
+ );
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 读取 worktree_status.json。不存在返回 null。
229
+ * 路径逃逸或敏感路径会抛 PatchWardenError(不静默吞掉安全违规)。
230
+ */
231
+ export function readWorktreeStatus(
232
+ worktreeId: string,
233
+ workspaceRoot?: string
234
+ ): WorktreeStatus | null {
235
+ const wsRoot = resolveWorkspaceRoot(workspaceRoot);
236
+ const worktreePath = getWorktreeDir(worktreeId, workspaceRoot);
237
+
238
+ guardWorkspacePath(worktreePath, wsRoot);
239
+ guardSensitivePath(worktreePath);
240
+
241
+ const statusFilePath = join(worktreePath, "worktree_status.json");
242
+ if (!existsSync(statusFilePath)) return null;
243
+
244
+ try {
245
+ const raw = readFileSync(statusFilePath, "utf-8");
246
+ return JSON.parse(raw) as WorktreeStatus;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 将 worktree 的 branch 合并回主工作区(workspaceRoot)。
254
+ *
255
+ * 流程:
256
+ * 1. 读取 worktree_status.json,校验 status === "active"
257
+ * 2. `git merge <branch>`(在 workspaceRoot 执行)
258
+ * 3. 原子更新 worktree_status.json:status="merged",merged_at=ISO timestamp
259
+ *
260
+ * 合并失败时抛 PatchWardenError("worktree_merge_failed"),不删除 worktree
261
+ * (保留供人工排查冲突)。
262
+ */
263
+ export function mergeWorktree(
264
+ worktreeId: string,
265
+ workspaceRoot: string
266
+ ): { status: "merged" } {
267
+ const wsRoot = resolveWorkspaceRoot(workspaceRoot);
268
+ const worktreePath = getWorktreeDir(worktreeId, workspaceRoot);
269
+
270
+ guardWorkspacePath(worktreePath, wsRoot);
271
+ guardSensitivePath(worktreePath);
272
+
273
+ const status = readWorktreeStatus(worktreeId, workspaceRoot);
274
+ if (!status) {
275
+ throw new PatchWardenError(
276
+ "worktree_not_found",
277
+ `Worktree "${worktreeId}" not found or has no worktree_status.json`,
278
+ "Ensure the worktree id was created via createWorktree before merging.",
279
+ true,
280
+ { worktree_id: worktreeId }
281
+ );
282
+ }
283
+
284
+ if (status.status !== "active") {
285
+ throw new PatchWardenError(
286
+ "invalid_worktree_state",
287
+ `Worktree "${worktreeId}" is not active (current status: "${status.status}")`,
288
+ "Only active worktrees can be merged.",
289
+ true,
290
+ { worktree_id: worktreeId, current_status: status.status }
291
+ );
292
+ }
293
+
294
+ try {
295
+ runGit(["merge", status.branch], wsRoot, GIT_TIMEOUT_MS);
296
+ } catch (err) {
297
+ // 合并失败:不删 worktree,保留供人工排查
298
+ throw new PatchWardenError(
299
+ "worktree_merge_failed",
300
+ `Failed to merge worktree branch "${status.branch}" into workspace: ${gitErrorMessage(err)}`,
301
+ "Resolve merge conflicts manually in the main workspace, then retry or discard the worktree.",
302
+ true,
303
+ { worktree_id: worktreeId, branch: status.branch }
304
+ );
305
+ }
306
+
307
+ const updatedStatus: WorktreeStatus = {
308
+ ...status,
309
+ status: "merged",
310
+ merged_at: new Date().toISOString(),
311
+ };
312
+ const statusFilePath = join(worktreePath, "worktree_status.json");
313
+ writeStatusAtomic(statusFilePath, updatedStatus);
314
+
315
+ return { status: "merged" };
316
+ }
317
+
318
+ /**
319
+ * 丢弃 worktree:移除 worktree 目录与临时 branch,归档最终状态。
320
+ *
321
+ * 流程:
322
+ * 1. 读取 worktree_status.json(在 remove 之前读取,因为 status 文件位于
323
+ * worktree 目录内),校验 status === "active"
324
+ * 2. `git worktree remove --force <worktreePath>`
325
+ * 3. `git branch -D <branch>` 删除临时 branch
326
+ * 4. 把更新后的 status(discarded)写到归档目录
327
+ * `<workspaceRoot>/.patchwarden/worktree-archive/<worktreeId>.json`
328
+ * (.patchwarden 始终为安全路径,用 guardWorkspacePath 校验)
329
+ *
330
+ * 移除失败抛 PatchWardenError("worktree_discard_failed")。
331
+ */
332
+ export function discardWorktree(
333
+ worktreeId: string,
334
+ workspaceRoot: string
335
+ ): { status: "discarded" } {
336
+ const wsRoot = resolveWorkspaceRoot(workspaceRoot);
337
+ const worktreePath = getWorktreeDir(worktreeId, workspaceRoot);
338
+
339
+ guardWorkspacePath(worktreePath, wsRoot);
340
+ guardSensitivePath(worktreePath);
341
+
342
+ // 在 remove 之前读取 status(status 文件位于 worktree 目录内)
343
+ const status = readWorktreeStatus(worktreeId, workspaceRoot);
344
+ if (!status) {
345
+ throw new PatchWardenError(
346
+ "worktree_not_found",
347
+ `Worktree "${worktreeId}" not found or has no worktree_status.json`,
348
+ "Ensure the worktree id was created via createWorktree before discarding.",
349
+ true,
350
+ { worktree_id: worktreeId }
351
+ );
352
+ }
353
+
354
+ if (status.status !== "active") {
355
+ throw new PatchWardenError(
356
+ "invalid_worktree_state",
357
+ `Worktree "${worktreeId}" is not active (current status: "${status.status}")`,
358
+ "Only active worktrees can be discarded.",
359
+ true,
360
+ { worktree_id: worktreeId, current_status: status.status }
361
+ );
362
+ }
363
+
364
+ try {
365
+ runGit(["worktree", "remove", "--force", worktreePath], wsRoot, GIT_TIMEOUT_MS);
366
+ } catch (err) {
367
+ // git remove 失败时再尝试直接删目录(best effort),然后抛错
368
+ try {
369
+ rmSync(worktreePath, { recursive: true, force: true });
370
+ } catch { /* ignore */ }
371
+ throw new PatchWardenError(
372
+ "worktree_discard_failed",
373
+ `Failed to remove worktree "${worktreeId}": ${gitErrorMessage(err)}`,
374
+ "Remove the worktree directory manually and run `git worktree prune`.",
375
+ true,
376
+ { worktree_id: worktreeId, worktree_path: worktreePath }
377
+ );
378
+ }
379
+
380
+ // 删除临时 branch(best effort — 可能已随 worktree remove 清理)
381
+ try {
382
+ runGit(["branch", "-D", status.branch], wsRoot, GIT_BRANCH_TIMEOUT_MS);
383
+ } catch { /* ignore */ }
384
+
385
+ // 归档最终状态到 .patchwarden/worktree-archive/(worktree 目录可能已消失)
386
+ const archivedStatus: WorktreeStatus = {
387
+ ...status,
388
+ status: "discarded",
389
+ discarded_at: new Date().toISOString(),
390
+ };
391
+
392
+ const archiveDir = join(wsRoot, WORKTREE_ARCHIVE_DIR);
393
+ guardWorkspacePath(archiveDir, wsRoot);
394
+ // .patchwarden 路径在 sensitiveGuard 中始终视为安全(SAFE_PREFIX),无需额外校验
395
+
396
+ try {
397
+ mkdirSync(archiveDir, { recursive: true });
398
+ } catch { /* ignore */ }
399
+
400
+ const archiveFilePath = join(archiveDir, `${worktreeId}.json`);
401
+ writeStatusAtomic(archiveFilePath, archivedStatus);
402
+
403
+ return { status: "discarded" };
404
+ }
package/src/logging.ts CHANGED
@@ -1,4 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
1
4
  import { redactSensitiveContent } from "./security/contentRedaction.js";
5
+ import { stableJsonStringify } from "./tools/toolRegistry.js";
6
+ import { getConfig } from "./config.js";
2
7
 
3
8
  // ── Types ─────────────────────────────────────────────────────────
4
9
 
@@ -11,6 +16,26 @@ export interface LogEntry {
11
16
  [key: string]: unknown;
12
17
  }
13
18
 
19
+ /**
20
+ * v0.8.1: 单次 invoke_discovered_tool 调用的结构化审计记录。
21
+ *
22
+ * 注意:出于安全考虑,只记录参数的 sha256 digest
23
+ * (`arguments_digest`),绝不记录原始参数。日志以 JSON Lines 形式
24
+ * 追加写入 `.patchwarden/logs/invocation.log`。
25
+ */
26
+ export interface InvocationLogEntry {
27
+ timestamp: string; // ISO 时间戳
28
+ toolName: string; // 被调用的工具名
29
+ discoveryToken: string; // discover_tools 颁发的 token id
30
+ risk: string; // ToolRisk 值
31
+ profile: string; // ToolProfile 值
32
+ arguments_digest: string; // sha256 of stableJsonStringify(arguments),格式 "sha256:<hex>"
33
+ allowedScope?: string[]; // 允许的 scope(可选)
34
+ result: "ok" | "error"; // 调用结果
35
+ error_code?: string; // 失败时的错误码
36
+ duration_ms: number; // 调用耗时(毫秒)
37
+ }
38
+
14
39
  // ── Helpers ───────────────────────────────────────────────────────
15
40
 
16
41
  /**
@@ -150,3 +175,69 @@ export function installGlobalHandlers(): void {
150
175
  process.exit(1);
151
176
  });
152
177
  }
178
+
179
+ // ── v0.8.1: Invocation log ───────────────────────────────────────
180
+
181
+ /**
182
+ * 计算工具调用参数的 sha256 digest。
183
+ *
184
+ * 使用 `stableJsonStringify`(按字段名排序的 canonical JSON)计算,
185
+ * 这样相同语义的参数(仅 key 顺序不同)会得到相同的 digest。
186
+ * 返回格式:"sha256:<hex>"。
187
+ *
188
+ * 安全说明:digest 是单向哈希,无法还原出原始参数内容。
189
+ */
190
+ export function computeArgumentsDigest(args: unknown): string {
191
+ const canonical = stableJsonStringify(args);
192
+ const hash = createHash("sha256").update(canonical).digest("hex");
193
+ return `sha256:${hash}`;
194
+ }
195
+
196
+ /**
197
+ * 将一次 invoke_discovered_tool 调用记录追加到 invocation.log。
198
+ *
199
+ * 日志位置:`<workspaceRoot>/.patchwarden/logs/invocation.log`,以
200
+ * JSON Lines 形式追加写入。`entry` 中只含参数 digest,不含原始参数。
201
+ *
202
+ * 该函数不会抛错——日志写入失败不应阻断主调用流程。失败时仅向
203
+ * stderr 记录一条 error 日志。
204
+ *
205
+ * @param entry 调用记录(调用方需先计算好 `arguments_digest`)
206
+ * @param options 可选,`logsDir` 用于覆盖日志目录(主要用于测试)
207
+ */
208
+ export function logToolInvocation(
209
+ entry: InvocationLogEntry,
210
+ options?: { logsDir?: string },
211
+ ): void {
212
+ try {
213
+ let logsDir: string;
214
+ if (options?.logsDir) {
215
+ logsDir = options.logsDir;
216
+ } else {
217
+ let workspaceRoot: string;
218
+ try {
219
+ workspaceRoot = getConfig().workspaceRoot;
220
+ } catch {
221
+ workspaceRoot = process.cwd();
222
+ }
223
+ logsDir = join(workspaceRoot, ".patchwarden", "logs");
224
+ }
225
+
226
+ mkdirSync(logsDir, { recursive: true });
227
+ const logFilePath = join(logsDir, "invocation.log");
228
+ appendFileSync(logFilePath, JSON.stringify(entry) + "\n", "utf8");
229
+ } catch (err) {
230
+ // 日志失败不应阻断主流程,仅向 stderr 记录错误
231
+ try {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ emit({
234
+ timestamp: new Date().toISOString(),
235
+ level: "error",
236
+ message: "invocation_log_write_failed",
237
+ error: message,
238
+ });
239
+ } catch {
240
+ // 连 stderr 写入都失败,彻底吞掉以避免影响主流程
241
+ }
242
+ }
243
+ }