claude-dev-env 1.80.0 → 1.82.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 (64) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  7. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  9. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  10. package/audit-rubrics/CLAUDE.md +3 -2
  11. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  12. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  13. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  14. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  15. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  16. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  17. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  19. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  20. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  21. package/docs/CODE_RULES.md +2 -2
  22. package/hooks/blocking/CLAUDE.md +3 -0
  23. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  24. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  25. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  26. package/hooks/blocking/code_rules_docstrings.py +93 -0
  27. package/hooks/blocking/code_rules_enforcer.py +54 -4
  28. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  29. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  30. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  31. package/hooks/blocking/code_rules_unused_imports.py +0 -3
  32. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  33. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  34. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  35. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  38. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  40. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  41. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  42. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  43. package/hooks/hooks.json +5 -0
  44. package/hooks/hooks_constants/CLAUDE.md +3 -0
  45. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  46. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  47. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  48. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  49. package/package.json +1 -1
  50. package/rules/docstring-prose-matches-implementation.md +2 -1
  51. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  52. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  55. package/skills/autoconverge/SKILL.md +92 -7
  56. package/skills/autoconverge/reference/convergence.md +3 -3
  57. package/skills/autoconverge/reference/gotchas.md +5 -5
  58. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  59. package/skills/autoconverge/workflow/converge.contract.test.mjs +161 -29
  60. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +342 -1
  61. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  62. package/skills/autoconverge/workflow/converge.mjs +178 -32
  63. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  64. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
@@ -16,14 +16,14 @@ export const meta = {
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
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 three times, 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' },
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' },
20
20
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
21
21
  ],
22
22
  }
23
23
 
24
24
  const CONFIG = {
25
25
  maxIterations: 20,
26
- copilotMaxPolls: 3,
26
+ copilotMaxPolls: 8,
27
27
  sharedScripts: '$HOME/.claude/skills/pr-converge/scripts',
28
28
  prLoopScripts: '$HOME/.claude/_shared/pr-loop/scripts',
29
29
  bugteamRubric: '$HOME/.claude/skills/bugteam/reference/audit-contract.md',
@@ -35,7 +35,11 @@ const HEADLESS_SAFETY_PREAMBLE =
35
35
  '- 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' +
36
36
  '- Keep scratch files and cleanup inside the OS temp dir; never target a repository or worktree path.\n' +
37
37
  '- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution and on backtick subshells. The compound path additionally fails closed on any $ in the target — including $CLAUDE_JOB_DIR. The standalone path declines a $-bearing target only when the literal path is not already under an ephemeral root, so it does not by itself stop a $VAR that expands inside an ephemeral root. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Even so, prefer a Python helper for any cleanup whose path is variable-built or whose setup/teardown spans multiple steps: author the helper file and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely and never depends on which auto-allow path matches.\n' +
38
- '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
38
+ '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n' +
39
+ 'WAITS AND POLLS — foreground sleep is blocked in this headless harness: a bare Bash "sleep N" or a PowerShell "Start-Sleep" is denied, and a wait you move to a background process — then end your turn to await it — never resumes, because a schema-bearing agent runs for a single turn. Therefore:\n' +
40
+ '- Perform every required delay or poll-interval wait inside this same turn by pairing the Monitor tool with a bounded until-loop: the Monitor tool streams its events to you while you keep working, and the until-loop re-runs the step condition on the interval the step names, up to the attempt budget the step names, exiting the moment the condition holds or the budget is spent — consume the Monitor notifications as they arrive rather than ending your turn to await them. Never end your turn to wait for something to finish.\n' +
41
+ '- 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' +
42
+ '- 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'
39
43
 
40
44
  let activeRepoPath = null
41
45
 
@@ -102,7 +106,7 @@ function runGitTask(task, head) {
102
106
  `Report whether ${prCoordinates} (HEAD ${head}) has merge conflicts with its base branch. Do not edit, commit, push, or rebase — read only.\n\n` +
103
107
  `GitHub computes mergeability asynchronously, so .mergeable is null right after a push until it finishes. Poll until it resolves: run\n` +
104
108
  ` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq '{mergeable: .mergeable, state: .mergeable_state}'\n` +
105
- `up to 5 times, 5 seconds apart (delay each retry with "sleep 5", or the PowerShell alternative "Start-Sleep -Seconds 5"), stopping as soon as mergeable is true or false.\n\n` +
109
+ `up to 5 times, 5 seconds apart (wait each 5-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above), stopping as soon as mergeable is true or false.\n\n` +
106
110
  `Return conflicting:true when mergeable is false or state is "dirty" (the branch conflicts with the base). Return conflicting:false when mergeable is true, or when mergeable stays null after the full poll budget — mergeability is unknown, so let the bug checks proceed rather than rebase on a guess.`,
107
111
  { label: 'git-utility', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },
108
112
  )
@@ -200,11 +204,12 @@ async function fixerWithRecovery(head, findings, sourceLabel) {
200
204
 
201
205
  /**
202
206
  * Spawn a fresh code-editor clean-coder agent for a specific edit task (fix-edit,
203
- * conflict-edit, repair-edit, repair-commit, standards-edit, hardening-commit,
204
- * commit-recover, verify-recover). Each task carries its own edit instructions.
207
+ * conflict-edit, repair-edit, repair-commit, standards-edit,
208
+ * standards-resolve-threads, hardening-commit, commit-recover, verify-recover).
209
+ * Each task carries its own edit instructions.
205
210
  * @param {string} task the short task name
206
211
  * @param {object} context task-specific context
207
- * @returns {Promise<object|string>} the structured output, or the transcript string when the 'hardening-commit' resume runs schema-less
212
+ * @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('standards-resolve-threads') runs
208
213
  */
209
214
  function runCodeEditorTask(task, context) {
210
215
  const label = `code-editor:${task}`
@@ -293,14 +298,28 @@ function runCodeEditorTask(task, context) {
293
298
  { label, phase: 'Converge', schema: STANDARDS_EDIT_SCHEMA, agentType: 'clean-coder' },
294
299
  )
295
300
  }
301
+ if (task === 'standards-resolve-threads') {
302
+ const findingsBlock = renderFindingsBlock(context.findings)
303
+ const threadIds = context.findings
304
+ .flatMap((each) => collectFindingThreadIds(each))
305
+ .filter((each) => typeof each === 'number')
306
+ return convergeAgent(
307
+ `You are the THREAD-RESOLUTION step for a code-standard-only round on ${prCoordinates}, HEAD ${context.head} (${context.sourceLabel}). This run already filed the deferred-fix follow-up issue ${context.issueUrl}, so this batch's code-standard findings defer to that same issue. Make NO code edits, NO commit, and NO push — only reply to and resolve the review threads this batch carries.\n\n` +
308
+ `Findings:\n${findingsBlock}\n\n` +
309
+ `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 ${context.issueUrl}." 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` +
310
+ `Return a one-line summary naming the threads you resolved.`,
311
+ { label, phase: 'Converge', agentType: 'clean-coder' },
312
+ )
313
+ }
296
314
  if (task === 'hardening-commit') {
297
315
  return convergeAgent(
298
316
  `You are the COMMIT step opening the environment-hardening PR (${context.sourceLabel}) for the change staged in ${context.hardeningRepoPath} on branch ${context.hardeningBranch}. The edit step left the hooks/rules edits in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree. Do NOT touch the PR's own branch.\n\n` +
299
317
  `Rules:\n` +
300
318
  `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Only commit and push what is already there.\n` +
301
- `- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n\n` +
302
- `Return a one-line summary naming the hardening PR URL.`,
303
- { label, phase: 'Converge', agentType: 'clean-coder' },
319
+ `- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n` +
320
+ `- Title the PR as a Conventional Commit — a type prefix (feat, fix, chore, docs, refactor, perf, ci, style, test, build, revert), an optional scope in parentheses, then a colon and a short summary, e.g. "feat(hooks): block the deferred violation class". The target repo's CI validates the PR title as a semantic commit and rejects a non-conforming title.\n\n` +
321
+ `Return the full https URL of the DRAFT hardening PR in hardeningPrUrl (empty string when no PR was opened) and a one-line summary.`,
322
+ { label, phase: 'Converge', schema: HARDENING_COMMIT_SCHEMA, agentType: 'clean-coder' },
304
323
  )
305
324
  }
306
325
  if (task === 'commit-recover') {
@@ -569,6 +588,16 @@ const STANDARDS_EDIT_SCHEMA = {
569
588
  required: ['issueUrl', 'hardeningRepoPath', 'hardeningBranch', 'hardeningEdited', 'summary'],
570
589
  }
571
590
 
591
+ const HARDENING_COMMIT_SCHEMA = {
592
+ type: 'object',
593
+ additionalProperties: false,
594
+ properties: {
595
+ hardeningPrUrl: { type: 'string', description: 'the full https URL of the DRAFT environment-hardening PR the commit step opened, or an empty string when no PR was opened' },
596
+ summary: { type: 'string' },
597
+ },
598
+ required: ['hardeningPrUrl', 'summary'],
599
+ }
600
+
572
601
  /**
573
602
  * Build the verdict-fence step instructions for a verify agent, binding the
574
603
  * surface hash by branch name rather than by a self-resolved cwd. Resolving
@@ -1091,7 +1120,7 @@ function runBugbotLens(head) {
1091
1120
  ` Only count entries whose commit_id starts with ${head}.\n` +
1092
1121
  ` - If findings exist on HEAD -> return them (each with its inline comment id in replyToCommentId when present, else null).\n` +
1093
1122
  ` - If a clean review exists on HEAD -> return clean.\n` +
1094
- `4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (delay each iteration with "sleep 60", or the PowerShell alternative "Start-Sleep -Seconds 60") for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", delay 8 seconds with "sleep 8" (PowerShell alternative "Start-Sleep -Seconds 8"), then poll as above.\n` +
1123
+ `4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (wait each 60-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above) for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", wait 8 seconds inside this turn with the Monitor tool (per the WAITS AND POLLS rule above), then poll as above.\n` +
1095
1124
  `5. If after the full poll budget Bugbot has neither a check run nor a review on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} (treat as down).\n\n` +
1096
1125
  `Scope is the whole PR; you are only reading Bugbot's own output here. For each finding set category: 'code-standard' when it is a pure CODE_RULES/style violation (naming, comments, type hints, magic values, structure) with no behavioral impact; 'bug' otherwise. Return strictly the schema.`,
1097
1126
  { label: 'lens:bugbot', phase: 'Converge', schema: LENS_SCHEMA },
@@ -1286,9 +1315,9 @@ function runCopilotGate(head) {
1286
1315
  `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` +
1287
1316
  `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` +
1288
1317
  ` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
1289
- `2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (delay each attempt with "sleep 360", or the PowerShell alternative "Start-Sleep -Seconds 360"). 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` +
1318
+ `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 down: true). 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` +
1290
1319
  ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
1291
- ` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
1320
+ ` - 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` +
1292
1321
  ` - 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` +
1293
1322
  ` - 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` +
1294
1323
  `Return strictly the schema.`,
@@ -1391,19 +1420,54 @@ function isStandardsOnlyRound(findings) {
1391
1420
  return findings.length > 0 && findings.every((each) => each.category === 'code-standard')
1392
1421
  }
1393
1422
 
1423
+ /**
1424
+ * Decide whether a standards-only round should file the follow-up fix issue and
1425
+ * open the environment-hardening PR. Standards findings are deferred rather than
1426
+ * fixed on this PR, so the same code-standard findings re-surface on every
1427
+ * converge round and on the Copilot gate; without this guard each re-entry files
1428
+ * a fresh duplicate follow-up issue and hardening PR for the one deferred finding
1429
+ * class. The follow-up issue is filed once per convergence run — a round whose
1430
+ * filing succeeds latches the guard, while a round whose filing failed leaves it
1431
+ * clear so a later round retries the filing.
1432
+ * @param {boolean} hasAlreadyFiled true when an earlier standards-only round in this run already filed the follow-up issue
1433
+ * @returns {boolean} true when this round should attempt the follow-up filing
1434
+ */
1435
+ function shouldOpenStandardsFollowUp(hasAlreadyFiled) {
1436
+ return hasAlreadyFiled !== true
1437
+ }
1438
+
1394
1439
  /**
1395
1440
  * Build the standards-deferral note for the closing report, naming the
1396
- * environment-hardening PR only when one was opened this round so the note
1441
+ * environment-hardening PR only when one was opened for this run so the note
1397
1442
  * never claims a PR the skip paths did not produce.
1398
1443
  * @param {number} findingsCount count of deferred code-standard findings
1399
- * @param {boolean} hardeningPrOpened true when the hardening PR was opened this round
1444
+ * @param {boolean} wasHardeningPrOpened true when the hardening PR was opened for this run
1400
1445
  * @returns {string} the human-facing deferral note
1401
1446
  */
1402
- function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1447
+ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
1403
1448
  const base = `${findingsCount} code-standard finding(s) deferred to a follow-up fix issue`
1404
- return hardeningPrOpened
1449
+ return wasHardeningPrOpened
1405
1450
  ? `${base} plus an environment-hardening PR — verify both land`
1406
- : `${base} — verify it lands (no environment-hardening PR was opened this round)`
1451
+ : `${base} — verify it lands (no environment-hardening PR was opened for this run)`
1452
+ }
1453
+
1454
+ /**
1455
+ * Parse a GitHub pull-request URL into the owner, repo, and number a recursive
1456
+ * converge run needs to address it.
1457
+ *
1458
+ * A hardening PR the commit step opens returns its URL as
1459
+ * `https://github.com/<owner>/<repo>/pull/<number>`; this reads those three
1460
+ * coordinates back out so the self-closing orchestrator can converge the
1461
+ * deferred PR in turn. A blank or non-matching string yields null, so a commit
1462
+ * step that opened no PR contributes no deferred coordinate.
1463
+ * @param {string} prUrl the hardening PR's https URL, or an empty string
1464
+ * @returns {{owner: string, repo: string, prNumber: number}|null} the parsed coordinates, or null when the URL does not match
1465
+ */
1466
+ function parseDeferredPr(prUrl) {
1467
+ if (typeof prUrl !== 'string') return null
1468
+ const match = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?(?:[?#].*)?$/)
1469
+ if (!match) return null
1470
+ return { owner: match[1], repo: match[2], prNumber: Number(match[3]) }
1407
1471
  }
1408
1472
 
1409
1473
  /**
@@ -1414,29 +1478,104 @@ function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1414
1478
  * commit (clean-coder makes one commit, pushes, and opens the DRAFT hardening
1415
1479
  * PR). Splitting the edit from the push lets a workflow code-verifier produce the
1416
1480
  * verdict the verified-commit gate requires for the cross-repo hardening commit.
1417
- * This PR's own branch is never touched. When the edit staged no hardening, or
1418
- * the verify step fails, the follow-up issue still stands and the commit step is
1419
- * skipped.
1481
+ * This PR's own branch is never touched. When a hardening PR already opened for
1482
+ * this run, the edit staged no hardening, or the verify step fails, the follow-up
1483
+ * issue still stands and the commit step is skipped.
1420
1484
  * @param {string} head PR HEAD SHA the findings were raised against
1421
1485
  * @param {Array<object>} findings deduped code-standard-only findings
1422
1486
  * @param {string} sourceLabel short description of where the findings came from
1423
- * @returns {Promise<object>} `{ hardeningPrOpened }` true only when the hardening PR was opened this round
1487
+ * @param {boolean} hasHardeningPrAlreadyOpened true when an earlier round already opened the environment-hardening PR for this run, so the verify and commit steps are skipped and no second PR opens while the edit retries the issue filing
1488
+ * @returns {Promise<object>} `{ followUpIssueFiled, issueUrl, hardeningPrOpened, deferredPr }` — followUpIssueFiled true when the standards-edit step returned a non-empty issue URL, issueUrl that filed URL (empty string when the filing failed) so a later reuse-path round can reference it when resolving its own threads, hardeningPrOpened true when the hardening-commit step returned a non-empty hardeningPrUrl (a PR opened) so the run-once latch holds even when that URL does not parse into coordinates, and false when the commit step returned an empty URL (no PR opened) so a later round retries the open, and deferredPr the opened PR's `{owner, repo, prNumber}` (null when no PR was opened or the committed URL does not parse) so the self-closing orchestrator can converge it in turn
1424
1489
  */
1425
- async function spawnStandardsFollowUp(head, findings, sourceLabel) {
1490
+ async function spawnStandardsFollowUp(head, findings, sourceLabel, hasHardeningPrAlreadyOpened) {
1426
1491
  const editResult = await runCodeEditorTask('standards-edit', { head, findings, sourceLabel })
1492
+ const followUpIssueFiled = typeof editResult?.issueUrl === 'string' && editResult.issueUrl.length > 0
1493
+ const followUpIssueUrl = followUpIssueFiled ? editResult.issueUrl : ''
1494
+ if (hasHardeningPrAlreadyOpened === true) {
1495
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1496
+ }
1427
1497
  if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
1428
- return { hardeningPrOpened: false }
1498
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1429
1499
  }
1430
1500
  const verifyTranscript = await runVerifierTask('hardening-verify', {
1431
1501
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch,
1432
1502
  })
1433
1503
  if (!verdictPassed(verifyTranscript)) {
1434
- return { hardeningPrOpened: false }
1504
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1435
1505
  }
1436
- await runCodeEditorTask('hardening-commit', {
1506
+ const commitResult = await runCodeEditorTask('hardening-commit', {
1437
1507
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch, issueUrl: editResult.issueUrl,
1438
1508
  })
1439
- return { hardeningPrOpened: true }
1509
+ const deferredPr = parseDeferredPr(commitResult?.hardeningPrUrl)
1510
+ const hardeningPrOpened = typeof commitResult?.hardeningPrUrl === 'string' && commitResult.hardeningPrUrl.length > 0
1511
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened, deferredPr }
1512
+ }
1513
+
1514
+ /**
1515
+ * Report whether any finding in a batch carries a GitHub review thread that needs
1516
+ * an inline reply and resolution.
1517
+ * @param {Array<object>} findings the batch of findings
1518
+ * @returns {boolean} true when at least one finding carries a review comment id
1519
+ */
1520
+ function findingsCarryThreads(findings) {
1521
+ return findings.some((each) => collectFindingThreadIds(each).length > 0)
1522
+ }
1523
+
1524
+ /**
1525
+ * On the reuse path — after this run already filed the deferred-fix follow-up
1526
+ * issue — reply to and resolve this batch's code-standard review threads against
1527
+ * that same issue. The issue filing and hardening PR stay gated behind the
1528
+ * run-once flags; only the per-batch thread resolution runs here, so a later
1529
+ * standards-only round's bot threads are still marked resolved and the FINALIZE
1530
+ * zero-unresolved-bot-threads gate passes. A batch of in-memory audit findings
1531
+ * that carries no review thread needs no agent, so the resolve step is skipped.
1532
+ * @param {string} head PR HEAD SHA the findings were raised against
1533
+ * @param {Array<object>} findings this batch's deduped code-standard-only findings
1534
+ * @param {string} sourceLabel short description of where the findings came from
1535
+ * @returns {Promise<void>}
1536
+ */
1537
+ async function resolveStandardsThreadsForBatch(head, findings, sourceLabel) {
1538
+ if (!findingsCarryThreads(findings)) {
1539
+ return
1540
+ }
1541
+ await runCodeEditorTask('standards-resolve-threads', {
1542
+ head, findings, sourceLabel, issueUrl: standardsFollowUpIssueUrl,
1543
+ })
1544
+ }
1545
+
1546
+ /**
1547
+ * File the deferred follow-up issue and open the environment-hardening PR at most
1548
+ * once per convergence run, then reuse them. The two guards latch independently:
1549
+ * the follow-up issue latches only when its filing succeeds, and the hardening PR
1550
+ * latches the moment one opens and never re-opens. A standards-only round whose
1551
+ * issue filing already succeeded — in the converge phase or at the Copilot gate —
1552
+ * skips spawnStandardsFollowUp, resolves this batch's own code-standard review
1553
+ * threads against the already-filed issue via resolveStandardsThreadsForBatch, and
1554
+ * returns the remembered hardening outcome. A round whose issue filing failed
1555
+ * re-runs spawnStandardsFollowUp to retry the filing, passing the remembered
1556
+ * hardening state so an already-opened hardening PR is never re-opened. The run
1557
+ * therefore files one follow-up issue and opens one hardening PR rather than a
1558
+ * fresh duplicate per re-entry — while every round still resolves the review
1559
+ * threads its own findings carry, even when the filing must retry after the
1560
+ * hardening PR has opened.
1561
+ * @param {string} head PR HEAD SHA the findings were raised against
1562
+ * @param {Array<object>} findings deduped code-standard-only findings
1563
+ * @param {string} sourceLabel short description of where the findings came from
1564
+ * @returns {Promise<object>} `{ hardeningPrOpened, deferredPr }` — hardeningPrOpened true when a hardening PR was opened for this run, and deferredPr the opened PR's `{owner, repo, prNumber}` when this call opened it (null otherwise) so the self-closing orchestrator can converge it in turn
1565
+ */
1566
+ async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
1567
+ if (!shouldOpenStandardsFollowUp(hasStandardsFollowUpFiled)) {
1568
+ log(`Standards deferral (${sourceLabel}): reusing the follow-up fix issue already filed for this run rather than filing a duplicate; environment-hardening PR ${wasStandardsHardeningPrOpened ? 'was opened for this run' : 'was not opened for this run'}`)
1569
+ await resolveStandardsThreadsForBatch(head, findings, sourceLabel)
1570
+ return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: null }
1571
+ }
1572
+ const standardsOutcome = await spawnStandardsFollowUp(head, findings, sourceLabel, wasStandardsHardeningPrOpened)
1573
+ hasStandardsFollowUpFiled = standardsOutcome?.followUpIssueFiled === true
1574
+ if (hasStandardsFollowUpFiled) {
1575
+ standardsFollowUpIssueUrl = standardsOutcome.issueUrl
1576
+ }
1577
+ wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened || standardsOutcome?.hardeningPrOpened === true
1578
+ return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: standardsOutcome?.deferredPr ?? null }
1440
1579
  }
1441
1580
 
1442
1581
  let phase = 'CONVERGE'
@@ -1448,7 +1587,11 @@ let bugbotDown = input.bugbotDisabled || false
1448
1587
  let copilotDown = input.copilotDisabled || false
1449
1588
  let copilotNote = null
1450
1589
  let standardsNote = null
1590
+ let hasStandardsFollowUpFiled = false
1591
+ let wasStandardsHardeningPrOpened = false
1592
+ let standardsFollowUpIssueUrl = ''
1451
1593
  let reuseNote = null
1594
+ const deferredPrs = []
1452
1595
 
1453
1596
  const preflightHead = await runGitTask('resolve-head')
1454
1597
  if (isResolvedHeadUsable(preflightHead?.sha)) {
@@ -1501,8 +1644,9 @@ while (iterations < CONFIG.maxIterations) {
1501
1644
  const findings = roundOutcome.findings
1502
1645
  if (isStandardsOnlyRound(findings)) {
1503
1646
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
1504
- const standardsOutcome = await spawnStandardsFollowUp(head, findings, 'converge-round')
1505
- standardsNote = standardsDeferralNote(findings.length, standardsOutcome?.hardeningPrOpened === true)
1647
+ const standardsOutcome = await openStandardsFollowUpOnce(head, findings, 'converge-round')
1648
+ standardsNote = standardsDeferralNote(findings.length, standardsOutcome.hardeningPrOpened)
1649
+ if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
1506
1650
  const auditResult = await runGeneralUtilityTask('post-clean-audit', { head })
1507
1651
  if (!auditResult?.posted) {
1508
1652
  blocker = cleanAuditBlocker(head, auditResult)
@@ -1564,8 +1708,9 @@ while (iterations < CONFIG.maxIterations) {
1564
1708
  if (copilotOutcome.kind === 'fix') {
1565
1709
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
1566
1710
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
1567
- const standardsOutcome = await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
1568
- standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome?.hardeningPrOpened === true)
1711
+ const standardsOutcome = await openStandardsFollowUpOnce(head, copilotOutcome.findings, 'copilot')
1712
+ standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome.hardeningPrOpened)
1713
+ if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
1569
1714
  copilotDown = false
1570
1715
  copilotNote = null
1571
1716
  phase = 'FINALIZE'
@@ -1601,7 +1746,7 @@ while (iterations < CONFIG.maxIterations) {
1601
1746
  const readyResult = await runGeneralUtilityTask('mark-ready', { head, copilotDown })
1602
1747
  const readyOutcome = classifyReadyOutcome(readyResult)
1603
1748
  if (readyOutcome.converged) {
1604
- return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote }
1749
+ return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote, deferredPrs }
1605
1750
  }
1606
1751
  blocker = readyOutcome.blocker
1607
1752
  break
@@ -1621,4 +1766,5 @@ return {
1621
1766
  standardsNote,
1622
1767
  copilotNote,
1623
1768
  reuseNote,
1769
+ deferredPrs,
1624
1770
  }
@@ -126,7 +126,7 @@ function childRunInput(prEntry) {
126
126
 
127
127
  const multiInput = classifyMultiInput(args)
128
128
  if (multiInput.blocker) {
129
- return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
129
+ return { converged: false, prCount: 0, convergedCount: 0, results: [], allDeferredPrs: [], blocker: multiInput.blocker }
130
130
  }
131
131
  const input = multiInput.input
132
132
 
@@ -147,6 +147,7 @@ const childResults = await parallel(
147
147
  rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
148
148
  finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
149
149
  blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
150
+ deferredPrs: childOutcome && Array.isArray(childOutcome.deferredPrs) ? childOutcome.deferredPrs : [],
150
151
  }
151
152
  }),
152
153
  )
@@ -161,17 +162,20 @@ const results = childResults.map((eachResult, eachIndex) =>
161
162
  rounds: null,
162
163
  finalSha: null,
163
164
  blocker: 'child run threw or was skipped before returning an outcome',
165
+ deferredPrs: [],
164
166
  }
165
167
  : eachResult,
166
168
  )
167
169
 
168
170
  const convergedCount = results.filter((eachResult) => eachResult.converged).length
169
- log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged`)
171
+ const allDeferredPrs = results.flatMap((eachResult) => eachResult.deferredPrs)
172
+ log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged, ${allDeferredPrs.length} deferred hardening PR(s) opened`)
170
173
 
171
174
  return {
172
175
  converged: convergedCount === results.length,
173
176
  prCount: results.length,
174
177
  convergedCount,
175
178
  results,
179
+ allDeferredPrs,
176
180
  blocker: null,
177
181
  }
@@ -21,6 +21,19 @@ const productionModule = new Function(
21
21
  )();
22
22
  const { normalizeMultiInput, classifyMultiInput, childRunInput } = productionModule;
23
23
 
24
+ const AsyncFunction = async function () {}.constructor;
25
+ const workflowBodySource = multiSource.slice(multiSource.indexOf('function normalizeMultiInput('));
26
+ const runMultiWorkflowBody = new AsyncFunction(
27
+ 'args',
28
+ 'phase',
29
+ 'log',
30
+ 'parallel',
31
+ 'workflow',
32
+ workflowBodySource,
33
+ );
34
+
35
+ function ignoreCall() {}
36
+
24
37
  const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
25
38
 
26
39
  function validEntry(prNumber) {
@@ -124,3 +137,16 @@ test('childRunInput forwards bugbotDisabled true when the entry opts out', () =>
124
137
  test('childRunInput defaults bugbotDisabled to false when the entry omits it', () => {
125
138
  assert.equal(childRunInput(validEntry(398)).bugbotDisabled, false);
126
139
  });
140
+
141
+ test('the malformed-input blocker return carries an empty allDeferredPrs list', async () => {
142
+ const blockerOutcome = await runMultiWorkflowBody(
143
+ 'not json at all',
144
+ ignoreCall,
145
+ ignoreCall,
146
+ ignoreCall,
147
+ ignoreCall,
148
+ );
149
+ assert.notEqual(blockerOutcome.blocker, null);
150
+ assert.equal(blockerOutcome.prCount, 0);
151
+ assert.deepEqual(blockerOutcome.allDeferredPrs, []);
152
+ });