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,15 @@
1
- import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
2
- import { resolve, join, dirname } from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { resolve, join } from "node:path";
4
4
  import { getTasksDir, getPlansDir, getConfig } from "../config.js";
5
5
  import { guardPath, guardWorkspacePath, guardReadPath } from "../security/pathGuard.js";
6
6
  import { guardTestCommand } from "../security/commandGuard.js";
7
+ import { guardRuntimeSelfModification } from "../security/runtimeGuard.js";
8
+ import { guardPlanContent } from "../security/planGuard.js";
9
+ import { assessRisk } from "../security/riskEngine.js";
10
+ import { createAssessment, readAssessment, validateAssessmentFreshness, generateAssessmentId, createAssessmentDir, } from "../assessments/assessmentStore.js";
11
+ import { runAgentAssessment } from "../assessments/agentAssessor.js";
12
+ import { captureRepoSnapshot } from "../runner/changeCapture.js";
7
13
  import { writeTaskProgress } from "../taskProgress.js";
8
14
  import { PatchWardenError } from "../errors.js";
9
15
  import { savePlan } from "./savePlan.js";
@@ -15,19 +21,34 @@ export function createTask(input) {
15
21
  const config = getConfig();
16
22
  const tasksDir = getTasksDir(config);
17
23
  const plansDir = getPlansDir(config);
24
+ const executionMode = input.execution_mode || "execute";
25
+ // ── assessment_id execute mode: load record and override params ──
26
+ let assessmentRecord = null;
27
+ let effectiveInput = input;
28
+ if (executionMode === "execute" && input.assessment_id) {
29
+ assessmentRecord = readAssessment(input.assessment_id);
30
+ // Parameter mismatch check: if ChatGPT passes params that differ from the assessment, reject
31
+ effectiveInput = mergeAssessmentIntoInput(input, assessmentRecord);
32
+ }
33
+ // ── Validate required fields ──
34
+ // agent and repo_path are required unless assessment_id fills them.
35
+ // assess_only always requires them because the risk engine needs to check them.
36
+ if (!effectiveInput.agent || effectiveInput.agent.trim() === "") {
37
+ throw new PatchWardenError("agent_required", "create_task requires an agent name.", "Pass a configured agent name, or use assessment_id from a prior assess_only call.");
38
+ }
18
39
  const planSources = [
19
- input.plan_id?.trim() ? "plan_id" : "",
20
- input.inline_plan?.trim() ? "inline_plan" : "",
21
- input.template ? "template" : "",
40
+ effectiveInput.plan_id?.trim() ? "plan_id" : "",
41
+ effectiveInput.inline_plan?.trim() ? "inline_plan" : "",
42
+ effectiveInput.template ? "template" : "",
22
43
  ].filter(Boolean);
23
44
  if (planSources.length !== 1) {
24
45
  throw new PatchWardenError("invalid_plan_source", "create_task requires exactly one of plan_id, inline_plan, or template.", "Use an existing plan_id, pass inline_plan text, or choose one built-in template.");
25
46
  }
26
- if (input.template && !TASK_TEMPLATE_NAMES.includes(input.template)) {
27
- throw new PatchWardenError("invalid_task_template", `Unknown task template "${input.template}".`, `Use one of: ${TASK_TEMPLATE_NAMES.join(", ")}.`);
47
+ if (effectiveInput.template && !TASK_TEMPLATE_NAMES.includes(effectiveInput.template)) {
48
+ throw new PatchWardenError("invalid_task_template", `Unknown task template "${effectiveInput.template}".`, `Use one of: ${TASK_TEMPLATE_NAMES.join(", ")}.`);
28
49
  }
29
50
  // Resolve repo alias if configured
30
- let resolvedRepoPath = input.repo_path?.trim() || "";
51
+ let resolvedRepoPath = effectiveInput.repo_path?.trim() || "";
31
52
  const aliases = config.repoAliases;
32
53
  if (aliases && resolvedRepoPath && aliases[resolvedRepoPath]) {
33
54
  resolvedRepoPath = aliases[resolvedRepoPath];
@@ -36,8 +57,8 @@ export function createTask(input) {
36
57
  throw new PatchWardenError("repo_path_required", "create_task requires an explicit repo_path; PatchWarden will not default to workspaceRoot.", 'Pass a repository path inside workspaceRoot, for example repo_path: "my-project".', true, { operation: "create_task", safe_alternative: "Pass an existing repository directory under workspaceRoot." });
37
58
  }
38
59
  // Validate agent
39
- if (!config.agents[input.agent]) {
40
- throw new PatchWardenError("agent_not_configured", `Unknown agent "${input.agent}". Available: ${Object.keys(config.agents).join(", ")}`, "Call list_agents and use an available configured agent.");
60
+ if (!config.agents[effectiveInput.agent]) {
61
+ throw new PatchWardenError("agent_not_configured", `Unknown agent "${effectiveInput.agent}". Available: ${Object.keys(config.agents).join(", ")}`, "Call list_agents and use an available configured agent.");
41
62
  }
42
63
  // Validate repo_path is within workspace
43
64
  const safeRepoPath = guardWorkspacePath(resolvedRepoPath, config.workspaceRoot);
@@ -49,93 +70,273 @@ export function createTask(input) {
49
70
  }
50
71
  // Runtime self-modification protection: refuse to modify the active
51
72
  // PatchWarden runtime directory or its critical subdirectories.
52
- const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
53
- const criticalDirs = ["dist", "src", "scripts", "release"];
54
- if (safeRepoPath === runtimeRoot || safeRepoPath.startsWith(runtimeRoot + resolve("/")[0])) {
55
- const isCritical = criticalDirs.some((dir) => safeRepoPath === join(runtimeRoot, dir) ||
56
- safeRepoPath.startsWith(join(runtimeRoot, dir) + resolve("/")[0]));
57
- if (safeRepoPath === runtimeRoot || isCritical) {
58
- throw new PatchWardenError("runtime_self_modification_blocked", `repo_path "${resolvedRepoPath}" points to the active PatchWarden runtime or its critical subdirectories.`, "Use a dev copy or git worktree for PatchWarden development. The running MCP server must not be modified by a task.", true, {
59
- operation: "create_task",
60
- path: resolvedRepoPath,
61
- resolved_repo_path: safeRepoPath,
62
- runtime_root: runtimeRoot,
63
- safe_alternative: "Clone or copy PatchWarden to a separate directory for development tasks.",
64
- });
65
- }
66
- }
73
+ guardRuntimeSelfModification(safeRepoPath);
67
74
  // Validate test command — must be in allowlist, no swallowing
68
75
  let testCmd = "";
69
- if (input.test_command && input.test_command.trim() !== "") {
70
- testCmd = guardTestCommand(input.test_command, config);
76
+ if (effectiveInput.test_command && effectiveInput.test_command.trim() !== "") {
77
+ testCmd = guardTestCommand(effectiveInput.test_command, config, safeRepoPath);
71
78
  // guardTestCommand throws if not in allowedTestCommands
72
79
  }
73
- if (input.verify_commands !== undefined && !Array.isArray(input.verify_commands)) {
80
+ if (effectiveInput.verify_commands !== undefined && !Array.isArray(effectiveInput.verify_commands)) {
74
81
  throw new PatchWardenError("invalid_verify_commands", "verify_commands must be an array of allow-listed command strings.", "Pass an array such as [\"npm test\", \"npm run build\"].");
75
82
  }
76
- if ((input.verify_commands?.length || 0) > 20) {
83
+ if ((effectiveInput.verify_commands?.length || 0) > 20) {
77
84
  throw new PatchWardenError("invalid_verify_commands", "verify_commands cannot contain more than 20 commands.", "Keep verification focused and use no more than 20 allow-listed commands.");
78
85
  }
79
86
  const verifyCommands = [...new Set([
80
- ...(input.verify_commands || []).map((command) => guardTestCommand(command, config)),
87
+ ...(effectiveInput.verify_commands || []).map((command) => guardTestCommand(command, config, safeRepoPath)),
81
88
  ...(testCmd ? [testCmd] : []),
82
89
  ])];
83
- const timeoutSeconds = input.timeout_seconds ?? config.defaultTaskTimeoutSeconds;
90
+ const timeoutSeconds = effectiveInput.timeout_seconds ?? config.defaultTaskTimeoutSeconds;
84
91
  if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
85
92
  throw new PatchWardenError("invalid_timeout", "timeout_seconds must be a positive integer", `Use a whole number from 1 to ${config.maxTaskTimeoutSeconds}.`);
86
93
  }
87
94
  if (timeoutSeconds > config.maxTaskTimeoutSeconds) {
88
95
  throw new PatchWardenError("invalid_timeout", `timeout_seconds cannot exceed configured maximum ${config.maxTaskTimeoutSeconds}`, `Use a value no greater than ${config.maxTaskTimeoutSeconds}.`);
89
96
  }
90
- let planId = input.plan_id?.trim() || "";
97
+ let planId = effectiveInput.plan_id?.trim() || "";
91
98
  let planSource = "saved";
92
99
  let changePolicy = "repo_scoped_changes";
100
+ let planContentForHash = null;
101
+ // ── assess_only: check plan content WITHOUT saving first ──
102
+ // This catches guardPlanContent violations as hard rule hits instead of throwing.
103
+ if (executionMode === "assess_only") {
104
+ let assessPlanContent = "";
105
+ let assessPlanTitle = effectiveInput.plan_title?.trim() || "Assessment plan";
106
+ if (planId) {
107
+ const planFile = join(resolve(plansDir, planId), "plan.md");
108
+ guardReadPath(planFile, config.workspaceRoot, config.plansDir);
109
+ if (!existsSync(planFile)) {
110
+ throw new PatchWardenError("plan_not_found", `Plan "${planId}" not found.`, "Call save_plan first, or pass inline_plan/template directly to create_task.");
111
+ }
112
+ assessPlanContent = readFileSync(planFile, "utf-8");
113
+ }
114
+ else if (effectiveInput.inline_plan?.trim()) {
115
+ assessPlanContent = effectiveInput.inline_plan.trim();
116
+ }
117
+ else if (effectiveInput.template) {
118
+ const expanded = expandTaskTemplate({
119
+ template: effectiveInput.template,
120
+ goal: effectiveInput.goal || "",
121
+ source_task_id: effectiveInput.source_task_id,
122
+ verify_commands: verifyCommands,
123
+ });
124
+ assessPlanContent = expanded.content;
125
+ assessPlanTitle = expanded.title;
126
+ changePolicy = expanded.change_policy;
127
+ }
128
+ // Run risk engine WITHOUT saving the plan
129
+ let planBlocked = false;
130
+ let planBlockReason = "";
131
+ try {
132
+ guardPlanContent(assessPlanTitle, assessPlanContent);
133
+ }
134
+ catch (e) {
135
+ planBlocked = true;
136
+ planBlockReason = e instanceof PatchWardenError ? e.reason : "plan_content_blocked";
137
+ }
138
+ const snapshot = captureRepoSnapshot(safeRepoPath);
139
+ const snapshotTruncated = snapshot.warnings.some((w) => w.includes("snapshot limited"));
140
+ let riskResult;
141
+ if (planBlocked) {
142
+ riskResult = {
143
+ risk_level: "high",
144
+ decision: "blocked",
145
+ reason_codes: [],
146
+ risk_hints: [],
147
+ hard_rule_hits: [planBlockReason],
148
+ };
149
+ }
150
+ else {
151
+ riskResult = assessRisk({
152
+ repoPath: resolvedRepoPath,
153
+ resolvedRepoPath: safeRepoPath,
154
+ planContent: assessPlanContent,
155
+ planTitle: assessPlanTitle,
156
+ testCommand: testCmd,
157
+ verifyCommands: verifyCommands,
158
+ template: effectiveInput.template,
159
+ goal: effectiveInput.goal,
160
+ agent: effectiveInput.agent,
161
+ config,
162
+ snapshotTruncated,
163
+ });
164
+ }
165
+ // Save the plan now that risk assessment is done (for allow/needs_confirm)
166
+ if (!planBlocked) {
167
+ if (effectiveInput.inline_plan?.trim()) {
168
+ const saved = savePlan({
169
+ title: effectiveInput.plan_title?.trim() || "Inline task plan",
170
+ content: effectiveInput.inline_plan.trim(),
171
+ });
172
+ planId = saved.plan_id;
173
+ planSource = "inline";
174
+ planContentForHash = readFileSync(saved.path, "utf-8");
175
+ }
176
+ else if (effectiveInput.template) {
177
+ const saved = savePlan({ title: assessPlanTitle, content: assessPlanContent });
178
+ planId = saved.plan_id;
179
+ planSource = "template";
180
+ planContentForHash = readFileSync(saved.path, "utf-8");
181
+ }
182
+ else {
183
+ planContentForHash = assessPlanContent;
184
+ }
185
+ }
186
+ // ── Agent assessment: only when enabled AND deterministic decision is "allow" ──
187
+ let finalRiskResult = riskResult;
188
+ let agentAssessmentSummary = null;
189
+ let preGeneratedAssessmentId;
190
+ let preGeneratedAssessmentDir;
191
+ if (config.enableAgentAssessment === true && riskResult.decision === "allow" && !planBlocked) {
192
+ preGeneratedAssessmentId = generateAssessmentId();
193
+ preGeneratedAssessmentDir = createAssessmentDir(preGeneratedAssessmentId);
194
+ const assessorAgentName = config.agentAssessmentAgentName || effectiveInput.agent;
195
+ agentAssessmentSummary = runAgentAssessment({
196
+ assessmentId: preGeneratedAssessmentId,
197
+ assessmentDir: preGeneratedAssessmentDir,
198
+ agentName: assessorAgentName,
199
+ repoPath: safeRepoPath,
200
+ workspaceRoot: config.workspaceRoot,
201
+ goal: effectiveInput.goal || "",
202
+ planContent: assessPlanContent,
203
+ timeoutSeconds: config.agentAssessmentTimeoutSeconds || 120,
204
+ maxOutputBytes: config.agentAssessmentMaxOutputBytes || 524288,
205
+ config,
206
+ });
207
+ finalRiskResult = {
208
+ risk_level: agentAssessmentSummary.merged_risk,
209
+ decision: agentAssessmentSummary.merged_decision,
210
+ reason_codes: [...riskResult.reason_codes, ...agentAssessmentSummary.merged_reason_codes],
211
+ risk_hints: riskResult.risk_hints,
212
+ hard_rule_hits: riskResult.hard_rule_hits,
213
+ };
214
+ }
215
+ const record = createAssessment({
216
+ decision: finalRiskResult.decision,
217
+ risk_level: finalRiskResult.risk_level,
218
+ risk_hints: finalRiskResult.risk_hints,
219
+ hard_rule_hits: finalRiskResult.hard_rule_hits,
220
+ reason_codes: finalRiskResult.reason_codes,
221
+ repo_path: resolvedRepoPath,
222
+ resolved_repo_path: safeRepoPath,
223
+ plan_id: planId || null,
224
+ plan_content: planContentForHash,
225
+ template: effectiveInput.template || null,
226
+ goal: effectiveInput.goal || null,
227
+ test_command: testCmd || null,
228
+ verify_commands: verifyCommands,
229
+ agent: effectiveInput.agent,
230
+ timeout_seconds: timeoutSeconds,
231
+ change_policy: changePolicy,
232
+ snapshot,
233
+ ...(preGeneratedAssessmentId ? { assessment_id: preGeneratedAssessmentId } : {}),
234
+ ...(preGeneratedAssessmentDir ? { assessment_dir: preGeneratedAssessmentDir } : {}),
235
+ agent_assessment_summary: agentAssessmentSummary,
236
+ });
237
+ const nextAction = record.decision === "allow"
238
+ ? "Call the returned next_tool_call exactly as provided; do not resend goal, plan, repository, agent, or verification arguments."
239
+ : record.decision === "needs_confirm"
240
+ ? `Assessment requires local confirmation. Run patchwarden-confirm ${record.assessment_id} locally, then call the returned next_tool_call exactly as provided.`
241
+ : `Assessment blocked. Fix the reported hard rule hit and re-run assess_only.`;
242
+ const nextToolCall = record.decision === "blocked" ? undefined : {
243
+ name: "create_task",
244
+ arguments: {
245
+ execution_mode: "execute",
246
+ assessment_id: record.assessment_id,
247
+ },
248
+ };
249
+ return {
250
+ assessment_id: record.assessment_id,
251
+ assessment_short_id: record.assessment_short_id,
252
+ decision: record.decision,
253
+ risk_level: record.risk_level,
254
+ risk_hints: record.risk_hints,
255
+ hard_rule_hits: record.hard_rule_hits,
256
+ reason_codes: record.reason_codes,
257
+ expires_at: record.expires_at,
258
+ requires_confirm: record.requires_confirm,
259
+ workspace_snapshot_summary: record.workspace_snapshot_summary,
260
+ next_action: nextAction,
261
+ ...(nextToolCall ? { next_tool_call: nextToolCall } : {}),
262
+ local_confirmation: {
263
+ required: record.requires_confirm,
264
+ command: record.requires_confirm ? "patchwarden-confirm" : null,
265
+ arguments: record.requires_confirm ? [record.assessment_id] : [],
266
+ },
267
+ ...(finalRiskResult.hard_rule_hits.length > 0
268
+ ? { suggestion: `Hard rule hit: ${finalRiskResult.hard_rule_hits.join(", ")}` }
269
+ : {}),
270
+ ...(agentAssessmentSummary ? {
271
+ agent_assessment: {
272
+ status: agentAssessmentSummary.status,
273
+ merged_risk: agentAssessmentSummary.merged_risk,
274
+ merged_decision: agentAssessmentSummary.merged_decision,
275
+ confidence: agentAssessmentSummary.output?.confidence ?? null,
276
+ notes: agentAssessmentSummary.output?.notes ?? null,
277
+ read_only_violation: agentAssessmentSummary.read_only_violation,
278
+ stdout_truncated: agentAssessmentSummary.stdout_truncated,
279
+ stderr_truncated: agentAssessmentSummary.stderr_truncated,
280
+ }
281
+ } : {}),
282
+ };
283
+ }
284
+ // ── execute mode: resolve plan normally ──
93
285
  if (planId) {
94
286
  const planFile = join(resolve(plansDir, planId), "plan.md");
95
287
  guardReadPath(planFile, config.workspaceRoot, config.plansDir);
96
288
  if (!existsSync(planFile)) {
97
289
  throw new PatchWardenError("plan_not_found", `Plan "${planId}" not found.`, "Call save_plan first, or pass inline_plan/template directly to create_task.");
98
290
  }
291
+ planContentForHash = readFileSync(planFile, "utf-8");
99
292
  }
100
- else if (input.inline_plan?.trim()) {
293
+ else if (effectiveInput.inline_plan?.trim()) {
101
294
  const saved = savePlan({
102
- title: input.plan_title?.trim() || "Inline task plan",
103
- content: input.inline_plan.trim(),
295
+ title: effectiveInput.plan_title?.trim() || "Inline task plan",
296
+ content: effectiveInput.inline_plan.trim(),
104
297
  });
105
298
  planId = saved.plan_id;
106
299
  planSource = "inline";
300
+ planContentForHash = readFileSync(saved.path, "utf-8");
107
301
  }
108
302
  else {
109
303
  const expanded = expandTaskTemplate({
110
- template: input.template,
111
- goal: input.goal || "",
112
- source_task_id: input.source_task_id,
304
+ template: effectiveInput.template,
305
+ goal: effectiveInput.goal || "",
306
+ source_task_id: effectiveInput.source_task_id,
113
307
  verify_commands: verifyCommands,
114
308
  });
115
309
  const saved = savePlan({ title: expanded.title, content: expanded.content });
116
310
  planId = saved.plan_id;
117
311
  planSource = "template";
118
312
  changePolicy = expanded.change_policy;
313
+ planContentForHash = readFileSync(saved.path, "utf-8");
119
314
  }
120
- const taskId = `task_${Date.now()}_${planId.replace(/^plan_/, "")}`;
121
- const taskDir = resolve(tasksDir, taskId);
122
- guardPath(taskDir, config.workspaceRoot, config.tasksDir);
123
- mkdirSync(taskDir, { recursive: true });
315
+ // ── execute mode with assessment_id: validate freshness ──
316
+ if (assessmentRecord) {
317
+ const snapshot = captureRepoSnapshot(safeRepoPath);
318
+ const validation = validateAssessmentFreshness(input.assessment_id, snapshot);
319
+ if (!validation.valid) {
320
+ throw new PatchWardenError(validation.failure_reason || "assessment_validation_failed", `Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`, "Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.", true, { assessment_id: input.assessment_id, failure_reason: validation.failure_reason });
321
+ }
322
+ }
323
+ const { taskId, taskDir } = createTaskDirectory(tasksDir, config.workspaceRoot, config.tasksDir);
124
324
  const status = "pending";
125
325
  const statusFile = join(taskDir, "status.json");
126
326
  const statusData = {
127
327
  task_id: taskId,
128
328
  plan_id: planId,
129
329
  plan_source: planSource,
130
- template: input.template || null,
330
+ template: effectiveInput.template || null,
131
331
  change_policy: changePolicy,
132
- agent: input.agent,
332
+ agent: effectiveInput.agent,
133
333
  workspace_root: resolve(config.workspaceRoot),
134
334
  repo_path: resolvedRepoPath,
135
335
  resolved_repo_path: safeRepoPath,
136
336
  test_command: testCmd,
137
337
  verify_commands: verifyCommands,
138
338
  timeout_seconds: timeoutSeconds,
339
+ assessment_id: assessmentRecord?.assessment_id || input.assessment_id || null,
139
340
  status,
140
341
  phase: "queued",
141
342
  created_at: new Date().toISOString(),
@@ -163,14 +364,14 @@ export function createTask(input) {
163
364
  return {
164
365
  task_id: taskId,
165
366
  plan_id: planId,
166
- agent: input.agent,
367
+ agent: effectiveInput.agent,
167
368
  status,
168
369
  timeout_seconds: timeoutSeconds,
169
370
  continuation_required: watcher.available && hasWaitForTask,
170
371
  next_action: !watcher.available ? nextActionBlocked : hasWaitForTask ? nextActionWait : nextActionPoll,
171
372
  path: taskDir,
172
373
  plan_source: planSource,
173
- ...(input.template ? { template: input.template } : {}),
374
+ ...(effectiveInput.template ? { template: effectiveInput.template } : {}),
174
375
  change_policy: changePolicy,
175
376
  server_version: PATCHWARDEN_VERSION,
176
377
  tool_profile: catalog?.tool_profile || resolveToolProfile(config.toolProfile),
@@ -186,3 +387,53 @@ export function createTask(input) {
186
387
  : { name: "get_task_status", arguments: { task_id: taskId } },
187
388
  };
188
389
  }
390
+ function mergeAssessmentIntoInput(input, record) {
391
+ // Use plan_id from the assessment record (the plan was already saved during assess_only).
392
+ // Do NOT set template/inline_plan alongside plan_id — createTask requires exactly one plan source.
393
+ const merged = {
394
+ ...input,
395
+ plan_id: record.plan_id || input.plan_id,
396
+ template: undefined,
397
+ inline_plan: undefined,
398
+ goal: record.goal || input.goal,
399
+ agent: record.agent,
400
+ repo_path: record.repo_path,
401
+ test_command: record.test_command || undefined,
402
+ verify_commands: record.verify_commands || input.verify_commands,
403
+ timeout_seconds: record.timeout_seconds || input.timeout_seconds,
404
+ };
405
+ // Parameter mismatch check: if caller passed explicit params that differ from assessment
406
+ if (input.template && record.template && input.template !== record.template) {
407
+ throw new PatchWardenError("assessment_parameter_mismatch", `template mismatch: caller passed "${input.template}" but assessment has "${record.template}".`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "template", assessment_value: record.template, caller_value: input.template });
408
+ }
409
+ if (input.goal && record.goal && input.goal !== record.goal) {
410
+ throw new PatchWardenError("assessment_parameter_mismatch", `goal mismatch: caller passed a different goal than the assessment.`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "goal" });
411
+ }
412
+ if (input.repo_path && record.repo_path && input.repo_path !== record.repo_path) {
413
+ throw new PatchWardenError("assessment_parameter_mismatch", `repo_path mismatch: caller passed "${input.repo_path}" but assessment has "${record.repo_path}".`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "repo_path", assessment_value: record.repo_path, caller_value: input.repo_path });
414
+ }
415
+ return merged;
416
+ }
417
+ function createTaskDirectory(tasksDir, workspaceRoot, configuredTasksDir) {
418
+ guardPath(tasksDir, workspaceRoot, configuredTasksDir);
419
+ mkdirSync(tasksDir, { recursive: true });
420
+ for (let attempt = 0; attempt < 10; attempt += 1) {
421
+ const timestamp = new Date().toISOString()
422
+ .replace(/[-:]/g, "")
423
+ .replace("T", "_")
424
+ .slice(0, 15);
425
+ const taskId = `task_${timestamp}_${randomBytes(3).toString("hex")}`;
426
+ const taskDir = resolve(tasksDir, taskId);
427
+ guardPath(taskDir, workspaceRoot, configuredTasksDir);
428
+ try {
429
+ mkdirSync(taskDir, { recursive: false });
430
+ return { taskId, taskDir };
431
+ }
432
+ catch (error) {
433
+ if (error.code === "EEXIST")
434
+ continue;
435
+ throw error;
436
+ }
437
+ }
438
+ throw new PatchWardenError("task_id_generation_failed", "Could not allocate a unique short task ID.", "Retry create_task; no task directory was created.");
439
+ }
@@ -0,0 +1,19 @@
1
+ import { type ClassifiedChange } from "../runner/changeCapture.js";
2
+ export interface FinalizeDirectSessionInput {
3
+ session_id: string;
4
+ }
5
+ export interface FinalizeDirectSessionOutput {
6
+ session_id: string;
7
+ changed_files_total: number;
8
+ source_changes: ClassifiedChange[];
9
+ tracked_build_artifacts: ClassifiedChange[];
10
+ runtime_generated_files: ClassifiedChange[];
11
+ suspicious_changes: ClassifiedChange[];
12
+ out_of_scope_changes: ClassifiedChange[];
13
+ verification_summary: Record<string, "passed" | "failed">;
14
+ diff_path: string;
15
+ summary_path: string;
16
+ finalized: boolean;
17
+ next_action: string;
18
+ }
19
+ export declare function finalizeDirectSession(input: FinalizeDirectSessionInput): FinalizeDirectSessionOutput;
@@ -0,0 +1,84 @@
1
+ import { join, resolve, relative, isAbsolute } from "node:path";
2
+ import { readDirectSession, finalizeDirectSessionRecord, getDirectSessionDir, } from "../direct/directSessionStore.js";
3
+ import { guardDirectSessionActive } from "../direct/directGuards.js";
4
+ import { captureRepoSnapshot, buildChangeArtifacts, } from "../runner/changeCapture.js";
5
+ // ── Main function ──────────────────────────────────────────────────
6
+ export function finalizeDirectSession(input) {
7
+ const { session_id } = input;
8
+ // 1. Read session and guard active (not expired, not finalized)
9
+ const session = readDirectSession(session_id);
10
+ guardDirectSessionActive(session);
11
+ // 2. Capture after snapshot
12
+ const afterSnapshot = captureRepoSnapshot(session.resolved_repo_path);
13
+ // 3. Build change artifacts from before/after snapshots
14
+ const changeArtifacts = buildChangeArtifacts(session.resolved_repo_path, session.workspace_snapshot_before, afterSnapshot);
15
+ // 4. Finalize session record — writes changed-files.json, diff.patch,
16
+ // summary.json, summary.md and marks the session as finalized.
17
+ finalizeDirectSessionRecord(session_id, afterSnapshot, changeArtifacts);
18
+ // 5. Build verification summary from session.verification_runs
19
+ const verification_summary = {};
20
+ for (const run of session.verification_runs) {
21
+ verification_summary[run.command] = run.passed ? "passed" : "failed";
22
+ }
23
+ // 6. Identify out-of-scope changes (paths outside resolved_repo_path)
24
+ const out_of_scope_changes = findOutOfScopeChanges(changeArtifacts, session.resolved_repo_path);
25
+ // 7. Build compact summary with categorized changes
26
+ const sessionDir = getDirectSessionDir(session_id);
27
+ return {
28
+ session_id,
29
+ changed_files_total: changeArtifacts.changed_files.length,
30
+ source_changes: changeArtifacts.artifact_hygiene.source_changes,
31
+ tracked_build_artifacts: changeArtifacts.artifact_hygiene.tracked_build_artifacts,
32
+ runtime_generated_files: changeArtifacts.artifact_hygiene.runtime_generated_files,
33
+ suspicious_changes: changeArtifacts.artifact_hygiene.suspicious_changes,
34
+ out_of_scope_changes,
35
+ verification_summary,
36
+ diff_path: join(sessionDir, "diff.patch"),
37
+ summary_path: join(sessionDir, "summary.md"),
38
+ finalized: true,
39
+ next_action: "Call audit_session to independently review the changes.",
40
+ };
41
+ }
42
+ // ── Helpers ────────────────────────────────────────────────────────
43
+ /**
44
+ * Filter changeArtifacts.changed_files for paths that resolve outside the
45
+ * session's resolved_repo_path and map them to ClassifiedChange format.
46
+ */
47
+ function findOutOfScopeChanges(artifacts, resolvedRepoPath) {
48
+ const normalizedRepo = resolve(resolvedRepoPath);
49
+ return artifacts.changed_files
50
+ .filter((file) => {
51
+ const fullPath = resolve(normalizedRepo, file.path);
52
+ const rel = relative(normalizedRepo, fullPath);
53
+ return isAbsolute(rel) || rel.startsWith("..");
54
+ })
55
+ .map((file) => toClassifiedChange(file));
56
+ }
57
+ /**
58
+ * Map a ChangedFile to the ClassifiedChange format by adding a reason string.
59
+ */
60
+ function toClassifiedChange(file) {
61
+ return {
62
+ path: file.path,
63
+ change: file.change,
64
+ tracked: file.tracked,
65
+ ignored: file.ignored,
66
+ kind: file.kind,
67
+ reason: classificationReason(file),
68
+ };
69
+ }
70
+ /**
71
+ * Produce a human-readable reason for a changed file classification.
72
+ * Mirrors the logic in changeCapture.ts classifyArtifactHygiene.
73
+ */
74
+ function classificationReason(change) {
75
+ if (change.ignored)
76
+ return "untracked path is ignored by repository Git rules";
77
+ if (change.kind === "build_artifact" && change.tracked)
78
+ return "artifact-like path is tracked by Git and requires review";
79
+ if (change.kind === "build_artifact")
80
+ return "artifact-like path is not ignored and requires review";
81
+ if (change.kind === "runtime_generated")
82
+ return "runtime-generated path is not ignored and requires review";
83
+ return change.tracked ? "tracked source change" : "untracked source change";
84
+ }
@@ -62,5 +62,55 @@ export interface TaskSummaryOutput {
62
62
  watcher: unknown;
63
63
  pending_reason: string | null;
64
64
  execution_blocked: boolean;
65
+ artifact_hygiene: Record<string, unknown>;
65
66
  }
67
+ export interface CompactTaskSummaryOutput {
68
+ view: "compact";
69
+ task_id: string;
70
+ status: string;
71
+ terminal: boolean;
72
+ acceptance_status: TaskSummaryOutput["acceptance_status"];
73
+ phase: string;
74
+ repo_path: string;
75
+ changed_files_total: number;
76
+ out_of_scope_changes_total: number;
77
+ artifact_hygiene: {
78
+ counts: Record<string, number>;
79
+ source_changes: unknown[];
80
+ tracked_build_artifacts: unknown[];
81
+ ignored_untracked_artifacts: unknown[];
82
+ runtime_generated_files: unknown[];
83
+ suspicious_changes: unknown[];
84
+ max_items: number;
85
+ truncated: boolean;
86
+ };
87
+ release_artifacts_count: number;
88
+ release_artifact_paths: string[];
89
+ artifact_status: string | null;
90
+ verification_summary: TaskSummaryOutput["verification_summary"];
91
+ summary: string;
92
+ warnings: string[];
93
+ errors: string[];
94
+ failure_reason: string | null;
95
+ failed_command: string | null;
96
+ suggested_next_action: string;
97
+ execution_blocked: boolean;
98
+ pending_reason: string | null;
99
+ redacted: boolean;
100
+ redaction_categories: string[];
101
+ }
102
+ export interface GetTaskSummaryOptions {
103
+ view?: "compact" | "standard";
104
+ max_items?: number;
105
+ }
106
+ export type TaskSummaryResult = TaskSummaryOutput | CompactTaskSummaryOutput;
66
107
  export declare function getTaskSummary(taskId: string): TaskSummaryOutput;
108
+ export declare function getTaskSummary(taskId: string, options: {
109
+ view: "compact";
110
+ max_items?: number;
111
+ }): CompactTaskSummaryOutput;
112
+ export declare function getTaskSummary(taskId: string, options: {
113
+ view?: "standard";
114
+ max_items?: number;
115
+ }): TaskSummaryOutput;
116
+ export declare function getTaskSummary(taskId: string, options: GetTaskSummaryOptions): TaskSummaryResult;