claude-dev-env 1.75.0 → 1.77.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 (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -128,27 +128,32 @@ function resumeGitAgent(agentId, task, head) {
128
128
 
129
129
  /**
130
130
  * Spawn the fixer clean-coder agent for one fix batch, establishing its role and
131
- * the PR coordinates so each later resume (verify-commit, commit, and recovery)
132
- * continues the same session. The spawn makes no edits the verify-commit resume
133
- * is the first step that touches the working tree. Returns the runtime agent id
134
- * so the resume calls target the live session; a runtime without resume support
135
- * returns no agent id, and each resume falls back to a fresh spawn.
131
+ * the PR coordinates so each later resume (commit and recovery edits) continues
132
+ * the same session. The fixer never verifiesa separate verifier agent grades
133
+ * the working tree, so the verdict comes from a different session than the one
134
+ * that commits and recovers, mirroring the repair and conflict paths. The spawn
135
+ * makes no edits the first recovery resume is the earliest step that touches
136
+ * the working tree. Returns the runtime agent id so the resume calls target the
137
+ * live session; a runtime without resume support returns no agent id, and each
138
+ * resume falls back to a fresh spawn.
136
139
  * @param {string} head PR HEAD SHA
137
140
  * @param {Array<object>} findings the findings to fix
138
141
  * @param {string} sourceLabel short description of where the findings came from
139
- * @param {string} task initial task name
140
142
  * @returns {Promise<string|undefined>} the runtime agent id, or undefined when the runtime returns none
141
143
  */
142
- async function spawnFixerAgent(head, findings, sourceLabel, task) {
144
+ async function spawnFixerAgent(head, findings, sourceLabel) {
143
145
  const result = await convergeAgent(
144
- `You are the fixer agent for ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. The edit step left fixes in the working tree, uncommitted. Across this session you run a sequence of steps (the first is ${task}): verify the working-tree fixes, commit and push them, and recover when a verify objection or a commit-gate block needs another edit. Make NO edits in this first turn — confirm only that the working tree is on the PR branch at HEAD ${head} with uncommitted fixes present, then wait for the verify-commit instructions. Reply READY.`,
146
+ `You are the fixer agent for ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. The edit step left fixes in the working tree, uncommitted. Across this session you run a sequence of steps: commit and push the working-tree fixes once a separate verifier passes them, and recover when a verify objection or a commit-gate block needs another edit. A separate verifier agent grades the working tree, so you never verify your own edits. Make NO edits in this first turn — confirm only that the working tree is on the PR branch at HEAD ${head} with uncommitted fixes present, then wait for the next step's instructions. Reply READY.`,
145
147
  { label: `fixer:${sourceLabel}`, phase: 'Converge', agentType: 'clean-coder' },
146
148
  )
147
149
  return result?.agentId
148
150
  }
149
151
 
150
152
  /**
151
- * Resume the fixer agent for verify-commit or recovery.
153
+ * Resume the fixer agent for commit or recovery edits. The fixer never verifies;
154
+ * a separate verifier agent emits the verdict, so commit, verify-recover, and
155
+ * commit-recover all run on the editing session while the verdict that gates the
156
+ * commit comes from a different session.
152
157
  * @param {string} agentId the agent id from spawnFixerAgent
153
158
  * @param {string} task the short task name
154
159
  * @param {object} context task-specific context
@@ -156,18 +161,6 @@ async function spawnFixerAgent(head, findings, sourceLabel, task) {
156
161
  */
157
162
  function resumeFixerAgent(agentId, task, context) {
158
163
  const label = `fixer:${context.sourceLabel}`
159
- if (task === 'verify-commit') {
160
- const findingsBlock = renderFindingsBlock(context.findings)
161
- return convergeAgent(
162
- `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` +
163
- `Findings the working-tree fixes must address:\n${findingsBlock}\n\n` +
164
- `Steps:\n` +
165
- `1. Resolve the worktree repo root for running tests: REPO=$(git rev-parse --show-toplevel).\n` +
166
- `2. Verify the uncommitted working-tree changes resolve every finding above: run the relevant tests and the named gates against the working tree. Read the diff (git diff) and confirm each finding is fixed test-first per CODE_RULES.\n` +
167
- `3. ${buildVerdictFenceSteps(input.owner, input.repo, input.prNumber)}`,
168
- { label, phase: 'Converge', agentType: 'code-verifier', resume: agentId },
169
- )
170
- }
171
164
  if (task === 'commit') {
172
165
  return convergeAgent(
173
166
  `You are the COMMIT step for fixes (${context.sourceLabel}) on ${prCoordinates}, HEAD ${context.head}. The edit step left fixes in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree.\n\n` +
@@ -181,6 +174,20 @@ function resumeFixerAgent(agentId, task, context) {
181
174
  { label, phase: 'Converge', schema: FIX_SCHEMA, agentType: 'clean-coder', resume: agentId },
182
175
  )
183
176
  }
177
+ if (task === 'commit-recover') {
178
+ const attempt = context.attempt || 1
179
+ return convergeAgent(
180
+ `You are the COMMIT-RECOVERY fixer (attempt ${attempt}) for fixes (${context.sourceLabel}) on ${prCoordinates}, HEAD ${context.head}. A prior commit step was blocked by a commit-time hook or gate that requires a code change. A separate verify step then a separate commit step run after you.\n\n` +
181
+ `The blocking hook or gate said:\n${context.blockerDetail}\n\n` +
182
+ `Rules:\n` +
183
+ `- Confirm the working tree is on the PR branch at HEAD ${context.head} with the prior fixes still present.\n` +
184
+ `- 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` +
185
+ `- 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` +
186
+ `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.` +
187
+ PRE_COMMIT_GATE_STEP,
188
+ { label, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder', resume: agentId },
189
+ )
190
+ }
184
191
  const objection = context.objection || VERIFY_OBJECTION_FALLBACK
185
192
  const attempt = context.attempt || 1
186
193
  return convergeAgent(
@@ -197,77 +204,41 @@ function resumeFixerAgent(agentId, task, context) {
197
204
  }
198
205
 
199
206
  /**
200
- * Joined fixer recovery loop: resume the fixer agent for verify+commit, extract the verdict,
201
- * and recover when the verdict fails or the commit is blocked.
207
+ * Joined fixer recovery loop: a separate verifier agent grades the working-tree
208
+ * fixes while the fixer session only recovers and commits, so the verdict that
209
+ * gates the commit comes from a different session than the one that edits and
210
+ * pushes — the same editor/verifier separation the repair and conflict paths
211
+ * use. The verify step routes through verifyWithRecovery (verify on the verifier,
212
+ * recover on the fixer); the commit step routes through commitWithRecovery
213
+ * (commit and commit-recover on the fixer, re-verify on the verifier). A failed
214
+ * verdict returns the unchanged HEAD so the round reads as not-progressed.
202
215
  * @param {string} fixerAgentId the fixer agent id from spawnFixerAgent
216
+ * @param {string} verifierId the verifier agent id from spawnVerifierAgent
203
217
  * @param {string} head PR HEAD SHA
204
218
  * @param {Array<object>} findings the findings to fix
205
219
  * @param {string} sourceLabel short description of where the findings came from
206
220
  * @returns {Promise<object>} FIX_SCHEMA result
207
221
  */
208
- async function fixerWithRecovery(fixerAgentId, head, findings, sourceLabel) {
209
- const verifyTranscript = await resumeFixerAgent(fixerAgentId, 'verify-commit', { head, findings, sourceLabel })
210
- const verdict = extractVerdict(verifyTranscript)
211
- if (verdict && verdict.all_pass === true) {
212
- const commitResult = await resumeFixerAgent(fixerAgentId, 'commit', { head, findings, sourceLabel })
213
- if (commitNeedsCodeRecovery(commitResult)) {
214
- let attempt = 0
215
- let recoveryResult = commitResult
216
- while (commitNeedsCodeRecovery(recoveryResult) && attempt < FIX_RECOVERY_MAX_ATTEMPTS) {
217
- attempt += 1
218
- const recoverEdit = await resumeFixerAgent(fixerAgentId, 'verify-recover', {
219
- head, findings, sourceLabel, objection: recoveryResult.blockerDetail, attempt,
220
- })
221
- if (recoverEdit?.edited !== true) break
222
- const reVerify = await resumeFixerAgent(fixerAgentId, 'verify-commit', { head, findings, sourceLabel })
223
- const reVerdict = extractVerdict(reVerify)
224
- if (!reVerdict || reVerdict.all_pass !== true) break
225
- recoveryResult = await resumeFixerAgent(fixerAgentId, 'commit', { head, findings, sourceLabel })
226
- }
227
- return recoveryResult
228
- }
229
- return commitResult
230
- }
231
- let attempt = 0
232
- let lastTranscript = verifyTranscript
233
- while ((!verdict || verdict.all_pass !== true) && attempt < FIX_RECOVERY_MAX_ATTEMPTS) {
234
- attempt += 1
235
- const objection = extractVerifyObjection(lastTranscript)
236
- const recoverEdit = await resumeFixerAgent(fixerAgentId, 'verify-recover', {
237
- head, findings, sourceLabel, objection, attempt,
238
- })
239
- if (recoverEdit?.edited !== true) break
240
- lastTranscript = await resumeFixerAgent(fixerAgentId, 'verify-commit', { head, findings, sourceLabel })
241
- const freshVerdict = extractVerdict(lastTranscript)
242
- if (freshVerdict && freshVerdict.all_pass === true) {
243
- const commitResult = await resumeFixerAgent(fixerAgentId, 'commit', { head, findings, sourceLabel })
244
- if (commitNeedsCodeRecovery(commitResult)) {
245
- let commitAttempt = 0
246
- let commitRecovery = commitResult
247
- while (commitNeedsCodeRecovery(commitRecovery) && commitAttempt < FIX_RECOVERY_MAX_ATTEMPTS) {
248
- commitAttempt += 1
249
- const commitEdit = await resumeFixerAgent(fixerAgentId, 'verify-recover', {
250
- head, findings, sourceLabel, objection: commitRecovery.blockerDetail, attempt: commitAttempt,
251
- })
252
- if (commitEdit?.edited !== true) break
253
- const reVerify2 = await resumeFixerAgent(fixerAgentId, 'verify-commit', { head, findings, sourceLabel })
254
- const reVerdict2 = extractVerdict(reVerify2)
255
- if (!reVerdict2 || reVerdict2.all_pass !== true) break
256
- commitRecovery = await resumeFixerAgent(fixerAgentId, 'commit', { head, findings, sourceLabel })
257
- }
258
- return commitRecovery
259
- }
260
- return commitResult
222
+ async function fixerWithRecovery(fixerAgentId, verifierId, head, findings, sourceLabel) {
223
+ const verifyTranscript = await verifyWithRecovery({
224
+ runVerify: () => resumeVerifierAgent(verifierId, 'fix-verify', { head, findings, sourceLabel }),
225
+ runRecoverEdit: (objection, attempt) => resumeFixerAgent(fixerAgentId, 'verify-recover', { head, findings, sourceLabel, objection, attempt }),
226
+ })
227
+ if (!verdictPassed(verifyTranscript)) {
228
+ return {
229
+ newSha: head,
230
+ pushed: false,
231
+ resolvedWithoutCommit: false,
232
+ summary: `verify step did not pass the working-tree fixes for ${findings.length} finding(s) — not committing`,
233
+ blockedNeedingEdit: false,
234
+ blockerDetail: '',
261
235
  }
262
236
  }
263
- return {
264
- newSha: head,
265
- pushed: false,
266
- resolvedWithoutCommit: false,
267
- summary: `verify step did not pass the working-tree fixes for ${findings.length} finding(s) — not committing`,
268
- blockedNeedingEdit: false,
269
- blockerDetail: '',
270
- }
237
+ return commitWithRecovery({
238
+ runCommit: () => resumeFixerAgent(fixerAgentId, 'commit', { head, findings, sourceLabel }),
239
+ runVerify: () => resumeVerifierAgent(verifierId, 'fix-verify', { head, findings, sourceLabel }),
240
+ runRecoverEdit: (detail, attempt) => resumeFixerAgent(fixerAgentId, 'commit-recover', { head, findings, sourceLabel, blockerDetail: detail, attempt }),
241
+ })
271
242
  }
272
243
 
273
244
  /**
@@ -424,7 +395,7 @@ function resumeCodeEditorAgent(agentId, task, context) {
424
395
 
425
396
  /**
426
397
  * Spawn the verifier code-verifier agent once per converge round, establishing
427
- * its role so each later resume (repair-verify, hardening-verify) continues the
398
+ * its role so each later resume (fix-verify, repair-verify, hardening-verify) continues the
428
399
  * same session. The spawn makes no edits — verification only. Returns the runtime
429
400
  * agent id so the resume calls target the live session; a runtime without resume
430
401
  * support returns no agent id, and each resume falls back to a fresh spawn.
@@ -447,6 +418,18 @@ async function spawnVerifierAgent() {
447
418
  */
448
419
  function resumeVerifierAgent(agentId, task, context) {
449
420
  const label = `verifier:${task}`
421
+ if (task === 'fix-verify') {
422
+ const findingsBlock = renderFindingsBlock(context.findings)
423
+ return convergeAgent(
424
+ `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` +
425
+ `Findings the working-tree fixes must address:\n${findingsBlock}\n\n` +
426
+ `Steps:\n` +
427
+ `1. Resolve the worktree repo root for running tests: REPO=$(git rev-parse --show-toplevel).\n` +
428
+ `2. Verify the uncommitted working-tree changes resolve every finding above: run the relevant tests and the named gates against the working tree. Read the diff (git diff) and confirm each finding is fixed test-first per CODE_RULES.\n` +
429
+ `3. ${buildVerdictFenceSteps(input.owner, input.repo, input.prNumber)}`,
430
+ { label, phase: 'Converge', agentType: 'code-verifier', resume: agentId },
431
+ )
432
+ }
450
433
  if (task === 'repair-verify') {
451
434
  const failureBlock = context.failures.length
452
435
  ? context.failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
@@ -929,15 +912,6 @@ function parseLastVerdictFence(transcript) {
929
912
  }
930
913
  }
931
914
 
932
- /**
933
- * Extract the full verdict object from a transcript carrying a verdict fence.
934
- * @param {string|null|undefined} transcript the agent transcript text
935
- * @returns {object|null} the parsed verdict with all_pass, findings, and manifest_sha256, or null
936
- */
937
- function extractVerdict(transcript) {
938
- return parseLastVerdictFence(transcript)
939
- }
940
-
941
915
  /**
942
916
  * Decide whether a workflow code-verifier transcript ended in a passing
943
917
  * verdict. Reads the LAST ```verdict ...``` fenced JSON block via the shared
@@ -1055,7 +1029,7 @@ function commitNeedsCodeRecovery(commitResult) {
1055
1029
  * resolve-head agent or a malformed result yields a falsy SHA; spawning lenses
1056
1030
  * against it interpolates the literal string 'HEAD undefined' into their prompts
1057
1031
  * and produces a spurious clean verdict on a non-existent commit.
1058
- * @param {string|null|undefined} resolvedHead the SHA from resolveHead()
1032
+ * @param {string|null|undefined} resolvedHead the SHA from the git-utility agent resume for 'resolve-head'
1059
1033
  * @returns {boolean} true only when the SHA is a non-empty string
1060
1034
  */
1061
1035
  function isResolvedHeadUsable(resolvedHead) {
@@ -1068,7 +1042,7 @@ function isResolvedHeadUsable(resolvedHead) {
1068
1042
  * not-conflicting so the run proceeds straight to the bug checks rather than
1069
1043
  * force-pushing a rebase on a verdict that does not exist — a transient check
1070
1044
  * failure must never trigger a destructive rebase.
1071
- * @param {object|null|undefined} mergeState the checkMergeConflicts result
1045
+ * @param {object|null|undefined} mergeState the git-utility agent resume result for 'check-merge-conflicts'
1072
1046
  * @returns {boolean} true only when the check reported conflicting:true
1073
1047
  */
1074
1048
  function isMergeConflicting(mergeState) {
@@ -1357,14 +1331,16 @@ async function verifyWithRecovery({ runVerify, runRecoverEdit }) {
1357
1331
  }
1358
1332
 
1359
1333
  /**
1360
- * Fix lens: edit (clean-coder, no commit) -> verify (code-verifier emits a
1361
- * verdict fence binding the working tree) -> commit (clean-coder, one commit +
1362
- * push, no edits). Splitting the single editing-and-committing agent lets a
1363
- * workflow code-verifier produce the verdict the verified-commit gate requires,
1364
- * which the SubagentStop minter cannot mint for workflow-spawned agents. When
1365
- * verification fails (or the edit step stalled with no thread to resolve), the
1366
- * commit step is skipped and the unchanged HEAD is returned so the round reads
1367
- * as not-progressed.
1334
+ * Fix lens: edit (clean-coder, no commit) -> verify (a separate code-verifier
1335
+ * emits a verdict fence binding the working tree) -> commit (clean-coder, one
1336
+ * commit + push, no edits). The verifier is a distinct persistent group from the
1337
+ * fixer, so the verdict that gates the commit comes from a different session than
1338
+ * the one that edits and pushes the same editor/verifier separation the repair
1339
+ * and conflict paths use, and the separation a workflow code-verifier needs to
1340
+ * produce the verdict the verified-commit gate requires, which the SubagentStop
1341
+ * minter cannot mint for workflow-spawned agents. When verification fails (or the
1342
+ * edit step stalled with no thread to resolve), the commit step is skipped and the
1343
+ * unchanged HEAD is returned so the round reads as not-progressed.
1368
1344
  * @param {string} head PR HEAD SHA the findings were raised against
1369
1345
  * @param {Array<object>} findings deduped findings across all lenses
1370
1346
  * @param {string} sourceLabel short description of where the findings came from
@@ -1383,8 +1359,9 @@ async function applyFixes(head, findings, sourceLabel) {
1383
1359
  blockerDetail: '',
1384
1360
  }
1385
1361
  }
1386
- const fixerAgentId = await spawnFixerAgent(head, findings, sourceLabel, 'verify-commit')
1387
- return fixerWithRecovery(fixerAgentId, head, findings, sourceLabel)
1362
+ const fixerAgentId = await spawnFixerAgent(head, findings, sourceLabel)
1363
+ const verifierId = await spawnVerifierAgent()
1364
+ return fixerWithRecovery(fixerAgentId, verifierId, head, findings, sourceLabel)
1388
1365
  }
1389
1366
 
1390
1367
  /**
@@ -1561,7 +1538,7 @@ function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1561
1538
  * @param {string} sourceLabel short description of where the findings came from
1562
1539
  * @returns {Promise<object>} `{ hardeningPrOpened }` — true only when the hardening PR was opened this round
1563
1540
  */
1564
- async function spawnStandardsFollowUp(head, findings, sourceLabel, generalId) {
1541
+ async function spawnStandardsFollowUp(head, findings, sourceLabel) {
1565
1542
  const codeEditorId = await spawnCodeEditorAgent()
1566
1543
  const editResult = await resumeCodeEditorAgent(codeEditorId, 'standards-edit', { head, findings, sourceLabel })
1567
1544
  if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
@@ -1647,7 +1624,7 @@ while (iterations < CONFIG.maxIterations) {
1647
1624
  if (isStandardsOnlyRound(findings)) {
1648
1625
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
1649
1626
  const generalId = await spawnGeneralUtilityAgent()
1650
- const standardsOutcome = await spawnStandardsFollowUp(head, findings, 'converge-round', generalId)
1627
+ const standardsOutcome = await spawnStandardsFollowUp(head, findings, 'converge-round')
1651
1628
  standardsNote = standardsDeferralNote(findings.length, standardsOutcome?.hardeningPrOpened === true)
1652
1629
  const auditResult = await resumeGeneralUtilityAgent(generalId, 'post-clean-audit', { head })
1653
1630
  if (!auditResult?.posted) {
@@ -1704,8 +1681,7 @@ while (iterations < CONFIG.maxIterations) {
1704
1681
  if (copilotOutcome.kind === 'fix') {
1705
1682
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
1706
1683
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
1707
- const copilotGeneralId = await spawnGeneralUtilityAgent()
1708
- const standardsOutcome = await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot', copilotGeneralId)
1684
+ const standardsOutcome = await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
1709
1685
  standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome?.hardeningPrOpened === true)
1710
1686
  copilotDown = false
1711
1687
  copilotNote = null
@@ -23,6 +23,11 @@ from bugteam_scripts_constants.bugteam_code_rules_gate_constants import (
23
23
  BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
24
24
  BANNED_NOUN_SPAN_GROUP_INDEX,
25
25
  BANNED_NOUN_VIOLATION_PATTERN,
26
+ INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
27
+ INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
28
+ INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX,
29
+ INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
30
+ INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
26
31
  HUNK_HEADER_RAW_PATTERN,
27
32
  ISOLATION_DEFINITION_LINE_GROUP_INDEX,
28
33
  ISOLATION_SPAN_GROUP_INDEX,
@@ -958,6 +963,43 @@ def banned_noun_span_range(violation_text: str) -> range | None:
958
963
  return range(definition_line, definition_line + line_span)
959
964
 
960
965
 
966
+ def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
967
+ """Return the union of both spans of a same-file inline-duplicate issue, or None.
968
+
969
+ The same-file inline-duplicate message names two functions that share a body —
970
+ the helper and the enclosing function carrying the inline copy — and the live
971
+ Write/Edit hook scopes the violation by the UNION of both spans, blocking when
972
+ an edit touches either function. So the message carries both spans: ``(inline
973
+ duplicate body spans: helper at line H spanning P lines, enclosing at line E
974
+ spanning Q lines)``. The two spans can be disjoint (an unrelated function may
975
+ sit between the helper and its inline copy), so this returns the union as a
976
+ line-number set rather than a single contiguous range — a range covering the
977
+ gap would wrongly block an edit confined to that intervening function, which
978
+ the PreToolUse path leaves unflagged.
979
+
980
+ Args:
981
+ violation_text: A single violation string emitted by the enforcer.
982
+
983
+ Returns:
984
+ The frozenset of every line in the helper span and the enclosing span, or
985
+ None when the text is not a same-file inline-duplicate violation.
986
+ """
987
+ span_match = INLINE_DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
988
+ if span_match is None:
989
+ return None
990
+ helper_line = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX))
991
+ helper_span = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX))
992
+ enclosing_line = int(
993
+ span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX)
994
+ )
995
+ enclosing_span = int(
996
+ span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX)
997
+ )
998
+ helper_lines = range(helper_line, helper_line + helper_span)
999
+ enclosing_lines = range(enclosing_line, enclosing_line + enclosing_span)
1000
+ return frozenset(helper_lines) | frozenset(enclosing_lines)
1001
+
1002
+
961
1003
  def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
962
1004
  return (
963
1005
  function_length_span_range,
@@ -1003,10 +1045,15 @@ def split_violations_by_scope(
1003
1045
 
1004
1046
  Returns:
1005
1047
  Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
1006
- None, every issue is blocking. Every diff-scoped violation
1007
- (function-length, HOME/TMP isolation, banned-noun) carries an
1008
- enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
1009
- through one shared extractor registry; such a violation is blocking
1048
+ None, every issue is blocking. A same-file inline-duplicate violation
1049
+ carries both the helper span and the enclosing span;
1050
+ ``inline_duplicate_body_span_lines`` reconstructs their union as a
1051
+ line-number set, and the violation is blocking when an added line falls
1052
+ in either span — matching the live Write/Edit hook's union scoping. Every
1053
+ other diff-scoped violation (function-length, HOME/TMP isolation,
1054
+ banned-noun) carries one enclosing-unit span fragment that
1055
+ ``enclosing_span_range`` reconstructs through one shared extractor
1056
+ registry; such a violation is blocking
1010
1057
  when its declared span intersects the added lines (the unit grew or its
1011
1058
  signature changed in this diff) and advisory otherwise (a pre-existing
1012
1059
  untouched unit). Every other issue is blocking when its ``Line N:``
@@ -1017,6 +1064,13 @@ def split_violations_by_scope(
1017
1064
  blocking: list[str] = []
1018
1065
  advisory: list[str] = []
1019
1066
  for each_issue in all_issues:
1067
+ inline_duplicate_lines = inline_duplicate_body_span_lines(each_issue)
1068
+ if inline_duplicate_lines is not None:
1069
+ if inline_duplicate_lines & all_added_line_numbers:
1070
+ blocking.append(each_issue)
1071
+ else:
1072
+ advisory.append(each_issue)
1073
+ continue
1020
1074
  span_range = enclosing_span_range(each_issue)
1021
1075
  if span_range is not None:
1022
1076
  if any(each_line in all_added_line_numbers for each_line in span_range):
@@ -31,6 +31,15 @@ BANNED_NOUN_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
31
31
  BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX: int = 1
32
32
  BANNED_NOUN_SPAN_GROUP_INDEX: int = 2
33
33
 
34
+ INLINE_DUPLICATE_BODY_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
35
+ r"\(inline duplicate body spans: helper at line (\d+) spanning (\d+) lines, "
36
+ r"enclosing at line (\d+) spanning (\d+) lines\)"
37
+ )
38
+ INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX: int = 1
39
+ INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX: int = 2
40
+ INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX: int = 3
41
+ INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX: int = 4
42
+
34
43
  ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
35
44
  {".py", ".js", ".ts", ".tsx", ".jsx"}
36
45
  )
@@ -1022,3 +1022,45 @@ def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
1022
1022
  "a staged deletion has no staged blob; the gate must skip it cleanly "
1023
1023
  "rather than fail closed as if the file were unreadable"
1024
1024
  )
1025
+
1026
+
1027
+ _INLINE_DUPLICATE_MESSAGE = (
1028
+ "Function '_wait_for_render' duplicates an inline block in '_navigate_then_wait'"
1029
+ " — this function body is also present inline (Reuse before create / DRY) "
1030
+ "(inline duplicate body spans: helper at line 4 spanning 10 lines, "
1031
+ "enclosing at line 16 spanning 11 lines)"
1032
+ )
1033
+
1034
+
1035
+ def test_inline_duplicate_body_span_lines_unions_helper_and_enclosing_spans() -> None:
1036
+ """The same-file inline-duplicate message carries both spans, and the gate
1037
+ recovers their union as a line-number set so a touch of either function blocks —
1038
+ mirroring the live Write/Edit hook's union scoping."""
1039
+ span_lines = gate_module.inline_duplicate_body_span_lines(_INLINE_DUPLICATE_MESSAGE)
1040
+ assert span_lines == frozenset(range(4, 14)) | frozenset(range(16, 27))
1041
+
1042
+
1043
+ def test_inline_duplicate_blocks_when_only_enclosing_copy_added() -> None:
1044
+ """An added line in the enclosing span alone blocks, because the live hook scopes
1045
+ by the union of both spans and blocks the same edit — the common shape where a
1046
+ block is copied INTO a growing enclosing function, leaving the helper untouched."""
1047
+ added_line_in_enclosing_only = 18
1048
+ blocking, advisory = gate_module.split_violations_by_scope(
1049
+ [_INLINE_DUPLICATE_MESSAGE],
1050
+ all_added_line_numbers={added_line_in_enclosing_only},
1051
+ )
1052
+ assert blocking == [_INLINE_DUPLICATE_MESSAGE]
1053
+ assert advisory == []
1054
+
1055
+
1056
+ def test_inline_duplicate_advises_when_gap_line_added() -> None:
1057
+ """An edit confined to the gap between the helper span (4-13) and the enclosing
1058
+ span (16-26) must not block, matching the live hook. The union set keeps the gap
1059
+ out of scope where a single contiguous range would wrongly block it."""
1060
+ gap_line_between_spans = 14
1061
+ blocking, advisory = gate_module.split_violations_by_scope(
1062
+ [_INLINE_DUPLICATE_MESSAGE],
1063
+ all_added_line_numbers={gap_line_between_spans},
1064
+ )
1065
+ assert advisory == [_INLINE_DUPLICATE_MESSAGE]
1066
+ assert blocking == []