diff --git a/.agents/skills/nemoclaw-maintainer-day/HOTSPOTS.md b/.agents/skills/nemoclaw-maintainer-day/HOTSPOTS.md new file mode 100644 index 000000000..12441dfbb --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/HOTSPOTS.md @@ -0,0 +1,46 @@ +# Hotspots Workflow + +Find files hurting throughput and reduce their future blast radius. + +## Step 1: Run the Hotspot Script + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts +``` + +This combines 30-day git churn on `main` with open PR file overlap, flags risky areas, and outputs a ranked JSON list. + +Pipe into state: + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts | node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts set-hotspots +``` + +## Step 2: Prioritize + +Review the ranked output. Most urgent: high `combinedScore` + `isRisky: true` + weak tests. + +## Step 3: Choose Cooling Strategy + +Smallest change to reduce future collisions: + +- extract stable logic from giant file into tested helper +- split parsing from execution +- add regression tests around repeated breakage +- deduplicate workflow logic +- narrow interfaces with typed helpers + +Prefer changes that also improve testability. + +## Step 4: Keep Small + +One file cluster per pass. Stop if next step is large redesign → follow [SEQUENCE-WORK.md](SEQUENCE-WORK.md). + +## Step 5: Validate + +Run relevant tests. If risky code, also follow [TEST-GAPS.md](TEST-GAPS.md). + +## Notes + +- Goal is lower future merge pain, not aesthetic cleanup. +- No giant refactors inside contributor PRs. diff --git a/.agents/skills/nemoclaw-maintainer-day/MERGE-GATE.md b/.agents/skills/nemoclaw-maintainer-day/MERGE-GATE.md new file mode 100644 index 000000000..2e2c86ca0 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/MERGE-GATE.md @@ -0,0 +1,47 @@ +# Merge Gate Workflow + +Run the last maintainer check before approval. Never merge automatically. + +## Gates + +For the full priority list see [PR-REVIEW-PRIORITIES.md](PR-REVIEW-PRIORITIES.md). A PR is approval-ready only when **all** hard gates pass: + +1. **CI green** — all required checks in `statusCheckRollup`. +2. **No conflicts** — `mergeStateStatus` clean. +3. **No major CodeRabbit** — ignore style nits; block on correctness/security bugs. +4. **Risky code tested** — see [RISKY-AREAS.md](RISKY-AREAS.md). Confirm tests exist (added or pre-existing). + +## Step 1: Run the Gate Checker + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts +``` + +This checks all 4 gates programmatically and returns structured JSON with `allPass` and per-gate `pass`/`details`. + +## Step 2: Interpret Results + +The script handles the deterministic checks. You handle judgment calls: + +- **Conflicts (DIRTY):** Do NOT approve — GitHub invalidates approvals when new commits are pushed. Salvage first (rebase), wait for CI, then re-run the gate checker. Follow [SALVAGE-PR.md](SALVAGE-PR.md). +- **CI failing but narrow:** Follow the salvage workflow in [SALVAGE-PR.md](SALVAGE-PR.md). +- **CI pending:** Wait and re-check. Do not approve while checks are still running. +- **CodeRabbit:** Script flags unresolved major/critical threads. Review the `snippet` to confirm it's a real issue vs style nit. If doubt, leave unapproved. +- **Tests:** If `riskyCodeTested.pass` is false, follow [TEST-GAPS.md](TEST-GAPS.md). + +## Step 3: Approve or Report + +**Approve only when:** `allPass` is true AND `mergeStateStatus` is not DIRTY. Approving a PR with conflicts is wasted effort — the rebase will invalidate the approval. + +The correct sequence for a conflicted PR: **salvage (rebase) → CI green → approve → report ready for merge.** + +**All pass + no conflicts:** Approve and summarize why. + +**Any fail:** + +| Gate | Status | What is needed | +|------|--------|----------------| +| CI | Failing | Fix flaky timeout test | +| Conflicts | DIRTY | Rebase onto main first — approval would be invalidated | + +Use full GitHub links. diff --git a/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md b/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md new file mode 100644 index 000000000..1c9ea7b3a --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md @@ -0,0 +1,41 @@ +# PR Review Priorities + +Ordered list of what NemoClaw maintainers look for in a pull request. Higher items block approval; lower items inform queue ranking. + +## Hard gates (all must pass to approve) + +1. **Security correctness** — no sandbox escape, SSRF, credential exposure, policy bypass, or installer trust violation. PRs touching risky areas (see [RISKY-AREAS.md](RISKY-AREAS.md)) get a deep security pass before anything else. +2. **CI green** — all required checks in `statusCheckRollup` must pass. +3. **No merge conflicts** — `mergeStateStatus` must be clean. +4. **No unresolved major/critical CodeRabbit findings** — correctness and safety findings block; style nits do not. Use judgment on borderline cases. +5. **Tests for touched risky code** — risky areas must have test coverage, either added in the PR or pre-existing. No exceptions. + +## Quality expectations (block if violated, but fixable via salvage) + +1. **Narrow scope** — each PR has one clear objective. Unrelated config changes, drive-by refactors, and tool setting diffs get reverted to `main`. +2. **Contributor intent preserved** — the fix must match what the contributor intended. Stop and ask when the diff would change semantics or when intent is unclear. +3. **Small, mergeable changes** — prefer substrate-first slicing: extract helper, add tests for current behavior, land fix on top. One file cluster per pass. If the next step is a large redesign, route to sequencing. + +## Queue ranking signals (inform priority, not approval) + +1. **Actionability** — PRs closest to done rank highest. A merge-ready PR outranks a near-miss; a near-miss outranks a blocked item. +2. **Security-sensitive and actionable** — PRs touching risky code get a priority bump, but only when they are not otherwise blocked. +3. **Staleness** — PRs idle for more than 7 days get a mild bump to prevent rot. +4. **Hotspot relief** — PRs that reduce future conflict pressure in high-churn files are preferred over equivalent work elsewhere. + +## Daily cadence + +The team follows a daily ship cycle. All maintainer skills operate within this rhythm. + +1. **Morning** (`/nemoclaw-maintainer-morning`) — triage the backlog, pick items for the day, label them with the target version (e.g., `v0.0.8`). +2. **During the day** (`/nemoclaw-maintainer-day`) — land PRs using the maintainer loop. Version labels make progress visible on dashboards. +3. **Evening** (`/nemoclaw-maintainer-evening`) — check what shipped, bump open items to the next version (`v0.0.9`), generate a QA-focused summary, and cut the tag. +4. **Overnight** — QA team (different timezone) tests the tag. Any issues they file enter the next morning's triage like any other issue. + +Version labels are living markers: they always mean "ship in this version." If an item doesn't make the cut, the label moves to the next patch version. + +## Explicitly not priorities + +- **Code style and formatting** — not a reason to block or delay. No opportunistic reformatting. +- **Documentation completeness** — not required for approval unless the PR changes user-facing behavior. +- **Architectural elegance** — the goal is lower future merge pain, not aesthetic cleanup. diff --git a/.agents/skills/nemoclaw-maintainer-day/RISKY-AREAS.md b/.agents/skills/nemoclaw-maintainer-day/RISKY-AREAS.md new file mode 100644 index 000000000..f22431d3f --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/RISKY-AREAS.md @@ -0,0 +1,14 @@ +# NemoClaw Risky Code Areas + +PRs touching these areas need tests before approval. + +| Area | Key paths | +|------|-----------| +| Installer / bootstrap shell | `install.sh`, `setup.sh`, `brev-setup.sh`, `scripts/*.sh` | +| Onboarding / host glue | `bin/lib/onboard.js`, `bin/*.js` | +| Sandbox / policy / SSRF | `nemoclaw/src/blueprint/`, `nemoclaw-blueprint/`, policy presets | +| Workflow / enforcement | `.github/workflows/`, prek hooks, DCO, signing, version/tag flows | +| Credentials / inference / network | credential helpers, inference provider routing, approval flows | + +A PR in a risky area is only promoted in the queue when it is actually actionable. +If risky and under-tested, follow the test gaps or security sweep workflows. diff --git a/.agents/skills/nemoclaw-maintainer-day/SALVAGE-PR.md b/.agents/skills/nemoclaw-maintainer-day/SALVAGE-PR.md new file mode 100644 index 000000000..29cb952d8 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/SALVAGE-PR.md @@ -0,0 +1,84 @@ +# Salvage PR Workflow + +Take one near-mergeable PR and make the smallest safe change to unblock it. + +**Default to maintainer salvage.** When a maintainer picks an item from the queue, the assumption is they're doing the work now — rebase, fix conflicts, add missing tests, push. Do not default to "ask the contributor and wait" because that blocks the daily cadence. Only defer to the contributor when the fix requires understanding intent that isn't clear from the diff. + +## Step 1: Gather Context + +```bash +gh pr view --repo NVIDIA/NemoClaw \ + --json number,title,url,body,baseRefName,headRefName,author,files,commits,comments,reviews,statusCheckRollup,mergeStateStatus,reviewDecision + +gh pr diff --repo NVIDIA/NemoClaw +``` + +Also read: maintainer and CodeRabbit comments, linked issues, recent `main` changes in touched files. Understand the PR's purpose before coding. + +## Step 2: Assess Fit + +**Maintainer does it now:** rebase and resolve conflicts, add missing tests for risky code, fix one or two failing checks, apply small correctness fixes from review, narrow gate cleanup. + +**Defer to contributor only when:** the fix requires a design change the maintainer can't judge from the diff, contributor intent is ambiguous and the wrong guess would change semantics, or the PR spans multiple subsystems the maintainer isn't familiar with. + +## Step 3: Check Out and Reproduce + +```bash +gh pr checkout +git fetch origin --prune +``` + +Reproduce locally. Run narrowest relevant commands first. + +## Step 4: Review PR Scope Before Fixing + +Before fixing, review **all** changed files in the PR — not just the ones causing failures. Flag any files that expand the PR's scope unnecessarily (config changes, unrelated refactors, tool settings). Revert those to `main` if they aren't needed for the feature to work. + +## Step 5: Fix Narrowly + +Smallest change that clears the blocker. No opportunistic reformatting. + +If risky code is touched (see [RISKY-AREAS.md](RISKY-AREAS.md)), treat missing tests as part of the fix — follow [TEST-GAPS.md](TEST-GAPS.md) when needed. + +## Step 6: Conflicts + +Resolve only mechanical conflicts (import ordering, adjacent additions, branch drift). Stop and summarize if the conflict changes behavior. + +## Step 7: Validate + +```bash +npm test # root integration tests +cd nemoclaw && npm test # plugin tests +npm run typecheck:cli # CLI type check +make check # all linters +``` + +Use only commands matching the changed area. + +## Step 8: Push + +Push when: fix is small, improves mergeability, validation passed, you have push permission. Never force-push. If you cannot push, prepare a comment describing the fix. + +**Fork PRs:** Most PRs come from contributor forks. Check where to push: + +```bash +gh pr view --repo NVIDIA/NemoClaw --json headRepositoryOwner,headRepository,headRefName,maintainerCanModify +``` + +If `maintainerCanModify` is true, push directly to the fork: + +```bash +git push git@github.com:/.git : +``` + +Do **not** push to `origin` — that creates a separate branch on NVIDIA/NemoClaw that won't appear in the PR. + +## Step 9: Route to Merge Gate + +If PR looks ready, follow [MERGE-GATE.md](MERGE-GATE.md). + +## Notes + +- Goal is safe backlog reduction, not finishing the PR at any cost. +- Never hide unresolved reviewer concerns. +- Use full GitHub links. diff --git a/.agents/skills/nemoclaw-maintainer-day/SECURITY-SWEEP.md b/.agents/skills/nemoclaw-maintainer-day/SECURITY-SWEEP.md new file mode 100644 index 000000000..dab2ab593 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/SECURITY-SWEEP.md @@ -0,0 +1,44 @@ +# Security Sweep Workflow + +Review a security-sensitive item before it enters the normal fast path. + +## Step 1: Identify the Security Item + +The morning triage and `find-review-pr` already surface security-labeled PRs. Start from the item selected in the day loop's action step. If running standalone, check the triage queue for PRs touching risky areas (see [RISKY-AREAS.md](RISKY-AREAS.md)). + +## Step 2: Gather Context + +Read the PR or issue, all comments, linked items, changed files, diff, current checks, and recent relevant `main` commits. + +## Step 3: Classify Risk + +Which bucket applies? + +- **escape or policy bypass** +- **credential or secret exposure** +- **installer or release integrity** +- **workflow or governance bypass** +- **input validation or SSRF weakness** +- **test gap in risky code** + +If none apply, route back to normal action selection. + +## Step 4: Deep Security Pass + +Load `security-code-review` for the nine-category review whenever the item changes behavior in a security-sensitive area. Do not skip this step just because the diff is small. + +## Step 5: Decide Action + +### Salvage-now + +All true: risk is understood, fix is small/local, required tests are clear, no unresolved design question. Follow [SALVAGE-PR.md](SALVAGE-PR.md) and [TEST-GAPS.md](TEST-GAPS.md). + +### Blocked + +Any true: fix changes core trust assumptions, review found real vulnerability needing redesign, PR adds risk without tests, reviewer disagreement. Summarize blocker clearly; do not approve. + +## Notes + +- Backlog reduction never outranks a credible security concern. +- No security-sensitive approvals without both deep review and tests. +- Use full GitHub links. diff --git a/.agents/skills/nemoclaw-maintainer-day/SEQUENCE-WORK.md b/.agents/skills/nemoclaw-maintainer-day/SEQUENCE-WORK.md new file mode 100644 index 000000000..67a316da6 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/SEQUENCE-WORK.md @@ -0,0 +1,43 @@ +# Sequence Work Workflow + +Turn a large problem into a short sequence of mergeable slices. + +## Step 1: Read the Full Problem Surface + +Read greedily: issue body and comments, linked issues, linked PRs with review comments and status, touched code/tests/docs, recent `main` changes if the area is active. + +Do not sequence work from the title alone. + +## Step 2: Identify What Is Moving + +Inventory overlapping open PRs and recently merged changes. For each: blocker that must land first? Dependency to build on? Conflict to avoid? Noise? + +## Step 3: Define Slices + +Each slice should have: one core objective, short file list, explicit tests, merge dependency list, stop condition. + +Prefer substrate-first sequencing: + +1. Extract stable helper or type boundary +2. Add regression tests for current behavior +3. Land behavioral fix or refactor on top +4. Remove duplication afterward + +## Step 4: Rank + +Use repo priorities: (1) backlog reduction, (2) security, (3) test coverage, (4) hotspot cooling. + +A slice that unblocks several PRs moves up. An elegant but non-urgent slice moves down. + +## Step 5: Output + +| Order | Slice | Why now | Depends on | Tests | +|-------|-------|---------|------------|-------| +| 1 | Extract timeout parsing from onboard | Enables safe tests, reduces conflicts | None | Unit tests for invalid env values | + +Also include: outstanding blockers, which slices are safe for the maintainer loop, where human design decisions are needed. + +## Notes + +- Every slice maps to real files, tests, and merge behavior — not abstract architecture. +- Prefer small serially mergeable changes over one ambitious cleanup branch. diff --git a/.agents/skills/nemoclaw-maintainer-day/SKILL.md b/.agents/skills/nemoclaw-maintainer-day/SKILL.md new file mode 100644 index 000000000..ba02b8191 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/SKILL.md @@ -0,0 +1,79 @@ +--- +name: nemoclaw-maintainer-day +description: Runs the daytime maintainer loop for NemoClaw, prioritizing items labeled with the current version target. Picks the highest-value item, executes the right workflow (merge gate, salvage, security sweep, test gaps, hotspot cooling, or sequencing), and reports progress. Use during the workday to land PRs and close issues. Designed for /loop (e.g. /loop 10m /nemoclaw-maintainer-day). Trigger keywords - maintainer day, work on PRs, land PRs, make progress, what's next, keep going, maintainer loop. +user_invocable: true +--- + +# NemoClaw Maintainer Day + +Execute one pass of the maintainer loop, prioritizing version-targeted work. + +**Autonomy:** push small fixes and approve when gates pass. Never merge. Stop and ask for merge decisions, architecture decisions, and unclear contributor intent. + +## References + +- PR review priorities: [PR-REVIEW-PRIORITIES.md](PR-REVIEW-PRIORITIES.md) +- Risky code areas: [RISKY-AREAS.md](RISKY-AREAS.md) +- State schema: [STATE-SCHEMA.md](STATE-SCHEMA.md) + +## Step 1: Check Version Progress + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts +``` + +The first script determines the target version. The second shows shipped vs open. + +## Step 2: Pick One Action + +From the open version-targeted items, pick the highest-value one: + +1. **Ready-now PR** — green CI, no conflicts, no major CodeRabbit, has tests → follow [MERGE-GATE.md](MERGE-GATE.md) +2. **Salvage-now PR** — close to ready, needs small fix → follow [SALVAGE-PR.md](SALVAGE-PR.md) +3. **Security item** — touches risky areas → follow [SECURITY-SWEEP.md](SECURITY-SWEEP.md) +4. **Test-gap item** — risky code with weak tests → follow [TEST-GAPS.md](TEST-GAPS.md) +5. **Hotspot cooling** — repeated conflicts → follow [HOTSPOTS.md](HOTSPOTS.md) +6. **Sequencing needed** — too large for one pass → follow [SEQUENCE-WORK.md](SEQUENCE-WORK.md) + +If all version-targeted items are blocked, fall back to the general backlog. Productive work on non-labeled items is better than waiting. + +Prefer finishing one almost-ready contribution over starting a new refactor. + +## Step 3: Execute + +Follow the chosen workflow document. A good pass ends with one of: + +- a PR approved, a fix pushed, a test gap closed, a hotspot mitigated, or a blocker surfaced. + +## Step 4: Report Progress + +Re-run the progress script and show the update: + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts +``` + +If all version-targeted items are done, suggest running `/nemoclaw-maintainer-evening` early. + +Update `.nemoclaw-maintainer/state.json` via the state script: + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts history "" +``` + +## Commit Hygiene + +The prek "Regenerate agent skills from docs" hook auto-stages `.agents/skills/` files. Before every `git add` and `git commit` on a PR branch, run `git reset HEAD .agents/skills/nemoclaw-maintainer-*` to unstage them. Only commit skill files in dedicated skill PRs. + +## Stop and Ask When + +- Broad refactor or architecture decision needed +- Contributor intent unclear and diff would change semantics +- Multiple subsystems must change for CI +- Sensitive security boundaries with unclear risk +- Next step is opening a new PR or merging + +## /loop Integration + +Designed for `/loop 10m /nemoclaw-maintainer-day`. Each pass should produce compact output: what was done, what changed, what needs the user. Check `state.json` history to avoid re-explaining prior context on repeat runs. diff --git a/.agents/skills/nemoclaw-maintainer-day/STATE-SCHEMA.md b/.agents/skills/nemoclaw-maintainer-day/STATE-SCHEMA.md new file mode 100644 index 000000000..32d841140 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/STATE-SCHEMA.md @@ -0,0 +1,56 @@ +# State File Schema + +Path: `.nemoclaw-maintainer/state.json` (excluded via `.git/info/exclude`). + +```json +{ + "version": 1, + "repo": "NVIDIA/NemoClaw", + "updatedAt": null, + "priorities": [ + "reduce_pr_backlog", + "reduce_security_risk", + "increase_test_coverage", + "cool_hot_files" + ], + "gates": { + "greenCi": true, + "noConflicts": true, + "noMajorCodeRabbit": true, + "testsForTouchedRiskyCode": true, + "autoApprove": true, + "autoPushSmallFixes": true, + "autoMerge": false + }, + "excluded": { + "prs": {}, + "issues": {} + }, + "queue": { + "generatedAt": null, + "topAction": null, + "items": [], + "nearMisses": [] + }, + "hotspots": { + "generatedAt": null, + "files": [] + }, + "activeWork": { + "kind": null, + "target": null, + "branch": null, + "goal": null, + "startedAt": null + }, + "history": [] +} +``` + +## Field Notes + +- `gates.autoMerge` is always `false`. The loop may approve but never merges. +- `gates.autoPushSmallFixes` allows pushing narrow fixes to contributor branches. +- `excluded.prs` / `excluded.issues`: keys are numbers (as strings), values are `{ "reason": "...", "excludedAt": "ISO" }`. Items here are permanently skipped by triage until the user removes them. +- `history` entries: `{ "at": "ISO", "item": "PR#1234", "action": "approved|salvaged|blocked|sequenced", "note": "one line" }`. Keep under 50 entries; trim oldest. +- `queue.items` and `queue.nearMisses` store the latest triage output for comparison across runs. diff --git a/.agents/skills/nemoclaw-maintainer-day/TEST-GAPS.md b/.agents/skills/nemoclaw-maintainer-day/TEST-GAPS.md new file mode 100644 index 000000000..d1b341135 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/TEST-GAPS.md @@ -0,0 +1,65 @@ +# Test Gaps Workflow + +Close the highest-value test gaps without turning the task into a rewrite. + +For risky code areas, see [RISKY-AREAS.md](RISKY-AREAS.md). + +## Step 1: Collect File Set + +Sources: files changed in target PR, recent failing CI, `main` churn in hotspot area, hotspot list in state file. If unknown, derive from highest-ranked actionable PRs. + +## Step 2: Map to Existing Tests + +Repo conventions: + +- Root integration tests (`test/`): ESM imports +- Plugin tests: co-located as `*.test.ts` +- Shell logic: may need extraction into testable helper first + +For each risky file: is there a test covering the changed behavior? Is it too indirect or flaky? Can a small extraction improve testability? + +## Step 3: Choose Highest-Value Tests + +Prefer tests that catch regressions the maintainer loop actually sees: + +- invalid/boundary env values, shell quoting, retry/timeout behavior +- missing/malformed config, denied network/policy paths +- duplicate workflow/hook behavior, version/tag/DCO edge cases +- unauthorized or unsafe inputs + +Include at least one negative-path test for risky code. + +## Step 4: Extract Narrow Seams If Needed + +Smallest extraction that improves testability: move parsing into a pure helper, separate construction from execution, factor hook logic into a reusable function, replace bags of primitives with typed helpers. + +Do not broad-refactor under the label of "adding tests." + +## Step 5: Add Tests + +- CLI tests → `test/` +- Plugin tests → `nemoclaw/src/` +- TypeScript helpers → TypeScript tests +- Mock external systems; no real API calls in unit tests +- Security paths: prove the unsafe action is denied, not just that happy path works + +## Step 6: Validate + +```bash +npm test # root tests +cd nemoclaw && npm test # plugin tests +npm run typecheck:cli +make check +``` + +Narrowest command set that gives confidence. + +## Step 7: Report Gaps Honestly + +If untested risk remains, say so: no clean seam without larger refactor, too much hidden shell state, missing fixture strategy, flaky infra dependency. + +## Notes + +- Tests for risky code are merge readiness, not polish. +- One precise regression test beats many vague integration checks. +- If credible fix needs bigger redesign, follow [SEQUENCE-WORK.md](SEQUENCE-WORK.md). diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts new file mode 100644 index 000000000..a76d2b39c --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bump open items from one version label to another. + * + * Creates the target label if needed, then swaps labels on all open + * PRs and issues carrying the source version. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts [--repo OWNER/REPO] + */ + +import { run, parseStringArg } from "./shared.ts"; + +interface BumpedItem { + number: number; + title: string; + type: "pr" | "issue"; +} + +interface BumpOutput { + from: string; + to: string; + bumped: BumpedItem[]; +} + +function main(): void { + const args = process.argv.slice(2); + const from = args[0]; + const to = args[1]; + if (!from || !to) { + console.error("Usage: bump-stragglers.ts [--repo OWNER/REPO]"); + process.exit(1); + } + + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + // Create target label if needed + run("gh", [ + "label", "create", to, "--repo", repo, + "--description", "Release target", "--color", "1d76db", + ]); + + const bumped: BumpedItem[] = []; + + // Bump open PRs + const prOut = run("gh", [ + "pr", "list", "--repo", repo, "--label", from, + "--state", "open", "--json", "number,title", "--limit", "100", + ]); + if (prOut) { + try { + const prs = JSON.parse(prOut) as Array<{ number: number; title: string }>; + for (const pr of prs) { + run("gh", [ + "pr", "edit", String(pr.number), "--repo", repo, + "--remove-label", from, "--add-label", to, + ]); + bumped.push({ number: pr.number, title: pr.title, type: "pr" }); + } + } catch { /* ignore */ } + } + + // Bump open issues + const issueOut = run("gh", [ + "issue", "list", "--repo", repo, "--label", from, + "--state", "open", "--json", "number,title", "--limit", "100", + ]); + if (issueOut) { + try { + const issues = JSON.parse(issueOut) as Array<{ number: number; title: string }>; + for (const issue of issues) { + run("gh", [ + "issue", "edit", String(issue.number), "--repo", repo, + "--remove-label", from, "--add-label", to, + ]); + bumped.push({ number: issue.number, title: issue.title, type: "issue" }); + } + } catch { /* ignore */ } + } + + const output: BumpOutput = { from, to, bumped }; + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts new file mode 100644 index 000000000..06a23d9bf --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Deterministic merge-gate checker for a single NemoClaw PR. + * + * Checks all 4 required gates and outputs structured JSON. + * Claude uses the output to decide: approve, route to salvage, or report blockers. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts [--repo OWNER/REPO] + */ + +import { isRiskyFile, isTestFile, run, ghJson, parseStringArg } from "./shared.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface GateResult { + pass: boolean; + details: string; +} + +interface CodeRabbitThread { + path: string; + severity: "critical" | "major" | "minor" | "unknown"; + snippet: string; + resolved: boolean; +} + +interface GateOutput { + pr: number; + url: string; + title: string; + allPass: boolean; + gates: { + ci: GateResult & { failingChecks?: string[]; pendingChecks?: string[] }; + conflicts: GateResult & { mergeStateStatus?: string }; + coderabbit: GateResult & { unresolvedThreads?: CodeRabbitThread[] }; + riskyCodeTested: GateResult & { riskyFiles?: string[]; hasTests?: boolean }; + }; +} + +// --------------------------------------------------------------------------- +// Gate 1: CI green +// --------------------------------------------------------------------------- + +function checkCi( + statusCheckRollup: Array<{ name: string; status: string; conclusion: string }> | null, +): GateResult & { failingChecks?: string[]; pendingChecks?: string[] } { + if (!statusCheckRollup || statusCheckRollup.length === 0) { + return { pass: false, details: "No status checks found" }; + } + + const passing = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]); + const failing: string[] = []; + const pending: string[] = []; + + for (const check of statusCheckRollup) { + const conclusion = (check.conclusion ?? "").toUpperCase(); + const status = (check.status ?? "").toUpperCase(); + + // CodeRabbit reports as a commit status with null name/status but a + // non-null conclusion. Treat it as completed if conclusion is present. + const effectivelyCompleted = status === "COMPLETED" || (!status && conclusion); + + if (!effectivelyCompleted) { + pending.push(check.name ?? "(unknown)"); + } else if (!passing.has(conclusion)) { + failing.push(`${check.name ?? "(unknown)"}: ${conclusion}`); + } + } + + if (failing.length > 0) { + return { pass: false, details: `${failing.length} failing check(s)`, failingChecks: failing, pendingChecks: pending }; + } + if (pending.length > 0) { + return { pass: false, details: `${pending.length} pending check(s)`, pendingChecks: pending }; + } + return { pass: true, details: `All ${statusCheckRollup.length} checks green` }; +} + +// --------------------------------------------------------------------------- +// Gate 2: No conflicts +// --------------------------------------------------------------------------- + +function checkConflicts(mergeStateStatus: string): GateResult & { mergeStateStatus?: string } { + const clean = ["CLEAN", "HAS_HOOKS", "UNSTABLE"]; + const status = (mergeStateStatus ?? "UNKNOWN").toUpperCase(); + + if (clean.includes(status)) { + return { pass: true, details: "No merge conflicts", mergeStateStatus: status }; + } + return { pass: false, details: `Merge state: ${status}`, mergeStateStatus: status }; +} + +// --------------------------------------------------------------------------- +// Gate 3: CodeRabbit +// --------------------------------------------------------------------------- + +const SEVERITY_MARKERS = { + critical: ["🔴 Critical", "_🔴 Critical_", "Critical:"], + major: ["🟠 Major", "_🟠 Major_"], + minor: ["🟡 Minor", "_🟡 Minor_"], +} as const; + +const CODERABBIT_LOGINS = new Set(["coderabbitai[bot]", "coderabbitai"]); +const ADDRESSED_MARKERS = ["✅ Addressed in commit", ""]; + +function detectSeverity(body: string): "critical" | "major" | "minor" | "unknown" { + for (const marker of SEVERITY_MARKERS.critical) { + if (body.includes(marker)) return "critical"; + } + for (const marker of SEVERITY_MARKERS.major) { + if (body.includes(marker)) return "major"; + } + for (const marker of SEVERITY_MARKERS.minor) { + if (body.includes(marker)) return "minor"; + } + return "unknown"; +} + +function isAddressed(body: string): boolean { + return ADDRESSED_MARKERS.some((m) => body.includes(m)); +} + +function checkCodeRabbit( + repo: string, + number: number, +): GateResult & { unresolvedThreads?: CodeRabbitThread[] } { + const query = `query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + reviewThreads(first:100) { + nodes { + isResolved + comments(first:20) { + nodes { author { login } body path } + } + } + } + } + } + }`; + + const [owner, repoName] = repo.split("/"); + const out = run("gh", [ + "api", "graphql", + "-F", `owner=${owner}`, + "-F", `repo=${repoName}`, + "-F", `number=${number}`, + "-f", `query=${query}`, + ]); + + // Fail-closed: if we cannot reach the API, do not assume clean + if (!out) { + return { pass: false, details: "Could not fetch review threads (API error — fail-closed)" }; + } + + let data: { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes?: Array<{ + isResolved: boolean; + comments: { nodes: Array<{ author: { login: string }; body: string; path: string }> }; + }>; + }; + }; + }; + }; + }; + try { + data = JSON.parse(out); + } catch { + return { pass: false, details: "Could not parse review threads (invalid JSON — fail-closed)" }; + } + + const threads = data.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []; + const unresolved: CodeRabbitThread[] = []; + + for (const thread of threads) { + if (thread.isResolved) continue; + + const comments = thread.comments.nodes; + const coderabbitComments = comments.filter( + (c) => CODERABBIT_LOGINS.has(c.author?.login?.toLowerCase()), + ); + + for (const comment of coderabbitComments) { + if (isAddressed(comment.body)) continue; + const severity = detectSeverity(comment.body); + if (severity === "critical" || severity === "major") { + unresolved.push({ + path: comment.path || "(unknown)", + severity, + snippet: comment.body.slice(0, 200), + resolved: false, + }); + } + } + } + + if (unresolved.length === 0) { + return { pass: true, details: "No unresolved major/critical CodeRabbit findings" }; + } + return { + pass: false, + details: `${unresolved.length} unresolved major/critical CodeRabbit finding(s)`, + unresolvedThreads: unresolved, + }; +} + +// --------------------------------------------------------------------------- +// Gate 4: Risky code has tests +// --------------------------------------------------------------------------- + +function checkRiskyCodeTested( + files: Array<{ path: string; status: string }>, +): GateResult & { riskyFiles?: string[]; hasTests?: boolean } { + const riskyFiles = files.map((f) => f.path).filter(isRiskyFile); + if (riskyFiles.length === 0) { + return { pass: true, details: "No risky files changed" }; + } + + const hasTests = files.some((f) => isTestFile(f.path)); + if (hasTests) { + return { + pass: true, + details: `${riskyFiles.length} risky file(s) changed; test files present in PR`, + riskyFiles, + hasTests: true, + }; + } + + return { + pass: false, + details: `${riskyFiles.length} risky file(s) changed but no test files in PR`, + riskyFiles, + hasTests: false, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const args = process.argv.slice(2); + const prNumber = parseInt(args[0], 10); + if (isNaN(prNumber)) { + console.error("Usage: check-gates.ts [--repo OWNER/REPO]"); + process.exit(1); + } + + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + const prData = ghJson([ + "pr", "view", String(prNumber), + "--repo", repo, + "--json", "number,title,url,files,statusCheckRollup,mergeStateStatus", + ]) as { + number: number; + title: string; + url: string; + files: Array<{ path: string; status: string }>; + statusCheckRollup: Array<{ name: string; status: string; conclusion: string }>; + mergeStateStatus: string; + } | null; + + if (!prData) { + console.error(`Failed to fetch PR #${prNumber} from ${repo}`); + process.exit(1); + } + + const ci = checkCi(prData.statusCheckRollup); + const conflicts = checkConflicts(prData.mergeStateStatus); + const coderabbit = checkCodeRabbit(repo, prNumber); + const riskyCodeTested = checkRiskyCodeTested(prData.files ?? []); + + const output: GateOutput = { + pr: prNumber, + url: prData.url, + title: prData.title, + allPass: ci.pass && conflicts.pass && coderabbit.pass && riskyCodeTested.pass, + gates: { ci, conflicts, coderabbit, riskyCodeTested }, + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts new file mode 100644 index 000000000..3e15542d4 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generate a QA handoff summary for the upcoming release tag. + * + * Lists commits since the last tag, identifies risky areas touched, + * and suggests test focus areas. Output is JSON. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts [--repo OWNER/REPO] + */ + +import { isRiskyFile, run } from "./shared.ts"; + +interface CommitInfo { + sha: string; + subject: string; +} + +interface HandoffOutput { + previousTag: string; + targetVersion: string; + commitCount: number; + commits: CommitInfo[]; + riskyFilesTouched: string[]; + riskyAreas: string[]; + suggestedTestFocus: string[]; +} + +const AREA_LABELS: Record = { + "Installer / bootstrap": [/^install\.sh$/, /^setup\.sh$/, /^brev-setup\.sh$/, /^scripts\/.*\.sh$/], + "Onboarding / host glue": [/^bin\/lib\/onboard\.js$/, /^bin\/.*\.js$/], + "Sandbox / policy / SSRF": [/^nemoclaw\/src\/blueprint\//, /^nemoclaw-blueprint\//, /policy/i, /ssrf/i], + "Workflow / enforcement": [/^\.github\/workflows\//, /\.prek\./], + "Credentials / inference": [/credential/i, /inference/i], +}; + +function getLatestTag(): string { + const out = run("git", ["tag", "--sort=-v:refname"]); + if (!out) return "v0.0.0"; + for (const line of out.split("\n")) { + if (/^v\d+\.\d+\.\d+$/.test(line.trim())) return line.trim(); + } + return "v0.0.0"; +} + +function bumpPatch(tag: string): string { + const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!match) return "v0.0.1"; + return `v${match[1]}.${match[2]}.${parseInt(match[3], 10) + 1}`; +} + +function main(): void { + run("git", ["fetch", "origin", "--tags", "--prune"]); + + const previousTag = getLatestTag(); + const targetVersion = bumpPatch(previousTag); + + // Commits since last tag + const logOut = run("git", [ + "log", "--oneline", "--format=%h %s", `${previousTag}..origin/main`, + ]); + const commits: CommitInfo[] = []; + if (logOut) { + for (const line of logOut.split("\n")) { + const spaceIdx = line.indexOf(" "); + if (spaceIdx > 0) { + commits.push({ + sha: line.slice(0, spaceIdx), + subject: line.slice(spaceIdx + 1), + }); + } + } + } + + // Files changed since last tag + const diffOut = run("git", [ + "diff", "--name-only", `${previousTag}..origin/main`, + ]); + const changedFiles = diffOut ? diffOut.split("\n").map((f) => f.trim()).filter(Boolean) : []; + const riskyFilesTouched = changedFiles.filter(isRiskyFile); + + // Map risky files to area labels + const areasHit = new Set(); + for (const file of riskyFilesTouched) { + for (const [area, patterns] of Object.entries(AREA_LABELS)) { + if (patterns.some((re) => re.test(file))) { + areasHit.add(area); + } + } + } + const riskyAreas = [...areasHit]; + + // Suggest test focus based on areas + const suggestedTestFocus: string[] = []; + if (areasHit.has("Installer / bootstrap")) suggestedTestFocus.push("Fresh install and upgrade paths"); + if (areasHit.has("Onboarding / host glue")) suggestedTestFocus.push("Onboarding wizard, sandbox creation"); + if (areasHit.has("Sandbox / policy / SSRF")) suggestedTestFocus.push("Policy enforcement, network egress, SSRF protections"); + if (areasHit.has("Workflow / enforcement")) suggestedTestFocus.push("CI checks, pre-commit hooks, DCO signing"); + if (areasHit.has("Credentials / inference")) suggestedTestFocus.push("Credential storage, inference provider routing"); + if (suggestedTestFocus.length === 0 && commits.length > 0) suggestedTestFocus.push("General smoke test — no risky areas touched"); + + const output: HandoffOutput = { + previousTag, + targetVersion, + commitCount: commits.length, + commits, + riskyFilesTouched, + riskyAreas, + suggestedTestFocus, + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts new file mode 100644 index 000000000..1799a4f15 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Deterministic hotspot detection for NemoClaw. + * + * Combines 30-day git churn on main with open PR file overlap to rank + * the files causing the most merge pain. Outputs structured JSON. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/hotspots.ts [--days N] [--repo OWNER/REPO] + */ + +import { isRiskyFile, run, parseStringArg, parseIntArg } from "./shared.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Hotspot { + path: string; + mainTouchCount: number; + openPrCount: number; + combinedScore: number; + isRisky: boolean; +} + +interface HotspotOutput { + generatedAt: string; + repo: string; + days: number; + hotspots: Hotspot[]; +} + +// --------------------------------------------------------------------------- +// Data collection +// --------------------------------------------------------------------------- + +function gitChurn(days: number): Map { + const out = run("git", [ + "log", + `--since=${days} days ago`, + "--name-only", + "--format=", + "origin/main", + ]); + + const counts = new Map(); + if (!out) return counts; + + for (const line of out.split("\n")) { + const path = line.trim(); + if (path) { + counts.set(path, (counts.get(path) ?? 0) + 1); + } + } + return counts; +} + +function openPrFileOverlap(repo: string): Map { + const prListOut = run("gh", [ + "pr", "list", + "--repo", repo, + "--state", "open", + "--limit", "200", + "--json", "number", + ]); + + const counts = new Map(); + if (!prListOut) return counts; + + let prs: Array<{ number: number }>; + try { + prs = JSON.parse(prListOut); + } catch { + return counts; + } + + const sample = prs.slice(0, 50); + for (const pr of sample) { + const filesOut = run("gh", [ + "pr", "view", String(pr.number), + "--repo", repo, + "--json", "files", + ]); + if (!filesOut) continue; + + let data: { files?: Array<{ path: string }> }; + try { + data = JSON.parse(filesOut); + } catch { + continue; + } + + const seen = new Set(); + for (const f of data.files ?? []) { + if (!seen.has(f.path)) { + seen.add(f.path); + counts.set(f.path, (counts.get(f.path) ?? 0) + 1); + } + } + } + + return counts; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const args = process.argv.slice(2); + const days = parseIntArg(args, "--days", 30); + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + process.stderr.write("Collecting git churn...\n"); + const churn = gitChurn(days); + + process.stderr.write("Collecting open PR file overlap...\n"); + const prOverlap = openPrFileOverlap(repo); + + const allPaths = new Set([...churn.keys(), ...prOverlap.keys()]); + const hotspots: Hotspot[] = []; + + for (const path of allPaths) { + const mainTouchCount = churn.get(path) ?? 0; + const openPrCount = prOverlap.get(path) ?? 0; + + if (mainTouchCount < 2 && openPrCount < 2) continue; + + const risky = isRiskyFile(path); + // Score: main churn weight 1x, PR overlap 3x (conflict proxy), risky 2x bonus + const combinedScore = + mainTouchCount + openPrCount * 3 + (risky ? (mainTouchCount + openPrCount) * 2 : 0); + + hotspots.push({ path, mainTouchCount, openPrCount, combinedScore, isRisky: risky }); + } + + hotspots.sort((a, b) => b.combinedScore - a.combinedScore); + + const output: HotspotOutput = { + generatedAt: new Date().toISOString(), + repo, + days, + hotspots: hotspots.slice(0, 25), + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/shared.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/shared.ts new file mode 100644 index 000000000..c55744160 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/shared.ts @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared utilities for NemoClaw maintainer scripts. + * + * Centralizes risky-area detection, test-file detection, and shell helpers + * so that triage, check-gates, and hotspots stay in sync. + */ + +import { execFileSync } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Risky area patterns — paths that need tests before approval +// --------------------------------------------------------------------------- + +export const RISKY_PATTERNS: RegExp[] = [ + /^install\.sh$/, + /^setup\.sh$/, + /^brev-setup\.sh$/, + /^scripts\/.*\.sh$/, + /^bin\/lib\/onboard\.js$/, + /^bin\/.*\.js$/, + /^nemoclaw\/src\/blueprint\//, + /^nemoclaw-blueprint\//, + /^\.github\/workflows\//, + /\.prek\./, + /policy/i, + /ssrf/i, + /credential/i, + /inference/i, +]; + +export const TEST_PATTERNS: RegExp[] = [ + /\.test\.[jt]sx?$/, + /\.spec\.[jt]sx?$/, + /^test\//, +]; + +export function isRiskyFile(path: string): boolean { + return RISKY_PATTERNS.some((re) => re.test(path)); +} + +export function isTestFile(path: string): boolean { + return TEST_PATTERNS.some((re) => re.test(path)); +} + +// --------------------------------------------------------------------------- +// Shell helpers +// --------------------------------------------------------------------------- + +/** + * Run a command and return its stdout. On failure, logs the error to stderr + * and returns an empty string so callers can handle the absence of data. + */ +export function run( + cmd: string, + args: string[], + timeoutMs = 120_000, +): string { + try { + return execFileSync(cmd, args, { + encoding: "utf-8", + timeout: timeoutMs, + maxBuffer: 10 * 1024 * 1024, + }).trim(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[shared] ${cmd} ${args[0] ?? ""} failed: ${message}\n`); + return ""; + } +} + +/** + * Run `gh` with the given args and parse the JSON output. + * Returns null when the command fails or output is not valid JSON. + */ +export function ghJson(args: string[]): unknown { + const out = run("gh", args); + if (!out) return null; + try { + return JSON.parse(out); + } catch { + process.stderr.write(`[shared] gh JSON parse failed for: gh ${args.join(" ")}\n`); + return null; + } +} + +// --------------------------------------------------------------------------- +// Triage scoring weights +// +// Each weight reflects relative priority in the maintainer queue. +// Documented in nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md. +// --------------------------------------------------------------------------- + +/** PR passed all checks and is already approved — only needs final gate */ +export const SCORE_MERGE_NOW = 40; +/** PR has green CI, no conflicts, not draft — ready for maintainer review */ +export const SCORE_REVIEW_READY = 35; +/** PR is close to ready with a clear small fix path */ +export const SCORE_NEAR_MISS = 30; +/** PR touches security-sensitive code and is actionable */ +export const SCORE_SECURITY_ACTIONABLE = 20; +/** PR carries the "security" GitHub label */ +export const SCORE_LABEL_SECURITY = 15; +/** PR carries a "priority: high" GitHub label */ +export const SCORE_LABEL_PRIORITY_HIGH = 10; +/** PR has been stale > 7 days — mild priority bump to prevent rot */ +export const SCORE_STALE_AGE = 5; + +/** Draft PRs or PRs with non-trivial merge conflicts are effectively blocked */ +export const PENALTY_DRAFT_OR_CONFLICT = -100; +/** Unresolved major/critical CodeRabbit finding blocks approval */ +export const PENALTY_CODERABBIT_MAJOR = -80; +/** Broad CI red with no obvious local fix — not worth salvaging yet */ +export const PENALTY_BROAD_CI_RED = -60; +/** Blocked on external admin action (permissions, secrets, etc.) */ +export const PENALTY_MERGE_BLOCKED = -20; + +// --------------------------------------------------------------------------- +// CLI argument parsing helpers +// --------------------------------------------------------------------------- + +/** + * Parse a string CLI flag from argv. Returns `defaultValue` when the flag is + * absent or when the next token is missing / looks like another flag. + */ +export function parseStringArg(args: string[], flag: string, defaultValue: string): string { + const idx = args.indexOf(flag); + if (idx < 0) return defaultValue; + const value = args[idx + 1]; + if (!value || value.startsWith("--")) { + process.stderr.write(`[shared] ${flag} requires a value, using default: ${defaultValue}\n`); + return defaultValue; + } + return value; +} + +/** + * Parse an integer CLI flag from argv. Returns `defaultValue` when the flag is + * absent, the next token is missing, or the value is not a valid integer. + */ +export function parseIntArg(args: string[], flag: string, defaultValue: number): number { + const idx = args.indexOf(flag); + if (idx < 0) return defaultValue; + const value = parseInt(args[idx + 1], 10); + if (isNaN(value)) { + process.stderr.write(`[shared] ${flag} requires a number, using default: ${defaultValue}\n`); + return defaultValue; + } + return value; +} diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/state.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/state.ts new file mode 100644 index 000000000..bc5791689 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/state.ts @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * State file manager for the NemoClaw maintainer skills. + * + * Subcommands: + * init Create state file and .git/info/exclude entry + * show Print current state + * exclude Add PR to permanent exclusion list (triage only processes PRs) + * unexclude Remove from exclusion list + * history Add a history entry + * set-queue Update queue from triage output (pipe JSON to stdin) + * set-hotspots Update hotspots from hotspot output (pipe JSON to stdin) + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts [args] + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const STATE_DIR = resolve(".nemoclaw-maintainer"); +const STATE_PATH = resolve(STATE_DIR, "state.json"); +const GIT_EXCLUDE = resolve(".git", "info", "exclude"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface HistoryEntry { + at: string; + item: string; + action: string; + note: string; +} + +interface StateFile { + version: number; + repo: string; + updatedAt: string | null; + priorities: string[]; + gates: Record; + excluded: { + prs: Record; + issues: Record; + }; + queue: { + generatedAt: string | null; + topAction: unknown; + items: unknown[]; + nearMisses: unknown[]; + }; + hotspots: { + generatedAt: string | null; + files: unknown[]; + }; + activeWork: { + kind: string | null; + target: string | null; + branch: string | null; + goal: string | null; + startedAt: string | null; + }; + history: HistoryEntry[]; +} + +// --------------------------------------------------------------------------- +// State CRUD +// --------------------------------------------------------------------------- + +function defaultState(): StateFile { + return { + version: 1, + repo: "NVIDIA/NemoClaw", + updatedAt: null, + priorities: [ + "reduce_pr_backlog", + "reduce_security_risk", + "increase_test_coverage", + "cool_hot_files", + ], + gates: { + greenCi: true, + noConflicts: true, + noMajorCodeRabbit: true, + testsForTouchedRiskyCode: true, + autoApprove: true, + autoPushSmallFixes: true, + autoMerge: false, + }, + excluded: { prs: {}, issues: {} }, + queue: { generatedAt: null, topAction: null, items: [], nearMisses: [] }, + hotspots: { generatedAt: null, files: [] }, + activeWork: { kind: null, target: null, branch: null, goal: null, startedAt: null }, + history: [], + }; +} + +function loadState(): StateFile { + if (!existsSync(STATE_PATH)) { + return defaultState(); + } + return JSON.parse(readFileSync(STATE_PATH, "utf-8")) as StateFile; +} + +function saveState(state: StateFile): void { + state.updatedAt = new Date().toISOString(); + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n"); +} + +function ensureExclude(): void { + if (!existsSync(GIT_EXCLUDE)) return; + const content = readFileSync(GIT_EXCLUDE, "utf-8"); + const entry = ".nemoclaw-maintainer/"; + if (!content.includes(entry)) { + appendFileSync(GIT_EXCLUDE, `\n${entry}\n`); + console.error(`Added ${entry} to ${GIT_EXCLUDE}`); + } +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +function cmdInit(): void { + mkdirSync(STATE_DIR, { recursive: true }); + if (!existsSync(STATE_PATH)) { + saveState(defaultState()); + console.log(`Created ${STATE_PATH}`); + } else { + console.log(`${STATE_PATH} already exists`); + } + ensureExclude(); +} + +function cmdShow(): void { + const state = loadState(); + console.log(JSON.stringify(state, null, 2)); +} + +function cmdExclude(numberStr: string, reason: string): void { + const state = loadState(); + state.excluded.prs[numberStr] = { + reason, + excludedAt: new Date().toISOString(), + }; + saveState(state); + console.log(`Excluded PR #${numberStr}: ${reason}`); +} + +function cmdUnexclude(numberStr: string): void { + const state = loadState(); + delete state.excluded.prs[numberStr]; + delete state.excluded.issues[numberStr]; + saveState(state); + console.log(`Unexcluded #${numberStr}`); +} + +function cmdHistory(action: string, item: string, note: string): void { + const state = loadState(); + state.history.push({ + at: new Date().toISOString(), + item, + action, + note, + }); + if (state.history.length > 50) { + state.history = state.history.slice(-50); + } + saveState(state); + console.log(`Added history: ${action} ${item}`); +} + +function cmdSetQueue(): void { + const input = readFileSync(0, "utf-8"); + let triageOutput: Record; + try { + triageOutput = JSON.parse(input); + } catch (err) { + console.error(`Failed to parse triage JSON from stdin: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + const state = loadState(); + + state.queue = { + generatedAt: triageOutput.generatedAt ?? new Date().toISOString(), + topAction: triageOutput.queue?.[0] ?? null, + items: triageOutput.queue ?? [], + nearMisses: triageOutput.nearMisses ?? [], + }; + saveState(state); + console.log(`Queue updated: ${state.queue.items.length} items, ${state.queue.nearMisses.length} near misses`); +} + +function cmdSetHotspots(): void { + const input = readFileSync(0, "utf-8"); + let hotspotOutput: Record; + try { + hotspotOutput = JSON.parse(input); + } catch (err) { + console.error(`Failed to parse hotspot JSON from stdin: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + const state = loadState(); + + state.hotspots = { + generatedAt: hotspotOutput.generatedAt ?? new Date().toISOString(), + files: hotspotOutput.hotspots ?? [], + }; + saveState(state); + console.log(`Hotspots updated: ${state.hotspots.files.length} entries`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const [subcommand, ...args] = process.argv.slice(2); + + switch (subcommand) { + case "init": + cmdInit(); + break; + case "show": + cmdShow(); + break; + case "exclude": + if (args.length < 2) { + console.error("Usage: state.ts exclude "); + process.exit(1); + } + cmdExclude(args[0], args.slice(1).join(" ")); + break; + case "unexclude": + if (args.length < 1) { + console.error("Usage: state.ts unexclude "); + process.exit(1); + } + cmdUnexclude(args[0]); + break; + case "history": + if (args.length < 3) { + console.error("Usage: state.ts history "); + process.exit(1); + } + cmdHistory(args[0], args[1], args.slice(2).join(" ")); + break; + case "set-queue": + cmdSetQueue(); + break; + case "set-hotspots": + cmdSetHotspots(); + break; + default: + console.error( + "Usage: state.ts [args]", + ); + process.exit(1); + } +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/triage.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/triage.ts new file mode 100644 index 000000000..41e8e5945 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/triage.ts @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Deterministic NemoClaw maintainer triage queue builder. + * + * Lists open PRs via gh, classifies them as merge-ready / near-miss / blocked, + * enriches top candidates with file-level risky-area detection, applies + * scoring weights, filters exclusions from the state file, and outputs + * a ranked JSON queue. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/triage.ts [--limit N] [--approved-only] + */ + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + isRiskyFile, + run, + parseStringArg, + parseIntArg, + SCORE_MERGE_NOW, + SCORE_REVIEW_READY, + SCORE_NEAR_MISS, + SCORE_SECURITY_ACTIONABLE, + SCORE_LABEL_SECURITY, + SCORE_LABEL_PRIORITY_HIGH, + SCORE_STALE_AGE, + PENALTY_DRAFT_OR_CONFLICT, + PENALTY_CODERABBIT_MAJOR, + PENALTY_BROAD_CI_RED, + PENALTY_MERGE_BLOCKED, +} from "./shared.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PrData { + number: number; + title: string; + url: string; + author: { login: string }; + additions: number; + deletions: number; + changedFiles: number; + isDraft: boolean; + createdAt: string; + updatedAt: string; + mergeStateStatus: string; + reviewDecision: string; + labels: Array<{ name: string }>; + statusCheckRollup: Array<{ + name: string; + status: string; + conclusion: string; + }>; +} + +interface ClassifiedPr { + number: number; + title: string; + url: string; + author: string; + churn: number; + changedFiles: number; + checksGreen: boolean; + coderabbitMajor: boolean; + reasons: string[]; + mergeNow: boolean; + reviewReady: boolean; + nearMiss: boolean; + updatedAt: string; + createdAt: string; + draft: boolean; + labels: string[]; +} + +interface QueueItem { + rank: number; + number: number; + url: string; + title: string; + author: string; + score: number; + bucket: "merge-now" | "review-ready" | "salvage-now" | "blocked"; + reasons: string[]; + riskyFiles: string[]; + churn: number; + changedFiles: number; + nextAction: string; + ageHours: number; + labels: string[]; +} + +interface HotCluster { + path: string; + openPrCount: number; +} + +interface TriageOutput { + generatedAt: string; + repo: string; + scanned: number; + queue: QueueItem[]; + nearMisses: QueueItem[]; + hotClusters: HotCluster[]; +} + +interface StateFile { + excluded: { + prs: Record; + issues: Record; + }; +} + +// --------------------------------------------------------------------------- +// Shell helpers +// --------------------------------------------------------------------------- + +function ghApi(path: string): unknown { + const out = run("gh", ["api", path]); + if (!out) return null; + try { + return JSON.parse(out); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +function fetchOpenPrs(repo: string, approvedOnly: boolean): PrData[] { + // Use gh api --paginate with REST for lightweight pagination (no GraphQL timeout). + // --jq outputs one JSON object per PR per page; we collect them as NDJSON then parse. + const out = run("gh", [ + "api", "--paginate", + `repos/${repo}/pulls?state=open&per_page=100`, + "--jq", `.[] | { + number, title, url: .html_url, + author: {login: .user.login}, + additions: 0, deletions: 0, changedFiles: 0, + isDraft: .draft, + createdAt: .created_at, updatedAt: .updated_at, + mergeStateStatus: (if .mergeable_state == "dirty" then "DIRTY" + elif .mergeable_state == "clean" then "CLEAN" + elif .mergeable_state == "blocked" then "BLOCKED" + elif .mergeable_state == "unstable" then "UNSTABLE" + else "UNKNOWN" end), + reviewDecision: "", + labels: [.labels[] | {name}], + statusCheckRollup: [] + }`, + ], 300_000); + if (!out) return []; + + try { + // Output is NDJSON (one JSON object per line) + const prs: PrData[] = []; + for (const line of out.split("\n")) { + const trimmed = line.trim(); + if (trimmed && trimmed.startsWith("{")) { + prs.push(JSON.parse(trimmed) as PrData); + } + } + if (approvedOnly) { + return prs.filter((pr) => pr.reviewDecision === "APPROVED"); + } + return prs; + } catch { + return []; + } +} + +/** + * Enrich a PR with review decision and CI status via GraphQL (per-PR). + * Only call this for top candidates — it's one API call per PR. + */ +function enrichPr(repo: string, pr: PrData): void { + const out = run("gh", [ + "pr", "view", String(pr.number), "--repo", repo, + "--json", "reviewDecision,statusCheckRollup,additions,deletions,changedFiles", + ]); + if (!out) return; + try { + const data = JSON.parse(out) as { + reviewDecision: string; + statusCheckRollup: PrData["statusCheckRollup"]; + additions: number; + deletions: number; + changedFiles: number; + }; + pr.reviewDecision = data.reviewDecision ?? ""; + pr.statusCheckRollup = data.statusCheckRollup ?? []; + pr.additions = data.additions; + pr.deletions = data.deletions; + pr.changedFiles = data.changedFiles; + } catch { /* leave as-is */ } +} + +function classifyPr(pr: PrData): ClassifiedPr { + const reasons: string[] = []; + const draft = pr.isDraft; + if (draft) reasons.push("draft"); + + // Check CI status + // gh returns two shapes: CheckRun {name, status, conclusion} and + // StatusContext {context, state}. Handle both. + const checks = pr.statusCheckRollup ?? []; + const passingConclusions = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]); + let checksGreen = checks.length > 0; + for (const check of checks) { + const asAny = check as Record; + // StatusContext uses "state", CheckRun uses "conclusion"+"status" + if (asAny.state) { + // StatusContext: state is SUCCESS/FAILURE/PENDING/ERROR + if (!passingConclusions.has(asAny.state.toUpperCase())) { + checksGreen = false; + break; + } + } else { + // CheckRun: check conclusion after completion + const conclusion = (asAny.conclusion ?? "").toUpperCase(); + const status = (asAny.status ?? "").toUpperCase(); + const done = status === "COMPLETED" || (!status && conclusion); + if (!done || !passingConclusions.has(conclusion)) { + checksGreen = false; + break; + } + } + } + if (checks.length === 0) checksGreen = false; + if (!checksGreen && !draft) reasons.push("failing-checks"); + + // Check merge state + // DIRTY = actual merge conflict. BLOCKED = branch protection (e.g. missing reviews). + // CLEAN/HAS_HOOKS/UNSTABLE = mergeable. UNKNOWN = not yet computed. + const mergeState = (pr.mergeStateStatus ?? "UNKNOWN").toUpperCase(); + const hasConflict = mergeState === "DIRTY"; + if (hasConflict) reasons.push("merge-conflict"); + + // Check review decision + const approved = pr.reviewDecision === "APPROVED"; + const blocked = mergeState === "BLOCKED"; + if (blocked && !hasConflict) reasons.push("merge-blocked"); + + // Simple CodeRabbit heuristic: check labels for major findings + // (Full CodeRabbit check is in check-gates.ts via GraphQL) + const coderabbitMajor = false; // conservative — gate checker does the real check + + // Classify into buckets + // merge-now: approved + green CI + no conflicts — ready for final gate + const mergeNow = !draft && checksGreen && !hasConflict && approved && !coderabbitMajor; + // review-ready: green CI + no conflicts + not draft — best candidates for review + const reviewReady = !draft && !mergeNow && checksGreen && !hasConflict; + // near-miss: not draft, has fixable blockers (failing CI or minor conflict) + const nearMiss = !draft && !mergeNow && !reviewReady && reasons.length <= 2 && + !hasConflict; + + return { + number: pr.number, + title: pr.title, + url: pr.url, + author: pr.author?.login ?? "unknown", + churn: pr.additions + pr.deletions, + changedFiles: pr.changedFiles, + checksGreen, + coderabbitMajor, + reasons, + mergeNow, + reviewReady, + nearMiss, + updatedAt: pr.updatedAt, + createdAt: pr.createdAt, + draft, + labels: (pr.labels ?? []).map((l) => l.name), + }; +} + +function fetchPrFiles(repo: string, number: number): string[] { + const data = ghApi(`repos/${repo}/pulls/${number}/files?per_page=100`) as + | Array<{ filename: string }> + | null; + if (!Array.isArray(data)) return []; + return data.map((f) => f.filename); +} + +function loadState(): StateFile | null { + const stateDir = resolve(".nemoclaw-maintainer"); + const statePath = resolve(stateDir, "state.json"); + if (!existsSync(statePath)) return null; + try { + return JSON.parse(readFileSync(statePath, "utf-8")) as StateFile; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +function scoreItem( + item: ClassifiedPr, + riskyFiles: string[], +): { score: number; bucket: "merge-now" | "review-ready" | "salvage-now" | "blocked"; nextAction: string } { + let score = 0; + let bucket: "merge-now" | "review-ready" | "salvage-now" | "blocked" = "blocked"; + let nextAction = "review"; + + if (item.mergeNow) { + score += SCORE_MERGE_NOW; + bucket = "merge-now"; + nextAction = "merge-gate"; + } else if (item.reviewReady) { + score += SCORE_REVIEW_READY; + bucket = "review-ready"; + nextAction = "review → merge-gate"; + } else if (item.nearMiss) { + score += SCORE_NEAR_MISS; + bucket = "salvage-now"; + nextAction = "salvage-pr"; + } + + if (riskyFiles.length > 0 && bucket !== "blocked") { + score += SCORE_SECURITY_ACTIONABLE; + nextAction = bucket === "merge-now" ? "security-sweep → merge-gate" : "security-sweep → review"; + } + + // GitHub label boosts + const labelSet = new Set(item.labels.map((l) => l.toLowerCase())); + if (labelSet.has("security")) score += SCORE_LABEL_SECURITY; + if (labelSet.has("priority: high")) score += SCORE_LABEL_PRIORITY_HIGH; + + if (item.updatedAt) { + const age = Date.now() - new Date(item.updatedAt).getTime(); + if (age > 7 * 24 * 60 * 60 * 1000) score += SCORE_STALE_AGE; + } + + const reasons = new Set(item.reasons); + if (item.draft) score += PENALTY_DRAFT_OR_CONFLICT; + if (reasons.has("merge-conflict")) score += PENALTY_DRAFT_OR_CONFLICT; + if (item.coderabbitMajor) score += PENALTY_CODERABBIT_MAJOR; + if (reasons.has("failing-checks") && !item.nearMiss) score += PENALTY_BROAD_CI_RED; + if (reasons.has("merge-blocked")) score += PENALTY_MERGE_BLOCKED; + + return { score, bucket, nextAction }; +} + +// --------------------------------------------------------------------------- +// Hotspot detection from PR file overlap +// --------------------------------------------------------------------------- + +function detectHotClusters( + items: ClassifiedPr[], + repo: string, + fileCache: Map, +): HotCluster[] { + const fileCounts = new Map(); + + for (const item of items.slice(0, 30)) { + let files = fileCache.get(item.number); + if (!files) { + files = fetchPrFiles(repo, item.number); + fileCache.set(item.number, files); + } + const seen = new Set(); + for (const f of files) { + if (!seen.has(f)) { + seen.add(f); + fileCounts.set(f, (fileCounts.get(f) ?? 0) + 1); + } + } + } + + return [...fileCounts.entries()] + .filter(([, count]) => count >= 3) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([path, count]) => ({ path, openPrCount: count })); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const args = process.argv.slice(2); + const approvedOnly = args.includes("--approved-only"); + const limit = parseIntArg(args, "--limit", 10); + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + // 1. Fetch all open PRs via REST (lightweight, paginated, no GraphQL timeout) + process.stderr.write("Fetching all open PRs via REST...\n"); + const prs = fetchOpenPrs(repo, false); + if (prs.length === 0) { + console.error("No open PRs found. GitHub API may be experiencing issues."); + process.exit(1); + } + process.stderr.write(`Found ${prs.length} open PRs. Filtering non-draft candidates...\n`); + + // 2. Filter to non-draft, then enrich top candidates with CI + review data. + // NOTE: Enrichment is capped at limit*3 by design — each enrichPr() call is + // a separate GitHub API request, so we intentionally limit the blast radius. + // Un-enriched PRs will classify as "blocked" (empty checks), which is the + // safe default. This is NOT a bug. + const candidates = prs.filter((pr) => !pr.isDraft); + const enrichCount = Math.min(candidates.length, limit * 3); + process.stderr.write(`Enriching ${enrichCount} of ${candidates.length} candidates...\n`); + for (let i = 0; i < enrichCount; i++) { + enrichPr(repo, candidates[i]); + } + + // 3. Classify all PRs (un-enriched ones will be blocked due to empty checks) + const classified = prs.map(classifyPr); + process.stderr.write( + `Classified: ${classified.filter((c) => c.mergeNow).length} merge-now, ` + + `${classified.filter((c) => c.reviewReady).length} review-ready, ` + + `${classified.filter((c) => c.nearMiss).length} near-miss, ` + + `${classified.filter((c) => !c.mergeNow && !c.reviewReady && !c.nearMiss).length} blocked\n`, + ); + + // 2. Load exclusions + const state = loadState(); + const excludedPrs = new Set( + Object.keys(state?.excluded?.prs ?? {}).map(Number), + ); + + const allItems = classified.filter((item) => !excludedPrs.has(item.number)); + + // 3. Enrich top candidates with file data and scoring + const fileCache = new Map(); + const topCandidates = allItems + .filter((item) => item.mergeNow || item.reviewReady || item.nearMiss) + .slice(0, limit * 2); + + // Also include non-actionable non-draft items for context + const remaining = allItems + .filter((item) => !item.mergeNow && !item.reviewReady && !item.nearMiss && !item.draft) + .slice(0, limit); + + const toScore = [...topCandidates, ...remaining]; + + const scored: QueueItem[] = []; + for (const item of toScore) { + const files = fetchPrFiles(repo, item.number); + fileCache.set(item.number, files); + const riskyFiles = files.filter(isRiskyFile); + const { score, bucket, nextAction } = scoreItem(item, riskyFiles); + + scored.push({ + rank: 0, + number: item.number, + url: item.url, + title: item.title, + author: item.author, + score, + bucket, + reasons: item.reasons, + riskyFiles, + churn: item.churn, + changedFiles: item.changedFiles, + nextAction, + ageHours: item.createdAt + ? Math.floor((Date.now() - new Date(item.createdAt).getTime()) / 3_600_000) + : 0, + labels: item.labels, + }); + } + + // 4. Sort and rank + scored.sort((a, b) => b.score - a.score); + const queue = scored.filter((s) => s.bucket === "merge-now" || s.bucket === "review-ready").slice(0, limit); + const nearMisses = scored.filter((s) => s.bucket === "salvage-now").slice(0, limit); + queue.forEach((item, i) => (item.rank = i + 1)); + nearMisses.forEach((item, i) => (item.rank = i + 1)); + + // 5. Detect hot clusters + const hotClusters = detectHotClusters(allItems, repo, fileCache); + + // 6. Output + const output: TriageOutput = { + generatedAt: new Date().toISOString(), + repo, + scanned: prs.length, + queue, + nearMisses, + hotClusters, + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts new file mode 100644 index 000000000..2477ea8f3 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Check progress for a version label: shipped vs still open. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts [--repo OWNER/REPO] + */ + +import { run, parseStringArg } from "./shared.ts"; + +interface ProgressItem { + number: number; + title: string; + url: string; + type: "pr" | "issue"; + ageHours: number; +} + +interface ProgressOutput { + version: string; + shipped: ProgressItem[]; + open: ProgressItem[]; + summary: string; +} + +function queryItems( + repo: string, + kind: "pr" | "issue", + version: string, + state: string, +): ProgressItem[] { + const cmd = kind === "pr" ? "pr" : "issue"; + const out = run("gh", [ + cmd, "list", "--repo", repo, + "--label", version, "--state", state, + "--json", "number,title,url,createdAt", "--limit", "100", + ]); + if (!out) return []; + try { + const now = Date.now(); + const items = JSON.parse(out) as Array<{ number: number; title: string; url: string; createdAt: string }>; + return items.map((i) => ({ + number: i.number, + title: i.title, + url: i.url, + type: kind, + ageHours: Math.floor((now - new Date(i.createdAt).getTime()) / 3_600_000), + })); + } catch { + return []; + } +} + +function main(): void { + const args = process.argv.slice(2); + const version = args[0]; + if (!version) { + console.error("Usage: version-progress.ts [--repo OWNER/REPO]"); + process.exit(1); + } + + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + const shipped = [ + ...queryItems(repo, "pr", version, "merged"), + ...queryItems(repo, "issue", version, "closed"), + ]; + const open = [ + ...queryItems(repo, "pr", version, "open"), + ...queryItems(repo, "issue", version, "open"), + ]; + + const total = shipped.length + open.length; + const summary = total === 0 + ? `${version}: no items labeled` + : `${version}: ${shipped.length}/${total} shipped (${open.length} open)`; + + const output: ProgressOutput = { version, shipped, open, summary }; + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts new file mode 100644 index 000000000..00b63c217 --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Compute the day's target version and find stragglers from older versions. + * + * Reads the latest semver tag from the local repo, bumps patch by 1, then + * queries GitHub for open PRs/issues carrying version labels older than + * the target. Output is JSON. + * + * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts [--repo OWNER/REPO] + */ + +import { run, parseStringArg } from "./shared.ts"; + +interface Straggler { + number: number; + title: string; + url: string; + type: "pr" | "issue"; + versionLabel: string; +} + +interface VersionTargetOutput { + latestTag: string; + targetVersion: string; + stragglers: Straggler[]; +} + +function getLatestTag(): string { + const out = run("git", [ + "tag", "--sort=-v:refname", + ]); + if (!out) return "v0.0.0"; + + for (const line of out.split("\n")) { + if (/^v\d+\.\d+\.\d+$/.test(line.trim())) { + return line.trim(); + } + } + return "v0.0.0"; +} + +function bumpPatch(tag: string): string { + const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!match) return "v0.0.1"; + return `v${match[1]}.${match[2]}.${parseInt(match[3], 10) + 1}`; +} + +/** + * Compare two semver strings. Returns negative if a < b, 0 if equal, positive if a > b. + */ +function compareSemver(a: string, b: string): number { + const pa = a.replace(/^v/, "").split(".").map(Number); + const pb = b.replace(/^v/, "").split(".").map(Number); + for (let i = 0; i < 3; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function findStragglers(repo: string, targetVersion: string): Straggler[] { + const stragglers: Straggler[] = []; + + // Get all open PRs with version labels + const prOut = run("gh", [ + "pr", "list", "--repo", repo, "--state", "open", + "--json", "number,title,url,labels", "--limit", "200", + ]); + if (prOut) { + try { + const prs = JSON.parse(prOut) as Array<{ + number: number; title: string; url: string; + labels: Array<{ name: string }>; + }>; + for (const pr of prs) { + for (const label of pr.labels) { + // Only flag labels older than the target — not the target itself + // and not future versions (e.g. v0.0.11 is not a straggler) + if (/^v\d+\.\d+\.\d+$/.test(label.name) && compareSemver(label.name, targetVersion) < 0) { + stragglers.push({ + number: pr.number, + title: pr.title, + url: pr.url, + type: "pr", + versionLabel: label.name, + }); + } + } + } + } catch (err: unknown) { + process.stderr.write(`[version-target] Failed to parse PR list: ${err instanceof Error ? err.message : err}\n`); + } + } + + // Get all open issues with version labels + const issueOut = run("gh", [ + "issue", "list", "--repo", repo, "--state", "open", + "--json", "number,title,url,labels", "--limit", "200", + ]); + if (issueOut) { + try { + const issues = JSON.parse(issueOut) as Array<{ + number: number; title: string; url: string; + labels: Array<{ name: string }>; + }>; + for (const issue of issues) { + for (const label of issue.labels) { + if (/^v\d+\.\d+\.\d+$/.test(label.name) && compareSemver(label.name, targetVersion) < 0) { + stragglers.push({ + number: issue.number, + title: issue.title, + url: issue.url, + type: "issue", + versionLabel: label.name, + }); + } + } + } + } catch (err: unknown) { + process.stderr.write(`[version-target] Failed to parse issue list: ${err instanceof Error ? err.message : err}\n`); + } + } + + return stragglers; +} + +function main(): void { + const args = process.argv.slice(2); + // --repo controls gh queries; git tags always come from the local checkout + const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); + + run("git", ["fetch", "origin", "--tags", "--prune"]); + + const latestTag = getLatestTag(); + const targetVersion = bumpPatch(latestTag); + const stragglers = findStragglers(repo, targetVersion); + + const output: VersionTargetOutput = { latestTag, targetVersion, stragglers }; + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/.agents/skills/nemoclaw-maintainer-evening/SKILL.md b/.agents/skills/nemoclaw-maintainer-evening/SKILL.md new file mode 100644 index 000000000..f8aa9411f --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-evening/SKILL.md @@ -0,0 +1,63 @@ +--- +name: nemoclaw-maintainer-evening +description: Runs the end-of-day maintainer handoff for NemoClaw. Checks version target progress, bumps stragglers to the next patch version, generates a QA handoff summary, and cuts the release tag. Use at the end of the workday. Trigger keywords - evening, end of day, EOD, wrap up, ship it, cut tag, handoff, done for the day. +user_invocable: true +--- + +# NemoClaw Maintainer Evening + +Wrap up the day: check progress, bump stragglers, summarize for QA, cut the tag. + +See [PR-REVIEW-PRIORITIES.md](../nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md) for the daily cadence. + +## Step 1: Check Progress + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-progress.ts +``` + +The first script determines the target version. The second shows shipped vs open. Present the progress summary to the user. + +## Step 2: Bump Stragglers + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts +``` + +This creates the next version label if needed, then moves all open items from the current version to the next. Tell the user what got bumped. + +## Step 3: Generate Handoff Summary + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts +``` + +This lists commits since the last tag, identifies risky areas touched, and suggests QA test focus areas. Format the output as a concise summary the user can paste into the tag annotation or a handoff channel. + +## Step 4: Cut the Tag + +Load `cut-release-tag`. The version is already known — default to patch bump, but still show the commit and changelog for confirmation. + +## Step 5: Confirm and Share + +After the tag is cut, present the final summary: + +- **Tag**: `v0.0.8` at commit `abc1234` +- **Shipped**: 4 items (#1234, #1235, #1236, #1237) +- **Bumped to v0.0.9**: 1 item (#1238 — still needs CI fix) +- **QA focus areas**: installer changes, new onboard preset + +This summary can be shared in the team's handoff channel. + +## Step 6: Update State + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts history "tag-cut" "" "shipped N items, bumped M" +``` + +## Notes + +- Never cut a tag without user confirmation. +- If nothing was labeled or nothing shipped, ask whether to skip the tag today. +- Version labels are living markers: they always mean "ship in this version." If an item slips, the label moves forward. diff --git a/.agents/skills/nemoclaw-maintainer-morning/SKILL.md b/.agents/skills/nemoclaw-maintainer-morning/SKILL.md new file mode 100644 index 000000000..16ac63cec --- /dev/null +++ b/.agents/skills/nemoclaw-maintainer-morning/SKILL.md @@ -0,0 +1,65 @@ +--- +name: nemoclaw-maintainer-morning +description: Runs the morning maintainer standup for NemoClaw. Triages the backlog, determines the day's target version, labels selected items, surfaces stragglers from previous versions, and outputs the daily plan. Use at the start of the workday. Trigger keywords - morning, standup, start of day, daily plan, what are we shipping today. +user_invocable: true +--- + +# NemoClaw Maintainer Morning + +Start the day: triage, pick a version target, label items, share the plan. + +See [PR-REVIEW-PRIORITIES.md](../nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md) for the daily cadence and review priorities. + +## Step 1: Determine Target Version and Stragglers + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/version-target.ts +``` + +This fetches tags, computes the next patch version, and finds open items still carrying older version labels. Surface stragglers first — the team needs to decide: relabel to today's target, or defer further. + +## Step 2: Triage + +Run the triage script to rank the full backlog: + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/triage.ts --approved-only +``` + +If too few results, run without `--approved-only`. The script calls `gh-pr-merge-now --json`, enriches candidates with risky-area detection, and applies the scoring model documented in [PR-REVIEW-PRIORITIES.md](../nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md). + +Also use `find-review-pr` to surface PRs with `security` + `priority: high` labels. Merge these into the candidate pool. + +## Step 3: Label Version Targets + +Present the ranked queue to the user. After they confirm which items to target, label them: + +```bash +gh label create "" --repo NVIDIA/NemoClaw --description "Release target" --color "1d76db" 2>/dev/null || true +gh pr edit --repo NVIDIA/NemoClaw --add-label "" +gh issue edit --repo NVIDIA/NemoClaw --add-label "" +``` + +## Step 4: Save State and Output the Plan + +Pipe triage output into state: + +```bash +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/triage.ts \ + | node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts set-queue +``` + +Output the daily plan: + +| Target | Item | Type | Owner | Next action | +|--------|------|------|-------|-------------| +| v0.0.8 | [#1234](https://github.com/NVIDIA/NemoClaw/pull/1234) | PR | @author | Run merge gate | +| v0.0.8 | [#1235](https://github.com/NVIDIA/NemoClaw/issues/1235) | Issue | unassigned | Needs PR | + +Include: total items targeted, how many are PRs vs issues, how many are already merge-ready. + +## Notes + +- This skill runs once at the start of the day. Use `/nemoclaw-maintainer-day` during the day to execute. +- The target version label is the source of truth for "what we're shipping today." +- Stragglers from previous versions should be addressed first — they already slipped once.