diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 7f5496ee2c..f9a9e46861 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -107,6 +107,30 @@ on: description: "Refresh audit state without running review or apply work" required: false default: "false" + proof_nudges: + description: "Run proof-nudge reminder lane for PRs waiting on real behavior proof" + required: false + default: "false" + proof_nudges_execute: + description: "Post proof-nudge comments; false runs dry-run only" + required: false + default: "false" + proof_nudges_limit: + description: "Maximum proof nudges to plan or post" + required: false + default: "10" + proof_nudges_min_age_days: + description: "Minimum age in days since review/author activity before first proof nudge" + required: false + default: "5" + proof_nudges_cooldown_days: + description: "Same-head cooldown in days between proof nudges" + required: false + default: "7" + proof_nudges_item_numbers: + description: "Optional comma-separated PR numbers to inspect first" + required: false + default: "" schedule: - cron: "*/5 * * * *" # ClawHub review/apply schedules stay opt-in until the ClawSweeper app is installed there. @@ -121,6 +145,7 @@ on: - cron: "33 * * * *" - cron: "48 * * * *" - cron: "8,23,38,53 * * * *" + - cron: "41 9 * * *" permissions: contents: write @@ -133,7 +158,7 @@ env: CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093 concurrency: - group: ${{ github.event_name == 'repository_dispatch' && format('clawsweeper-event-{0}-{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || github.run_id) || format('{0}-{1}', (github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true' && github.event.inputs.apply_sync_comments_only == 'true') && 'clawsweeper-comment-sync' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *'))) && 'clawsweeper-apply' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '')) && format('clawsweeper-intake-exact-{0}', github.event.inputs.item_number || github.event.inputs.item_numbers) || ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'clawsweeper-intake-v2' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.audit_dashboard == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '7 */6 * * *' || github.event.schedule == '12 */6 * * *' || github.event.schedule == '17 */6 * * *'))) && 'clawsweeper-audit' || 'clawsweeper-review', github.event.inputs.target_repo || github.event.client_payload.target_repo || ((github.event.schedule == '17 */6 * * *') && 'openclaw/clawsweeper' || ((github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *' || github.event.schedule == '8,23,38,53 * * * *' || github.event.schedule == '12 */6 * * *') && 'openclaw/clawhub' || 'openclaw/openclaw'))) }} + group: ${{ github.event_name == 'repository_dispatch' && format('clawsweeper-event-{0}-{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || github.run_id) || format('{0}-{1}', (github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true' && github.event.inputs.apply_sync_comments_only == 'true') && 'clawsweeper-comment-sync' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *'))) && 'clawsweeper-apply' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.proof_nudges == 'true') || (github.event_name == 'schedule' && github.event.schedule == '41 9 * * *')) && 'clawsweeper-proof-nudges' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '')) && format('clawsweeper-intake-exact-{0}', github.event.inputs.item_number || github.event.inputs.item_numbers) || ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'clawsweeper-intake-v2' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.audit_dashboard == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '7 */6 * * *' || github.event.schedule == '12 */6 * * *' || github.event.schedule == '17 */6 * * *'))) && 'clawsweeper-audit' || 'clawsweeper-review', github.event.inputs.target_repo || github.event.client_payload.target_repo || ((github.event.schedule == '17 */6 * * *') && 'openclaw/clawsweeper' || ((github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *' || github.event.schedule == '8,23,38,53 * * * *' || github.event.schedule == '12 */6 * * *') && 'openclaw/clawhub' || 'openclaw/openclaw'))) }} cancel-in-progress: ${{ github.event_name == 'repository_dispatch' }} jobs: @@ -542,7 +567,7 @@ jobs: plan: name: Plan review candidates - if: ${{ github.event_name != 'repository_dispatch' && !((github.event_name == 'workflow_dispatch' && (github.event.inputs.apply_existing == 'true' || github.event.inputs.audit_dashboard == 'true')) || (github.event_name == 'schedule' && ((github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *') || (github.event.schedule == '7 */6 * * *' || github.event.schedule == '12 */6 * * *' || github.event.schedule == '17 */6 * * *')))) && !(github.event_name == 'schedule' && (github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *') && vars.CLAWSWEEPER_ENABLE_CLAWHUB != '1') }} + if: ${{ github.event_name != 'repository_dispatch' && !((github.event_name == 'workflow_dispatch' && (github.event.inputs.apply_existing == 'true' || github.event.inputs.audit_dashboard == 'true' || github.event.inputs.proof_nudges == 'true')) || (github.event_name == 'schedule' && ((github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *') || (github.event.schedule == '7 */6 * * *' || github.event.schedule == '12 */6 * * *' || github.event.schedule == '17 */6 * * *') || github.event.schedule == '41 9 * * *'))) && !(github.event_name == 'schedule' && (github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *') && vars.CLAWSWEEPER_ENABLE_CLAWHUB != '1') }} runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -1588,6 +1613,88 @@ jobs: --repo openclaw/clawsweeper-state \ --ref main || echo "Best-effort dashboard refresh dispatch failed; scheduled state dashboard will retry." + proof-nudges: + name: Proof nudges + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.proof_nudges == 'true') || (github.event_name == 'schedule' && github.event.schedule == '41 9 * * *' && vars.CLAWSWEEPER_PROOF_NUDGES_SCHEDULED == '1') }} + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + filter: blob:none + fetch-depth: 0 + + - name: Resolve target repository + id: target + run: | + target_repo="${{ github.event.inputs.target_repo || vars.CLAWSWEEPER_PROOF_NUDGES_TARGET_REPO || 'openclaw/openclaw' }}" + target_owner="${target_repo%%/*}" + target_name="${target_repo#*/}" + { + echo "target_repo=$target_repo" + echo "target_repo_owner=$target_owner" + echo "target_repo_name=$target_name" + } >> "$GITHUB_OUTPUT" + + - name: Create target proof-nudge token + id: target-proof-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + owner: ${{ steps.target.outputs.target_repo_owner }} + repositories: ${{ steps.target.outputs.target_repo_name }} + permission-contents: read + permission-issues: write + permission-pull-requests: read + + - name: Create state token + id: state-token + uses: ./.github/actions/create-state-token + with: + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + + - uses: ./.github/actions/setup-state + with: + token: ${{ steps.state-token.outputs.token }} + + - uses: ./.github/actions/setup-pnpm + with: + build-script: build:all + + - name: Run proof nudges + env: + GH_TOKEN: ${{ steps.target-proof-token.outputs.token }} + TARGET_REPO: ${{ steps.target.outputs.target_repo }} + run: | + set -euo pipefail + execute="${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.proof_nudges_execute == 'true') || (github.event_name == 'schedule' && vars.CLAWSWEEPER_PROOF_NUDGES_EXECUTE == '1') }}" + execute_arg=() + if [ "$execute" = "true" ]; then + execute_arg=(--execute) + fi + item_numbers="${{ github.event.inputs.proof_nudges_item_numbers || '' }}" + item_numbers_arg=() + if [ -n "$item_numbers" ]; then + item_numbers_arg=(--item-numbers "$item_numbers") + fi + pnpm run proof-nudges -- \ + --target-repo "$TARGET_REPO" \ + --limit "${{ github.event.inputs.proof_nudges_limit || vars.CLAWSWEEPER_PROOF_NUDGES_LIMIT || '10' }}" \ + --min-age-days "${{ github.event.inputs.proof_nudges_min_age_days || vars.CLAWSWEEPER_PROOF_NUDGES_MIN_AGE_DAYS || '5' }}" \ + --cooldown-days "${{ github.event.inputs.proof_nudges_cooldown_days || vars.CLAWSWEEPER_PROOF_NUDGES_COOLDOWN_DAYS || '7' }}" \ + --report-path proof-nudge-report.json \ + "${item_numbers_arg[@]}" \ + "${execute_arg[@]}" + + - uses: actions/upload-artifact@v7 + if: ${{ always() }} + with: + name: proof-nudge-report + path: proof-nudge-report.json + if-no-files-found: ignore + apply-existing: name: Apply close proposals if: ${{ ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *'))) && !(github.event_name == 'schedule' && github.event.schedule == '8,23,38,53 * * * *' && vars.CLAWSWEEPER_ENABLE_CLAWHUB != '1') }} diff --git a/README.md b/README.md index 548eda0e0f..ac64677aa1 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,12 @@ proof, supplied-but-not-sufficient proof, mock-only proof, and proof label mismatches. See [`docs/pr-proof-triage-dashboard.md`](docs/pr-proof-triage-dashboard.md). +The optional proof-nudge lane can dry-run or post polite reminder comments for +open PRs that remain blocked on `triage: needs-real-behavior-proof`. It uses +comment-body cooldown markers, never closes PRs, and keeps scheduled operation +behind default-off repository variables. See +[`docs/proof-nudges.md`](docs/proof-nudges.md). + ## How It Works ClawSweeper is split into four operational lanes: diff --git a/docs/proof-nudges.md b/docs/proof-nudges.md new file mode 100644 index 0000000000..0d4efe10fb --- /dev/null +++ b/docs/proof-nudges.md @@ -0,0 +1,80 @@ +# ClawSweeper Proof Nudges + +Read when changing the ClawSweeper lane that reminds pull request authors to add real behavior proof. + +## Scope + +The proof-nudge lane is read-mostly triage hygiene. It can post a polite reminder comment on open pull requests that are stuck on `triage: needs-real-behavior-proof`, but it does not close pull requests, merge pull requests, change labels, request reviews, or modify review records. + +The lane uses the latest ClawSweeper review report plus the live pull request state. It does not scrape the visible review comment for policy. + +## Eligibility + +A pull request is eligible only when all of these are true: + +- The live item is an open pull request. +- The live pull request still has `triage: needs-real-behavior-proof`. +- The latest report still says real behavior proof blocks merge. +- The report head SHA matches the live pull request head SHA. +- The pull request is past the first-nudge age gate, defaulting to 5 days. +- The author has not commented recently and the head commit is not recent. +- There is no same-head proof nudge inside the cooldown window, defaulting to 7 days. + +The lane skips maintainer-authored, bot-authored, security-sensitive, and release-style pull requests. It also skips pull requests with `proof: supplied`, `proof: sufficient`, or `proof: override`, because those need review or policy handling rather than another contributor reminder. + +## Marker + +Cooldown state lives in the reminder comment body: + +```html + +``` + +The marker records the pull request number, reviewed head SHA, timestamp, and marker version. This avoids label churn and keeps the reminder state tied to the exact head that was nudged. + +## Command + +Dry-run is the default: + +```bash +pnpm run proof-nudges -- --target-repo openclaw/openclaw +``` + +Post comments only with an explicit execute flag: + +```bash +pnpm run proof-nudges -- --target-repo openclaw/openclaw --execute --limit 10 +``` + +Useful options: + +- `--limit`: maximum nudges to plan or post, default `10`. +- `--processed-limit`: maximum records to inspect in one run. +- `--min-age-days`: first-nudge age gate, default `5`. +- `--cooldown-days`: same-head cooldown, default `7`. +- `--item-numbers`: comma-separated pull request numbers for a targeted dry-run or execute run. +- `--report-path`: JSON output path, default `proof-nudge-report.json`. +- `--max-runtime-ms`: optional hard runtime stop. + +## Workflow Use + +The ClawSweeper workflow exposes this as a manual `workflow_dispatch` lane. It defaults to dry-run. Use `proof_nudges_execute=true` only after reviewing a dry-run report. + +The workflow also includes a daily scheduled lane at `41 9 * * *`, but it is off by default. This lets maintainers enable scheduled proof nudges later without another code change. + +Scheduled operation uses repository variables: + +- `CLAWSWEEPER_PROOF_NUDGES_SCHEDULED=1`: enable the scheduled lane. Without this, the daily schedule is skipped. +- `CLAWSWEEPER_PROOF_NUDGES_EXECUTE=1`: allow the scheduled lane to post comments. Without this, scheduled runs remain dry-run only. +- `CLAWSWEEPER_PROOF_NUDGES_TARGET_REPO`: optional target repo, default `openclaw/openclaw`. +- `CLAWSWEEPER_PROOF_NUDGES_LIMIT`: optional scheduled batch size, default `10`. +- `CLAWSWEEPER_PROOF_NUDGES_MIN_AGE_DAYS`: optional first-nudge age gate, default `5`. +- `CLAWSWEEPER_PROOF_NUDGES_COOLDOWN_DAYS`: optional same-head cooldown, default `7`. + +Suggested rollout: + +1. Run manually with `proof_nudges=true` and `proof_nudges_execute=false`. +2. Set `CLAWSWEEPER_PROOF_NUDGES_SCHEDULED=1` to collect scheduled dry-run reports. +3. Set `CLAWSWEEPER_PROOF_NUDGES_EXECUTE=1` only after the scheduled reports look correct. + +This first version intentionally has no auto-close behavior. Any escalation after repeated proof nudges needs a separate maintainer policy decision. diff --git a/package.json b/package.json index e9a89c6e03..79e39bfa46 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "review": "node dist/clawsweeper.js review", "apply-artifacts": "node dist/clawsweeper.js apply-artifacts", "apply-decisions": "node dist/clawsweeper.js apply-decisions", + "proof-nudges": "node dist/clawsweeper.js proof-nudges", "audit": "node dist/clawsweeper.js audit", "reconcile": "node dist/clawsweeper.js reconcile", "status": "node dist/clawsweeper.js status", diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index e0dfe16cdc..b22ce8be43 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -642,6 +642,88 @@ interface ApplyResult { reason: string; } +type ProofNudgeAction = + | "proof_nudge_posted" + | "proof_nudge_planned" + | "skipped_not_pull_request" + | "skipped_not_open" + | "skipped_missing_label" + | "skipped_policy_exempt" + | "skipped_stale_report_head" + | "skipped_recent_author_activity" + | "skipped_recent_review" + | "skipped_recent_nudge" + | "skipped_maintainer_authored" + | "skipped_bot_authored" + | "skipped_protected_label" + | "skipped_locked_conversation" + | "skipped_no_live_head" + | "skipped_runtime_budget"; + +interface ProofNudgeResult { + repo?: string | undefined; + number: number; + action: ProofNudgeAction; + reason: string; + url?: string | undefined; + headSha?: string | undefined; + reviewedAt?: string | undefined; +} + +interface ProofNudgeComment { + author?: string | undefined; + body?: string | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; +} + +interface ProofNudgeMarker { + item?: number; + sha?: string; + at?: string; + v?: string; +} + +interface ProofNudgeEligibilityOptions { + item: Pick< + Item, + | "kind" + | "number" + | "title" + | "author" + | "authorAssociation" + | "updatedAt" + | "labels" + | "locked" + | "activeLockReason" + >; + markdown: string; + comments?: readonly ProofNudgeComment[]; + headSha?: string | undefined; + headCommittedAt?: string | undefined; + now?: number; + minAgeDays?: number; + cooldownDays?: number; +} + +interface ProofNudgeEligibility { + eligible: boolean; + action: Exclude< + ProofNudgeAction, + "proof_nudge_posted" | "skipped_not_open" | "skipped_runtime_budget" + >; + reason: string; + latestActivityAt?: string | undefined; + latestNudgeAt?: string | undefined; +} + +interface ProofNudgePullRequestDetails { + headSha?: string | undefined; + headRepo?: string | undefined; + headCommittedAt?: string | undefined; + draft?: boolean; +} + interface ReconcileResult { openItemsSeen: number; pagesScanned: number; @@ -755,6 +837,13 @@ const AUTOMERGE_LABEL = "clawsweeper:automerge"; const AUTOFIX_LABEL = "clawsweeper:autofix"; const PROOF_OVERRIDE_LABEL = "proof: override"; const PROOF_SUFFICIENT_LABEL = "proof: sufficient"; +const PROOF_SUPPLIED_LABEL = "proof: supplied"; +const REAL_BEHAVIOR_PROOF_REQUIRED_LABEL = "triage: needs-real-behavior-proof"; +const PROOF_NUDGE_MARKER_PREFIX = "`; +} + +function isProofNudgeMarkerWhitespace(char: string | undefined): boolean { + return char === " " || char === "\n" || char === "\r" || char === "\t" || char === "\f"; +} + +function parseProofNudgeMarkerAttributes(text: string): ProofNudgeMarker { + const marker: ProofNudgeMarker = {}; + const attributes = text.slice(0, 512); + let index = 0; + while (index < attributes.length) { + while (index < attributes.length && isProofNudgeMarkerWhitespace(attributes[index])) index += 1; + const keyStart = index; + const first = attributes.charCodeAt(index); + if (!((first >= 65 && first <= 90) || (first >= 97 && first <= 122))) { + index += 1; + continue; + } + index += 1; + while (index < attributes.length) { + const char = attributes.charCodeAt(index); + const isAttributeKeyChar = + (char >= 65 && char <= 90) || + (char >= 97 && char <= 122) || + (char >= 48 && char <= 57) || + char === 45 || + char === 95; + if (!isAttributeKeyChar) break; + index += 1; + } + const key = attributes.slice(keyStart, index).toLowerCase(); + while (index < attributes.length && isProofNudgeMarkerWhitespace(attributes[index])) index += 1; + if (attributes[index] !== "=") continue; + index += 1; + while (index < attributes.length && isProofNudgeMarkerWhitespace(attributes[index])) index += 1; + let value = ""; + if (attributes[index] === '"') { + index += 1; + const valueStart = index; + while (index < attributes.length && attributes[index] !== '"') index += 1; + value = attributes.slice(valueStart, index); + if (attributes[index] === '"') index += 1; + } else { + const valueStart = index; + while ( + index < attributes.length && + attributes[index] !== ">" && + !isProofNudgeMarkerWhitespace(attributes[index]) + ) { + index += 1; + } + value = attributes.slice(valueStart, index); + } + if (key === "item") { + const item = Number(value); + if (Number.isInteger(item) && item > 0) marker.item = item; + } else if (key === "sha") { + marker.sha = value; + } else if (key === "at") { + marker.at = value; + } else if (key === "v") { + marker.v = value; + } + } + return marker; +} + +function proofNudgeMarkersFromCommentBody(body: string | undefined): ProofNudgeMarker[] { + if (!body?.includes(PROOF_NUDGE_MARKER_PREFIX)) return []; + const markers: ProofNudgeMarker[] = []; + let index = 0; + while (index < body.length && markers.length < 20) { + const markerStart = body.indexOf(PROOF_NUDGE_MARKER_PREFIX, index); + if (markerStart < 0) break; + const attributesStart = markerStart + PROOF_NUDGE_MARKER_PREFIX.length; + const markerEnd = body.indexOf("-->", attributesStart); + if (markerEnd < 0) break; + markers.push(parseProofNudgeMarkerAttributes(body.slice(attributesStart, markerEnd))); + index = markerEnd + 3; + } + return markers; +} + +function isProofNudgeMarkerCommentAuthor(author: string | undefined): boolean { + const login = author?.trim(); + return Boolean(login && PATCHABLE_REVIEW_COMMENT_AUTHORS.has(login)); +} + +function proofNudgeMarkersFromComments(comments: readonly ProofNudgeComment[]): ProofNudgeMarker[] { + return comments + .filter((comment) => isProofNudgeMarkerCommentAuthor(comment.author)) + .flatMap((comment) => proofNudgeMarkersFromCommentBody(comment.body)); +} + +function latestIsoTimestamp(values: readonly (string | undefined)[]): string | undefined { + let latest: string | undefined; + for (const value of values) latest = latestTimestamp(latest, value); + return latest; +} + +function latestAuthorCommentAt( + comments: readonly ProofNudgeComment[], + author: string | undefined, +): string | undefined { + const normalizedAuthor = author?.trim().toLowerCase(); + if (!normalizedAuthor) return undefined; + return latestIsoTimestamp( + comments + .filter((comment) => comment.author?.toLowerCase() === normalizedAuthor) + .map((comment) => comment.updatedAt ?? comment.createdAt), + ); +} + +function latestProofNudgeAt( + comments: readonly ProofNudgeComment[], + options: { number: number; headSha: string }, +): string | undefined { + return latestIsoTimestamp( + proofNudgeMarkersFromComments(comments) + .filter( + (marker) => + marker.item === options.number && + marker.sha === options.headSha && + timestampMs(marker.at) !== null, + ) + .map((marker) => marker.at), + ); +} + +function staleProofNudgeReportHead(markdown: string, headSha: string | undefined): boolean { + if (!headSha) return false; + const reportHeadSha = frontMatterValue(markdown, "pull_head_sha"); + const normalizedReportHeadSha = reportHeadSha?.trim() ?? ""; + if (!normalizedReportHeadSha || normalizedReportHeadSha.toLowerCase() === "unknown") return true; + return normalizedReportHeadSha !== headSha.trim(); +} + +function proofNudgeEligibility(options: ProofNudgeEligibilityOptions): ProofNudgeEligibility { + const now = options.now ?? Date.now(); + const minAgeMs = Math.max(0, options.minAgeDays ?? DEFAULT_PROOF_NUDGE_MIN_AGE_DAYS) * DAY_MS; + const cooldownMs = + Math.max(0, options.cooldownDays ?? DEFAULT_PROOF_NUDGE_COOLDOWN_DAYS) * DAY_MS; + const comments = options.comments ?? []; + if (options.item.kind !== "pull_request") { + return { + eligible: false, + action: "skipped_not_pull_request", + reason: `type is ${options.item.kind}`, + }; + } + if (!hasNormalizedLabel(options.item.labels, REAL_BEHAVIOR_PROOF_REQUIRED_LABEL)) { + return { + eligible: false, + action: "skipped_missing_label", + reason: `${REAL_BEHAVIOR_PROOF_REQUIRED_LABEL} is not present`, + }; + } + const lockedReason = lockedConversationApplyReason(options.item); + if (lockedReason) { + return { + eligible: false, + action: "skipped_locked_conversation", + reason: lockedReason, + }; + } + if ( + hasNormalizedLabel(options.item.labels, PROOF_OVERRIDE_LABEL) || + hasNormalizedLabel(options.item.labels, PROOF_SUFFICIENT_LABEL) || + hasNormalizedLabel(options.item.labels, PROOF_SUPPLIED_LABEL) + ) { + return { + eligible: false, + action: "skipped_policy_exempt", + reason: "proof is already supplied, sufficient, or overridden", + }; + } + if (isMaintainerAuthored(options.item)) { + return { + eligible: false, + action: "skipped_maintainer_authored", + reason: `author association is ${normalizeAuthorAssociation(options.item.authorAssociation)}`, + }; + } + if (isAutomationReportAuthor(options.item.author)) { + return { + eligible: false, + action: "skipped_bot_authored", + reason: `author is ${options.item.author}`, + }; + } + const protectedLabelsForNudge = proofNudgeProtectedLabels(options.item.labels); + if (protectedLabelsForNudge.length > 0 || isReleaseTitle(options.item.title)) { + const reason = protectedLabelsForNudge.length + ? `protected label: ${protectedLabelsForNudge.join(", ")}` + : "release-style PR title"; + return { eligible: false, action: "skipped_protected_label", reason }; + } + if (!realBehaviorProofNeedsContributorNudge(options.markdown)) { + return { + eligible: false, + action: "skipped_policy_exempt", + reason: "latest ClawSweeper proof policy does not require contributor action", + }; + } + if (!options.headSha) { + return { + eligible: false, + action: "skipped_no_live_head", + reason: "live PR head SHA could not be inspected", + }; + } + if (staleProofNudgeReportHead(options.markdown, options.headSha)) { + return { + eligible: false, + action: "skipped_stale_report_head", + reason: "live PR head SHA differs from the reviewed report", + }; + } + const reviewedAt = frontMatterValue(options.markdown, "reviewed_at"); + if (reviewedAt && !isOlderThanMs(reviewedAt, minAgeMs, now)) { + return { + eligible: false, + action: "skipped_recent_review", + reason: `latest ClawSweeper review is newer than ${options.minAgeDays ?? DEFAULT_PROOF_NUDGE_MIN_AGE_DAYS} day(s)`, + latestActivityAt: reviewedAt, + }; + } + const latestActivityAt = latestIsoTimestamp([ + latestAuthorCommentAt(comments, options.item.author), + options.item.updatedAt, + options.headCommittedAt, + ]); + if (latestActivityAt && !isOlderThanMs(latestActivityAt, minAgeMs, now)) { + return { + eligible: false, + action: "skipped_recent_author_activity", + reason: `author comment, PR update, or head commit is newer than ${options.minAgeDays ?? DEFAULT_PROOF_NUDGE_MIN_AGE_DAYS} day(s)`, + latestActivityAt, + }; + } + const latestNudgeAt = latestProofNudgeAt(comments, { + number: options.item.number, + headSha: options.headSha, + }); + if (latestNudgeAt && !isOlderThanMs(latestNudgeAt, cooldownMs, now)) { + return { + eligible: false, + action: "skipped_recent_nudge", + reason: `same-head nudge is inside the ${options.cooldownDays ?? DEFAULT_PROOF_NUDGE_COOLDOWN_DAYS} day cooldown`, + latestNudgeAt, + }; + } + return { + eligible: true, + action: "proof_nudge_planned", + reason: "needs real behavior proof and is past the nudge age/cooldown gates", + latestActivityAt, + latestNudgeAt, + }; +} + +export function proofNudgeEligibilityForTest( + options: ProofNudgeEligibilityOptions, +): ProofNudgeEligibility { + return proofNudgeEligibility(options); +} + +function renderProofNudgeComment(options: { + item: Pick; + headSha: string; + timestamp: string; +}): string { + const mention = + options.item.author && !isAutomationReportAuthor(options.item.author) + ? `@${options.item.author} ` + : ""; + return [ + `${mention}thanks for the PR. ClawSweeper is still waiting on real behavior proof before this can move forward.`, + "", + "Useful proof can be a screenshot, short video, terminal output, copied live output, linked artifact, or redacted logs that show the changed behavior after the fix. Please redact private tokens, phone numbers, private endpoints, customer data, and anything else sensitive.", + "", + "Once proof is added to the PR body or a comment, ClawSweeper or a maintainer can re-check it.", + "", + proofNudgeMarker({ + number: options.item.number, + headSha: options.headSha, + timestamp: options.timestamp, + }), + ].join("\n"); +} + +export function renderProofNudgeCommentForTest(options: { + number: number; + author?: string; + headSha?: string; + timestamp?: string; +}): string { + return renderProofNudgeComment({ + item: { + number: options.number, + author: options.author ?? "contributor", + }, + headSha: options.headSha ?? "abc123def456", + timestamp: options.timestamp ?? "2026-01-10T00:00:00.000Z", + }); +} + function parseBoldListHeading(line: string): { label: string; detail: string } | null { const prefix = "- **"; if (!line.startsWith(prefix)) return null; @@ -10417,6 +10856,251 @@ function applyDecisionsCommand(args: Args): void { console.log(JSON.stringify(results, null, 2)); } +function proofNudgeComments(number: number): ProofNudgeComment[] { + return ghPaged(`repos/${targetRepo()}/issues/${number}/comments`).map((comment) => { + const record = asRecord(comment); + return { + author: login(record.user), + body: stringOrUndefined(record.body), + createdAt: stringOrUndefined(record.created_at), + updatedAt: stringOrUndefined(record.updated_at), + }; + }); +} + +function fetchPullRequestProofNudgeDetails(number: number): ProofNudgePullRequestDetails { + const pull = ghJson>([ + "api", + `repos/${targetRepo()}/pulls/${number}`, + "--jq", + "{draft,head:{sha:.head.sha,repo:{full_name:(.head.repo.full_name // null)}}}", + ]); + const head = asRecord(pull.head); + const headRepo = stringOrUndefined(asRecord(head.repo).full_name); + const headSha = stringOrUndefined(head.sha); + const details: ProofNudgePullRequestDetails = { + headSha, + headRepo, + draft: pull.draft === true, + }; + if (!headRepo || !headSha) return details; + try { + const commit = ghJson>([ + "api", + `repos/${headRepo}/commits/${headSha}`, + "--jq", + "{authorDate:.commit.author.date,committerDate:.commit.committer.date}", + ]); + details.headCommittedAt = latestIsoTimestamp([ + stringOrUndefined(commit.authorDate), + stringOrUndefined(commit.committerDate), + ]); + } catch (error) { + console.warn( + `Skipping head commit date for #${number}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return details; +} + +function postProofNudgeComment(number: number, body: string): Record { + const payload = writeCommentPayload(number, body); + return ghJson>([ + "api", + `repos/${targetRepo()}/issues/${number}/comments`, + "--method", + "POST", + "--input", + payload, + "--jq", + "{id,html_url}", + ]); +} + +function proofNudgeCandidateRecords( + itemsDir: string, + requestedItemNumbers: readonly number[], +): { + file: string; + markdown: string; + number: number; + likelyProofNudge: boolean; + reviewedAt?: string | undefined; + sortAt: number; +}[] { + const requested = new Set(requestedItemNumbers); + return markdownFiles(itemsDir) + .map((file) => { + const path = join(itemsDir, file); + const markdown = readFileSync(path, "utf8"); + const number = numberForMarkdownFile(file); + const reviewedAt = frontMatterValue(markdown, "reviewed_at"); + const sortAt = + timestampMs(reviewedAt) ?? + timestampMs(frontMatterValue(markdown, "item_updated_at")) ?? + Number.NEGATIVE_INFINITY; + const likelyProofNudge = realBehaviorProofNeedsContributorNudge(markdown); + return { file, markdown, number, likelyProofNudge, reviewedAt, sortAt }; + }) + .filter(({ file, markdown, number }) => { + if (requested.size > 0 && !requested.has(number)) return false; + if (!isMarkdownForActiveRepo(markdown, join(itemsDir, file))) return false; + if (frontMatterValue(markdown, "type") !== "pull_request") return false; + return true; + }) + .sort( + (left, right) => + Number(right.likelyProofNudge) - Number(left.likelyProofNudge) || + left.sortAt - right.sortAt || + left.number - right.number, + ); +} + +export function proofNudgeCandidateRecordsForTest( + itemsDir: string, + requestedItemNumbers: readonly number[] = [], +): ReturnType { + return proofNudgeCandidateRecords(itemsDir, requestedItemNumbers); +} + +function proofNudgesCommand(args: Args): void { + repoFromArgs(args); + const itemsDir = resolve(stringArg(args.items_dir, defaultItemsDir())); + const limit = Math.max(0, numberArg(args.limit, DEFAULT_PROOF_NUDGE_LIMIT)); + const processedLimit = Math.max(1, numberArg(args.processed_limit, Math.max(limit * 20, 50))); + const minAgeDays = Math.max(0, numberArg(args.min_age_days, DEFAULT_PROOF_NUDGE_MIN_AGE_DAYS)); + const cooldownDays = Math.max( + 0, + numberArg(args.cooldown_days, DEFAULT_PROOF_NUDGE_COOLDOWN_DAYS), + ); + const execute = boolArg(args.execute); + const dryRun = !execute; + const maxRuntimeMs = numberArg(args.max_runtime_ms, 0); + const reportPath = resolve(stringArg(args.report_path, join(ROOT, "proof-nudge-report.json"))); + const requestedItemNumbers = itemNumbersArg(args.item_numbers, args.item_number); + const startedAtMs = Date.now(); + const results: ProofNudgeResult[] = []; + let processedCount = 0; + let nudgeCount = 0; + + if (!existsSync(itemsDir)) { + ensureDir(dirname(reportPath)); + writeFileSync(reportPath, JSON.stringify(results, null, 2), "utf8"); + console.log(JSON.stringify(results, null, 2)); + return; + } + + const candidates = proofNudgeCandidateRecords(itemsDir, requestedItemNumbers); + const logProgress = (message: string): void => { + const counts = results.reduce>((accumulator, result) => { + accumulator[result.action] = (accumulator[result.action] ?? 0) + 1; + return accumulator; + }, {}); + console.error( + [ + `[proof-nudges] ${new Date().toISOString()} ${message}`, + `nudges=${nudgeCount}/${limit}`, + `processed=${processedCount}/${processedLimit}`, + `dry_run=${dryRun}`, + `counts=${JSON.stringify(counts)}`, + ].join(" "), + ); + }; + + logProgress( + `starting proof nudges: candidates=${candidates.length} min_age_days=${minAgeDays} cooldown_days=${cooldownDays} item_numbers=${requestedItemNumbers.join(",") || "all"}`, + ); + for (const candidate of candidates) { + if (nudgeCount >= limit) break; + if (processedCount >= processedLimit) break; + if (runtimeBudgetExceeded(startedAtMs, maxRuntimeMs, Date.now())) { + results.push({ + repo: targetRepo(), + number: 0, + action: "skipped_runtime_budget", + reason: `max runtime ${maxRuntimeMs}ms reached`, + }); + logProgress(`stopping proof nudges: max runtime ${maxRuntimeMs}ms reached`); + break; + } + + const resultBase = { + repo: markdownRepository(candidate.markdown, join(itemsDir, candidate.file)), + number: candidate.number, + reviewedAt: candidate.reviewedAt, + }; + const { item, state } = fetchItem(candidate.number); + if (state !== "open") { + results.push({ + ...resultBase, + action: "skipped_not_open", + reason: `state is ${state}`, + }); + processedCount += 1; + continue; + } + const pullDetails = + item.kind === "pull_request" ? fetchPullRequestProofNudgeDetails(candidate.number) : {}; + const comments = item.kind === "pull_request" ? proofNudgeComments(candidate.number) : []; + const eligibility = proofNudgeEligibility({ + item, + markdown: candidate.markdown, + comments, + headSha: pullDetails.headSha, + headCommittedAt: pullDetails.headCommittedAt, + minAgeDays, + cooldownDays, + }); + if (!eligibility.eligible) { + results.push({ + ...resultBase, + action: eligibility.action, + reason: eligibility.reason, + headSha: pullDetails.headSha, + }); + processedCount += 1; + continue; + } + + const timestamp = new Date().toISOString(); + const headSha = pullDetails.headSha; + if (!headSha) { + results.push({ + ...resultBase, + action: "skipped_no_live_head", + reason: "live PR head SHA could not be inspected", + }); + processedCount += 1; + continue; + } + const body = renderProofNudgeComment({ item, headSha, timestamp }); + if (dryRun) { + results.push({ + ...resultBase, + action: "proof_nudge_planned", + reason: eligibility.reason, + headSha, + }); + } else { + const comment = postProofNudgeComment(candidate.number, body); + results.push({ + ...resultBase, + action: "proof_nudge_posted", + reason: eligibility.reason, + url: commentUrl(comment) ?? undefined, + headSha, + }); + } + processedCount += 1; + nudgeCount += 1; + logProgress(`${dryRun ? "planned" : "posted"} proof nudge #${candidate.number}`); + } + ensureDir(dirname(reportPath)); + writeFileSync(reportPath, JSON.stringify(results, null, 2), "utf8"); + logProgress("finished proof nudges"); + console.log(JSON.stringify(results, null, 2)); +} + function applyArtifactsCommand(args: Args): void { repoFromArgs(args); const artifactDir = resolve(stringArg(args.artifact_dir, "artifacts")); @@ -11718,6 +12402,7 @@ export function main(argv = process.argv.slice(2)): void { else if (command === "review") reviewCommand(args); else if (command === "apply-artifacts") applyArtifactsCommand(args); else if (command === "apply-decisions") applyDecisionsCommand(args); + else if (command === "proof-nudges") proofNudgesCommand(args); else if (command === "audit") auditCommand(args); else if (command === "reconcile") reconcileCommand(args); else if (command === "dashboard") { diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index e3c510e533..72c7ff78ab 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -64,9 +64,12 @@ import { prStatusLabelSchemeForTest, priorityLabelsForTest, priorityLabelSchemeForTest, + proofNudgeCandidateRecordsForTest, + proofNudgeEligibilityForTest, protectedLabels, realBehaviorProofSufficientLabelsForTest, relatedTitleSearchTerms, + renderProofNudgeCommentForTest, renderReviewStartStatusComment, reviewArtifactDestination, reviewAutomationMarkersFromReport, @@ -6192,6 +6195,333 @@ test("ClawSweeper proof label sync recognizes missing optional labels", () => { ); }); +function proofNudgeReport(overrides = {}) { + const values = { + labels: JSON.stringify(["triage: needs-real-behavior-proof"]), + authorAssociation: "CONTRIBUTOR", + author: "contributor", + reviewStatus: "complete", + headSha: "abc123def456", + reviewedAt: "2026-01-01T00:00:00Z", + pullFiles: JSON.stringify(["src/app.ts"]), + proofStatus: "missing", + evidenceKind: "none", + needsContributorAction: "true", + proofSummary: "The PR needs after-fix proof from a real setup.", + ...overrides, + }; + return `--- +repository: openclaw/openclaw +number: 42 +type: pull_request +title: Proof nudge sample +author: ${values.author} +author_association: ${values.authorAssociation} +labels: ${values.labels} +review_status: ${values.reviewStatus} +pull_files: ${values.pullFiles} +pull_files_truncated: false +pull_head_sha: ${values.headSha} +reviewed_at: ${values.reviewedAt} +--- + +## Real Behavior Proof + +Status: ${values.proofStatus} + +Evidence kind: ${values.evidenceKind} + +Needs contributor action: ${values.needsContributorAction} + +Summary: ${values.proofSummary} +`; +} + +function proofNudgeItem(overrides = {}) { + return item({ + kind: "pull_request", + number: 42, + title: "Proof nudge sample", + author: "contributor", + authorAssociation: "CONTRIBUTOR", + labels: ["triage: needs-real-behavior-proof"], + locked: false, + activeLockReason: null, + ...overrides, + }); +} + +test("proof nudge candidate scan defers proof-label checks to live PR state", () => { + const root = mkdtempSync(tmpPrefix); + try { + writeFileSync(join(root, "41.md"), proofNudgeReport({ reviewedAt: "2026-01-05T00:00:00Z" })); + writeFileSync( + join(root, "42.md"), + `--- +repository: openclaw/openclaw +number: 42 +type: pull_request +labels: [] +review_status: complete +reviewed_at: 2026-01-01T00:00:00Z +--- + +## Summary + +Stored report label snapshots can be older than the live PR labels. +`, + ); + + const requestedCandidates = proofNudgeCandidateRecordsForTest(root, [42]); + const allCandidates = proofNudgeCandidateRecordsForTest(root); + + assert.deepEqual( + requestedCandidates.map((candidate) => candidate.number), + [42], + ); + assert.deepEqual( + allCandidates.map((candidate) => candidate.number), + [41, 42], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("proof nudges become eligible only after proof policy and age gates pass", () => { + const result = proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport(), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }); + + assert.equal(result.eligible, true); + assert.equal(result.action, "proof_nudge_planned"); +}); + +test("proof nudges skip recent reviews and recent author activity", () => { + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport({ reviewedAt: "2026-01-08T00:00:00Z" }), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }).action, + "skipped_recent_review", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport(), + comments: [ + { + author: "contributor", + body: "I'll add proof later.", + updatedAt: "2026-01-09T00:00:00Z", + }, + ], + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }).action, + "skipped_recent_author_activity", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem({ updatedAt: "2026-01-09T00:00:00Z" }), + markdown: proofNudgeReport(), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }).action, + "skipped_recent_author_activity", + ); +}); + +test("proof nudges use same-head cooldown markers instead of label churn", () => { + const comment = renderProofNudgeCommentForTest({ + number: 42, + author: "contributor", + headSha: "abc123def456", + timestamp: "2026-01-08T00:00:00.000Z", + }); + const result = proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport(), + comments: [{ author: "clawsweeper[bot]", body: comment, updatedAt: "2026-01-08T00:00:00Z" }], + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }); + + assert.equal(result.eligible, false); + assert.equal(result.action, "skipped_recent_nudge"); + assert.match(comment, /`, + updatedAt: "2026-01-08T00:00:00Z", + }, + ], + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + minAgeDays: 5, + cooldownDays: 7, + }); + + assert.equal(malformedMarker.eligible, true); + assert.equal(malformedMarker.action, "proof_nudge_planned"); +}); + +test("proof nudges skip stale report heads and proof-policy exemptions", () => { + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport({ headSha: "oldhead" }), + headSha: "newhead", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + "skipped_stale_report_head", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport({ headSha: "unknown" }), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + "skipped_stale_report_head", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem({ labels: ["triage: needs-real-behavior-proof", "proof: override"] }), + markdown: proofNudgeReport({ + labels: JSON.stringify(["triage: needs-real-behavior-proof", "proof: override"]), + }), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + "skipped_policy_exempt", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem(), + markdown: proofNudgeReport({ + proofStatus: "not_applicable", + needsContributorAction: "false", + reviewStatus: "failed", + }), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + "skipped_policy_exempt", + ); + assert.equal( + proofNudgeEligibilityForTest({ + item: proofNudgeItem({ labels: ["triage: needs-real-behavior-proof", "proof: supplied"] }), + markdown: proofNudgeReport(), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + "skipped_policy_exempt", + ); +}); + +test("proof nudges skip maintainer, bot, security, and release PRs", () => { + for (const [input, expected] of [ + [proofNudgeItem({ authorAssociation: "MEMBER" }), "skipped_maintainer_authored"], + [proofNudgeItem({ author: "dependabot[bot]" }), "skipped_bot_authored"], + [ + proofNudgeItem({ + labels: ["triage: needs-real-behavior-proof", "clawsweeper:needs-security-review"], + }), + "skipped_protected_label", + ], + [proofNudgeItem({ title: "Release 1.2.3" }), "skipped_protected_label"], + [proofNudgeItem({ title: "chore(release): 1.2.3" }), "skipped_protected_label"], + ] as const) { + assert.equal( + proofNudgeEligibilityForTest({ + item: input, + markdown: proofNudgeReport({ + author: input.author, + authorAssociation: input.authorAssociation, + labels: JSON.stringify(input.labels), + }), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }).action, + expected, + ); + } +}); + +test("proof nudges skip locked PR conversations before posting", () => { + const result = proofNudgeEligibilityForTest({ + item: proofNudgeItem({ locked: true, activeLockReason: "resolved" }), + markdown: proofNudgeReport(), + headSha: "abc123def456", + headCommittedAt: "2026-01-01T00:00:00Z", + now: Date.parse("2026-01-10T00:00:00Z"), + }); + + assert.equal(result.eligible, false); + assert.equal(result.action, "skipped_locked_conversation"); + assert.match(result.reason, /conversation is locked \(resolved\)/); +}); + +test("proof nudge copy asks for evidence without promising author-only re-review access", () => { + const comment = renderProofNudgeCommentForTest({ + number: 42, + author: "contributor", + headSha: "abc123def456", + }); + + assert.match(comment, /^@contributor thanks for the PR\./); + assert.match(comment, /screenshot, short video, terminal output/); + assert.match(comment, /ClawSweeper or a maintainer can re-check it/); + assert.doesNotMatch(comment, /@clawsweeper re-review/); +}); + test("ClawSweeper PR rating labels use one themed overall label", () => { assert.deepEqual(prRatingLabelsForTest(["bug"], "A"), ["bug", "rating: 🦞 diamond lobster"]); assert.deepEqual( @@ -6907,6 +7237,33 @@ test("review workflow gives Codex a read-only inspection token", () => { assert.match(workflow, /CLAWSWEEPER_PROOF_INSPECTION_TOKEN/); }); +test("proof nudge workflow is manual-first and scheduled behind repo vars", () => { + const workflow = readFileSync(".github/workflows/sweep.yml", "utf8"); + const job = workflow.slice( + workflow.indexOf(" proof-nudges:"), + workflow.indexOf("\n apply-existing:"), + ); + const planCondition = workflow.slice( + workflow.indexOf(" plan:"), + workflow.indexOf("\n runs-on:", workflow.indexOf(" plan:")), + ); + const concurrency = workflow.slice(workflow.indexOf("concurrency:"), workflow.indexOf("\njobs:")); + + assert.match(workflow, /proof_nudges_execute:[\s\S]*?default: "false"/); + assert.match(workflow, /cron: "41 9 \* \* \*"/); + assert.match(concurrency, /github\.event\.schedule == '41 9 \* \* \*'/); + assert.match(concurrency, /'clawsweeper-proof-nudges'/); + assert.match(job, /github\.event\.inputs\.proof_nudges == 'true'/); + assert.match(job, /vars\.CLAWSWEEPER_PROOF_NUDGES_SCHEDULED == '1'/); + assert.match(job, /vars\.CLAWSWEEPER_PROOF_NUDGES_EXECUTE == '1'/); + assert.match(job, /execute_arg=\(\)/); + assert.match(job, /if \[ "\$execute" = "true" \]/); + assert.match(job, /pnpm run proof-nudges/); + assert.match(job, /vars\.CLAWSWEEPER_PROOF_NUDGES_LIMIT/); + assert.match(planCondition, /github\.event\.inputs\.proof_nudges == 'true'/); + assert.match(planCondition, /github\.event\.schedule == '41 9 \* \* \*'/); +}); + test("read-only checkout mode restores file modes and leaves git metadata writable", () => { const root = mkdtempSync(tmpPrefix); try {