patchwarden 0.6.1 → 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 (261) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +112 -24
  6. package/README.md +36 -25
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/agents/agentRouter.d.ts +40 -0
  10. package/dist/agents/agentRouter.js +95 -0
  11. package/dist/config.d.ts +1 -1
  12. package/dist/config.js +6 -2
  13. package/dist/controlCenter.d.ts +14 -0
  14. package/dist/controlCenter.js +2015 -0
  15. package/dist/doctor.js +35 -4
  16. package/dist/goal/acceptanceEngine.d.ts +62 -0
  17. package/dist/goal/acceptanceEngine.js +103 -0
  18. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  19. package/dist/goal/acceptanceTemplate.js +104 -0
  20. package/dist/goal/goalGraph.d.ts +58 -0
  21. package/dist/goal/goalGraph.js +189 -0
  22. package/dist/goal/goalProgress.d.ts +81 -0
  23. package/dist/goal/goalProgress.js +216 -0
  24. package/dist/goal/goalStatus.d.ts +60 -0
  25. package/dist/goal/goalStatus.js +137 -0
  26. package/dist/goal/goalStore.d.ts +79 -0
  27. package/dist/goal/goalStore.js +211 -0
  28. package/dist/goal/handoffExport.d.ts +34 -0
  29. package/dist/goal/handoffExport.js +183 -0
  30. package/dist/goal/subgoalSync.d.ts +40 -0
  31. package/dist/goal/subgoalSync.js +72 -0
  32. package/dist/goal/worktreeManager.d.ts +96 -0
  33. package/dist/goal/worktreeManager.js +292 -0
  34. package/dist/logging.d.ts +44 -0
  35. package/dist/logging.js +68 -0
  36. package/dist/release/releaseGate.d.ts +99 -0
  37. package/dist/release/releaseGate.js +475 -0
  38. package/dist/runner/postTaskCleanup.d.ts +13 -0
  39. package/dist/runner/postTaskCleanup.js +147 -0
  40. package/dist/runner/runTask.js +50 -10
  41. package/dist/security/discoveryTokenStore.d.ts +62 -0
  42. package/dist/security/discoveryTokenStore.js +100 -0
  43. package/dist/security/toolInvocationGuard.d.ts +35 -0
  44. package/dist/security/toolInvocationGuard.js +153 -0
  45. package/dist/smoke-test.js +18 -10
  46. package/dist/taskRuntime.d.ts +17 -0
  47. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  48. package/dist/test/unit/acceptance-engine.test.js +228 -0
  49. package/dist/test/unit/agent-router.test.d.ts +1 -0
  50. package/dist/test/unit/agent-router.test.js +287 -0
  51. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  52. package/dist/test/unit/audit-checks.test.js +350 -0
  53. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  54. package/dist/test/unit/diagnose-task.test.js +457 -0
  55. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  56. package/dist/test/unit/discovery-token-store.test.js +139 -0
  57. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  58. package/dist/test/unit/goal-graph.test.js +298 -0
  59. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  60. package/dist/test/unit/goal-progress.test.js +381 -0
  61. package/dist/test/unit/goal-status.test.d.ts +1 -0
  62. package/dist/test/unit/goal-status.test.js +215 -0
  63. package/dist/test/unit/goal-store.test.d.ts +1 -0
  64. package/dist/test/unit/goal-store.test.js +253 -0
  65. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  66. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  67. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  68. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  69. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  70. package/dist/test/unit/handoff-export.test.js +263 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  72. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  73. package/dist/test/unit/logging.test.js +127 -5
  74. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  75. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  76. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  77. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  78. package/dist/test/unit/release-gate.test.d.ts +1 -0
  79. package/dist/test/unit/release-gate.test.js +242 -0
  80. package/dist/test/unit/safe-views.test.d.ts +1 -0
  81. package/dist/test/unit/safe-views.test.js +171 -0
  82. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  83. package/dist/test/unit/schema-drift-check.test.js +175 -0
  84. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  85. package/dist/test/unit/subgoal-sync.test.js +183 -0
  86. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  87. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  88. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  89. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  90. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  91. package/dist/test/unit/toolSearch.test.js +571 -0
  92. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  93. package/dist/test/unit/worktree-manager.test.js +176 -0
  94. package/dist/tools/auditTask.d.ts +103 -1
  95. package/dist/tools/auditTask.js +461 -8
  96. package/dist/tools/cancelTask.js +1 -1
  97. package/dist/tools/checkReleaseGate.d.ts +21 -0
  98. package/dist/tools/checkReleaseGate.js +22 -0
  99. package/dist/tools/createTask.d.ts +18 -2
  100. package/dist/tools/createTask.js +29 -1
  101. package/dist/tools/diagnoseTask.d.ts +45 -0
  102. package/dist/tools/diagnoseTask.js +347 -0
  103. package/dist/tools/discardWorktree.d.ts +24 -0
  104. package/dist/tools/discardWorktree.js +27 -0
  105. package/dist/tools/discoverTools.d.ts +11 -0
  106. package/dist/tools/discoverTools.js +39 -0
  107. package/dist/tools/explainTool.d.ts +11 -0
  108. package/dist/tools/explainTool.js +21 -0
  109. package/dist/tools/getTaskSummary.js +2 -1
  110. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  111. package/dist/tools/goalSubgoalTask.js +110 -0
  112. package/dist/tools/healthCheck.d.ts +1 -0
  113. package/dist/tools/healthCheck.js +5 -1
  114. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  115. package/dist/tools/invokeDiscoveredTool.js +112 -0
  116. package/dist/tools/listTasks.d.ts +3 -1
  117. package/dist/tools/listTasks.js +14 -1
  118. package/dist/tools/mergeWorktree.d.ts +24 -0
  119. package/dist/tools/mergeWorktree.js +27 -0
  120. package/dist/tools/reconcileTasks.d.ts +41 -0
  121. package/dist/tools/reconcileTasks.js +352 -0
  122. package/dist/tools/registry.js +550 -0
  123. package/dist/tools/safeStatus.d.ts +31 -1
  124. package/dist/tools/safeStatus.js +43 -1
  125. package/dist/tools/safeViews.d.ts +256 -0
  126. package/dist/tools/safeViews.js +250 -0
  127. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  128. package/dist/tools/schemaDriftCheck.js +80 -0
  129. package/dist/tools/toolCatalog.d.ts +4 -3
  130. package/dist/tools/toolCatalog.js +33 -11
  131. package/dist/tools/toolRegistry.d.ts +61 -0
  132. package/dist/tools/toolRegistry.js +724 -0
  133. package/dist/tools/toolSearch.d.ts +110 -0
  134. package/dist/tools/toolSearch.js +331 -0
  135. package/dist/tools/toolUsageStats.d.ts +41 -0
  136. package/dist/tools/toolUsageStats.js +116 -0
  137. package/dist/tools/waitForTask.js +1 -0
  138. package/dist/version.d.ts +2 -2
  139. package/dist/version.js +2 -2
  140. package/dist/watcherStatus.d.ts +29 -0
  141. package/dist/watcherStatus.js +92 -1
  142. package/docs/control-center/README.md +33 -0
  143. package/docs/control-center/control-center-daily-driver.md +211 -0
  144. package/docs/control-center/control-center-mvp.md +205 -0
  145. package/docs/control-center/control-center-phase2.md +159 -0
  146. package/docs/demo.md +3 -0
  147. package/docs/release-v0.6.4.md +45 -0
  148. package/examples/openai-tunnel/README.md +5 -5
  149. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  150. package/package.json +25 -16
  151. package/scripts/README.md +47 -0
  152. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  153. package/scripts/checks/control-center-smoke.js +1098 -0
  154. package/scripts/{control-smoke.js → checks/control-smoke.js} +19 -5
  155. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +2 -1
  156. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
  157. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +25 -21
  158. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +54 -7
  159. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +40 -11
  160. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  161. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  162. package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
  163. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +13 -7
  164. package/scripts/control/control-center-tray.ps1 +281 -0
  165. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  166. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  167. package/scripts/control/restart-control-center.ps1 +173 -0
  168. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  169. package/scripts/control/start-control-center.ps1 +263 -0
  170. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  171. package/scripts/control/stop-patchwarden.ps1 +114 -0
  172. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  173. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  174. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  175. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  176. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  177. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  178. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  179. package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
  180. package/src/agents/agentRouter.ts +149 -0
  181. package/src/config.ts +9 -3
  182. package/src/controlCenter.ts +2166 -0
  183. package/src/doctor.ts +40 -5
  184. package/src/goal/acceptanceEngine.ts +160 -0
  185. package/src/goal/acceptanceTemplate.ts +121 -0
  186. package/src/goal/goalGraph.ts +225 -0
  187. package/src/goal/goalProgress.ts +301 -0
  188. package/src/goal/goalStatus.ts +234 -0
  189. package/src/goal/goalStore.ts +306 -0
  190. package/src/goal/handoffExport.ts +211 -0
  191. package/src/goal/subgoalSync.ts +82 -0
  192. package/src/goal/worktreeManager.ts +404 -0
  193. package/src/logging.ts +91 -0
  194. package/src/release/releaseGate.ts +567 -0
  195. package/src/runner/postTaskCleanup.ts +154 -0
  196. package/src/runner/runTask.ts +49 -10
  197. package/src/security/discoveryTokenStore.ts +157 -0
  198. package/src/security/toolInvocationGuard.ts +251 -0
  199. package/src/smoke-test.ts +15 -7
  200. package/src/taskRuntime.ts +17 -0
  201. package/src/test/unit/acceptance-engine.test.ts +261 -0
  202. package/src/test/unit/agent-router.test.ts +342 -0
  203. package/src/test/unit/audit-checks.test.ts +567 -0
  204. package/src/test/unit/diagnose-task.test.ts +544 -0
  205. package/src/test/unit/discovery-token-store.test.ts +181 -0
  206. package/src/test/unit/goal-graph.test.ts +347 -0
  207. package/src/test/unit/goal-progress.test.ts +538 -0
  208. package/src/test/unit/goal-status.test.ts +270 -0
  209. package/src/test/unit/goal-store.test.ts +318 -0
  210. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  211. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  212. package/src/test/unit/handoff-export.test.ts +295 -0
  213. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  214. package/src/test/unit/logging.test.ts +177 -5
  215. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  216. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  217. package/src/test/unit/release-gate.test.ts +314 -0
  218. package/src/test/unit/safe-views.test.ts +184 -0
  219. package/src/test/unit/schema-drift-check.test.ts +258 -0
  220. package/src/test/unit/subgoal-sync.test.ts +236 -0
  221. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  222. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  223. package/src/test/unit/toolSearch.test.ts +756 -0
  224. package/src/test/unit/worktree-manager.test.ts +247 -0
  225. package/src/tools/auditTask.ts +831 -402
  226. package/src/tools/cancelTask.ts +1 -1
  227. package/src/tools/checkReleaseGate.ts +45 -0
  228. package/src/tools/createTask.ts +64 -6
  229. package/src/tools/diagnoseTask.ts +460 -0
  230. package/src/tools/discardWorktree.ts +42 -0
  231. package/src/tools/discoverTools.ts +51 -0
  232. package/src/tools/explainTool.ts +26 -0
  233. package/src/tools/getTaskSummary.ts +2 -1
  234. package/src/tools/goalSubgoalTask.ts +170 -0
  235. package/src/tools/healthCheck.ts +4 -1
  236. package/src/tools/invokeDiscoveredTool.ts +163 -0
  237. package/src/tools/listTasks.ts +16 -2
  238. package/src/tools/mergeWorktree.ts +42 -0
  239. package/src/tools/reconcileTasks.ts +464 -0
  240. package/src/tools/registry.ts +618 -1
  241. package/src/tools/safeStatus.ts +73 -2
  242. package/src/tools/safeViews.ts +271 -0
  243. package/src/tools/schemaDriftCheck.ts +120 -0
  244. package/src/tools/toolCatalog.ts +34 -11
  245. package/src/tools/toolRegistry.ts +786 -0
  246. package/src/tools/toolSearch.ts +464 -0
  247. package/src/tools/toolUsageStats.ts +151 -0
  248. package/src/tools/waitForTask.ts +1 -0
  249. package/src/version.ts +2 -2
  250. package/src/watcherStatus.ts +97 -1
  251. package/ui/colors_and_type.css +141 -0
  252. package/ui/pages/audit.html +743 -0
  253. package/ui/pages/dashboard.html +1154 -0
  254. package/ui/pages/direct-sessions.html +652 -0
  255. package/ui/pages/logs.html +502 -0
  256. package/ui/pages/task-detail.html +1229 -0
  257. package/ui/pages/tasks.html +702 -0
  258. package/ui/pages/workspace.html +947 -0
  259. package/ui/partials/project-shell.html +362 -0
  260. package/ui/vendor/lucide.js +12 -0
  261. package/ui/vendor/tailwindcss-browser.js +947 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * v0.8.0: Goal Session 子目标验收业务逻辑 — accept / reject / summarize。
3
+ *
4
+ * 三个公共函数:
5
+ * - acceptSubgoal:校验所有关联 task 已 accepted 后,将 subgoal 置为 accepted
6
+ * - rejectSubgoal:从任意非终态拒绝 subgoal,记录 rejected_reason
7
+ * - summarizeGoalProgress:统计 goal 进度,返回 completion_rate / blocked / risks
8
+ *
9
+ * 任务 status.json 路径解析:
10
+ * 优先用 getConfig().tasksDir(绝对路径直接用,相对路径 join workspaceRoot)。
11
+ * 所有函数接受可选的 workspaceRoot 参数用于测试。
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { isAbsolute, join } from "node:path";
16
+ import { getConfig } from "../config.js";
17
+ import { PatchWardenError } from "../errors.js";
18
+ import { readGoalStatus, writeGoalStatus } from "./goalStore.js";
19
+ import { updateSubgoalStatus, type GoalStatus, type Subgoal } from "./goalStatus.js";
20
+ import { getBlockedSubgoals } from "./goalGraph.js";
21
+
22
+ // ── 类型定义 ──────────────────────────────────────────────────────
23
+
24
+ export interface GoalProgressSummary {
25
+ goal_id: string;
26
+ title: string;
27
+ total: number;
28
+ accepted: number;
29
+ rejected: number;
30
+ running: number;
31
+ ready: number;
32
+ needs_fix: number;
33
+ done_by_agent: number;
34
+ completion_rate: string;
35
+ blocked_subgoals: Array<{ subgoal_id: string; title: string; blocked_by: string[] }>;
36
+ risks: Array<{ subgoal_id: string; title: string; status: string; reason: string }>;
37
+ }
38
+
39
+ // ── 辅助:解析 workspaceRoot ──────────────────────────────────────
40
+
41
+ function resolveWorkspaceRoot(workspaceRoot?: string): string {
42
+ return workspaceRoot ?? getConfig().workspaceRoot;
43
+ }
44
+
45
+ /**
46
+ * 解析任务 status.json 路径。
47
+ * 读取 getConfig().tasksDir:绝对路径直接用,相对路径 join workspaceRoot。
48
+ */
49
+ function resolveTaskStatusPath(taskId: string, workspaceRoot?: string): string {
50
+ const config = getConfig();
51
+ const wsRoot = resolveWorkspaceRoot(workspaceRoot);
52
+ const tasksDir = config.tasksDir;
53
+ const resolvedTasksDir = isAbsolute(tasksDir) ? tasksDir : join(wsRoot, tasksDir);
54
+ return join(resolvedTasksDir, taskId, "status.json");
55
+ }
56
+
57
+ /**
58
+ * 读取单个任务的 status 字段。
59
+ * 如果 status.json 不存在或无法解析,返回 null(视为未 accepted)。
60
+ */
61
+ function readTaskStatus(taskId: string, workspaceRoot?: string): string | null {
62
+ const statusPath = resolveTaskStatusPath(taskId, workspaceRoot);
63
+ if (!existsSync(statusPath)) {
64
+ return null;
65
+ }
66
+ try {
67
+ const raw = readFileSync(statusPath, "utf-8");
68
+ const parsed = JSON.parse(raw) as { status?: string };
69
+ return typeof parsed.status === "string" ? parsed.status : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // ── 公共 API ──────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * 验收子目标:校验所有关联 task 已 accepted 后,将 subgoal 置为 accepted。
79
+ *
80
+ * 校验规则(理解 B — 更严格):
81
+ * 1. task_ids 不能为空(无关联任务无法验收)
82
+ * 2. 所有 task_ids 对应的 task status 必须为 "accepted"
83
+ * 3. 通过 updateSubgoalStatus 状态机校验(要求 subgoal 当前为 done_by_agent)
84
+ *
85
+ * @param goalId Goal 标识
86
+ * @param subgoalId Subgoal 标识
87
+ * @param workspaceRoot 可选,用于测试
88
+ * @returns { subgoal_id, status, accepted_at }
89
+ * @throws {PatchWardenError} reason="goal_not_found" | "subgoal_not_found" | "subgoal_not_ready" | "invalid_status_transition"
90
+ */
91
+ export function acceptSubgoal(
92
+ goalId: string,
93
+ subgoalId: string,
94
+ workspaceRoot?: string
95
+ ): { subgoal_id: string; status: "accepted"; accepted_at: string } {
96
+ const goalStatus = readGoalStatus(goalId, workspaceRoot);
97
+
98
+ const subgoal = goalStatus.subgoals.find((s) => s.id === subgoalId);
99
+ if (!subgoal) {
100
+ throw new PatchWardenError(
101
+ "subgoal_not_found",
102
+ `Subgoal "${subgoalId}" not found in goal "${goalId}"`,
103
+ "Ensure the subgoal id exists before accepting.",
104
+ true,
105
+ { subgoal_id: subgoalId, goal_id: goalId }
106
+ );
107
+ }
108
+
109
+ // 校验 1:task_ids 不能为空
110
+ if (subgoal.task_ids.length === 0) {
111
+ throw new PatchWardenError(
112
+ "subgoal_not_ready",
113
+ `Subgoal "${subgoalId}" has no associated tasks; cannot accept without task verification.`,
114
+ "Link tasks to the subgoal and run audit_task on each before accepting.",
115
+ true,
116
+ { subgoal_id: subgoalId, goal_id: goalId, task_ids: [] }
117
+ );
118
+ }
119
+
120
+ // 校验 2:所有 task 必须为 accepted
121
+ const unacceptedTasks: Array<{ task_id: string; current_status: string }> = [];
122
+ for (const taskId of subgoal.task_ids) {
123
+ const taskStatus = readTaskStatus(taskId, workspaceRoot);
124
+ if (taskStatus !== "accepted") {
125
+ unacceptedTasks.push({
126
+ task_id: taskId,
127
+ current_status: taskStatus ?? "missing",
128
+ });
129
+ }
130
+ }
131
+
132
+ if (unacceptedTasks.length > 0) {
133
+ throw new PatchWardenError(
134
+ "subgoal_not_ready",
135
+ `Subgoal "${subgoalId}" cannot be accepted: ${unacceptedTasks.length} task(s) are not in "accepted" status.`,
136
+ "Run audit_task on each unaccepted task first.",
137
+ true,
138
+ {
139
+ subgoal_id: subgoalId,
140
+ goal_id: goalId,
141
+ unaccepted_tasks: unacceptedTasks,
142
+ }
143
+ );
144
+ }
145
+
146
+ // 校验 3:通过状态机转换(要求 subgoal 当前为 done_by_agent)
147
+ const updatedGoalStatus = updateSubgoalStatus(goalStatus, subgoalId, "accepted");
148
+
149
+ writeGoalStatus(goalId, updatedGoalStatus, workspaceRoot);
150
+
151
+ const updatedSubgoal = updatedGoalStatus.subgoals.find((s) => s.id === subgoalId)!;
152
+ return {
153
+ subgoal_id: subgoalId,
154
+ status: "accepted",
155
+ accepted_at: updatedSubgoal.accepted_at!,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * 拒绝子目标:从任意非终态(ready/running/done_by_agent/needs_fix)拒绝 subgoal。
161
+ *
162
+ * 不使用 updateSubgoalStatus 的状态机校验(因为状态机只允许 done_by_agent → rejected),
163
+ * 直接手动设置 status 和 rejected_reason。
164
+ *
165
+ * @param goalId Goal 标识
166
+ * @param subgoalId Subgoal 标识
167
+ * @param reason 拒绝原因
168
+ * @param workspaceRoot 可选,用于测试
169
+ * @returns { subgoal_id, status, rejected_reason }
170
+ * @throws {PatchWardenError} reason="goal_not_found" | "subgoal_not_found" | "invalid_status_transition"
171
+ */
172
+ export function rejectSubgoal(
173
+ goalId: string,
174
+ subgoalId: string,
175
+ reason: string,
176
+ workspaceRoot?: string
177
+ ): { subgoal_id: string; status: "rejected"; rejected_reason: string } {
178
+ const goalStatus = readGoalStatus(goalId, workspaceRoot);
179
+
180
+ const index = goalStatus.subgoals.findIndex((s) => s.id === subgoalId);
181
+ if (index === -1) {
182
+ throw new PatchWardenError(
183
+ "subgoal_not_found",
184
+ `Subgoal "${subgoalId}" not found in goal "${goalId}"`,
185
+ "Ensure the subgoal id exists before rejecting.",
186
+ true,
187
+ { subgoal_id: subgoalId, goal_id: goalId }
188
+ );
189
+ }
190
+
191
+ const current = goalStatus.subgoals[index];
192
+
193
+ // 终态检查:accepted 和 rejected 不允许再 reject
194
+ if (current.status === "accepted" || current.status === "rejected") {
195
+ throw new PatchWardenError(
196
+ "invalid_status_transition",
197
+ `Cannot reject subgoal "${subgoalId}" in terminal state "${current.status}".`,
198
+ "Reject is only allowed from non-terminal states (ready, running, done_by_agent, needs_fix).",
199
+ true,
200
+ {
201
+ subgoal_id: subgoalId,
202
+ goal_id: goalId,
203
+ from_status: current.status,
204
+ to_status: "rejected",
205
+ }
206
+ );
207
+ }
208
+
209
+ // 手动更新 status 和 rejected_reason(绕过状态机)
210
+ const updatedSubgoal: Subgoal = {
211
+ ...current,
212
+ status: "rejected",
213
+ rejected_reason: reason,
214
+ };
215
+
216
+ const newSubgoals = [...goalStatus.subgoals];
217
+ newSubgoals[index] = updatedSubgoal;
218
+
219
+ const updatedGoalStatus: GoalStatus = {
220
+ ...goalStatus,
221
+ subgoals: newSubgoals,
222
+ updated_at: new Date().toISOString(),
223
+ };
224
+
225
+ writeGoalStatus(goalId, updatedGoalStatus, workspaceRoot);
226
+
227
+ return {
228
+ subgoal_id: subgoalId,
229
+ status: "rejected",
230
+ rejected_reason: reason,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * 汇总 Goal 进度:统计各状态数量、完成率、阻塞子目标、风险项。
236
+ *
237
+ * @param goalId Goal 标识
238
+ * @param workspaceRoot 可选,用于测试
239
+ * @returns GoalProgressSummary
240
+ * @throws {PatchWardenError} reason="goal_not_found"
241
+ */
242
+ export function summarizeGoalProgress(
243
+ goalId: string,
244
+ workspaceRoot?: string
245
+ ): GoalProgressSummary {
246
+ const goalStatus = readGoalStatus(goalId, workspaceRoot);
247
+
248
+ const subgoals = goalStatus.subgoals;
249
+ const total = subgoals.length;
250
+ const accepted = subgoals.filter((s) => s.status === "accepted").length;
251
+ const rejected = subgoals.filter((s) => s.status === "rejected").length;
252
+ const running = subgoals.filter((s) => s.status === "running").length;
253
+ const ready = subgoals.filter((s) => s.status === "ready").length;
254
+ const needsFix = subgoals.filter((s) => s.status === "needs_fix").length;
255
+ const doneByAgent = subgoals.filter((s) => s.status === "done_by_agent").length;
256
+ const completionRate = total > 0
257
+ ? `${Math.round((accepted / total) * 100)}%`
258
+ : "0%";
259
+
260
+ // 阻塞子目标
261
+ const blockedSubgoals = getBlockedSubgoals(goalStatus).map((b) => ({
262
+ subgoal_id: b.subgoal.id,
263
+ title: b.subgoal.title,
264
+ blocked_by: [...b.blocked_by],
265
+ }));
266
+
267
+ // 风险项:needs_fix 和 running
268
+ const risks: GoalProgressSummary["risks"] = [];
269
+ for (const s of subgoals) {
270
+ if (s.status === "needs_fix") {
271
+ risks.push({
272
+ subgoal_id: s.id,
273
+ title: s.title,
274
+ status: s.status,
275
+ reason: "needs_fix",
276
+ });
277
+ } else if (s.status === "running") {
278
+ risks.push({
279
+ subgoal_id: s.id,
280
+ title: s.title,
281
+ status: s.status,
282
+ reason: "running",
283
+ });
284
+ }
285
+ }
286
+
287
+ return {
288
+ goal_id: goalStatus.goal_id,
289
+ title: goalStatus.title,
290
+ total,
291
+ accepted,
292
+ rejected,
293
+ running,
294
+ ready,
295
+ needs_fix: needsFix,
296
+ done_by_agent: doneByAgent,
297
+ completion_rate: completionRate,
298
+ blocked_subgoals: blockedSubgoals,
299
+ risks,
300
+ };
301
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * v0.8.0: Goal Session 子目标状态机 — 不可变更新 + 状态机校验。
3
+ *
4
+ * 类型定义:SubgoalStatus / Subgoal / GoalStatus
5
+ * 状态机合法转换:
6
+ * ready → running
7
+ * running → done_by_agent
8
+ * done_by_agent → accepted | rejected | needs_fix
9
+ * needs_fix → running
10
+ *
11
+ * 所有更新函数返回新的 GoalStatus 对象,不修改原对象。
12
+ */
13
+
14
+ import { PatchWardenError } from "../errors.js";
15
+
16
+ // ── 类型定义 ──────────────────────────────────────────────────────
17
+
18
+ export type SubgoalStatus =
19
+ | "ready"
20
+ | "running"
21
+ | "done_by_agent"
22
+ | "accepted"
23
+ | "rejected"
24
+ | "needs_fix";
25
+
26
+ export interface Subgoal {
27
+ id: string;
28
+ title: string;
29
+ status: SubgoalStatus;
30
+ depends_on: string[];
31
+ task_ids: string[];
32
+ accepted_at?: string;
33
+ rejected_reason?: string;
34
+ }
35
+
36
+ export interface GoalStatus {
37
+ goal_id: string;
38
+ title: string;
39
+ status: "active" | "completed" | "abandoned";
40
+ repo_path: string;
41
+ created_at: string;
42
+ updated_at: string;
43
+ subgoals: Subgoal[];
44
+ }
45
+
46
+ // ── 合法状态转换表 ────────────────────────────────────────────────
47
+
48
+ const LEGAL_TRANSITIONS: Record<SubgoalStatus, SubgoalStatus[]> = {
49
+ ready: ["running"],
50
+ running: ["done_by_agent"],
51
+ done_by_agent: ["accepted", "rejected", "needs_fix"],
52
+ accepted: [],
53
+ rejected: [],
54
+ needs_fix: ["running"],
55
+ };
56
+
57
+ // ── 函数实现 ──────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * 创建初始 GoalStatus,用于新 goal。
61
+ * status 为 "active",subgoals 为空数组,时间戳为当前 ISO 时间。
62
+ */
63
+ export function createInitialGoalStatus(
64
+ goalId: string,
65
+ title: string,
66
+ repoPath: string
67
+ ): GoalStatus {
68
+ const now = new Date().toISOString();
69
+ return {
70
+ goal_id: goalId,
71
+ title,
72
+ status: "active",
73
+ repo_path: repoPath,
74
+ created_at: now,
75
+ updated_at: now,
76
+ subgoals: [],
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 向 goalStatus 添加一个新 subgoal。
82
+ * subgoal_id 格式:`subgoal-{NNN}`,NNN 三位补零,从 001 开始递增。
83
+ * 校验 dependsOn 中每个 id 是否存在于现有 subgoals。
84
+ * 不可变更新:返回新的 GoalStatus 对象。
85
+ */
86
+ export function addSubgoal(
87
+ goalStatus: GoalStatus,
88
+ title: string,
89
+ dependsOn: string[] = []
90
+ ): { goalStatus: GoalStatus; subgoalId: string } {
91
+ const existingIds = new Set(goalStatus.subgoals.map((s) => s.id));
92
+ for (const depId of dependsOn) {
93
+ if (!existingIds.has(depId)) {
94
+ throw new PatchWardenError(
95
+ "invalid_dependency",
96
+ `Subgoal dependency "${depId}" does not exist`,
97
+ "Ensure depends_on references only existing subgoal ids.",
98
+ true,
99
+ { subgoal_id: depId, existing_ids: [...existingIds] }
100
+ );
101
+ }
102
+ }
103
+
104
+ const nextNumber = goalStatus.subgoals.length + 1;
105
+ const subgoalId = `subgoal-${String(nextNumber).padStart(3, "0")}`;
106
+ const newSubgoal: Subgoal = {
107
+ id: subgoalId,
108
+ title,
109
+ status: "ready",
110
+ depends_on: [...dependsOn],
111
+ task_ids: [],
112
+ };
113
+
114
+ const now = new Date().toISOString();
115
+ const updated: GoalStatus = {
116
+ ...goalStatus,
117
+ subgoals: [...goalStatus.subgoals, newSubgoal],
118
+ updated_at: now,
119
+ };
120
+
121
+ return { goalStatus: updated, subgoalId };
122
+ }
123
+
124
+ /**
125
+ * 更新指定 subgoal 的状态,校验状态机合法转换。
126
+ * - accepted:设置 accepted_at
127
+ * - rejected:需要 options.rejected_reason
128
+ * 不可变更新:返回新的 GoalStatus 对象。
129
+ */
130
+ export function updateSubgoalStatus(
131
+ goalStatus: GoalStatus,
132
+ subgoalId: string,
133
+ newStatus: SubgoalStatus,
134
+ options?: { rejected_reason?: string }
135
+ ): GoalStatus {
136
+ const index = goalStatus.subgoals.findIndex((s) => s.id === subgoalId);
137
+ if (index === -1) {
138
+ throw new PatchWardenError(
139
+ "subgoal_not_found",
140
+ `Subgoal "${subgoalId}" not found in goal "${goalStatus.goal_id}"`,
141
+ "Ensure the subgoal id exists before updating its status.",
142
+ true,
143
+ { subgoal_id: subgoalId, goal_id: goalStatus.goal_id }
144
+ );
145
+ }
146
+
147
+ const current = goalStatus.subgoals[index];
148
+ const legal = LEGAL_TRANSITIONS[current.status];
149
+ if (!legal.includes(newStatus)) {
150
+ throw new PatchWardenError(
151
+ "invalid_status_transition",
152
+ `Invalid status transition for subgoal "${subgoalId}": "${current.status}" -> "${newStatus}"`,
153
+ `Valid transitions from "${current.status}": ${legal.length > 0 ? legal.join(", ") : "(none, terminal state)"}.`,
154
+ true,
155
+ {
156
+ subgoal_id: subgoalId,
157
+ from_status: current.status,
158
+ to_status: newStatus,
159
+ legal_transitions: legal,
160
+ }
161
+ );
162
+ }
163
+
164
+ const updatedSubgoal: Subgoal = {
165
+ ...current,
166
+ status: newStatus,
167
+ };
168
+
169
+ if (newStatus === "accepted") {
170
+ updatedSubgoal.accepted_at = new Date().toISOString();
171
+ }
172
+ if (newStatus === "rejected") {
173
+ if (!options || !options.rejected_reason) {
174
+ throw new PatchWardenError(
175
+ "invalid_status_transition",
176
+ `Rejecting subgoal "${subgoalId}" requires rejected_reason`,
177
+ "Provide options.rejected_reason when transitioning to \"rejected\".",
178
+ true,
179
+ { subgoal_id: subgoalId, from_status: current.status, to_status: newStatus }
180
+ );
181
+ }
182
+ updatedSubgoal.rejected_reason = options.rejected_reason;
183
+ }
184
+
185
+ const newSubgoals = [...goalStatus.subgoals];
186
+ newSubgoals[index] = updatedSubgoal;
187
+
188
+ return {
189
+ ...goalStatus,
190
+ subgoals: newSubgoals,
191
+ updated_at: new Date().toISOString(),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * 将 taskId 关联到指定 subgoal(去重)。
197
+ * 不可变更新:返回新的 GoalStatus 对象。
198
+ */
199
+ export function linkTaskToSubgoal(
200
+ goalStatus: GoalStatus,
201
+ subgoalId: string,
202
+ taskId: string
203
+ ): GoalStatus {
204
+ const index = goalStatus.subgoals.findIndex((s) => s.id === subgoalId);
205
+ if (index === -1) {
206
+ throw new PatchWardenError(
207
+ "subgoal_not_found",
208
+ `Subgoal "${subgoalId}" not found in goal "${goalStatus.goal_id}"`,
209
+ "Ensure the subgoal id exists before linking a task.",
210
+ true,
211
+ { subgoal_id: subgoalId, goal_id: goalStatus.goal_id }
212
+ );
213
+ }
214
+
215
+ const current = goalStatus.subgoals[index];
216
+ if (current.task_ids.includes(taskId)) {
217
+ // 已存在,不重复添加,但仍返回等价的新对象以保持不可变契约
218
+ return { ...goalStatus, updated_at: new Date().toISOString() };
219
+ }
220
+
221
+ const updatedSubgoal: Subgoal = {
222
+ ...current,
223
+ task_ids: [...current.task_ids, taskId],
224
+ };
225
+
226
+ const newSubgoals = [...goalStatus.subgoals];
227
+ newSubgoals[index] = updatedSubgoal;
228
+
229
+ return {
230
+ ...goalStatus,
231
+ subgoals: newSubgoals,
232
+ updated_at: new Date().toISOString(),
233
+ };
234
+ }