patchwarden 1.1.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 (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  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 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. 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);
@@ -105,6 +105,11 @@ try {
105
105
  throw new Error("get_task_summary schema must expose view and max_items");
106
106
  }
107
107
  const safeRequirements = {
108
+ run_task_loop: ["repo_path", "goal", "verify_commands", "direct_verify", "isolation_mode", "worktree_cleanup"],
109
+ recommend_agent_for_task: ["repo_path", "goal"],
110
+ get_task_lineage: ["lineage_id"],
111
+ export_task_evidence_pack: ["lineage_id"],
112
+ get_project_policy: ["repo_path"],
108
113
  safe_result: ["task_id"],
109
114
  safe_audit: ["task_id"],
110
115
  safe_test_summary: ["task_id"],
@@ -124,6 +129,7 @@ try {
124
129
  create_direct_session: ["repo_path"],
125
130
  apply_patch: ["session_id", "path", "expected_sha256", "operations"],
126
131
  run_verification: ["session_id", "command"],
132
+ run_direct_verification_bundle: ["session_id", "commands"],
127
133
  finalize_direct_session: ["session_id"],
128
134
  audit_session: ["session_id"],
129
135
  safe_direct_summary: ["session_id"],
@@ -200,6 +206,11 @@ try {
200
206
  health_check: ["detail"],
201
207
  list_tasks: ["repo_path", "active_only"],
202
208
  get_task_summary: ["view", "max_items"],
209
+ run_task_loop: ["repo_path", "goal", "verify_commands", "direct_verify", "isolation_mode", "worktree_cleanup"],
210
+ recommend_agent_for_task: ["repo_path", "goal"],
211
+ get_task_lineage: ["lineage_id"],
212
+ export_task_evidence_pack: ["lineage_id"],
213
+ get_project_policy: ["repo_path"],
203
214
  safe_result: ["task_id"],
204
215
  safe_audit: ["task_id"],
205
216
  safe_test_summary: ["task_id"],
@@ -208,6 +219,7 @@ try {
208
219
  create_direct_session: ["repo_path"],
209
220
  apply_patch: ["session_id", "path", "expected_sha256", "operations"],
210
221
  run_verification: ["session_id", "command"],
222
+ run_direct_verification_bundle: ["session_id", "commands"],
211
223
  finalize_direct_session: ["session_id"],
212
224
  audit_session: ["session_id"],
213
225
  safe_direct_summary: ["session_id"],
@@ -117,12 +117,16 @@ try {
117
117
  "discard_worktree",
118
118
  "discover_tools",
119
119
  "explain_tool",
120
+ "export_goal_report",
120
121
  "export_handoff",
122
+ "export_task_evidence_pack",
121
123
  "finalize_direct_session",
122
124
  "get_diff",
123
125
  "get_plan",
126
+ "get_project_policy",
124
127
  "get_result",
125
128
  "get_result_json",
129
+ "get_task_lineage",
126
130
  "get_task_log_tail",
127
131
  "get_task_progress",
128
132
  "get_task_status",
@@ -130,6 +134,7 @@ try {
130
134
  "get_task_summary",
131
135
  "get_test_log",
132
136
  "health_check",
137
+ "import_speckit_tasks",
133
138
  "invoke_discovered_tool",
134
139
  "kill_task",
135
140
  "list_agents",
@@ -139,9 +144,16 @@ try {
139
144
  "merge_worktree",
140
145
  "read_goal",
141
146
  "read_workspace_file",
147
+ "recommend_agent_for_task",
142
148
  "reconcile_tasks",
143
149
  "reject_subgoal",
150
+ "release_check",
151
+ "release_cleanup",
152
+ "release_prepare",
153
+ "release_verify",
144
154
  "retry_task",
155
+ "run_direct_verification_bundle",
156
+ "run_task_loop",
145
157
  "run_verification",
146
158
  "safe_audit",
147
159
  "safe_audit_direct_session",
@@ -167,7 +179,7 @@ try {
167
179
  if (!tools._meta || typeof tools._meta.tool_manifest_sha256 !== "string" || tools._meta.tool_manifest_sha256.length !== 64) {
168
180
  throw new Error(`tools/list _meta missing manifest hash: ${JSON.stringify(tools._meta || null)}`);
169
181
  }
170
- if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 54) {
182
+ if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 66) {
171
183
  throw new Error(`tools/list _meta profile/count mismatch: ${JSON.stringify(tools._meta)}`);
172
184
  }
173
185
  if (typeof tools._meta.schema_epoch !== "string" || typeof tools._meta.server_version !== "string") {
@@ -417,7 +429,7 @@ try {
417
429
  await disabledClient.close();
418
430
  ok("chatgpt_direct disabled exposes only health_check with diagnostic");
419
431
 
420
- // 2. chatgpt_direct enabled: 13 tools + minimal create_direct_session
432
+ // 2. chatgpt_direct enabled: 14 tools + minimal create_direct_session
421
433
  const enabledConfigPath = join(tempRoot, "direct-enabled.json");
422
434
  const directRepo = join(workspaceRoot, "direct-fixture");
423
435
  mkdirSync(join(directRepo, "src"), { recursive: true });
@@ -473,6 +485,7 @@ try {
473
485
  "health_check",
474
486
  "list_workspace",
475
487
  "read_workspace_file",
488
+ "run_direct_verification_bundle",
476
489
  "run_verification",
477
490
  "safe_audit_direct_session",
478
491
  "safe_direct_summary",
@@ -483,8 +496,8 @@ try {
483
496
  if (JSON.stringify(enabledNames) !== JSON.stringify(expectedDirect)) {
484
497
  throw new Error(`chatgpt_direct enabled tools mismatch: ${enabledNames.join(", ")}`);
485
498
  }
486
- if (enabledTools._meta.tool_count !== 13) {
487
- throw new Error(`chatgpt_direct enabled tool_count should be 13, got ${enabledTools._meta.tool_count}`);
499
+ if (enabledTools._meta.tool_count !== 14) {
500
+ throw new Error(`chatgpt_direct enabled tool_count should be 14, got ${enabledTools._meta.tool_count}`);
488
501
  }
489
502
 
490
503
  // Minimal create_direct_session
@@ -497,6 +510,19 @@ try {
497
510
  if (!sessionResult.session_id || !sessionResult.session_id.startsWith("direct_")) {
498
511
  throw new Error(`create_direct_session failed: ${JSON.stringify(sessionResult)}`);
499
512
  }
513
+ const bundleResult = JSON.parse(
514
+ (await enabledClient.callTool({
515
+ name: "run_direct_verification_bundle",
516
+ arguments: { session_id: sessionResult.session_id, commands: ["npm test"], timeout_seconds: 30 },
517
+ })).content?.[0]?.text || "{}"
518
+ );
519
+ if (bundleResult.status !== "passed" || bundleResult.command_count !== 1 || bundleResult.passed_commands !== 1) {
520
+ throw new Error(`run_direct_verification_bundle failed: ${JSON.stringify(bundleResult)}`);
521
+ }
522
+ const bundleText = JSON.stringify(bundleResult).toLowerCase();
523
+ if (bundleText.includes("stdout_tail") || bundleText.includes("stderr_tail") || bundleText.includes("verification.log")) {
524
+ throw new Error("run_direct_verification_bundle leaked log tail fields");
525
+ }
500
526
 
501
527
  const enabledHealth = JSON.parse(
502
528
  (await enabledClient.callTool({ name: "health_check", arguments: {} })).content?.[0]?.text || "{}"
@@ -504,12 +530,12 @@ try {
504
530
  if (enabledHealth.direct_profile_enabled !== true) {
505
531
  throw new Error(`direct_profile_enabled should be true, got ${enabledHealth.direct_profile_enabled}`);
506
532
  }
507
- if (enabledHealth.direct_tool_count !== 13) {
508
- throw new Error(`direct_tool_count should be 13, got ${enabledHealth.direct_tool_count}`);
533
+ if (enabledHealth.direct_tool_count !== 14) {
534
+ throw new Error(`direct_tool_count should be 14, got ${enabledHealth.direct_tool_count}`);
509
535
  }
510
536
 
511
537
  await enabledClient.close();
512
- ok("chatgpt_direct enabled exposes 13 tools and create_direct_session works");
538
+ ok("chatgpt_direct enabled exposes 14 tools and create_direct_session works");
513
539
  } catch (error) {
514
540
  fail("MCP smoke test", error);
515
541
  } finally {
@@ -105,7 +105,7 @@ function createFixture(name) {
105
105
  server_version: "0.6.0",
106
106
  schema_epoch: "2026-06-22-v6",
107
107
  tool_profile: "chatgpt_core",
108
- tool_count: 21,
108
+ tool_count: 26,
109
109
  tool_names: [],
110
110
  tool_manifest_sha256: "a".repeat(64),
111
111
  });
@@ -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()