patchwarden 0.6.0 → 0.6.1

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 (81) hide show
  1. package/README.md +3 -3
  2. package/dist/doctor.js +1 -1
  3. package/dist/logging.d.ts +52 -0
  4. package/dist/logging.js +123 -0
  5. package/dist/runner/changeCapture.d.ts +41 -0
  6. package/dist/runner/changeCapture.js +171 -1
  7. package/dist/runner/runTask.js +204 -24
  8. package/dist/smoke-test.js +8 -8
  9. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  10. package/dist/test/unit/android-doctor.test.js +118 -0
  11. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  12. package/dist/test/unit/chinese-path.test.js +91 -0
  13. package/dist/test/unit/command-guard.test.d.ts +1 -0
  14. package/dist/test/unit/command-guard.test.js +160 -0
  15. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  16. package/dist/test/unit/direct-guards.test.js +213 -0
  17. package/dist/test/unit/logging.test.d.ts +1 -0
  18. package/dist/test/unit/logging.test.js +275 -0
  19. package/dist/test/unit/path-guard.test.d.ts +1 -0
  20. package/dist/test/unit/path-guard.test.js +109 -0
  21. package/dist/test/unit/safe-status.test.d.ts +1 -0
  22. package/dist/test/unit/safe-status.test.js +165 -0
  23. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  24. package/dist/test/unit/sensitive-guard.test.js +104 -0
  25. package/dist/test/unit/sync-file.test.d.ts +1 -0
  26. package/dist/test/unit/sync-file.test.js +154 -0
  27. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  28. package/dist/test/unit/watcher-status.test.js +169 -0
  29. package/dist/tools/androidDoctor.d.ts +38 -0
  30. package/dist/tools/androidDoctor.js +391 -0
  31. package/dist/tools/auditTask.js +11 -5
  32. package/dist/tools/getTaskSummary.d.ts +3 -0
  33. package/dist/tools/getTaskSummary.js +15 -1
  34. package/dist/tools/healthCheck.d.ts +5 -0
  35. package/dist/tools/healthCheck.js +21 -0
  36. package/dist/tools/registry.js +53 -0
  37. package/dist/tools/safeStatus.d.ts +19 -0
  38. package/dist/tools/safeStatus.js +72 -0
  39. package/dist/tools/syncFile.d.ts +18 -0
  40. package/dist/tools/syncFile.js +65 -0
  41. package/dist/tools/taskOutputs.d.ts +2 -2
  42. package/dist/tools/toolCatalog.d.ts +2 -2
  43. package/dist/tools/toolCatalog.js +2 -0
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +2 -2
  46. package/dist/watcherStatus.d.ts +1 -0
  47. package/dist/watcherStatus.js +96 -4
  48. package/docs/performance-notes.md +55 -0
  49. package/docs/release-v0.6.1.md +75 -0
  50. package/package.json +3 -2
  51. package/scripts/http-mcp-smoke.js +10 -2
  52. package/scripts/lifecycle-smoke.js +336 -2
  53. package/scripts/mcp-manifest-check.js +30 -7
  54. package/scripts/mcp-smoke.js +11 -8
  55. package/scripts/pack-clean.js +157 -1
  56. package/scripts/unit-tests.js +36 -0
  57. package/src/doctor.ts +1 -1
  58. package/src/logging.ts +152 -0
  59. package/src/runner/changeCapture.ts +212 -1
  60. package/src/runner/runTask.ts +220 -22
  61. package/src/smoke-test.ts +5 -5
  62. package/src/test/unit/android-doctor.test.ts +158 -0
  63. package/src/test/unit/chinese-path.test.ts +106 -0
  64. package/src/test/unit/command-guard.test.ts +221 -0
  65. package/src/test/unit/direct-guards.test.ts +297 -0
  66. package/src/test/unit/logging.test.ts +325 -0
  67. package/src/test/unit/path-guard.test.ts +150 -0
  68. package/src/test/unit/safe-status.test.ts +187 -0
  69. package/src/test/unit/sensitive-guard.test.ts +124 -0
  70. package/src/test/unit/sync-file.test.ts +231 -0
  71. package/src/test/unit/watcher-status.test.ts +190 -0
  72. package/src/tools/androidDoctor.ts +424 -0
  73. package/src/tools/auditTask.ts +11 -5
  74. package/src/tools/getTaskSummary.ts +22 -1
  75. package/src/tools/healthCheck.ts +22 -0
  76. package/src/tools/registry.ts +63 -0
  77. package/src/tools/safeStatus.ts +96 -0
  78. package/src/tools/syncFile.ts +122 -0
  79. package/src/tools/toolCatalog.ts +2 -0
  80. package/src/version.ts +2 -2
  81. package/src/watcherStatus.ts +101 -4
@@ -0,0 +1,72 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve, join } 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 { readTaskRuntime } from "../taskRuntime.js";
7
+ import { readWatcherStatus } from "../watcherStatus.js";
8
+ export function safeStatus(taskId, config) {
9
+ const cfg = config || getConfig();
10
+ const tasksDir = getTasksDir(cfg);
11
+ const taskDir = resolve(tasksDir, taskId);
12
+ const statusFile = join(taskDir, "status.json");
13
+ // Check existence BEFORE guardReadPath so that a non-existent task
14
+ // returns a structured not_found response instead of throwing.
15
+ if (!existsSync(statusFile)) {
16
+ return {
17
+ task_id: taskId,
18
+ status: "not_found",
19
+ phase: null,
20
+ created_at: null,
21
+ started_at: null,
22
+ updated_at: null,
23
+ finished_at: null,
24
+ last_heartbeat_at: null,
25
+ current_command: null,
26
+ verify_status: null,
27
+ artifact_status: null,
28
+ watcher_state: null,
29
+ error_code: null,
30
+ error_summary: null,
31
+ };
32
+ }
33
+ guardReadPath(statusFile, cfg.workspaceRoot, cfg.tasksDir);
34
+ guardSensitivePath(statusFile);
35
+ const raw = readFileSync(statusFile, "utf-8");
36
+ const status = JSON.parse(raw);
37
+ const runtime = readTaskRuntime(taskDir);
38
+ const phase = (runtime.phase || status.phase || "queued");
39
+ const watcher = readWatcherStatus(cfg);
40
+ // Extract short error summary — never the full error text
41
+ let errorCode = null;
42
+ let errorSummary = null;
43
+ if (status.error && typeof status.error === "string") {
44
+ const errorStr = status.error;
45
+ // Keep only the first 200 chars as summary
46
+ errorSummary = errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
47
+ // Try to extract an error code from the status
48
+ if (typeof status.status === "string" && status.status.startsWith("failed")) {
49
+ errorCode = status.status;
50
+ }
51
+ }
52
+ else if (typeof status.status === "string" && status.status.startsWith("failed")) {
53
+ errorCode = status.status;
54
+ errorSummary = `Task ended with status: ${status.status}`;
55
+ }
56
+ return {
57
+ task_id: taskId,
58
+ status: status.status || "not_found",
59
+ phase,
60
+ created_at: typeof status.created_at === "string" ? status.created_at : null,
61
+ started_at: typeof status.started_at === "string" ? status.started_at : null,
62
+ updated_at: typeof status.updated_at === "string" ? status.updated_at : null,
63
+ finished_at: typeof status.finished_at === "string" ? status.finished_at : null,
64
+ last_heartbeat_at: runtime.last_heartbeat_at || (typeof status.last_heartbeat_at === "string" ? status.last_heartbeat_at : null),
65
+ current_command: runtime.current_command ?? (typeof status.current_command === "string" ? status.current_command : null) ?? null,
66
+ verify_status: typeof status.verify_status === "string" ? status.verify_status : null,
67
+ artifact_status: typeof status.artifact_status === "string" ? status.artifact_status : null,
68
+ watcher_state: watcher.status,
69
+ error_code: errorCode,
70
+ error_summary: errorSummary,
71
+ };
72
+ }
@@ -0,0 +1,18 @@
1
+ import { type PatchWardenConfig } from "../config.js";
2
+ export interface SyncFileResult {
3
+ source_path: string;
4
+ target_path: string;
5
+ before_target_sha256: string | null;
6
+ after_target_sha256: string;
7
+ source_sha256: string;
8
+ copied_bytes: number;
9
+ changed: boolean;
10
+ }
11
+ /**
12
+ * Copy a file from source to target within the same session repo.
13
+ * Both source and target must be inside the session's repo_path.
14
+ */
15
+ export declare function syncFile(sessionId: string, sourcePath: string, targetPath: string, options?: {
16
+ expected_source_sha256?: string;
17
+ expected_target_sha256?: string;
18
+ }, config?: PatchWardenConfig): SyncFileResult;
@@ -0,0 +1,65 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { PatchWardenError } from "../errors.js";
4
+ import { getConfig } from "../config.js";
5
+ import { guardDirectPath, guardDirectWritePath } from "../direct/directGuards.js";
6
+ import { guardSensitivePath } from "../security/sensitiveGuard.js";
7
+ import { computeFileSha256 } from "../direct/directPatch.js";
8
+ /**
9
+ * Copy a file from source to target within the same session repo.
10
+ * Both source and target must be inside the session's repo_path.
11
+ */
12
+ export function syncFile(sessionId, sourcePath, targetPath, options, config) {
13
+ const cfg = config || getConfig();
14
+ const sessionsDir = resolve(cfg.workspaceRoot, cfg.directSessionsDir);
15
+ // Load session to get repo_path
16
+ const sessionFile = resolve(sessionsDir, sessionId, "session.json");
17
+ if (!existsSync(sessionFile)) {
18
+ throw new PatchWardenError("direct_session_not_found", `Direct session "${sessionId}" not found.`, "Create a direct session first using direct_start_session.", true, { session_id: sessionId });
19
+ }
20
+ const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
21
+ const repoPath = resolve(session.repo_path);
22
+ const workspaceRoot = cfg.workspaceRoot;
23
+ // Guard source path — must be inside repo
24
+ const resolvedSource = guardDirectPath(sourcePath, repoPath, workspaceRoot);
25
+ guardSensitivePath(resolvedSource);
26
+ if (!existsSync(resolvedSource)) {
27
+ throw new PatchWardenError("source_file_not_found", `Source file does not exist: "${sourcePath}".`, "Ensure the source path is correct.", true, { source_path: sourcePath });
28
+ }
29
+ // Guard target path — must be inside repo, not in blocked dirs
30
+ const resolvedTarget = guardDirectWritePath(targetPath, repoPath, workspaceRoot);
31
+ guardSensitivePath(resolvedTarget);
32
+ // Verify source sha256 if provided
33
+ const sourceSha256 = computeFileSha256(resolvedSource);
34
+ if (options?.expected_source_sha256 && options.expected_source_sha256 !== sourceSha256) {
35
+ throw new PatchWardenError("source_hash_mismatch", `Source file hash mismatch. Expected "${options.expected_source_sha256}" but got "${sourceSha256}".`, "Re-read the source file to get the current sha256.", true, { expected_sha256: options.expected_source_sha256, actual_sha256: sourceSha256 });
36
+ }
37
+ // Get target sha256 before copy
38
+ let beforeTargetSha256 = null;
39
+ if (existsSync(resolvedTarget)) {
40
+ beforeTargetSha256 = computeFileSha256(resolvedTarget);
41
+ // Verify target sha256 if provided
42
+ if (options?.expected_target_sha256 && options.expected_target_sha256 !== beforeTargetSha256) {
43
+ throw new PatchWardenError("target_hash_mismatch", `Target file hash mismatch. Expected "${options.expected_target_sha256}" but got "${beforeTargetSha256}".`, "Re-read the target file to get the current sha256.", true, { expected_sha256: options.expected_target_sha256, actual_sha256: beforeTargetSha256 });
44
+ }
45
+ }
46
+ // Read source content
47
+ const sourceContent = readFileSync(resolvedSource);
48
+ const copiedBytes = sourceContent.length;
49
+ // Create target directory if needed
50
+ mkdirSync(dirname(resolvedTarget), { recursive: true });
51
+ // Write to target
52
+ writeFileSync(resolvedTarget, sourceContent, "utf-8");
53
+ // Compute after hash
54
+ const afterTargetSha256 = computeFileSha256(resolvedTarget);
55
+ const changed = beforeTargetSha256 !== afterTargetSha256;
56
+ return {
57
+ source_path: sourcePath,
58
+ target_path: targetPath,
59
+ before_target_sha256: beforeTargetSha256,
60
+ after_target_sha256: afterTargetSha256,
61
+ source_sha256: sourceSha256,
62
+ copied_bytes: copiedBytes,
63
+ changed,
64
+ };
65
+ }
@@ -14,7 +14,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
14
14
  lines?: number;
15
15
  redact?: boolean;
16
16
  }): {
17
- file: "stdout" | "stderr" | "test" | "verify";
17
+ file: "test" | "stdout" | "stderr" | "verify";
18
18
  lines: number;
19
19
  total_bytes: number;
20
20
  task_id: string;
@@ -37,7 +37,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
37
37
  truncated?: undefined;
38
38
  } | {
39
39
  task_id: string;
40
- file: "stdout" | "stderr" | "test" | "verify";
40
+ file: "test" | "stdout" | "stderr" | "verify";
41
41
  filename: string;
42
42
  content: string;
43
43
  available: boolean;
@@ -12,8 +12,8 @@ export interface ToolCatalogSnapshot {
12
12
  tool_names: string[];
13
13
  tool_manifest_sha256: string;
14
14
  }
15
- export declare const CHATGPT_CORE_TOOL_NAMES: readonly ["health_check", "list_agents", "list_workspace", "read_workspace_file", "save_plan", "create_task", "wait_for_task", "get_task_summary", "get_diff", "get_result", "get_result_json", "get_test_log", "get_task_status", "list_tasks", "cancel_task", "audit_task"];
16
- export declare const CHATGPT_DIRECT_TOOL_NAMES: readonly ["health_check", "list_workspace", "create_direct_session", "search_workspace", "read_workspace_file", "apply_patch", "run_verification", "finalize_direct_session", "audit_session"];
15
+ export declare const CHATGPT_CORE_TOOL_NAMES: readonly ["health_check", "list_agents", "list_workspace", "read_workspace_file", "save_plan", "create_task", "wait_for_task", "get_task_summary", "get_diff", "get_result", "get_result_json", "get_test_log", "get_task_status", "list_tasks", "cancel_task", "audit_task", "safe_status"];
16
+ export declare const CHATGPT_DIRECT_TOOL_NAMES: readonly ["health_check", "list_workspace", "create_direct_session", "search_workspace", "read_workspace_file", "apply_patch", "run_verification", "finalize_direct_session", "audit_session", "sync_file"];
17
17
  export declare function resolveToolProfile(configProfile?: string): ToolProfile;
18
18
  export declare function selectToolsForProfile<T extends CatalogTool>(tools: T[], profile: ToolProfile, enableDirectProfile?: boolean): T[];
19
19
  export declare function buildToolCatalogSnapshot(tools: CatalogTool[], profile: ToolProfile): ToolCatalogSnapshot;
@@ -17,6 +17,7 @@ export const CHATGPT_CORE_TOOL_NAMES = [
17
17
  "list_tasks",
18
18
  "cancel_task",
19
19
  "audit_task",
20
+ "safe_status",
20
21
  ];
21
22
  export const CHATGPT_DIRECT_TOOL_NAMES = [
22
23
  "health_check",
@@ -28,6 +29,7 @@ export const CHATGPT_DIRECT_TOOL_NAMES = [
28
29
  "run_verification",
29
30
  "finalize_direct_session",
30
31
  "audit_session",
32
+ "sync_file",
31
33
  ];
32
34
  let lastSnapshot = null;
33
35
  export function resolveToolProfile(configProfile) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const PATCHWARDEN_VERSION = "0.6.0";
2
- export declare const TOOL_SCHEMA_EPOCH = "2026-06-22-v6";
1
+ export declare const PATCHWARDEN_VERSION = "0.6.1";
2
+ export declare const TOOL_SCHEMA_EPOCH = "2026-06-24-v7";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const PATCHWARDEN_VERSION = "0.6.0";
2
- export const TOOL_SCHEMA_EPOCH = "2026-06-22-v6";
1
+ export const PATCHWARDEN_VERSION = "0.6.1";
2
+ export const TOOL_SCHEMA_EPOCH = "2026-06-24-v7";
@@ -11,6 +11,7 @@ export interface WatcherStatusSnapshot {
11
11
  instance_id: string | null;
12
12
  launcher_pid: number | null;
13
13
  reason: string | null;
14
+ activity: string | null;
14
15
  }
15
16
  export declare function getWatcherHeartbeatPath(config?: PatchWardenConfig): string;
16
17
  export declare function readWatcherStatus(config?: PatchWardenConfig, nowMs?: number): WatcherStatusSnapshot;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { getConfig, getTasksDir } from "./config.js";
4
4
  export function getWatcherHeartbeatPath(config = getConfig()) {
@@ -8,6 +8,22 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
8
8
  const staleAfterSeconds = config.watcherStaleSeconds;
9
9
  const heartbeatPath = getWatcherHeartbeatPath(config);
10
10
  if (!existsSync(heartbeatPath)) {
11
+ // Even if watcher heartbeat is missing, check if a task is actively running
12
+ const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
13
+ if (taskFallback) {
14
+ return {
15
+ status: "healthy",
16
+ available: true,
17
+ stale_after_seconds: staleAfterSeconds,
18
+ last_heartbeat_at: taskFallback.heartbeat_at,
19
+ heartbeat_age_seconds: taskFallback.age_seconds,
20
+ heartbeat_pid: null,
21
+ instance_id: null,
22
+ launcher_pid: null,
23
+ reason: null,
24
+ activity: taskFallback.activity,
25
+ };
26
+ }
11
27
  return {
12
28
  status: "missing",
13
29
  available: false,
@@ -18,6 +34,7 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
18
34
  instance_id: null,
19
35
  launcher_pid: null,
20
36
  reason: "Watcher heartbeat has not been observed. Start or restart the PatchWarden watcher.",
37
+ activity: null,
21
38
  };
22
39
  }
23
40
  try {
@@ -29,16 +46,47 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
29
46
  const ageMs = Math.max(0, nowMs - heartbeatMs);
30
47
  const ageSeconds = Math.round(ageMs / 1000);
31
48
  const healthy = ageMs < staleAfterSeconds * 1000;
49
+ if (healthy) {
50
+ return {
51
+ status: "healthy",
52
+ available: true,
53
+ stale_after_seconds: staleAfterSeconds,
54
+ last_heartbeat_at: String(data.last_heartbeat_at),
55
+ heartbeat_age_seconds: ageSeconds,
56
+ heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
57
+ instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
58
+ launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
59
+ reason: null,
60
+ activity: null,
61
+ };
62
+ }
63
+ // Watcher heartbeat is stale — check if a task is actively running
64
+ const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
65
+ if (taskFallback) {
66
+ return {
67
+ status: "healthy",
68
+ available: true,
69
+ stale_after_seconds: staleAfterSeconds,
70
+ last_heartbeat_at: taskFallback.heartbeat_at,
71
+ heartbeat_age_seconds: taskFallback.age_seconds,
72
+ heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
73
+ instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
74
+ launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
75
+ reason: null,
76
+ activity: taskFallback.activity,
77
+ };
78
+ }
32
79
  return {
33
- status: healthy ? "healthy" : "stale",
34
- available: healthy,
80
+ status: "stale",
81
+ available: false,
35
82
  stale_after_seconds: staleAfterSeconds,
36
83
  last_heartbeat_at: String(data.last_heartbeat_at),
37
84
  heartbeat_age_seconds: ageSeconds,
38
85
  heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
39
86
  instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
40
87
  launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
41
- reason: healthy ? null : "Watcher heartbeat is stale. Restart the PatchWarden watcher.",
88
+ reason: "Watcher heartbeat is stale. Restart the PatchWarden watcher.",
89
+ activity: null,
42
90
  };
43
91
  }
44
92
  catch {
@@ -52,9 +100,53 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
52
100
  instance_id: null,
53
101
  launcher_pid: null,
54
102
  reason: "Watcher heartbeat file is unreadable.",
103
+ activity: null,
55
104
  };
56
105
  }
57
106
  }
107
+ function checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds) {
108
+ const tasksDir = getTasksDir(config);
109
+ if (!existsSync(tasksDir))
110
+ return null;
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(tasksDir, { withFileTypes: true });
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory())
120
+ continue;
121
+ const taskDir = join(tasksDir, entry.name);
122
+ const statusFile = join(taskDir, "status.json");
123
+ const runtimeFile = join(taskDir, "runtime.json");
124
+ if (!existsSync(statusFile) || !existsSync(runtimeFile))
125
+ continue;
126
+ try {
127
+ const status = JSON.parse(readFileSync(statusFile, "utf-8"));
128
+ if (status.status !== "running")
129
+ continue;
130
+ const runtime = JSON.parse(readFileSync(runtimeFile, "utf-8"));
131
+ const heartbeatAt = String(runtime.last_heartbeat_at || "");
132
+ const heartbeatMs = Date.parse(heartbeatAt);
133
+ if (!Number.isFinite(heartbeatMs))
134
+ continue;
135
+ const ageMs = Math.max(0, nowMs - heartbeatMs);
136
+ if (ageMs < staleAfterSeconds * 1000) {
137
+ return {
138
+ heartbeat_at: heartbeatAt,
139
+ age_seconds: Math.round(ageMs / 1000),
140
+ activity: `watcher busy executing task ${entry.name}`,
141
+ };
142
+ }
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ }
148
+ return null;
149
+ }
58
150
  export function derivePendingReason(task, watcher) {
59
151
  if (task.status === "pending") {
60
152
  if (watcher.status === "stale")
@@ -0,0 +1,55 @@
1
+ # PatchWarden Performance Notes
2
+
3
+ ## Current State (v0.6.1)
4
+
5
+ ### Identified Optimization Opportunities
6
+
7
+ The following performance optimizations have been identified for future implementation. They are documented here rather than implemented in v0.6.1 to avoid introducing risk to the core task execution path.
8
+
9
+ ### 1. Async Git Operations in changeCapture.ts
10
+
11
+ **Current**: `runGit()` uses `spawnSync()` which blocks the event loop during git operations.
12
+
13
+ **Proposed**: Replace with `execFile()` (async) and use `Promise.all()` for independent git queries in `captureRepoSnapshot()`.
14
+
15
+ **Risk**: Medium — changes the core change capture path. Requires careful testing of all snapshot/diff/scope scenarios.
16
+
17
+ **Expected Benefit**: ~200-500ms improvement per task for repos with many files, as multiple git queries can run concurrently.
18
+
19
+ ### 2. Streaming File Hash
20
+
21
+ **Current**: `computeFileSha256()` reads the entire file into memory with `readFileSync()`.
22
+
23
+ **Proposed**: Use `createReadStream()` + `crypto.createHash()` for streaming hash computation.
24
+
25
+ **Risk**: Low — isolated to hash computation, no semantic change.
26
+
27
+ **Expected Benefit**: Reduces peak memory usage for large files (e.g., release artifacts > 5MB).
28
+
29
+ ### 3. Workspace Snapshot Caching
30
+
31
+ **Current**: `walkWorkspace()` traverses the entire workspace directory tree on every snapshot.
32
+
33
+ **Proposed**: Cache the directory listing and invalidate on file modification events.
34
+
35
+ **Risk**: High — requires file system watchers and cache invalidation logic. Could introduce stale data bugs.
36
+
37
+ **Expected Benefit**: ~100-1000ms improvement for large workspaces with many repos.
38
+
39
+ ### 4. Parallel Task Status Reads
40
+
41
+ **Current**: `listTasks()` reads each task's `status.json` sequentially.
42
+
43
+ **Proposed**: Use `Promise.all()` to read multiple task status files concurrently.
44
+
45
+ **Risk**: Low — read-only operation, no semantic change.
46
+
47
+ **Expected Benefit**: ~50-200ms improvement when listing 10+ tasks.
48
+
49
+ ## Decision for v0.6.1
50
+
51
+ All four optimizations are deferred to a future release. The current synchronous implementation is correct and well-tested. Changing the core paths would require extensive regression testing that is beyond the scope of this stability release.
52
+
53
+ ## Monitoring
54
+
55
+ The structured logging module (`src/logging.ts`) added in v0.6.1 provides the infrastructure to measure tool call durations (`duration_ms` in audit logs). Once deployed, real-world timing data can be collected to prioritize which optimization to implement first.
@@ -0,0 +1,75 @@
1
+ # PatchWarden v0.6.1 Release Notes
2
+
3
+ **Release Date**: 2026-06-25
4
+
5
+ ## Overview
6
+
7
+ PatchWarden v0.6.1 is a stability and observability release. It addresses watcher heartbeat false positives, adds Chinese path reliability, introduces two new tools (`safe_status` and `sync_file`), and adds comprehensive security guard unit tests.
8
+
9
+ ## New Features
10
+
11
+ ### safe_status Tool
12
+
13
+ A minimal task lifecycle status tool that returns task state without exposing diff, log content, or file contents. Useful when upper-layer security policies block content-bearing tools.
14
+
15
+ ```json
16
+ {
17
+ "task_id": "task-001",
18
+ "status": "running",
19
+ "phase": "executing_agent",
20
+ "last_heartbeat_at": "2026-06-24T10:00:12Z",
21
+ "current_command": "codex exec",
22
+ "watcher_state": "healthy"
23
+ }
24
+ ```
25
+
26
+ ### sync_file Tool
27
+
28
+ Copy a file from source to target within a Direct session repo. Both paths must be inside the session's `repo_path`. Supports optional sha256 verification for both source and target.
29
+
30
+ ### Android Build Doctor
31
+
32
+ When a managed project contains an `android_app` directory, the health check now includes Android build environment diagnostics: Java version, JAVA_HOME, ANDROID_HOME, SDK platform, build-tools, Gradle wrapper, and APK output path.
33
+
34
+ ## Bug Fixes
35
+
36
+ - **Watcher stale false positive**: Long-running tasks no longer cause the watcher to be incorrectly reported as stale. The watcher status now falls back to checking running task heartbeats.
37
+ - **Chinese path encoding**: All file I/O operations verified to use UTF-8 encoding. Health check now reports `path_encoding` status.
38
+ - **Tracked external dirty detection**: `captureRepoSnapshot` now correctly identifies tracked-but-modified external files via `dirty_paths` field parsed from `git status --porcelain`. The regex now includes `R` (rename) status, fixing dead code that previously never executed for renamed files.
39
+ - **Release zip path separators**: The packaging script (`pack-clean.js`) now creates zip archives with POSIX `/` path separators instead of Windows backslashes. Linux `unzip` no longer reports `warning: appears to use backslashes as path separators`.
40
+
41
+ ## Security
42
+
43
+ - 136 unit tests covering all security guards (path guard, sensitive guard, command guard, direct guards)
44
+ - Tests use Node's built-in `node:test` — zero new dependencies
45
+
46
+ ## Observability
47
+
48
+ - Structured JSON logging to stderr (never stdout, to avoid polluting MCP JSON-RPC)
49
+ - Tool call audit logs with duration tracking
50
+ - Global `unhandledRejection` and `uncaughtException` handlers
51
+
52
+ ## Tool Profile Changes
53
+
54
+ | Profile | v0.6.0 | v0.6.1 |
55
+ |---------|--------|--------|
56
+ | full | 28 | 30 |
57
+ | chatgpt_core | 16 | 17 |
58
+ | chatgpt_direct | 9 | 10 |
59
+
60
+ ## Migration
61
+
62
+ No breaking changes. All new fields are optional. New tools are additive and do not affect existing tools.
63
+
64
+ ## Verification
65
+
66
+ - TypeScript compilation: PASS
67
+ - Unit tests: 136 pass, 0 fail, 1 skipped (Windows symlink)
68
+ - Smoke tests: 139 pass, 0 fail
69
+ - Lifecycle tests: 22 pass, 0 fail (includes tracked external file rename regression test)
70
+ - Doctor CI: PASS (81 OK, 0 WARN, 0 FAIL on the release check host)
71
+ - Tool manifest check: PASS
72
+ - Package manifest check: PASS (260 files)
73
+ - Brand check: PASS (128 tracked files)
74
+ - MCP HTTP tests: 13 pass, 0 fail
75
+ - Release zip: POSIX path separators verified (261 entries, no backslashes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchwarden",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "A secure local MCP bridge: clients save plans, local agents execute tasks, and results are returned through workspace-scoped files.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,7 +34,8 @@
34
34
  "watch": "node dist/runner/watch.js",
35
35
  "doctor": "node dist/doctor.js",
36
36
  "doctor:ci": "node dist/doctor.js --allow-default-config",
37
- "test": "node dist/smoke-test.js && node scripts/lifecycle-smoke.js && node scripts/doctor-smoke.js && node scripts/tunnel-supervisor-smoke.js && node scripts/watcher-supervisor-smoke.js && node scripts/control-smoke.js && node scripts/mcp-manifest-check.js && node scripts/brand-check.js",
37
+ "test:unit": "node scripts/unit-tests.js",
38
+ "test": "node dist/smoke-test.js && node scripts/unit-tests.js && node scripts/lifecycle-smoke.js && node scripts/doctor-smoke.js && node scripts/tunnel-supervisor-smoke.js && node scripts/watcher-supervisor-smoke.js && node scripts/control-smoke.js && node scripts/mcp-manifest-check.js && node scripts/brand-check.js",
38
39
  "test:lifecycle": "npm run build && node scripts/lifecycle-smoke.js",
39
40
  "test:doctor": "npm run build && node scripts/doctor-smoke.js",
40
41
  "test:tunnel-supervisor": "npm run build && node scripts/tunnel-supervisor-smoke.js",
@@ -139,7 +139,7 @@ try {
139
139
  serverStderr += chunk.toString();
140
140
  });
141
141
 
142
- await sleep(1000);
142
+ await sleep(3000);
143
143
  if (serverProcess.exitCode !== null) {
144
144
  throw new Error(`HTTP server exited early: ${serverStderr}`);
145
145
  }
@@ -274,7 +274,15 @@ try {
274
274
  stdio: ["ignore", "ignore", "pipe"],
275
275
  });
276
276
  serverProcess.stderr.on("data", (chunk) => { serverStderr += chunk.toString(); });
277
- await sleep(1000);
277
+ await sleep(3000);
278
+ // Wait for server to be ready
279
+ for (let i = 0; i < 10; i++) {
280
+ try {
281
+ const r = await fetch(`${mcpUrl}/healthz`);
282
+ if (r.status === 200) break;
283
+ } catch {}
284
+ await sleep(500);
285
+ }
278
286
 
279
287
  await test("token: no token returns 401", async () => {
280
288
  try {