patchwarden 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) 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/runner/agentInvocation.d.ts +23 -0
  24. package/dist/runner/agentInvocation.js +106 -0
  25. package/dist/runner/changeCapture.d.ts +29 -0
  26. package/dist/runner/changeCapture.js +107 -5
  27. package/dist/runner/runTask.js +70 -44
  28. package/dist/runner/simpleProcess.d.ts +21 -0
  29. package/dist/runner/simpleProcess.js +206 -0
  30. package/dist/runner/watch.js +17 -2
  31. package/dist/security/commandGuard.d.ts +2 -1
  32. package/dist/security/commandGuard.js +25 -3
  33. package/dist/security/riskEngine.d.ts +27 -0
  34. package/dist/security/riskEngine.js +118 -0
  35. package/dist/security/runtimeGuard.d.ts +6 -0
  36. package/dist/security/runtimeGuard.js +28 -0
  37. package/dist/smoke-test.js +1353 -7
  38. package/dist/tools/applyPatch.d.ts +16 -0
  39. package/dist/tools/applyPatch.js +41 -0
  40. package/dist/tools/auditSession.d.ts +5 -0
  41. package/dist/tools/auditSession.js +12 -0
  42. package/dist/tools/auditTask.d.ts +7 -0
  43. package/dist/tools/auditTask.js +94 -2
  44. package/dist/tools/createDirectSession.d.ts +14 -0
  45. package/dist/tools/createDirectSession.js +54 -0
  46. package/dist/tools/createTask.d.ts +48 -1
  47. package/dist/tools/createTask.js +298 -47
  48. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  49. package/dist/tools/finalizeDirectSession.js +84 -0
  50. package/dist/tools/getTaskSummary.d.ts +47 -0
  51. package/dist/tools/getTaskSummary.js +64 -1
  52. package/dist/tools/healthCheck.d.ts +22 -12
  53. package/dist/tools/healthCheck.js +23 -6
  54. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  55. package/dist/tools/readWorkspaceFile.js +48 -2
  56. package/dist/tools/registry.js +217 -19
  57. package/dist/tools/runVerification.d.ts +16 -0
  58. package/dist/tools/runVerification.js +32 -0
  59. package/dist/tools/savePlan.d.ts +1 -0
  60. package/dist/tools/savePlan.js +38 -7
  61. package/dist/tools/searchWorkspace.d.ts +19 -0
  62. package/dist/tools/searchWorkspace.js +205 -0
  63. package/dist/tools/taskTemplates.js +2 -1
  64. package/dist/tools/toolCatalog.d.ts +3 -2
  65. package/dist/tools/toolCatalog.js +32 -5
  66. package/dist/tools/waitForTask.d.ts +2 -2
  67. package/dist/tools/waitForTask.js +1 -1
  68. package/dist/version.d.ts +2 -2
  69. package/dist/version.js +2 -2
  70. package/docs/chatgpt-usage.md +101 -0
  71. package/docs/release-checklist.md +14 -0
  72. package/docs/release-v0.6.0.md +71 -0
  73. package/examples/config.example.json +5 -0
  74. package/examples/openai-tunnel/README.md +11 -4
  75. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  76. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  77. package/package.json +12 -8
  78. package/scripts/brand-check.js +58 -12
  79. package/scripts/control-smoke.js +206 -0
  80. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  81. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  82. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  83. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  84. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  85. package/scripts/lifecycle-smoke.js +66 -6
  86. package/scripts/manage-patchwarden.ps1 +639 -0
  87. package/scripts/mcp-manifest-check.js +100 -58
  88. package/scripts/mcp-smoke.js +157 -2
  89. package/scripts/pack-clean.js +3 -4
  90. package/scripts/package-manifest-check.js +78 -0
  91. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  92. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  93. package/scripts/restart-patchwarden.ps1 +5 -4
  94. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  95. package/scripts/tunnel-supervisor-smoke.js +36 -4
  96. package/scripts/watcher-supervisor-smoke.js +10 -6
  97. package/src/assessments/agentAssessor.ts +324 -0
  98. package/src/assessments/assessmentStore.ts +426 -0
  99. package/src/assessments/confirmCli.ts +29 -0
  100. package/src/config.ts +178 -4
  101. package/src/direct/directAudit.ts +400 -0
  102. package/src/direct/directGuards.ts +279 -0
  103. package/src/direct/directPatch.ts +258 -0
  104. package/src/direct/directSessionStore.ts +345 -0
  105. package/src/direct/directVerification.ts +138 -0
  106. package/src/doctor.ts +103 -7
  107. package/src/runner/agentInvocation.ts +125 -0
  108. package/src/runner/changeCapture.ts +140 -5
  109. package/src/runner/runTask.ts +61 -43
  110. package/src/runner/simpleProcess.ts +223 -0
  111. package/src/runner/watch.ts +18 -2
  112. package/src/security/commandGuard.ts +46 -4
  113. package/src/security/riskEngine.ts +160 -0
  114. package/src/security/runtimeGuard.ts +41 -0
  115. package/src/smoke-test.ts +1291 -4
  116. package/src/tools/applyPatch.ts +86 -0
  117. package/src/tools/auditSession.ts +28 -0
  118. package/src/tools/auditTask.ts +100 -2
  119. package/src/tools/createDirectSession.ts +113 -0
  120. package/src/tools/createTask.ts +405 -55
  121. package/src/tools/finalizeDirectSession.ts +144 -0
  122. package/src/tools/getTaskSummary.ts +111 -1
  123. package/src/tools/healthCheck.ts +23 -6
  124. package/src/tools/readWorkspaceFile.ts +85 -2
  125. package/src/tools/registry.ts +242 -19
  126. package/src/tools/runVerification.ts +58 -0
  127. package/src/tools/savePlan.ts +57 -7
  128. package/src/tools/searchWorkspace.ts +275 -0
  129. package/src/tools/taskTemplates.ts +2 -1
  130. package/src/tools/toolCatalog.ts +35 -6
  131. package/src/tools/waitForTask.ts +3 -3
  132. package/src/version.ts +2 -2
  133. package/tsconfig.json +18 -17
  134. package/Check-PatchWarden-Health.cmd +0 -6
  135. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  136. package/Restart-PatchWarden.cmd +0 -19
  137. package/Start-PatchWarden-Tunnel.cmd +0 -7
  138. package/docs/release-v0.3.0.md +0 -43
  139. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,125 @@
1
+ import { basename } from "node:path";
2
+ import { PatchWardenConfig } from "../config.js";
3
+ import { guardAgentCommand, sanitizePromptArg, type AllowedCommand } from "../security/commandGuard.js";
4
+
5
+ export interface AgentInvocation {
6
+ command: string;
7
+ args: string[];
8
+ cwd: string;
9
+ commandLabel: string;
10
+ promptMode: "inline" | "file";
11
+ promptFilePath?: string;
12
+ }
13
+
14
+ /**
15
+ * Build agent invocation parameters from config.
16
+ * Replaces {repo}, {prompt}, and {prompt_file} placeholders.
17
+ * runTask and agentAssessor share this function to ensure consistent agent startup.
18
+ */
19
+ export function buildAgentInvocation(
20
+ agentName: string,
21
+ repoPath: string,
22
+ prompt: string,
23
+ config: PatchWardenConfig,
24
+ promptFilePath?: string
25
+ ): AgentInvocation {
26
+ const agentCmd = guardAgentCommand(agentName, config);
27
+ const sanitizedPrompt = sanitizePromptArg(prompt);
28
+
29
+ const hasPromptFilePlaceholder = agentCmd.args.includes("{prompt_file}");
30
+ const promptMode: "inline" | "file" = hasPromptFilePlaceholder && promptFilePath ? "file" : "inline";
31
+
32
+ const resolvedArgs = agentCmd.args.map((arg) => {
33
+ if (arg === "{repo}") return repoPath;
34
+ if (arg === "{prompt}") return sanitizedPrompt;
35
+ if (arg === "{prompt_file}" && promptMode === "file" && promptFilePath) return promptFilePath;
36
+ return arg;
37
+ });
38
+
39
+ return {
40
+ command: agentCmd.command,
41
+ args: resolvedArgs,
42
+ cwd: repoPath,
43
+ commandLabel: `${basename(agentCmd.command)} (configured agent command)`,
44
+ promptMode,
45
+ ...(promptMode === "file" && promptFilePath ? { promptFilePath } : {}),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Build task execution prompt. Mechanically extracted from runTask.ts.
51
+ */
52
+ export function buildExecutionPrompt(plan: string, repoPath: string, testCommand: string): string {
53
+ let prompt = `You are executing a pre-written plan in a local repository.
54
+
55
+ ## Repository
56
+ ${repoPath}
57
+
58
+ ## Plan
59
+ ${plan}
60
+
61
+ ## Instructions
62
+ 1. Read the plan carefully.
63
+ 2. Implement the changes in this repository only.
64
+ 3. Do NOT modify files outside this repository.
65
+ 4. Leave repository changes uncommitted for review; remote operations are outside this task.
66
+ 5. After implementing, describe what you changed.
67
+ 6. Output a summary with what was done, files modified, and issues encountered.
68
+ `;
69
+ if (testCommand) {
70
+ prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
71
+ }
72
+ return prompt;
73
+ }
74
+
75
+ /**
76
+ * Build agentAssessor inspect-only prompt.
77
+ */
78
+ export function buildAssessmentPrompt(goal: string, planContent: string, repoPath: string): string {
79
+ return `You are performing a READ-ONLY risk assessment of a planned task. Do NOT modify any files.
80
+
81
+ ## Repository
82
+ ${repoPath}
83
+
84
+ ## Goal
85
+ ${goal}
86
+
87
+ ## Plan
88
+ ${planContent}
89
+
90
+ ## Instructions
91
+ 1. Read the plan and goal carefully.
92
+ 2. Inspect the repository to understand the scope of changes.
93
+ 3. Do NOT create, edit, delete, rename, or generate any files.
94
+ 4. Assess the risk level of executing this plan.
95
+ 5. Identify affected file paths within the repository.
96
+ 6. Check for destructive actions, sensitive file access, or out-of-scope changes.
97
+ 7. Output your assessment as JSON after the marker ===ASSESSMENT_JSON=== on a new line.
98
+
99
+ ## Required JSON output format
100
+ After your analysis, output exactly this format:
101
+
102
+ ===ASSESSMENT_JSON===
103
+ {
104
+ "risk_level": "low" | "medium" | "high",
105
+ "reason_codes": ["short", "descriptive", "codes"],
106
+ "affected_paths": ["relative/path/to/file"],
107
+ "destructive_actions": ["description of any destructive actions"],
108
+ "requires_user_confirm": false,
109
+ "confidence": 0.0,
110
+ "notes": "Brief summary of findings"
111
+ }
112
+
113
+ ## Risk level guidelines
114
+ - low: Small source change, no sensitive files, no destructive actions
115
+ - medium: Multiple files, build artifacts, dependency changes, or uncertain scope
116
+ - high: Destructive actions, sensitive file access, out-of-scope changes, or large-scale deletion
117
+
118
+ ## Constraints
119
+ - affected_paths must be relative paths within the repository (no absolute paths)
120
+ - reason_codes: max 50 entries, each max 100 chars
121
+ - destructive_actions: max 20 entries, each max 200 chars
122
+ - notes: max 2000 chars
123
+ - confidence: number from 0.0 to 1.0
124
+ `;
125
+ }
@@ -19,6 +19,8 @@ const SKIP_DIRECTORIES = new Set([".git", ".patchwarden", "node_modules"]);
19
19
  export interface FileFingerprint {
20
20
  size: number;
21
21
  sha256: string;
22
+ tracked: boolean;
23
+ ignored: boolean;
22
24
  }
23
25
 
24
26
  export interface RepoSnapshot {
@@ -37,6 +39,33 @@ export interface ChangedFile {
37
39
  old_path?: string;
38
40
  before_sha256: string | null;
39
41
  after_sha256: string | null;
42
+ tracked: boolean;
43
+ ignored: boolean;
44
+ kind: "source" | "build_artifact" | "runtime_generated";
45
+ }
46
+
47
+ export interface ClassifiedChange {
48
+ path: string;
49
+ change: ChangedFile["change"];
50
+ tracked: boolean;
51
+ ignored: boolean;
52
+ kind: ChangedFile["kind"];
53
+ reason: string;
54
+ }
55
+
56
+ export interface ArtifactHygiene {
57
+ counts: {
58
+ source_changes: number;
59
+ tracked_build_artifacts: number;
60
+ ignored_untracked_artifacts: number;
61
+ runtime_generated_files: number;
62
+ suspicious_changes: number;
63
+ };
64
+ source_changes: ClassifiedChange[];
65
+ tracked_build_artifacts: ClassifiedChange[];
66
+ ignored_untracked_artifacts: ClassifiedChange[];
67
+ runtime_generated_files: ClassifiedChange[];
68
+ suspicious_changes: ClassifiedChange[];
40
69
  }
41
70
 
42
71
  export interface ChangeArtifacts {
@@ -57,6 +86,7 @@ export interface ChangeArtifacts {
57
86
  workspace_dirty_after: boolean;
58
87
  patch_mode: "textual" | "no_changes" | "hash_only";
59
88
  unavailable_reason: string | null;
89
+ artifact_hygiene: ArtifactHygiene;
60
90
  }
61
91
 
62
92
  export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
@@ -65,11 +95,23 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
65
95
  let head: string | null = null;
66
96
  let status = "";
67
97
  let paths: string[] = [];
98
+ const trackedPaths = new Set<string>();
99
+ const ignoredPaths = new Set<string>();
68
100
 
69
101
  if (isGit) {
70
102
  const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
71
103
  if (headResult.status === 0) head = headResult.stdout.trim() || null;
72
104
  status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
105
+ const tracked = runGit(repoPath, ["ls-files", "-z"]);
106
+ if (tracked.status === 0) {
107
+ for (const path of tracked.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
108
+ }
109
+ const ignored = runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]);
110
+ if (ignored.status === 0) {
111
+ for (const path of ignored.stdout.split("\0").filter(Boolean)) ignoredPaths.add(normalizePath(path));
112
+ } else {
113
+ warnings.push("git ignored-file discovery failed; ignored classification may be incomplete");
114
+ }
73
115
  const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
74
116
  if (listed.status === 0) {
75
117
  paths = [...new Set([
@@ -92,7 +134,7 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
92
134
 
93
135
  const files: Record<string, FileFingerprint> = {};
94
136
  for (const inputPath of paths.sort()) {
95
- const normalized = inputPath.replace(/\\/g, "/");
137
+ const normalized = normalizePath(inputPath);
96
138
  if (!normalized || normalized.startsWith(".patchwarden/") || isSensitivePath(normalized)) continue;
97
139
  const absolutePath = resolve(repoPath, inputPath);
98
140
  try {
@@ -101,7 +143,12 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
101
143
  const sha256 = stat.size <= MAX_HASH_BYTES
102
144
  ? createHash("sha256").update(readFileSync(absolutePath)).digest("hex")
103
145
  : `large-file:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
104
- files[normalized] = { size: stat.size, sha256 };
146
+ files[normalized] = {
147
+ size: stat.size,
148
+ sha256,
149
+ tracked: trackedPaths.has(normalized),
150
+ ignored: !trackedPaths.has(normalized) && ignoredPaths.has(normalized),
151
+ };
105
152
  } catch {
106
153
  warnings.push(`could not fingerprint: ${normalized}`);
107
154
  }
@@ -128,6 +175,7 @@ export function buildChangeArtifacts(
128
175
  after: RepoSnapshot
129
176
  ): ChangeArtifacts {
130
177
  const changedFiles = compareSnapshots(before, after);
178
+ const artifactHygiene = classifyArtifactHygiene(changedFiles);
131
179
  const sections: string[] = [];
132
180
  const scopedPaths = [...new Set(changedFiles.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))];
133
181
 
@@ -179,6 +227,7 @@ export function buildChangeArtifacts(
179
227
  ? "Git could not produce a textual patch for the changed files; hash evidence remains available."
180
228
  : "Repository is not a Git worktree; only bounded hash evidence is available.")
181
229
  : null,
230
+ artifact_hygiene: artifactHygiene,
182
231
  };
183
232
  }
184
233
 
@@ -232,11 +281,11 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
232
281
  const left = before.files[path];
233
282
  const right = after.files[path];
234
283
  if (!left && right) {
235
- changed.push({ path, change: "added", before_sha256: null, after_sha256: right.sha256 });
284
+ changed.push(classifyChangedFile(path, "added", null, right));
236
285
  } else if (left && !right) {
237
- changed.push({ path, change: "deleted", before_sha256: left.sha256, after_sha256: null });
286
+ changed.push(classifyChangedFile(path, "deleted", left, null));
238
287
  } else if (left.sha256 !== right.sha256) {
239
- changed.push({ path, change: "modified", before_sha256: left.sha256, after_sha256: right.sha256 });
288
+ changed.push(classifyChangedFile(path, "modified", left, right));
240
289
  }
241
290
  }
242
291
  const deletedByHash = new Map<string, ChangedFile[]>();
@@ -260,6 +309,9 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
260
309
  change: "renamed",
261
310
  before_sha256: source.before_sha256,
262
311
  after_sha256: file.after_sha256,
312
+ tracked: file.tracked || source.tracked,
313
+ ignored: file.ignored,
314
+ kind: classifyPathKind(file.path),
263
315
  });
264
316
  }
265
317
 
@@ -267,6 +319,89 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
267
319
  .sort((left, right) => left.path.localeCompare(right.path));
268
320
  }
269
321
 
322
+ export function emptyArtifactHygiene(): ArtifactHygiene {
323
+ return {
324
+ counts: {
325
+ source_changes: 0,
326
+ tracked_build_artifacts: 0,
327
+ ignored_untracked_artifacts: 0,
328
+ runtime_generated_files: 0,
329
+ suspicious_changes: 0,
330
+ },
331
+ source_changes: [],
332
+ tracked_build_artifacts: [],
333
+ ignored_untracked_artifacts: [],
334
+ runtime_generated_files: [],
335
+ suspicious_changes: [],
336
+ };
337
+ }
338
+
339
+ function classifyChangedFile(
340
+ path: string,
341
+ change: ChangedFile["change"],
342
+ before: FileFingerprint | null,
343
+ after: FileFingerprint | null
344
+ ): ChangedFile {
345
+ return {
346
+ path,
347
+ change,
348
+ before_sha256: before?.sha256 || null,
349
+ after_sha256: after?.sha256 || null,
350
+ tracked: Boolean(after?.tracked || before?.tracked),
351
+ ignored: Boolean(after?.ignored ?? before?.ignored),
352
+ kind: classifyPathKind(path),
353
+ };
354
+ }
355
+
356
+ function classifyArtifactHygiene(changes: ChangedFile[]): ArtifactHygiene {
357
+ const hygiene = emptyArtifactHygiene();
358
+ const entries = changes.map((change): ClassifiedChange => ({
359
+ path: change.path,
360
+ change: change.change,
361
+ tracked: change.tracked,
362
+ ignored: change.ignored,
363
+ kind: change.kind,
364
+ reason: classificationReason(change),
365
+ }));
366
+ hygiene.source_changes = entries.filter((entry) => entry.kind === "source" && !entry.ignored);
367
+ hygiene.tracked_build_artifacts = entries.filter((entry) => entry.kind === "build_artifact" && entry.tracked);
368
+ hygiene.ignored_untracked_artifacts = entries.filter((entry) => entry.ignored && !entry.tracked);
369
+ hygiene.runtime_generated_files = entries.filter((entry) => entry.kind === "runtime_generated");
370
+ hygiene.suspicious_changes = entries.filter((entry) =>
371
+ (entry.kind === "build_artifact" || entry.kind === "runtime_generated") && !entry.ignored
372
+ );
373
+ hygiene.counts = {
374
+ source_changes: hygiene.source_changes.length,
375
+ tracked_build_artifacts: hygiene.tracked_build_artifacts.length,
376
+ ignored_untracked_artifacts: hygiene.ignored_untracked_artifacts.length,
377
+ runtime_generated_files: hygiene.runtime_generated_files.length,
378
+ suspicious_changes: hygiene.suspicious_changes.length,
379
+ };
380
+ return hygiene;
381
+ }
382
+
383
+ function classifyPathKind(path: string): ChangedFile["kind"] {
384
+ const normalized = normalizePath(path).toLowerCase();
385
+ const parts = normalized.split("/");
386
+ const basename = parts[parts.length - 1] || "";
387
+ if (basename === "sync-store.json" || /\.(log|tmp|temp|pid)$/.test(basename)) return "runtime_generated";
388
+ if (parts.some((part) => ["dist", "release", "build", "out", "coverage", ".next"].includes(part))) return "build_artifact";
389
+ if (/\.(exe|dll|pak|bin|zip|tgz|tar\.gz)$/.test(basename)) return "build_artifact";
390
+ return "source";
391
+ }
392
+
393
+ function classificationReason(change: ChangedFile): string {
394
+ if (change.ignored) return "untracked path is ignored by repository Git rules";
395
+ if (change.kind === "build_artifact" && change.tracked) return "artifact-like path is tracked by Git and requires review";
396
+ if (change.kind === "build_artifact") return "artifact-like path is not ignored and requires review";
397
+ if (change.kind === "runtime_generated") return "runtime-generated path is not ignored and requires review";
398
+ return change.tracked ? "tracked source change" : "untracked source change";
399
+ }
400
+
401
+ function normalizePath(value: string): string {
402
+ return value.replace(/\\/g, "/");
403
+ }
404
+
270
405
  function walkWorkspace(root: string): string[] {
271
406
  const result: string[] = [];
272
407
  const visit = (directory: string) => {
@@ -6,21 +6,22 @@ import {
6
6
  writeFileSync,
7
7
  type WriteStream,
8
8
  } from "node:fs";
9
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { isAbsolute, join, relative, resolve } from "node:path";
10
10
  import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
11
11
  import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
12
12
  import {
13
- guardAgentCommand,
14
13
  guardTestCommand,
15
- sanitizePromptArg,
16
14
  } from "../security/commandGuard.js";
17
15
  import { writeTaskProgress } from "../taskProgress.js";
18
16
  import { writeTaskRuntime } from "../taskRuntime.js";
17
+ import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
18
+ import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
19
19
  import type { TaskPhase, TaskStatus } from "../tools/createTask.js";
20
20
  import {
21
21
  buildChangeArtifacts,
22
22
  captureRepoSnapshot,
23
23
  compareSnapshots,
24
+ emptyArtifactHygiene,
24
25
  writeSnapshot,
25
26
  type ChangedFile,
26
27
  type ChangeArtifacts,
@@ -94,7 +95,6 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
94
95
  let verifyCommands: string[];
95
96
  let timeoutSeconds: number;
96
97
  try {
97
- verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
98
98
  timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
99
99
  } catch (error) {
100
100
  return failBeforeExecution(taskId, taskDir, errorMessage(error));
@@ -109,6 +109,28 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
109
109
  const message = `repo_path validation failed: ${errorMessage(error)}`;
110
110
  return failBeforeExecution(taskId, taskDir, message);
111
111
  }
112
+ try {
113
+ verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
114
+ } catch (error) {
115
+ const message = `verification metadata validation failed: ${errorMessage(error)}`;
116
+ return failBeforeExecution(taskId, taskDir, message);
117
+ }
118
+
119
+ // ── Assessment freshness revalidation before execution ──
120
+ const assessmentId = String(initialStatus.assessment_id || "");
121
+ if (assessmentId) {
122
+ try {
123
+ const preExecSnapshot = captureRepoSnapshot(repoPath);
124
+ const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
125
+ if (!validation.valid) {
126
+ const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
127
+ return failBeforeExecution(taskId, taskDir, message);
128
+ }
129
+ } catch (error) {
130
+ const message = `assessment validation error: ${errorMessage(error)}`;
131
+ return failBeforeExecution(taskId, taskDir, message);
132
+ }
133
+ }
112
134
 
113
135
  updateStatus(taskDir, {
114
136
  status: "running",
@@ -143,19 +165,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
143
165
  const planFile = resolve(plansDir, planId, "plan.md");
144
166
  if (!existsSync(planFile)) throw new Error(`Plan not found: "${planId}". Save the plan first.`);
145
167
  const planContent = readFileSync(planFile, "utf-8");
146
- const agentCmd = guardAgentCommand(agentName, config);
147
- const prompt = sanitizePromptArg(buildExecutionPrompt(planContent, repoPath, testCommand));
148
- const resolvedArgs = agentCmd.args.map((arg) => {
149
- if (arg === "{repo}") return repoPath;
150
- if (arg === "{prompt}") return prompt;
151
- return arg;
152
- });
153
- const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
168
+ const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
169
+ const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
170
+ const agentCommandLabel = invocation.commandLabel;
154
171
 
155
172
  setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
156
173
  agentResult = await runManagedProcess({
157
- command: agentCmd.command,
158
- args: resolvedArgs,
174
+ command: invocation.command,
175
+ args: invocation.args,
159
176
  cwd: repoPath,
160
177
  taskDir,
161
178
  statusFile,
@@ -232,6 +249,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
232
249
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
233
250
  patch_mode: "hash_only",
234
251
  unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
252
+ artifact_hygiene: emptyArtifactHygiene(),
235
253
  };
236
254
  finalError ||= `Change capture failed: ${errorMessage(error)}`;
237
255
  finalStatus = "failed";
@@ -326,6 +344,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
326
344
  change_policy: changePolicy,
327
345
  summary: finalError || "Agent execution and configured verification completed successfully.",
328
346
  changed_files: changes.changed_files,
347
+ artifact_hygiene: changes.artifact_hygiene,
329
348
  out_of_scope_changes: outOfScopeChanges,
330
349
  verify_status: verifyJson.status,
331
350
  verify_commands: verifyJson.commands,
@@ -365,6 +384,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
365
384
  finished_at: finishedAt,
366
385
  error: finalError,
367
386
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
387
+ artifact_hygiene_counts: changes.artifact_hygiene.counts,
368
388
  out_of_scope_changes: outOfScopeChanges,
369
389
  verify_status: verifyJson.status,
370
390
  verify_commands: verifyCommands,
@@ -483,6 +503,8 @@ async function runManagedProcess(options: {
483
503
  let spawnError: string | null = null;
484
504
  let terminationReason: TerminationReason = null;
485
505
  let forceTimer: ReturnType<typeof setTimeout> | null = null;
506
+ let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
507
+ let childExitFinish: ((code: number | null) => void) | null = null;
486
508
  let terminationStarted = false;
487
509
 
488
510
  const heartbeat = () => {
@@ -520,6 +542,15 @@ async function runManagedProcess(options: {
520
542
  gracefulKill(child);
521
543
  forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
522
544
  }
545
+ // Fallback: if neither close nor exit fires within 10s after taskkill,
546
+ // force-resolve to prevent the runner from hanging indefinitely.
547
+ // Only starts AFTER termination is requested — normal long tasks are unaffected.
548
+ fallbackTimer = setTimeout(() => {
549
+ try { forceKill(child); } catch {}
550
+ try { child.kill("SIGKILL"); } catch {}
551
+ // Resolve the exit promise via the shared finish handle
552
+ if (childExitFinish) childExitFinish(null);
553
+ }, 10000);
523
554
  };
524
555
 
525
556
  child.stdout?.on("data", (chunk: Buffer) => {
@@ -540,9 +571,18 @@ async function runManagedProcess(options: {
540
571
  const finish = (code: number | null) => {
541
572
  if (settled) return;
542
573
  settled = true;
574
+ if (fallbackTimer) clearTimeout(fallbackTimer);
575
+ childExitFinish = null;
543
576
  resolveExit(code);
544
577
  };
578
+ childExitFinish = finish;
545
579
  child.once("close", (code) => finish(code));
580
+ child.once("exit", (code) => {
581
+ // On Windows, "close" can lag behind "exit" when stdio pipes drain
582
+ // after taskkill. Resolve on exit to avoid hanging the runner, but
583
+ // still let "close" fire if it arrives first.
584
+ finish(code);
585
+ });
546
586
  child.once("error", (error) => {
547
587
  spawnError = error.message;
548
588
  finish(null);
@@ -551,8 +591,8 @@ async function runManagedProcess(options: {
551
591
 
552
592
  clearInterval(heartbeatTimer);
553
593
  if (forceTimer) clearTimeout(forceTimer);
554
- stdoutStream?.end();
555
- stderrStream?.end();
594
+ stdoutStream?.destroy();
595
+ stderrStream?.destroy();
556
596
  writeTaskRuntime(options.taskDir, {
557
597
  phase: options.phase,
558
598
  last_heartbeat_at: new Date().toISOString(),
@@ -572,7 +612,7 @@ async function runTrustedTestCommand(
572
612
  deadlineMs: number
573
613
  ): Promise<TestExecutionResult> {
574
614
  const config = getConfig();
575
- const trusted = guardTestCommand(testCommand, config);
615
+ const trusted = guardTestCommand(testCommand, config, repoPath);
576
616
  const startedAtMs = Date.now();
577
617
  const startedAt = new Date(startedAtMs).toISOString();
578
618
  const parts = trusted.split(/\s+/).filter(Boolean);
@@ -606,29 +646,6 @@ async function runTrustedTestCommand(
606
646
  };
607
647
  }
608
648
 
609
- function buildExecutionPrompt(plan: string, repoPath: string, testCommand: string): string {
610
- let prompt = `You are executing a pre-written plan in a local repository.
611
-
612
- ## Repository
613
- ${repoPath}
614
-
615
- ## Plan
616
- ${plan}
617
-
618
- ## Instructions
619
- 1. Read the plan carefully.
620
- 2. Implement the changes in this repository only.
621
- 3. Do NOT modify files outside this repository.
622
- 4. Do NOT commit or push changes.
623
- 5. After implementing, describe what you changed.
624
- 6. Output a summary with what was done, files modified, and issues encountered.
625
- `;
626
- if (testCommand) {
627
- prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
628
- }
629
- return prompt;
630
- }
631
-
632
649
  function buildTestLog(result: TestExecutionResult): string {
633
650
  if (result.skipped) return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
634
651
  return [
@@ -777,14 +794,15 @@ function buildVerifyLog(commands: VerifyCommandRecord[]): string {
777
794
  function normalizeVerifyCommands(
778
795
  value: unknown,
779
796
  legacyTestCommand: string,
780
- config: ReturnType<typeof getConfig>
797
+ config: ReturnType<typeof getConfig>,
798
+ repoPath: string
781
799
  ): string[] {
782
800
  if (value !== undefined && !Array.isArray(value)) {
783
801
  throw new Error("Invalid task verify_commands metadata; expected an array.");
784
802
  }
785
803
  return [...new Set([
786
- ...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config)),
787
- ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
804
+ ...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config, repoPath)),
805
+ ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
788
806
  ])];
789
807
  }
790
808