@openhands/extensions 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.agents/skills/custom-codereview-guide.md +31 -0
  2. package/.claude-plugin/marketplace.json +9 -0
  3. package/.codex-plugin/marketplace.json +9 -0
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/pypi-publish.yml +2 -2
  7. package/.github/workflows/tests.yml +1 -1
  8. package/.plugin/marketplace.json +9 -0
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +7 -2
  11. package/README.md +3 -2
  12. package/automations/bundle-index.js +5 -5
  13. package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
  14. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  15. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  16. package/marketplaces/openhands-extensions.json +9 -0
  17. package/package.json +1 -1
  18. package/plugins/openhands/skills/openhands-automation/README.md +6 -4
  19. package/plugins/openhands/skills/openhands-automation/SKILL.md +103 -21
  20. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +79 -12
  21. package/plugins/openhands/skills/openhands-sdk/SKILL.md +2 -1
  22. package/plugins/qa-changes/README.md +4 -0
  23. package/plugins/qa-changes/skills/qa-changes/README.md +3 -1
  24. package/plugins/qa-changes/skills/qa-changes/SKILL.md +42 -0
  25. package/plugins/vulnerability-remediation/skills/github/README.md +5 -1
  26. package/plugins/vulnerability-remediation/skills/github/SKILL.md +27 -0
  27. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +51 -4
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/_version.py +1 -1
  30. package/scripts/sync_openhands_sdk_skill.py +16 -13
  31. package/skills/azure-devops/README.md +3 -1
  32. package/skills/azure-devops/SKILL.md +4 -0
  33. package/skills/bitbucket/README.md +3 -1
  34. package/skills/bitbucket/SKILL.md +4 -2
  35. package/skills/bitbucket-cloud/README.md +2 -0
  36. package/skills/bitbucket-cloud/SKILL.md +2 -0
  37. package/skills/bitbucket-data-center/README.md +2 -0
  38. package/skills/bitbucket-data-center/SKILL.md +2 -0
  39. package/skills/canvas-extension-api/SKILL.md +4 -1
  40. package/skills/canvas-extension-api/references/native-design.md +250 -0
  41. package/skills/github/README.md +5 -1
  42. package/skills/github/SKILL.md +27 -0
  43. package/skills/github/scripts/agent_conversation.py +51 -4
  44. package/skills/github-issue-to-pr/scripts/agent_conversation.py +51 -4
  45. package/skills/github-issue-triage/scripts/agent_conversation.py +51 -4
  46. package/skills/github-pr-reviewer/README.md +5 -0
  47. package/skills/github-pr-reviewer/SKILL.md +12 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +51 -4
  49. package/skills/github-pr-reviewer/scripts/main.py +20 -6
  50. package/skills/github-pr-reviewer/scripts/worker.py +77 -15
  51. package/skills/github-pr-reviewer/tests/test_main.py +23 -0
  52. package/skills/gitlab/README.md +3 -1
  53. package/skills/gitlab/SKILL.md +4 -0
  54. package/skills/index.js +25 -13
  55. package/skills/linear/README.md +2 -0
  56. package/skills/linear/SKILL.md +6 -2
  57. package/skills/openhands-automation/README.md +6 -4
  58. package/skills/openhands-automation/SKILL.md +103 -21
  59. package/skills/openhands-automation/references/custom-automation.md +79 -12
  60. package/skills/openhands-sdk/SKILL.md +2 -1
  61. package/skills/qa-changes/README.md +3 -1
  62. package/skills/qa-changes/SKILL.md +42 -0
  63. package/skills/simplified-technical-english/.claude-plugin/plugin.json +15 -0
  64. package/skills/simplified-technical-english/.codex-plugin/plugin.json +15 -0
  65. package/skills/simplified-technical-english/.plugin/plugin.json +15 -0
  66. package/skills/simplified-technical-english/README.md +21 -0
  67. package/skills/simplified-technical-english/SKILL.md +65 -0
  68. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  69. package/tests/test_agent_conversation_dispatch.py +130 -0
  70. package/tests/test_github_reviewer_delivery.py +279 -2
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
32
  # How long to wait for a paused conversation's sandbox to come back up.
33
33
  _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
34
  _CLOUD_POLL_SECONDS = 3
35
+ # How many times a matched delivery whose conversation ended in ERROR is sent
36
+ # again. The delivery key never changes for a stable subject, so without a
37
+ # retry its work is stranded; without a bound, a conversation that fails the
38
+ # same way every time would be re-run on every scan.
39
+ _MAX_ERROR_RETRIES = 2
35
40
 
36
41
 
37
42
  def _register_tools() -> None:
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
204
209
 
205
210
  same_delivery = record.get("delivery") == delivery
206
211
  same_head = bool(head) and record.get("head") == head
212
+ # Error retries count against one delivery; a new delivery starts over.
213
+ error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
214
+ can_retry = error_retries < _MAX_ERROR_RETRIES
207
215
 
208
216
  if self._cloud:
209
217
  disposition, conversation_id = self._deliver_cloud(
210
- record, conversation_id, subject, prompt, same_delivery, same_head
218
+ record,
219
+ conversation_id,
220
+ subject,
221
+ prompt,
222
+ same_delivery,
223
+ same_head,
224
+ can_retry,
211
225
  )
212
226
  else:
213
227
  disposition = self._deliver_local(
214
- conversation_id, prompt, same_delivery, same_head
228
+ conversation_id, prompt, same_delivery, same_head, can_retry
215
229
  )
216
230
 
217
231
  if disposition in ("deduplicated", "in_progress"):
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
228
242
  }
229
243
  if disposition == "created" and self._cloud:
230
244
  new_record["started_at"] = time.time()
245
+ if disposition == "retried":
246
+ error_retries += 1
247
+ if error_retries:
248
+ new_record["error_retries"] = error_retries
231
249
  _kv_request(state_key, "PUT", new_record)
232
250
  return {
233
251
  "disposition": disposition,
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
235
253
  }
236
254
 
237
255
  def _deliver_cloud(
238
- self, record, conversation_id, subject, prompt, same_delivery, same_head
256
+ self,
257
+ record,
258
+ conversation_id,
259
+ subject,
260
+ prompt,
261
+ same_delivery,
262
+ same_head,
263
+ can_retry,
239
264
  ):
240
265
  """Deliver through the OpenHands API; return the disposition and the id.
241
266
 
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
272
297
  if status in ("idle", "paused"):
273
298
  self._cloud.run(conversation)
274
299
  return "resumed", current_id
300
+ if status == "error" and can_retry:
301
+ # The delivery matched but its conversation died before the work
302
+ # finished, so send the revision again rather than strand it.
303
+ # The API reports an execution status only while the sandbox
304
+ # runs, so an errored conversation whose sandbox was already
305
+ # paused is not seen here and keeps reporting deduplicated.
306
+ self._cloud.send(conversation, prompt)
307
+ return "retried", current_id
275
308
  return "deduplicated", current_id
276
309
  self._cloud.send(conversation, prompt)
277
310
  return "resumed", current_id
278
311
 
279
- def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
312
+ def _deliver_local(
313
+ self, conversation_id, prompt, same_delivery, same_head, can_retry
314
+ ) -> str:
280
315
  if self._workspace is None:
281
316
  raise RuntimeError("AgentConversationDispatcher must be used as a context")
282
317
 
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
321
356
  ):
322
357
  conversation.update_secrets(self._secrets)
323
358
  conversation.run(blocking=False)
359
+ elif (
360
+ conversation.state.execution_status
361
+ == ConversationExecutionStatus.ERROR
362
+ and can_retry
363
+ ):
364
+ # The delivery matched but its conversation died before
365
+ # the work finished, so send the revision again rather
366
+ # than strand it.
367
+ conversation.update_secrets(self._secrets)
368
+ conversation.send_message(prompt)
369
+ conversation.run(blocking=False)
370
+ disposition = "retried"
324
371
  else:
325
372
  disposition = "deduplicated"
326
373
  else:
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
32
  # How long to wait for a paused conversation's sandbox to come back up.
33
33
  _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
34
  _CLOUD_POLL_SECONDS = 3
35
+ # How many times a matched delivery whose conversation ended in ERROR is sent
36
+ # again. The delivery key never changes for a stable subject, so without a
37
+ # retry its work is stranded; without a bound, a conversation that fails the
38
+ # same way every time would be re-run on every scan.
39
+ _MAX_ERROR_RETRIES = 2
35
40
 
36
41
 
37
42
  def _register_tools() -> None:
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
204
209
 
205
210
  same_delivery = record.get("delivery") == delivery
206
211
  same_head = bool(head) and record.get("head") == head
212
+ # Error retries count against one delivery; a new delivery starts over.
213
+ error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
214
+ can_retry = error_retries < _MAX_ERROR_RETRIES
207
215
 
208
216
  if self._cloud:
209
217
  disposition, conversation_id = self._deliver_cloud(
210
- record, conversation_id, subject, prompt, same_delivery, same_head
218
+ record,
219
+ conversation_id,
220
+ subject,
221
+ prompt,
222
+ same_delivery,
223
+ same_head,
224
+ can_retry,
211
225
  )
212
226
  else:
213
227
  disposition = self._deliver_local(
214
- conversation_id, prompt, same_delivery, same_head
228
+ conversation_id, prompt, same_delivery, same_head, can_retry
215
229
  )
216
230
 
217
231
  if disposition in ("deduplicated", "in_progress"):
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
228
242
  }
229
243
  if disposition == "created" and self._cloud:
230
244
  new_record["started_at"] = time.time()
245
+ if disposition == "retried":
246
+ error_retries += 1
247
+ if error_retries:
248
+ new_record["error_retries"] = error_retries
231
249
  _kv_request(state_key, "PUT", new_record)
232
250
  return {
233
251
  "disposition": disposition,
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
235
253
  }
236
254
 
237
255
  def _deliver_cloud(
238
- self, record, conversation_id, subject, prompt, same_delivery, same_head
256
+ self,
257
+ record,
258
+ conversation_id,
259
+ subject,
260
+ prompt,
261
+ same_delivery,
262
+ same_head,
263
+ can_retry,
239
264
  ):
240
265
  """Deliver through the OpenHands API; return the disposition and the id.
241
266
 
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
272
297
  if status in ("idle", "paused"):
273
298
  self._cloud.run(conversation)
274
299
  return "resumed", current_id
300
+ if status == "error" and can_retry:
301
+ # The delivery matched but its conversation died before the work
302
+ # finished, so send the revision again rather than strand it.
303
+ # The API reports an execution status only while the sandbox
304
+ # runs, so an errored conversation whose sandbox was already
305
+ # paused is not seen here and keeps reporting deduplicated.
306
+ self._cloud.send(conversation, prompt)
307
+ return "retried", current_id
275
308
  return "deduplicated", current_id
276
309
  self._cloud.send(conversation, prompt)
277
310
  return "resumed", current_id
278
311
 
279
- def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
312
+ def _deliver_local(
313
+ self, conversation_id, prompt, same_delivery, same_head, can_retry
314
+ ) -> str:
280
315
  if self._workspace is None:
281
316
  raise RuntimeError("AgentConversationDispatcher must be used as a context")
282
317
 
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
321
356
  ):
322
357
  conversation.update_secrets(self._secrets)
323
358
  conversation.run(blocking=False)
359
+ elif (
360
+ conversation.state.execution_status
361
+ == ConversationExecutionStatus.ERROR
362
+ and can_retry
363
+ ):
364
+ # The delivery matched but its conversation died before
365
+ # the work finished, so send the revision again rather
366
+ # than strand it.
367
+ conversation.update_secrets(self._secrets)
368
+ conversation.send_message(prompt)
369
+ conversation.run(blocking=False)
370
+ disposition = "retried"
324
371
  else:
325
372
  disposition = "deduplicated"
326
373
  else:
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
32
  # How long to wait for a paused conversation's sandbox to come back up.
33
33
  _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
34
  _CLOUD_POLL_SECONDS = 3
35
+ # How many times a matched delivery whose conversation ended in ERROR is sent
36
+ # again. The delivery key never changes for a stable subject, so without a
37
+ # retry its work is stranded; without a bound, a conversation that fails the
38
+ # same way every time would be re-run on every scan.
39
+ _MAX_ERROR_RETRIES = 2
35
40
 
36
41
 
37
42
  def _register_tools() -> None:
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
204
209
 
205
210
  same_delivery = record.get("delivery") == delivery
206
211
  same_head = bool(head) and record.get("head") == head
212
+ # Error retries count against one delivery; a new delivery starts over.
213
+ error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
214
+ can_retry = error_retries < _MAX_ERROR_RETRIES
207
215
 
208
216
  if self._cloud:
209
217
  disposition, conversation_id = self._deliver_cloud(
210
- record, conversation_id, subject, prompt, same_delivery, same_head
218
+ record,
219
+ conversation_id,
220
+ subject,
221
+ prompt,
222
+ same_delivery,
223
+ same_head,
224
+ can_retry,
211
225
  )
212
226
  else:
213
227
  disposition = self._deliver_local(
214
- conversation_id, prompt, same_delivery, same_head
228
+ conversation_id, prompt, same_delivery, same_head, can_retry
215
229
  )
216
230
 
217
231
  if disposition in ("deduplicated", "in_progress"):
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
228
242
  }
229
243
  if disposition == "created" and self._cloud:
230
244
  new_record["started_at"] = time.time()
245
+ if disposition == "retried":
246
+ error_retries += 1
247
+ if error_retries:
248
+ new_record["error_retries"] = error_retries
231
249
  _kv_request(state_key, "PUT", new_record)
232
250
  return {
233
251
  "disposition": disposition,
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
235
253
  }
236
254
 
237
255
  def _deliver_cloud(
238
- self, record, conversation_id, subject, prompt, same_delivery, same_head
256
+ self,
257
+ record,
258
+ conversation_id,
259
+ subject,
260
+ prompt,
261
+ same_delivery,
262
+ same_head,
263
+ can_retry,
239
264
  ):
240
265
  """Deliver through the OpenHands API; return the disposition and the id.
241
266
 
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
272
297
  if status in ("idle", "paused"):
273
298
  self._cloud.run(conversation)
274
299
  return "resumed", current_id
300
+ if status == "error" and can_retry:
301
+ # The delivery matched but its conversation died before the work
302
+ # finished, so send the revision again rather than strand it.
303
+ # The API reports an execution status only while the sandbox
304
+ # runs, so an errored conversation whose sandbox was already
305
+ # paused is not seen here and keeps reporting deduplicated.
306
+ self._cloud.send(conversation, prompt)
307
+ return "retried", current_id
275
308
  return "deduplicated", current_id
276
309
  self._cloud.send(conversation, prompt)
277
310
  return "resumed", current_id
278
311
 
279
- def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
312
+ def _deliver_local(
313
+ self, conversation_id, prompt, same_delivery, same_head, can_retry
314
+ ) -> str:
280
315
  if self._workspace is None:
281
316
  raise RuntimeError("AgentConversationDispatcher must be used as a context")
282
317
 
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
321
356
  ):
322
357
  conversation.update_secrets(self._secrets)
323
358
  conversation.run(blocking=False)
359
+ elif (
360
+ conversation.state.execution_status
361
+ == ConversationExecutionStatus.ERROR
362
+ and can_retry
363
+ ):
364
+ # The delivery matched but its conversation died before
365
+ # the work finished, so send the revision again rather
366
+ # than strand it.
367
+ conversation.update_secrets(self._secrets)
368
+ conversation.send_message(prompt)
369
+ conversation.run(blocking=False)
370
+ disposition = "retried"
324
371
  else:
325
372
  disposition = "deduplicated"
326
373
  else:
@@ -61,6 +61,11 @@ This skill is activated by:
61
61
  checkout when the review ends, so nothing accumulates between runs
62
62
  - Publishes a real pull request review, with inline comments where a finding
63
63
  maps to a changed line, and verifies on GitHub that it landed
64
+ - Requires live evidence from the real app before approving a user-visible UI
65
+ change when the repository's guidance demands it: unit tests, CSS-token
66
+ assertions, generated mockups, and reconstructed captures cannot substitute.
67
+ Missing evidence yields a COMMENT review that names the gap, so no approval
68
+ and no maintainer handoff
64
69
  - Posts acknowledgement comments with AI disclosure
65
70
  - Configurable review tone and polling schedule
66
71
  - Optional human handoff after an exact-head approval. The scanner ranks the
@@ -189,6 +189,18 @@ handler recognizes the verdict and requests one configured maintainer through th
189
189
  same handoff used after an approval. An in-scope change continues the existing
190
190
  review unchanged.
191
191
 
192
+ Before approving a change to user-visible UI behavior, the prompt requires live
193
+ evidence from a real running application when the repository's guidance demands
194
+ it: a screenshot, screen recording, or equivalent capture of the running app
195
+ that exercises the production-facing path. Unit tests, CSS-token or contract
196
+ assertions, generated mockups, and reconstructed captures may support the review
197
+ but cannot substitute for that evidence. When the required evidence is missing,
198
+ the reviewer publishes one `event: COMMENT` review naming exactly what is
199
+ missing and ends with `🔄 CHANGES REQUESTED`, so approval is withheld and the
200
+ deterministic maintainer handoff does not fire. Non-UI changes keep the existing
201
+ requirement of the real command and its observed output, with tests insufficient
202
+ as sole proof.
203
+
192
204
  The script prepares each review's workspace before the agent starts: the pull
193
205
  request's head commit is downloaded as a tarball and extracted to a directory of
194
206
  its own, which becomes the conversation's working directory. The agent is told
@@ -32,6 +32,11 @@ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
32
  # How long to wait for a paused conversation's sandbox to come back up.
33
33
  _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
34
  _CLOUD_POLL_SECONDS = 3
35
+ # How many times a matched delivery whose conversation ended in ERROR is sent
36
+ # again. The delivery key never changes for a stable subject, so without a
37
+ # retry its work is stranded; without a bound, a conversation that fails the
38
+ # same way every time would be re-run on every scan.
39
+ _MAX_ERROR_RETRIES = 2
35
40
 
36
41
 
37
42
  def _register_tools() -> None:
@@ -204,14 +209,23 @@ class AgentConversationDispatcher:
204
209
 
205
210
  same_delivery = record.get("delivery") == delivery
206
211
  same_head = bool(head) and record.get("head") == head
212
+ # Error retries count against one delivery; a new delivery starts over.
213
+ error_retries = int(record.get("error_retries") or 0) if same_delivery else 0
214
+ can_retry = error_retries < _MAX_ERROR_RETRIES
207
215
 
208
216
  if self._cloud:
209
217
  disposition, conversation_id = self._deliver_cloud(
210
- record, conversation_id, subject, prompt, same_delivery, same_head
218
+ record,
219
+ conversation_id,
220
+ subject,
221
+ prompt,
222
+ same_delivery,
223
+ same_head,
224
+ can_retry,
211
225
  )
212
226
  else:
213
227
  disposition = self._deliver_local(
214
- conversation_id, prompt, same_delivery, same_head
228
+ conversation_id, prompt, same_delivery, same_head, can_retry
215
229
  )
216
230
 
217
231
  if disposition in ("deduplicated", "in_progress"):
@@ -228,6 +242,10 @@ class AgentConversationDispatcher:
228
242
  }
229
243
  if disposition == "created" and self._cloud:
230
244
  new_record["started_at"] = time.time()
245
+ if disposition == "retried":
246
+ error_retries += 1
247
+ if error_retries:
248
+ new_record["error_retries"] = error_retries
231
249
  _kv_request(state_key, "PUT", new_record)
232
250
  return {
233
251
  "disposition": disposition,
@@ -235,7 +253,14 @@ class AgentConversationDispatcher:
235
253
  }
236
254
 
237
255
  def _deliver_cloud(
238
- self, record, conversation_id, subject, prompt, same_delivery, same_head
256
+ self,
257
+ record,
258
+ conversation_id,
259
+ subject,
260
+ prompt,
261
+ same_delivery,
262
+ same_head,
263
+ can_retry,
239
264
  ):
240
265
  """Deliver through the OpenHands API; return the disposition and the id.
241
266
 
@@ -272,11 +297,21 @@ class AgentConversationDispatcher:
272
297
  if status in ("idle", "paused"):
273
298
  self._cloud.run(conversation)
274
299
  return "resumed", current_id
300
+ if status == "error" and can_retry:
301
+ # The delivery matched but its conversation died before the work
302
+ # finished, so send the revision again rather than strand it.
303
+ # The API reports an execution status only while the sandbox
304
+ # runs, so an errored conversation whose sandbox was already
305
+ # paused is not seen here and keeps reporting deduplicated.
306
+ self._cloud.send(conversation, prompt)
307
+ return "retried", current_id
275
308
  return "deduplicated", current_id
276
309
  self._cloud.send(conversation, prompt)
277
310
  return "resumed", current_id
278
311
 
279
- def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
312
+ def _deliver_local(
313
+ self, conversation_id, prompt, same_delivery, same_head, can_retry
314
+ ) -> str:
280
315
  if self._workspace is None:
281
316
  raise RuntimeError("AgentConversationDispatcher must be used as a context")
282
317
 
@@ -321,6 +356,18 @@ class AgentConversationDispatcher:
321
356
  ):
322
357
  conversation.update_secrets(self._secrets)
323
358
  conversation.run(blocking=False)
359
+ elif (
360
+ conversation.state.execution_status
361
+ == ConversationExecutionStatus.ERROR
362
+ and can_retry
363
+ ):
364
+ # The delivery matched but its conversation died before
365
+ # the work finished, so send the revision again rather
366
+ # than strand it.
367
+ conversation.update_secrets(self._secrets)
368
+ conversation.send_message(prompt)
369
+ conversation.run(blocking=False)
370
+ disposition = "retried"
324
371
  else:
325
372
  disposition = "deduplicated"
326
373
  else:
@@ -847,7 +847,21 @@ def _build_review_prompt(
847
847
  "credential, configuration, or external state stayed unchanged. Do not add speculative or "
848
848
  "out-of-scope notes: every blocking or non-blocking observation must identify demonstrated "
849
849
  "behavior on the current head and explain why it matters to the merge decision.\n"
850
- f"7. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using "
850
+ "7. LIVE EVIDENCE GATE - if the change alters user-visible UI behavior and the repository "
851
+ "guidance requires live evidence for it, you must not approve unless that evidence is from a "
852
+ "real running application and exercises the production-facing path. Live evidence means a "
853
+ "screenshot, screen recording, or equivalent capture of the running app, not the source, a "
854
+ "diff, or a description of what it should render. Unit tests, CSS-token or contract "
855
+ "assertions, generated mockups, and reconstructed or hand-built captures may support the "
856
+ "review but never substitute for the required live evidence. When the repository guidance "
857
+ "requires that evidence and only non-live evidence is available, do not approve: publish "
858
+ "exactly one review with `event: COMMENT` that names exactly which live evidence is missing, "
859
+ "and end its body with the verdict on its own line: `🔄 CHANGES REQUESTED`. State the gap as "
860
+ "a blocking finding, not a note, and never let a passing test suite stand in for the missing "
861
+ "capture. A UI change that does supply compliant live evidence, and any non-UI change "
862
+ "(backend, API, CLI, script) that follows the existing requirement of the real command and "
863
+ "its observed output, continues to step 8.\n"
864
+ f"8. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using "
851
865
  "`commit_id` equal to the Head SHA above. Use `event: APPROVE` when there are "
852
866
  "no material findings; otherwise use `event: COMMENT`. Never use "
853
867
  "`REQUEST_CHANGES`.\n"
@@ -858,19 +872,19 @@ def _build_review_prompt(
858
872
  " Put the overall assessment in `body`, and each line-specific finding in the `comments` "
859
873
  "array with `path`, `line`, `side: RIGHT`, and `body`.\n"
860
874
  " Only create inline comments for actionable findings; do not open praise or nitpick threads.\n"
861
- "8. If a finding cannot be attached to a changed line, put it in the review body instead. "
875
+ "9. If a finding cannot be attached to a changed line, put it in the review body instead. "
862
876
  "If the API rejects the inline positions, retry with every finding in the body and no `comments` array. "
863
877
  "If GitHub forbids the configured bot from approving its own PR, retry the clean review with "
864
878
  "`event: COMMENT` and keep the approved verdict.\n"
865
- "9. Begin the review body with this disclosure: "
879
+ "10. Begin the review body with this disclosure: "
866
880
  "`_This review was posted by an AI agent (OpenHands)._`\n"
867
- "10. End the review body with a verdict on its own line: either `✅ APPROVED` "
881
+ "11. End the review body with a verdict on its own line: either `✅ APPROVED` "
868
882
  "or `🔄 CHANGES REQUESTED`.\n"
869
- "11. If there are no material issues, still publish a review saying so, with the "
883
+ "12. If there are no material issues, still publish a review saying so, with the "
870
884
  "disclosure and the verdict.\n"
871
885
  f"\nReview instructions:\n{tone}{extra}{guide_section}\n\n"
872
886
  "After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. "
873
- "If publishing still fails after the fallback in step 8, output the complete review text "
887
+ "If publishing still fails after the fallback in step 9, output the complete review text "
874
888
  "so it can be posted as a comment instead."
875
889
  )
876
890