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
@@ -1,9 +1,24 @@
1
- import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { resolve, join, dirname } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { getTasksDir, getPlansDir, getConfig } from "../config.js";
5
6
  import { guardPath, guardWorkspacePath, guardReadPath } from "../security/pathGuard.js";
6
7
  import { guardTestCommand } from "../security/commandGuard.js";
8
+ import { guardRuntimeSelfModification } from "../security/runtimeGuard.js";
9
+ import { guardPlanContent } from "../security/planGuard.js";
10
+ import { assessRisk } from "../security/riskEngine.js";
11
+ import {
12
+ createAssessment,
13
+ readAssessment,
14
+ validateAssessmentFreshness,
15
+ generateAssessmentId,
16
+ createAssessmentDir,
17
+ type AssessmentRecord,
18
+ type AgentAssessmentSummary,
19
+ } from "../assessments/assessmentStore.js";
20
+ import { runAgentAssessment } from "../assessments/agentAssessor.js";
21
+ import { captureRepoSnapshot } from "../runner/changeCapture.js";
7
22
  import { writeTaskProgress } from "../taskProgress.js";
8
23
  import { PatchWardenError } from "../errors.js";
9
24
  import { savePlan } from "./savePlan.js";
@@ -53,13 +68,59 @@ export interface CreateTaskInput {
53
68
  template?: TaskTemplateName;
54
69
  goal?: string;
55
70
  source_task_id?: string;
56
- agent: string;
71
+ agent?: string;
57
72
  repo_path?: string;
58
73
  test_command?: string;
59
74
  verify_commands?: string[];
60
75
  timeout_seconds?: number;
76
+ execution_mode?: "assess_only" | "execute";
77
+ assessment_id?: string;
61
78
  }
62
79
 
80
+ export interface AssessOnlyOutput {
81
+ assessment_id: string;
82
+ assessment_short_id: string;
83
+ decision: "allow" | "needs_confirm" | "blocked";
84
+ risk_level: "low" | "medium" | "high";
85
+ risk_hints: string[];
86
+ hard_rule_hits: string[];
87
+ reason_codes: string[];
88
+ expires_at: string;
89
+ requires_confirm: boolean;
90
+ workspace_snapshot_summary: {
91
+ head: string | null;
92
+ file_count: number;
93
+ workspace_dirty: boolean;
94
+ snapshot_truncated: boolean;
95
+ };
96
+ next_action: string;
97
+ next_tool_call?: {
98
+ name: "create_task";
99
+ arguments: {
100
+ execution_mode: "execute";
101
+ assessment_id: string;
102
+ };
103
+ };
104
+ local_confirmation: {
105
+ required: boolean;
106
+ command: "patchwarden-confirm" | null;
107
+ arguments: string[];
108
+ };
109
+ suggestion?: string;
110
+ agent_assessment?: {
111
+ status: string;
112
+ merged_risk: string;
113
+ merged_decision: string;
114
+ confidence: number | null;
115
+ notes: string | null;
116
+ read_only_violation: boolean;
117
+ stdout_truncated: boolean;
118
+ stderr_truncated: boolean;
119
+ } | null;
120
+ }
121
+
122
+ export type CreateTaskResult = CreateTaskOutput | AssessOnlyOutput;
123
+
63
124
  export interface CreateTaskOutput {
64
125
  task_id: string;
65
126
  plan_id: string;
@@ -85,15 +146,39 @@ export interface CreateTaskOutput {
85
146
  };
86
147
  }
87
148
 
88
- export function createTask(input: CreateTaskInput): CreateTaskOutput {
149
+ export function createTask(input: CreateTaskInput & { execution_mode: "assess_only" }): AssessOnlyOutput;
150
+ export function createTask(input: CreateTaskInput): CreateTaskOutput;
151
+ export function createTask(input: CreateTaskInput): CreateTaskResult {
89
152
  const config = getConfig();
90
153
  const tasksDir = getTasksDir(config);
91
154
  const plansDir = getPlansDir(config);
92
155
 
156
+ const executionMode = input.execution_mode || "execute";
157
+
158
+ // ── assessment_id execute mode: load record and override params ──
159
+ let assessmentRecord: AssessmentRecord | null = null;
160
+ let effectiveInput = input;
161
+ if (executionMode === "execute" && input.assessment_id) {
162
+ assessmentRecord = readAssessment(input.assessment_id);
163
+ // Parameter mismatch check: if ChatGPT passes params that differ from the assessment, reject
164
+ effectiveInput = mergeAssessmentIntoInput(input, assessmentRecord);
165
+ }
166
+
167
+ // ── Validate required fields ──
168
+ // agent and repo_path are required unless assessment_id fills them.
169
+ // assess_only always requires them because the risk engine needs to check them.
170
+ if (!effectiveInput.agent || effectiveInput.agent.trim() === "") {
171
+ throw new PatchWardenError(
172
+ "agent_required",
173
+ "create_task requires an agent name.",
174
+ "Pass a configured agent name, or use assessment_id from a prior assess_only call."
175
+ );
176
+ }
177
+
93
178
  const planSources = [
94
- input.plan_id?.trim() ? "plan_id" : "",
95
- input.inline_plan?.trim() ? "inline_plan" : "",
96
- input.template ? "template" : "",
179
+ effectiveInput.plan_id?.trim() ? "plan_id" : "",
180
+ effectiveInput.inline_plan?.trim() ? "inline_plan" : "",
181
+ effectiveInput.template ? "template" : "",
97
182
  ].filter(Boolean);
98
183
  if (planSources.length !== 1) {
99
184
  throw new PatchWardenError(
@@ -102,16 +187,16 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
102
187
  "Use an existing plan_id, pass inline_plan text, or choose one built-in template."
103
188
  );
104
189
  }
105
- if (input.template && !TASK_TEMPLATE_NAMES.includes(input.template)) {
190
+ if (effectiveInput.template && !TASK_TEMPLATE_NAMES.includes(effectiveInput.template)) {
106
191
  throw new PatchWardenError(
107
192
  "invalid_task_template",
108
- `Unknown task template "${input.template}".`,
193
+ `Unknown task template "${effectiveInput.template}".`,
109
194
  `Use one of: ${TASK_TEMPLATE_NAMES.join(", ")}.`
110
195
  );
111
196
  }
112
197
 
113
198
  // Resolve repo alias if configured
114
- let resolvedRepoPath = input.repo_path?.trim() || "";
199
+ let resolvedRepoPath = effectiveInput.repo_path?.trim() || "";
115
200
  const aliases = (config as any).repoAliases as Record<string, string> | undefined;
116
201
  if (aliases && resolvedRepoPath && aliases[resolvedRepoPath]) {
117
202
  resolvedRepoPath = aliases[resolvedRepoPath];
@@ -128,10 +213,10 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
128
213
  }
129
214
 
130
215
  // Validate agent
131
- if (!config.agents[input.agent]) {
216
+ if (!config.agents[effectiveInput.agent]) {
132
217
  throw new PatchWardenError(
133
218
  "agent_not_configured",
134
- `Unknown agent "${input.agent}". Available: ${Object.keys(config.agents).join(", ")}`,
219
+ `Unknown agent "${effectiveInput.agent}". Available: ${Object.keys(config.agents).join(", ")}`,
135
220
  "Call list_agents and use an available configured agent."
136
221
  );
137
222
  }
@@ -162,45 +247,23 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
162
247
 
163
248
  // Runtime self-modification protection: refuse to modify the active
164
249
  // PatchWarden runtime directory or its critical subdirectories.
165
- const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
166
- const criticalDirs = ["dist", "src", "scripts", "release"];
167
- if (safeRepoPath === runtimeRoot || safeRepoPath.startsWith(runtimeRoot + resolve("/")[0])) {
168
- const isCritical = criticalDirs.some((dir) =>
169
- safeRepoPath === join(runtimeRoot, dir) ||
170
- safeRepoPath.startsWith(join(runtimeRoot, dir) + resolve("/")[0])
171
- );
172
- if (safeRepoPath === runtimeRoot || isCritical) {
173
- throw new PatchWardenError(
174
- "runtime_self_modification_blocked",
175
- `repo_path "${resolvedRepoPath}" points to the active PatchWarden runtime or its critical subdirectories.`,
176
- "Use a dev copy or git worktree for PatchWarden development. The running MCP server must not be modified by a task.",
177
- true,
178
- {
179
- operation: "create_task",
180
- path: resolvedRepoPath,
181
- resolved_repo_path: safeRepoPath,
182
- runtime_root: runtimeRoot,
183
- safe_alternative: "Clone or copy PatchWarden to a separate directory for development tasks.",
184
- }
185
- );
186
- }
187
- }
250
+ guardRuntimeSelfModification(safeRepoPath);
188
251
 
189
252
  // Validate test command — must be in allowlist, no swallowing
190
253
  let testCmd = "";
191
- if (input.test_command && input.test_command.trim() !== "") {
192
- testCmd = guardTestCommand(input.test_command, config);
254
+ if (effectiveInput.test_command && effectiveInput.test_command.trim() !== "") {
255
+ testCmd = guardTestCommand(effectiveInput.test_command, config, safeRepoPath);
193
256
  // guardTestCommand throws if not in allowedTestCommands
194
257
  }
195
258
 
196
- if (input.verify_commands !== undefined && !Array.isArray(input.verify_commands)) {
259
+ if (effectiveInput.verify_commands !== undefined && !Array.isArray(effectiveInput.verify_commands)) {
197
260
  throw new PatchWardenError(
198
261
  "invalid_verify_commands",
199
262
  "verify_commands must be an array of allow-listed command strings.",
200
263
  "Pass an array such as [\"npm test\", \"npm run build\"]."
201
264
  );
202
265
  }
203
- if ((input.verify_commands?.length || 0) > 20) {
266
+ if ((effectiveInput.verify_commands?.length || 0) > 20) {
204
267
  throw new PatchWardenError(
205
268
  "invalid_verify_commands",
206
269
  "verify_commands cannot contain more than 20 commands.",
@@ -208,11 +271,11 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
208
271
  );
209
272
  }
210
273
  const verifyCommands = [...new Set([
211
- ...(input.verify_commands || []).map((command) => guardTestCommand(command, config)),
274
+ ...(effectiveInput.verify_commands || []).map((command) => guardTestCommand(command, config, safeRepoPath)),
212
275
  ...(testCmd ? [testCmd] : []),
213
276
  ])];
214
277
 
215
- const timeoutSeconds = input.timeout_seconds ?? config.defaultTaskTimeoutSeconds;
278
+ const timeoutSeconds = effectiveInput.timeout_seconds ?? config.defaultTaskTimeoutSeconds;
216
279
  if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
217
280
  throw new PatchWardenError(
218
281
  "invalid_timeout",
@@ -228,9 +291,206 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
228
291
  );
229
292
  }
230
293
 
231
- let planId = input.plan_id?.trim() || "";
294
+ let planId = effectiveInput.plan_id?.trim() || "";
232
295
  let planSource: CreateTaskOutput["plan_source"] = "saved";
233
296
  let changePolicy: ChangePolicy = "repo_scoped_changes";
297
+ let planContentForHash: string | null = null;
298
+
299
+ // ── assess_only: check plan content WITHOUT saving first ──
300
+ // This catches guardPlanContent violations as hard rule hits instead of throwing.
301
+ if (executionMode === "assess_only") {
302
+ let assessPlanContent = "";
303
+ let assessPlanTitle = effectiveInput.plan_title?.trim() || "Assessment plan";
304
+ if (planId) {
305
+ const planFile = join(resolve(plansDir, planId), "plan.md");
306
+ guardReadPath(planFile, config.workspaceRoot, config.plansDir);
307
+ if (!existsSync(planFile)) {
308
+ throw new PatchWardenError(
309
+ "plan_not_found",
310
+ `Plan "${planId}" not found.`,
311
+ "Call save_plan first, or pass inline_plan/template directly to create_task."
312
+ );
313
+ }
314
+ assessPlanContent = readFileSync(planFile, "utf-8");
315
+ } else if (effectiveInput.inline_plan?.trim()) {
316
+ assessPlanContent = effectiveInput.inline_plan.trim();
317
+ } else if (effectiveInput.template) {
318
+ const expanded = expandTaskTemplate({
319
+ template: effectiveInput.template,
320
+ goal: effectiveInput.goal || "",
321
+ source_task_id: effectiveInput.source_task_id,
322
+ verify_commands: verifyCommands,
323
+ });
324
+ assessPlanContent = expanded.content;
325
+ assessPlanTitle = expanded.title;
326
+ changePolicy = expanded.change_policy;
327
+ }
328
+
329
+ // Run risk engine WITHOUT saving the plan
330
+ let planBlocked = false;
331
+ let planBlockReason = "";
332
+ try {
333
+ guardPlanContent(assessPlanTitle, assessPlanContent);
334
+ } catch (e) {
335
+ planBlocked = true;
336
+ planBlockReason = e instanceof PatchWardenError ? e.reason : "plan_content_blocked";
337
+ }
338
+
339
+ const snapshot = captureRepoSnapshot(safeRepoPath);
340
+ const snapshotTruncated = snapshot.warnings.some((w) => w.includes("snapshot limited"));
341
+
342
+ let riskResult;
343
+ if (planBlocked) {
344
+ riskResult = {
345
+ risk_level: "high" as const,
346
+ decision: "blocked" as const,
347
+ reason_codes: [],
348
+ risk_hints: [],
349
+ hard_rule_hits: [planBlockReason],
350
+ };
351
+ } else {
352
+ riskResult = assessRisk({
353
+ repoPath: resolvedRepoPath,
354
+ resolvedRepoPath: safeRepoPath,
355
+ planContent: assessPlanContent,
356
+ planTitle: assessPlanTitle,
357
+ testCommand: testCmd,
358
+ verifyCommands: verifyCommands,
359
+ template: effectiveInput.template,
360
+ goal: effectiveInput.goal,
361
+ agent: effectiveInput.agent,
362
+ config,
363
+ snapshotTruncated,
364
+ });
365
+ }
366
+
367
+ // Save the plan now that risk assessment is done (for allow/needs_confirm)
368
+ if (!planBlocked) {
369
+ if (effectiveInput.inline_plan?.trim()) {
370
+ const saved = savePlan({
371
+ title: effectiveInput.plan_title?.trim() || "Inline task plan",
372
+ content: effectiveInput.inline_plan.trim(),
373
+ });
374
+ planId = saved.plan_id;
375
+ planSource = "inline";
376
+ planContentForHash = readFileSync(saved.path, "utf-8");
377
+ } else if (effectiveInput.template) {
378
+ const saved = savePlan({ title: assessPlanTitle, content: assessPlanContent });
379
+ planId = saved.plan_id;
380
+ planSource = "template";
381
+ planContentForHash = readFileSync(saved.path, "utf-8");
382
+ } else {
383
+ planContentForHash = assessPlanContent;
384
+ }
385
+ }
386
+
387
+ // ── Agent assessment: only when enabled AND deterministic decision is "allow" ──
388
+ let finalRiskResult = riskResult;
389
+ let agentAssessmentSummary: AgentAssessmentSummary | null = null;
390
+ let preGeneratedAssessmentId: string | undefined;
391
+ let preGeneratedAssessmentDir: string | undefined;
392
+
393
+ if (config.enableAgentAssessment === true && riskResult.decision === "allow" && !planBlocked) {
394
+ preGeneratedAssessmentId = generateAssessmentId();
395
+ preGeneratedAssessmentDir = createAssessmentDir(preGeneratedAssessmentId);
396
+
397
+ const assessorAgentName = config.agentAssessmentAgentName || effectiveInput.agent;
398
+ agentAssessmentSummary = runAgentAssessment({
399
+ assessmentId: preGeneratedAssessmentId,
400
+ assessmentDir: preGeneratedAssessmentDir,
401
+ agentName: assessorAgentName,
402
+ repoPath: safeRepoPath,
403
+ workspaceRoot: config.workspaceRoot,
404
+ goal: effectiveInput.goal || "",
405
+ planContent: assessPlanContent,
406
+ timeoutSeconds: config.agentAssessmentTimeoutSeconds || 120,
407
+ maxOutputBytes: config.agentAssessmentMaxOutputBytes || 524288,
408
+ config,
409
+ });
410
+
411
+ finalRiskResult = {
412
+ risk_level: agentAssessmentSummary.merged_risk,
413
+ decision: agentAssessmentSummary.merged_decision,
414
+ reason_codes: [...riskResult.reason_codes, ...agentAssessmentSummary.merged_reason_codes],
415
+ risk_hints: riskResult.risk_hints,
416
+ hard_rule_hits: riskResult.hard_rule_hits,
417
+ };
418
+ }
419
+
420
+ const record = createAssessment({
421
+ decision: finalRiskResult.decision,
422
+ risk_level: finalRiskResult.risk_level,
423
+ risk_hints: finalRiskResult.risk_hints,
424
+ hard_rule_hits: finalRiskResult.hard_rule_hits,
425
+ reason_codes: finalRiskResult.reason_codes,
426
+ repo_path: resolvedRepoPath,
427
+ resolved_repo_path: safeRepoPath,
428
+ plan_id: planId || null,
429
+ plan_content: planContentForHash,
430
+ template: effectiveInput.template || null,
431
+ goal: effectiveInput.goal || null,
432
+ test_command: testCmd || null,
433
+ verify_commands: verifyCommands,
434
+ agent: effectiveInput.agent,
435
+ timeout_seconds: timeoutSeconds,
436
+ change_policy: changePolicy,
437
+ snapshot,
438
+ ...(preGeneratedAssessmentId ? { assessment_id: preGeneratedAssessmentId } : {}),
439
+ ...(preGeneratedAssessmentDir ? { assessment_dir: preGeneratedAssessmentDir } : {}),
440
+ agent_assessment_summary: agentAssessmentSummary,
441
+ });
442
+
443
+ const nextAction = record.decision === "allow"
444
+ ? "Call the returned next_tool_call exactly as provided; do not resend goal, plan, repository, agent, or verification arguments."
445
+ : record.decision === "needs_confirm"
446
+ ? `Assessment requires local confirmation. Run patchwarden-confirm ${record.assessment_id} locally, then call the returned next_tool_call exactly as provided.`
447
+ : `Assessment blocked. Fix the reported hard rule hit and re-run assess_only.`;
448
+
449
+ const nextToolCall = record.decision === "blocked" ? undefined : {
450
+ name: "create_task" as const,
451
+ arguments: {
452
+ execution_mode: "execute" as const,
453
+ assessment_id: record.assessment_id,
454
+ },
455
+ };
456
+
457
+ return {
458
+ assessment_id: record.assessment_id,
459
+ assessment_short_id: record.assessment_short_id,
460
+ decision: record.decision,
461
+ risk_level: record.risk_level,
462
+ risk_hints: record.risk_hints,
463
+ hard_rule_hits: record.hard_rule_hits,
464
+ reason_codes: record.reason_codes,
465
+ expires_at: record.expires_at,
466
+ requires_confirm: record.requires_confirm,
467
+ workspace_snapshot_summary: record.workspace_snapshot_summary,
468
+ next_action: nextAction,
469
+ ...(nextToolCall ? { next_tool_call: nextToolCall } : {}),
470
+ local_confirmation: {
471
+ required: record.requires_confirm,
472
+ command: record.requires_confirm ? "patchwarden-confirm" : null,
473
+ arguments: record.requires_confirm ? [record.assessment_id] : [],
474
+ },
475
+ ...(finalRiskResult.hard_rule_hits.length > 0
476
+ ? { suggestion: `Hard rule hit: ${finalRiskResult.hard_rule_hits.join(", ")}` }
477
+ : {}),
478
+ ...(agentAssessmentSummary ? {
479
+ agent_assessment: {
480
+ status: agentAssessmentSummary.status,
481
+ merged_risk: agentAssessmentSummary.merged_risk,
482
+ merged_decision: agentAssessmentSummary.merged_decision,
483
+ confidence: agentAssessmentSummary.output?.confidence ?? null,
484
+ notes: agentAssessmentSummary.output?.notes ?? null,
485
+ read_only_violation: agentAssessmentSummary.read_only_violation,
486
+ stdout_truncated: agentAssessmentSummary.stdout_truncated,
487
+ stderr_truncated: agentAssessmentSummary.stderr_truncated,
488
+ }
489
+ } : {}),
490
+ };
491
+ }
492
+
493
+ // ── execute mode: resolve plan normally ──
234
494
  if (planId) {
235
495
  const planFile = join(resolve(plansDir, planId), "plan.md");
236
496
  guardReadPath(planFile, config.workspaceRoot, config.plansDir);
@@ -241,31 +501,45 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
241
501
  "Call save_plan first, or pass inline_plan/template directly to create_task."
242
502
  );
243
503
  }
244
- } else if (input.inline_plan?.trim()) {
504
+ planContentForHash = readFileSync(planFile, "utf-8");
505
+ } else if (effectiveInput.inline_plan?.trim()) {
245
506
  const saved = savePlan({
246
- title: input.plan_title?.trim() || "Inline task plan",
247
- content: input.inline_plan.trim(),
507
+ title: effectiveInput.plan_title?.trim() || "Inline task plan",
508
+ content: effectiveInput.inline_plan.trim(),
248
509
  });
249
510
  planId = saved.plan_id;
250
511
  planSource = "inline";
512
+ planContentForHash = readFileSync(saved.path, "utf-8");
251
513
  } else {
252
514
  const expanded = expandTaskTemplate({
253
- template: input.template!,
254
- goal: input.goal || "",
255
- source_task_id: input.source_task_id,
515
+ template: effectiveInput.template!,
516
+ goal: effectiveInput.goal || "",
517
+ source_task_id: effectiveInput.source_task_id,
256
518
  verify_commands: verifyCommands,
257
519
  });
258
520
  const saved = savePlan({ title: expanded.title, content: expanded.content });
259
521
  planId = saved.plan_id;
260
522
  planSource = "template";
261
523
  changePolicy = expanded.change_policy;
524
+ planContentForHash = readFileSync(saved.path, "utf-8");
262
525
  }
263
526
 
264
- const taskId = `task_${Date.now()}_${planId.replace(/^plan_/, "")}`;
265
- const taskDir = resolve(tasksDir, taskId);
527
+ // ── execute mode with assessment_id: validate freshness ──
528
+ if (assessmentRecord) {
529
+ const snapshot = captureRepoSnapshot(safeRepoPath);
530
+ const validation = validateAssessmentFreshness(input.assessment_id!, snapshot);
531
+ if (!validation.valid) {
532
+ throw new PatchWardenError(
533
+ validation.failure_reason || "assessment_validation_failed",
534
+ `Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`,
535
+ "Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.",
536
+ true,
537
+ { assessment_id: input.assessment_id, failure_reason: validation.failure_reason }
538
+ );
539
+ }
540
+ }
266
541
 
267
- guardPath(taskDir, config.workspaceRoot, config.tasksDir);
268
- mkdirSync(taskDir, { recursive: true });
542
+ const { taskId, taskDir } = createTaskDirectory(tasksDir, config.workspaceRoot, config.tasksDir);
269
543
 
270
544
  const status: TaskStatus = "pending";
271
545
  const statusFile = join(taskDir, "status.json");
@@ -273,15 +547,16 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
273
547
  task_id: taskId,
274
548
  plan_id: planId,
275
549
  plan_source: planSource,
276
- template: input.template || null,
550
+ template: effectiveInput.template || null,
277
551
  change_policy: changePolicy,
278
- agent: input.agent,
552
+ agent: effectiveInput.agent,
279
553
  workspace_root: resolve(config.workspaceRoot),
280
554
  repo_path: resolvedRepoPath,
281
555
  resolved_repo_path: safeRepoPath,
282
556
  test_command: testCmd,
283
557
  verify_commands: verifyCommands,
284
558
  timeout_seconds: timeoutSeconds,
559
+ assessment_id: assessmentRecord?.assessment_id || input.assessment_id || null,
285
560
  status,
286
561
  phase: "queued" as TaskPhase,
287
562
  created_at: new Date().toISOString(),
@@ -312,14 +587,14 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
312
587
  return {
313
588
  task_id: taskId,
314
589
  plan_id: planId,
315
- agent: input.agent,
590
+ agent: effectiveInput.agent,
316
591
  status,
317
592
  timeout_seconds: timeoutSeconds,
318
593
  continuation_required: watcher.available && hasWaitForTask,
319
594
  next_action: !watcher.available ? nextActionBlocked : hasWaitForTask ? nextActionWait : nextActionPoll,
320
595
  path: taskDir,
321
596
  plan_source: planSource,
322
- ...(input.template ? { template: input.template } : {}),
597
+ ...(effectiveInput.template ? { template: effectiveInput.template } : {}),
323
598
  change_policy: changePolicy,
324
599
  server_version: PATCHWARDEN_VERSION,
325
600
  tool_profile: catalog?.tool_profile || resolveToolProfile(config.toolProfile),
@@ -333,5 +608,80 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
333
608
  : hasWaitForTask
334
609
  ? { name: "wait_for_task", arguments: { task_id: taskId, timeout_seconds: 25 } }
335
610
  : { name: "get_task_status", arguments: { task_id: taskId } },
611
+ } as CreateTaskOutput;
612
+ }
613
+
614
+ function mergeAssessmentIntoInput(
615
+ input: CreateTaskInput,
616
+ record: AssessmentRecord
617
+ ): CreateTaskInput {
618
+ // Use plan_id from the assessment record (the plan was already saved during assess_only).
619
+ // Do NOT set template/inline_plan alongside plan_id — createTask requires exactly one plan source.
620
+ const merged: CreateTaskInput = {
621
+ ...input,
622
+ plan_id: record.plan_id || input.plan_id,
623
+ template: undefined,
624
+ inline_plan: undefined,
625
+ goal: record.goal || input.goal,
626
+ agent: record.agent,
627
+ repo_path: record.repo_path,
628
+ test_command: record.test_command || undefined,
629
+ verify_commands: record.verify_commands || input.verify_commands,
630
+ timeout_seconds: record.timeout_seconds || input.timeout_seconds,
336
631
  };
632
+ // Parameter mismatch check: if caller passed explicit params that differ from assessment
633
+ if (input.template && record.template && input.template !== record.template) {
634
+ throw new PatchWardenError(
635
+ "assessment_parameter_mismatch",
636
+ `template mismatch: caller passed "${input.template}" but assessment has "${record.template}".`,
637
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
638
+ true,
639
+ { field: "template", assessment_value: record.template, caller_value: input.template }
640
+ );
641
+ }
642
+ if (input.goal && record.goal && input.goal !== record.goal) {
643
+ throw new PatchWardenError(
644
+ "assessment_parameter_mismatch",
645
+ `goal mismatch: caller passed a different goal than the assessment.`,
646
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
647
+ true,
648
+ { field: "goal" }
649
+ );
650
+ }
651
+ if (input.repo_path && record.repo_path && input.repo_path !== record.repo_path) {
652
+ throw new PatchWardenError(
653
+ "assessment_parameter_mismatch",
654
+ `repo_path mismatch: caller passed "${input.repo_path}" but assessment has "${record.repo_path}".`,
655
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
656
+ true,
657
+ { field: "repo_path", assessment_value: record.repo_path, caller_value: input.repo_path }
658
+ );
659
+ }
660
+ return merged;
661
+ }
662
+
663
+ function createTaskDirectory(tasksDir: string, workspaceRoot: string, configuredTasksDir: string): { taskId: string; taskDir: string } {
664
+ guardPath(tasksDir, workspaceRoot, configuredTasksDir);
665
+ mkdirSync(tasksDir, { recursive: true });
666
+ for (let attempt = 0; attempt < 10; attempt += 1) {
667
+ const timestamp = new Date().toISOString()
668
+ .replace(/[-:]/g, "")
669
+ .replace("T", "_")
670
+ .slice(0, 15);
671
+ const taskId = `task_${timestamp}_${randomBytes(3).toString("hex")}`;
672
+ const taskDir = resolve(tasksDir, taskId);
673
+ guardPath(taskDir, workspaceRoot, configuredTasksDir);
674
+ try {
675
+ mkdirSync(taskDir, { recursive: false });
676
+ return { taskId, taskDir };
677
+ } catch (error) {
678
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
679
+ throw error;
680
+ }
681
+ }
682
+ throw new PatchWardenError(
683
+ "task_id_generation_failed",
684
+ "Could not allocate a unique short task ID.",
685
+ "Retry create_task; no task directory was created."
686
+ );
337
687
  }