patchwarden 0.6.4 → 1.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.
Files changed (254) hide show
  1. package/README.en.md +54 -20
  2. package/README.md +48 -21
  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 +212 -3
  8. package/dist/direct/directSessionStore.d.ts +2 -0
  9. package/dist/direct/directVerification.js +7 -0
  10. package/dist/doctor.js +32 -1
  11. package/dist/goal/acceptanceEngine.d.ts +62 -0
  12. package/dist/goal/acceptanceEngine.js +103 -0
  13. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  14. package/dist/goal/acceptanceTemplate.js +104 -0
  15. package/dist/goal/goalGraph.d.ts +58 -0
  16. package/dist/goal/goalGraph.js +189 -0
  17. package/dist/goal/goalProgress.d.ts +81 -0
  18. package/dist/goal/goalProgress.js +216 -0
  19. package/dist/goal/goalStatus.d.ts +60 -0
  20. package/dist/goal/goalStatus.js +137 -0
  21. package/dist/goal/goalStore.d.ts +79 -0
  22. package/dist/goal/goalStore.js +211 -0
  23. package/dist/goal/handoffExport.d.ts +34 -0
  24. package/dist/goal/handoffExport.js +183 -0
  25. package/dist/goal/subgoalSync.d.ts +40 -0
  26. package/dist/goal/subgoalSync.js +72 -0
  27. package/dist/goal/worktreeManager.d.ts +96 -0
  28. package/dist/goal/worktreeManager.js +292 -0
  29. package/dist/logging.d.ts +44 -0
  30. package/dist/logging.js +68 -0
  31. package/dist/policy/projectPolicy.d.ts +55 -0
  32. package/dist/policy/projectPolicy.js +286 -0
  33. package/dist/release/releaseGate.d.ts +99 -0
  34. package/dist/release/releaseGate.js +475 -0
  35. package/dist/runner/postTaskCleanup.d.ts +13 -0
  36. package/dist/runner/postTaskCleanup.js +147 -0
  37. package/dist/runner/runTask.js +50 -10
  38. package/dist/security/discoveryTokenStore.d.ts +62 -0
  39. package/dist/security/discoveryTokenStore.js +100 -0
  40. package/dist/security/toolInvocationGuard.d.ts +35 -0
  41. package/dist/security/toolInvocationGuard.js +153 -0
  42. package/dist/smoke-test.js +18 -10
  43. package/dist/taskRuntime.d.ts +17 -0
  44. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  45. package/dist/test/unit/acceptance-engine.test.js +228 -0
  46. package/dist/test/unit/agent-router.test.d.ts +1 -0
  47. package/dist/test/unit/agent-router.test.js +287 -0
  48. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  49. package/dist/test/unit/audit-checks.test.js +350 -0
  50. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  51. package/dist/test/unit/diagnose-task.test.js +457 -0
  52. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  53. package/dist/test/unit/discovery-token-store.test.js +139 -0
  54. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  55. package/dist/test/unit/evidence-pack.test.js +130 -0
  56. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  57. package/dist/test/unit/goal-graph.test.js +298 -0
  58. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  59. package/dist/test/unit/goal-progress.test.js +381 -0
  60. package/dist/test/unit/goal-status.test.d.ts +1 -0
  61. package/dist/test/unit/goal-status.test.js +215 -0
  62. package/dist/test/unit/goal-store.test.d.ts +1 -0
  63. package/dist/test/unit/goal-store.test.js +253 -0
  64. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  65. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  66. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  67. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  68. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  69. package/dist/test/unit/handoff-export.test.js +263 -0
  70. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  72. package/dist/test/unit/logging.test.js +127 -5
  73. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  74. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  75. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  76. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  77. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  78. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  79. package/dist/test/unit/release-gate.test.d.ts +1 -0
  80. package/dist/test/unit/release-gate.test.js +242 -0
  81. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  82. package/dist/test/unit/run-task-loop.test.js +380 -0
  83. package/dist/test/unit/safe-views.test.d.ts +1 -0
  84. package/dist/test/unit/safe-views.test.js +171 -0
  85. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  86. package/dist/test/unit/schema-drift-check.test.js +176 -0
  87. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  88. package/dist/test/unit/subgoal-sync.test.js +183 -0
  89. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  90. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  91. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  92. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  93. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  94. package/dist/test/unit/toolSearch.test.js +571 -0
  95. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  96. package/dist/test/unit/worktree-manager.test.js +176 -0
  97. package/dist/tools/auditTask.d.ts +103 -1
  98. package/dist/tools/auditTask.js +461 -8
  99. package/dist/tools/cancelTask.js +1 -1
  100. package/dist/tools/checkReleaseGate.d.ts +21 -0
  101. package/dist/tools/checkReleaseGate.js +22 -0
  102. package/dist/tools/createTask.d.ts +18 -2
  103. package/dist/tools/createTask.js +29 -1
  104. package/dist/tools/diagnoseTask.d.ts +45 -0
  105. package/dist/tools/diagnoseTask.js +347 -0
  106. package/dist/tools/discardWorktree.d.ts +24 -0
  107. package/dist/tools/discardWorktree.js +27 -0
  108. package/dist/tools/discoverTools.d.ts +11 -0
  109. package/dist/tools/discoverTools.js +39 -0
  110. package/dist/tools/evidencePack.d.ts +39 -0
  111. package/dist/tools/evidencePack.js +168 -0
  112. package/dist/tools/explainTool.d.ts +11 -0
  113. package/dist/tools/explainTool.js +21 -0
  114. package/dist/tools/getTaskSummary.js +2 -1
  115. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  116. package/dist/tools/goalSubgoalTask.js +110 -0
  117. package/dist/tools/healthCheck.d.ts +1 -0
  118. package/dist/tools/healthCheck.js +5 -1
  119. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  120. package/dist/tools/invokeDiscoveredTool.js +112 -0
  121. package/dist/tools/listTasks.d.ts +3 -1
  122. package/dist/tools/listTasks.js +14 -1
  123. package/dist/tools/mergeWorktree.d.ts +24 -0
  124. package/dist/tools/mergeWorktree.js +27 -0
  125. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  126. package/dist/tools/recommendAgentForTask.js +56 -0
  127. package/dist/tools/reconcileTasks.d.ts +41 -0
  128. package/dist/tools/reconcileTasks.js +352 -0
  129. package/dist/tools/registry.js +926 -2
  130. package/dist/tools/releaseMode.d.ts +50 -0
  131. package/dist/tools/releaseMode.js +370 -0
  132. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  133. package/dist/tools/runDirectVerificationBundle.js +64 -0
  134. package/dist/tools/runTaskLoop.d.ts +57 -0
  135. package/dist/tools/runTaskLoop.js +417 -0
  136. package/dist/tools/runVerification.d.ts +4 -0
  137. package/dist/tools/runVerification.js +4 -0
  138. package/dist/tools/safeStatus.d.ts +31 -1
  139. package/dist/tools/safeStatus.js +43 -1
  140. package/dist/tools/safeViews.d.ts +262 -0
  141. package/dist/tools/safeViews.js +252 -0
  142. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  143. package/dist/tools/schemaDriftCheck.js +80 -0
  144. package/dist/tools/taskLineage.d.ts +91 -0
  145. package/dist/tools/taskLineage.js +175 -0
  146. package/dist/tools/toolCatalog.d.ts +4 -3
  147. package/dist/tools/toolCatalog.js +39 -11
  148. package/dist/tools/toolRegistry.d.ts +61 -0
  149. package/dist/tools/toolRegistry.js +834 -0
  150. package/dist/tools/toolSearch.d.ts +110 -0
  151. package/dist/tools/toolSearch.js +331 -0
  152. package/dist/tools/toolUsageStats.d.ts +41 -0
  153. package/dist/tools/toolUsageStats.js +116 -0
  154. package/dist/tools/waitForTask.js +1 -0
  155. package/dist/version.d.ts +2 -2
  156. package/dist/version.js +2 -2
  157. package/dist/watcherStatus.d.ts +29 -0
  158. package/dist/watcherStatus.js +92 -1
  159. package/docs/chatgpt-usage.md +31 -0
  160. package/docs/control-center/README.md +9 -0
  161. package/package.json +3 -2
  162. package/scripts/checks/control-center-smoke.js +87 -0
  163. package/scripts/checks/control-smoke.js +2 -2
  164. package/scripts/checks/doctor-smoke.js +1 -0
  165. package/scripts/checks/lifecycle-smoke.js +13 -9
  166. package/scripts/checks/mcp-manifest-check.js +65 -6
  167. package/scripts/checks/mcp-smoke.js +60 -9
  168. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  169. package/src/agents/agentRouter.ts +149 -0
  170. package/src/config.ts +9 -3
  171. package/src/controlCenter.ts +215 -4
  172. package/src/direct/directSessionStore.ts +2 -0
  173. package/src/direct/directVerification.ts +7 -0
  174. package/src/doctor.ts +36 -1
  175. package/src/goal/acceptanceEngine.ts +160 -0
  176. package/src/goal/acceptanceTemplate.ts +121 -0
  177. package/src/goal/goalGraph.ts +225 -0
  178. package/src/goal/goalProgress.ts +301 -0
  179. package/src/goal/goalStatus.ts +234 -0
  180. package/src/goal/goalStore.ts +306 -0
  181. package/src/goal/handoffExport.ts +211 -0
  182. package/src/goal/subgoalSync.ts +82 -0
  183. package/src/goal/worktreeManager.ts +404 -0
  184. package/src/logging.ts +91 -0
  185. package/src/policy/projectPolicy.ts +344 -0
  186. package/src/release/releaseGate.ts +567 -0
  187. package/src/runner/postTaskCleanup.ts +154 -0
  188. package/src/runner/runTask.ts +49 -10
  189. package/src/security/discoveryTokenStore.ts +157 -0
  190. package/src/security/toolInvocationGuard.ts +251 -0
  191. package/src/smoke-test.ts +15 -7
  192. package/src/taskRuntime.ts +17 -0
  193. package/src/test/unit/acceptance-engine.test.ts +261 -0
  194. package/src/test/unit/agent-router.test.ts +342 -0
  195. package/src/test/unit/audit-checks.test.ts +567 -0
  196. package/src/test/unit/diagnose-task.test.ts +544 -0
  197. package/src/test/unit/discovery-token-store.test.ts +181 -0
  198. package/src/test/unit/evidence-pack.test.ts +142 -0
  199. package/src/test/unit/goal-graph.test.ts +347 -0
  200. package/src/test/unit/goal-progress.test.ts +538 -0
  201. package/src/test/unit/goal-status.test.ts +270 -0
  202. package/src/test/unit/goal-store.test.ts +318 -0
  203. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  204. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  205. package/src/test/unit/handoff-export.test.ts +295 -0
  206. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  207. package/src/test/unit/logging.test.ts +177 -5
  208. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  209. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  210. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  211. package/src/test/unit/release-gate.test.ts +314 -0
  212. package/src/test/unit/run-task-loop.test.ts +425 -0
  213. package/src/test/unit/safe-views.test.ts +184 -0
  214. package/src/test/unit/schema-drift-check.test.ts +260 -0
  215. package/src/test/unit/subgoal-sync.test.ts +236 -0
  216. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  217. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  218. package/src/test/unit/toolSearch.test.ts +756 -0
  219. package/src/test/unit/worktree-manager.test.ts +247 -0
  220. package/src/tools/auditTask.ts +831 -402
  221. package/src/tools/cancelTask.ts +1 -1
  222. package/src/tools/checkReleaseGate.ts +45 -0
  223. package/src/tools/createTask.ts +64 -6
  224. package/src/tools/diagnoseTask.ts +460 -0
  225. package/src/tools/discardWorktree.ts +42 -0
  226. package/src/tools/discoverTools.ts +51 -0
  227. package/src/tools/evidencePack.ts +205 -0
  228. package/src/tools/explainTool.ts +26 -0
  229. package/src/tools/getTaskSummary.ts +2 -1
  230. package/src/tools/goalSubgoalTask.ts +170 -0
  231. package/src/tools/healthCheck.ts +4 -1
  232. package/src/tools/invokeDiscoveredTool.ts +163 -0
  233. package/src/tools/listTasks.ts +16 -2
  234. package/src/tools/listWorkspace.ts +71 -71
  235. package/src/tools/mergeWorktree.ts +42 -0
  236. package/src/tools/recommendAgentForTask.ts +79 -0
  237. package/src/tools/reconcileTasks.ts +464 -0
  238. package/src/tools/registry.ts +1093 -73
  239. package/src/tools/releaseMode.ts +450 -0
  240. package/src/tools/runDirectVerificationBundle.ts +98 -0
  241. package/src/tools/runTaskLoop.ts +526 -0
  242. package/src/tools/runVerification.ts +8 -0
  243. package/src/tools/safeStatus.ts +73 -2
  244. package/src/tools/safeViews.ts +273 -0
  245. package/src/tools/schemaDriftCheck.ts +120 -0
  246. package/src/tools/taskLineage.ts +300 -0
  247. package/src/tools/toolCatalog.ts +40 -11
  248. package/src/tools/toolRegistry.ts +896 -0
  249. package/src/tools/toolSearch.ts +464 -0
  250. package/src/tools/toolUsageStats.ts +151 -0
  251. package/src/tools/waitForTask.ts +1 -0
  252. package/src/version.ts +2 -2
  253. package/src/watcherStatus.ts +97 -1
  254. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,45 @@
1
+ import { type PatchWardenConfig } from "../config.js";
2
+ export type DiagnosisType = "active_running" | "stale_running" | "possibly_stale_running" | "orphaned_running" | "artifact_collection_stuck" | "done_candidate" | "unknown" | "terminal";
3
+ export type DiagnosisConfidence = "high" | "medium" | "low";
4
+ export type SafeAction = "leave_unchanged" | "mark_failed_stale" | "mark_orphaned" | "mark_done_by_agent" | "collect_artifacts" | "recollect_artifacts" | "needs_fix";
5
+ export interface DiagnosisEvidence {
6
+ heartbeat_age_seconds: number | null;
7
+ stdout_age_seconds: number | null;
8
+ stderr_age_seconds: number | null;
9
+ stdout_size_bytes: number | null;
10
+ stderr_size_bytes: number | null;
11
+ child_pid: number | null;
12
+ child_pid_alive: boolean | null;
13
+ child_started_at: string | null;
14
+ pid_reuse_suspected: boolean;
15
+ watcher_instance_id: string | null;
16
+ current_watcher_instance_id: string | null;
17
+ watcher_owns_task: boolean;
18
+ watcher_ownership_reason: string;
19
+ has_result_md: boolean;
20
+ has_test_log: boolean;
21
+ has_git_diff: boolean;
22
+ has_artifact_manifest: boolean;
23
+ task_started_at: string | null;
24
+ status_updated_at: string | null;
25
+ collecting_artifacts_phase: boolean;
26
+ }
27
+ export interface DiagnoseTaskOutput {
28
+ task_id: string;
29
+ status: string;
30
+ phase: string | null;
31
+ diagnosis: DiagnosisType;
32
+ confidence: DiagnosisConfidence;
33
+ reasons: string[];
34
+ safe_actions: SafeAction[];
35
+ evidence: DiagnosisEvidence;
36
+ logs: {
37
+ stdout_tail: string | null;
38
+ stderr_tail: string | null;
39
+ } | null;
40
+ }
41
+ export interface DiagnoseTaskInput {
42
+ task_id: string;
43
+ include_logs?: boolean;
44
+ }
45
+ export declare function diagnoseTask(input: DiagnoseTaskInput, config?: PatchWardenConfig): DiagnoseTaskOutput;
@@ -0,0 +1,347 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { getTasksDir, getConfig } from "../config.js";
4
+ import { guardReadPath } from "../security/pathGuard.js";
5
+ import { guardSensitivePath } from "../security/sensitiveGuard.js";
6
+ import { redactSensitiveContent } from "../security/contentRedaction.js";
7
+ import { readTaskRuntime } from "../taskRuntime.js";
8
+ import { isWatcherOwningTask, readWatcherInstanceId } from "../watcherStatus.js";
9
+ // ── Thresholds (seconds) ──────────────────────────────────────────
10
+ //
11
+ // These are intentionally conservative. A task is only "stale" when multiple
12
+ // signals agree. A single stale signal produces "possibly_stale_running" or
13
+ // "unknown", never an automatic safe_fix.
14
+ const HEARTBEAT_STALE_SECONDS = 300; // 5 min: heartbeat this old is a strong stale signal
15
+ const HEARTBEAT_POSSIBLY_STALE_SECONDS = 120; // 2 min: heartbeat this old is a weak stale signal
16
+ const LOG_STALE_SECONDS = 300; // 5 min: stdout/stderr unchanged this long is stale
17
+ const LOG_TAIL_LINES = 40;
18
+ // ── PID liveness check ─────────────────────────────────────────────
19
+ /**
20
+ * Check whether a PID is alive without sending a signal that could disturb it.
21
+ * Returns null when the check itself fails (e.g., permission denied),
22
+ * so callers can avoid treating "couldn't check" as "process is dead".
23
+ */
24
+ function isPidAlive(pid) {
25
+ if (!Number.isInteger(pid) || pid <= 0)
26
+ return false;
27
+ try {
28
+ // process.kill(pid, 0) does not actually kill; it just checks liveness.
29
+ // It throws if the process does not exist or the caller lacks permission.
30
+ process.kill(pid, 0);
31
+ return true;
32
+ }
33
+ catch (err) {
34
+ const code = err.code;
35
+ if (code === "ESRCH")
36
+ return false; // No such process
37
+ if (code === "EPERM")
38
+ return null; // Alive but not ours — treat as unknown
39
+ return null;
40
+ }
41
+ }
42
+ // ── File age helper ────────────────────────────────────────────────
43
+ function fileAgeSeconds(filePath, nowMs) {
44
+ if (!existsSync(filePath))
45
+ return null;
46
+ try {
47
+ const stat = statSync(filePath);
48
+ return { age: Math.max(0, Math.round((nowMs - stat.mtimeMs) / 1000)), size: stat.size };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function readTailLines(filePath, lines) {
55
+ if (!existsSync(filePath))
56
+ return null;
57
+ try {
58
+ const raw = readFileSync(filePath, "utf-8");
59
+ const tail = raw.split("\n").slice(-lines).join("\n");
60
+ return redactSensitiveContent(tail).content;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ // ── Main diagnosis entry point ─────────────────────────────────────
67
+ export function diagnoseTask(input, config = getConfig()) {
68
+ const taskId = input.task_id;
69
+ const tasksDir = getTasksDir(config);
70
+ const taskDir = resolve(tasksDir, taskId);
71
+ const statusFile = join(taskDir, "status.json");
72
+ guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
73
+ guardSensitivePath(statusFile);
74
+ if (!existsSync(statusFile)) {
75
+ throw new Error(`Task not found: "${taskId}". Check the task ID or call list_tasks.`);
76
+ }
77
+ const raw = readFileSync(statusFile, "utf-8");
78
+ const status = JSON.parse(raw);
79
+ const runtime = readTaskRuntime(taskDir);
80
+ const nowMs = Date.now();
81
+ const statusStr = typeof status.status === "string" ? status.status : "unknown";
82
+ const phaseStr = (runtime.phase || (typeof status.phase === "string" ? status.phase : "queued"));
83
+ // ── Terminal tasks: no diagnosis needed ──
84
+ const terminalStatuses = new Set([
85
+ "done", "done_by_agent", "failed", "failed_verification",
86
+ "failed_scope_violation", "failed_policy_violation",
87
+ "failed_stale", "orphaned", "canceled",
88
+ ]);
89
+ if (terminalStatuses.has(statusStr)) {
90
+ return buildTerminalDiagnosis(taskId, statusStr, phaseStr);
91
+ }
92
+ // ── Only diagnose running / collecting_artifacts / pending ──
93
+ if (statusStr !== "running" && statusStr !== "collecting_artifacts" && statusStr !== "pending") {
94
+ return buildUnknownDiagnosis(taskId, statusStr, phaseStr, [`unexpected status "${statusStr}" for diagnosis`]);
95
+ }
96
+ // ── Gather evidence ──
97
+ const heartbeatStr = (typeof runtime.last_heartbeat_at === "string" && runtime.last_heartbeat_at) ||
98
+ (typeof status.last_heartbeat_at === "string" && status.last_heartbeat_at) ||
99
+ (typeof status.updated_at === "string" && status.updated_at) ||
100
+ null;
101
+ const heartbeatMs = heartbeatStr ? Date.parse(heartbeatStr) : NaN;
102
+ const heartbeatAgeSeconds = Number.isFinite(heartbeatMs)
103
+ ? Math.max(0, Math.round((nowMs - heartbeatMs) / 1000))
104
+ : null;
105
+ const stdoutPath = join(taskDir, "stdout.log");
106
+ const stderrPath = join(taskDir, "stderr.log");
107
+ const stdoutStat = fileAgeSeconds(stdoutPath, nowMs);
108
+ const stderrStat = fileAgeSeconds(stderrPath, nowMs);
109
+ const resultMdPath = join(taskDir, "result.md");
110
+ const testLogPath = join(taskDir, "test.log");
111
+ const gitDiffPath = join(taskDir, "git.diff");
112
+ const artifactManifestPath = join(taskDir, "artifact_manifest.json");
113
+ const hasResultMd = existsSync(resultMdPath);
114
+ const hasTestLog = existsSync(testLogPath);
115
+ const hasGitDiff = existsSync(gitDiffPath);
116
+ const hasArtifactManifest = existsSync(artifactManifestPath);
117
+ const childPid = typeof runtime.child_pid === "number" ? runtime.child_pid : null;
118
+ const childStartedAt = typeof runtime.child_started_at === "string" ? runtime.child_started_at : null;
119
+ const childPidAlive = childPid !== null ? isPidAlive(childPid) : null;
120
+ // PID reuse heuristic: PID is alive but child_started_at is much older than
121
+ // the heartbeat threshold AND logs are stale. We cannot reliably read the
122
+ // OS process start time without platform-specific code, so we treat
123
+ // "PID alive + everything else stale" as a PID-reuse signal rather than
124
+ // "active". This is the conservative choice required by the safety contract.
125
+ const pidReuseSuspected = childPidAlive === true &&
126
+ heartbeatAgeSeconds !== null &&
127
+ heartbeatAgeSeconds > HEARTBEAT_STALE_SECONDS &&
128
+ (stdoutStat === null || stdoutStat.age > LOG_STALE_SECONDS) &&
129
+ (stderrStat === null || stderrStat.age > LOG_STALE_SECONDS);
130
+ const ownership = isWatcherOwningTask(taskDir, config);
131
+ const currentWatcherInstanceId = readWatcherInstanceId(config);
132
+ const collectingArtifactsPhase = phaseStr === "collecting_artifacts" || statusStr === "collecting_artifacts";
133
+ const taskStartedAt = typeof runtime.task_started_at === "string"
134
+ ? runtime.task_started_at
135
+ : typeof status.started_at === "string"
136
+ ? status.started_at
137
+ : null;
138
+ const statusUpdatedAt = typeof status.updated_at === "string" ? status.updated_at : null;
139
+ const evidence = {
140
+ heartbeat_age_seconds: heartbeatAgeSeconds,
141
+ stdout_age_seconds: stdoutStat?.age ?? null,
142
+ stderr_age_seconds: stderrStat?.age ?? null,
143
+ stdout_size_bytes: stdoutStat?.size ?? null,
144
+ stderr_size_bytes: stderrStat?.size ?? null,
145
+ child_pid: childPid,
146
+ child_pid_alive: childPidAlive,
147
+ child_started_at: childStartedAt,
148
+ pid_reuse_suspected: pidReuseSuspected,
149
+ watcher_instance_id: ownership.task_watcher_instance_id,
150
+ current_watcher_instance_id: currentWatcherInstanceId,
151
+ watcher_owns_task: ownership.owned,
152
+ watcher_ownership_reason: ownership.reason,
153
+ has_result_md: hasResultMd,
154
+ has_test_log: hasTestLog,
155
+ has_git_diff: hasGitDiff,
156
+ has_artifact_manifest: hasArtifactManifest,
157
+ task_started_at: taskStartedAt,
158
+ status_updated_at: statusUpdatedAt,
159
+ collecting_artifacts_phase: collectingArtifactsPhase,
160
+ };
161
+ // ── Decision tree ──
162
+ //
163
+ // The diagnosis must NEVER rely on a single signal. Each branch below
164
+ // combines at least two signals before producing a high-confidence
165
+ // conclusion. When signals conflict, confidence drops to medium/low.
166
+ const reasons = [];
167
+ const heartbeatStale = heartbeatAgeSeconds !== null && heartbeatAgeSeconds > HEARTBEAT_STALE_SECONDS;
168
+ const heartbeatPossiblyStale = heartbeatAgeSeconds !== null &&
169
+ heartbeatAgeSeconds > HEARTBEAT_POSSIBLY_STALE_SECONDS &&
170
+ heartbeatAgeSeconds <= HEARTBEAT_STALE_SECONDS;
171
+ const heartbeatFresh = heartbeatAgeSeconds !== null && heartbeatAgeSeconds <= HEARTBEAT_POSSIBLY_STALE_SECONDS;
172
+ const logsStale = (stdoutStat === null || stdoutStat.age > LOG_STALE_SECONDS) &&
173
+ (stderrStat === null || stderrStat.age > LOG_STALE_SECONDS);
174
+ const logsGrowing = stdoutStat !== null && stdoutStat.age <= HEARTBEAT_POSSIBLY_STALE_SECONDS;
175
+ // Collect artifact evidence strings
176
+ if (heartbeatStale)
177
+ reasons.push(`heartbeat older than ${HEARTBEAT_STALE_SECONDS}s (age: ${heartbeatAgeSeconds}s)`);
178
+ if (heartbeatPossiblyStale)
179
+ reasons.push(`heartbeat older than ${HEARTBEAT_POSSIBLY_STALE_SECONDS}s (age: ${heartbeatAgeSeconds}s)`);
180
+ if (stdoutStat && stdoutStat.age > LOG_STALE_SECONDS)
181
+ reasons.push(`stdout.log unchanged for ${stdoutStat.age}s`);
182
+ if (stderrStat && stderrStat.age > LOG_STALE_SECONDS)
183
+ reasons.push(`stderr.log unchanged for ${stderrStat.age}s`);
184
+ if (childPidAlive === false)
185
+ reasons.push(`child_pid ${childPid} is not alive`);
186
+ if (pidReuseSuspected)
187
+ reasons.push(`PID ${childPid} is alive but all other signals are stale — suspected PID reuse`);
188
+ if (!ownership.owned && ownership.reason === "instance_mismatch") {
189
+ reasons.push(`watcher_instance_id mismatch: task=${ownership.task_watcher_instance_id}, current=${ownership.current_watcher_instance_id}`);
190
+ }
191
+ if (!ownership.owned && ownership.reason === "watcher_missing") {
192
+ reasons.push("watcher heartbeat is missing — no live watcher owns this task");
193
+ }
194
+ if (!ownership.owned && ownership.reason === "no_runtime_record") {
195
+ reasons.push("task runtime has no watcher_instance_id recorded — ownership cannot be confirmed");
196
+ }
197
+ if (hasResultMd)
198
+ reasons.push("result.md already exists while status is still running");
199
+ if (hasTestLog)
200
+ reasons.push("test.log already exists while status is still running");
201
+ // ── done_candidate: artifacts exist but status not converged ──
202
+ // Strong signal: result.md + test.log + git.diff all exist, status still running.
203
+ // Medium signal: only result.md exists.
204
+ if (hasResultMd && (hasTestLog || hasGitDiff)) {
205
+ return buildResult(taskId, statusStr, phaseStr, "done_candidate", "high", reasons, [
206
+ "mark_done_by_agent",
207
+ "leave_unchanged",
208
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
209
+ }
210
+ if (hasResultMd && collectingArtifactsPhase) {
211
+ return buildResult(taskId, statusStr, phaseStr, "done_candidate", "medium", reasons, [
212
+ "mark_done_by_agent",
213
+ "collect_artifacts",
214
+ "leave_unchanged",
215
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
216
+ }
217
+ // ── orphaned_running: watcher does not own the task ──
218
+ if (!ownership.owned && (ownership.reason === "instance_mismatch" || ownership.reason === "watcher_missing")) {
219
+ return buildResult(taskId, statusStr, phaseStr, "orphaned_running", "high", reasons, [
220
+ "mark_orphaned",
221
+ "leave_unchanged",
222
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
223
+ }
224
+ // ── artifact_collection_stuck: in collecting_artifacts phase and stuck ──
225
+ if (collectingArtifactsPhase) {
226
+ const collectionStuck = (heartbeatStale || heartbeatPossiblyStale) && (logsStale || childPidAlive === false);
227
+ if (collectionStuck) {
228
+ return buildResult(taskId, statusStr, phaseStr, "artifact_collection_stuck", "high", reasons, [
229
+ "recollect_artifacts",
230
+ "mark_failed_stale",
231
+ "leave_unchanged",
232
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
233
+ }
234
+ // Collecting but not clearly stuck — possibly still active
235
+ if (heartbeatFresh || logsGrowing) {
236
+ return buildResult(taskId, statusStr, phaseStr, "active_running", "medium", reasons, [
237
+ "leave_unchanged",
238
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
239
+ }
240
+ return buildResult(taskId, statusStr, phaseStr, "artifact_collection_stuck", "medium", reasons, [
241
+ "recollect_artifacts",
242
+ "leave_unchanged",
243
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
244
+ }
245
+ // ── active_running: heartbeat fresh, watcher owns, logs growing or PID alive ──
246
+ if (heartbeatFresh && ownership.owned && (logsGrowing || childPidAlive === true)) {
247
+ return buildResult(taskId, statusStr, phaseStr, "active_running", "high", reasons, [
248
+ "leave_unchanged",
249
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
250
+ }
251
+ // ── stale_running: heartbeat stale, PID dead, watcher doesn't own ──
252
+ if (heartbeatStale && (childPidAlive === false || !ownership.owned) && logsStale) {
253
+ return buildResult(taskId, statusStr, phaseStr, "stale_running", "high", reasons, [
254
+ "mark_failed_stale",
255
+ "collect_artifacts",
256
+ "leave_unchanged",
257
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
258
+ }
259
+ // ── possibly_stale_running: some stale signals but not enough for high confidence ──
260
+ // This branch catches the PID-reuse-suspected case as well: PID is alive
261
+ // but everything else is stale. We refuse to call it "active".
262
+ if (pidReuseSuspected) {
263
+ return buildResult(taskId, statusStr, phaseStr, "possibly_stale_running", "medium", reasons, [
264
+ "leave_unchanged",
265
+ "mark_failed_stale",
266
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
267
+ }
268
+ if (heartbeatStale || (heartbeatPossiblyStale && (logsStale || !ownership.owned))) {
269
+ return buildResult(taskId, statusStr, phaseStr, "possibly_stale_running", "medium", reasons, [
270
+ "leave_unchanged",
271
+ "mark_failed_stale",
272
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
273
+ }
274
+ // ── unknown: insufficient evidence ──
275
+ return buildResult(taskId, statusStr, phaseStr, "unknown", "low", reasons, [
276
+ "leave_unchanged",
277
+ ], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
278
+ }
279
+ // ── Result builders ─────────────────────────────────────────────────
280
+ function buildResult(taskId, status, phase, diagnosis, confidence, reasons, safeActions, evidence, logPaths) {
281
+ return {
282
+ task_id: taskId,
283
+ status,
284
+ phase,
285
+ diagnosis,
286
+ confidence,
287
+ reasons,
288
+ safe_actions: safeActions,
289
+ evidence,
290
+ logs: logPaths
291
+ ? {
292
+ stdout_tail: readTailLines(logPaths.stdoutPath, LOG_TAIL_LINES),
293
+ stderr_tail: readTailLines(logPaths.stderrPath, Math.min(LOG_TAIL_LINES, 20)),
294
+ }
295
+ : null,
296
+ };
297
+ }
298
+ function buildTerminalDiagnosis(taskId, status, phase) {
299
+ return {
300
+ task_id: taskId,
301
+ status,
302
+ phase,
303
+ diagnosis: "terminal",
304
+ confidence: "high",
305
+ reasons: [`status "${status}" is already terminal; no reconciliation needed`],
306
+ safe_actions: ["leave_unchanged"],
307
+ evidence: emptyEvidence(),
308
+ logs: null,
309
+ };
310
+ }
311
+ function buildUnknownDiagnosis(taskId, status, phase, extraReasons) {
312
+ return {
313
+ task_id: taskId,
314
+ status,
315
+ phase,
316
+ diagnosis: "unknown",
317
+ confidence: "low",
318
+ reasons: ["insufficient evidence to diagnose", ...extraReasons],
319
+ safe_actions: ["leave_unchanged"],
320
+ evidence: emptyEvidence(),
321
+ logs: null,
322
+ };
323
+ }
324
+ function emptyEvidence() {
325
+ return {
326
+ heartbeat_age_seconds: null,
327
+ stdout_age_seconds: null,
328
+ stderr_age_seconds: null,
329
+ stdout_size_bytes: null,
330
+ stderr_size_bytes: null,
331
+ child_pid: null,
332
+ child_pid_alive: null,
333
+ child_started_at: null,
334
+ pid_reuse_suspected: false,
335
+ watcher_instance_id: null,
336
+ current_watcher_instance_id: null,
337
+ watcher_owns_task: false,
338
+ watcher_ownership_reason: "no_runtime_record",
339
+ has_result_md: false,
340
+ has_test_log: false,
341
+ has_git_diff: false,
342
+ has_artifact_manifest: false,
343
+ task_started_at: null,
344
+ status_updated_at: null,
345
+ collecting_artifacts_phase: false,
346
+ };
347
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * v1.0.0 Part B: discard_worktree MCP 工具 handler — 移除隔离 worktree 与临时
3
+ * branch,并将最终状态归档到 .patchwarden/worktree-archive/。
4
+ *
5
+ * 参考 src/tools/auditTask.ts 的导出/返回风格。
6
+ */
7
+ export interface DiscardWorktreeInput {
8
+ worktree_id: string;
9
+ repo_path: string;
10
+ }
11
+ export interface DiscardWorktreeOutput {
12
+ worktree_id: string;
13
+ status: "discarded";
14
+ discarded_at: string;
15
+ }
16
+ /**
17
+ * 丢弃指定 worktree:移除 worktree 目录、删除临时 branch、归档状态。
18
+ *
19
+ * @throws PatchWardenError("workspace_path_escape") 当 repo_path 逃逸出 workspaceRoot
20
+ * @throws PatchWardenError("worktree_not_found") 当 worktree 不存在
21
+ * @throws PatchWardenError("invalid_worktree_state") 当 worktree 非 active
22
+ * @throws PatchWardenError("worktree_discard_failed") 当 git worktree remove 失败
23
+ */
24
+ export declare function discardWorktreeTool(input: DiscardWorktreeInput): DiscardWorktreeOutput;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * v1.0.0 Part B: discard_worktree MCP 工具 handler — 移除隔离 worktree 与临时
3
+ * branch,并将最终状态归档到 .patchwarden/worktree-archive/。
4
+ *
5
+ * 参考 src/tools/auditTask.ts 的导出/返回风格。
6
+ */
7
+ import { getConfig } from "../config.js";
8
+ import { guardWorkspacePath } from "../security/pathGuard.js";
9
+ import { discardWorktree } from "../goal/worktreeManager.js";
10
+ /**
11
+ * 丢弃指定 worktree:移除 worktree 目录、删除临时 branch、归档状态。
12
+ *
13
+ * @throws PatchWardenError("workspace_path_escape") 当 repo_path 逃逸出 workspaceRoot
14
+ * @throws PatchWardenError("worktree_not_found") 当 worktree 不存在
15
+ * @throws PatchWardenError("invalid_worktree_state") 当 worktree 非 active
16
+ * @throws PatchWardenError("worktree_discard_failed") 当 git worktree remove 失败
17
+ */
18
+ export function discardWorktreeTool(input) {
19
+ const config = getConfig();
20
+ const repoPathSafe = guardWorkspacePath(input.repo_path, config.workspaceRoot);
21
+ discardWorktree(input.worktree_id, repoPathSafe);
22
+ return {
23
+ worktree_id: input.worktree_id,
24
+ status: "discarded",
25
+ discarded_at: new Date().toISOString(),
26
+ };
27
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * v0.7.1: discover_tools MCP 工具封装。
3
+ *
4
+ * 搜索候选工具,返回压缩摘要和风险等级。
5
+ * 不加入 chatgpt_core,仅 full profile。
6
+ * v0.8.1: 为每个搜索结果生成 discoveryToken(server-side 保存)。
7
+ * v0.9.0: 传入 usageStatsProvider,从 invocation.log 聚合历史成功率用于混合排序。
8
+ */
9
+ import type { ToolDef } from "./registry.js";
10
+ import { discoverTools as searchDiscover, type DiscoverToolsInput } from "./toolSearch.js";
11
+ export declare function discoverTools(input: DiscoverToolsInput, tools: ToolDef[]): ReturnType<typeof searchDiscover>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * v0.7.1: discover_tools MCP 工具封装。
3
+ *
4
+ * 搜索候选工具,返回压缩摘要和风险等级。
5
+ * 不加入 chatgpt_core,仅 full profile。
6
+ * v0.8.1: 为每个搜索结果生成 discoveryToken(server-side 保存)。
7
+ * v0.9.0: 传入 usageStatsProvider,从 invocation.log 聚合历史成功率用于混合排序。
8
+ */
9
+ import { join } from "node:path";
10
+ import { buildToolRegistry } from "./toolRegistry.js";
11
+ import { discoverTools as searchDiscover } from "./toolSearch.js";
12
+ import { issueToken } from "../security/discoveryTokenStore.js";
13
+ import { aggregateUsageStats } from "./toolUsageStats.js";
14
+ import { getConfig } from "../config.js";
15
+ export function discoverTools(input, tools) {
16
+ const registry = buildToolRegistry(tools);
17
+ // v0.9.0: 从 invocation.log 聚合历史成功率,用于混合排序公式
18
+ let usageMap = null;
19
+ try {
20
+ const workspaceRoot = getConfig().workspaceRoot;
21
+ const logsDir = join(workspaceRoot, ".patchwarden", "logs");
22
+ usageMap = aggregateUsageStats(logsDir);
23
+ }
24
+ catch {
25
+ // 配置读取失败或日志目录不存在时,无历史数据(不阻断搜索)
26
+ usageMap = null;
27
+ }
28
+ return searchDiscover(input, registry, (tool) => {
29
+ return issueToken({
30
+ toolName: tool.name,
31
+ risk: tool.risk,
32
+ query: input.query,
33
+ schemaDigest: tool.inputSchemaDigest,
34
+ profile: input.profile ?? "full",
35
+ });
36
+ }, usageMap
37
+ ? (toolName) => usageMap.get(toolName) ?? null
38
+ : undefined);
39
+ }
@@ -0,0 +1,39 @@
1
+ import { type SafeTaskLineage } from "./taskLineage.js";
2
+ export interface SafeEvidencePack {
3
+ evidence_pack_id: string;
4
+ lineage_id: string;
5
+ generated_at: string;
6
+ path: string;
7
+ files: {
8
+ json: string;
9
+ markdown: string;
10
+ };
11
+ lineage: SafeTaskLineage;
12
+ policy: {
13
+ valid: boolean;
14
+ issue_count: number;
15
+ release_readiness: unknown;
16
+ };
17
+ catalog: {
18
+ server_version: string;
19
+ schema_epoch: string;
20
+ tool_profile: string | null;
21
+ tool_count: number | null;
22
+ tool_manifest_sha256: string | null;
23
+ };
24
+ omitted: string[];
25
+ next_action: string;
26
+ bounded: true;
27
+ }
28
+ export declare function exportTaskEvidencePack(input: {
29
+ lineage_id: string;
30
+ max_items?: number;
31
+ }): SafeEvidencePack;
32
+ export declare function listEvidencePacks(options?: {
33
+ max_items?: number;
34
+ }): {
35
+ evidence_packs: SafeEvidencePack[];
36
+ total: number;
37
+ truncated: boolean;
38
+ };
39
+ export declare function readEvidencePack(lineageId: string): SafeEvidencePack | null;