claude-dev-env 1.71.0 → 1.73.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 (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -14,6 +14,7 @@ export const meta = {
14
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.',
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
+ { 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' },
17
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' },
18
19
  { title: 'Copilot gate', detail: 'Request Copilot review and poll up to three times; route findings back into Converge; when Copilot is down or out of quota, log a notice and mark the PR ready with the gate bypassed' },
19
20
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
@@ -35,16 +36,45 @@ const HEADLESS_SAFETY_PREAMBLE =
35
36
  '- Keep scratch files and cleanup inside the OS temp dir or $CLAUDE_JOB_DIR/tmp (auto-allowed as ephemeral); never target a repository or worktree path with rm -rf.\n' +
36
37
  '- 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'
37
38
 
39
+ let activeRepoPath = null
40
+
41
+ /**
42
+ * Build the per-agent worktree directive for a path-scoped run.
43
+ *
44
+ * A multi-PR parent run drives several converge children from one shared
45
+ * working directory, so each child pins its own agents to the worktree its PR
46
+ * is checked out in; without that pin every child's git, gh, diff, edit,
47
+ * commit, and test commands would run in the shared launch directory rather
48
+ * than the PR's own checkout. The parent hands the worktree path in as
49
+ * input.repoPath, which sets activeRepoPath. A single-PR run carries no
50
+ * repoPath, so this returns an empty string and every agent keeps its own
51
+ * working directory — behavior identical to a run with no path scoping.
52
+ * @param {string|null} repoPath the PR worktree absolute path, or null for the single-PR default
53
+ * @returns {string} the worktree directive to prepend, or an empty string when repoPath is null
54
+ */
55
+ const worktreeDirective = (repoPath) =>
56
+ repoPath
57
+ ? `WORKTREE — this PR is checked out at ${repoPath}. Unless a step explicitly names a different repository directory (for example an environment-hardening repo checkout, which you cd into exactly as that step directs), run every git, gh, diff, edit, commit, push, and test command for this PR in that worktree: cd "${repoPath}" before any such command, and resolve repository roots from there.\n\n`
58
+ : ''
59
+
38
60
  /**
39
61
  * Spawn a workflow agent with the headless-safety preamble prepended to its
40
62
  * prompt. Every agent in this convergence loop runs unattended, so each one is
41
- * routed through here to inherit the same no-confirmation-prompt guidance.
63
+ * routed through here to inherit the same no-confirmation-prompt guidance. On a
64
+ * path-scoped run the worktree directive is prepended too, so every agent runs
65
+ * in the PR's own worktree (activeRepoPath); on a single-PR run that directive
66
+ * is empty and the agent keeps its own working directory.
42
67
  * @param {string} prompt the agent's role-specific instruction body
43
68
  * @param {object} options the agent() options (label, phase, schema, agentType, model)
44
69
  * @returns {Promise<*>} the agent() result
45
70
  */
46
71
  const convergeAgent = (prompt, options) =>
47
- agent(`${HEADLESS_SAFETY_PREAMBLE}${prompt}`, options)
72
+ agent(`${HEADLESS_SAFETY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
73
+
74
+ const PRE_COMMIT_GATE_STEP =
75
+ `\n\nFINAL STEP — pre-commit gate check (do NOT commit): before your turn ends, prove your working-tree changes CAN be committed by dry-running the CODE_RULES commit gate that gates git commit (precommit_code_rules_gate). From inside the checkout that holds your changes, resolve its root with git rev-parse --show-toplevel, stage your changes with git add -A, then run exactly:\n` +
76
+ ` python "${CONFIG.prLoopScripts}/code_rules_gate.py" --repo-root "<that root>" --staged\n` +
77
+ `Exit 0 means the commit gate would accept the commit. On any non-zero exit, read every violation it prints, fix each one test-first per CODE_RULES, and re-run the gate until it exits 0. Then unstage with git restore --staged . so the verify step reads the working-tree diff. Make NO git commit and NO git push here — this is a dry committability check; the separate verify and commit steps run after you, and the verified-commit gate is their job, not yours. Your turn does not end while the commit gate would reject the commit.`
48
78
 
49
79
  const LENS_SCHEMA = {
50
80
  type: 'object',
@@ -130,6 +160,28 @@ const REPAIR_EDIT_SCHEMA = {
130
160
  required: ['edited', 'rebased', 'resolvedWithoutCommit', 'summary'],
131
161
  }
132
162
 
163
+ const MERGE_CONFLICT_SCHEMA = {
164
+ type: 'object',
165
+ additionalProperties: false,
166
+ properties: {
167
+ conflicting: {
168
+ type: 'boolean',
169
+ description: 'true only when GitHub reports the PR branch conflicts with its base (mergeable:false or mergeable_state:dirty); false when it merges cleanly or mergeability could not be computed',
170
+ },
171
+ },
172
+ required: ['conflicting'],
173
+ }
174
+
175
+ const CONFLICT_EDIT_SCHEMA = {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ properties: {
179
+ rebased: { type: 'boolean', description: 'true when the branch was rebased onto origin/main and every conflict resolved in the working tree' },
180
+ summary: { type: 'string' },
181
+ },
182
+ required: ['rebased', 'summary'],
183
+ }
184
+
133
185
  const STANDARDS_EDIT_SCHEMA = {
134
186
  type: 'object',
135
187
  additionalProperties: false,
@@ -529,6 +581,19 @@ function isResolvedHeadUsable(resolvedHead) {
529
581
  return typeof resolvedHead === 'string' && resolvedHead.length > 0
530
582
  }
531
583
 
584
+ /**
585
+ * Decide whether the pre-flight merge-conflict check found the PR branch in
586
+ * conflict with its base. A dead check agent (null/undefined result) reports
587
+ * not-conflicting so the run proceeds straight to the bug checks rather than
588
+ * force-pushing a rebase on a verdict that does not exist — a transient check
589
+ * failure must never trigger a destructive rebase.
590
+ * @param {object|null|undefined} mergeState the checkMergeConflicts result
591
+ * @returns {boolean} true only when the check reported conflicting:true
592
+ */
593
+ function isMergeConflicting(mergeState) {
594
+ return mergeState != null && mergeState.conflicting === true
595
+ }
596
+
532
597
  /**
533
598
  * Decide whether the mark-ready step actually cleared the draft state. The run
534
599
  * reports converged only when the mark-ready agent confirms ready:true; a dead
@@ -655,6 +720,7 @@ if (runInput.blocker) {
655
720
  return { converged: false, rounds: 0, finalSha: null, blocker: runInput.blocker }
656
721
  }
657
722
  const input = runInput.input
723
+ activeRepoPath = typeof input.repoPath === 'string' && input.repoPath ? input.repoPath : null
658
724
  const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNumber} (https://github.com/${input.owner}/${input.repo}/pull/${input.prNumber})`
659
725
 
660
726
  /**
@@ -749,6 +815,29 @@ function runAuditLens(head) {
749
815
  )
750
816
  }
751
817
 
818
+ /**
819
+ * Reuse lens: a one-time pre-convergence pass that scans the full diff for
820
+ * places the PR re-implements behavior the codebase already provides, and
821
+ * returns only the reuse improvements that are certain, behaviorally identical,
822
+ * and autonomously implementable. It reports findings without editing; the
823
+ * reuse pass routes the qualifying findings through applyFixes so they are
824
+ * implemented in one commit before the convergence rounds begin.
825
+ * @param {string} head PR HEAD SHA to evaluate
826
+ * @returns {Promise<object>} LENS_SCHEMA result carrying the qualifying reuse findings
827
+ */
828
+ function runReuseAuditPass(head) {
829
+ return convergeAgent(
830
+ `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` +
831
+ `Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT delta-scope to recent commits or a single file. The workflow already fetched origin/main, so do NOT run git fetch; run git diff --name-only origin/main...HEAD to enumerate the changed files, then read the complete diff of each. For every new function, helper, constant, type, or block of logic the PR introduces, search the repository (Serena symbol search, grep, and the project's config/ and shared/ modules) for an existing equivalent that already provides the same behavior.\n\n` +
832
+ `Report a reuse finding ONLY when ALL THREE criteria hold — when any one is in doubt, omit the finding:\n` +
833
+ ` A. CERTAIN: an existing symbol or module unquestionably covers the new code's behavior, and you can cite it at file:line.\n` +
834
+ ` B. BEHAVIORALLY IDENTICAL: replacing the new code with the existing one changes no observable behavior — same inputs, outputs, side effects, and error handling.\n` +
835
+ ` C. AUTONOMOUSLY IMPLEMENTABLE: the replacement is a mechanical edit (import and call the existing symbol, delete the duplicate) that needs no product decision, no API the existing code lacks, and no human judgment.\n\n` +
836
+ `Do NOT edit, commit, or push — report only; a separate fix step applies what you return. Return strictly the schema: clean=true with empty findings when no reuse case clears all three criteria, otherwise one entry per qualifying reuse improvement. For each: file and line of the duplicate in the PR; severity P2; category 'code-standard'; title naming the existing symbol to reuse; detail giving the existing symbol's file:line and the exact mechanical replacement; replyToCommentId=null. Set sha=${'`'}${head}${'`'}, down=false.`,
837
+ { label: 'lens:reuse', phase: 'Reuse', schema: LENS_SCHEMA, agentType: 'code-quality-agent' },
838
+ )
839
+ }
840
+
752
841
  /**
753
842
  * Render the numbered findings block shared by the fix steps.
754
843
  * @param {Array<object>} findings deduped findings to render
@@ -792,7 +881,8 @@ function applyFixesEdit(head, findings, sourceLabel) {
792
881
  `Return values:\n` +
793
882
  `- When you edited code to fix at least one finding: edited=true, resolvedWithoutCommit=false.\n` +
794
883
  `- When every finding was already addressed so no code change was needed — yet you still resolved each GitHub review thread above: edited=false, resolvedWithoutCommit=true. Only set this when every thread that carries a comment id is resolved; otherwise the round is treated as stalled.\n` +
795
- `Always include a one-line summary.`,
884
+ `Always include a one-line summary.` +
885
+ PRE_COMMIT_GATE_STEP,
796
886
  { label: `fix-edit:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
797
887
  )
798
888
  }
@@ -865,7 +955,8 @@ function recoverCommitBlockEdit(head, blockerDetail, sourceLabel, attempt) {
865
955
  `- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
866
956
  `- Fix ONLY the violation named above, test-first (failing test, then minimum code to pass) per CODE_RULES. Do not re-open the original findings, and do not touch GitHub review threads — the edit step already handled those.\n` +
867
957
  `- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
868
- `Return values: edited=true with a one-line summary when you changed code to clear the block; edited=false, resolvedWithoutCommit=false when the block cannot be cleared with a code change.`,
958
+ `Return values: edited=true with a one-line summary when you changed code to clear the block; edited=false, resolvedWithoutCommit=false when the block cannot be cleared with a code change.` +
959
+ PRE_COMMIT_GATE_STEP,
869
960
  { label: `fix-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
870
961
  )
871
962
  }
@@ -891,7 +982,8 @@ function recoverVerifyFailEdit(head, objection, sourceLabel, attempt) {
891
982
  `- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
892
983
  `- Address every objection above test-first (failing test, then minimum code to pass) per CODE_RULES, so each named concern is genuinely resolved the way the verdict requires. Do not touch GitHub review threads — the edit step already handled those.\n` +
893
984
  `- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
894
- `Return values: edited=true with a one-line summary when you changed code to address the objections; edited=false, resolvedWithoutCommit=false when the objections cannot be cleared with a code change.`,
985
+ `Return values: edited=true with a one-line summary when you changed code to address the objections; edited=false, resolvedWithoutCommit=false when the objections cannot be cleared with a code change.` +
986
+ PRE_COMMIT_GATE_STEP,
895
987
  { label: `fix-verify-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
896
988
  )
897
989
  }
@@ -1130,7 +1222,8 @@ function repairConvergenceEdit(head, failures) {
1130
1222
  `- edited=true when you changed code in the working tree to fix a bot-thread concern.\n` +
1131
1223
  `- rebased=true when you rebased the branch onto origin/main.\n` +
1132
1224
  `- resolvedWithoutCommit=true only when you addressed the gates with neither a code change nor a rebase (bot threads resolved only), so there is nothing for the commit step to push.\n` +
1133
- `Always include a one-line summary.`,
1225
+ `Always include a one-line summary.` +
1226
+ PRE_COMMIT_GATE_STEP,
1134
1227
  { label: 'repair-edit', phase: 'Finalize', schema: REPAIR_EDIT_SCHEMA, agentType: 'clean-coder' },
1135
1228
  )
1136
1229
  }
@@ -1235,6 +1328,79 @@ async function repairConvergence(head, failures) {
1235
1328
  })
1236
1329
  }
1237
1330
 
1331
+ /**
1332
+ * Pre-flight merge-conflict check: ask GitHub whether the PR branch still merges
1333
+ * cleanly into its base. GitHub computes mergeability asynchronously, so the
1334
+ * agent polls until .mergeable resolves to a boolean before judging. Read-only —
1335
+ * it makes no edit, commit, push, or rebase.
1336
+ * @param {string} head PR HEAD SHA the check runs against
1337
+ * @returns {Promise<object>} MERGE_CONFLICT_SCHEMA result
1338
+ */
1339
+ function checkMergeConflicts(head) {
1340
+ return convergeAgent(
1341
+ `Report whether ${prCoordinates} (HEAD ${head}) has merge conflicts with its base branch. Do not edit, commit, push, or rebase — read only.\n\n` +
1342
+ `GitHub computes mergeability asynchronously, so .mergeable is null right after a push until it finishes. Poll until it resolves: run\n` +
1343
+ ` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq '{mergeable: .mergeable, state: .mergeable_state}'\n` +
1344
+ `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` +
1345
+ `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.`,
1346
+ { label: 'check-merge-conflicts', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },
1347
+ )
1348
+ }
1349
+
1350
+ /**
1351
+ * Conflict-resolution edit step: one clean-coder rebases the PR branch onto
1352
+ * origin/main and resolves every conflict in the working tree, making NO push —
1353
+ * the verify step binds a verdict to the rebased tree before the commit step
1354
+ * force-pushes it. A rebase necessarily creates local commits, which is expected;
1355
+ * only the force-push is withheld so the verifier binds the surface first.
1356
+ * @param {string} head PR HEAD SHA before the rebase
1357
+ * @returns {Promise<object>} CONFLICT_EDIT_SCHEMA result
1358
+ */
1359
+ function resolveConflictsEdit(head) {
1360
+ return convergeAgent(
1361
+ `You are the EDIT step resolving merge conflicts for ${prCoordinates}, HEAD ${head}, before the bug checks run. The PR branch conflicts with origin/main. A separate verify step then a separate commit step run after you.\n\n` +
1362
+ `Rules:\n` +
1363
+ `- Confirm the working tree is on the PR branch at HEAD ${head} with no unrelated edits before you start.\n` +
1364
+ `- Rebase the branch onto origin/main and resolve every conflict so the tree is clean and conflict-free: git fetch origin main; git rebase origin/main; resolve each conflict, preserving the intent of both the PR's change and the incoming base change. A rebase creates local commits, which is fine.\n` +
1365
+ `- Do NOT push and do NOT force-push — the commit step force-pushes after the verify step binds a verdict. Pushing here would change the surface the verifier binds to.\n\n` +
1366
+ `Return rebased=true with a one-line summary when you rebased onto origin/main and resolved the conflicts; rebased=false with a summary when the branch did not actually need a rebase or you could not complete it.`,
1367
+ { label: 'resolve-conflicts-edit', phase: 'Converge', schema: CONFLICT_EDIT_SCHEMA, agentType: 'clean-coder' },
1368
+ )
1369
+ }
1370
+
1371
+ /**
1372
+ * Pre-flight conflict resolution: when the PR branch conflicts with its base,
1373
+ * rebase it clean before the bug checks run — check (Explore probes mergeability)
1374
+ * -> edit (clean-coder rebases and resolves, no push) -> verify (code-verifier
1375
+ * binds a verdict to the rebased tree) -> commit (clean-coder force-with-lease
1376
+ * pushes). Returns the post-rebase HEAD so the first converge round runs its
1377
+ * lenses on the conflict-free diff. A non-conflicting PR, a rebase the edit step
1378
+ * declined, or a failed verdict returns the unchanged HEAD so the run proceeds to
1379
+ * the bug checks unchanged. A mid-run conflict (origin/main advancing later) is
1380
+ * still caught by the FINALIZE convergence repair, which also rebases.
1381
+ * @param {string} head PR HEAD SHA before any rebase
1382
+ * @returns {Promise<string>} the HEAD SHA after a successful rebase push, or the unchanged head
1383
+ */
1384
+ async function resolveMergeConflicts(head) {
1385
+ const mergeState = await checkMergeConflicts(head)
1386
+ if (!isMergeConflicting(mergeState)) return head
1387
+ log(`Pre-flight: ${prCoordinates} conflicts with origin/main — rebasing clean before the bug checks`)
1388
+ const editResult = await resolveConflictsEdit(head)
1389
+ if (editResult?.rebased !== true) return head
1390
+ const failures = ['PR branch had merge conflicts with origin/main; the rebase must leave a clean, conflict-free tree']
1391
+ const verifyTranscript = await verifyWithRecovery({
1392
+ runVerify: () => verifyRepairChanges(head, failures),
1393
+ runRecoverEdit: (objection, attempt) => recoverVerifyFailEdit(head, objection, 'conflict-rebase', attempt),
1394
+ })
1395
+ if (!verdictPassed(verifyTranscript)) return head
1396
+ const commitResult = await commitWithRecovery({
1397
+ runCommit: () => commitRepairFixes(head, true),
1398
+ runVerify: () => verifyRepairChanges(head, failures),
1399
+ runRecoverEdit: (detail, attempt) => recoverCommitBlockEdit(head, detail, 'conflict-rebase', attempt),
1400
+ })
1401
+ return commitResult?.newSha || head
1402
+ }
1403
+
1238
1404
  /**
1239
1405
  * Decide whether a review round surfaced ONLY code-standard violations — pure
1240
1406
  * CODE_RULES/style findings with no behavioral impact. Such a round passes for
@@ -1269,7 +1435,8 @@ function standardsFollowUpEdit(head, findings, sourceLabel) {
1269
1435
  `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` +
1270
1436
  `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` +
1271
1437
  `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` +
1272
- `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.`,
1438
+ `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.` +
1439
+ PRE_COMMIT_GATE_STEP,
1273
1440
  { label: `standards-edit:${sourceLabel}`, phase: 'Converge', schema: STANDARDS_EDIT_SCHEMA, agentType: 'clean-coder' },
1274
1441
  )
1275
1442
  }
@@ -1428,9 +1595,36 @@ let bugbotDown = input.bugbotDisabled || false
1428
1595
  let copilotDown = false
1429
1596
  let copilotNote = null
1430
1597
  let standardsNote = null
1598
+ let reuseNote = null
1431
1599
  const allRoundFindings = []
1432
1600
  const fixSummaries = []
1433
1601
 
1602
+ const preflightHead = await resolveHead()
1603
+ if (isResolvedHeadUsable(preflightHead)) {
1604
+ await resolveMergeConflicts(preflightHead)
1605
+ }
1606
+
1607
+ log('Reuse pass: scanning the full diff for certain, behaviorally identical, autonomously implementable reuse improvements before convergence')
1608
+ const reuseHead = await resolveHead()
1609
+ if (isResolvedHeadUsable(reuseHead)) {
1610
+ await prefetchMainForRound()
1611
+ const reuse = await runReuseAuditPass(reuseHead)
1612
+ const reuseFindings = reuse?.findings || []
1613
+ if (reuseFindings.length > 0) {
1614
+ log(`Reuse pass: ${reuseFindings.length} qualifying reuse improvement(s) — applying before convergence`)
1615
+ allRoundFindings.push(...reuseFindings)
1616
+ const reuseFix = await applyFixes(reuseHead, reuseFindings, 'reuse-pass')
1617
+ if (reuseFix?.summary) fixSummaries.push(reuseFix.summary)
1618
+ reuseNote = reuseFix?.pushed === true
1619
+ ? `${reuseFindings.length} reuse improvement(s) applied before convergence (${reuseFix.newSha?.slice(0, SHA_COMPARISON_PREFIX_LENGTH)})`
1620
+ : `${reuseFindings.length} reuse improvement(s) identified before convergence but not landed — the code-review lens re-surfaces any that remain`
1621
+ } else {
1622
+ log('Reuse pass: no reuse case cleared all three criteria — proceeding to convergence')
1623
+ }
1624
+ } else {
1625
+ log('Reuse pass: could not resolve HEAD — proceeding to convergence')
1626
+ }
1627
+
1434
1628
  while (iterations < CONFIG.maxIterations) {
1435
1629
  iterations += 1
1436
1630
  if (phase === 'CONVERGE') {
@@ -1556,7 +1750,7 @@ while (iterations < CONFIG.maxIterations) {
1556
1750
  const readyOutcome = classifyReadyOutcome(readyResult)
1557
1751
  if (readyOutcome.converged) {
1558
1752
  await spawnConvergenceSummary(dedupeFindings(allRoundFindings), fixSummaries, rounds, standardsNote, copilotNote)
1559
- return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote }
1753
+ return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote }
1560
1754
  }
1561
1755
  blocker = readyOutcome.blocker
1562
1756
  break
@@ -1575,4 +1769,5 @@ return {
1575
1769
  blocker: blocker || `iteration cap reached (${CONFIG.maxIterations})`,
1576
1770
  standardsNote,
1577
1771
  copilotNote,
1772
+ reuseNote,
1578
1773
  }
@@ -0,0 +1,47 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+
10
+ function sliceBetween(startNeedle, endNeedle) {
11
+ const sliceStart = convergeSource.indexOf(startNeedle);
12
+ assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
13
+ const sliceEnd = convergeSource.indexOf(endNeedle, sliceStart + startNeedle.length);
14
+ assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
15
+ return convergeSource.slice(sliceStart, sliceEnd);
16
+ }
17
+
18
+ const productionModule = new Function(
19
+ `${sliceBetween('const worktreeDirective =', '\nconst convergeAgent =')}\n` +
20
+ 'return { worktreeDirective };',
21
+ )();
22
+ const { worktreeDirective } = productionModule;
23
+
24
+ test('a single-PR run (no repoPath) produces an empty worktree directive', () => {
25
+ assert.equal(worktreeDirective(null), '');
26
+ });
27
+
28
+ test('a path-scoped run pins every agent to the PR worktree by absolute path', () => {
29
+ const directive = worktreeDirective('/worktrees/pr-398');
30
+ assert.match(directive, /\/worktrees\/pr-398/);
31
+ assert.match(directive, /cd /);
32
+ assert.match(directive, /git, gh, diff, edit, commit, push, and test/);
33
+ });
34
+
35
+ test('a path-scoped run defers to a step that names a different repository directory', () => {
36
+ assert.match(worktreeDirective('/worktrees/pr-398'), /different repository directory/i);
37
+ });
38
+
39
+ test('convergeAgent prepends the worktree directive for the active repo path', () => {
40
+ const agentDefinition = sliceBetween('const convergeAgent =', '\nconst PRE_COMMIT_GATE_STEP');
41
+ assert.match(agentDefinition, /worktreeDirective\(activeRepoPath\)/);
42
+ assert.match(agentDefinition, /HEADLESS_SAFETY_PREAMBLE/);
43
+ });
44
+
45
+ test('the run binds activeRepoPath from input.repoPath after the input is parsed', () => {
46
+ assert.match(convergeSource, /activeRepoPath = typeof input\.repoPath === 'string'/);
47
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Autoconverge multi-PR fan-out workflow driver.
3
+ *
4
+ * SINGLE-FILE CONTRACT — keep this file self-contained. The Workflow runtime
5
+ * wraps this body in a function (so top-level await and return work) and rejects
6
+ * static import statements, and `export const meta` must be the first statement.
7
+ * This driver fans out one converge.mjs child run per PR with parallel(); the
8
+ * converge.mjs child uses only agent()/parallel() (never workflow()), so the
9
+ * one-level workflow() nesting limit holds.
10
+ */
11
+
12
+ export const meta = {
13
+ name: 'autoconverge-multi',
14
+ description: 'Drive several draft PRs to convergence in one run: fan out one autoconverge converge.mjs child per PR in parallel, each pinned to its own checked-out worktree via repoPath, then report every PR\'s outcome together.',
15
+ whenToUse: 'Launched by the /autoconverge skill when the user names more than one PR to converge at once; the single-PR path launches workflow/converge.mjs directly.',
16
+ phases: [
17
+ { title: 'Converge all', detail: 'One converge.mjs child run per PR, all in parallel; each child is pinned to its own PR worktree through repoPath' },
18
+ ],
19
+ }
20
+
21
+ /**
22
+ * Normalize the workflow args global into a parsed object.
23
+ *
24
+ * The Workflow runtime may deliver args as a JSON-encoded string or as an
25
+ * object; a string is parsed and an object passes through unchanged. A non-JSON
26
+ * or empty string yields null so a malformed payload becomes a structured
27
+ * blocker rather than aborting the run.
28
+ * @param {string|object} rawArgs the workflow args global (JSON string or object)
29
+ * @returns {object|null} the parsed args, or null when a string payload fails to parse
30
+ */
31
+ function normalizeMultiInput(rawArgs) {
32
+ if (typeof rawArgs !== 'string') return rawArgs
33
+ try {
34
+ return JSON.parse(rawArgs)
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Decide whether one PR entry carries every coordinate a child run needs.
42
+ *
43
+ * A child converge run needs the PR's owner, repo, and number to address its
44
+ * GitHub calls, and the absolute worktree path the PR is checked out in to pin
45
+ * its agents there.
46
+ * @param {object} prEntry one element of the args.prs array
47
+ * @returns {boolean} true when owner, repo, prNumber, and a non-empty string repoPath are all present
48
+ */
49
+ function isUsablePrEntry(prEntry) {
50
+ return (
51
+ prEntry != null &&
52
+ Boolean(prEntry.owner) &&
53
+ Boolean(prEntry.repo) &&
54
+ Boolean(prEntry.prNumber) &&
55
+ typeof prEntry.repoPath === 'string' &&
56
+ Boolean(prEntry.repoPath)
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Validate the normalized multi-PR input into usable coordinates or a blocker.
62
+ *
63
+ * A fan-out run needs the absolute converge.mjs script path and a non-empty list
64
+ * of PR entries that each carry owner, repo, prNumber, and the absolute worktree
65
+ * path the PR is checked out in. A payload that fails to parse, a non-string
66
+ * convergeScriptPath, a missing or empty prs list, or any entry missing a
67
+ * coordinate yields a blocker the top-level run reports as
68
+ * {converged:false, blocker} rather than throwing on a missing field.
69
+ * @param {string|object} rawArgs the workflow args global (JSON string or object)
70
+ * @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
71
+ */
72
+ function classifyMultiInput(rawArgs) {
73
+ const candidate = normalizeMultiInput(rawArgs)
74
+ if (candidate == null) {
75
+ return {
76
+ input: null,
77
+ blocker: 'invalid run coordinates: the workflow args did not parse into an object',
78
+ }
79
+ }
80
+ if (typeof candidate.convergeScriptPath !== 'string' || !candidate.convergeScriptPath) {
81
+ return {
82
+ input: null,
83
+ blocker:
84
+ 'invalid run coordinates: convergeScriptPath (absolute path to converge.mjs) is required',
85
+ }
86
+ }
87
+ if (!Array.isArray(candidate.prs) || candidate.prs.length === 0) {
88
+ return {
89
+ input: null,
90
+ blocker: 'invalid run coordinates: prs must be a non-empty array of PR entries',
91
+ }
92
+ }
93
+ const unusableEntryCount = candidate.prs.filter(
94
+ (eachEntry) => !isUsablePrEntry(eachEntry),
95
+ ).length
96
+ if (unusableEntryCount > 0) {
97
+ return {
98
+ input: null,
99
+ blocker: `invalid run coordinates: ${unusableEntryCount} PR entry/entries missing owner, repo, prNumber, or repoPath`,
100
+ }
101
+ }
102
+ return { input: candidate, blocker: null }
103
+ }
104
+
105
+ const multiInput = classifyMultiInput(args)
106
+ if (multiInput.blocker) {
107
+ return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
108
+ }
109
+ const input = multiInput.input
110
+
111
+ phase('Converge all')
112
+ log(`autoconverge multi-PR: driving ${input.prs.length} PR(s) to ready in parallel`)
113
+
114
+ const childResults = await parallel(
115
+ input.prs.map((eachPr) => async () => {
116
+ const childOutcome = await workflow(
117
+ { scriptPath: input.convergeScriptPath },
118
+ {
119
+ owner: eachPr.owner,
120
+ repo: eachPr.repo,
121
+ prNumber: eachPr.prNumber,
122
+ repoPath: eachPr.repoPath,
123
+ bugbotDisabled: Boolean(eachPr.bugbotDisabled),
124
+ },
125
+ )
126
+ return {
127
+ owner: eachPr.owner,
128
+ repo: eachPr.repo,
129
+ prNumber: eachPr.prNumber,
130
+ converged: Boolean(childOutcome && childOutcome.converged),
131
+ rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
132
+ finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
133
+ blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
134
+ }
135
+ }),
136
+ )
137
+
138
+ const results = childResults.map((eachResult, eachIndex) =>
139
+ eachResult === null
140
+ ? {
141
+ owner: input.prs[eachIndex].owner,
142
+ repo: input.prs[eachIndex].repo,
143
+ prNumber: input.prs[eachIndex].prNumber,
144
+ converged: false,
145
+ rounds: null,
146
+ finalSha: null,
147
+ blocker: 'child run threw or was skipped before returning an outcome',
148
+ }
149
+ : eachResult,
150
+ )
151
+
152
+ const convergedCount = results.filter((eachResult) => eachResult.converged).length
153
+ log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged`)
154
+
155
+ return {
156
+ converged: convergedCount === results.length,
157
+ prCount: results.length,
158
+ convergedCount,
159
+ results,
160
+ blocker: null,
161
+ }
@@ -0,0 +1,100 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const multiSource = readFileSync(join(workflowDirectory, 'converge_multi.mjs'), 'utf8');
9
+
10
+ function sourceSliceBetween(startNeedle, endNeedle) {
11
+ const sliceStart = multiSource.indexOf(startNeedle);
12
+ assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
13
+ const sliceEnd = multiSource.indexOf(endNeedle, sliceStart + startNeedle.length);
14
+ assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
15
+ return multiSource.slice(sliceStart, sliceEnd);
16
+ }
17
+
18
+ const productionModule = new Function(
19
+ `${sourceSliceBetween('function normalizeMultiInput(', '\nconst multiInput =')}\n` +
20
+ 'return { normalizeMultiInput, isUsablePrEntry, classifyMultiInput };',
21
+ )();
22
+ const { normalizeMultiInput, classifyMultiInput } = productionModule;
23
+
24
+ const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
25
+
26
+ function validEntry(prNumber) {
27
+ return {
28
+ owner: 'JonEcho',
29
+ repo: 'python-automation',
30
+ prNumber,
31
+ repoPath: `/worktrees/pr-${prNumber}`,
32
+ };
33
+ }
34
+
35
+ function validArgs() {
36
+ return { convergeScriptPath: SCRIPT_PATH, prs: [validEntry(398), validEntry(402)] };
37
+ }
38
+
39
+ test('an object payload passes through unchanged', () => {
40
+ const parsed = validArgs();
41
+ assert.deepEqual(normalizeMultiInput(parsed), parsed);
42
+ });
43
+
44
+ test('a JSON-encoded string payload is parsed into coordinates', () => {
45
+ assert.deepEqual(normalizeMultiInput(JSON.stringify(validArgs())), validArgs());
46
+ });
47
+
48
+ test('a non-JSON string returns null rather than throwing', () => {
49
+ assert.equal(normalizeMultiInput('not json at all'), null);
50
+ });
51
+
52
+ test('valid coordinates classify with no blocker and keep every PR entry', () => {
53
+ const classified = classifyMultiInput(validArgs());
54
+ assert.equal(classified.blocker, null);
55
+ assert.equal(classified.input.prs.length, 2);
56
+ });
57
+
58
+ test('a missing convergeScriptPath is blocked', () => {
59
+ const classified = classifyMultiInput({ prs: [validEntry(398)] });
60
+ assert.equal(classified.input, null);
61
+ assert.match(classified.blocker, /convergeScriptPath/);
62
+ });
63
+
64
+ test('an empty prs array is blocked', () => {
65
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [] });
66
+ assert.equal(classified.input, null);
67
+ assert.match(classified.blocker, /non-empty array/);
68
+ });
69
+
70
+ test('a missing prs list is blocked', () => {
71
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH });
72
+ assert.equal(classified.input, null);
73
+ assert.match(classified.blocker, /non-empty array/);
74
+ });
75
+
76
+ test('a non-array prs value is blocked', () => {
77
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: 'nope' });
78
+ assert.equal(classified.input, null);
79
+ assert.match(classified.blocker, /non-empty array/);
80
+ });
81
+
82
+ test('an entry missing prNumber is blocked', () => {
83
+ const badEntry = { owner: 'JonEcho', repo: 'python-automation', repoPath: '/w/x' };
84
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
85
+ assert.equal(classified.input, null);
86
+ assert.match(classified.blocker, /missing/);
87
+ });
88
+
89
+ test('an entry missing repoPath is blocked', () => {
90
+ const badEntry = { owner: 'JonEcho', repo: 'python-automation', prNumber: 398 };
91
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
92
+ assert.equal(classified.input, null);
93
+ assert.match(classified.blocker, /missing/);
94
+ });
95
+
96
+ test('a null payload is blocked', () => {
97
+ const classified = classifyMultiInput('not json at all');
98
+ assert.equal(classified.input, null);
99
+ assert.match(classified.blocker, /did not parse/);
100
+ });