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
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
3
 
4
4
  // ── Type definitions ──────────────────────────────────────────────
5
5
 
@@ -12,14 +12,28 @@ export interface PatchWardenConfig {
12
12
  workspaceRoot: string;
13
13
  plansDir: string;
14
14
  tasksDir: string;
15
+ assessmentsDir: string;
16
+ assessmentTtlSeconds: number;
15
17
  agents: Record<string, AgentConfig>;
16
18
  allowedTestCommands: string[];
19
+ repoAllowedTestCommands?: Record<string, string[]>;
17
20
  maxReadFileBytes: number;
18
21
  defaultTaskTimeoutSeconds: number;
19
22
  maxTaskTimeoutSeconds: number;
20
23
  watcherStaleSeconds: number;
21
- toolProfile?: "full" | "chatgpt_core";
24
+ toolProfile?: "full" | "chatgpt_core" | "chatgpt_direct";
22
25
  repoAliases?: Record<string, string>;
26
+ enableAgentAssessment?: boolean;
27
+ agentAssessmentTimeoutSeconds?: number;
28
+ agentAssessmentMaxOutputBytes?: number;
29
+ agentAssessmentAgentName?: string;
30
+ enableDirectProfile?: boolean;
31
+ directSessionsDir: string;
32
+ directSessionTtlSeconds: number;
33
+ directMaxPatchBytes: number;
34
+ directMaxFileBytes: number;
35
+ directAllowedCommands?: string[];
36
+ repoDirectAllowedCommands?: Record<string, string[]>;
23
37
  }
24
38
 
25
39
  // ── Defaults ──────────────────────────────────────────────────────
@@ -28,6 +42,8 @@ const DEFAULT_CONFIG: PatchWardenConfig = {
28
42
  workspaceRoot: process.cwd(),
29
43
  plansDir: ".patchwarden/plans",
30
44
  tasksDir: ".patchwarden/tasks",
45
+ assessmentsDir: ".patchwarden/assessments",
46
+ assessmentTtlSeconds: 3600,
31
47
  agents: {
32
48
  codex: {
33
49
  command: "codex",
@@ -56,11 +72,28 @@ const DEFAULT_CONFIG: PatchWardenConfig = {
56
72
  "pytest",
57
73
  "cargo test",
58
74
  ],
75
+ repoAllowedTestCommands: {},
59
76
  maxReadFileBytes: 200_000,
60
77
  defaultTaskTimeoutSeconds: 900,
61
78
  maxTaskTimeoutSeconds: 3600,
62
79
  watcherStaleSeconds: 30,
63
80
  toolProfile: "full",
81
+ enableAgentAssessment: false,
82
+ agentAssessmentTimeoutSeconds: 120,
83
+ agentAssessmentMaxOutputBytes: 524288,
84
+ enableDirectProfile: false,
85
+ directSessionsDir: ".patchwarden/direct-sessions",
86
+ directSessionTtlSeconds: 3600,
87
+ directMaxPatchBytes: 200_000,
88
+ directMaxFileBytes: 500_000,
89
+ directAllowedCommands: [
90
+ "npm test",
91
+ "npm run test",
92
+ "npm run build",
93
+ "npm run lint",
94
+ "node --check main.js",
95
+ ],
96
+ repoDirectAllowedCommands: {},
64
97
  };
65
98
 
66
99
  // ── Load config ───────────────────────────────────────────────────
@@ -69,7 +102,15 @@ let _config: PatchWardenConfig | null = null;
69
102
 
70
103
  export function loadConfig(configPath?: string): PatchWardenConfig {
71
104
  if (_config) return _config;
105
+ return loadConfigInternal(configPath);
106
+ }
72
107
 
108
+ export function reloadConfig(configPath?: string): PatchWardenConfig {
109
+ _config = null;
110
+ return loadConfigInternal(configPath);
111
+ }
112
+
113
+ function loadConfigInternal(configPath?: string): PatchWardenConfig {
73
114
  const explicitPath = configPath || process.env.PATCHWARDEN_CONFIG;
74
115
  const candidatePaths = explicitPath
75
116
  ? [explicitPath]
@@ -119,6 +160,10 @@ export function getTasksDir(config: PatchWardenConfig): string {
119
160
  return resolve(config.workspaceRoot, config.tasksDir);
120
161
  }
121
162
 
163
+ export function getAssessmentsDir(config: PatchWardenConfig): string {
164
+ return resolve(config.workspaceRoot, config.assessmentsDir);
165
+ }
166
+
122
167
  function stripBom(value: string): string {
123
168
  return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
124
169
  }
@@ -133,12 +178,37 @@ function normalizeConfig(config: PatchWardenConfig): PatchWardenConfig {
133
178
  if (!config.tasksDir || typeof config.tasksDir !== "string") {
134
179
  throw new Error("tasksDir must be a non-empty string");
135
180
  }
181
+ if (!config.assessmentsDir || typeof config.assessmentsDir !== "string") {
182
+ throw new Error("assessmentsDir must be a non-empty string");
183
+ }
184
+ if (!Number.isInteger(config.assessmentTtlSeconds) || config.assessmentTtlSeconds < 60 || config.assessmentTtlSeconds > 86400) {
185
+ throw new Error("assessmentTtlSeconds must be an integer from 60 to 86400");
186
+ }
136
187
  if (!config.agents || typeof config.agents !== "object") {
137
188
  throw new Error("agents must be an object");
138
189
  }
139
190
  if (!Array.isArray(config.allowedTestCommands)) {
140
191
  throw new Error("allowedTestCommands must be an array");
141
192
  }
193
+ if (config.allowedTestCommands.some((command) => typeof command !== "string" || command.trim() === "")) {
194
+ throw new Error("allowedTestCommands must contain only non-empty command strings");
195
+ }
196
+ if (!config.repoAllowedTestCommands || typeof config.repoAllowedTestCommands !== "object" || Array.isArray(config.repoAllowedTestCommands)) {
197
+ throw new Error("repoAllowedTestCommands must be an object mapping workspace-relative repository paths to command arrays");
198
+ }
199
+ const repoAllowedTestCommands: Record<string, string[]> = {};
200
+ for (const [repoKey, commands] of Object.entries(config.repoAllowedTestCommands)) {
201
+ const normalizedKey = normalizeRepoKey(repoKey);
202
+ const resolvedRepo = resolve(config.workspaceRoot, normalizedKey);
203
+ const relativeRepo = relative(resolve(config.workspaceRoot), resolvedRepo);
204
+ if (isAbsolute(repoKey) || relativeRepo === ".." || relativeRepo.startsWith(`..${sep}`) || isAbsolute(relativeRepo)) {
205
+ throw new Error(`repoAllowedTestCommands key must stay inside workspaceRoot: "${repoKey}"`);
206
+ }
207
+ if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || command.trim() === "")) {
208
+ throw new Error(`repoAllowedTestCommands["${repoKey}"] must be an array of non-empty command strings`);
209
+ }
210
+ repoAllowedTestCommands[normalizedKey] = [...new Set(commands.map((command) => command.trim()))];
211
+ }
142
212
  if (!Number.isFinite(config.maxReadFileBytes) || config.maxReadFileBytes <= 0) {
143
213
  throw new Error("maxReadFileBytes must be a positive number");
144
214
  }
@@ -154,12 +224,116 @@ function normalizeConfig(config: PatchWardenConfig): PatchWardenConfig {
154
224
  if (!Number.isInteger(config.watcherStaleSeconds) || config.watcherStaleSeconds < 5 || config.watcherStaleSeconds > 3600) {
155
225
  throw new Error("watcherStaleSeconds must be an integer from 5 to 3600");
156
226
  }
157
- if (config.toolProfile !== undefined && config.toolProfile !== "full" && config.toolProfile !== "chatgpt_core") {
158
- throw new Error('toolProfile must be "full" or "chatgpt_core"');
227
+ if (config.toolProfile !== undefined && config.toolProfile !== "full" && config.toolProfile !== "chatgpt_core" && config.toolProfile !== "chatgpt_direct") {
228
+ throw new Error('toolProfile must be "full", "chatgpt_core", or "chatgpt_direct"');
229
+ }
230
+ if (config.enableAgentAssessment !== undefined && typeof config.enableAgentAssessment !== "boolean") {
231
+ throw new Error("enableAgentAssessment must be a boolean");
232
+ }
233
+ if (config.agentAssessmentTimeoutSeconds !== undefined) {
234
+ if (!Number.isInteger(config.agentAssessmentTimeoutSeconds) || config.agentAssessmentTimeoutSeconds < 10 || config.agentAssessmentTimeoutSeconds > 600) {
235
+ throw new Error("agentAssessmentTimeoutSeconds must be an integer from 10 to 600");
236
+ }
237
+ }
238
+ if (config.agentAssessmentMaxOutputBytes !== undefined) {
239
+ if (!Number.isInteger(config.agentAssessmentMaxOutputBytes) || config.agentAssessmentMaxOutputBytes < 16384 || config.agentAssessmentMaxOutputBytes > 8388608) {
240
+ throw new Error("agentAssessmentMaxOutputBytes must be an integer from 16384 to 8388608");
241
+ }
242
+ }
243
+ if (config.agentAssessmentAgentName !== undefined && typeof config.agentAssessmentAgentName !== "string") {
244
+ throw new Error("agentAssessmentAgentName must be a string");
245
+ }
246
+ if (config.enableDirectProfile !== undefined && typeof config.enableDirectProfile !== "boolean") {
247
+ throw new Error("enableDirectProfile must be a boolean");
248
+ }
249
+ if (!config.directSessionsDir || typeof config.directSessionsDir !== "string") {
250
+ throw new Error("directSessionsDir must be a non-empty string");
251
+ }
252
+ if (!Number.isInteger(config.directSessionTtlSeconds) || config.directSessionTtlSeconds < 60 || config.directSessionTtlSeconds > 86400) {
253
+ throw new Error("directSessionTtlSeconds must be an integer from 60 to 86400");
254
+ }
255
+ if (!Number.isInteger(config.directMaxPatchBytes) || config.directMaxPatchBytes <= 0) {
256
+ throw new Error("directMaxPatchBytes must be a positive integer");
257
+ }
258
+ if (!Number.isInteger(config.directMaxFileBytes) || config.directMaxFileBytes <= 0) {
259
+ throw new Error("directMaxFileBytes must be a positive integer");
260
+ }
261
+ if (config.directAllowedCommands !== undefined) {
262
+ if (!Array.isArray(config.directAllowedCommands)) {
263
+ throw new Error("directAllowedCommands must be an array");
264
+ }
265
+ if (config.directAllowedCommands.some((command) => typeof command !== "string" || command.trim() === "")) {
266
+ throw new Error("directAllowedCommands must contain only non-empty command strings");
267
+ }
268
+ }
269
+ if (config.repoDirectAllowedCommands !== undefined) {
270
+ if (typeof config.repoDirectAllowedCommands !== "object" || Array.isArray(config.repoDirectAllowedCommands)) {
271
+ throw new Error("repoDirectAllowedCommands must be an object mapping workspace-relative repository paths to command arrays");
272
+ }
273
+ const repoDirectAllowedCommands: Record<string, string[]> = {};
274
+ for (const [repoKey, commands] of Object.entries(config.repoDirectAllowedCommands)) {
275
+ const normalizedKey = normalizeRepoKey(repoKey);
276
+ const resolvedRepo = resolve(config.workspaceRoot, normalizedKey);
277
+ const relativeRepo = relative(resolve(config.workspaceRoot), resolvedRepo);
278
+ if (isAbsolute(repoKey) || relativeRepo === ".." || relativeRepo.startsWith(`..${sep}`) || isAbsolute(relativeRepo)) {
279
+ throw new Error(`repoDirectAllowedCommands key must stay inside workspaceRoot: "${repoKey}"`);
280
+ }
281
+ if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || command.trim() === "")) {
282
+ throw new Error(`repoDirectAllowedCommands["${repoKey}"] must be an array of non-empty command strings`);
283
+ }
284
+ repoDirectAllowedCommands[normalizedKey] = [...new Set(commands.map((command) => command.trim()))];
285
+ }
286
+ config.repoDirectAllowedCommands = repoDirectAllowedCommands;
159
287
  }
160
288
 
161
289
  return {
162
290
  ...config,
163
291
  workspaceRoot: resolve(config.workspaceRoot),
292
+ allowedTestCommands: [...new Set(config.allowedTestCommands.map((command) => command.trim()))],
293
+ repoAllowedTestCommands,
164
294
  };
165
295
  }
296
+
297
+ export function getRepoAllowedTestCommands(config: PatchWardenConfig, repoPath: string): string[] {
298
+ const target = comparablePath(resolve(repoPath));
299
+ for (const [repoKey, commands] of Object.entries(config.repoAllowedTestCommands || {})) {
300
+ if (comparablePath(resolve(config.workspaceRoot, repoKey)) === target) return [...commands];
301
+ }
302
+ return [];
303
+ }
304
+
305
+ export function getAllConfiguredTestCommands(config: PatchWardenConfig): string[] {
306
+ return [...new Set([
307
+ ...config.allowedTestCommands,
308
+ ...Object.values(config.repoAllowedTestCommands || {}).flat(),
309
+ ])];
310
+ }
311
+
312
+ export function getDirectSessionsDir(config: PatchWardenConfig): string {
313
+ return resolve(config.workspaceRoot, config.directSessionsDir);
314
+ }
315
+
316
+ export function getRepoDirectAllowedCommands(config: PatchWardenConfig, repoPath: string): string[] {
317
+ const target = comparablePath(resolve(repoPath));
318
+ for (const [repoKey, commands] of Object.entries(config.repoDirectAllowedCommands || {})) {
319
+ if (comparablePath(resolve(config.workspaceRoot, repoKey)) === target) return [...commands];
320
+ }
321
+ return [];
322
+ }
323
+
324
+ export function getAllConfiguredDirectCommands(config: PatchWardenConfig): string[] {
325
+ return [...new Set([
326
+ ...(config.directAllowedCommands || []),
327
+ ...Object.values(config.repoDirectAllowedCommands || {}).flat(),
328
+ ])];
329
+ }
330
+
331
+ function normalizeRepoKey(value: string): string {
332
+ const trimmed = String(value).trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
333
+ return trimmed === "" ? "." : trimmed;
334
+ }
335
+
336
+ function comparablePath(value: string): string {
337
+ const normalized = resolve(value).replace(/\\/g, "/");
338
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
339
+ }
@@ -0,0 +1,400 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join, resolve, relative, isAbsolute } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ import { isSensitivePath } from "../security/sensitiveGuard.js";
5
+ import { PatchWardenError } from "../errors.js";
6
+ import {
7
+ readDirectSession,
8
+ getDirectSessionDir,
9
+ type DirectSessionRecord,
10
+ type DirectSessionVerificationRun,
11
+ } from "./directSessionStore.js";
12
+ import type { ChangedFile, ChangeArtifacts } from "../runner/changeCapture.js";
13
+
14
+ // ── Types ──────────────────────────────────────────────────────────
15
+
16
+ export interface AuditCheck {
17
+ name: string;
18
+ result: "pass" | "warn" | "fail";
19
+ detail: string;
20
+ reason_code?: string;
21
+ }
22
+
23
+ export interface DirectSessionAuditOutput {
24
+ session_id: string;
25
+ decision: "pass" | "warn" | "fail";
26
+ reason_codes: string[];
27
+ blocking_findings: string[];
28
+ warnings: string[];
29
+ evidence: {
30
+ changed_files_total: number;
31
+ verification_runs: DirectSessionVerificationRun[];
32
+ diff_path: string;
33
+ summary_path: string;
34
+ audit_path: string;
35
+ };
36
+ next_action: string;
37
+ }
38
+
39
+ // ── Main audit function ────────────────────────────────────────────
40
+
41
+ export function auditDirectSession(sessionId: string): DirectSessionAuditOutput {
42
+ const config = getConfig();
43
+ const session = readDirectSession(sessionId);
44
+ const sessionDir = getDirectSessionDir(sessionId);
45
+
46
+ const checks: AuditCheck[] = [];
47
+ const reasonCodes: string[] = [];
48
+ const blockingFindings: string[] = [];
49
+ const warnings: string[] = [];
50
+
51
+ // Check 1: session finalized
52
+ checks.push({
53
+ name: "session_finalized",
54
+ result: session.finalized ? "pass" : "fail",
55
+ detail: session.finalized
56
+ ? "Session has been finalized."
57
+ : "Session has not been finalized. Call finalize_direct_session first.",
58
+ reason_code: session.finalized ? undefined : "session_not_finalized",
59
+ });
60
+
61
+ // Get change artifacts if available
62
+ const artifacts = session.change_artifacts;
63
+
64
+ // Check 2: diff empty
65
+ const changedFilesTotal = artifacts?.changed_files?.length ?? 0;
66
+ checks.push({
67
+ name: "diff_empty",
68
+ result: changedFilesTotal === 0 ? "warn" : "pass",
69
+ detail:
70
+ changedFilesTotal === 0
71
+ ? "No file changes detected in this session."
72
+ : `${changedFilesTotal} file(s) changed.`,
73
+ reason_code: changedFilesTotal === 0 ? "empty_diff" : undefined,
74
+ });
75
+
76
+ if (artifacts) {
77
+ // Check 3: out-of-scope changes
78
+ const outOfScope = findOutOfScopeChanges(artifacts, session.resolved_repo_path);
79
+ checks.push({
80
+ name: "out_of_scope_changes",
81
+ result: outOfScope.length === 0 ? "pass" : "fail",
82
+ detail:
83
+ outOfScope.length === 0
84
+ ? "All changes are within the session repo_path."
85
+ : `${outOfScope.length} file(s) modified outside session repo_path: ${outOfScope.join(", ")}`,
86
+ reason_code: outOfScope.length > 0 ? "out_of_scope_changes" : undefined,
87
+ });
88
+
89
+ // Check 4: sensitive files
90
+ const sensitiveFiles = findSensitiveChanges(artifacts);
91
+ checks.push({
92
+ name: "sensitive_file_access",
93
+ result: sensitiveFiles.length === 0 ? "pass" : "fail",
94
+ detail:
95
+ sensitiveFiles.length === 0
96
+ ? "No sensitive files modified."
97
+ : `Sensitive files modified: ${sensitiveFiles.join(", ")}`,
98
+ reason_code: sensitiveFiles.length > 0 ? "sensitive_file_modified" : undefined,
99
+ });
100
+
101
+ // Check 5: node_modules modification
102
+ const nodeModulesChanges = findPathChanges(artifacts, "node_modules");
103
+ checks.push({
104
+ name: "node_modules_modified",
105
+ result: nodeModulesChanges.length === 0 ? "pass" : "fail",
106
+ detail:
107
+ nodeModulesChanges.length === 0
108
+ ? "No node_modules modifications."
109
+ : `node_modules modified: ${nodeModulesChanges.join(", ")}`,
110
+ reason_code: nodeModulesChanges.length > 0 ? "node_modules_modified" : undefined,
111
+ });
112
+
113
+ // Check 6: release/dist modification
114
+ const releaseChanges = findPathChanges(artifacts, "release");
115
+ const distChanges = findPathChanges(artifacts, "dist");
116
+ const artifactDirChanges = [...releaseChanges, ...distChanges];
117
+ const hasBuildArtifacts = artifacts.artifact_hygiene.counts.tracked_build_artifacts > 0;
118
+ checks.push({
119
+ name: "release_dist_modified",
120
+ result: artifactDirChanges.length === 0
121
+ ? "pass"
122
+ : hasBuildArtifacts
123
+ ? "warn"
124
+ : "fail",
125
+ detail:
126
+ artifactDirChanges.length === 0
127
+ ? "No release/dist modifications."
128
+ : `release/dist modified: ${artifactDirChanges.join(", ")}${hasBuildArtifacts ? " (build-generated)" : " (not build-generated)"}`,
129
+ reason_code: artifactDirChanges.length > 0
130
+ ? hasBuildArtifacts
131
+ ? "build_artifact_modified"
132
+ : "release_dist_manually_modified"
133
+ : undefined,
134
+ });
135
+
136
+ // Check 7: file deletion
137
+ const deletedFiles = artifacts.changed_files.filter((f) => f.change === "deleted");
138
+ checks.push({
139
+ name: "file_deletion",
140
+ result: deletedFiles.length === 0 ? "pass" : "fail",
141
+ detail:
142
+ deletedFiles.length === 0
143
+ ? "No files deleted."
144
+ : `Files deleted: ${deletedFiles.map((f) => f.path).join(", ")}`,
145
+ reason_code: deletedFiles.length > 0 ? "file_deleted" : undefined,
146
+ });
147
+
148
+ // Check 8: file rename
149
+ const renamedFiles = artifacts.changed_files.filter((f) => f.change === "renamed");
150
+ checks.push({
151
+ name: "file_rename",
152
+ result: renamedFiles.length === 0 ? "pass" : "fail",
153
+ detail:
154
+ renamedFiles.length === 0
155
+ ? "No files renamed."
156
+ : `Files renamed: ${renamedFiles.map((f) => `${f.old_path} → ${f.path}`).join(", ")}`,
157
+ reason_code: renamedFiles.length > 0 ? "file_renamed" : undefined,
158
+ });
159
+
160
+ // Check 9: package-lock / dependency changes
161
+ const packageLockChanges = artifacts.changed_files.filter(
162
+ (f) =>
163
+ f.path === "package-lock.json" ||
164
+ f.path === "package.json" ||
165
+ f.path === "yarn.lock" ||
166
+ f.path === "pnpm-lock.yaml"
167
+ );
168
+ checks.push({
169
+ name: "dependency_changes",
170
+ result: packageLockChanges.length === 0 ? "pass" : "warn",
171
+ detail:
172
+ packageLockChanges.length === 0
173
+ ? "No dependency file changes."
174
+ : `Dependency files changed: ${packageLockChanges.map((f) => f.path).join(", ")}`,
175
+ reason_code: packageLockChanges.length > 0 ? "dependency_file_changed" : undefined,
176
+ });
177
+
178
+ // Check 13: title vs changed files consistency (warning only)
179
+ if (session.title && changedFilesTotal > 0) {
180
+ checks.push({
181
+ name: "title_change_consistency",
182
+ result: "pass",
183
+ detail: `Session title: "${session.title}", ${changedFilesTotal} file(s) changed. (Semantic check not performed - deterministic audit only.)`,
184
+ reason_code: undefined,
185
+ });
186
+ } else {
187
+ checks.push({
188
+ name: "title_change_consistency",
189
+ result: "pass",
190
+ detail: "No title provided or no changes to compare.",
191
+ });
192
+ }
193
+
194
+ // Check 14: suspicious changes
195
+ const suspiciousCount = artifacts.artifact_hygiene.counts.suspicious_changes;
196
+ checks.push({
197
+ name: "suspicious_changes",
198
+ result: suspiciousCount === 0 ? "pass" : "warn",
199
+ detail:
200
+ suspiciousCount === 0
201
+ ? "No suspicious changes detected."
202
+ : `${suspiciousCount} suspicious change(s) detected.`,
203
+ reason_code: suspiciousCount > 0 ? "suspicious_changes" : undefined,
204
+ });
205
+
206
+ // Check 15: runtime generated files
207
+ const runtimeCount = artifacts.artifact_hygiene.counts.runtime_generated_files;
208
+ checks.push({
209
+ name: "runtime_generated_files",
210
+ result: runtimeCount === 0 ? "pass" : "warn",
211
+ detail:
212
+ runtimeCount === 0
213
+ ? "No runtime-generated files detected."
214
+ : `${runtimeCount} runtime-generated file(s) detected.`,
215
+ reason_code: runtimeCount > 0 ? "runtime_generated_files" : undefined,
216
+ });
217
+
218
+ // Check 16: tracked build artifacts
219
+ const buildArtifactCount = artifacts.artifact_hygiene.counts.tracked_build_artifacts;
220
+ checks.push({
221
+ name: "tracked_build_artifacts",
222
+ result: buildArtifactCount === 0 ? "pass" : "warn",
223
+ detail:
224
+ buildArtifactCount === 0
225
+ ? "No tracked build artifacts detected."
226
+ : `${buildArtifactCount} tracked build artifact(s) detected.`,
227
+ reason_code: buildArtifactCount > 0 ? "tracked_build_artifacts" : undefined,
228
+ });
229
+ }
230
+
231
+ // Check 10: at least one verification command run
232
+ const verificationRuns = session.verification_runs || [];
233
+ const hasVerification = verificationRuns.length > 0;
234
+ const hasSourceChanges = artifacts
235
+ ? artifacts.changed_files.some((f) => f.kind === "source")
236
+ : false;
237
+
238
+ checks.push({
239
+ name: "verification_run",
240
+ result: hasVerification
241
+ ? "pass"
242
+ : hasSourceChanges
243
+ ? "fail"
244
+ : "warn",
245
+ detail: hasVerification
246
+ ? `${verificationRuns.length} verification command(s) run.`
247
+ : hasSourceChanges
248
+ ? "Source files were modified but no verification commands were run."
249
+ : "No verification commands were run (no source changes detected).",
250
+ reason_code: !hasVerification
251
+ ? hasSourceChanges
252
+ ? "source_changes_without_verification"
253
+ : "no_verification_run"
254
+ : undefined,
255
+ });
256
+
257
+ // Check 11: verification commands passed
258
+ if (hasVerification) {
259
+ const allPassed = verificationRuns.every((r) => r.passed);
260
+ checks.push({
261
+ name: "verification_passed",
262
+ result: allPassed ? "pass" : "fail",
263
+ detail: allPassed
264
+ ? "All verification commands passed."
265
+ : `${verificationRuns.filter((r) => !r.passed).length} verification command(s) failed.`,
266
+ reason_code: !allPassed ? "verification_failed" : undefined,
267
+ });
268
+ }
269
+
270
+ // Collect results
271
+ for (const check of checks) {
272
+ if (check.reason_code) reasonCodes.push(check.reason_code);
273
+ if (check.result === "fail") blockingFindings.push(`${check.name}: ${check.detail}`);
274
+ if (check.result === "warn") warnings.push(`${check.name}: ${check.detail}`);
275
+ }
276
+
277
+ // Determine decision
278
+ const hasFail = checks.some((c) => c.result === "fail");
279
+ const hasWarn = checks.some((c) => c.result === "warn");
280
+ const decision: "pass" | "warn" | "fail" = hasFail ? "fail" : hasWarn ? "warn" : "pass";
281
+
282
+ const diffPath = join(sessionDir, "diff.patch");
283
+ const summaryPath = join(sessionDir, "summary.md");
284
+ const auditPath = join(sessionDir, "audit.md");
285
+
286
+ const output: DirectSessionAuditOutput = {
287
+ session_id: sessionId,
288
+ decision,
289
+ reason_codes: reasonCodes,
290
+ blocking_findings: blockingFindings,
291
+ warnings,
292
+ evidence: {
293
+ changed_files_total: changedFilesTotal,
294
+ verification_runs: verificationRuns,
295
+ diff_path: diffPath,
296
+ summary_path: summaryPath,
297
+ audit_path: auditPath,
298
+ },
299
+ next_action: decision === "pass"
300
+ ? "Audit passed. Changes are safe to accept."
301
+ : decision === "warn"
302
+ ? "Audit completed with warnings. Review the warnings before accepting changes."
303
+ : "Audit failed. Review the blocking findings and create a new session to fix issues.",
304
+ };
305
+
306
+ // Write audit.json and audit.md
307
+ writeFileSync(
308
+ join(sessionDir, "audit.json"),
309
+ JSON.stringify(output, null, 2),
310
+ "utf-8"
311
+ );
312
+ writeFileSync(auditPath, formatAuditMd(output, checks, session), "utf-8");
313
+
314
+ return output;
315
+ }
316
+
317
+ // ── Helper functions ───────────────────────────────────────────────
318
+
319
+ function findOutOfScopeChanges(
320
+ artifacts: ChangeArtifacts,
321
+ resolvedRepoPath: string
322
+ ): string[] {
323
+ const normalizedRepo = resolve(resolvedRepoPath);
324
+ return artifacts.changed_files
325
+ .filter((f) => {
326
+ const fullPath = resolve(normalizedRepo, f.path);
327
+ const rel = relative(normalizedRepo, fullPath);
328
+ return isAbsolute(rel) || rel.startsWith("..");
329
+ })
330
+ .map((f) => f.path);
331
+ }
332
+
333
+ function findSensitiveChanges(artifacts: ChangeArtifacts): string[] {
334
+ return artifacts.changed_files
335
+ .filter((f) => isSensitivePath(f.path))
336
+ .map((f) => f.path);
337
+ }
338
+
339
+ function findPathChanges(artifacts: ChangeArtifacts, dirName: string): string[] {
340
+ return artifacts.changed_files
341
+ .filter((f) => {
342
+ const normalized = f.path.replace(/\\/g, "/");
343
+ return normalized.startsWith(`${dirName}/`) || normalized.includes(`/${dirName}/`);
344
+ })
345
+ .map((f) => f.path);
346
+ }
347
+
348
+ function formatAuditMd(
349
+ output: DirectSessionAuditOutput,
350
+ checks: AuditCheck[],
351
+ session: DirectSessionRecord
352
+ ): string {
353
+ const lines: string[] = [
354
+ "# Direct Session Audit Report",
355
+ "",
356
+ `**Session ID:** ${output.session_id}`,
357
+ `**Decision:** ${output.decision.toUpperCase()}`,
358
+ `**Changed files:** ${output.evidence.changed_files_total}`,
359
+ "",
360
+ "## Checks",
361
+ "",
362
+ "| Check | Result | Detail |",
363
+ "|---|---|---|",
364
+ ];
365
+
366
+ for (const check of checks) {
367
+ const emoji = check.result === "pass" ? "PASS" : check.result === "warn" ? "WARN" : "FAIL";
368
+ lines.push(`| ${check.name} | ${emoji} | ${check.detail.replace(/\|/g, "\\|")} |`);
369
+ }
370
+
371
+ if (output.blocking_findings.length > 0) {
372
+ lines.push("", "## Blocking Findings", "");
373
+ for (const finding of output.blocking_findings) {
374
+ lines.push(`- ${finding}`);
375
+ }
376
+ }
377
+
378
+ if (output.warnings.length > 0) {
379
+ lines.push("", "## Warnings", "");
380
+ for (const warning of output.warnings) {
381
+ lines.push(`- ${warning}`);
382
+ }
383
+ }
384
+
385
+ lines.push("", "## Evidence", "");
386
+ lines.push(`- Diff: \`${output.evidence.diff_path}\``);
387
+ lines.push(`- Summary: \`${output.evidence.summary_path}\``);
388
+ lines.push(`- Verification runs: ${output.evidence.verification_runs.length}`);
389
+
390
+ if (output.evidence.verification_runs.length > 0) {
391
+ lines.push("", "### Verification Results", "");
392
+ for (const run of output.evidence.verification_runs) {
393
+ lines.push(`- **${run.command}**: ${run.passed ? "PASSED" : "FAILED"} (exit code: ${run.exit_code})`);
394
+ }
395
+ }
396
+
397
+ lines.push("", `**Next action:** ${output.next_action}`);
398
+
399
+ return lines.join("\n");
400
+ }