patchwarden 1.5.0 → 1.5.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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -175,7 +175,7 @@ try {
175
175
  await test("git diff captures tracked and untracked task changes", async () => {
176
176
  writeFileSync(join(repoPath, "preexisting-user-file.txt"), "preexisting and untouched\n", "utf-8");
177
177
  const plan = savePlan({ title: "Change capture", content: "Modify fixture files." });
178
- const task = createTask({
178
+ const task = await createTask({
179
179
  plan_id: plan.plan_id,
180
180
  agent: "writer",
181
181
  repo_path: "repo",
@@ -231,7 +231,7 @@ try {
231
231
  });
232
232
 
233
233
  await test("inspect_only template fails when the agent changes repository files", async () => {
234
- const task = createTask({
234
+ const task = await createTask({
235
235
  template: "inspect_only",
236
236
  goal: "Inspect the fixture without modifying files",
237
237
  agent: "writer",
@@ -249,7 +249,7 @@ try {
249
249
 
250
250
  await test("legacy test_command becomes one independent verification command", async () => {
251
251
  const plan = savePlan({ title: "Legacy verification", content: "No changes." });
252
- const task = createTask({
252
+ const task = await createTask({
253
253
  plan_id: plan.plan_id,
254
254
  agent: "noop",
255
255
  repo_path: "repo",
@@ -267,7 +267,7 @@ try {
267
267
 
268
268
  await test("no-change task returns an explicit empty diff result", async () => {
269
269
  const plan = savePlan({ title: "No diff", content: "Do not change files." });
270
- const task = createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo" });
270
+ const task = await createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo" });
271
271
  const result = await runTask(task.task_id);
272
272
  if (!isDoneStatus(result.status)) throw new Error(`No-op task failed: ${JSON.stringify(result)}`);
273
273
  const diff = getDiff(task.task_id);
@@ -288,7 +288,7 @@ try {
288
288
 
289
289
  await test("large diff response is truncated while diff.patch remains complete", async () => {
290
290
  const plan = savePlan({ title: "Large diff", content: "Create a large text file." });
291
- const task = createTask({ plan_id: plan.plan_id, agent: "largewriter", repo_path: "repo" });
291
+ const task = await createTask({ plan_id: plan.plan_id, agent: "largewriter", repo_path: "repo" });
292
292
  const result = await runTask(task.task_id);
293
293
  if (!isDoneStatus(result.status)) throw new Error(`Large task failed: ${JSON.stringify(result)}`);
294
294
  const diff = getDiff(task.task_id);
@@ -300,7 +300,7 @@ try {
300
300
 
301
301
  await test("non-Git repositories return hash-only evidence with a reason", async () => {
302
302
  const plan = savePlan({ title: "Non-Git evidence", content: "Modify files in a non-Git repository." });
303
- const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "plain-repo" });
303
+ const task = await createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "plain-repo" });
304
304
  const result = await runTask(task.task_id);
305
305
  if (!isDoneStatus(result.status)) throw new Error(`Non-Git task failed: ${JSON.stringify(result)}`);
306
306
  const diff = getDiff(task.task_id);
@@ -315,7 +315,7 @@ try {
315
315
 
316
316
  await test("binary Git changes remain reviewable as a textual Git binary patch", async () => {
317
317
  const plan = savePlan({ title: "Binary evidence", content: "Create a binary fixture." });
318
- const task = createTask({ plan_id: plan.plan_id, agent: "binarywriter", repo_path: "repo" });
318
+ const task = await createTask({ plan_id: plan.plan_id, agent: "binarywriter", repo_path: "repo" });
319
319
  const result = await runTask(task.task_id);
320
320
  if (!isDoneStatus(result.status)) throw new Error(`Binary task failed: ${JSON.stringify(result)}`);
321
321
  const diff = getDiff(task.task_id);
@@ -326,7 +326,7 @@ try {
326
326
 
327
327
  await test("artifact hygiene separates source, ignored output, runtime data, and suspicious changes", async () => {
328
328
  const plan = savePlan({ title: "Artifact hygiene", content: "Generate representative task outputs." });
329
- const task = createTask({ plan_id: plan.plan_id, agent: "artifactwriter", repo_path: "repo" });
329
+ const task = await createTask({ plan_id: plan.plan_id, agent: "artifactwriter", repo_path: "repo" });
330
330
  const result = await runTask(task.task_id);
331
331
  if (!isDoneStatus(result.status)) throw new Error(`Artifact task failed: ${JSON.stringify(result)}`);
332
332
  const standard = getTaskSummary(task.task_id);
@@ -355,7 +355,7 @@ try {
355
355
 
356
356
  await test("deleted tracked files are identified with file stats", async () => {
357
357
  const plan = savePlan({ title: "Delete fixture", content: "Delete the designated fixture file." });
358
- const task = createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
358
+ const task = await createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
359
359
  const result = await runTask(task.task_id);
360
360
  if (!isDoneStatus(result.status)) throw new Error(`Delete task failed: ${JSON.stringify(result)}`);
361
361
  const diff = getDiff(task.task_id);
@@ -366,7 +366,7 @@ try {
366
366
 
367
367
  await test("wait_for_task stays in the tool loop and returns terminal acceptance", async () => {
368
368
  const plan = savePlan({ title: "Wait loop", content: "Finish normally." });
369
- const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
369
+ const task = await createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
370
370
  const running = runTask(task.task_id);
371
371
  const waited = await waitForTask(task.task_id, 5);
372
372
  await raceWithTimeout(running, 15000, "wait_for_task loop did not terminate within 15s");
@@ -380,7 +380,7 @@ try {
380
380
 
381
381
  await test("wait_for_task explicitly requires another call when the task is not terminal", async () => {
382
382
  const plan = savePlan({ title: "Pending wait", content: "Remain queued for this check." });
383
- const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo" });
383
+ const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo" });
384
384
  const waited = await waitForTask(task.task_id, 1);
385
385
  if (waited.terminal || !waited.timed_out || !waited.continuation_required) {
386
386
  throw new Error(`Expected continuation response: ${JSON.stringify(waited)}`);
@@ -390,11 +390,17 @@ try {
390
390
 
391
391
  await test("running task summary includes heartbeat, phase, command, and elapsed time", async () => {
392
392
  const plan = savePlan({ title: "Running summary", content: "Wait." });
393
- const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
393
+ const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
394
394
  const running = runTask(task.task_id);
395
395
  await waitForRunning(getTaskStatus, task.task_id);
396
- await sleep(50);
397
- const summary = getTaskSummary(task.task_id);
396
+ // Wait for the task to transition from "preparing" to actually running a command.
397
+ // Async snapshot may delay the preparing phase, so poll until current_command appears.
398
+ let summary;
399
+ for (let attempt = 0; attempt < 100; attempt++) {
400
+ await sleep(50);
401
+ summary = getTaskSummary(task.task_id);
402
+ if (summary.current_command) break;
403
+ }
398
404
  if (summary.terminal || !summary.last_heartbeat_at || !summary.phase || !summary.current_command || summary.elapsed_ms < 0) {
399
405
  throw new Error(`Running summary incomplete: ${JSON.stringify(summary)}`);
400
406
  }
@@ -404,7 +410,7 @@ try {
404
410
 
405
411
  await test("verification failure produces failed_verification and structured evidence", async () => {
406
412
  const plan = savePlan({ title: "Verify failure", content: "Finish normally." });
407
- const task = createTask({
413
+ const task = await createTask({
408
414
  plan_id: plan.plan_id,
409
415
  agent: "writer",
410
416
  repo_path: "repo",
@@ -434,7 +440,7 @@ try {
434
440
 
435
441
  await test("out-of-scope workspace changes fail the task and generate a rollback plan", async () => {
436
442
  const plan = savePlan({ title: "Scope violation", content: "Do not leave the repository." });
437
- const task = createTask({
443
+ const task = await createTask({
438
444
  plan_id: plan.plan_id,
439
445
  agent: "scopebreaker",
440
446
  repo_path: "repo",
@@ -505,7 +511,7 @@ try {
505
511
  reloadConfig(cfg2);
506
512
 
507
513
  const plan = savePlan({ title: "Noop with external dirty", content: "Do nothing." });
508
- const task = createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo", verify_commands: ["node --check main.js"] });
514
+ const task = await createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo", verify_commands: ["node --check main.js"] });
509
515
  const result = await runTask(task.task_id);
510
516
 
511
517
  if (!isDoneStatus(result.status)) {
@@ -590,7 +596,7 @@ try {
590
596
  reloadConfig(cfg2);
591
597
 
592
598
  const plan = savePlan({ title: "External dirty modifier", content: "Modify external tracked file." });
593
- const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
599
+ const task = await createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
594
600
  const result = await runTask(task.task_id);
595
601
 
596
602
  if (result.status !== "failed_scope_violation") {
@@ -675,7 +681,7 @@ try {
675
681
  reloadConfig(cfg2);
676
682
 
677
683
  const plan = savePlan({ title: "Clean external modifier", content: "Modify clean external tracked file." });
678
- const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
684
+ const task = await createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
679
685
  const result = await runTask(task.task_id);
680
686
 
681
687
  if (result.status !== "failed_scope_violation") {
@@ -752,7 +758,7 @@ try {
752
758
  reloadConfig(cfg2);
753
759
 
754
760
  const plan = savePlan({ title: "External rename", content: "Rename external tracked file." });
755
- const task = createTask({ plan_id: plan.plan_id, agent: "extrenamer", repo_path: "repo", verify_commands: ["node --check main.js"] });
761
+ const task = await createTask({ plan_id: plan.plan_id, agent: "extrenamer", repo_path: "repo", verify_commands: ["node --check main.js"] });
756
762
  const result = await runTask(task.task_id);
757
763
 
758
764
  if (result.status !== "failed_scope_violation") {
@@ -794,7 +800,7 @@ try {
794
800
 
795
801
  await test("timeout terminates a long-running agent", async () => {
796
802
  const plan = savePlan({ title: "Timeout", content: "Wait." });
797
- const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
803
+ const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
798
804
  const started = Date.now();
799
805
  const result = await runTask(task.task_id);
800
806
  if (result.status !== "failed" || !result.error?.includes("timed out")) {
@@ -810,7 +816,7 @@ try {
810
816
 
811
817
  await test("cancel_task safely stops a running agent", async () => {
812
818
  const plan = savePlan({ title: "Cancel", content: "Wait." });
813
- const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
819
+ const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
814
820
  const running = runTask(task.task_id);
815
821
  await waitForRunning(getTaskStatus, task.task_id);
816
822
  const request = cancelTask(task.task_id);
@@ -823,7 +829,7 @@ try {
823
829
 
824
830
  await test("kill_task immediately stops a running agent", async () => {
825
831
  const plan = savePlan({ title: "Kill", content: "Wait." });
826
- const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
832
+ const task = await createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
827
833
  const running = runTask(task.task_id);
828
834
  await waitForRunning(getTaskStatus, task.task_id);
829
835
  const request = killTask(task.task_id);
@@ -117,6 +117,7 @@ try {
117
117
  "discard_worktree",
118
118
  "discover_tools",
119
119
  "explain_tool",
120
+ "export_goal_report",
120
121
  "export_handoff",
121
122
  "export_task_evidence_pack",
122
123
  "finalize_direct_session",
@@ -133,6 +134,7 @@ try {
133
134
  "get_task_summary",
134
135
  "get_test_log",
135
136
  "health_check",
137
+ "import_speckit_tasks",
136
138
  "invoke_discovered_tool",
137
139
  "kill_task",
138
140
  "list_agents",
@@ -177,7 +179,7 @@ try {
177
179
  if (!tools._meta || typeof tools._meta.tool_manifest_sha256 !== "string" || tools._meta.tool_manifest_sha256.length !== 64) {
178
180
  throw new Error(`tools/list _meta missing manifest hash: ${JSON.stringify(tools._meta || null)}`);
179
181
  }
180
- if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 64) {
182
+ if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 66) {
181
183
  throw new Error(`tools/list _meta profile/count mismatch: ${JSON.stringify(tools._meta)}`);
182
184
  }
183
185
  if (typeof tools._meta.schema_epoch !== "string" || typeof tools._meta.server_version !== "string") {
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from PIL import Image, ImageDraw, ImageFont
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ OUT = ROOT / "docs" / "assets" / "patchwarden-oss-demo.gif"
10
+
11
+ W, H = 1280, 720
12
+ BG = (246, 248, 245)
13
+ INK = (32, 39, 39)
14
+ MUTED = (91, 101, 101)
15
+ GREEN = (38, 124, 92)
16
+ TEAL = (25, 112, 116)
17
+ BLUE = (48, 92, 160)
18
+ ORANGE = (183, 104, 36)
19
+ RED = (165, 62, 62)
20
+ LINE = (205, 214, 208)
21
+ PANEL = (255, 255, 252)
22
+ SHADOW = (218, 224, 219)
23
+ TERMINAL = (27, 34, 34)
24
+ TERMINAL_TEXT = (208, 230, 218)
25
+
26
+
27
+ def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
28
+ candidates = [
29
+ "C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf",
30
+ "C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf",
31
+ ]
32
+ for candidate in candidates:
33
+ try:
34
+ return ImageFont.truetype(candidate, size)
35
+ except OSError:
36
+ pass
37
+ return ImageFont.load_default()
38
+
39
+
40
+ F_TITLE = font(44, True)
41
+ F_H2 = font(27, True)
42
+ F_BODY = font(22)
43
+ F_SMALL = font(18)
44
+ F_MONO = font(19)
45
+ F_MONO_SMALL = font(16)
46
+
47
+
48
+ def rounded(draw: ImageDraw.ImageDraw, box, fill, outline=None, radius=18, width=2):
49
+ x0, y0, x1, y1 = box
50
+ draw.rounded_rectangle((x0 + 6, y0 + 8, x1 + 6, y1 + 8), radius, fill=SHADOW)
51
+ draw.rounded_rectangle(box, radius, fill=fill, outline=outline or LINE, width=width)
52
+
53
+
54
+ def label(draw: ImageDraw.ImageDraw, xy, text, fill=INK, fnt=F_BODY, anchor=None):
55
+ draw.text(xy, text, fill=fill, font=fnt, anchor=anchor)
56
+
57
+
58
+ def pill(draw: ImageDraw.ImageDraw, xy, text, fill, text_fill=(255, 255, 255)):
59
+ x, y = xy
60
+ bbox = draw.textbbox((0, 0), text, font=F_SMALL)
61
+ pad_x = 14
62
+ pad_y = 7
63
+ w = bbox[2] - bbox[0] + pad_x * 2
64
+ h = bbox[3] - bbox[1] + pad_y * 2
65
+ draw.rounded_rectangle((x, y, x + w, y + h), h // 2, fill=fill)
66
+ draw.text((x + pad_x, y + pad_y - 1), text, fill=text_fill, font=F_SMALL)
67
+
68
+
69
+ def wrap(draw: ImageDraw.ImageDraw, text: str, width: int, fnt=F_BODY) -> list[str]:
70
+ lines: list[str] = []
71
+ current = ""
72
+ for word in text.split():
73
+ test = f"{current} {word}".strip()
74
+ if draw.textlength(test, font=fnt) <= width:
75
+ current = test
76
+ else:
77
+ if current:
78
+ lines.append(current)
79
+ current = word
80
+ if current:
81
+ lines.append(current)
82
+ return lines
83
+
84
+
85
+ def draw_panel_text(draw, box, title, body, accent):
86
+ x0, y0, x1, y1 = box
87
+ rounded(draw, box, PANEL, LINE)
88
+ draw.rectangle((x0, y0, x0 + 8, y1), fill=accent)
89
+ label(draw, (x0 + 28, y0 + 24), title, fnt=F_H2)
90
+ y = y0 + 68
91
+ for line in wrap(draw, body, x1 - x0 - 56, F_BODY):
92
+ label(draw, (x0 + 28, y), line, fill=MUTED, fnt=F_BODY)
93
+ y += 30
94
+
95
+
96
+ def draw_terminal(draw, box, lines):
97
+ rounded(draw, box, TERMINAL, (66, 78, 78), radius=16)
98
+ x0, y0, x1, _ = box
99
+ draw.ellipse((x0 + 20, y0 + 18, x0 + 32, y0 + 30), fill=(236, 98, 86))
100
+ draw.ellipse((x0 + 42, y0 + 18, x0 + 54, y0 + 30), fill=(241, 190, 76))
101
+ draw.ellipse((x0 + 64, y0 + 18, x0 + 76, y0 + 30), fill=(99, 198, 103))
102
+ y = y0 + 54
103
+ for line, color in lines:
104
+ label(draw, (x0 + 24, y), line, fill=color, fnt=F_MONO_SMALL)
105
+ y += 25
106
+ if y > box[3] - 24:
107
+ break
108
+
109
+
110
+ def draw_progress(draw, active: int):
111
+ steps = [
112
+ ("Plan", BLUE),
113
+ ("Guard", TEAL),
114
+ ("Execute", ORANGE),
115
+ ("Evidence", GREEN),
116
+ ("Review", GREEN),
117
+ ]
118
+ start_x = 165
119
+ y = 650
120
+ gap = 235
121
+ for i, (name, color) in enumerate(steps):
122
+ x = start_x + gap * i
123
+ if i > 0:
124
+ line_color = color if i <= active else LINE
125
+ draw.line((x - gap + 48, y, x - 48, y), fill=line_color, width=5)
126
+ fill = color if i <= active else (226, 232, 228)
127
+ text = (255, 255, 255) if i <= active else MUTED
128
+ draw.ellipse((x - 28, y - 28, x + 28, y + 28), fill=fill)
129
+ label(draw, (x, y - 1), str(i + 1), fill=text, fnt=F_H2, anchor="mm")
130
+ label(draw, (x, y + 44), name, fill=INK if i <= active else MUTED, fnt=F_SMALL, anchor="mm")
131
+
132
+
133
+ def base(title: str, subtitle: str, active: int) -> tuple[Image.Image, ImageDraw.ImageDraw]:
134
+ img = Image.new("RGB", (W, H), BG)
135
+ draw = ImageDraw.Draw(img)
136
+ label(draw, (64, 42), title, fnt=F_TITLE)
137
+ label(draw, (66, 96), subtitle, fill=MUTED, fnt=F_BODY)
138
+ pill(draw, (1040, 48), "privacy-safe demo", GREEN)
139
+ draw_progress(draw, active)
140
+ return img, draw
141
+
142
+
143
+ def frame_intro():
144
+ img, draw = base(
145
+ "PatchWarden OSS Demo",
146
+ "A safe MCP task loop for ChatGPT, Codex, OpenCode, and local maintainers.",
147
+ 0,
148
+ )
149
+ draw_panel_text(
150
+ draw,
151
+ (90, 170, 1190, 530),
152
+ "What this GIF shows",
153
+ "A model plans work, PatchWarden turns it into a bounded task, a registered local agent executes it, and the maintainer reviews redacted evidence before accepting anything.",
154
+ GREEN,
155
+ )
156
+ pill(draw, (130, 420), "no API keys", BLUE)
157
+ pill(draw, (280, 420), "no real account names", TEAL)
158
+ pill(draw, (520, 420), "no unrestricted shell", ORANGE)
159
+ pill(draw, (770, 420), "no auto-publish", RED)
160
+ return img
161
+
162
+
163
+ def frame_plan():
164
+ img, draw = base(
165
+ "1. Model creates a bounded plan",
166
+ "The upstream client asks for a small maintainer task, not a raw shell command.",
167
+ 0,
168
+ )
169
+ draw_panel_text(
170
+ draw,
171
+ (70, 160, 590, 520),
172
+ "ChatGPT / Codex",
173
+ "Goal: update docs for a small OSS workflow. Verify with npm.cmd test. Return summary, diff, and evidence only.",
174
+ BLUE,
175
+ )
176
+ draw_panel_text(
177
+ draw,
178
+ (690, 160, 1210, 520),
179
+ "PatchWarden MCP",
180
+ "Receives structured task input with repo_path, agent, template, and allowlisted verification commands.",
181
+ TEAL,
182
+ )
183
+ draw.line((600, 340, 680, 340), fill=BLUE, width=6)
184
+ draw.polygon([(680, 340), (660, 328), (660, 352)], fill=BLUE)
185
+ return img
186
+
187
+
188
+ def frame_guard():
189
+ img, draw = base(
190
+ "2. PatchWarden checks the safety boundary",
191
+ "Local policy decides what can run and where files may change.",
192
+ 1,
193
+ )
194
+ draw_panel_text(
195
+ draw,
196
+ (70, 160, 1210, 330),
197
+ "Guardrails",
198
+ "repo_path must stay under workspaceRoot. Agent launch commands come from trusted config. Verification commands must exactly match allowedTestCommands.",
199
+ TEAL,
200
+ )
201
+ draw_panel_text(
202
+ draw,
203
+ (70, 380, 580, 540),
204
+ "Blocked by default",
205
+ ".env, tokens, SSH keys, cookies, credential files, out-of-workspace writes, and arbitrary shell.",
206
+ RED,
207
+ )
208
+ draw_panel_text(
209
+ draw,
210
+ (700, 380, 1210, 540),
211
+ "Allowed",
212
+ "A pre-registered local agent can work on the requested repository and produce auditable evidence.",
213
+ GREEN,
214
+ )
215
+ return img
216
+
217
+
218
+ def frame_execute():
219
+ img, draw = base(
220
+ "3. Watcher launches the registered agent",
221
+ "The task is executed locally while PatchWarden records status and scope evidence.",
222
+ 2,
223
+ )
224
+ draw_terminal(
225
+ draw,
226
+ (80, 160, 1200, 540),
227
+ [
228
+ ("> patchwarden watcher", TERMINAL_TEXT),
229
+ ("queued task_20260709_demo", (156, 205, 255)),
230
+ ("repo_path: demo-oss-project", TERMINAL_TEXT),
231
+ ("agent: opencode (registered)", TERMINAL_TEXT),
232
+ ("verify: npm.cmd test (allowlisted)", TERMINAL_TEXT),
233
+ ("scope: inside workspaceRoot", (142, 222, 170)),
234
+ ("status: running -> done", (142, 222, 170)),
235
+ ],
236
+ )
237
+ return img
238
+
239
+
240
+ def frame_evidence():
241
+ img, draw = base(
242
+ "4. Evidence is written for review",
243
+ "The maintainer can inspect safe summaries before opening deeper logs or diffs.",
244
+ 3,
245
+ )
246
+ draw_panel_text(
247
+ draw,
248
+ (80, 150, 410, 540),
249
+ "result.json",
250
+ "status, warnings, changed-file groups, and recommended next actions.",
251
+ GREEN,
252
+ )
253
+ draw_panel_text(
254
+ draw,
255
+ (475, 150, 805, 540),
256
+ "diff.patch",
257
+ "Git diff evidence when available, kept separate from the model plan.",
258
+ BLUE,
259
+ )
260
+ draw_panel_text(
261
+ draw,
262
+ (870, 150, 1200, 540),
263
+ "verify.json",
264
+ "Exact commands, exit codes, and verification status.",
265
+ ORANGE,
266
+ )
267
+ return img
268
+
269
+
270
+ def frame_review():
271
+ img, draw = base(
272
+ "5. Maintainer accepts, fixes, or rejects",
273
+ "PatchWarden supports review. It does not silently push, publish, or restart services.",
274
+ 4,
275
+ )
276
+ draw_panel_text(
277
+ draw,
278
+ (80, 160, 1200, 520),
279
+ "OSS maintainer handoff",
280
+ "Use safe_result, safe_audit, safe_test_summary, and evidence packs to decide the next step. Release actions still require separate human confirmation and GitHub/npm verification.",
281
+ GREEN,
282
+ )
283
+ pill(draw, (165, 410), "reviewable", GREEN)
284
+ pill(draw, (360, 410), "redacted", TEAL)
285
+ pill(draw, (525, 410), "bounded", BLUE)
286
+ pill(draw, (680, 410), "confirmation-gated", ORANGE)
287
+ return img
288
+
289
+
290
+ def hold(frames, image, count):
291
+ for _ in range(count):
292
+ frames.append(image.copy())
293
+
294
+
295
+ def main():
296
+ OUT.parent.mkdir(parents=True, exist_ok=True)
297
+ scenes = [
298
+ frame_intro(),
299
+ frame_plan(),
300
+ frame_guard(),
301
+ frame_execute(),
302
+ frame_evidence(),
303
+ frame_review(),
304
+ ]
305
+ frames: list[Image.Image] = []
306
+ for scene in scenes:
307
+ hold(frames, scene, 12)
308
+ frames[0].save(
309
+ OUT,
310
+ save_all=True,
311
+ append_images=frames[1:],
312
+ duration=120,
313
+ loop=0,
314
+ optimize=True,
315
+ )
316
+ print(OUT)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()
@@ -21,7 +21,7 @@ export interface AgentAssessorInput {
21
21
  config: PatchWardenConfig;
22
22
  }
23
23
 
24
- export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSummary {
24
+ export async function runAgentAssessment(input: AgentAssessorInput): Promise<AgentAssessmentSummary> {
25
25
  const logPaths: AgentAssessmentSummary["log_paths"] = {
26
26
  stdout: resolve(input.assessmentDir, "agent-assessment-stdout.log"),
27
27
  stderr: resolve(input.assessmentDir, "agent-assessment-stderr.log"),
@@ -53,7 +53,7 @@ export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSu
53
53
  // ── 2. Before snapshot (repo-scoped) ──
54
54
  let repoBefore: RepoSnapshot;
55
55
  try {
56
- repoBefore = captureRepoSnapshot(input.repoPath);
56
+ repoBefore = await captureRepoSnapshot(input.repoPath);
57
57
  } catch {
58
58
  // If we can't capture before snapshot, conservatively skip agent assessment
59
59
  emptySummary.status = "spawn_failed";
@@ -95,7 +95,7 @@ export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSu
95
95
  let readOnlyViolation = false;
96
96
  let violationFiles: string[] = [];
97
97
  try {
98
- const repoAfter = captureRepoSnapshot(input.repoPath);
98
+ const repoAfter = await captureRepoSnapshot(input.repoPath);
99
99
  const changes = compareSnapshots(repoBefore, repoAfter);
100
100
  if (changes.length > 0) {
101
101
  readOnlyViolation = true;
@@ -314,7 +314,7 @@ export function validateAssessmentFreshness(
314
314
  * This is intentionally not registered as an MCP tool: a remote client may
315
315
  * request confirmation, but it cannot grant confirmation to itself.
316
316
  */
317
- export function confirmAssessment(assessmentId: string): AssessmentConfirmationResult {
317
+ export async function confirmAssessment(assessmentId: string): Promise<AssessmentConfirmationResult> {
318
318
  if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
319
319
  throw new PatchWardenError(
320
320
  "assessment_id_invalid",
@@ -334,7 +334,7 @@ export function confirmAssessment(assessmentId: string): AssessmentConfirmationR
334
334
  );
335
335
  }
336
336
 
337
- const snapshot = captureRepoSnapshot(assessment.resolved_repo_path);
337
+ const snapshot = await captureRepoSnapshot(assessment.resolved_repo_path);
338
338
  const validation = validateAssessmentFreshness(assessmentId, snapshot, {
339
339
  allow_unconfirmed: true,
340
340
  // A standalone CLI has no active MCP profile snapshot. The execute path
@@ -10,20 +10,21 @@
10
10
  import { loadConfig } from "../config.js";
11
11
  import { confirmAssessment } from "./assessmentStore.js";
12
12
  import { errorPayload } from "../errors.js";
13
+ import { logger } from "../logging.js";
13
14
 
14
15
  loadConfig();
15
16
 
16
17
  const assessmentId = process.argv[2];
17
18
  if (!assessmentId || process.argv.length !== 3) {
18
- console.error("Usage: patchwarden-confirm <full_assessment_id>");
19
- console.error("The display-only assessment_short_id is not accepted.");
19
+ logger.info("Usage: patchwarden-confirm <full_assessment_id>");
20
+ logger.info("The display-only assessment_short_id is not accepted.");
20
21
  process.exit(1);
21
22
  }
22
23
 
23
24
  try {
24
- const result = confirmAssessment(assessmentId);
25
+ const result = await confirmAssessment(assessmentId);
25
26
  console.log(JSON.stringify(result, null, 2));
26
27
  } catch (error) {
27
- console.error(JSON.stringify(errorPayload(error), null, 2));
28
+ logger.error("confirm_assessment_failed", { error: errorPayload(error) });
28
29
  process.exit(1);
29
30
  }
package/src/config.ts CHANGED
@@ -23,6 +23,9 @@ export interface PatchWardenConfig {
23
23
  watcherStaleSeconds: number;
24
24
  toolProfile?: "full" | "chatgpt_core" | "chatgpt_direct" | "chatgpt_search";
25
25
  repoAliases?: Record<string, string>;
26
+ httpPort?: number;
27
+ http?: { port?: number; host?: string; ownerTokenEnv?: string };
28
+ enableRunTaskTool?: boolean;
26
29
  enableAgentAssessment?: boolean;
27
30
  agentAssessmentTimeoutSeconds?: number;
28
31
  agentAssessmentMaxOutputBytes?: number;
@@ -233,6 +236,40 @@ function normalizeConfig(config: PatchWardenConfig): PatchWardenConfig {
233
236
  ) {
234
237
  throw new Error('toolProfile must be "full", "chatgpt_core", "chatgpt_direct", or "chatgpt_search"');
235
238
  }
239
+ if (config.repoAliases !== undefined) {
240
+ if (typeof config.repoAliases !== "object" || config.repoAliases === null || Array.isArray(config.repoAliases)) {
241
+ throw new Error("repoAliases must be an object mapping alias names to repository paths");
242
+ }
243
+ for (const [alias, target] of Object.entries(config.repoAliases)) {
244
+ if (typeof target !== "string") {
245
+ throw new Error(`repoAliases["${alias}"] must be a string`);
246
+ }
247
+ }
248
+ }
249
+ if (config.httpPort !== undefined) {
250
+ if (typeof config.httpPort !== "number" || !Number.isInteger(config.httpPort) || config.httpPort < 1 || config.httpPort > 65535) {
251
+ throw new Error("httpPort must be an integer from 1 to 65535");
252
+ }
253
+ }
254
+ if (config.http !== undefined) {
255
+ if (typeof config.http !== "object" || config.http === null || Array.isArray(config.http)) {
256
+ throw new Error("http must be an object");
257
+ }
258
+ if (config.http.port !== undefined) {
259
+ if (typeof config.http.port !== "number" || !Number.isInteger(config.http.port) || config.http.port < 1 || config.http.port > 65535) {
260
+ throw new Error("http.port must be an integer from 1 to 65535");
261
+ }
262
+ }
263
+ if (config.http.host !== undefined && typeof config.http.host !== "string") {
264
+ throw new Error("http.host must be a string");
265
+ }
266
+ if (config.http.ownerTokenEnv !== undefined && typeof config.http.ownerTokenEnv !== "string") {
267
+ throw new Error("http.ownerTokenEnv must be a string");
268
+ }
269
+ }
270
+ if (config.enableRunTaskTool !== undefined && typeof config.enableRunTaskTool !== "boolean") {
271
+ throw new Error("enableRunTaskTool must be a boolean");
272
+ }
236
273
  if (config.enableAgentAssessment !== undefined && typeof config.enableAgentAssessment !== "boolean") {
237
274
  throw new Error("enableAgentAssessment must be a boolean");
238
275
  }