patchwarden 0.4.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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,144 @@
1
+ import { join, resolve, relative, isAbsolute } from "node:path";
2
+ import {
3
+ readDirectSession,
4
+ updateDirectSession,
5
+ finalizeDirectSessionRecord,
6
+ getDirectSessionDir,
7
+ } from "../direct/directSessionStore.js";
8
+ import { guardDirectSessionActive } from "../direct/directGuards.js";
9
+ import {
10
+ captureRepoSnapshot,
11
+ buildChangeArtifacts,
12
+ type ChangeArtifacts,
13
+ type ClassifiedChange,
14
+ type ChangedFile,
15
+ } from "../runner/changeCapture.js";
16
+
17
+ // ── Types ──────────────────────────────────────────────────────────
18
+
19
+ export interface FinalizeDirectSessionInput {
20
+ session_id: string;
21
+ }
22
+
23
+ export interface FinalizeDirectSessionOutput {
24
+ session_id: string;
25
+ changed_files_total: number;
26
+ source_changes: ClassifiedChange[];
27
+ tracked_build_artifacts: ClassifiedChange[];
28
+ runtime_generated_files: ClassifiedChange[];
29
+ suspicious_changes: ClassifiedChange[];
30
+ out_of_scope_changes: ClassifiedChange[];
31
+ verification_summary: Record<string, "passed" | "failed">;
32
+ diff_path: string;
33
+ summary_path: string;
34
+ finalized: boolean;
35
+ next_action: string;
36
+ }
37
+
38
+ // ── Main function ──────────────────────────────────────────────────
39
+
40
+ export function finalizeDirectSession(
41
+ input: FinalizeDirectSessionInput
42
+ ): FinalizeDirectSessionOutput {
43
+ const { session_id } = input;
44
+
45
+ // 1. Read session and guard active (not expired, not finalized)
46
+ const session = readDirectSession(session_id);
47
+ guardDirectSessionActive(session);
48
+
49
+ // 2. Capture after snapshot
50
+ const afterSnapshot = captureRepoSnapshot(session.resolved_repo_path);
51
+
52
+ // 3. Build change artifacts from before/after snapshots
53
+ const changeArtifacts = buildChangeArtifacts(
54
+ session.resolved_repo_path,
55
+ session.workspace_snapshot_before,
56
+ afterSnapshot
57
+ );
58
+
59
+ // 4. Finalize session record — writes changed-files.json, diff.patch,
60
+ // summary.json, summary.md and marks the session as finalized.
61
+ finalizeDirectSessionRecord(session_id, afterSnapshot, changeArtifacts);
62
+
63
+ // 5. Build verification summary from session.verification_runs
64
+ const verification_summary: Record<string, "passed" | "failed"> = {};
65
+ for (const run of session.verification_runs) {
66
+ verification_summary[run.command] = run.passed ? "passed" : "failed";
67
+ }
68
+
69
+ // 6. Identify out-of-scope changes (paths outside resolved_repo_path)
70
+ const out_of_scope_changes = findOutOfScopeChanges(
71
+ changeArtifacts,
72
+ session.resolved_repo_path
73
+ );
74
+
75
+ // 7. Build compact summary with categorized changes
76
+ const sessionDir = getDirectSessionDir(session_id);
77
+
78
+ return {
79
+ session_id,
80
+ changed_files_total: changeArtifacts.changed_files.length,
81
+ source_changes: changeArtifacts.artifact_hygiene.source_changes,
82
+ tracked_build_artifacts:
83
+ changeArtifacts.artifact_hygiene.tracked_build_artifacts,
84
+ runtime_generated_files:
85
+ changeArtifacts.artifact_hygiene.runtime_generated_files,
86
+ suspicious_changes: changeArtifacts.artifact_hygiene.suspicious_changes,
87
+ out_of_scope_changes,
88
+ verification_summary,
89
+ diff_path: join(sessionDir, "diff.patch"),
90
+ summary_path: join(sessionDir, "summary.md"),
91
+ finalized: true,
92
+ next_action: "Call audit_session to independently review the changes.",
93
+ };
94
+ }
95
+
96
+ // ── Helpers ────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Filter changeArtifacts.changed_files for paths that resolve outside the
100
+ * session's resolved_repo_path and map them to ClassifiedChange format.
101
+ */
102
+ function findOutOfScopeChanges(
103
+ artifacts: ChangeArtifacts,
104
+ resolvedRepoPath: string
105
+ ): ClassifiedChange[] {
106
+ const normalizedRepo = resolve(resolvedRepoPath);
107
+ return artifacts.changed_files
108
+ .filter((file) => {
109
+ const fullPath = resolve(normalizedRepo, file.path);
110
+ const rel = relative(normalizedRepo, fullPath);
111
+ return isAbsolute(rel) || rel.startsWith("..");
112
+ })
113
+ .map((file) => toClassifiedChange(file));
114
+ }
115
+
116
+ /**
117
+ * Map a ChangedFile to the ClassifiedChange format by adding a reason string.
118
+ */
119
+ function toClassifiedChange(file: ChangedFile): ClassifiedChange {
120
+ return {
121
+ path: file.path,
122
+ change: file.change,
123
+ tracked: file.tracked,
124
+ ignored: file.ignored,
125
+ kind: file.kind,
126
+ reason: classificationReason(file),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Produce a human-readable reason for a changed file classification.
132
+ * Mirrors the logic in changeCapture.ts classifyArtifactHygiene.
133
+ */
134
+ function classificationReason(change: ChangedFile): string {
135
+ if (change.ignored)
136
+ return "untracked path is ignored by repository Git rules";
137
+ if (change.kind === "build_artifact" && change.tracked)
138
+ return "artifact-like path is tracked by Git and requires review";
139
+ if (change.kind === "build_artifact")
140
+ return "artifact-like path is not ignored and requires review";
141
+ if (change.kind === "runtime_generated")
142
+ return "runtime-generated path is not ignored and requires review";
143
+ return change.tracked ? "tracked source change" : "untracked source change";
144
+ }
@@ -78,9 +78,57 @@ export interface TaskSummaryOutput {
78
78
  watcher: unknown;
79
79
  pending_reason: string | null;
80
80
  execution_blocked: boolean;
81
+ artifact_hygiene: Record<string, unknown>;
81
82
  }
82
83
 
83
- export function getTaskSummary(taskId: string): TaskSummaryOutput {
84
+ export interface CompactTaskSummaryOutput {
85
+ view: "compact";
86
+ task_id: string;
87
+ status: string;
88
+ terminal: boolean;
89
+ acceptance_status: TaskSummaryOutput["acceptance_status"];
90
+ phase: string;
91
+ repo_path: string;
92
+ changed_files_total: number;
93
+ out_of_scope_changes_total: number;
94
+ artifact_hygiene: {
95
+ counts: Record<string, number>;
96
+ source_changes: unknown[];
97
+ tracked_build_artifacts: unknown[];
98
+ ignored_untracked_artifacts: unknown[];
99
+ runtime_generated_files: unknown[];
100
+ suspicious_changes: unknown[];
101
+ max_items: number;
102
+ truncated: boolean;
103
+ };
104
+ release_artifacts_count: number;
105
+ release_artifact_paths: string[];
106
+ artifact_status: string | null;
107
+ verification_summary: TaskSummaryOutput["verification_summary"];
108
+ summary: string;
109
+ warnings: string[];
110
+ errors: string[];
111
+ failure_reason: string | null;
112
+ failed_command: string | null;
113
+ suggested_next_action: string;
114
+ execution_blocked: boolean;
115
+ pending_reason: string | null;
116
+ redacted: boolean;
117
+ redaction_categories: string[];
118
+ }
119
+
120
+ export interface GetTaskSummaryOptions {
121
+ view?: "compact" | "standard";
122
+ max_items?: number;
123
+ }
124
+
125
+ export type TaskSummaryResult = TaskSummaryOutput | CompactTaskSummaryOutput;
126
+
127
+ export function getTaskSummary(taskId: string): TaskSummaryOutput;
128
+ export function getTaskSummary(taskId: string, options: { view: "compact"; max_items?: number }): CompactTaskSummaryOutput;
129
+ export function getTaskSummary(taskId: string, options: { view?: "standard"; max_items?: number }): TaskSummaryOutput;
130
+ export function getTaskSummary(taskId: string, options: GetTaskSummaryOptions): TaskSummaryResult;
131
+ export function getTaskSummary(taskId: string, options: GetTaskSummaryOptions = {}): TaskSummaryResult {
84
132
  const config = getConfig();
85
133
  const taskDir = resolve(getTasksDir(config), taskId);
86
134
  const statusFile = join(taskDir, "status.json");
@@ -91,7 +139,14 @@ export function getTaskSummary(taskId: string): TaskSummaryOutput {
91
139
  const result = resultRead.data;
92
140
  const verify = verifyRead.data;
93
141
  const terminal = TERMINAL_STATUSES.has(String(status.status));
94
- const outOfScope = asArray(result.out_of_scope_changes ?? status.out_of_scope_changes);
142
+ // Phase 4: Use new_out_of_scope_changes (task-caused) for acceptance status.
143
+ // Pre-existing external dirty files that didn't change should NOT fail acceptance.
144
+ const outOfScope = asArray(
145
+ result.new_out_of_scope_changes
146
+ ?? status.new_out_of_scope_changes
147
+ ?? result.out_of_scope_changes
148
+ ?? status.out_of_scope_changes
149
+ );
95
150
  const verifyStatus = String(verify.status ?? result.verify_status ?? result.verify?.status ?? status.verify_status ?? "not_available");
96
151
  const errors = [status.error, ...asArray(result.errors), ...asArray(result.known_issues)]
97
152
  .filter((value): value is string => typeof value === "string" && value.trim() !== "");
@@ -156,6 +211,10 @@ export function getTaskSummary(taskId: string): TaskSummaryOutput {
156
211
  ? Math.max(0, (Number.isFinite(finishedAt) ? finishedAt : Date.now()) - startedAt)
157
212
  : 0;
158
213
  const changedFiles = asArray(result.changed_files ?? status.changed_files);
214
+ const changedFilesRead = tryReadJson(join(taskDir, "changed-files.json"));
215
+ const artifactHygiene = asRecord(result.artifact_hygiene ?? changedFilesRead.data.artifact_hygiene ?? {
216
+ counts: status.artifact_hygiene_counts || {},
217
+ });
159
218
  const verifyCommands = asArray(verify.commands ?? result.verify_commands ?? result.verify?.commands);
160
219
  const testLogSummary = summarizeTestLog(join(taskDir, "test.log"));
161
220
  const verificationSummary = buildVerificationSummary(verifyStatus, verifyCommands, testLogSummary);
@@ -227,7 +286,11 @@ export function getTaskSummary(taskId: string): TaskSummaryOutput {
227
286
  watcher: status.watcher,
228
287
  pending_reason: status.pending_reason || null,
229
288
  execution_blocked: Boolean(status.execution_blocked),
289
+ artifact_hygiene: artifactHygiene,
230
290
  };
291
+ if ((options.view || "standard") === "compact") {
292
+ return buildCompactSummary(output, normalizeMaxItems(options.max_items));
293
+ }
231
294
  const safe = redactSensitiveValue(output);
232
295
  return {
233
296
  ...safe.value,
@@ -236,6 +299,70 @@ export function getTaskSummary(taskId: string): TaskSummaryOutput {
236
299
  } as TaskSummaryOutput;
237
300
  }
238
301
 
302
+ function buildCompactSummary(output: Record<string, any>, maxItems: number): CompactTaskSummaryOutput {
303
+ const hygiene = asRecord(output.artifact_hygiene);
304
+ const groupNames = [
305
+ "source_changes",
306
+ "tracked_build_artifacts",
307
+ "ignored_untracked_artifacts",
308
+ "runtime_generated_files",
309
+ "suspicious_changes",
310
+ ] as const;
311
+ const groups = Object.fromEntries(groupNames.map((name) => [name, asArray(hygiene[name]).slice(0, maxItems)]));
312
+ const truncated = groupNames.some((name) => asArray(hygiene[name]).length > maxItems);
313
+
314
+ // Phase 6: Read artifact_manifest.json for release artifact info
315
+ const taskDir = resolve(getTasksDir(getConfig()), String(output.task_id));
316
+ const manifestRead = tryReadJson(join(taskDir, "artifact_manifest.json"));
317
+ const manifest = asRecord(manifestRead.data);
318
+ const releaseArtifacts = asArray(manifest.artifacts);
319
+ const releaseArtifactPaths = releaseArtifacts.map((a: any) => String(a.path || "")).slice(0, maxItems);
320
+
321
+ const compact = {
322
+ view: "compact" as const,
323
+ task_id: String(output.task_id),
324
+ status: String(output.status),
325
+ terminal: Boolean(output.terminal),
326
+ acceptance_status: output.acceptance_status,
327
+ phase: String(output.phase),
328
+ repo_path: String(output.repo_path),
329
+ changed_files_total: asArray(output.changed_files).length,
330
+ out_of_scope_changes_total: asArray(output.out_of_scope_changes).length,
331
+ artifact_hygiene: {
332
+ counts: asRecord(hygiene.counts) as Record<string, number>,
333
+ ...groups,
334
+ max_items: maxItems,
335
+ truncated,
336
+ },
337
+ release_artifacts_count: releaseArtifacts.length,
338
+ release_artifact_paths: releaseArtifactPaths,
339
+ artifact_status: String(output.artifact_status || manifest.status || "collected"),
340
+ verification_summary: output.verification_summary,
341
+ summary: String(output.summary).slice(0, 1000),
342
+ warnings: asArray(output.warnings).slice(0, maxItems),
343
+ errors: asArray(output.errors).slice(0, maxItems),
344
+ failure_reason: output.failure_reason || null,
345
+ failed_command: output.failed_command || null,
346
+ suggested_next_action: String(output.suggested_next_action),
347
+ execution_blocked: Boolean(output.execution_blocked),
348
+ pending_reason: output.pending_reason || null,
349
+ };
350
+ const safe = redactSensitiveValue(compact);
351
+ return {
352
+ ...safe.value,
353
+ redacted: safe.redacted,
354
+ redaction_categories: safe.redaction_categories,
355
+ } as CompactTaskSummaryOutput;
356
+ }
357
+
358
+ function normalizeMaxItems(value: number | undefined): number {
359
+ if (value === undefined) return 8;
360
+ if (!Number.isInteger(value) || value < 1 || value > 50) {
361
+ throw new Error("max_items must be an integer from 1 to 50.");
362
+ }
363
+ return value;
364
+ }
365
+
239
366
  function tryReadJson(path: string): { data: Record<string, any>; error?: string } {
240
367
  if (!existsSync(path)) return { data: {} };
241
368
  try {
@@ -249,6 +376,10 @@ function asArray(value: unknown): any[] {
249
376
  return Array.isArray(value) ? value : [];
250
377
  }
251
378
 
379
+ function asRecord(value: unknown): Record<string, any> {
380
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
381
+ }
382
+
252
383
  function summarizeTestLog(path: string): string {
253
384
  if (!existsSync(path)) return "test.log missing";
254
385
  const text = readFileSync(path, "utf-8");
@@ -1,12 +1,12 @@
1
1
  import { accessSync, constants, existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
4
+ import { getConfig, getTasksDir, getDirectSessionsDir, resolveWorkspaceRoot } from "../config.js";
5
5
  import { listAgents } from "./listAgents.js";
6
6
  import { redactSensitiveContent, redactSensitiveValue } from "../security/contentRedaction.js";
7
7
  import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
8
8
  import type { ToolCatalogSnapshot } from "./toolCatalog.js";
9
- import { CHATGPT_CORE_TOOL_NAMES, resolveToolProfile } from "./toolCatalog.js";
9
+ import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES, resolveToolProfile } from "./toolCatalog.js";
10
10
  import { readWatcherStatus } from "../watcherStatus.js";
11
11
  import { listTasks } from "./listTasks.js";
12
12
 
@@ -35,9 +35,17 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
35
35
  let profileConsistent = true;
36
36
  if (catalog) {
37
37
  const activeProfile = resolveToolProfile(config.toolProfile);
38
- const expectedNames = activeProfile === "chatgpt_core"
39
- ? [...CHATGPT_CORE_TOOL_NAMES]
40
- : null; // full profile: all tools are accepted
38
+ let expectedNames: string[] | null = null;
39
+ if (activeProfile === "chatgpt_core") {
40
+ expectedNames = [...CHATGPT_CORE_TOOL_NAMES];
41
+ } else if (activeProfile === "chatgpt_direct" && config.enableDirectProfile) {
42
+ expectedNames = [...CHATGPT_DIRECT_TOOL_NAMES];
43
+ } else if (activeProfile === "chatgpt_direct" && !config.enableDirectProfile) {
44
+ // Degraded mode: only health_check should be exposed
45
+ expectedNames = ["health_check"];
46
+ profileErrors.push("Direct profile is disabled (enableDirectProfile=false). Only health_check is available. Set enableDirectProfile: true to enable Direct session tools.");
47
+ profileConsistent = false;
48
+ }
41
49
  if (expectedNames) {
42
50
  const catalogNames = new Set(catalog.tool_names);
43
51
  for (const name of expectedNames) {
@@ -76,6 +84,11 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
76
84
  const status = watcher.available && workspace.available && tasks.available && agentReady && catalogConsistent
77
85
  ? "healthy"
78
86
  : "degraded";
87
+
88
+ const directSessionsDir = getDirectSessionsDir(config);
89
+ const directSessions = directoryStatus(directSessionsDir, true, workspaceRoot);
90
+ const activeProfile = resolveToolProfile(config.toolProfile);
91
+
79
92
  return {
80
93
  status,
81
94
  server_version: catalog?.server_version || PATCHWARDEN_VERSION,
@@ -89,11 +102,15 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
89
102
  catalog_consistent: catalogConsistent,
90
103
  mismatch_report: mismatchReport,
91
104
  tunnel_catalog_comparison: tunnelCatalogComparison,
105
+ direct_profile_enabled: config.enableDirectProfile ?? false,
106
+ direct_sessions_dir: directSessions,
107
+ direct_session_ttl_seconds: config.directSessionTtlSeconds,
108
+ ...(activeProfile === "chatgpt_direct" ? { direct_tool_count: config.enableDirectProfile ? CHATGPT_DIRECT_TOOL_NAMES.length : 1 } : {}),
92
109
  connector_visibility: {
93
110
  status: "not_observable_server_side",
94
111
  verification: "Refresh or reconnect the Connector and verify tools/list from a new ChatGPT conversation.",
95
112
  refresh_steps: [
96
- "1. Run Check-PatchWarden-Health.cmd to confirm tool_profile=chatgpt_core, tool_count=16, and catalog_consistent=true.",
113
+ "1. Run PatchWarden.cmd health to confirm the active profile, tool count, and catalog consistency.",
97
114
  "2. In ChatGPT Platform, refresh or reconnect the Connector (do not reuse an old session).",
98
115
  "3. Open a NEW ChatGPT conversation (old conversations retain their cached tool catalog).",
99
116
  "4. Call health_check in the new conversation; verify tool_manifest_sha256 matches the local report.",
@@ -105,6 +122,7 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
105
122
  uptime_seconds: Math.round((Date.now() - SERVER_STARTED_AT) / 1000),
106
123
  checked_at: new Date().toISOString(),
107
124
  },
125
+ path_encoding: checkPathEncoding(),
108
126
  watcher,
109
127
  workspace_root: workspace,
110
128
  tasks_dir: tasks,
@@ -232,3 +250,24 @@ function readTunnelStatus(): Record<string, unknown> & { last_error: string | nu
232
250
  function safeText(value: unknown, maxLength: number): string {
233
251
  return typeof value === "string" ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength) : "";
234
252
  }
253
+
254
+ function checkPathEncoding(): { encoding: string; warnings: string[] } {
255
+ const warnings: string[] = [];
256
+ // Node.js always uses UTF-8 for fs operations, but on Windows the console
257
+ // codepage can cause display issues. We check if the environment is likely
258
+ // to cause problems with non-ASCII paths.
259
+ if (process.platform === "win32") {
260
+ // Check if the system locale uses a codepage that might not support UTF-8
261
+ const lang = process.env.LANG || process.env.LC_ALL || "";
262
+ const chcp = process.env.PATCHWARDEN_CHCP || "";
263
+ if (chcp && !chcp.includes("65001") && !chcp.includes("utf")) {
264
+ warnings.push(`Windows codepage "${chcp}" may cause display issues with non-ASCII paths. Consider setting codepage to 65001 (UTF-8).`);
265
+ }
266
+ }
267
+ // Verify that Node.js fs operations use UTF-8 by default
268
+ // This is always true in Node.js >= 18
269
+ return {
270
+ encoding: "utf-8",
271
+ warnings,
272
+ };
273
+ }
@@ -1,8 +1,14 @@
1
- import { readFileSync, statSync } from "node:fs";
1
+ import { readFileSync, statSync, existsSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { resolve, relative, isAbsolute } from "node:path";
2
4
  import { getConfig } from "../config.js";
3
5
  import { guardReadPath } from "../security/pathGuard.js";
4
6
  import { guardSensitivePath } from "../security/sensitiveGuard.js";
5
7
  import { redactSensitiveContent } from "../security/contentRedaction.js";
8
+ import { PatchWardenError } from "../errors.js";
9
+ import { readDirectSession } from "../direct/directSessionStore.js";
10
+ import { guardDirectSessionActive, guardDirectReadPath, isBinaryFile, guardDirectFileSize } from "../direct/directGuards.js";
11
+ import { resolveToolProfile } from "./toolCatalog.js";
6
12
 
7
13
  export interface ReadWorkspaceFileOutput {
8
14
  path: string;
@@ -10,11 +16,41 @@ export interface ReadWorkspaceFileOutput {
10
16
  size: number;
11
17
  redacted?: boolean;
12
18
  redaction_categories?: string[];
19
+ // Direct mode extensions
20
+ relative_path?: string;
21
+ sha256?: string;
13
22
  }
14
23
 
15
- export function readWorkspaceFile(relativePath: string): ReadWorkspaceFileOutput {
24
+ export interface ReadWorkspaceFileInput {
25
+ path: string;
26
+ session_id?: string;
27
+ }
28
+
29
+ export function readWorkspaceFile(input: string | ReadWorkspaceFileInput): ReadWorkspaceFileOutput {
16
30
  const config = getConfig();
17
31
 
32
+ // Support both old string API and new object API
33
+ const relativePath = typeof input === "string" ? input : input.path;
34
+ const sessionId = typeof input === "string" ? undefined : input.session_id;
35
+
36
+ // Direct mode: session_id provided
37
+ if (sessionId) {
38
+ return readWorkspaceFileDirect(relativePath, sessionId);
39
+ }
40
+
41
+ // In chatgpt_direct profile, session_id is required
42
+ const profile = resolveToolProfile(config.toolProfile);
43
+ if (profile === "chatgpt_direct") {
44
+ throw new PatchWardenError(
45
+ "session_id_required",
46
+ "session_id is required when using the chatgpt_direct profile.",
47
+ "Provide a session_id from create_direct_session.",
48
+ true,
49
+ { operation: "read_workspace_file" }
50
+ );
51
+ }
52
+
53
+ // Compatibility mode: no session_id, original behavior
18
54
  const safePath = guardReadPath(relativePath, config.workspaceRoot);
19
55
  guardSensitivePath(safePath);
20
56
 
@@ -39,3 +75,50 @@ export function readWorkspaceFile(relativePath: string): ReadWorkspaceFileOutput
39
75
  redaction_categories: redaction.redaction_categories,
40
76
  };
41
77
  }
78
+
79
+ function readWorkspaceFileDirect(relativePath: string, sessionId: string): ReadWorkspaceFileOutput {
80
+ const config = getConfig();
81
+ const session = readDirectSession(sessionId);
82
+ guardDirectSessionActive(session);
83
+
84
+ // Resolve path within session's repo
85
+ const safePath = guardDirectReadPath(relativePath, session.resolved_repo_path, config.workspaceRoot);
86
+
87
+ if (!existsSync(safePath)) {
88
+ throw new PatchWardenError(
89
+ "file_not_found",
90
+ `File "${relativePath}" not found in session repo.`,
91
+ "Use search_workspace to find available files.",
92
+ true,
93
+ { path: relativePath, session_id: sessionId }
94
+ );
95
+ }
96
+
97
+ const stat = statSync(safePath);
98
+ guardDirectFileSize(stat.size);
99
+
100
+ // Block binary files
101
+ if (isBinaryFile(safePath)) {
102
+ throw new PatchWardenError(
103
+ "binary_file_blocked",
104
+ `File "${relativePath}" appears to be a binary file.`,
105
+ "Binary files cannot be read in Direct mode.",
106
+ true,
107
+ { path: relativePath }
108
+ );
109
+ }
110
+
111
+ const content = readFileSync(safePath, "utf-8");
112
+ const redaction = redactSensitiveContent(content);
113
+ const sha256 = createHash("sha256").update(content, "utf-8").digest("hex");
114
+
115
+ return {
116
+ path: safePath,
117
+ relative_path: relativePath,
118
+ content: redaction.content,
119
+ size: stat.size,
120
+ sha256,
121
+ redacted: redaction.redacted,
122
+ redaction_categories: redaction.redaction_categories,
123
+ };
124
+ }