claude-dev-env 1.81.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.
@@ -35,7 +35,11 @@ const HEADLESS_SAFETY_PREAMBLE =
35
35
  '- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
36
36
  '- Keep scratch files and cleanup inside the OS temp dir; never target a repository or worktree path.\n' +
37
37
  '- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution and on backtick subshells. The compound path additionally fails closed on any $ in the target — including $CLAUDE_JOB_DIR. The standalone path declines a $-bearing target only when the literal path is not already under an ephemeral root, so it does not by itself stop a $VAR that expands inside an ephemeral root. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Even so, prefer a Python helper for any cleanup whose path is variable-built or whose setup/teardown spans multiple steps: author the helper file and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely and never depends on which auto-allow path matches.\n' +
38
- '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
38
+ '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n' +
39
+ 'WAITS AND POLLS — foreground sleep is blocked in this headless harness: a bare Bash "sleep N" or a PowerShell "Start-Sleep" is denied, and a wait you move to a background process — then end your turn to await it — never resumes, because a schema-bearing agent runs for a single turn. Therefore:\n' +
40
+ '- Perform every required delay or poll-interval wait inside this same turn by pairing the Monitor tool with a bounded until-loop: the Monitor tool streams its events to you while you keep working, and the until-loop re-runs the step condition on the interval the step names, up to the attempt budget the step names, exiting the moment the condition holds or the budget is spent — consume the Monitor notifications as they arrive rather than ending your turn to await them. Never end your turn to wait for something to finish.\n' +
41
+ '- Size the Monitor to the whole wait: its timeout_ms defaults to 300000 (300 seconds) and the Monitor is killed the moment that elapses, so a poll whose interval or whose interval-times-attempts span runs longer registers a false time-out. Before you arm a Monitor, set timeout_ms to at least the poll interval times the attempt count the step names — up to the 3600000ms ceiling — or pass persistent: true so the Monitor is never timed out. A 360-second interval, or a many-minute total, exceeds the 300000 default, so arming the Monitor with the default truncates the wait; set timeout_ms to the step span instead.\n' +
42
+ '- When your run was given a result schema, your final action is always the StructuredOutput call. If the poll budget is spent before the awaited signal arrives, call StructuredOutput with the whole time-out result the step documents — for the Copilot gate, the full down result {sha, clean:false, down:true, findings:[]}, never a bare down flag — rather than ending the turn without a result.\n\n'
39
43
 
40
44
  let activeRepoPath = null
41
45
 
@@ -102,7 +106,7 @@ function runGitTask(task, head) {
102
106
  `Report whether ${prCoordinates} (HEAD ${head}) has merge conflicts with its base branch. Do not edit, commit, push, or rebase — read only.\n\n` +
103
107
  `GitHub computes mergeability asynchronously, so .mergeable is null right after a push until it finishes. Poll until it resolves: run\n` +
104
108
  ` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq '{mergeable: .mergeable, state: .mergeable_state}'\n` +
105
- `up to 5 times, 5 seconds apart (delay each retry with "sleep 5", or the PowerShell alternative "Start-Sleep -Seconds 5"), stopping as soon as mergeable is true or false.\n\n` +
109
+ `up to 5 times, 5 seconds apart (wait each 5-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above), stopping as soon as mergeable is true or false.\n\n` +
106
110
  `Return conflicting:true when mergeable is false or state is "dirty" (the branch conflicts with the base). Return conflicting:false when mergeable is true, or when mergeable stays null after the full poll budget — mergeability is unknown, so let the bug checks proceed rather than rebase on a guess.`,
107
111
  { label: 'git-utility', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },
108
112
  )
@@ -205,7 +209,7 @@ async function fixerWithRecovery(head, findings, sourceLabel) {
205
209
  * Each task carries its own edit instructions.
206
210
  * @param {string} task the short task name
207
211
  * @param {object} context task-specific context
208
- * @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('hardening-commit', 'standards-resolve-threads') runs
212
+ * @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('standards-resolve-threads') runs
209
213
  */
210
214
  function runCodeEditorTask(task, context) {
211
215
  const label = `code-editor:${task}`
@@ -312,9 +316,10 @@ function runCodeEditorTask(task, context) {
312
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` +
313
317
  `Rules:\n` +
314
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` +
315
- `- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n\n` +
316
- `Return a one-line summary naming the hardening PR URL.`,
317
- { label, phase: 'Converge', agentType: 'clean-coder' },
319
+ `- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n` +
320
+ `- Title the PR as a Conventional Commit — a type prefix (feat, fix, chore, docs, refactor, perf, ci, style, test, build, revert), an optional scope in parentheses, then a colon and a short summary, e.g. "feat(hooks): block the deferred violation class". The target repo's CI validates the PR title as a semantic commit and rejects a non-conforming title.\n\n` +
321
+ `Return the full https URL of the DRAFT hardening PR in hardeningPrUrl (empty string when no PR was opened) and a one-line summary.`,
322
+ { label, phase: 'Converge', schema: HARDENING_COMMIT_SCHEMA, agentType: 'clean-coder' },
318
323
  )
319
324
  }
320
325
  if (task === 'commit-recover') {
@@ -583,6 +588,16 @@ const STANDARDS_EDIT_SCHEMA = {
583
588
  required: ['issueUrl', 'hardeningRepoPath', 'hardeningBranch', 'hardeningEdited', 'summary'],
584
589
  }
585
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
+
586
601
  /**
587
602
  * Build the verdict-fence step instructions for a verify agent, binding the
588
603
  * surface hash by branch name rather than by a self-resolved cwd. Resolving
@@ -1105,7 +1120,7 @@ function runBugbotLens(head) {
1105
1120
  ` Only count entries whose commit_id starts with ${head}.\n` +
1106
1121
  ` - If findings exist on HEAD -> return them (each with its inline comment id in replyToCommentId when present, else null).\n` +
1107
1122
  ` - If a clean review exists on HEAD -> return clean.\n` +
1108
- `4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (delay each iteration with "sleep 60", or the PowerShell alternative "Start-Sleep -Seconds 60") for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", delay 8 seconds with "sleep 8" (PowerShell alternative "Start-Sleep -Seconds 8"), then poll as above.\n` +
1123
+ `4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (wait each 60-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above) for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", wait 8 seconds inside this turn with the Monitor tool (per the WAITS AND POLLS rule above), then poll as above.\n` +
1109
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` +
1110
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.`,
1111
1126
  { label: 'lens:bugbot', phase: 'Converge', schema: LENS_SCHEMA },
@@ -1300,7 +1315,7 @@ function runCopilotGate(head) {
1300
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` +
1301
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` +
1302
1317
  ` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
1303
- `2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (delay each attempt with "sleep 360", or the PowerShell alternative "Start-Sleep -Seconds 360"). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
1318
+ `2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (wait each 360-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above; if the attempt budget is spent with no review on HEAD, return down: true). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
1304
1319
  ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
1305
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` +
1306
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` +
@@ -1436,6 +1451,25 @@ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
1436
1451
  : `${base} — verify it lands (no environment-hardening PR was opened for this run)`
1437
1452
  }
1438
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]) }
1471
+ }
1472
+
1439
1473
  /**
1440
1474
  * Defer a standards-only round: edit (clean-coder files the follow-up fix issue,
1441
1475
  * stages an environment-hardening hooks/rules change in the config repo's
@@ -1451,28 +1485,30 @@ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
1451
1485
  * @param {Array<object>} findings deduped code-standard-only findings
1452
1486
  * @param {string} sourceLabel short description of where the findings came from
1453
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
1454
- * @returns {Promise<object>} `{ followUpIssueFiled, issueUrl, hardeningPrOpened }` — 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 only when the hardening PR was opened on this call
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
1455
1489
  */
1456
1490
  async function spawnStandardsFollowUp(head, findings, sourceLabel, hasHardeningPrAlreadyOpened) {
1457
1491
  const editResult = await runCodeEditorTask('standards-edit', { head, findings, sourceLabel })
1458
1492
  const followUpIssueFiled = typeof editResult?.issueUrl === 'string' && editResult.issueUrl.length > 0
1459
1493
  const followUpIssueUrl = followUpIssueFiled ? editResult.issueUrl : ''
1460
1494
  if (hasHardeningPrAlreadyOpened === true) {
1461
- return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1495
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1462
1496
  }
1463
1497
  if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
1464
- return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1498
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1465
1499
  }
1466
1500
  const verifyTranscript = await runVerifierTask('hardening-verify', {
1467
1501
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch,
1468
1502
  })
1469
1503
  if (!verdictPassed(verifyTranscript)) {
1470
- return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1504
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
1471
1505
  }
1472
- await runCodeEditorTask('hardening-commit', {
1506
+ const commitResult = await runCodeEditorTask('hardening-commit', {
1473
1507
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch, issueUrl: editResult.issueUrl,
1474
1508
  })
1475
- return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: true }
1509
+ const deferredPr = parseDeferredPr(commitResult?.hardeningPrUrl)
1510
+ const hardeningPrOpened = typeof commitResult?.hardeningPrUrl === 'string' && commitResult.hardeningPrUrl.length > 0
1511
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened, deferredPr }
1476
1512
  }
1477
1513
 
1478
1514
  /**
@@ -1525,13 +1561,13 @@ async function resolveStandardsThreadsForBatch(head, findings, sourceLabel) {
1525
1561
  * @param {string} head PR HEAD SHA the findings were raised against
1526
1562
  * @param {Array<object>} findings deduped code-standard-only findings
1527
1563
  * @param {string} sourceLabel short description of where the findings came from
1528
- * @returns {Promise<boolean>} true when a hardening PR was opened for this run
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
1529
1565
  */
1530
1566
  async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
1531
1567
  if (!shouldOpenStandardsFollowUp(hasStandardsFollowUpFiled)) {
1532
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'}`)
1533
1569
  await resolveStandardsThreadsForBatch(head, findings, sourceLabel)
1534
- return wasStandardsHardeningPrOpened
1570
+ return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: null }
1535
1571
  }
1536
1572
  const standardsOutcome = await spawnStandardsFollowUp(head, findings, sourceLabel, wasStandardsHardeningPrOpened)
1537
1573
  hasStandardsFollowUpFiled = standardsOutcome?.followUpIssueFiled === true
@@ -1539,7 +1575,7 @@ async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
1539
1575
  standardsFollowUpIssueUrl = standardsOutcome.issueUrl
1540
1576
  }
1541
1577
  wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened || standardsOutcome?.hardeningPrOpened === true
1542
- return wasStandardsHardeningPrOpened
1578
+ return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: standardsOutcome?.deferredPr ?? null }
1543
1579
  }
1544
1580
 
1545
1581
  let phase = 'CONVERGE'
@@ -1555,6 +1591,7 @@ let hasStandardsFollowUpFiled = false
1555
1591
  let wasStandardsHardeningPrOpened = false
1556
1592
  let standardsFollowUpIssueUrl = ''
1557
1593
  let reuseNote = null
1594
+ const deferredPrs = []
1558
1595
 
1559
1596
  const preflightHead = await runGitTask('resolve-head')
1560
1597
  if (isResolvedHeadUsable(preflightHead?.sha)) {
@@ -1607,8 +1644,9 @@ while (iterations < CONFIG.maxIterations) {
1607
1644
  const findings = roundOutcome.findings
1608
1645
  if (isStandardsOnlyRound(findings)) {
1609
1646
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
1610
- const wasHardeningPrOpened = await openStandardsFollowUpOnce(head, findings, 'converge-round')
1611
- standardsNote = standardsDeferralNote(findings.length, wasHardeningPrOpened)
1647
+ const standardsOutcome = await openStandardsFollowUpOnce(head, findings, 'converge-round')
1648
+ standardsNote = standardsDeferralNote(findings.length, standardsOutcome.hardeningPrOpened)
1649
+ if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
1612
1650
  const auditResult = await runGeneralUtilityTask('post-clean-audit', { head })
1613
1651
  if (!auditResult?.posted) {
1614
1652
  blocker = cleanAuditBlocker(head, auditResult)
@@ -1670,8 +1708,9 @@ while (iterations < CONFIG.maxIterations) {
1670
1708
  if (copilotOutcome.kind === 'fix') {
1671
1709
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
1672
1710
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
1673
- const wasHardeningPrOpened = await openStandardsFollowUpOnce(head, copilotOutcome.findings, 'copilot')
1674
- standardsNote = standardsDeferralNote(copilotOutcome.findings.length, wasHardeningPrOpened)
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)
1675
1714
  copilotDown = false
1676
1715
  copilotNote = null
1677
1716
  phase = 'FINALIZE'
@@ -1707,7 +1746,7 @@ while (iterations < CONFIG.maxIterations) {
1707
1746
  const readyResult = await runGeneralUtilityTask('mark-ready', { head, copilotDown })
1708
1747
  const readyOutcome = classifyReadyOutcome(readyResult)
1709
1748
  if (readyOutcome.converged) {
1710
- return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote }
1749
+ return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote, deferredPrs }
1711
1750
  }
1712
1751
  blocker = readyOutcome.blocker
1713
1752
  break
@@ -1727,4 +1766,5 @@ return {
1727
1766
  standardsNote,
1728
1767
  copilotNote,
1729
1768
  reuseNote,
1769
+ deferredPrs,
1730
1770
  }
@@ -126,7 +126,7 @@ function childRunInput(prEntry) {
126
126
 
127
127
  const multiInput = classifyMultiInput(args)
128
128
  if (multiInput.blocker) {
129
- return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
129
+ return { converged: false, prCount: 0, convergedCount: 0, results: [], allDeferredPrs: [], blocker: multiInput.blocker }
130
130
  }
131
131
  const input = multiInput.input
132
132
 
@@ -147,6 +147,7 @@ const childResults = await parallel(
147
147
  rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
148
148
  finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
149
149
  blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
150
+ deferredPrs: childOutcome && Array.isArray(childOutcome.deferredPrs) ? childOutcome.deferredPrs : [],
150
151
  }
151
152
  }),
152
153
  )
@@ -161,17 +162,20 @@ const results = childResults.map((eachResult, eachIndex) =>
161
162
  rounds: null,
162
163
  finalSha: null,
163
164
  blocker: 'child run threw or was skipped before returning an outcome',
165
+ deferredPrs: [],
164
166
  }
165
167
  : eachResult,
166
168
  )
167
169
 
168
170
  const convergedCount = results.filter((eachResult) => eachResult.converged).length
169
- log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged`)
171
+ const allDeferredPrs = results.flatMap((eachResult) => eachResult.deferredPrs)
172
+ log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged, ${allDeferredPrs.length} deferred hardening PR(s) opened`)
170
173
 
171
174
  return {
172
175
  converged: convergedCount === results.length,
173
176
  prCount: results.length,
174
177
  convergedCount,
175
178
  results,
179
+ allDeferredPrs,
176
180
  blocker: null,
177
181
  }
@@ -21,6 +21,19 @@ const productionModule = new Function(
21
21
  )();
22
22
  const { normalizeMultiInput, classifyMultiInput, childRunInput } = productionModule;
23
23
 
24
+ const AsyncFunction = async function () {}.constructor;
25
+ const workflowBodySource = multiSource.slice(multiSource.indexOf('function normalizeMultiInput('));
26
+ const runMultiWorkflowBody = new AsyncFunction(
27
+ 'args',
28
+ 'phase',
29
+ 'log',
30
+ 'parallel',
31
+ 'workflow',
32
+ workflowBodySource,
33
+ );
34
+
35
+ function ignoreCall() {}
36
+
24
37
  const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
25
38
 
26
39
  function validEntry(prNumber) {
@@ -124,3 +137,16 @@ test('childRunInput forwards bugbotDisabled true when the entry opts out', () =>
124
137
  test('childRunInput defaults bugbotDisabled to false when the entry omits it', () => {
125
138
  assert.equal(childRunInput(validEntry(398)).bugbotDisabled, false);
126
139
  });
140
+
141
+ test('the malformed-input blocker return carries an empty allDeferredPrs list', async () => {
142
+ const blockerOutcome = await runMultiWorkflowBody(
143
+ 'not json at all',
144
+ ignoreCall,
145
+ ignoreCall,
146
+ ignoreCall,
147
+ ignoreCall,
148
+ );
149
+ assert.notEqual(blockerOutcome.blocker, null);
150
+ assert.equal(blockerOutcome.prCount, 0);
151
+ assert.deepEqual(blockerOutcome.allDeferredPrs, []);
152
+ });