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,391 @@
1
+ /**
2
+ * PatchWarden Android Doctor — read-only Android build environment diagnostics.
3
+ *
4
+ * Inspects a repository for an `android_app` directory and reports whether the
5
+ * local environment can build an APK. The module is strictly read-only: it never
6
+ * auto-downloads the Android SDK, never modifies system environment variables,
7
+ * and never installs global dependencies. It only runs `java -version` (read-only)
8
+ * and inspects files/directories/env vars that already exist.
9
+ */
10
+ import { execSync } from "node:child_process";
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ // ── Constants ──────────────────────────────────────────────────────
14
+ /**
15
+ * Required explanatory sentence emitted whenever the Android SDK is missing.
16
+ * Callers rely on this exact wording to detect the "SDK missing" condition.
17
+ */
18
+ const SDK_MISSING_REASON = "Android project exists, APK not built because Android SDK is missing.";
19
+ // ── Helpers ────────────────────────────────────────────────────────
20
+ /**
21
+ * Run a shell command and return its trimmed combined stdout/stderr output.
22
+ * Returns an empty string when the command is missing or fails so callers can
23
+ * treat absence gracefully. A timeout guards against hanging processes.
24
+ */
25
+ function runCmd(cmdStr) {
26
+ try {
27
+ return execSync(cmdStr, {
28
+ encoding: "utf-8",
29
+ timeout: 8000,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }).trim();
32
+ }
33
+ catch {
34
+ return "";
35
+ }
36
+ }
37
+ /** True when `path` exists and is a directory. Never throws. */
38
+ function isDirectory(path) {
39
+ try {
40
+ return existsSync(path) && statSync(path).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** Extract the quoted version token from `java -version` output. */
47
+ function parseJavaVersion(output) {
48
+ const match = output.match(/version "([^"]+)"/);
49
+ return match ? match[1] : null;
50
+ }
51
+ /**
52
+ * Convert a Java version string to its major version number.
53
+ * "17.0.1" -> 17, "21" -> 21, "1.8.0_292" -> 8 (legacy 1.x scheme).
54
+ */
55
+ function parseJavaMajor(version) {
56
+ const match = version.match(/^(\d+)(?:\.(\d+))?/);
57
+ if (!match)
58
+ return null;
59
+ const major = parseInt(match[1], 10);
60
+ if (major === 1 && match[2])
61
+ return parseInt(match[2], 10);
62
+ return major;
63
+ }
64
+ /** Safely read a UTF-8 file, returning null on any error. */
65
+ function readTextFile(path) {
66
+ try {
67
+ return readFileSync(path, "utf-8");
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ // ── Public API ─────────────────────────────────────────────────────
74
+ /**
75
+ * Diagnose the Android build readiness for the repository at `repoPath`.
76
+ *
77
+ * - If `<repoPath>/android_app` does not exist, returns a skip result.
78
+ * - Otherwise runs 10 read-only checks and returns a structured report with an
79
+ * overall status: "fail" if any check failed, "warn" if any warned, else "ok".
80
+ *
81
+ * This function performs no mutations: it does not download the SDK, change env
82
+ * vars, or install dependencies.
83
+ */
84
+ export function diagnoseAndroidBuild(repoPath) {
85
+ const androidAppPath = join(repoPath, "android_app");
86
+ if (!isDirectory(androidAppPath)) {
87
+ return { status: "skip", reason: "No android_app directory found" };
88
+ }
89
+ const checks = [];
90
+ const checkedAt = new Date().toISOString();
91
+ // Resolve the Android SDK location. ANDROID_HOME is preferred; ANDROID_SDK_ROOT
92
+ // is a legacy fallback that some tools still honor.
93
+ const androidHomeEnv = process.env.ANDROID_HOME || "";
94
+ const androidSdkRootEnv = process.env.ANDROID_SDK_ROOT || "";
95
+ const sdkRoot = androidHomeEnv || androidSdkRootEnv;
96
+ const sdkExists = sdkRoot !== "" && isDirectory(sdkRoot);
97
+ // 1. java -version ------------------------------------------------------
98
+ // `java -version` writes to stderr; redirect with 2>&1 so execSync captures it.
99
+ const javaOutput = runCmd("java -version 2>&1");
100
+ const javaVersion = parseJavaVersion(javaOutput);
101
+ if (javaVersion) {
102
+ const major = parseJavaMajor(javaVersion);
103
+ if (major !== null && major < 17) {
104
+ checks.push({
105
+ check: "java -version",
106
+ status: "warn",
107
+ reason: `Java ${javaVersion} found. Modern Android Gradle Plugin (8.x) requires JDK 17+.`,
108
+ suggested_fix: "Install JDK 17 or newer and place it first on PATH (or point JAVA_HOME at it).",
109
+ });
110
+ }
111
+ else {
112
+ checks.push({
113
+ check: "java -version",
114
+ status: "ok",
115
+ reason: `Java ${javaVersion} is available on PATH.`,
116
+ suggested_fix: "",
117
+ });
118
+ }
119
+ }
120
+ else {
121
+ checks.push({
122
+ check: "java -version",
123
+ status: "fail",
124
+ reason: "java command not found or did not report a version.",
125
+ suggested_fix: "Install a JDK (17+ recommended for modern Android) and add its bin directory to PATH.",
126
+ });
127
+ }
128
+ // 2. JAVA_HOME ----------------------------------------------------------
129
+ const javaHome = process.env.JAVA_HOME || "";
130
+ if (javaHome && isDirectory(javaHome)) {
131
+ checks.push({
132
+ check: "JAVA_HOME",
133
+ status: "ok",
134
+ reason: `JAVA_HOME is set to ${javaHome}.`,
135
+ suggested_fix: "",
136
+ });
137
+ }
138
+ else if (javaHome) {
139
+ checks.push({
140
+ check: "JAVA_HOME",
141
+ status: "warn",
142
+ reason: `JAVA_HOME is set to "${javaHome}" but that directory does not exist.`,
143
+ suggested_fix: "Point JAVA_HOME at a valid JDK installation directory.",
144
+ });
145
+ }
146
+ else {
147
+ checks.push({
148
+ check: "JAVA_HOME",
149
+ status: "warn",
150
+ reason: "JAVA_HOME is not set. Gradle may be unable to locate the JDK.",
151
+ suggested_fix: "Set JAVA_HOME to your JDK installation (e.g. C:\\Program Files\\Java\\jdk-17).",
152
+ });
153
+ }
154
+ // 3. ANDROID_HOME -------------------------------------------------------
155
+ if (!androidHomeEnv) {
156
+ if (!sdkExists) {
157
+ checks.push({
158
+ check: "ANDROID_HOME",
159
+ status: "fail",
160
+ reason: `ANDROID_HOME is not set. ${SDK_MISSING_REASON}`,
161
+ suggested_fix: "Install the Android SDK and set ANDROID_HOME to its root directory " +
162
+ "(e.g. C:\\Users\\<you>\\AppData\\Local\\Android\\Sdk).",
163
+ });
164
+ }
165
+ else {
166
+ checks.push({
167
+ check: "ANDROID_HOME",
168
+ status: "warn",
169
+ reason: "ANDROID_HOME is not set. ANDROID_SDK_ROOT points at an SDK, but ANDROID_HOME is the preferred variable.",
170
+ suggested_fix: "Set ANDROID_HOME to the same value as ANDROID_SDK_ROOT.",
171
+ });
172
+ }
173
+ }
174
+ else if (!isDirectory(androidHomeEnv)) {
175
+ checks.push({
176
+ check: "ANDROID_HOME",
177
+ status: "fail",
178
+ reason: `ANDROID_HOME is set to "${androidHomeEnv}" but that directory does not exist.` +
179
+ (sdkExists ? "" : ` ${SDK_MISSING_REASON}`),
180
+ suggested_fix: "Install the Android SDK at the ANDROID_HOME path, or correct the variable to point at an existing SDK.",
181
+ });
182
+ }
183
+ else {
184
+ checks.push({
185
+ check: "ANDROID_HOME",
186
+ status: "ok",
187
+ reason: `ANDROID_HOME is set to ${androidHomeEnv}.`,
188
+ suggested_fix: "",
189
+ });
190
+ }
191
+ // 4. ANDROID_SDK_ROOT ---------------------------------------------------
192
+ if (androidSdkRootEnv && isDirectory(androidSdkRootEnv)) {
193
+ checks.push({
194
+ check: "ANDROID_SDK_ROOT",
195
+ status: "ok",
196
+ reason: `ANDROID_SDK_ROOT is set to ${androidSdkRootEnv}.`,
197
+ suggested_fix: "",
198
+ });
199
+ }
200
+ else if (androidSdkRootEnv) {
201
+ checks.push({
202
+ check: "ANDROID_SDK_ROOT",
203
+ status: "warn",
204
+ reason: `ANDROID_SDK_ROOT is set to "${androidSdkRootEnv}" but that directory does not exist.`,
205
+ suggested_fix: "Point ANDROID_SDK_ROOT at a valid Android SDK directory.",
206
+ });
207
+ }
208
+ else {
209
+ checks.push({
210
+ check: "ANDROID_SDK_ROOT",
211
+ status: "warn",
212
+ reason: "ANDROID_SDK_ROOT is not set. Some tools fall back to ANDROID_HOME, but setting both is recommended.",
213
+ suggested_fix: "Set ANDROID_SDK_ROOT to the same value as ANDROID_HOME.",
214
+ });
215
+ }
216
+ // 5. Android SDK platform ----------------------------------------------
217
+ if (!sdkExists) {
218
+ checks.push({
219
+ check: "Android SDK platform",
220
+ status: "skip",
221
+ reason: `Skipped: Android SDK not available. ${SDK_MISSING_REASON}`,
222
+ suggested_fix: "Install the Android SDK and a platform via sdkmanager.",
223
+ });
224
+ }
225
+ else {
226
+ const platformsDir = join(sdkRoot, "platforms");
227
+ if (isDirectory(platformsDir)) {
228
+ checks.push({
229
+ check: "Android SDK platform",
230
+ status: "ok",
231
+ reason: `Android SDK platforms directory found at ${platformsDir}.`,
232
+ suggested_fix: "",
233
+ });
234
+ }
235
+ else {
236
+ checks.push({
237
+ check: "Android SDK platform",
238
+ status: "warn",
239
+ reason: `No platforms directory found at ${platformsDir}. No Android platform is installed.`,
240
+ suggested_fix: 'Run: sdkmanager "platforms;android-34"',
241
+ });
242
+ }
243
+ }
244
+ // 6. Android build-tools ------------------------------------------------
245
+ if (!sdkExists) {
246
+ checks.push({
247
+ check: "Android build-tools",
248
+ status: "skip",
249
+ reason: `Skipped: Android SDK not available. ${SDK_MISSING_REASON}`,
250
+ suggested_fix: "Install the Android SDK and build-tools via sdkmanager.",
251
+ });
252
+ }
253
+ else {
254
+ const buildToolsDir = join(sdkRoot, "build-tools");
255
+ if (isDirectory(buildToolsDir)) {
256
+ checks.push({
257
+ check: "Android build-tools",
258
+ status: "ok",
259
+ reason: `Android build-tools directory found at ${buildToolsDir}.`,
260
+ suggested_fix: "",
261
+ });
262
+ }
263
+ else {
264
+ checks.push({
265
+ check: "Android build-tools",
266
+ status: "warn",
267
+ reason: `No build-tools directory found at ${buildToolsDir}.`,
268
+ suggested_fix: 'Run: sdkmanager "build-tools;34.0.0"',
269
+ });
270
+ }
271
+ }
272
+ // 7. Gradle wrapper -----------------------------------------------------
273
+ const gradlewUnix = join(androidAppPath, "gradlew");
274
+ const gradlewWin = join(androidAppPath, "gradlew.bat");
275
+ const wrapperProps = join(androidAppPath, "gradle", "wrapper", "gradle-wrapper.properties");
276
+ let gradleVersion = null;
277
+ const propsContent = readTextFile(wrapperProps);
278
+ if (propsContent) {
279
+ const match = propsContent.match(/gradle-([\d.]+)-/);
280
+ gradleVersion = match ? match[1] : null;
281
+ }
282
+ if (existsSync(gradlewUnix) || existsSync(gradlewWin)) {
283
+ checks.push({
284
+ check: "Gradle wrapper",
285
+ status: "ok",
286
+ reason: gradleVersion
287
+ ? `Gradle wrapper found in android_app (Gradle ${gradleVersion}).`
288
+ : "Gradle wrapper found in android_app.",
289
+ suggested_fix: "",
290
+ });
291
+ }
292
+ else {
293
+ checks.push({
294
+ check: "Gradle wrapper",
295
+ status: "warn",
296
+ reason: "No gradlew or gradlew.bat found in android_app.",
297
+ suggested_fix: "Generate the wrapper inside android_app with: gradle wrapper (or copy gradlew/gradlew.bat/gradle-wrapper.jar from an existing project).",
298
+ });
299
+ }
300
+ // 8. settings.gradle.kts -----------------------------------------------
301
+ const settingsGradleKts = join(androidAppPath, "settings.gradle.kts");
302
+ const settingsGradleGroovy = join(androidAppPath, "settings.gradle");
303
+ if (existsSync(settingsGradleKts)) {
304
+ checks.push({
305
+ check: "android_app/settings.gradle.kts",
306
+ status: "ok",
307
+ reason: "settings.gradle.kts found in android_app.",
308
+ suggested_fix: "",
309
+ });
310
+ }
311
+ else if (existsSync(settingsGradleGroovy)) {
312
+ checks.push({
313
+ check: "android_app/settings.gradle.kts",
314
+ status: "warn",
315
+ reason: "settings.gradle.kts not found, but settings.gradle (Groovy DSL) is present.",
316
+ suggested_fix: "Kotlin DSL is preferred; consider migrating settings.gradle to settings.gradle.kts.",
317
+ });
318
+ }
319
+ else {
320
+ checks.push({
321
+ check: "android_app/settings.gradle.kts",
322
+ status: "warn",
323
+ reason: "settings.gradle.kts not found in android_app.",
324
+ suggested_fix: "Create android_app/settings.gradle.kts declaring the project modules.",
325
+ });
326
+ }
327
+ // 9. app/build.gradle.kts ----------------------------------------------
328
+ const appBuildGradleKts = join(androidAppPath, "app", "build.gradle.kts");
329
+ const appBuildGradleGroovy = join(androidAppPath, "app", "build.gradle");
330
+ if (existsSync(appBuildGradleKts)) {
331
+ checks.push({
332
+ check: "android_app/app/build.gradle.kts",
333
+ status: "ok",
334
+ reason: "app/build.gradle.kts found.",
335
+ suggested_fix: "",
336
+ });
337
+ }
338
+ else if (existsSync(appBuildGradleGroovy)) {
339
+ checks.push({
340
+ check: "android_app/app/build.gradle.kts",
341
+ status: "warn",
342
+ reason: "app/build.gradle.kts not found, but app/build.gradle (Groovy DSL) is present.",
343
+ suggested_fix: "Kotlin DSL is preferred; consider migrating app/build.gradle to app/build.gradle.kts.",
344
+ });
345
+ }
346
+ else {
347
+ checks.push({
348
+ check: "android_app/app/build.gradle.kts",
349
+ status: "warn",
350
+ reason: "app/build.gradle.kts not found in android_app/app.",
351
+ suggested_fix: "Create android_app/app/build.gradle.kts with the Android application plugin and build config.",
352
+ });
353
+ }
354
+ // 10. APK output path ---------------------------------------------------
355
+ const apkDir = join(androidAppPath, "app", "build", "outputs", "apk");
356
+ if (!sdkExists) {
357
+ checks.push({
358
+ check: "APK output path",
359
+ status: "skip",
360
+ reason: SDK_MISSING_REASON,
361
+ suggested_fix: "Install the Android SDK, then build with: cd android_app && ./gradlew assembleDebug",
362
+ });
363
+ }
364
+ else if (isDirectory(apkDir)) {
365
+ checks.push({
366
+ check: "APK output path",
367
+ status: "ok",
368
+ reason: `APK output directory found at ${apkDir}.`,
369
+ suggested_fix: "",
370
+ });
371
+ }
372
+ else {
373
+ checks.push({
374
+ check: "APK output path",
375
+ status: "warn",
376
+ reason: "APK output directory not found. The project has not been built yet.",
377
+ suggested_fix: "Build the APK: cd android_app && ./gradlew assembleDebug",
378
+ });
379
+ }
380
+ // Overall status: fail dominates warn dominates ok.
381
+ const hasFail = checks.some((c) => c.status === "fail");
382
+ const hasWarn = checks.some((c) => c.status === "warn");
383
+ const overall = hasFail ? "fail" : hasWarn ? "warn" : "ok";
384
+ return {
385
+ status: overall,
386
+ repo_path: repoPath,
387
+ android_app_path: androidAppPath,
388
+ checks,
389
+ checked_at: checkedAt,
390
+ };
391
+ }
@@ -115,13 +115,19 @@ export function auditTask(taskId) {
115
115
  checks.push({ name: "verify_status", result: "fail", detail: "verify.json is invalid JSON." });
116
116
  }
117
117
  }
118
- const outOfScope = Array.isArray(statusData.out_of_scope_changes) ? statusData.out_of_scope_changes : [];
118
+ // Phase 4: Use new_out_of_scope_changes (task-caused) instead of out_of_scope_changes (all)
119
+ // Pre-existing external dirty files that didn't change during the task should NOT fail audit.
120
+ const newOutOfScope = Array.isArray(statusData.new_out_of_scope_changes)
121
+ ? statusData.new_out_of_scope_changes
122
+ : Array.isArray(statusData.out_of_scope_changes)
123
+ ? statusData.out_of_scope_changes
124
+ : [];
119
125
  checks.push({
120
126
  name: "scope_changes",
121
- result: outOfScope.length > 0 ? "fail" : "pass",
122
- detail: outOfScope.length > 0
123
- ? `${outOfScope.length} out-of-scope change(s) detected.`
124
- : "No out-of-scope changes recorded.",
127
+ result: newOutOfScope.length > 0 ? "fail" : "pass",
128
+ detail: newOutOfScope.length > 0
129
+ ? `${newOutOfScope.length} new out-of-scope change(s) detected during task execution.`
130
+ : "No new out-of-scope changes recorded.",
125
131
  });
126
132
  const changedFilesFile = join(taskDir, "changed-files.json");
127
133
  if (existsSync(changedFilesFile)) {
@@ -84,6 +84,9 @@ export interface CompactTaskSummaryOutput {
84
84
  max_items: number;
85
85
  truncated: boolean;
86
86
  };
87
+ release_artifacts_count: number;
88
+ release_artifact_paths: string[];
89
+ artifact_status: string | null;
87
90
  verification_summary: TaskSummaryOutput["verification_summary"];
88
91
  summary: string;
89
92
  warnings: string[];
@@ -23,7 +23,12 @@ export function getTaskSummary(taskId, options = {}) {
23
23
  const result = resultRead.data;
24
24
  const verify = verifyRead.data;
25
25
  const terminal = TERMINAL_STATUSES.has(String(status.status));
26
- const outOfScope = asArray(result.out_of_scope_changes ?? status.out_of_scope_changes);
26
+ // Phase 4: Use new_out_of_scope_changes (task-caused) for acceptance status.
27
+ // Pre-existing external dirty files that didn't change should NOT fail acceptance.
28
+ const outOfScope = asArray(result.new_out_of_scope_changes
29
+ ?? status.new_out_of_scope_changes
30
+ ?? result.out_of_scope_changes
31
+ ?? status.out_of_scope_changes);
27
32
  const verifyStatus = String(verify.status ?? result.verify_status ?? result.verify?.status ?? status.verify_status ?? "not_available");
28
33
  const errors = [status.error, ...asArray(result.errors), ...asArray(result.known_issues)]
29
34
  .filter((value) => typeof value === "string" && value.trim() !== "");
@@ -183,6 +188,12 @@ function buildCompactSummary(output, maxItems) {
183
188
  ];
184
189
  const groups = Object.fromEntries(groupNames.map((name) => [name, asArray(hygiene[name]).slice(0, maxItems)]));
185
190
  const truncated = groupNames.some((name) => asArray(hygiene[name]).length > maxItems);
191
+ // Phase 6: Read artifact_manifest.json for release artifact info
192
+ const taskDir = resolve(getTasksDir(getConfig()), String(output.task_id));
193
+ const manifestRead = tryReadJson(join(taskDir, "artifact_manifest.json"));
194
+ const manifest = asRecord(manifestRead.data);
195
+ const releaseArtifacts = asArray(manifest.artifacts);
196
+ const releaseArtifactPaths = releaseArtifacts.map((a) => String(a.path || "")).slice(0, maxItems);
186
197
  const compact = {
187
198
  view: "compact",
188
199
  task_id: String(output.task_id),
@@ -199,6 +210,9 @@ function buildCompactSummary(output, maxItems) {
199
210
  max_items: maxItems,
200
211
  truncated,
201
212
  },
213
+ release_artifacts_count: releaseArtifacts.length,
214
+ release_artifact_paths: releaseArtifactPaths,
215
+ artifact_status: String(output.artifact_status || manifest.status || "collected"),
202
216
  verification_summary: output.verification_summary,
203
217
  summary: String(output.summary).slice(0, 1000),
204
218
  warnings: asArray(output.warnings).slice(0, maxItems),
@@ -31,6 +31,10 @@ export declare function healthCheck(catalog?: ToolCatalogSnapshot, input?: Healt
31
31
  uptime_seconds: number;
32
32
  checked_at: string;
33
33
  };
34
+ path_encoding: {
35
+ encoding: string;
36
+ warnings: string[];
37
+ };
34
38
  watcher: {
35
39
  supervisor: Record<string, unknown>;
36
40
  status: import("../watcherStatus.js").WatcherState;
@@ -42,6 +46,7 @@ export declare function healthCheck(catalog?: ToolCatalogSnapshot, input?: Healt
42
46
  instance_id: string | null;
43
47
  launcher_pid: number | null;
44
48
  reason: string | null;
49
+ activity: string | null;
45
50
  };
46
51
  workspace_root: {
47
52
  available: boolean;
@@ -111,6 +111,7 @@ export function healthCheck(catalog, input = {}) {
111
111
  uptime_seconds: Math.round((Date.now() - SERVER_STARTED_AT) / 1000),
112
112
  checked_at: new Date().toISOString(),
113
113
  },
114
+ path_encoding: checkPathEncoding(),
114
115
  watcher,
115
116
  workspace_root: workspace,
116
117
  tasks_dir: tasks,
@@ -245,3 +246,23 @@ function readTunnelStatus() {
245
246
  function safeText(value, maxLength) {
246
247
  return typeof value === "string" ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength) : "";
247
248
  }
249
+ function checkPathEncoding() {
250
+ const warnings = [];
251
+ // Node.js always uses UTF-8 for fs operations, but on Windows the console
252
+ // codepage can cause display issues. We check if the environment is likely
253
+ // to cause problems with non-ASCII paths.
254
+ if (process.platform === "win32") {
255
+ // Check if the system locale uses a codepage that might not support UTF-8
256
+ const lang = process.env.LANG || process.env.LC_ALL || "";
257
+ const chcp = process.env.PATCHWARDEN_CHCP || "";
258
+ if (chcp && !chcp.includes("65001") && !chcp.includes("utf")) {
259
+ warnings.push(`Windows codepage "${chcp}" may cause display issues with non-ASCII paths. Consider setting codepage to 65001 (UTF-8).`);
260
+ }
261
+ }
262
+ // Verify that Node.js fs operations use UTF-8 by default
263
+ // This is always true in Node.js >= 18
264
+ return {
265
+ encoding: "utf-8",
266
+ warnings,
267
+ };
268
+ }
@@ -23,6 +23,8 @@ import { getTaskSummary } from "../tools/getTaskSummary.js";
23
23
  import { waitForTask } from "../tools/waitForTask.js";
24
24
  import { errorPayload, PatchWardenError } from "../errors.js";
25
25
  import { auditTask } from "../tools/auditTask.js";
26
+ import { safeStatus } from "../tools/safeStatus.js";
27
+ import { logger } from "../logging.js";
26
28
  import { runTask } from "../runner/runTask.js";
27
29
  import { createDirectSession } from "../tools/createDirectSession.js";
28
30
  import { searchWorkspace } from "../tools/searchWorkspace.js";
@@ -30,6 +32,7 @@ import { applyPatch } from "../tools/applyPatch.js";
30
32
  import { runVerification } from "../tools/runVerification.js";
31
33
  import { finalizeDirectSession } from "../tools/finalizeDirectSession.js";
32
34
  import { auditSession } from "../tools/auditSession.js";
35
+ import { syncFile } from "../tools/syncFile.js";
33
36
  import { TASK_TEMPLATE_NAMES } from "./taskTemplates.js";
34
37
  import { buildToolCatalogSnapshot, getLastToolCatalogSnapshot, resolveToolProfile, selectToolsForProfile, } from "./toolCatalog.js";
35
38
  export function getToolDefs() {
@@ -375,6 +378,17 @@ export function getToolDefs() {
375
378
  required: ["task_id"],
376
379
  },
377
380
  },
381
+ {
382
+ name: "safe_status",
383
+ description: "Return minimal task lifecycle status without exposing diff, log content, file contents, or sensitive paths. Use this when only task state is needed and content-bearing tools may be blocked by upper-layer security.",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ task_id: { type: "string", description: "Task ID to check" },
388
+ },
389
+ required: ["task_id"],
390
+ },
391
+ },
378
392
  ];
379
393
  // Direct session tools
380
394
  const directCommands = getAllConfiguredDirectCommands(config);
@@ -481,6 +495,21 @@ export function getToolDefs() {
481
495
  required: ["session_id"],
482
496
  },
483
497
  });
498
+ tools.push({
499
+ name: "sync_file",
500
+ description: "Copy a file from source to target within the same Direct session repo. Both paths must be inside the session repo_path. Returns before/after sha256 hashes and whether the target changed.",
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: {
504
+ session_id: { type: "string", description: "Direct session ID" },
505
+ source_path: { type: "string", description: "Relative path to source file within repo" },
506
+ target_path: { type: "string", description: "Relative path to target file within repo" },
507
+ expected_source_sha256: { type: "string", description: "Optional: expected sha256 of source file" },
508
+ expected_target_sha256: { type: "string", description: "Optional: expected sha256 of target file before copy" },
509
+ },
510
+ required: ["session_id", "source_path", "target_path"],
511
+ },
512
+ });
484
513
  // run_task: only available when explicitly enabled
485
514
  if (config.enableRunTaskTool === true) {
486
515
  tools.push({
@@ -513,6 +542,20 @@ function guardDirectProfileEnabled() {
513
542
  }
514
543
  }
515
544
  export async function handleToolCall(name, args) {
545
+ const startTime = Date.now();
546
+ const taskId = args?.task_id ? String(args.task_id) : args?.session_id ? String(args.session_id) : undefined;
547
+ try {
548
+ const result = await handleToolCallInternal(name, args);
549
+ logger.audit(name, true, Date.now() - startTime, undefined, taskId);
550
+ return result;
551
+ }
552
+ catch (err) {
553
+ const errorReason = err instanceof Error ? err.message : String(err);
554
+ logger.audit(name, false, Date.now() - startTime, errorReason, taskId);
555
+ throw err;
556
+ }
557
+ }
558
+ async function handleToolCallInternal(name, args) {
516
559
  switch (name) {
517
560
  case "save_plan": {
518
561
  return toResult(savePlan({
@@ -620,6 +663,9 @@ export async function handleToolCall(name, args) {
620
663
  case "audit_task": {
621
664
  return toResult(auditTask(String(args?.task_id ?? "")));
622
665
  }
666
+ case "safe_status": {
667
+ return toResult(safeStatus(String(args?.task_id ?? "")));
668
+ }
623
669
  case "run_task": {
624
670
  const config = getConfig();
625
671
  if (config.enableRunTaskTool !== true) {
@@ -676,6 +722,13 @@ export async function handleToolCall(name, args) {
676
722
  session_id: String(args?.session_id ?? ""),
677
723
  }));
678
724
  }
725
+ case "sync_file": {
726
+ guardDirectProfileEnabled();
727
+ return toResult(syncFile(String(args?.session_id ?? ""), String(args?.source_path ?? ""), String(args?.target_path ?? ""), {
728
+ expected_source_sha256: args?.expected_source_sha256 ? String(args.expected_source_sha256) : undefined,
729
+ expected_target_sha256: args?.expected_target_sha256 ? String(args.expected_target_sha256) : undefined,
730
+ }));
731
+ }
679
732
  default:
680
733
  throw new Error(`Unknown tool: ${name}`);
681
734
  }
@@ -0,0 +1,19 @@
1
+ import { type PatchWardenConfig } from "../config.js";
2
+ import type { TaskStatus, TaskPhase } from "./createTask.js";
3
+ export interface SafeStatusOutput {
4
+ task_id: string;
5
+ status: TaskStatus | "not_found";
6
+ phase: TaskPhase | null;
7
+ created_at: string | null;
8
+ started_at: string | null;
9
+ updated_at: string | null;
10
+ finished_at: string | null;
11
+ last_heartbeat_at: string | null;
12
+ current_command: string | null;
13
+ verify_status: "passed" | "failed" | "skipped" | null;
14
+ artifact_status: string | null;
15
+ watcher_state: string | null;
16
+ error_code: string | null;
17
+ error_summary: string | null;
18
+ }
19
+ export declare function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStatusOutput;