claude-dev-env 1.92.0 → 1.93.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 (216) hide show
  1. package/CLAUDE.md +5 -39
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +7 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/rules/testing.md +0 -2
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +184 -0
  179. package/skills/orchestrator-refresh/SKILL.md +37 -0
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +32 -164
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
  216. package/skills/team-advisor-refresh/SKILL.md +0 -25
@@ -11,12 +11,13 @@
11
11
 
12
12
  export const meta = {
13
13
  name: 'autoconverge',
14
- description: 'Drive one draft PR to convergence in a single autonomous run: parallel Bugbot + code-review + bug-audit lenses on the same HEAD each round, dedup findings, fix once, re-verify, then a Copilot wait-gate and a final convergence check that marks the PR ready.',
14
+ description: 'Drive one draft PR to convergence in a single autonomous run: each round runs a deterministic static sweep first, then code-review, bug-audit, and self-review lenses in parallel on the same HEAD, dedups findings, fixes once, and re-verifies; Bugbot and Copilot then run as terminal confirmation gates before a final convergence check marks the PR ready.',
15
15
  whenToUse: 'Launched by the /autoconverge skill after it resolves PR scope, enters a worktree, and grants project .claude permissions.',
16
16
  phases: [
17
17
  { title: 'Reuse', detail: 'Before convergence, one reuse lens scans the full diff for reuse improvements that are certain, behaviorally identical, and autonomously implementable, and applies the qualifying ones in one commit' },
18
- { title: 'Converge', detail: 'Bugbot + code-review + bug-audit in parallel each round; one clean-coder applies all fixes; loop until all three are clean on a stable HEAD' },
19
- { title: 'Copilot gate', detail: 'When the quota pre-check reports Copilot out of premium-request quota or unavailable, skip the gate with no agent spawned; otherwise request a Copilot review and poll up to the configured cap, route findings back into Converge, and when Copilot is down or out of quota log a notice and mark the PR ready with the gate bypassed' },
18
+ { title: 'Converge', detail: 'A deterministic static sweep runs first each round; then code-review, bug-audit, and self-review run in parallel on the same HEAD; one clean-coder applies all fixes; the loop repeats until every lens is clean on a stable HEAD' },
19
+ { title: 'Bugbot gate', detail: 'A terminal confirmation gate that runs once the internal lenses are clean; when Bugbot is disabled or unavailable it spawns no agent and passes, otherwise it fetches Bugbot\'s verdict and routes any finding back into Converge' },
20
+ { title: 'Copilot gate', detail: 'When the quota pre-check reports Copilot out of premium-request quota or unavailable, skip the gate with no agent spawned; otherwise request a Copilot review and poll up to the configured cap, then route findings by tier — self-healing findings flow back into Converge, and one or more code-concern findings return blocker user-review with the findings for the orchestrator to gate on rather than auto-fixing or marking the PR ready; when Copilot is down or out of quota log a notice and mark the PR ready with the gate bypassed' },
20
21
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
21
22
  ],
22
23
  }
@@ -28,6 +29,7 @@ const CONFIG = {
28
29
  sharedScripts: '$HOME/.claude/skills/pr-converge/scripts',
29
30
  prLoopScripts: '$HOME/.claude/_shared/pr-loop/scripts',
30
31
  bugteamRubric: '$HOME/.claude/_shared/pr-loop/audit-contract.md',
32
+ precatchRubric: '$HOME/.claude/_shared/pr-loop/precatch-rubric.md',
31
33
  }
32
34
 
33
35
  const REVIEWER_GATE_SENTINEL = 'CLAUDE_REVIEWER_GATE=autoconverge '
@@ -38,7 +40,7 @@ const TIERS = {
38
40
  haikuLow: { model: 'haiku', effort: 'low' },
39
41
  }
40
42
 
41
- const HEADLESS_SAFETY_PREAMBLE =
43
+ const HEADLESS_EDIT_PREAMBLE =
42
44
  'HEADLESS RUN — you run unattended: no human can answer a permission or confirmation prompt, and any such prompt stalls the entire convergence run. The destructive_command_blocker hook matches dangerous patterns (rm -rf, git reset --hard, dd, mkfs, chmod -R, fork bombs) as raw text anywhere in a Bash command, with no quote-awareness — so a destructive string stalls you even when it is only data you never execute. Therefore:\n' +
43
45
  '- Never place a destructive-command literal inside a Bash command — not in echo, not in a heredoc, and not as an argument to python -c, node -e, or awk. To exercise or verify destructive_command_blocker (or any hook) behavior, run the committed test suite, e.g. python -m pytest <test_file>, which passes the command strings as in-language data rather than as a shell command.\n' +
44
46
  '- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
@@ -50,6 +52,22 @@ const HEADLESS_SAFETY_PREAMBLE =
50
52
  '- Size the Monitor to the whole wait: its timeout_ms defaults to 300000 (300 seconds) and the Monitor is killed the moment that elapses, so a poll whose interval or whose interval-times-attempts span runs longer registers a false time-out. Before you arm a Monitor, set timeout_ms to at least the poll interval times the attempt count the step names — up to the 3600000ms ceiling — or pass persistent: true so the Monitor is never timed out. A 360-second interval, or a many-minute total, exceeds the 300000 default, so arming the Monitor with the default truncates the wait; set timeout_ms to the step span instead.\n' +
51
53
  '- When your run was given a result schema, your final action is always the StructuredOutput call. If the poll budget is spent before the awaited signal arrives, call StructuredOutput with the whole time-out result the step documents — for the Copilot gate, the full down result {sha, clean:false, down:true, findings:[]}, never a bare down flag — rather than ending the turn without a result.\n\n'
52
54
 
55
+ const HEADLESS_READONLY_DESTRUCTIVE_POINTER =
56
+ '- Never run a destructive command (rm -rf, git reset --hard, dd, mkfs, chmod -R, a fork bomb) and never place its literal text in a Bash command: this step reads only and edits nothing, so it needs no destructive command. If a step seems to require one, report it as a blocker rather than running it.\n'
57
+
58
+ /**
59
+ * The read-only preamble a review, verify, or utility agent receives: the full
60
+ * edit preamble with the rm-shape-rules bullet dropped, since an agent that edits
61
+ * nothing never runs rm and the shape rules add no value to its prompt. The
62
+ * one-line destructive pointer keeps the escape-hatch guidance in view. The
63
+ * derivation reads the single rm-shape bullet out of the edit preamble and swaps
64
+ * the pointer in, so the two preambles share every other clause from one source.
65
+ */
66
+ const HEADLESS_READONLY_PREAMBLE = HEADLESS_EDIT_PREAMBLE.replace(
67
+ /- rm shape rules — [\s\S]*?\n(?=- If a step appears to require)/,
68
+ HEADLESS_READONLY_DESTRUCTIVE_POINTER,
69
+ )
70
+
53
71
  let activeRepoPath = null
54
72
 
55
73
  /**
@@ -72,18 +90,32 @@ const worktreeDirective = (repoPath) =>
72
90
  : ''
73
91
 
74
92
  /**
75
- * Spawn a workflow agent with the headless-safety preamble prepended to its
76
- * prompt. Every agent in this convergence loop runs unattended, so each one is
77
- * routed through here to inherit the same no-confirmation-prompt guidance. On a
78
- * path-scoped run the worktree directive is prepended too, so every agent runs
79
- * in the PR's own worktree (activeRepoPath); on a single-PR run that directive
80
- * is empty and the agent keeps its own working directory.
93
+ * Spawn a workflow agent that edits code, with the full edit preamble prepended
94
+ * to its prompt. An edit agent may run rm during cleanup, so it inherits the
95
+ * rm-shape rules alongside the no-confirmation-prompt guidance. On a path-scoped
96
+ * run the worktree directive is prepended too, so the agent runs in the PR's own
97
+ * worktree (activeRepoPath); on a single-PR run that directive is empty and the
98
+ * agent keeps its own working directory. A read-only agent (a review, verify, or
99
+ * utility spawn) routes through convergeReadOnlyAgent for the trimmed preamble.
81
100
  * @param {string} prompt the agent's role-specific instruction body
82
101
  * @param {object} options the agent() options (label, phase, schema, agentType, model)
83
102
  * @returns {Promise<*>} the agent() result
84
103
  */
85
104
  const convergeAgent = (prompt, options) =>
86
- agent(`${HEADLESS_SAFETY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
105
+ agent(`${HEADLESS_EDIT_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
106
+
107
+ /**
108
+ * Spawn a read-only workflow agent with the trimmed read-only preamble prepended
109
+ * to its prompt. A review, verify, or utility agent edits nothing and never runs
110
+ * rm, so it receives the read-only preamble that drops the rm-shape-rules bullet.
111
+ * The worktree directive is prepended on a path-scoped run exactly as for
112
+ * convergeAgent, so a read-only agent still runs in the PR's own worktree.
113
+ * @param {string} prompt the agent's role-specific instruction body
114
+ * @param {object} options the agent() options (label, phase, schema, agentType, model)
115
+ * @returns {Promise<*>} the agent() result
116
+ */
117
+ const convergeReadOnlyAgent = (prompt, options) =>
118
+ agent(`${HEADLESS_READONLY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
87
119
 
88
120
  /**
89
121
  * Spawn a fresh git-utility Explore agent for a specific task. The one task,
@@ -105,7 +137,7 @@ function runGitTask(task) {
105
137
  if (task !== 'preflight-git') {
106
138
  throw new Error(`runGitTask has no handler for task ${task}`)
107
139
  }
108
- return convergeAgent(
140
+ return convergeReadOnlyAgent(
109
141
  `Run five read-only preflight steps for ${prCoordinates}. Do not edit, commit, push, rebase, or modify any files — read only.\n\n` +
110
142
  `STEP 1 — resolve HEAD. Print the current PR HEAD SHA. Run exactly:\n` +
111
143
  ` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .head.sha\n` +
@@ -308,7 +340,7 @@ function runCodeEditorTask(task, context) {
308
340
  `You are the EDIT step deferring a code-standard-only round on ${prCoordinates}, HEAD ${context.head} (${context.sourceLabel}). The round surfaced ONLY code-standard violations (CODE_RULES/style, no behavioral impact); the run treats it as passed and defers the fixes to follow-up work, which you now stage. A separate verify step then a separate commit step open the hardening PR after you. Do NOT commit or push to the PR's own branch.\n\n` +
309
341
  `Findings:\n${findingsBlock}\n\n` +
310
342
  `1. Follow-up fix issue: file a GitHub issue on ${input.owner}/${input.repo} (gh issue create --body-file with a temp file) titled "Deferred code-standard fixes from PR #${input.prNumber}". The body references the PR and lists each finding with its file:line, severity, and detail. The issue carries the fix work; do not open a fix PR. Capture the issue URL.\n` +
311
- `2. Stage the environment-hardening change: in the Claude environment config repo (the repo owning ~/.claude hooks and rules — JonEcho/llm-settings for hooks, jl-cmd/claude-code-config for rules/skills; pick whichever owns the surface that would block these violation classes), find or clone a local checkout, fetch origin, and create a branch off origin/main. Edit the hooks/rules in that checkout's WORKING TREE so each violation class found here is blocked at Write/Edit time, before code is written. Do NOT commit and do NOT push — the commit step does that after the verify step binds a verdict to the working tree. Return the checkout's absolute path in hardeningRepoPath, the branch name in hardeningBranch, and set hardeningEdited=true. When no hardening is feasible for these classes, leave hardeningRepoPath and hardeningBranch empty and hardeningEdited=false; the follow-up issue still stands.\n` +
343
+ `2. Stage the environment-hardening change: in the Claude environment config repo (the repo owning ~/.claude hooks and rules — JonEcho/llm-settings for hooks, jl-cmd/claude-dev-env for rules/skills; pick whichever owns the surface that would block these violation classes), find or clone a local checkout, fetch origin, and create a branch off origin/main. Edit the hooks/rules in that checkout's WORKING TREE so each violation class found here is blocked at Write/Edit time, before code is written. Do NOT commit and do NOT push — the commit step does that after the verify step binds a verdict to the working tree. Return the checkout's absolute path in hardeningRepoPath, the branch name in hardeningBranch, and set hardeningEdited=true. When no hardening is feasible for these classes, leave hardeningRepoPath and hardeningBranch empty and hardeningEdited=false; the follow-up issue still stands.\n` +
312
344
  `3. For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "Code-standard-only finding — deferred to follow-up issue <url>." Then resolve the thread by its PRRT_ node id (GraphQL lookup on comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread — not the numeric comment id).\n\n` +
313
345
  `Return the issue URL in issueUrl (empty string when it could not be filed), the hardening checkout path and branch, hardeningEdited, and a one-line summary.` +
314
346
  PRE_COMMIT_GATE_STEP,
@@ -382,7 +414,7 @@ function runVerifierTask(task, context) {
382
414
  const label = `verifier:${task}`
383
415
  if (task === 'fix-verify') {
384
416
  const findingsBlock = renderFindingsBlock(context.findings)
385
- return convergeAgent(
417
+ return convergeReadOnlyAgent(
386
418
  `You are the VERIFY step for ${context.findings.length} finding(s) (${context.sourceLabel}) on ${prCoordinates}, HEAD ${context.head}. The edit step left fixes in the working tree, uncommitted. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
387
419
  `Findings the working-tree fixes must address:\n${findingsBlock}\n\n` +
388
420
  `Steps:\n` +
@@ -396,7 +428,7 @@ function runVerifierTask(task, context) {
396
428
  const failureBlock = context.failures.length
397
429
  ? context.failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
398
430
  : 'none reported'
399
- return convergeAgent(
431
+ return convergeReadOnlyAgent(
400
432
  `You are the VERIFY step for the convergence repair on ${prCoordinates}, HEAD ${context.head}. The edit step left its repair in the working tree (a bot-thread fix uncommitted, and/or a rebase onto origin/main), unpushed. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
401
433
  `Concerns the working-tree repair must resolve (the gates the convergence check flagged):\n${failureBlock}\n\n` +
402
434
  `Steps:\n` +
@@ -406,7 +438,7 @@ function runVerifierTask(task, context) {
406
438
  { label, phase: 'Finalize', agentType: 'code-verifier', ...TIERS.sonnetMedium },
407
439
  )
408
440
  }
409
- return convergeAgent(
441
+ return convergeReadOnlyAgent(
410
442
  `You are the VERIFY step for an environment-hardening change (${context.sourceLabel}) staged in the working tree of ${context.hardeningRepoPath}. The edit step left the hooks/rules edits uncommitted there. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
411
443
  `Concern the working-tree change must resolve: the edited hooks/rules block the code-standard violation classes from the deferred round at Write/Edit time, and a hook change carries a passing test per CODE_RULES.\n\n` +
412
444
  `Steps:\n` +
@@ -480,7 +512,7 @@ function runGeneralUtilityTask(task, context) {
480
512
  context.deferredStandardsFindings.length > 0
481
513
  ? `The ${context.deferredStandardsFindings.length} unique code-standard finding(s) after de-duplication across the lens reports above were excluded from this CLEAN post and ${describeStandardsDeferral(context.standardsDeferral)}.\n\n`
482
514
  : ''
483
- return convergeAgent(
515
+ return convergeReadOnlyAgent(
484
516
  `Transcribe a completed audit result to the PR thread for ${prCoordinates} at commit ${context.head}. You are relaying results that ${ranCount} audit lens(es) already produced on this HEAD earlier in this run — you are not judging the code yourself or clearing a merge gate.\n\n` +
485
517
  `${ranCount} audit lens(es) ran on HEAD ${context.head} this round: ${ranRoster}.\n\n` +
486
518
  notRunNote +
@@ -515,7 +547,7 @@ function runConvergenceCheck(context) {
515
547
  const copilotOptOut = context.copilotDown
516
548
  ? ` Copilot is down this run, so before gh pr ready export the token in this same shell session so the independent mark-ready blocker hook's convergence re-check inherits it:\n bash: export CLAUDE_REVIEWS_DISABLED="copilot" (PowerShell: $env:CLAUDE_REVIEWS_DISABLED = "copilot")\n`
517
549
  : ''
518
- return convergeAgent(
550
+ return convergeReadOnlyAgent(
519
551
  `Run the convergence gate for ${prCoordinates} on HEAD ${context.head} and, only when every gate passes, mark the PR ready. Do not edit code.\n\n` +
520
552
  `1. Run: python "${CONFIG.sharedScripts}/check_convergence.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}${bugbotDownFlag}${copilotDownFlag}\n` +
521
553
  ` Exit 0 -> every gate passed: set pass:true, failures:[].\n` +
@@ -565,6 +597,31 @@ const LENS_SCHEMA = {
565
597
  required: ['sha', 'clean', 'down', 'findings'],
566
598
  }
567
599
 
600
+ const COPILOT_FINDING_TIERS = ['self-healing', 'code-concern']
601
+
602
+ const COPILOT_FINDINGS_SCHEMA = {
603
+ type: 'array',
604
+ items: {
605
+ type: 'object',
606
+ additionalProperties: false,
607
+ properties: {
608
+ file: { type: 'string' },
609
+ line: { type: 'integer' },
610
+ severity: { type: 'string', enum: ['P0', 'P1', 'P2'] },
611
+ category: { type: 'string', enum: ['bug', 'code-standard'], description: 'code-standard for pure CODE_RULES/style violations with no behavioral impact; bug otherwise' },
612
+ tier: {
613
+ type: 'string',
614
+ enum: COPILOT_FINDING_TIERS,
615
+ description: 'routing signal separate from category: self-healing when the fix cannot change observable runtime behavior for production callers (style, type hints, misplaced or unused imports, formatting, magic-value extraction, test-only changes, doc-or-description vs code mismatches, code de-duplication); code-concern when the finding is behavior-changing or needs a product decision (logic or correctness, security, data handling, error-handling semantics, concurrency). Classify as code-concern whenever the tier is in doubt.',
616
+ },
617
+ title: { type: 'string' },
618
+ detail: { type: 'string' },
619
+ replyToCommentId: { type: ['integer', 'null'], description: 'GitHub review comment id to reply to and resolve, or null when the finding has no thread' },
620
+ },
621
+ required: ['file', 'line', 'severity', 'category', 'tier', 'title', 'detail', 'replyToCommentId'],
622
+ },
623
+ }
624
+
568
625
  const COPILOT_SCHEMA = {
569
626
  type: 'object',
570
627
  additionalProperties: false,
@@ -572,7 +629,8 @@ const COPILOT_SCHEMA = {
572
629
  sha: { type: 'string' },
573
630
  clean: { type: 'boolean' },
574
631
  down: { type: 'boolean', description: 'true when Copilot is down or out of quota — it posts an out-of-usage notice or never surfaces a review on HEAD after the poll cap; the gate is bypassed and the run proceeds to mark-ready' },
575
- findings: LENS_SCHEMA.properties.findings,
632
+ reviewUrl: { type: 'string', description: 'the Copilot review html_url when the gate carries findings, otherwise an empty string — the user-review payload links the orchestrator and the user to the review' },
633
+ findings: COPILOT_FINDINGS_SCHEMA,
576
634
  },
577
635
  required: ['sha', 'clean', 'down', 'findings'],
578
636
  }
@@ -594,7 +652,7 @@ const REVIEWER_AVAILABILITY_SCHEMA = {
594
652
  type: 'object',
595
653
  additionalProperties: false,
596
654
  properties: {
597
- down: { type: 'boolean', description: 'true when reviewer_availability.py --reviewer bugbot exited non-zero (opted out via CLAUDE_REVIEWS_DISABLED)' },
655
+ down: { type: 'boolean', description: 'true when reviewer_availability.py --reviewer bugbot exited non-zero (off by default unless CLAUDE_REVIEWS_ENABLED lists bugbot, or opted out via CLAUDE_REVIEWS_DISABLED)' },
598
656
  reason: { type: 'string', description: 'the one-line reason reviewer_availability.py printed for Bugbot' },
599
657
  },
600
658
  required: ['down', 'reason'],
@@ -743,7 +801,7 @@ const CLEAN_AUDIT_SCHEMA = {
743
801
 
744
802
  const SEVERITY_RANK = { P0: 0, P1: 1, P2: 2 }
745
803
  const SHA_COMPARISON_PREFIX_LENGTH = 7
746
- const LENS_NAMES = ['Cursor Bugbot', 'code-review', 'bug-audit']
804
+ const LENS_NAMES = ['code-review', 'bug-audit', 'self-review']
747
805
  const GITHUB_ISSUE_URL_PATTERN = /^(https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)\/?(?:[?#].*)?$/
748
806
 
749
807
  /**
@@ -902,20 +960,20 @@ function resolveRoundOutcome(lensResults) {
902
960
  * A lens is 'ran' when it returned a live result carrying a real review — either
903
961
  * not down, or down but still producing findings (which resolveRoundOutcome folds
904
962
  * into the round, so the same result is quoted here as ran). It is 'down' when the
905
- * workflow never spawned it (the synthesized Bugbot down-stub carries notSpawned,
906
- * so its stub is never presented as a returned result), 'reported-down' when its
907
- * agent ran but reported itself down with no findings (an opt-out or a poll-budget
908
- * timeout that surfaced no review), and 'dead' when its agent died and returned no
909
- * result. Only 'ran' carries a quoted report.
963
+ * workflow never spawned it (a down-stub carrying notSpawned is never presented as
964
+ * a returned result), 'reported-down' when its agent ran but reported itself down
965
+ * with no findings (an opt-out or a poll-budget timeout that surfaced no review),
966
+ * and 'dead' when its agent died and returned no result. Only 'ran' carries a
967
+ * quoted report.
910
968
  *
911
969
  * ::
912
970
  *
913
- * nameLensResults([{down: true, notSpawned: true, ...}, null, auditReport])
914
- * -> [{lens: 'Cursor Bugbot', status: 'down', report: null},
915
- * {lens: 'code-review', status: 'dead', report: null},
916
- * {lens: 'bug-audit', status: 'ran', report: auditReport}]
971
+ * nameLensResults([codeReviewReport, null, selfReviewReport])
972
+ * -> [{lens: 'code-review', status: 'ran', report: codeReviewReport},
973
+ * {lens: 'bug-audit', status: 'dead', report: null},
974
+ * {lens: 'self-review', status: 'ran', report: selfReviewReport}]
917
975
  *
918
- * The order matches the parallel([bugbot, code-review, bug-audit]) spawn order.
976
+ * The order matches the parallel([code-review, bug-audit, self-review]) spawn order.
919
977
  * @param {Array<object|null>} lensResults the positional lens results for the round
920
978
  * @returns {Array<object>} one {lens, status, report} entry per lens position
921
979
  */
@@ -944,13 +1002,13 @@ function nameLensResults(lensResults) {
944
1002
  *
945
1003
  * ::
946
1004
  *
947
- * describeNotRunLens({lens: 'Cursor Bugbot', status: 'down'})
948
- * -> 'Cursor Bugbot (down/disabled — did not run)'
949
- * describeNotRunLens({lens: 'Cursor Bugbot', status: 'reported-down'})
950
- * -> 'Cursor Bugbot (ran, but reported itself down — it produced no review for this HEAD)'
1005
+ * describeNotRunLens({lens: 'self-review', status: 'down'})
1006
+ * -> 'self-review (down/disabled — did not run)'
1007
+ * describeNotRunLens({lens: 'self-review', status: 'reported-down'})
1008
+ * -> 'self-review (ran, but reported itself down — it produced no review for this HEAD)'
951
1009
  *
952
- * The reported-down wording stays mechanism-neutral because the reviewer returns
953
- * the same down shape for an opt-out (no poll ever ran) and a poll-budget timeout.
1010
+ * The reported-down wording stays mechanism-neutral because a lens returns the
1011
+ * same down shape for an opt-out (no poll ever ran) and a poll-budget timeout.
954
1012
  * @param {{lens: string, status: string}} lensEntry a non-ran lens provenance entry
955
1013
  * @returns {string} the disclosure clause for that lens
956
1014
  */
@@ -1303,28 +1361,86 @@ function classifyReadyOutcome(readyResult) {
1303
1361
  }
1304
1362
 
1305
1363
  /**
1306
- * Classify a Copilot gate result into the loop's next action. A dead gate agent
1307
- * (null result) is a retry rather than an approval, mirroring the converge
1308
- * lenses' dead-agent convention so a failed gate is never mistaken for a clean
1309
- * Copilot review. A down result Copilot out of quota or unreachable, so it
1364
+ * Classify a terminal reviewer-gate result into the loop's next action, shared by
1365
+ * both external confirmation gates Cursor Bugbot and GitHub Copilot. The logic
1366
+ * is reviewer-neutral over the {clean, down, findings} shape both gates return. A
1367
+ * dead gate agent (null result) is a retry rather than an approval, mirroring the
1368
+ * converge lenses' dead-agent convention so a failed gate is never mistaken for a
1369
+ * clean review. A down result — the reviewer out of quota or unreachable, so it
1310
1370
  * posts an out-of-usage notice or never surfaces a review after the poll cap —
1311
- * routes to the 'down' kind, which logs a notice and proceeds to mark-ready with
1312
- * the Copilot gate bypassed, the same way a down Bugbot lens is bypassed; this is
1313
- * checked first so an outage proceeds rather than waiting on a review that will
1314
- * not arrive. Findings route to a fix step. The gate otherwise approves only when
1315
- * it explicitly reports clean:true with no findings a clean:false result with
1316
- * zero findings is an unreliable or malformed gate response and retries rather
1317
- * than advancing to Finalize, so a PR never goes ready on a HEAD Copilot did not
1318
- * call clean.
1371
+ * routes to the 'down' kind, which logs a notice and proceeds with the gate
1372
+ * bypassed; this is checked first so an outage proceeds rather than waiting on a
1373
+ * review that will not arrive. Findings route to a fix step. The gate otherwise
1374
+ * approves only when it explicitly reports clean:true with no findings a
1375
+ * clean:false result with zero findings is an unreliable or malformed gate
1376
+ * response and retries rather than advancing, so a PR never advances on a HEAD the
1377
+ * reviewer did not call clean.
1378
+ * @param {object|null|undefined} reviewerGate the terminal gate result, or null on agent failure
1379
+ * @returns {{kind: string, findings?: Array<object>}} the next action
1380
+ */
1381
+ function classifyReviewerGateOutcome(reviewerGate) {
1382
+ if (reviewerGate == null) return { kind: 'retry' }
1383
+ if (reviewerGate.down === true) return { kind: 'down' }
1384
+ if (reviewerGate.findings.length > 0) return { kind: 'fix', findings: reviewerGate.findings }
1385
+ if (reviewerGate.clean === true) return { kind: 'approved' }
1386
+ return { kind: 'retry' }
1387
+ }
1388
+
1389
+ /**
1390
+ * Layer the Copilot-specific two-tier triage over the shared reviewer-gate
1391
+ * classifier. The base classifier decides retry / down / approved and routes any
1392
+ * findings to a fix; this wrapper then upgrades a fix round carrying a
1393
+ * code-concern finding to a 'user-review' outcome, so the workflow neither
1394
+ * auto-fixes it nor marks the PR ready. A self-healing-only fix round passes
1395
+ * through unchanged and flows into the fix flow. Only the Copilot gate uses this
1396
+ * wrapper; the terminal Bugbot gate keeps the base classifier, whose findings
1397
+ * carry no tier and always route to a fix.
1319
1398
  * @param {object|null|undefined} copilot the Copilot gate result, or null on agent failure
1320
1399
  * @returns {{kind: string, findings?: Array<object>}} the next action
1321
1400
  */
1322
1401
  function classifyCopilotOutcome(copilot) {
1323
- if (copilot == null) return { kind: 'retry' }
1324
- if (copilot.down === true) return { kind: 'down' }
1325
- if (copilot.findings.length > 0) return { kind: 'fix', findings: copilot.findings }
1326
- if (copilot.clean === true) return { kind: 'approved' }
1327
- return { kind: 'retry' }
1402
+ const outcome = classifyReviewerGateOutcome(copilot)
1403
+ if (outcome.kind === 'fix' && hasCodeConcernFinding(outcome.findings)) {
1404
+ return { kind: 'user-review', findings: outcome.findings }
1405
+ }
1406
+ return outcome
1407
+ }
1408
+
1409
+ /**
1410
+ * Decide whether a Copilot round carries a code-concern finding — one that is
1411
+ * behavior-changing or needs a product decision, so the workflow may neither
1412
+ * auto-fix it nor mark the PR ready. Any tier other than the explicit
1413
+ * 'self-healing' counts as a code concern, so a missing or unexpected tier
1414
+ * defaults to code-concern rather than silently routing into the auto-fix flow.
1415
+ * @param {Array<object>} findings the Copilot findings for the round
1416
+ * @returns {boolean} true when at least one finding is not tier self-healing
1417
+ */
1418
+ function hasCodeConcernFinding(findings) {
1419
+ return findings.some((each) => each.tier !== 'self-healing')
1420
+ }
1421
+
1422
+ /**
1423
+ * Build the user-review payload the orchestrator hands to the
1424
+ * copilot-finding-triage skill: the Copilot review link plus the code-concern
1425
+ * findings pared to the fields the ntfy summary and the review gate read. The
1426
+ * workflow returns this alongside blocker 'user-review' so the orchestrating
1427
+ * session runs the notify-and-wait gate; a background workflow cannot hold for a
1428
+ * human, so it carries the findings out rather than deciding them.
1429
+ * @param {object} copilot the COPILOT_SCHEMA gate result carrying the review URL
1430
+ * @param {Array<object>} findings the Copilot findings for this HEAD
1431
+ * @returns {{reviewUrl: string, findings: Array<object>}} the triage payload
1432
+ */
1433
+ function buildUserReview(copilot, findings) {
1434
+ return {
1435
+ reviewUrl: copilot.reviewUrl || '',
1436
+ findings: findings.map((each) => ({
1437
+ file: each.file,
1438
+ line: each.line,
1439
+ severity: each.severity,
1440
+ tier: each.tier,
1441
+ title: each.title,
1442
+ })),
1443
+ }
1328
1444
  }
1329
1445
 
1330
1446
  /**
@@ -1338,7 +1454,7 @@ function classifyCopilotOutcome(copilot) {
1338
1454
  * from the current outcome is what lets a recovered Copilot — one that returns
1339
1455
  * standards-only findings after an earlier down pass — reach FINALIZE without
1340
1456
  * the stale bypass that would skip its non-clean review.
1341
- * @param {{kind: string}} copilotOutcome a classifyCopilotOutcome result
1457
+ * @param {{kind: string}} copilotOutcome a classifyReviewerGateOutcome result
1342
1458
  * @returns {boolean} true only when this pass's Copilot gate is bypassed
1343
1459
  */
1344
1460
  function resolveCopilotDown(copilotOutcome) {
@@ -1445,6 +1561,35 @@ function renderLensDiffContext(preflightResult) {
1445
1561
  )
1446
1562
  }
1447
1563
 
1564
+ /**
1565
+ * Static-sweep lens: the deterministic pre-catch pass that runs the CODE_RULES
1566
+ * gate, ruff, mypy, and stem-matched pytest over the PR's changed files, so the
1567
+ * opus reading lenses only ever review sweep-clean code. It runs on the cheaper
1568
+ * sonnet tier because the mechanical, static, and test classes are the right job
1569
+ * for a deterministic run rather than a reading judgment. It uses only local
1570
+ * git, python, and node commands — never the GitHub CLI — so it runs in any
1571
+ * session, and a gate it could not run is disclosed in a finding detail rather
1572
+ * than silently treated as clean.
1573
+ * @param {string} head PR HEAD SHA to evaluate
1574
+ * @param {object|null|undefined} preflightResult the preflight-git result carrying the changed-file list and diffstat for this round
1575
+ * @returns {Promise<object>} LENS_SCHEMA result carrying each gate or test failure as a finding
1576
+ */
1577
+ function runStaticSweep(head, preflightResult) {
1578
+ return convergeReadOnlyAgent(
1579
+ `You are the deterministic static-sweep lens for ${prCoordinates}, HEAD ${head}. Run the mechanical gates over the PR's changed files and report each failure as a finding; do not edit, commit, or push.\n\n` +
1580
+ renderLensDiffContext(preflightResult) +
1581
+ `Use only local git, python, and node commands — never the GitHub CLI — so this runs in any session.\n\n` +
1582
+ `1. Detect the repository's lint, type-check, and test entrypoints from pyproject.toml, package.json, and the repo layout.\n` +
1583
+ `2. Resolve the repo root with git rev-parse --show-toplevel, then run:\n` +
1584
+ ` - python "${CONFIG.prLoopScripts}/code_rules_gate.py" --repo-root <that root> --base origin/main (exit 1 means blocking CODE_RULES violations on the diff).\n` +
1585
+ ` - ruff check on the changed files, and mypy on the changed files.\n` +
1586
+ ` - stem-matched pytest for the changed production modules — run each changed module's paired test file.\n` +
1587
+ `3. Map each failure to one LENS_SCHEMA finding at its file:line: category 'code-standard' for a lint, CODE_RULES, or type violation; category 'bug' for a failing test. Set severity per impact and replyToCommentId=null.\n\n` +
1588
+ `When a gate cannot run in this session because a tool is absent, report that as a finding detail rather than treating the gate as clean. Return strictly the schema: clean=true with empty findings when every gate and test passes on the diff, otherwise one entry per failure. Set sha=${'`'}${head}${'`'}, down=false.`,
1589
+ { label: 'lens:static-sweep', phase: 'Converge', schema: LENS_SCHEMA, agentType: 'Explore', ...TIERS.sonnetMedium },
1590
+ )
1591
+ }
1592
+
1448
1593
  /**
1449
1594
  * Bugbot lens: ensure Cursor Bugbot has rendered a verdict on the given HEAD,
1450
1595
  * triggering and polling its CI check run when needed, and return its findings.
@@ -1453,7 +1598,7 @@ function renderLensDiffContext(preflightResult) {
1453
1598
  * @returns {Promise<object>} LENS_SCHEMA result
1454
1599
  */
1455
1600
  function runBugbotLens(head, preflightResult) {
1456
- return convergeAgent(
1601
+ return convergeReadOnlyAgent(
1457
1602
  `You are the Cursor Bugbot lens for ${prCoordinates}, HEAD ${head}. Cursor Bugbot participates this run.\n\n` +
1458
1603
  `Goal: return Bugbot's verdict on HEAD ${head}. Do not edit code, commit, or push. You may post the literal trigger comment described below.\n\n` +
1459
1604
  renderLensDiffContext(preflightResult) +
@@ -1481,7 +1626,7 @@ function runBugbotLens(head, preflightResult) {
1481
1626
  * @returns {Promise<object>} LENS_SCHEMA result
1482
1627
  */
1483
1628
  function runCodeReviewLens(head, preflightResult) {
1484
- return convergeAgent(
1629
+ return convergeReadOnlyAgent(
1485
1630
  `You are the code-review lens for ${prCoordinates}, HEAD ${head}.\n\n` +
1486
1631
  `Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT delta-scope to recent commits or to a single file.\n\n` +
1487
1632
  renderLensDiffContext(preflightResult) +
@@ -1499,16 +1644,43 @@ function runCodeReviewLens(head, preflightResult) {
1499
1644
  * @returns {Promise<object>} LENS_SCHEMA result
1500
1645
  */
1501
1646
  function runAuditLens(head, preflightResult) {
1502
- return convergeAgent(
1647
+ return convergeReadOnlyAgent(
1503
1648
  `You are the second-opinion bug-audit lens for ${prCoordinates}, HEAD ${head}.\n\n` +
1504
1649
  `Read the audit rubric at ${CONFIG.bugteamRubric} and apply its categories (A through P) against the FULL origin/main...HEAD diff — every file the PR touches, never a delta cut.\n\n` +
1505
1650
  renderLensDiffContext(preflightResult) +
1506
1651
  `This is a clean-room audit: assume nothing from other lenses. Report only findings backed by concrete file:line evidence. Do NOT edit, commit, or push.\n\n` +
1652
+ `Adversarial second pass — mandatory after the primary A-P pass: assume your first pass missed at least 3 P1 bugs. Where are they? Re-examine the diff category by category and return either new Shape-A findings at new file:line references, or an explicit Shape-B adversarial-probe entry naming each category you re-examined and why it holds. A bare "nothing new" is not an acceptable result for this pass.\n\n` +
1653
+ `The doc-vs-code parity, test-assertion completeness, and PR-description-vs-diff lanes sit outside the A-P categories; read the pre-catch rubric at ${CONFIG.precatchRubric} for their checklists and fold any finding those lanes surface into your returned findings.\n\n` +
1507
1654
  `Return strictly the schema: clean=true with empty findings when the diff passes every category, otherwise one entry per finding (severity P0/P1/P2; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise; replyToCommentId=null). Set sha=${'`'}${head}${'`'}, down=false.`,
1508
1655
  { label: 'lens:bug-audit', phase: 'Converge', schema: LENS_SCHEMA, agentType: 'code-quality-agent', ...TIERS.opusMedium },
1509
1656
  )
1510
1657
  }
1511
1658
 
1659
+ /**
1660
+ * Self-review lens: the semantic pre-catch parity pass over the full diff. It
1661
+ * covers the doc-vs-code parity, test-assertion completeness, and
1662
+ * PR-description-vs-diff lanes that sit outside the A-P bug categories, reusing
1663
+ * the pr-consistency-audit skill's canonical-source cross-reference method. It
1664
+ * stays on opus because line-citation accuracy, symbol attribution, and
1665
+ * inventory or count claims are semantic judgments a cheaper tier misreads. It
1666
+ * reports findings without editing.
1667
+ * @param {string} head PR HEAD SHA to evaluate
1668
+ * @param {object|null|undefined} preflightResult the preflight-git result carrying the changed-file list and diffstat for this round
1669
+ * @returns {Promise<object>} LENS_SCHEMA result carrying the parity findings
1670
+ */
1671
+ function runSelfReviewLens(head, preflightResult) {
1672
+ return convergeReadOnlyAgent(
1673
+ `You are the self-review parity lens for ${prCoordinates}, HEAD ${head}. Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT edit, commit, or push.\n\n` +
1674
+ renderLensDiffContext(preflightResult) +
1675
+ `Read the pre-catch rubric at ${CONFIG.precatchRubric} for the three lane checklists, and cover each lane:\n` +
1676
+ `1. Doc-vs-code parity: reuse the pr-consistency-audit skill's canonical-source cross-reference method ($HOME/.claude/skills/pr-consistency-audit/SKILL.md) and the drift rubric at $HOME/.claude/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md. Verify every line citation resolves, every referenced file or script path exists, every symbol is attributed to the file that defines it, and every inventory, count, and ordering claim matches the code.\n` +
1677
+ `2. Test-assertion completeness: every changed or new production path has a paired test that calls it and asserts on its behavior, and a changed test pins behavior rather than hiding it behind a mock.\n` +
1678
+ `3. PR-description-vs-diff two-way parity: fetch the PR body read-only, then confirm every PR-body claim maps to a hunk in the diff and every hunk maps to a claim; flag invented paths, invented counts, and out-of-scope changes.\n\n` +
1679
+ `Before returning clean, state one proof-of-absence line per lane naming what you checked. Return strictly the schema: clean=true with empty findings when all three lanes pass, otherwise one entry per finding (severity P0/P1/P2; category 'code-standard' for a pure doc or style parity gap with no behavioral impact, 'bug' otherwise; replyToCommentId=null). Set sha=${'`'}${head}${'`'}, down=false.`,
1680
+ { label: 'lens:self-review', phase: 'Converge', schema: LENS_SCHEMA, agentType: 'code-quality-agent', ...TIERS.opusMedium },
1681
+ )
1682
+ }
1683
+
1512
1684
  /**
1513
1685
  * Reuse lens: a one-time pre-convergence pass that scans the full diff for
1514
1686
  * places the PR re-implements behavior the codebase already provides, and
@@ -1521,7 +1693,7 @@ function runAuditLens(head, preflightResult) {
1521
1693
  * @returns {Promise<object>} LENS_SCHEMA result carrying the qualifying reuse findings
1522
1694
  */
1523
1695
  function runReuseAuditPass(head, preflightResult) {
1524
- return convergeAgent(
1696
+ return convergeReadOnlyAgent(
1525
1697
  `You are the REUSE lens for ${prCoordinates}, HEAD ${head}. This pass runs once before convergence to find where the PR re-implements behavior the codebase already provides.\n\n` +
1526
1698
  `Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT delta-scope to recent commits or a single file.\n\n` +
1527
1699
  renderLensDiffContext(preflightResult) +
@@ -1554,11 +1726,22 @@ function renderFindingsBlock(findings) {
1554
1726
 
1555
1727
  const FIX_RECOVERY_MAX_ATTEMPTS = 2
1556
1728
 
1729
+ /**
1730
+ * Commit-recovery attempt cap, held to one. The edit agent already pre-clears the
1731
+ * commit gate via PRE_COMMIT_GATE_STEP before the working tree reaches the commit
1732
+ * step, so a second commit-recover edit is wasted effort: a rejection that
1733
+ * survives one recovery pass is a genuine post-clear block that fails fast to a
1734
+ * blocker rather than looping. The verify-recovery loop keeps the wider
1735
+ * FIX_RECOVERY_MAX_ATTEMPTS budget, since a verify objection can take more than
1736
+ * one pass to resolve.
1737
+ */
1738
+ const COMMIT_RECOVERY_MAX_ATTEMPTS = 1
1739
+
1557
1740
  /**
1558
1741
  * Run a commit step and, when it is blocked by a commit-time hook or gate that
1559
1742
  * requires a code change, route back to a fixer: fix the blocking violation,
1560
1743
  * re-verify so a fresh verdict binds the corrected surface, then retry the
1561
- * commit — bounded by FIX_RECOVERY_MAX_ATTEMPTS. The loop breaks early when the
1744
+ * commit — bounded by COMMIT_RECOVERY_MAX_ATTEMPTS. The loop breaks early when the
1562
1745
  * fixer makes no edit or the re-verify does not pass, returning the last commit
1563
1746
  * result so the caller's existing no-push handling still applies. A transient
1564
1747
  * failure never enters the loop (commitNeedsCodeRecovery is false), so an auth or
@@ -1569,7 +1752,7 @@ const FIX_RECOVERY_MAX_ATTEMPTS = 2
1569
1752
  async function commitWithRecovery({ runCommit, runVerify, runRecoverEdit }) {
1570
1753
  let commitResult = await runCommit()
1571
1754
  let attempt = 0
1572
- while (commitNeedsCodeRecovery(commitResult) && attempt < FIX_RECOVERY_MAX_ATTEMPTS) {
1755
+ while (commitNeedsCodeRecovery(commitResult) && attempt < COMMIT_RECOVERY_MAX_ATTEMPTS) {
1573
1756
  attempt += 1
1574
1757
  const recoverEdit = await runRecoverEdit(commitResult.blockerDetail, attempt)
1575
1758
  if (recoverEdit?.edited !== true) break
@@ -1667,7 +1850,7 @@ function cleanAuditBlocker(head, auditResult) {
1667
1850
  * @returns {Promise<object>} COPILOT_SCHEMA result
1668
1851
  */
1669
1852
  function runCopilotGate(head) {
1670
- return convergeAgent(
1853
+ return convergeReadOnlyAgent(
1671
1854
  `You are the Copilot gate for ${prCoordinates}, HEAD ${head}. Do not edit code, commit, or push.\n\n` +
1672
1855
  `Copilot can run out of usage. When the newest Copilot review on HEAD carries an out-of-usage notice — a body stating Copilot was unable to review because the user who requested the review has reached their quota limit, or any equivalent quota / premium-request / usage-limit exhaustion message rather than an actual code review — Copilot is down for this run: return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} and stop. Do NOT re-request a review, do NOT keep polling, and do NOT treat the notice as a finding.\n\n` +
1673
1856
  `1. Read any existing Copilot review on HEAD first: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}. This lists every Copilot review across all commits newest-first; only count entries whose commit_id starts with ${head}. If the newest such HEAD-scoped Copilot review is the out-of-usage notice above -> return the down result and stop. A notice on any earlier commit is NOT down: ignore it and continue. With no Copilot review on HEAD, skip a duplicate request: python "${CONFIG.sharedScripts}/check_pending_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --user copilot. Exit 0 means a request is already pending; otherwise request one:\n` +
@@ -1675,8 +1858,13 @@ function runCopilotGate(head) {
1675
1858
  `2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (wait each 360-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above; if the attempt budget is spent with no review on HEAD, return the full down result {sha:${'`'}${head}${'`'}, clean:false, down:true, findings:[]}). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
1676
1859
  ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
1677
1860
  ` - Copilot review present on HEAD whose state is APPROVED, or COMMENTED with no inline findings -> a clean pass: return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
1678
- ` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, down:false.\n` +
1861
+ ` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise; tier per the two-tier rubric below), reviewUrl set to the Copilot review html_url, clean:false, down:false.\n` +
1679
1862
  ` - No review after ${CONFIG.copilotMaxPolls} attempts -> Copilot is down for this run (unreachable, or silently out of quota with no notice): return {sha:${'`'}${head}${'`'}, clean:false, down:true, findings:[]}.\n\n` +
1863
+ `Tier every finding for routing, separate from its category:\n` +
1864
+ ` - tier 'self-healing': pure style, type hints, misplaced or unused imports, formatting, magic-value extraction, test-only changes, doc-or-description vs code mismatches, or code de-duplication — any fix that cannot change observable runtime behavior for production callers. These route into the fix flow automatically.\n` +
1865
+ ` - tier 'code-concern': logic or correctness, security, data handling, error-handling semantics, or concurrency — anything behavior-changing or needing a product decision. These hold for a user-review gate rather than an auto-fix.\n` +
1866
+ ` - Classify as 'code-concern' whenever the tier is in doubt.\n\n` +
1867
+ `A down verdict is valid ONLY in two cases: the review request itself failed, or the FULL poll budget (${CONFIG.copilotMaxPolls} attempts x 360 seconds) elapsed with no review on HEAD. A successful review request means the review is in flight; returning down:true on a partial poll is an invalid result. Never end the poll early for any reason other than a received review on HEAD or an out-of-usage notice — not tooling friction, not turn-length pressure, not a failed attempt to write a polling helper. When your wait tooling fails, re-arm it and keep polling until the budget is spent.\n\n` +
1680
1868
  `Return strictly the schema.`,
1681
1869
  { label: 'copilot-gate', phase: 'Copilot gate', schema: COPILOT_SCHEMA, ...TIERS.haikuLow },
1682
1870
  )
@@ -2101,14 +2289,25 @@ while (iterations < CONFIG.maxIterations) {
2101
2289
  log(`Round ${rounds}: preflight-git agent returned no SHA — retrying without spawning lenses`)
2102
2290
  continue
2103
2291
  }
2104
- const isBugbotDownPreSpawn = resolveReviewerDown(reviewerAvailability?.bugbot, input.bugbotDisabled || false)
2105
- log(`Round ${rounds}: parallel Bugbot + code-review + bug-audit on ${head?.slice(0, 7)}`)
2292
+ const sweep = await runStaticSweep(head, reviewerAvailability)
2293
+ if ((sweep?.findings || []).length > 0) {
2294
+ resetNoLensRounds()
2295
+ log(`Round ${rounds}: static sweep raised ${sweep.findings.length} deterministic gate/test failure(s) — fixing before the reading lenses`)
2296
+ const sweepFix = await applyFixes(head, sweep.findings, 'static-sweep')
2297
+ const sweepProgress = detectFixProgress(sweepFix, head, false)
2298
+ if (!sweepProgress.progressed) {
2299
+ blocker = `static-sweep stall: the deterministic gates raised ${sweep.findings.length} failure(s) the fixer could not clear on HEAD ${head} — re-running would loop to the iteration cap`
2300
+ break
2301
+ }
2302
+ head = null
2303
+ continue
2304
+ }
2305
+ log(`Round ${rounds}: parallel code-review + bug-audit + self-review on ${head?.slice(0, 7)}`)
2106
2306
  const lenses = await parallel([
2107
- () => (isBugbotDownPreSpawn ? Promise.resolve({ sha: head, clean: true, down: true, notSpawned: true, findings: [] }) : runBugbotLens(head, reviewerAvailability)),
2108
2307
  () => runCodeReviewLens(head, reviewerAvailability),
2109
2308
  () => runAuditLens(head, reviewerAvailability),
2309
+ () => runSelfReviewLens(head, reviewerAvailability),
2110
2310
  ])
2111
- bugbotDown = lenses[0] == null ? true : resolveReviewerDown(lenses[0], input.bugbotDisabled || false)
2112
2311
  const roundOutcome = resolveRoundOutcome(lenses)
2113
2312
  if (roundOutcome.allLensesDead) {
2114
2313
  if (registerNoLensRound(['a review lens agent died'])) {
@@ -2138,7 +2337,7 @@ while (iterations < CONFIG.maxIterations) {
2138
2337
  blocker = cleanAuditBlocker(head, auditResult)
2139
2338
  break
2140
2339
  }
2141
- phase = 'COPILOT'
2340
+ phase = 'BUGBOT'
2142
2341
  continue
2143
2342
  }
2144
2343
  if (findings.length > 0) {
@@ -2183,6 +2382,54 @@ while (iterations < CONFIG.maxIterations) {
2183
2382
  break
2184
2383
  }
2185
2384
  resetNoLensRounds()
2385
+ phase = 'BUGBOT'
2386
+ continue
2387
+ }
2388
+
2389
+ if (phase === 'BUGBOT') {
2390
+ if (resolveReviewerDown(reviewerAvailability?.bugbot, input.bugbotDisabled || false)) {
2391
+ bugbotDown = true
2392
+ log('Bugbot gate: the shared reviewer-availability probe (or the run input) reported Bugbot unavailable — skipping the terminal Bugbot gate with no agent spawned and proceeding to the Copilot gate.')
2393
+ phase = 'COPILOT'
2394
+ continue
2395
+ }
2396
+ const bugbot = await runBugbotLens(head, reviewerAvailability)
2397
+ const bugbotOutcome = classifyReviewerGateOutcome(bugbot)
2398
+ if (bugbotOutcome.kind === 'retry') {
2399
+ log('Bugbot gate agent died or returned an unreliable not-clean result with no findings — re-running the gate on the same HEAD')
2400
+ continue
2401
+ }
2402
+ if (bugbotOutcome.kind === 'down') {
2403
+ log('Bugbot gate: Bugbot is down or out of quota — no review on HEAD after the poll cap. Bypassing the terminal Bugbot gate and proceeding to the Copilot gate.')
2404
+ bugbotDown = true
2405
+ phase = 'COPILOT'
2406
+ continue
2407
+ }
2408
+ if (bugbotOutcome.kind === 'fix') {
2409
+ if (isStandardsOnlyRound(bugbotOutcome.findings)) {
2410
+ log(`Bugbot raised ${bugbotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
2411
+ const standardsOutcome = await openStandardsFollowUpOnce(head, bugbotOutcome.findings, 'bugbot', { copilotDisabled: copilotDown, bugbotDisabled: bugbotDown })
2412
+ standardsNote = standardsDeferralNote(bugbotOutcome.findings.length, buildStandardsDeferral())
2413
+ if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
2414
+ bugbotDown = false
2415
+ phase = 'COPILOT'
2416
+ continue
2417
+ }
2418
+ log(`Bugbot raised ${bugbotOutcome.findings.length} finding(s) — fixing and re-converging`)
2419
+ const fixResult = await applyFixes(head, bugbotOutcome.findings, 'bugbot')
2420
+ const hadThreadBearingFinding = bugbotOutcome.findings.some((each) => collectFindingThreadIds(each).length > 0)
2421
+ const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
2422
+ if (!fixProgress.progressed) {
2423
+ blocker = fixResult?.resolvedWithoutCommit === true && !hadThreadBearingFinding
2424
+ ? `fix stalled: bugbot gate raised ${bugbotOutcome.findings.length} in-memory finding(s) with no GitHub thread, the fix judged them all stale (resolvedWithoutCommit) and moved no code on HEAD ${head} — re-raising would loop to the iteration cap`
2425
+ : `bugbot fix lens landed no push for ${bugbotOutcome.findings.length} finding(s) on HEAD ${head}`
2426
+ break
2427
+ }
2428
+ head = null
2429
+ phase = 'CONVERGE'
2430
+ continue
2431
+ }
2432
+ bugbotDown = false
2186
2433
  phase = 'COPILOT'
2187
2434
  continue
2188
2435
  }
@@ -2210,6 +2457,20 @@ while (iterations < CONFIG.maxIterations) {
2210
2457
  phase = 'FINALIZE'
2211
2458
  continue
2212
2459
  }
2460
+ if (copilotOutcome.kind === 'user-review') {
2461
+ log(`Copilot raised ${copilotOutcome.findings.length} code-concern finding(s) — holding for user review: the workflow does not auto-fix them and does not mark the PR ready`)
2462
+ return {
2463
+ converged: false,
2464
+ rounds,
2465
+ finalSha: head,
2466
+ blocker: 'user-review',
2467
+ userReview: buildUserReview(copilot, copilotOutcome.findings),
2468
+ standardsNote,
2469
+ copilotNote,
2470
+ reuseNote,
2471
+ deferredPrs,
2472
+ }
2473
+ }
2213
2474
  if (copilotOutcome.kind === 'fix') {
2214
2475
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
2215
2476
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)