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.
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +54 -4
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +0 -3
- package/hooks/blocking/conventional_pr_title_gate.py +444 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +3 -0
- package/hooks/hooks_constants/blocking_check_limits.py +9 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +92 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/gotchas.md +5 -5
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +161 -29
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +342 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +178 -32
- package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
- 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
|
|
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:
|
|
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 (
|
|
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,
|
|
204
|
-
* commit-recover, verify-recover).
|
|
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
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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}
|
|
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,
|
|
1447
|
+
function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
|
|
1403
1448
|
const base = `${findingsCount} code-standard finding(s) deferred to a follow-up fix issue`
|
|
1404
|
-
return
|
|
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
|
|
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
|
|
1418
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
|
1505
|
-
standardsNote = standardsDeferralNote(findings.length, standardsOutcome
|
|
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
|
|
1568
|
-
standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome
|
|
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
|
-
|
|
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
|
+
});
|