Skip to content

chore(repo): add issue and PR triage tooling, and a batch PR reviewer - #37082

Open
AgentEnder wants to merge 17 commits into
masterfrom
triage-skills
Open

AgentEnder wants to merge 17 commits into
masterfrom
triage-skills

Conversation

@AgentEnder

Copy link
Copy Markdown
Member

Current Behavior

Triage is manual, and a wrong label or a clumsy comment is public the moment it is made. Reviewing several PRs means running /review-pr one at a time.

Expected Behavior

.claude/tools/triage stages label changes, assignees and comments as markdown records under a gitignored directory. pnpm triage-tui is where they get approved, rejected, or sent back with a note. Nothing reaches gh before that.

/triage-issues applies scope and type labels, asks for a reproduction when one is missing, runs it when one exists, and proposes a priority and an owner from .claude/tools/triage-owners.md. /triage-prs picks an assignee, drafts a PR whose CI is red, and checks for a linked issue. Neither one is a code review.

/review-many-prs fans /review-pr out over several PRs, three at a time, each in its own herdr tab. .claude/tools/review gives the drafts a status so a batch does not need its own watcher.

Related Issue(s)

Fixes #

Triage produces label changes, assignees and public comments. Applying
those straight from an agent means the first time a human sees a mistake,
it is already on a public issue and cannot be unsent. This stages each
mutation instead, and only a human moves it forward.

- `.claude/tools/triage` writes one hand-editable markdown record per
  issue and applies approved ones through gh. It refuses to stage the
  bot-owned `stale`/`outdated` labels, two stale countdowns at once, a
  label both added and removed, or a close with no comment explaining it.
- `.claude/tools/triage-tui` is the approval surface: approve, reject,
  leave a note the agent reads back with `triage feedback`, open the
  record in $EDITOR, or open the issue on GitHub.
- `triage-owners.json` routes scopes to maintainers and rotates rows with
  several owners, so one person does not absorb a whole sweep.

The TUI is its own workspace package because ink depends on yoga-layout,
which uses top-level await and cannot be transformed to CommonJS; the
sibling package.json scopes just that directory to ESM. It also keeps ink
out of the root dependency tree. `pnpm triage-tui` runs it.

Everything except the TUI runs on node builtins alone, so applying still
works in a fresh clone before pnpm install.
A repo-facing workflow for triaging nrwl/nx issues: apply the labels that
make an issue count as triaged, ask for a reproduction when one is
missing, run it when one exists, and propose a priority and an owner.
Every mutation is staged through .claude/tools/triage for a human to
approve; the skill never writes to GitHub itself.

The definition of "triaged" is not a convention here. Any open issue with
no `scope:` label increments untriagedIssueCount in
scripts/issues-scraper/scrape-issues.ts, which issue-notifier.yml posts to
Slack weekly, so the queue query mirrors that scraper exactly.

Things the skill encodes because they are easy to get wrong:

- A `blocked:` label is an eviction timer, not a note. schedule-stale.yml
  gives 7 days to `stale` and 21 more to closed. Removing one when the
  reporter answers is a triage duty, since the bot only strips `stale`.
- `stale` and `outdated` are bot-owned and must never be set by hand.
- closedByPullRequestsReferences finds a PR already in flight, including
  on open issues. Scraping /timeline for cross-referenced events does not.
- The scope table is built from how maintainers actually label, not from
  the label descriptions: `scope: bundlers` reads "webpack, rollup" but
  covers vite, esbuild, rspack and rsbuild too.
- A `Steps to Reproduce` section containing only `1.` is the bug form's
  seeded default, not a reproduction. No labels at all means the issue was
  filed outside the form, so none of its required fields are guaranteed.
- Before proposing `community`, check the fix is contributable. The repo
  does ship graph UI source under graph/, but those packages import the
  published closed-source @nx/graph, so "it's a graph bug" decides nothing.

references/labels.md holds the scope map and close reasons;
references/replies.md holds comment templates.
A reproduction clones a stranger's repo and installs it with that repo's own
package manager. Fetching that into the shared object store would mix an
untrusted history into the repo every PR review reads.

- `sandbox start` without `--checkout` now provisions an empty private
  directory in the shared host instead of a worktree of nx, with `root`
  pointing at the workspace itself since there is no `nx` subtree.
- `sandbox exec` skips `ensureInstalled` for a scratch workspace: there is
  nothing to install, and `mise install && pnpm install` fails in an empty
  directory.
- reproduce-issue is rewritten onto that mode, so repro code runs in the
  shared host rather than a container of its own.
Nothing reaches GitHub until a human approves it. Every label, assignee,
comment and close is written to `.nx-issue-triage/<N>.md` first, and only
`apply` calls `gh`.

- Add the record lifecycle: stage, list, show, set, feedback, apply, rm,
  prune, and `notify` for pushing into a live TUI.
- Refuse to stage a record that sets a bot-owned label, starts two stale
  countdowns at once, adds and removes the same label, or closes an issue
  with no comment, so the guardrails hold when the skill is only skimmed.
- Journal every write to ~/.nx-triage/journal.jsonl, outside the state
  directory, so `recover` can restore records if that directory is cleared.
  It is gitignored and shared between sessions, so it really does get lost.
- Record the issue's current labels and assignees at stage time, so review
  can tell a real mutation from a no-op restatement.
- Move owner routing from triage-owners.json to a documented triage-owners.md.
  The table now carries a roster that `owner` asserts against, so a handle
  that has left the team fails loudly instead of quietly drawing issues, plus
  freeform per-area guidance and per-person standing notes that JSON had
  nowhere to put.
Reviewing staged records by reading markdown and running `triage set` by
hand does not scale past a handful, and a sweep stages dozens.

- Approve, reject, request changes with a note, open the issue on GitHub,
  and edit a record in $EDITOR from the list.
- Reload as the state directory changes, so records land in front of the
  reviewer while the agent is still staging them.
- Accept pushes from `triage notify`: reload, select a record, or show a
  message, so the agent can point the reviewer at what it just restaged.
- Render the record body rather than the raw file, and keep comment text
  selectable without picking up soft-wrap breaks.
The skill's failure mode was a confident record built on reading rather than
running. Every excuse in the new table was written into a real record here,
sounded reasonable, and was wrong.

- Fetch the whole queue in one GraphQL call instead of three REST calls per
  issue, and read each issue out of the file rather than into context.
- Default to attempting a reproduction. `repro: not run` is legitimate in
  exactly two cases, an open PR or nothing runnable, and must name which in
  its first sentence. audit-repro.sh checks that mechanically.
- Add a Step 3 check for a fix that landed WITHOUT a closing keyword:
  #36426 was fixed by a PR that said `Fixes` on its later duplicate, so
  every PR-detection path came back clean on an issue already fixed.
- Require plain prose outside the collapsed Triage notes block on every
  comment. A bare `<details>` reads as a bot dropping an artifact on the
  thread, which is what the attribution split exists to prevent.
- Note that a Windows VM is reachable, so `os: windows` is not unrunnable,
  and that the sandbox is Linux, so a clean run narrows a darwin bug rather
  than clearing it.
The agent and the reviewer work in parallel: records land while the reviewer
is still deciding on earlier ones. That needs the TUI beside the
conversation, not in front of it, and it needs decisions coming back without
the agent polling.

- Open `triage review` in a herdr or tmux split, self-closing on quit.
- Arm watch-triage.sh as the return path: one line per status change with
  the reviewer's note, exiting once every record settles. It also reports if
  the state directory disappears mid-review, which a stray clean elsewhere
  in a shared checkout really can do.
- Say plainly that this needs a multiplexer rather than degrading to a
  foreground TUI that takes the session over, or a detached one that exits
  immediately for want of a terminal.
- Apply approvals as they arrive rather than at the end, so a reviewer who
  approves ten and steps away comes back to ten applied.
Nx bans the loudest machine-written tells already, but only inside astro-docs:
STYLE_GUIDE.md forbids em dashes, en dashes and semicolons, and the vale styles
list 60-odd banned phrases. vale runs on astro-docs/** only, so none of it covers
the text agents actually send outward: PR comments, issue replies, review
comments, triage comments.

Adds the skill plus scripts/deslop-scan.mjs, which applies those same committed
rules to any file or to stdin. Landing it first so tools/triage can delegate its
comment check to the scanner instead of carrying a regex of its own.
Drafts at $REVIEW_DIR/<PR>.md were already markdown with frontmatter, but they
carried no lifecycle. Every consumer re-derived state from ls plus grep
'^verdict:' plus mtime, and each caller invented its own freshness window, which
is what made a previous round's draft read as a new one.

Adds .claude/tools/review as the one record store, with a status: key that runs
from awaiting-grill through posted or discarded, and a watch verb: one long-lived
process emits a line per transition, so a batch needs no watcher of its own.

review-pr and review-pending-pr-reviews now write status: alongside posted_at,
since the two answer different questions. review-pr's Step 8 rewrites the whole
file, so it carries an explicit warning that dropping the key makes an in-flight
review look finished.

review-many-prs is the first consumer: it fans review-pr out over several PRs in
their own herdr tabs, three at a time, holding each slot until the child parks at
the grill.
Triaging a PR and triaging an issue differ in what they decide, but not in how
an owner is chosen or how a mutation is staged for approval. triage-issues
carried all of it inline, so adding triage-prs meant either a second copy of the
common half or a skill that reimplements it from memory.

Extracts that half into .claude/skills/triage-shared: owners.md for the routing
rules and staging.md for the record format. It has no SKILL.md on purpose, since
it is reference material rather than a procedure. Both skills point at it, so a
correction lands once rather than in one of two copies.

Adds triage-prs, which decides who handles a PR, drafts it when CI is red, checks
for a linked GitHub or Linear issue, and judges whether an unlinked community PR
should stay open.

tools/triage grows the PR-side verbs and hands its comment check to the deslop
scanner rather than a regex kept here. The TUI becomes the approval surface for
both record kinds.
The review TUI had one way to say something back: c, which sets the record to
changes-requested and holds the apply until it is restaged. That is the right
channel for "this record is wrong" and the wrong one for "and also open a Linear
ticket for this one", which decides nothing about the staged mutations and should
not gate them.

Adds n. It writes to the events bus rather than into the record, so the status is
untouched, an approved record can carry a note without delaying its apply, and
the note outlives apply archiving the record it names. triage note and triage
notes are the CLI ends of the same channel. The detail pane shows a record's
notes back, because a note is otherwise write-only: the flash confirming it is
gone in four seconds, and a reviewer who cannot see what they already asked for
writes it twice.

That bus turned out not to be trustworthy enough to carry the only copy of
anything. appendJsonl swallowed every write error, so an unwritable state dir
looked exactly like a quiet queue; it now records the failure, warns once from
the CLI, and the TUI says the note was not saved rather than claiming it landed.
Three paths also changed the review list while emitting nothing: e hands the
record to $EDITOR, recover restores one from the journal, and rm and prune take
one out. Each emits now, so the bus is a complete account of what happened.

A rejection archives itself the moment it is made, the way apply retires a record
it has already written to GitHub. Nothing downstream acts on one, so leaving it
in the list only made the reviewer scroll past their own decisions.

That last change is what retires watch-triage.sh. It reported by diffing
directory snapshots, which can only see records still present at the end of a
tick, so anything archived produced no line at all: approved to applied to
archived passed in silence, and its applied case was dead code. triage monitor
reads the bus instead, where the transition is recorded before the file moves. It
seeds the pending count once from disk, since absolute state is the one question
events cannot answer alone, and keeps it from the bus after that.
Five files under .claude were failing the format check before any of this
branch's work, so prepush was already red for reasons nobody was going to connect
to the change in front of them.

Formatting only, no content changes. oxfmt reports the whole changeset clean.

tools/triage is not included because neither formatter covers it. It is an
executable Node script with a shebang and no extension, so oxfmt matches no
target file for it and nx format:check skips it for the same reason. Worth
knowing before reaching for prettier directly to fill that gap: prettier resolves
neither a config file nor the .editorconfig section keyed to
[*.{kts,kt,js,ts,jsx,tsx}], falls back to its own defaults, and rewrites every
string literal in the file from single to double quotes.
…he fixups off

The pane said "stale" for any head that moved, which was wrong twice over. An
empty [Self-Healing CI Rerun] commit changed nothing and still read as stale, and
a rebase that preserved the PR's change read the same as one that rewrote it.
Flagging both trains you to ignore the flag.

Comparing shas only says the head moved, and comparing trees cannot answer the
rebase case, because a rebase changes the tree even when the PR's own change is
identical. Comparing the patch-id of the PR's diff against its merge base does.
The +/- count is restricted to the files the PR touches at either point; a
whole-tree diff across a rebase reports the upstream churn the branch landed on,
which on #36912 was 1354 insertions of other people's work against 417 of the
author's. All of it is local git, so it costs no gh approval.

The detail pane also lost the last two lines of every long record, because a
scrollbox's height counts its borders and it was being handed two more rows than
it could draw. It now has an explicit height, renders the whole record rather
than the current attempt's section, and scrolls from the keyboard.

`c` copies a handoff prompt carrying the branch, the draft's path and the drift,
so a fixup session starts from the record instead of rediscovering it.
Both files drifted again after the earlier formatting pass, so prepush would have
gone red on work unrelated to whatever change was in front of the person pushing.

Formatting only. oxfmt reports the whole of .claude clean afterwards, and
review.spec.ts still passes.
The driver needs herdr to create tabs for its children. It does not need one for
itself, and giving it one spends a tab that someone then has to close by hand.
Backgrounding it with its output on disk costs nothing and lets the harness
report the exit.

Also records two things the skill was silent on: drafts are skipped unless
--include-drafts, and --dry-run belongs in the foreground.
The TUI runs under Bun because opentui reaches the terminal through Bun's FFI,
and nothing in the skills said so. `triage review` already checks for bun and
prints the install line, so the tool was the only place that knew.

The workspace comment still claimed the package existed to keep ink out of the
root dependency tree. Ink was replaced by opentui several commits ago, so the
comment named a dependency that is no longer there.
Adding a workspace package re-resolves every dependency name it shares with the
rest of the repo, and this one shared two.

`@opentui/react: ^0.5.9` floated to 0.5.11 while `@opentui/core` was pinned to
0.5.9, so the lock carried two copies of a package that ships a prebuilt binary
for seven platforms. react@0.5.9 depends on core@0.5.9 exactly, so pinning both
leaves one.

`ws` was declared only to satisfy an @opentui/react peer that nothing in the TUI
imports, and every version of it perturbed somebody else: `^8.18.0` pulled five
consumers up to 8.21.1, `8.21.1` pulled eight, `8.19.0` pulled four down.
Dropping it lets pnpm satisfy the peer from the tree that is already there.
`pnpm install --lockfile-only` now adds 175 lines and deletes none. Verified
against a control run with the workspace entry removed, which produces no lock
churn at all, and by rendering the real TUI under a pty against the trimmed
dependency set.
@AgentEnder
AgentEnder requested a review from a team as a code owner September 17, 2026 02:04
@AgentEnder
AgentEnder requested a review from lourw September 17, 2026 02:04
@netlify

netlify Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit ea260f0
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6aab4ac039e05900085a1367
😎 Deploy Preview https://deploy-preview-37082--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit ea260f0
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6aab4ac09c59d000085b3c4f
😎 Deploy Preview https://deploy-preview-37082--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud

nx-cloud Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit ea260f0

Command Status Duration Result
nx affected --targets=lint,oxlint,test,build,e2... ✅ Succeeded 40m 4s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 3s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 33s View ↗
nx build workspace-plugin ✅ Succeeded 2m 47s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 16s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 3s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-17 02:52:14 UTC

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​react@​19.3.01001008497100
Addednpm/​@​opentui/​core@​0.5.9951009398100
Addednpm/​@​opentui/​react@​0.5.91001009998100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant