diff --git a/.agents/recipes/issue-triage/recipe.md b/.agents/recipes/issue-triage/recipe.md
new file mode 100644
index 000000000..396b0667a
--- /dev/null
+++ b/.agents/recipes/issue-triage/recipe.md
@@ -0,0 +1,186 @@
+---
+name: issue-triage
+description: Weekly triage of open issues and PRs - classify, verify, detect staleness, duplicates, and cross-reference
+trigger: schedule
+tool: claude-code
+timeout_minutes: 15
+max_turns: 30
+permissions:
+ contents: read
+ issues: write
+ pull-requests: read
+---
+
+# Repository Triage
+
+Triage all open issues and pull requests in this repository, then post a
+combined report to the tracking issue.
+
+## Instructions
+
+### 1. Gather data
+
+Collect all open issues, open PRs, and recent merge activity:
+
+```bash
+# All open issues with metadata
+gh issue list --state open --limit 200 \
+ --json number,title,state,createdAt,updatedAt,labels,assignees,author,body
+
+# All open PRs with metadata
+gh pr list --state open --limit 200 \
+ --json number,title,state,createdAt,updatedAt,labels,author,headRefName,body
+
+# Recently merged PRs (last 60 days) to cross-reference
+gh pr list --state merged --limit 100 \
+ --json number,title,headRefName,body,mergedAt
+
+# PR check status for open PRs
+for pr in $(gh pr list --state open --json number --jq '.[].number'); do
+ echo "=== PR #${pr} ==="
+ gh pr checks "$pr" --json name,state --jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length'
+done
+```
+
+### 2. Triage issues
+
+For each open issue, determine:
+
+**Classification** (pick one):
+- `bug` - something is broken
+- `feature` - new capability or enhancement
+- `chore` - maintenance, CI, docs, refactoring
+- `discussion` - needs design input or decision before work starts
+
+**Staleness** (based on last update, today's date, and activity):
+- `active` - updated within the last 14 days
+- `aging` - updated 14-30 days ago
+- `stale` - no update for 30+ days
+
+**Verification** - check if the issue has been addressed:
+- Search merged PRs for closing keywords (`Fixes #N`, `Closes #N`, `Resolves #N`)
+ referencing this issue
+- Search merged PR titles and branches for keywords matching the issue
+- If a merged PR appears to fix the issue, flag it as `potentially resolved`
+- If there is an open PR linked to the issue, note the PR number
+
+**Labels as signals** - issues with `needs-attention` were flagged by the stale
+PR workflow because their linked PR was auto-closed. Always include these in the
+"Action needed" section.
+
+**Duplicates / related** - flag issues that overlap in scope or description.
+
+### 3. Triage PRs
+
+For each open PR, determine:
+
+**Health flags** (check all that apply):
+- `no-issue` - PR body has no `Fixes/Closes/Resolves #N` reference (external
+ contributors only - collaborators are exempt)
+- `issue-closed` - PR links to an issue that is already closed (by another PR
+ or manually)
+- `checks-failing` - PR has failing CI checks
+- `stale` - no author activity (push or comment) for 14+ days with failing
+ checks
+- `duplicate-fix` - another open PR references the same issue
+
+**Cross-reference** - for each PR that references an issue:
+- Verify the linked issue exists and is open
+- Check if another open or merged PR also references the same issue
+- If two open PRs fix the same issue, flag both as `duplicate-fix`
+
+### 4. Build the report
+
+Write the combined report to `/tmp/issue-triage-report.md` using this format:
+
+```markdown
+
+## Repository Triage Report
+
+**Run date:** YYYY-MM-DD
+**Open issues:** N | **Open PRs:** N
+
+---
+
+### Issues: action needed
+
+Issues that need maintainer attention (potentially resolved, stale with no
+assignee, possible duplicates, needs-attention label).
+
+| # | Title | Category | Staleness | Flag | Notes |
+|---|-------|----------|-----------|------|-------|
+
+### Issues: active work
+
+Issues with assignees or linked open PRs.
+
+| # | Title | Category | Assignee | PR | Last updated |
+|---|-------|----------|----------|-----|-------------|
+
+### Issues: backlog
+
+Remaining open issues, ordered by staleness (most stale first).
+
+| # | Title | Category | Staleness | Last updated |
+|---|-------|----------|-----------|-------------|
+
+---
+
+### PRs: action needed
+
+PRs with health flags that need maintainer attention.
+
+| # | Title | Author | Flags | Notes |
+|---|-------|--------|-------|-------|
+
+### PRs: healthy
+
+Open PRs with no flags.
+
+| # | Title | Author | Linked issue | Last updated |
+|---|-------|--------|-------------|-------------|
+
+---
+
+### Summary
+
+**Issues:**
+- N triaged, N flagged for action, N active, N backlog
+- Flags: X potentially resolved, Y stale, Z duplicates
+
+**PRs:**
+- N triaged, N flagged
+- Flags: X no linked issue, Y checks failing, Z stale, W duplicate fixes
+```
+
+### 5. Post the report
+
+Find the tracking issue number from the `ISSUE_TRIAGE_TRACKING_ISSUE`
+environment variable. Find the last comment by `github-actions[bot]` that
+contains `` and note its ID.
+
+- If a previous comment exists, **edit it in place** using
+ `gh api -X PATCH repos/{owner}/{repo}/issues/comments/{id}`.
+- If no previous comment exists, post a new comment using `gh issue comment`.
+
+```bash
+# Edit existing comment
+gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \
+ -f body="$(cat /tmp/issue-triage-report.md)"
+
+# Or post new comment
+gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md
+```
+
+## Constraints
+
+- **Read-only triage.** Do not close, label, or modify any issues or PRs. The
+ report is for maintainers to act on.
+- **Do not post the report yourself if you cannot find the tracking issue.**
+ Write the report to `/tmp/issue-triage-report.md` and stop. The workflow
+ will handle fallback posting.
+- **Stay concise.** Notes columns should be one sentence max. Link to the
+ relevant PR, issue, or duplicate - don't explain the fix.
+- **Cost awareness.** Do not read full issue/PR bodies unless needed to
+ determine duplicates or verify cross-references. The metadata from
+ `gh issue list` and `gh pr list` is enough for most checks.
diff --git a/.github/workflows/agentic-ci-issue-triage.yml b/.github/workflows/agentic-ci-issue-triage.yml
new file mode 100644
index 000000000..f1c36b6fe
--- /dev/null
+++ b/.github/workflows/agentic-ci-issue-triage.yml
@@ -0,0 +1,152 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: "Agentic CI: Repository Triage"
+
+on:
+ schedule:
+ - cron: "0 10 * * 1" # every Monday at 10:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: read
+
+concurrency:
+ group: agentic-ci-issue-triage
+ cancel-in-progress: true
+
+jobs:
+ triage:
+ if: github.repository_owner == 'NVIDIA-NeMo'
+ runs-on: [self-hosted, agentic-ci]
+ timeout-minutes: 15
+ steps:
+ - name: Check required config
+ env:
+ AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
+ TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
+ run: |
+ if [ -z "$AGENTIC_CI_MODEL" ]; then
+ echo "::error::AGENTIC_CI_MODEL variable is not set. Configure it in repo settings."
+ exit 1
+ fi
+ if [ -z "$TRACKING_ISSUE" ]; then
+ echo "::error::ISSUE_TRIAGE_TRACKING_ISSUE variable is not set. Create a pinned issue and set the variable."
+ exit 1
+ fi
+
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Pre-flight checks
+ env:
+ ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }}
+ ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }}
+ AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
+ run: |
+ if ! command -v claude &> /dev/null; then
+ echo "::error::claude CLI not found in PATH"
+ exit 1
+ fi
+ echo "Claude CLI version: $(claude --version 2>&1 || true)"
+
+ if [ -n "$ANTHROPIC_BASE_URL" ] && [ -n "$ANTHROPIC_API_KEY" ]; then
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+ --max-time 10 \
+ -X POST "${ANTHROPIC_BASE_URL}/v1/messages" \
+ -H "Content-Type: application/json" \
+ -H "x-api-key: ${ANTHROPIC_API_KEY}" \
+ -H "anthropic-version: 2023-06-01" \
+ -d "{\"model\":\"${AGENTIC_CI_MODEL}\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")
+ if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
+ echo "::error::API pre-flight failed with HTTP ${HTTP_CODE}"
+ exit 1
+ fi
+ echo "API pre-flight passed (HTTP ${HTTP_CODE})"
+ fi
+
+ - name: Run issue triage recipe
+ env:
+ ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }}
+ ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }}
+ AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
+ DISABLE_PROMPT_CACHING: "1"
+ GH_TOKEN: ${{ github.token }}
+ ISSUE_TRIAGE_TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ set -o pipefail
+
+ RUNNER_CTX=$(cat .agents/recipes/_runner.md)
+ RECIPE_BODY=$(cat .agents/recipes/issue-triage/recipe.md \
+ | sed '1,/^---$/{ /^---$/,/^---$/d }')
+
+ PROMPT=$(printf '%s\n\n%s\n' "${RUNNER_CTX}" "${RECIPE_BODY}")
+
+ claude \
+ --model "$AGENTIC_CI_MODEL" \
+ -p "$PROMPT" \
+ --max-turns 30 \
+ --output-format text \
+ --verbose \
+ 2>&1 | tee /tmp/claude-triage-log.txt || true
+ continue-on-error: true
+
+ - name: Fallback post if agent did not post
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
+ run: |
+ if [ ! -s "/tmp/issue-triage-report.md" ]; then
+ echo "::warning::Triage report not created by agent."
+ exit 0
+ fi
+
+ # Check if the agent already posted/updated the comment.
+ MARKER=""
+ EXISTING=$(gh api "repos/${{ github.repository }}/issues/${TRACKING_ISSUE}/comments" \
+ --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${MARKER}\"))] | last | .id" \
+ 2>/dev/null || echo "")
+
+ REPORT=$(cat /tmp/issue-triage-report.md)
+
+ # Only post if the report marker is not already in a recent comment
+ # with today's date (agent already posted).
+ TODAY=$(date -u +%Y-%m-%d)
+ if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
+ EXISTING_BODY=$(gh api "repos/${{ github.repository }}/issues/comments/${EXISTING}" --jq '.body')
+ if echo "$EXISTING_BODY" | grep -q "$TODAY"; then
+ echo "Agent already posted today's report. Skipping fallback."
+ exit 0
+ fi
+ # Update existing comment.
+ gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
+ -f body="$REPORT"
+ echo "Updated existing triage comment."
+ else
+ gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md
+ echo "Posted new triage comment."
+ fi
+
+ - name: Write job summary
+ if: always()
+ run: |
+ if [ -s "/tmp/issue-triage-report.md" ]; then
+ cat /tmp/issue-triage-report.md >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "No triage report was generated." >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ if [ -s "/tmp/claude-triage-log.txt" ]; then
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "Agent log
" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ tail -100 /tmp/claude-triage-log.txt >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ echo " " >> "$GITHUB_STEP_SUMMARY"
+ fi
diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml
new file mode 100644
index 000000000..4842a0464
--- /dev/null
+++ b/.github/workflows/pr-linked-issue.yml
@@ -0,0 +1,258 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: "Linked Issue Check"
+
+on:
+ # Re-check when PR is opened or body is edited (author adds Fixes #N).
+ pull_request_target:
+ types: [opened, edited, synchronize, reopened]
+ branches: [main]
+
+ # Re-check open PRs when a maintainer adds the "triaged" label to an issue.
+ issues:
+ types: [labeled]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: read
+
+jobs:
+ # ── Job 1: validate linked issue on PR events ─────────────────────────
+ # SECURITY: This workflow uses pull_request_target to get write access for
+ # posting comments on fork PRs. It MUST NOT check out or execute code from
+ # the PR branch. All inputs from the PR (body, author) are read via API
+ # only. Adding actions/checkout here would run untrusted fork code with
+ # base repo write permissions.
+ check:
+ if: >-
+ github.repository_owner == 'NVIDIA-NeMo'
+ && github.event_name != 'issues'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check author permissions
+ id: author
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
+ run: |
+ USER="$PR_AUTHOR"
+
+ # Bots that are always allowed (match DCO allowlist pattern).
+ if [ "$USER" = "dependabot[bot]" ]; then
+ echo "is_collaborator=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${USER}/permission" \
+ --jq '.permission' 2>/dev/null || echo "none")
+ echo "permission=${PERMISSION}"
+
+ if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then
+ echo "is_collaborator=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "is_collaborator=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Parse issue reference from PR body
+ id: parse
+ if: steps.author.outputs.is_collaborator != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_BODY: ${{ github.event.pull_request.body }}
+ run: |
+ if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then
+ echo "issue_num=" >> "$GITHUB_OUTPUT"
+ echo "No PR body found"
+ exit 0
+ fi
+
+ # Case-insensitive match for Fixes #N, Closes #N, Resolves #N.
+ printf '%s' "$PR_BODY" > /tmp/pr-body-raw.txt
+ ISSUE_NUM=$(grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' /tmp/pr-body-raw.txt | head -1 || true)
+ echo "issue_num=${ISSUE_NUM}" >> "$GITHUB_OUTPUT"
+ echo "Parsed issue number: ${ISSUE_NUM:-}"
+
+ - name: Validate issue exists and is triaged
+ id: validate
+ if: steps.author.outputs.is_collaborator != 'true' && steps.parse.outputs.issue_num != ''
+ env:
+ GH_TOKEN: ${{ github.token }}
+ ISSUE_NUM: ${{ steps.parse.outputs.issue_num }}
+ run: |
+ RESPONSE=$(gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" 2>/dev/null) || {
+ echo "issue_exists=false" >> "$GITHUB_OUTPUT"
+ echo "is_triaged=false" >> "$GITHUB_OUTPUT"
+ echo "Issue #${ISSUE_NUM} not found"
+ exit 0
+ }
+
+ # Verify it's an issue, not a PR (GitHub's issues API returns both).
+ IS_PR=$(echo "$RESPONSE" | jq -r 'has("pull_request")')
+ if [ "$IS_PR" = "true" ]; then
+ echo "issue_exists=false" >> "$GITHUB_OUTPUT"
+ echo "is_triaged=false" >> "$GITHUB_OUTPUT"
+ echo "#${ISSUE_NUM} is a pull request, not an issue"
+ exit 0
+ fi
+
+ echo "issue_exists=true" >> "$GITHUB_OUTPUT"
+
+ TRIAGED=$(echo "$RESPONSE" | jq -r '[.labels[].name] | any(. == "triaged")')
+ echo "is_triaged=${TRIAGED}" >> "$GITHUB_OUTPUT"
+ echo "Issue #${ISSUE_NUM} exists, triaged=${TRIAGED}"
+
+ - name: Build comment body and post result
+ id: comment
+ env:
+ GH_TOKEN: ${{ github.token }}
+ IS_COLLABORATOR: ${{ steps.author.outputs.is_collaborator }}
+ ISSUE_NUM: ${{ steps.parse.outputs.issue_num }}
+ ISSUE_EXISTS: ${{ steps.validate.outputs.issue_exists }}
+ IS_TRIAGED: ${{ steps.validate.outputs.is_triaged }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ MARKER=""
+
+ # Find existing bot comment with our marker.
+ COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
+ --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${MARKER}\"))] | last | .id // empty" \
+ 2>/dev/null || echo "")
+
+ if [ "$IS_COLLABORATOR" = "true" ]; then
+ echo "status=pass" >> "$GITHUB_OUTPUT"
+ # Clean up any leftover comment from before the author became a collaborator.
+ if [ -n "$COMMENT_ID" ]; then
+ gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" || true
+ fi
+ exit 0
+ fi
+
+ # Build comment body.
+ if [ -z "$ISSUE_NUM" ]; then
+ STATUS="fail"
+ cat > /tmp/comment-body.md <<'MSG'
+
+ ### Linked Issue Check
+
+ This PR does not reference an issue. External contributions must link to
+ a triaged issue before the PR can be merged.
+
+ Add one of the following to your PR description:
+ - `Fixes #`
+ - `Closes #`
+ - `Resolves #`
+
+ If no issue exists yet, [open one](https://github.com/NVIDIA-NeMo/DataDesigner/issues/new/choose)
+ and a maintainer will triage it.
+
+ See [CONTRIBUTING.md](https://github.com/NVIDIA-NeMo/DataDesigner/blob/main/CONTRIBUTING.md)
+ for details.
+ MSG
+ elif [ "$ISSUE_EXISTS" != "true" ]; then
+ STATUS="fail"
+ cat > /tmp/comment-body.md <
+ ### Linked Issue Check
+
+ The referenced issue #${ISSUE_NUM} was not found. Please check the issue
+ number in your PR description.
+ MSG
+ elif [ "$IS_TRIAGED" != "true" ]; then
+ STATUS="fail"
+ cat > /tmp/comment-body.md <
+ ### Linked Issue Check
+
+ Issue #${ISSUE_NUM} has not been triaged yet. A maintainer needs to review
+ the issue and add the \`triaged\` label before this PR can be merged.
+
+ You can continue working on the PR in the meantime. The check will
+ re-run automatically once the issue is triaged.
+ MSG
+ else
+ STATUS="pass"
+ fi
+
+ echo "status=${STATUS}" >> "$GITHUB_OUTPUT"
+
+ # Post, update, or delete the comment.
+ if [ "$STATUS" = "fail" ]; then
+ if [ -n "$COMMENT_ID" ]; then
+ gh api -X PATCH "repos/${REPO}/issues/comments/${COMMENT_ID}" \
+ -f body="$(cat /tmp/comment-body.md)"
+ else
+ gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
+ -f body="$(cat /tmp/comment-body.md)"
+ fi
+ elif [ -n "$COMMENT_ID" ]; then
+ gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" || true
+ fi
+
+ - name: Set check result
+ if: steps.comment.outputs.status == 'fail'
+ run: |
+ echo "::error::Linked issue check failed. See the PR comment for details."
+ exit 1
+
+ # ── Job 2: re-trigger check when an issue gets triaged ────────────────
+ retrigger:
+ if: >-
+ github.repository_owner == 'NVIDIA-NeMo'
+ && github.event_name == 'issues'
+ && github.event.label.name == 'triaged'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Find PRs referencing this issue
+ id: find-prs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ run: |
+ # List open PRs and find those whose body references this issue.
+ PRS=$(gh pr list --repo "${{ github.repository }}" --state open \
+ --json number,body --limit 200 \
+ | jq -r "[.[] | select(.body != null) | select(.body | test(\"(?i)(fixes|closes|resolves)\\\\s+#${ISSUE_NUMBER}\\\\b\")) | .number] | .[]")
+
+ if [ -z "$PRS" ]; then
+ echo "No open PRs reference issue #${ISSUE_NUMBER}"
+ echo "prs=" >> "$GITHUB_OUTPUT"
+ else
+ echo "Found PRs: ${PRS}"
+ echo "prs=$(echo "$PRS" | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Re-trigger linked issue check
+ if: steps.find-prs.outputs.prs != ''
+ env:
+ GH_TOKEN: ${{ github.token }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ PR_NUMBERS: ${{ steps.find-prs.outputs.prs }}
+ run: |
+ TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+
+ for PR_NUM in $PR_NUMBERS; do
+ echo "Re-triggering check for PR #${PR_NUM}..."
+
+ # Read current PR body to a file to avoid shell expansion issues.
+ gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q '.body' > /tmp/current-body.txt
+
+ # Append or update hidden timestamp to trigger the 'edited' event,
+ # which re-runs the check job.
+ MARKER="||" \
+ /tmp/current-body.txt > /tmp/pr-body.md
+ else
+ cp /tmp/current-body.txt /tmp/pr-body.md
+ printf '\n' "$TIMESTAMP" >> /tmp/pr-body.md
+ fi
+
+ gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body-file /tmp/pr-body.md
+
+ # Post a visible comment so the author knows what happened.
+ gh pr comment "$PR_NUM" --repo "${{ github.repository }}" --body \
+ "Issue #${ISSUE_NUMBER} has been triaged. The linked issue check is being re-evaluated."
+ done
diff --git a/.github/workflows/pr-stale.yml b/.github/workflows/pr-stale.yml
new file mode 100644
index 000000000..8efac6cd8
--- /dev/null
+++ b/.github/workflows/pr-stale.yml
@@ -0,0 +1,198 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: "Stale PR Cleanup"
+
+on:
+ schedule:
+ - cron: "0 9 * * *" # daily at 09:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ stale-check:
+ if: github.repository_owner == 'NVIDIA-NeMo'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check stale PRs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+
+ NOW=$(date -u +%s)
+
+ # Thresholds in days.
+ REMIND_DAYS_EXTERNAL=7
+ CLOSE_DAYS_EXTERNAL=14
+ REMIND_DAYS_COLLAB=14
+ CLOSE_DAYS_COLLAB=28
+
+ REMINDER_MARKER=""
+ CLOSE_MARKER=""
+
+ # Fetch all open PRs.
+ PRS=$(gh pr list --repo "$REPO" --state open \
+ --json number,author,labels,createdAt --limit 200)
+
+ PR_COUNT=$(echo "$PRS" | jq 'length')
+ echo "Found ${PR_COUNT} open PRs"
+
+ echo "$PRS" | jq -c '.[]' | while IFS= read -r PR; do
+ PR_NUM=$(echo "$PR" | jq -r '.number')
+ AUTHOR=$(echo "$PR" | jq -r '.author.login')
+ LABELS=$(echo "$PR" | jq -r '[.labels[].name] | join(",")')
+
+ echo "--- PR #${PR_NUM} by ${AUTHOR} ---"
+
+ # Skip if keep-open label is present.
+ if echo "$LABELS" | grep -q "keep-open"; then
+ echo " Skipping: has keep-open label"
+ continue
+ fi
+
+ # Check if author is a collaborator.
+ PERMISSION=$(gh api "repos/${REPO}/collaborators/${AUTHOR}/permission" \
+ --jq '.permission' 2>/dev/null || echo "none")
+
+ IS_COLLAB=false
+ if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then
+ IS_COLLAB=true
+ fi
+
+ if [ "$IS_COLLAB" = "true" ]; then
+ REMIND_DAYS=$REMIND_DAYS_COLLAB
+ CLOSE_DAYS=$CLOSE_DAYS_COLLAB
+ else
+ REMIND_DAYS=$REMIND_DAYS_EXTERNAL
+ CLOSE_DAYS=$CLOSE_DAYS_EXTERNAL
+ fi
+
+ # Check for failing checks on the PR's head commit.
+ CHECKS_JSON=$(gh pr checks "$PR_NUM" --repo "$REPO" --json name,state 2>/dev/null || echo "[]")
+ FAILING=$(echo "$CHECKS_JSON" | jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length')
+
+ if [ "$FAILING" -eq 0 ]; then
+ echo " Skipping: no failing checks"
+ continue
+ fi
+
+ FAILING_NAMES=$(echo "$CHECKS_JSON" | jq -r '[.[] | select(.state == "FAILURE" or .state == "ERROR") | .name] | join(", ")')
+ echo " Failing checks (${FAILING}): ${FAILING_NAMES}"
+
+ # Find last author activity: latest push or author comment.
+ LAST_PUSH=$(gh api "repos/${REPO}/pulls/${PR_NUM}/commits" \
+ --jq 'last | .commit.committer.date' 2>/dev/null || echo "")
+ LAST_COMMENT=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \
+ --jq "[.[] | select(.user.login == \"${AUTHOR}\") | .created_at] | last" 2>/dev/null || echo "")
+
+ # Convert to epoch, take the most recent.
+ LAST_PUSH_EPOCH=0
+ LAST_COMMENT_EPOCH=0
+ if [ -n "$LAST_PUSH" ] && [ "$LAST_PUSH" != "null" ]; then
+ LAST_PUSH_EPOCH=$(date -u -d "$LAST_PUSH" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_PUSH" +%s 2>/dev/null || echo 0)
+ fi
+ if [ -n "$LAST_COMMENT" ] && [ "$LAST_COMMENT" != "null" ]; then
+ LAST_COMMENT_EPOCH=$(date -u -d "$LAST_COMMENT" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_COMMENT" +%s 2>/dev/null || echo 0)
+ fi
+
+ if [ "$LAST_PUSH_EPOCH" -gt "$LAST_COMMENT_EPOCH" ]; then
+ LAST_ACTIVITY_EPOCH=$LAST_PUSH_EPOCH
+ else
+ LAST_ACTIVITY_EPOCH=$LAST_COMMENT_EPOCH
+ fi
+
+ if [ "$LAST_ACTIVITY_EPOCH" -eq 0 ]; then
+ # Fall back to PR creation date.
+ CREATED=$(echo "$PR" | jq -r '.createdAt')
+ LAST_ACTIVITY_EPOCH=$(date -u -d "$CREATED" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$CREATED" +%s 2>/dev/null || echo "$NOW")
+ fi
+
+ DAYS_INACTIVE=$(( (NOW - LAST_ACTIVITY_EPOCH) / 86400 ))
+ echo " Days inactive: ${DAYS_INACTIVE} (remind=${REMIND_DAYS}, close=${CLOSE_DAYS}, collab=${IS_COLLAB})"
+
+ # Check for existing reminder comment and when it was posted.
+ REMINDER_JSON=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \
+ --jq "[.[] | select(.body | contains(\"${REMINDER_MARKER}\"))] | last // empty" 2>/dev/null || echo "")
+
+ HAS_REMINDER=0
+ if [ -n "$REMINDER_JSON" ]; then
+ HAS_REMINDER=1
+ REMINDER_ID=$(echo "$REMINDER_JSON" | jq -r '.id')
+ REMINDER_DATE=$(echo "$REMINDER_JSON" | jq -r '.created_at')
+ REMINDER_EPOCH=$(date -u -d "$REMINDER_DATE" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$REMINDER_DATE" +%s 2>/dev/null || echo 0)
+
+ # If activity happened after the reminder, the author responded.
+ # Delete the stale reminder so the cycle restarts cleanly.
+ if [ "$LAST_ACTIVITY_EPOCH" -gt "$REMINDER_EPOCH" ]; then
+ echo " Activity after reminder - deleting stale reminder, resetting timer"
+ gh api -X DELETE "repos/${REPO}/issues/comments/${REMINDER_ID}" 2>/dev/null || true
+ HAS_REMINDER=0
+ fi
+ fi
+
+ if [ "$DAYS_INACTIVE" -ge "$CLOSE_DAYS" ] && [ "$HAS_REMINDER" -gt 0 ]; then
+ echo " Closing PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days, reminder was posted)"
+
+ CLOSE_BODY=$(cat < /tmp/close-body.md
+ gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/close-body.md
+ gh pr close "$PR_NUM" --repo "$REPO"
+
+ # Signal linked issues so the triage workflow picks them up.
+ gh pr view "$PR_NUM" --repo "$REPO" --json body -q '.body' > /tmp/pr-body-raw.txt 2>/dev/null || true
+ LINKED_ISSUE=$(grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' /tmp/pr-body-raw.txt | head -1 || true)
+ if [ -n "$LINKED_ISSUE" ]; then
+ ISSUE_STATE=$(gh api "repos/${REPO}/issues/${LINKED_ISSUE}" --jq '.state' 2>/dev/null || echo "")
+ if [ "$ISSUE_STATE" = "open" ]; then
+ gh issue edit "$LINKED_ISSUE" --repo "$REPO" --add-label "needs-attention" 2>/dev/null || true
+ echo " Added needs-attention label to issue #${LINKED_ISSUE}"
+ fi
+ fi
+
+ elif [ "$DAYS_INACTIVE" -ge "$REMIND_DAYS" ] && [ "$HAS_REMINDER" -eq 0 ]; then
+ echo " Posting reminder on PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days)"
+
+ GRACE_LEFT=$(( CLOSE_DAYS - DAYS_INACTIVE ))
+ if [ "$GRACE_LEFT" -lt 1 ]; then
+ GRACE_LEFT=1
+ fi
+
+ REMIND_BODY=$(cat < /tmp/remind-body.md
+ gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/remind-body.md
+
+ else
+ echo " No action needed"
+ fi
+ done
+
+ echo "Stale PR check complete"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index de1bcc561..d1aa08f12 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,7 +50,8 @@ The repository includes skills for common development tasks. These are located i
## Pull Requests
-- PRs should link to the issue they address (`Fixes #NNN` or `Closes #NNN`)
+- PRs must link to the issue they address (`Fixes #NNN` or `Closes #NNN`). For external contributors, this is enforced by a required status check: the linked issue must exist and carry the `triaged` label (added by a maintainer after review). Collaborators are exempt from this check. You can open the PR before the issue is triaged - the check re-runs automatically once a maintainer adds the label.
+- PRs with failing checks that remain inactive are automatically reminded after 7 days and closed after 14 days (collaborators: 14/28 days). Push an update or leave a comment to reset the timer. If you need more time, ask a maintainer to add the `keep-open` label.
- Use the `create-pr` skill for well-formatted PR descriptions, or follow the PR template
- Ensure all checks pass before requesting review:
```bash
diff --git a/plans/518/pr-hygiene-plan.md b/plans/518/pr-hygiene-plan.md
new file mode 100644
index 000000000..b647112d2
--- /dev/null
+++ b/plans/518/pr-hygiene-plan.md
@@ -0,0 +1,92 @@
+---
+date: 2026-04-09
+status: in-progress
+authors:
+ - andreatgretel
+---
+
+# Plan: PR Hygiene Automation
+
+Closes #518.
+
+## Problem
+
+External contributors open PRs and never come back - DCO, title, or other required
+checks fail and the PR sits indefinitely. The PR template and CONTRIBUTING.md already
+ask for linked issues and proper formatting, but nothing enforces it.
+
+## Goals
+
+1. **Linked issue check** - external PRs must reference a triaged issue to merge.
+2. **Stale PR cleanup** - remind authors of failing checks, auto-close if unaddressed.
+3. **Minimal friction** for collaborators - team members bypass the linked-issue check.
+
+## Non-goals
+
+- No agent or self-hosted runner involvement (plain GitHub Actions on `ubuntu-latest`).
+- Not part of the agentic CI plan (plans/472).
+
+---
+
+## Design
+
+### Linked issue check (`pr-linked-issue.yml`)
+
+**Trigger:** `pull_request_target: [opened, edited, synchronize, reopened]` +
+`issues: [labeled]` (for re-check when an issue is triaged).
+
+Uses `pull_request_target` so the workflow token can post comments on fork PRs.
+Safe because the workflow never checks out or executes PR code.
+
+**Two jobs:**
+
+1. **`check`** (PR events) - validates the PR:
+ - Collaborators (`admin`/`write` permission) pass unconditionally.
+ - Non-collaborators must have `Fixes #N`/`Closes #N`/`Resolves #N` in the PR
+ body, pointing to an existing issue with the `triaged` label.
+ - Posts/updates a comment explaining what's missing or confirming success.
+
+2. **`retrigger`** (issue labeled) - when `triaged` is added to an issue, finds
+ open PRs referencing it and edits a hidden HTML comment in their body to
+ trigger the `edited` event, which re-runs the `check` job.
+
+### Stale PR cleanup (`pr-stale.yml`)
+
+**Trigger:** daily cron (09:00 UTC) + `workflow_dispatch`.
+
+For each open PR with failing checks and no author activity:
+
+| Author type | Reminder | Auto-close |
+|-------------|----------|------------|
+| Non-collaborator | 7 days | 14 days |
+| Collaborator | 14 days | 28 days |
+
+The `keep-open` label prevents auto-close.
+
+Uses `gh api` directly for comment management (looping over multiple PRs).
+
+### Labels
+
+| Label | Purpose |
+|-------|---------|
+| `triaged` | Maintainer approval gate for external PRs |
+| `task` | Auto-label for development-task issue template (was missing) |
+| `keep-open` | Prevent stale-PR auto-close |
+
+---
+
+## Deliverables
+
+- [x] Labels created (`triaged`, `task`, `keep-open`)
+- [ ] `.github/workflows/pr-linked-issue.yml`
+- [ ] `.github/workflows/pr-stale.yml`
+- [ ] CONTRIBUTING.md updated with linked-issue requirement
+- [ ] `Linked Issue Check` added as required status check on `main` (post-merge)
+
+## Validation
+
+- Open test PR from non-collaborator, verify check blocks without triaged issue.
+- Add `triaged` to linked issue, verify re-check passes.
+- Verify collaborator PRs pass without a linked issue.
+- Run stale workflow via `workflow_dispatch`, verify correct PR targeting.
+- Confirm `keep-open` label prevents auto-close.