This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 0214936d feat(pr-management-triage): fold violation feedback into the
PR body to denoise notifications (#491)
0214936d is described below
commit 0214936d44153a109ccbd411b1641d0fe5dd581f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 11 10:19:47 2026 +0200
feat(pr-management-triage): fold violation feedback into the PR body to
denoise notifications (#491)
* feat(pr-management-triage): fold violation feedback into the PR body to
denoise notifications
Maintainers reported ([email protected], "[DISCUSS] What do we do
with unreviewed PRs", 2026-06-10) that triage comments flood their
mailboxes and bury the real human comments on PRs they track. A PR
comment notifies every subscriber; editing the PR description does not.
By default (`triage_feedback_channel: pr-body`) the deterministic
quality-violation feedback for the `draft`, `comment`, and `close`
actions is now folded into the PR description as an idempotent,
marker-delimited `pr-triage-fold` block carrying `triaged=`/`head=`
metadata, instead of being posted as a comment. The block carries no
`@`-mention so the edit is fully silent. Re-triage and the
`pr-management-stats` "is triaged" detection read the block from the PR
body, so folded PRs are not re-flagged and are still counted as triaged.
Pings, author-confirmation requests, security-language, suspicious-changes
and stale-sweep notices are unchanged — their purpose is to notify a
human. The behaviour is a project-config switch defaulting to the silent
channel; `comment` preserves the prior notifying behaviour.
Generated-by: Claude Code (Opus 4.8 1M context)
* test(pr-management-triage): add body-fold already-triaged eval cases
Regression guard for the denoise change. Extends the decision-table eval
spec with the already-triaged rows (3/4) and the viewer_triage_fold_present
glossary, plus two fixtures:
- case-17-fold-already-triaged: a PR carrying a pr-triage-fold block whose
head matches the current head and was triaged < 7 days ago with no author
activity since classifies as already_triaged -> skip (a folded PR must not
be re-flagged every sweep).
- case-18-fold-stale-after-push: a fold whose head= no longer matches the
current head (author pushed since) is stale, so the PR is re-classified on
current state (CONFLICTING -> draft).
Full decision-table suite (18 cases) passes via `skill-eval --cli "claude
-p"`.
Generated-by: Claude Code (Opus 4.8 1M context)
---
projects/_template/pr-management-config.md | 1 +
.../pr-management-triage-comment-templates.md | 20 +-
skills/pr-management-stats/classify.md | 26 ++-
skills/pr-management-stats/fetch.md | 6 +-
skills/pr-management-triage/SKILL.md | 41 +++-
skills/pr-management-triage/actions.md | 243 +++++++++++++++------
skills/pr-management-triage/classify-and-act.md | 79 +++++--
skills/pr-management-triage/comment-templates.md | 146 ++++++++++++-
skills/pr-management-triage/fetch-and-batch.md | 14 +-
skills/pr-management-triage/rationale.md | 70 ++++++
skills/pr-management-triage/stale-sweeps.md | 15 +-
.../evals/pr-management-triage/README.md | 11 +-
.../case-17-fold-already-triaged/expected.json | 5 +
.../case-17-fold-already-triaged/report.md | 21 ++
.../case-18-fold-stale-after-push/expected.json | 5 +
.../case-18-fold-stale-after-push/report.md | 20 ++
.../decision-table/fixtures/system-prompt.md | 17 ++
17 files changed, 636 insertions(+), 104 deletions(-)
diff --git a/projects/_template/pr-management-config.md
b/projects/_template/pr-management-config.md
index 3022a6fd..a6128e1a 100644
--- a/projects/_template/pr-management-config.md
+++ b/projects/_template/pr-management-config.md
@@ -78,5 +78,6 @@ default to use the standard variant.
| Key | Default | Notes |
|---|---|---|
+| `triage_feedback_channel` | `pr-body` | Where the deterministic
quality-violation feedback for the `draft`, `comment` (deterministic-flag), and
`close` actions is delivered. `pr-body` (default): the violations are **folded
into the PR description** as a managed marker block — editing a PR body does
**not** notify subscribers, so the maintainer mailbox stays quiet (the [denoise
rationale](../../skills/pr-management-triage/rationale.md#why-fold-feedback-into-the-pr-body-denoise)).
`comme [...]
| `confirmation_handback_mode` | `reviewer-ping` |
`request-author-confirmation` action's "If yes" branch. `reviewer-ping`: the
author marks threads resolved and `@`-pings the reviewer for a final look +
label. `maintainer-sweep`: the author replies with a short `yes / ready` and
the next triage sweep promotes the PR to the maintainer review queue. Pick
`maintainer-sweep` if your project runs a regular maintainer triage cadence and
prefers a lightweight contributor confirmation over a re [...]
| `session_history_gist` | `enabled` | [Step
6b](../../skills/pr-management-triage/SKILL.md#step-6b--propose-session-history-gist-update)
— propose appending each session to a private GitHub gist on the maintainer's
account. Set to `disabled` to skip Step 6b unconditionally for this project
(overrides the per-invocation `no-history` flag). The local state file at
`.apache-magpie.session-state.json` is read regardless so an existing gist
remains discoverable. See [`session-history.md`](.. [...]
diff --git a/projects/_template/pr-management-triage-comment-templates.md
b/projects/_template/pr-management-triage-comment-templates.md
index 7aa0636e..0ac3ff95 100644
--- a/projects/_template/pr-management-triage-comment-templates.md
+++ b/projects/_template/pr-management-triage-comment-templates.md
@@ -63,13 +63,24 @@ to those categories).
The framework uses a literal string to detect already-triaged
PRs (searches the PR body and comments for it). **Do not
paraphrase**: the same exact string must appear verbatim in
-every triage comment the skill posts, and the
+every triage feedback body the skill writes, and the
`pr-management-stats` skill uses the same marker for
"is this PR triaged" detection.
+Under the default
+[`triage_feedback_channel: pr-body`](pr-management-config.md),
+the violations feedback for `draft` / `comment` / `close` is
+**folded into the PR description** (a `pr-triage-fold` managed
+block) rather than posted as a comment — so the marker appears in
+the PR body. The block still carries the
+`Pull Request quality criteria` link text, so the same marker
+detection works in both channels. See
+[`comment-templates.md#body-fold-rendering`](../../skills/pr-management-triage/comment-templates.md#body-fold-rendering).
+
| Concept | Value |
|---|---|
| Triage-marker visible link text | `Pull Request quality criteria` |
+| Body-fold block marker tokens (framework-fixed) | `pr-triage-fold` /
`/pr-triage-fold` |
## AI-attribution footer
@@ -89,6 +100,13 @@ expanded from the [Project-specific
URLs](#project-specific-urls)
table (`<project_display_name>` and
`<two_stage_triage_rationale_url>` respectively).
+When a body is folded into the PR description instead of posted
+as a comment (the default `pr-body` channel), the framework uses
+the parallel `<ai_attribution_footer_body>` variant — same
+`<PROJECT>` / `<two_stage_triage_rationale_url>` substitutions,
+worded for a description edit. See
+[`comment-templates.md#body-fold-rendering`](../../skills/pr-management-triage/comment-templates.md#body-fold-rendering).
+
## Template body overrides
Leave this section empty unless your project needs a body
diff --git a/skills/pr-management-stats/classify.md
b/skills/pr-management-stats/classify.md
index ed8d5b15..2c4fc217 100644
--- a/skills/pr-management-stats/classify.md
+++ b/skills/pr-management-stats/classify.md
@@ -26,6 +26,7 @@ is_triaged(pr) :=
AND c.body CONTAINS "Pull Request quality criteria"
AND (c.createdAt > head_commit.committedDate
OR head_commit.committedDate > c.createdAt) # see [Triage
marker](#triage-marker)
+ OR pr.body CONTAINS "pr-triage-fold" # body-fold channel
(default) — see [Triage marker](#triage-marker)
```
**What the literal marker is:** the **substring `Pull Request quality
@@ -34,8 +35,13 @@ template that every `pr-management-triage` action body
carries (see
[`pr-management-triage/comment-templates.md`](../pr-management-triage/comment-templates.md)).
The classifier scans every comment's `body` (NOT `bodyText` — the latter
strips HTML comments, see [Both marker forms count](#both-marker-forms-count)
-below) for the exact substring. The string is also accepted in an HTML-comment
-form left by the legacy `breeze pr auto-triage` command.
+below) for the exact substring, **and the PR's own `body`** — under the
+default `triage_feedback_channel: pr-body` the `pr-management-triage` skill
+folds the same marker into the PR description as a `pr-triage-fold` block
+instead of posting a comment (the denoise change; see
+[`pr-management-triage/rationale.md`](../pr-management-triage/rationale.md#why-fold-feedback-into-the-pr-body-denoise)).
+The string is also accepted in an HTML-comment form left by the legacy
+`breeze pr auto-triage` command.
The marker is a **single point of failure** — rename the link text in the
comment template and this detector silently stops counting. Adopters can
@@ -76,6 +82,7 @@ is_ai_triaged(pr) :=
EXISTS comment c IN pr.comments
WHERE c.authorAssociation IN (OWNER, MEMBER, COLLABORATOR)
AND c.body CONTAINS "AI-assisted triage tool"
+ OR pr.body CONTAINS "AI-assisted triage tool" # body-fold footer
(default channel)
```
A PR received at least one maintainer comment whose body contains the
@@ -164,22 +171,27 @@ de-facto-triaged until they pick up the marker or the
ready label.
## Triage marker
-A PR is *triaged* when it has at least one comment that:
+A PR is *triaged* when **either** of the following holds:
+
+**(a) Comment channel** — it has at least one comment that:
- is authored by `OWNER` / `MEMBER` / `COLLABORATOR` (`authorAssociation`)
- contains the literal string `Pull Request quality criteria` in the comment's
**raw `body`** (NOT `bodyText` — see below)
- has `createdAt` **after** the PR's last commit's `committedDate` **at the
time the comment was posted** (otherwise the triage pre-dates the current code
and is stale). **Exception:** if the PR author subsequently pushes a commit
*after* the triage comment (`last_commit.committedDate` >
`triage_comment.createdAt`), do **not** treat the marker as stale — that commit
is evidence the author responded to triage feedback. Classify as
`triaged_responded` (see [Triaged sub-states](#triaged-sub [...]
+**(b) Body-fold channel** (default `triage_feedback_channel: pr-body`) — the
PR's **raw `body`** contains a `pr-triage-fold` managed block. The block's
opening marker carries `triaged=<ISO>` and `head=<sha7>`; use `triaged=` as the
triage timestamp and `head=` matching the current head SHA as the "after last
commit" test (when `head=` no longer matches, the author pushed since the fold
— classify as `triaged_responded`, same as the comment-channel exception). This
is the form the live `p [...]
+
### Both marker forms count
Two flavours of the marker circulate in `<upstream>` and both must be detected:
-| Source | Form of marker in the comment body | Where it appears |
+| Source | Form of marker | Where it appears |
|---|---|---|
-| `pr-management-triage` skill / removed `breeze pr auto-triage` — violations
path | `[Pull Request quality criteria](https://github.com/…)` visible link |
violations-style draft / comment / close bodies |
-| Removed `breeze pr auto-triage` — staleness path (legacy comments only) |
`<!-- Pull Request quality criteria -->` **HTML comment** appended to the body
| staleness-close / stale-workflow / inactive-open comments posted before the
command was removed |
+| `pr-management-triage` skill (`triage_feedback_channel: pr-body`, the
default) — violations path | `pr-triage-fold` HTML-comment block **in the PR
`body`**, containing the `[Pull Request quality criteria](…)` link | folded
into the PR description for draft / comment / close |
+| `pr-management-triage` skill (`triage_feedback_channel: comment`) / removed
`breeze pr auto-triage` — violations path | `[Pull Request quality
criteria](https://github.com/…)` visible link **in a comment** |
violations-style draft / comment / close comment bodies |
+| Removed `breeze pr auto-triage` — staleness path (legacy comments only) |
`<!-- Pull Request quality criteria -->` **HTML comment** appended to a comment
body | staleness-close / stale-workflow / inactive-open comments posted before
the command was removed |
-The HTML-comment form is invisible in the GraphQL `bodyText` field (bodyText
strips HTML comments). Fetching `body` preserves it, and a single substring
match for `Pull Request quality criteria` catches both the visible link and the
hidden HTML marker. The `pr-management-triage` skill currently only emits the
visible-link form, but the HTML-comment form remains on PRs that were triaged
before the breeze command was removed, so the detector must continue to handle
both.
+The HTML-comment form is invisible in the GraphQL `bodyText` field (bodyText
strips HTML comments). Fetching `body` (both the comment bodies and the
PR-level body) preserves it. A substring match for `Pull Request quality
criteria` catches the visible link in either location; the `pr-triage-fold`
block additionally carries the literal token `pr-triage-fold`, which is what
`is_triaged`'s body clause matches. The detector must handle all three forms:
the default body-fold block, the commen [...]
This is why [`fetch.md`](fetch.md) specifies `body` (not `bodyText`) in the
comments subfield. A previous iteration of the skill used `bodyText` and missed
~10% of triaged PRs on `<upstream>` — specifically, the ones that had only the
staleness-style legacy auto-triage comment.
diff --git a/skills/pr-management-stats/fetch.md
b/skills/pr-management-stats/fetch.md
index 46bf19b5..2ecee9e1 100644
--- a/skills/pr-management-stats/fetch.md
+++ b/skills/pr-management-stats/fetch.md
@@ -28,6 +28,7 @@ query(
url
createdAt
isDraft
+ body # raw markdown — carries the pr-triage-fold
block (body-fold triage channel)
author { login }
authorAssociation
labels(first: 30) { nodes { name } }
@@ -133,6 +134,7 @@ query(
state
merged
isDraft
+ body # raw markdown — carries the pr-triage-fold
block (body-fold triage channel)
author { login }
authorAssociation
labels(first: 30) { nodes { name } }
@@ -177,6 +179,8 @@ In this case the visible body contains no "Pull Request
quality criteria" text a
Raw bodies are slightly noisier (Markdown formatting characters) but the
marker string is distinctive enough that false positives are not a concern on
`<upstream>`.
+**The marker can now also live in the PR's own `body`, not just a comment.**
Under the default `triage_feedback_channel: pr-body`, the
`pr-management-triage` skill folds violations feedback into the PR description
(a `pr-triage-fold` block) rather than posting a comment — the denoise change
(see
[`pr-management-triage/rationale.md`](../pr-management-triage/rationale.md#why-fold-feedback-into-the-pr-body-denoise)).
The folded block still contains the `Pull Request quality criteria` link,
[...]
+
### Known limitation
Two GitHub-search behaviours conspire to make Table 1 hard to get right:
@@ -218,7 +222,7 @@ query {
}
```
-For each returned PR, apply the same marker check as
[`classify.md`](classify.md) (`Pull Request quality criteria` substring in raw
`body`, author in `OWNER/MEMBER/COLLABORATOR`). Record `responded_before_close`
when the author has a comment after the triage marker and on or before
`closedAt`.
+For each returned PR, apply the same marker check as
[`classify.md`](classify.md) — the `Pull Request quality criteria` substring in
a comment's raw `body` (author in `OWNER/MEMBER/COLLABORATOR`), **or** a
`pr-triage-fold` block in the PR's own `body` (the default body-fold channel).
Record `responded_before_close` when the author has a comment after the triage
marker and on or before `closedAt`.
Empirical delta on `<upstream>`, cutoff 2026-03-11:
diff --git a/skills/pr-management-triage/SKILL.md
b/skills/pr-management-triage/SKILL.md
index d4c32e88..a8be46a0 100644
--- a/skills/pr-management-triage/SKILL.md
+++ b/skills/pr-management-triage/SKILL.md
@@ -275,8 +275,12 @@ intentional exception: `suspicious-changes`) ends with the
conversation with the contributor.
Do not paraphrase the footer, do not omit it from templates
-that carry it, and do not let per-PR edits drop it. See
-[`comment-templates.md#ai-attribution-footer`](comment-templates.md).
+that carry it, and do not let per-PR edits drop it. When a body
+is folded into the PR description instead of posted as a comment
+(Golden rule 11), use the parallel `<ai_attribution_footer_body>`
+variant — same calibration, worded for a description edit. See
+[`comment-templates.md#ai-attribution-footer`](comment-templates.md)
+and
[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering).
**Golden rule 9 — never talk over an active maintainer
conversation.** When a maintainer has commented on the PR
@@ -312,7 +316,8 @@ comment bodies posted on the contributor's PR, `[A]ll` /
`[E]ach`
prompt previews, the Step 6 session summary — the reference must
be one click away in whatever surface it lands on:
-- **On markdown surfaces** (the violations comment, the stale-draft
+- **On markdown surfaces** (the violations feedback — whether
+ posted as a comment or folded into the PR body, the stale-draft
comment, the workflow-approval reply, any draft text the skill
posts to `<upstream>`): use the markdown link form per
[`AGENTS.md` § *Linking tracker issues and
PRs*](../../AGENTS.md#linking-tracker-issues-and-prs):
@@ -342,6 +347,36 @@ emitting any user-visible screen**: grep the body for bare
`#\d+`
/ `<upstream>#\d+` tokens that aren't already inside a markdown
link or an OSC 8 wrapper, and convert any match.
+**Golden rule 11 — deliver violation feedback through the
+configured channel, and default to the silent one.** The
+deterministic quality-violation feedback for `draft`, `comment`
+(deterministic-flag), and `close` is delivered per
+[`<project-config>/pr-management-config.md →
triage_feedback_channel`](../../projects/_template/pr-management-config.md),
+which defaults to **`pr-body`**: the feedback is *folded into the
+PR description* as a managed marker block instead of posted as a
+comment. Editing a PR body does not notify subscribers, so the
+default keeps maintainer mailboxes quiet — the denoise change from
+the dev@ thread with Elad (see
+[`rationale.md#why-fold-feedback-into-the-pr-body-denoise`](rationale.md#why-fold-feedback-into-the-pr-body-denoise)).
+Two consequences the implementation MUST honour:
+
+- **The folded block carries no `@`-mention.** A body edit that
+ introduces a fresh `@`-mention can itself notify; reference the
+ author as a backtick-quoted login instead. The no-`@`-mention
+ rule is what makes the fold silent.
+- **Pings still notify.** `review-nudge`, `reviewer-ping`,
+ `request-author-confirmation`, `security-language`,
+ `suspicious-changes`, and stale-sweep notices always post a
+ comment regardless of the setting — their purpose is to reach a
+ human. Only the three violation-feedback actions honour the
+ channel switch.
+
+The maintainer-facing proposal MUST state which channel a given
+action will use, so the maintainer knows whether a notification
+will fire. See
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering)
+and [`actions.md`](actions.md).
+
---
## Inputs
diff --git a/skills/pr-management-triage/actions.md
b/skills/pr-management-triage/actions.md
index 3a75195e..e74f45b4 100644
--- a/skills/pr-management-triage/actions.md
+++ b/skills/pr-management-triage/actions.md
@@ -10,9 +10,13 @@ action in this file assumes:
- the PR's `head_sha` has been re-checked against the value
captured in Step 1 and matches (optimistic lock — see
[`interaction-loop.md#optimistic-lock`](interaction-loop.md)),
-- the action's comment (if any) has been previewed to the
- maintainer from the appropriate template in
- [`comment-templates.md`](comment-templates.md).
+- the action's feedback body (if any) — whether it will be
+ posted as a comment or folded into the PR description per
+ [`triage_feedback_channel`](../../projects/_template/pr-management-config.md)
+ — has been previewed to the maintainer from the appropriate
+ template in [`comment-templates.md`](comment-templates.md). The
+ preview MUST state which channel will be used so the maintainer
+ knows whether a notification will fire.
All mutations go through **`gh`**, never through raw `curl` /
`requests`. `gh` carries the maintainer's authenticated token
@@ -20,30 +24,85 @@ and retries transient failures correctly.
---
-## `draft` — convert to draft and post violations comment
+## `draft` — convert to draft and fold violations into the PR body
-Two mutations, **sequence matters** — convert first, then post
-the comment. Posting the comment before converting leaves the
-comment on a non-draft PR if the conversion fails.
+Two mutations, **sequence matters** — convert first, then deliver
+the violations feedback. Delivering the feedback before converting
+risks a "converted to draft" note on a still-open PR if the
+conversion fails.
```bash
# 1. Convert to draft (`gh pr ready <N> --undo` is the CLI
# equivalent of the GraphQL `convertPullRequestToDraft` mutation).
gh pr ready <N> --repo <repo> --undo
-# 2. Post the violations comment
+# 2. Deliver the violations feedback — fold into the PR body (default)
+# or post a comment, per triage_feedback_channel (see below).
+```
+
+On the `gh pr ready --undo` failing: surface the error, **do
+not** deliver the feedback. A "converted to draft" note on a
+still-open PR is a worse state than no note at all.
+
+### Delivering the feedback — `triage_feedback_channel`
+
+The body is built from the `draft` template in
+[`comment-templates.md#draft-comment`](comment-templates.md#draft-comment).
+**Which channel carries it is read from
+[`<project-config>/pr-management-config.md →
triage_feedback_channel`](../../projects/_template/pr-management-config.md)**
+(default `pr-body`).
+
+**`pr-body` (default) — fold into the PR description, no
+notification.** Render the body wrapped per
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering)
+(no `@`-mention; `<ai_attribution_footer_body>`; opening marker
+carrying `triaged=<ISO-UTC> head=<sha7> action=draft`) into
+`/tmp/pr-<N>-foldblock.md`, then read-modify-write the body:
+
+```bash
+# Read the current body.
+gh pr view <N> --repo <repo> --json body --jq '.body' > /tmp/pr-<N>-curbody.md
+
+# Strip any existing managed span (idempotent — keeps exactly one block).
+awk 'BEGIN{skip=0}
+ /<!-- pr-triage-fold:/{skip=1}
+ skip==0{print}
+ /<!-- \/pr-triage-fold -->/{skip=0}' \
+ /tmp/pr-<N>-curbody.md > /tmp/pr-<N>-stripped.md
+
+# Append the freshly rendered block (a blank line separates it from
+# the author's body) and write back. A body edit does NOT notify.
+{ cat /tmp/pr-<N>-stripped.md; printf '\n'; cat /tmp/pr-<N>-foldblock.md; } >
/tmp/pr-<N>-newbody.md
+gh pr edit <N> --repo <repo> --body-file /tmp/pr-<N>-newbody.md
+rm -f /tmp/pr-<N>-curbody.md /tmp/pr-<N>-stripped.md /tmp/pr-<N>-newbody.md
/tmp/pr-<N>-foldblock.md
+```
+
+The `awk` strip is the contract — any text tool that removes the
+`<!-- pr-triage-fold: … -->` … `<!-- /pr-triage-fold -->` span
+inclusively is equivalent. `gh pr edit --body` replaces the whole
+body, which is why we read → splice → write rather than append
+blindly.
+
+**`comment` — legacy behaviour, posts a comment (notifies).**
+
+```bash
+# Post the violations comment (built from the draft template, @-mention
intact).
gh pr comment <N> --repo <repo> --body-file /tmp/pr-<N>-draft-body.md
```
-Build `/tmp/pr-<N>-draft-body.md` from the `draft` template in
-[`comment-templates.md`](comment-templates.md#draft-comment).
-Write the file, `gh pr comment --body-file`, then delete the
-temp file in the same turn. Body-file mode avoids shell-escape
-issues for long markdown bodies.
+Build `/tmp/pr-<N>-draft-body.md` from the `draft` template, write
+the file, `gh pr comment --body-file`, then delete the temp file
+in the same turn. Body-file mode avoids shell-escape issues for
+long markdown bodies.
-On the `gh pr ready --undo` failing: surface the error, **do
-not** post the comment. A comment that says "converted to draft"
-on a still-open PR is a worse state than no comment at all.
+On the body edit (or comment) failing after a successful draft
+conversion: surface the error and leave the PR as a draft — the
+draft flip is still a maintainer-visible improvement; the next
+sweep will re-deliver the feedback. Do not roll back the draft.
+
+The sub-cases below (`ready for maintainer review` label, already
+a draft, collaborator-authored) apply to **both** channels —
+"deliver the feedback" means *fold or comment per the setting*.
### If the PR carries `ready for maintainer review`
@@ -70,13 +129,13 @@ gh pr edit <N> --repo <repo> --remove-label "ready for
maintainer review"
# 1. Convert to draft
gh pr ready <N> --repo <repo> --undo
-# 2. Post the violations comment
-gh pr comment <N> --repo <repo> --body-file /tmp/pr-<N>-draft-body.md
+# 2. Deliver the violations feedback (fold into body, or comment —
+# per triage_feedback_channel; see "Delivering the feedback" above).
```
If step 0 fails with anything other than the benign "label not
applied" / "label not found" response, surface the error and
-proceed to the draft + comment anyway — the label-removal
+proceed to the draft + feedback anyway — the label-removal
failure is a soft signal (the maintainer may need to clean up
manually), but stranding the PR in a half-state would be
worse. The maintainer-facing preview should note when step 0
@@ -85,80 +144,94 @@ will run so the proposal is honest about both state
changes.
**Case B — merit discussion present** (per the exception in
[`strip-ready-on-downgrade`](classify-and-act.md#hard-rules-cross-cutting-the-table)).
Skip step 0 (label stays) and step 1 (PR stays out of draft).
-Post only the violations comment:
+Deliver only the violations feedback (fold into the body under
+`pr-body`, or post the comment under `comment`):
```bash
-# 1. Post the violations comment (label stays; PR stays open).
-gh pr comment <N> --repo <repo> --body-file /tmp/pr-<N>-draft-body.md
+# Deliver the violations feedback only (label stays; PR stays open).
+# pr-body: read-modify-write the body block; comment: gh pr comment.
```
The maintainer-facing preview MUST surface that the merit
discussion was detected and that the action is being
-de-escalated from `draft` to `comment-only` for this reason
+de-escalated from `draft` to feedback-only for this reason
— include the URLs of the maintainer-opened unresolved review
thread(s) that triggered the exception so the maintainer can
-sanity-check the call. The violations comment body is
-unchanged from Case A; it informs the author that mechanical
-issues remain even though the discussion is what's keeping
-the label on.
+sanity-check the call. The feedback body is unchanged from Case
+A; it informs the author that mechanical issues remain even
+though the discussion is what's keeping the label on.
### If the PR is already a draft
-Skip the `gh pr ready --undo` step. Post only the comment. The
-decision table in [`classify-and-act.md`](classify-and-act.md)
-should have chosen `comment` instead in this case, but
-double-check here as a guard. The label-removal step (when
-applicable) still runs first.
+Skip the `gh pr ready --undo` step. Deliver only the feedback
+(fold or comment per the channel). The decision table in
+[`classify-and-act.md`](classify-and-act.md) should have chosen
+`comment` instead in this case, but double-check here as a
+guard. The label-removal step (when applicable) still runs first.
### Collaborator-authored PRs
Do not draft a collaborator's PR. If somehow the action landed
-as `draft` for a collaborator, fall back to `comment` with the
-same body — no draft flip. The label-removal step (when
-applicable) still runs.
+as `draft` for a collaborator, fall back to delivering the
+feedback only (no draft flip) — folded into the body under
+`pr-body`, or posted as a comment under `comment`. The
+label-removal step (when applicable) still runs.
---
-## `comment` — post violations / stale-review / ping comment
+## `comment` — deliver violations / stale-review / ping feedback
-A single mutation. The template depends on the upstream
+A single mutation. The template — and **whether it folds into the
+PR body or posts a comment** — depends on the upstream
classification:
-| Upstream | Body source |
-|---|---|
-| `deterministic_flag` with action `comment` |
[`comment-templates.md#comment-only`](comment-templates.md) |
-| `stale_review` with action `ping` |
[`comment-templates.md#review-nudge`](comment-templates.md) |
-| `deterministic_flag` (explicit ping action) |
[`comment-templates.md#reviewer-ping`](comment-templates.md) |
+| Upstream | Body source | Channel |
+|---|---|---|
+| `deterministic_flag` with action `comment` |
[`comment-templates.md#comment-only`](comment-templates.md) |
**`triage_feedback_channel`** (fold under `pr-body`, comment under `comment`) |
+| `stale_review` with action `ping` |
[`comment-templates.md#review-nudge`](comment-templates.md) | **always
comment** (the purpose is to notify) |
+| `deterministic_flag` (explicit ping action) |
[`comment-templates.md#reviewer-ping`](comment-templates.md) | **always
comment** (the purpose is to notify) |
+
+**Only the `deterministic_flag` → `comment` (violations) body
+honours `triage_feedback_channel`.** Under the default `pr-body`
+it is folded into the PR description using the read-modify-write
+recipe in
+[`#draft`](#draft--convert-to-draft-and-fold-violations-into-the-pr-body)
+(opening marker `action=comment`); under `comment` it is posted
+as a PR comment. The two `ping` bodies always post a comment —
+folding a ping into the body would defeat its only purpose.
```bash
+# ping bodies (and the violations body under triage_feedback_channel: comment):
gh pr comment <N> --repo <repo> --body-file /tmp/pr-<N>-comment.md
```
For a `ping` action, `@`-mention every stale reviewer plus the
PR author in the body — do not let the ping go without naming
-the people it's for.
+the people it's for. (The fold path, by contrast, carries no
+`@`-mention — that distinction is intentional: pings notify,
+folds don't.)
### If the PR carries `ready for maintainer review` (deterministic_flag only)
When the upstream classification is `deterministic_flag` and the
PR carries the label (regression bypass of F4 — see
[`strip-ready-on-downgrade`](classify-and-act.md#hard-rules-cross-cutting-the-table)),
-strip the label **before** posting the comment — **unless**
+strip the label **before** delivering the feedback — **unless**
[`merit_discussion_thread_present`](classify-and-act.md#merit_discussion_thread_present)
-holds, in which case the label stays and only the comment is
-posted.
+holds, in which case the label stays and only the feedback is
+delivered.
```bash
# 0. Remove the now-stale ready-for-review label.
# SKIP this step when merit_discussion_thread_present holds.
gh pr edit <N> --repo <repo> --remove-label "ready for maintainer review"
-# 1. Post the violations comment
-gh pr comment <N> --repo <repo> --body-file /tmp/pr-<N>-comment.md
+# 1. Deliver the violations feedback (fold into body, or comment —
+# per triage_feedback_channel).
```
A 422 "label not applied" / "label not found" is benign — log
-and continue with the comment.
+and continue with the feedback.
This applies only to the `deterministic_flag` → `comment`
branch (typically the collaborator-mode fallback from `draft`,
@@ -174,11 +247,31 @@ unresolved review thread(s) that triggered the exception.
---
-## `close` — close with comment and quality-violations label
+## `close` — close with fold and quality-violations label
+
+Three mutations. Deliver the reasoning **first** (so the
+contributor sees why), then close, then label. Closing without
+explaining the reasoning is perceived as hostile — do not do it.
+
+**`pr-body` (default) — fold the reasoning into the description,
+then close.** The fold lands *before* the close so the description
+already explains the close when the close notification fires. The
+close event still notifies subscribers (inherent to closing); the
+fold only removes the separate comment notification.
+
+```bash
+# 1. Fold the close reasoning into the PR body (read-modify-write,
+# opening marker action=close — recipe under `#draft`).
+gh pr edit <N> --repo <repo> --body-file /tmp/pr-<N>-newbody.md
+
+# 2. Close the PR
+gh pr close <N> --repo <repo>
+
+# 3. Add the quality-violations label (if the label exists on the repo)
+gh pr edit <N> --repo <repo> --add-label "closed because of multiple quality
violations"
+```
-Three mutations. Comment first (so the contributor sees the
-reasoning), then close, then label. Closing without commenting
-is perceived as hostile — do not do it.
+**`comment` — legacy behaviour, comment first then close.**
```bash
# 1. Post the close comment
@@ -191,10 +284,12 @@ gh pr close <N> --repo <repo>
gh pr edit <N> --repo <repo> --add-label "closed because of multiple quality
violations"
```
-Body template: [`comment-templates.md#close`](comment-templates.md).
+Body template: [`comment-templates.md#close`](comment-templates.md);
+fold wrapping per
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering).
If the label is missing (per `prerequisites.md#3`), skip the
-label step with a one-line warning; the close + comment is
+label step with a one-line warning; the close + feedback is
still valid.
`close` is always a **per-PR** action, never batched. Even
@@ -210,10 +305,11 @@ holds, the
[`strip-ready-on-downgrade`](classify-and-act.md#hard-rules-cross-cutting-the-table)
exception applies: **skip step 2** (do not close the PR) and
**do not strip the ready-for-maintainer-review label**. Steps
-1 and 3 still run — the close comment surfaces the
-queue-pressure reasoning, and the quality-violations label
-records that the PR was flagged. The PR remains open with
-both labels, surfaced for human review.
+1 and 3 still run — the close reasoning is delivered (folded
+into the body under `pr-body`, or posted as a comment under
+`comment`) and the quality-violations label records that the PR
+was flagged. The PR remains open with both labels, surfaced for
+human review.
The maintainer-facing preview MUST surface that step 2 is
being skipped and quote the URL(s) of the maintainer-opened
@@ -666,8 +762,8 @@ The label string is read from
[`<project-config>/pr-management-config.md →
ready_for_maintainer_review_label`](../../projects/_template/pr-management-config.md);
do not hard-code it. The same `gh` recipe is used by the
"strip-on-downgrade" hook inside `draft` and `comment`
-(`actions.md` §[draft](#draft--convert-to-draft-and-post-violations-comment) /
-§[comment](#comment--post-violations--stale-review--ping-comment)),
+(`actions.md`
§[draft](#draft--convert-to-draft-and-fold-violations-into-the-pr-body) /
+§[comment](#comment--deliver-violations--stale-review--ping-feedback)),
but those flows additionally convert to draft / post a
comment. The `strip-ready-label` action is **only** the
label-removal step — no other mutation, no comment.
@@ -701,14 +797,17 @@ One step. No comment to sequence against.
## Order-of-operations recap for destructive actions
-For every action that includes a comment, post the comment
-**before** the state change that hides it:
+"Deliver feedback" below means *fold into the PR body or post a
+comment per `triage_feedback_channel`* for the three actions that
+honour it (`draft`, `comment` deterministic-flag, `close`); the
+rest always post a comment. For every action that posts a comment,
+post it **before** the state change that hides it:
| Action | Order |
|---|---|
-| `draft` | (*if F4-regression: remove ready-for-review label*) → convert to
draft → post comment |
-| `comment` | (*if F4-regression on `deterministic_flag`: remove
ready-for-review label*) → post comment |
-| `close` | post comment → close → label |
+| `draft` | (*if F4-regression: remove ready-for-review label*) → convert to
draft → deliver feedback (fold/comment) |
+| `comment` | (*if F4-regression on `deterministic_flag`: remove
ready-for-review label*) → deliver feedback (fold/comment for
deterministic-flag; comment for pings) |
+| `close` | deliver feedback (fold→close, or comment→close) → close → label |
| `flag-suspicious` | post comment → close → label *(per PR in the batch)* |
| `mark-ready` | label only |
| `request-author-confirmation` | post comment only (no label) |
@@ -719,10 +818,14 @@ For every action that includes a comment, post the comment
| `approve-workflow` | approve (no comment) |
The `draft` case is the exception to "comment before state
-change" because drafts still show comments fine. The `close`
-case must be comment-first because closed-PR comments are
-visible but the "PR closed" notification beats the comment
-otherwise and the contributor reads the wrong order.
+change" because drafts still show comments (and the folded body)
+fine. The `close` case sequences the feedback first because a
+closed-PR comment is visible but the "PR closed" notification
+beats it otherwise — and under `pr-body` the fold must land
+before the close so the description already explains it. Under
+`pr-body`, the `draft` / `comment` / `close` feedback is a silent
+body edit (no `@`-mention) and produces no notification at all —
+only `close`'s own close event notifies.
---
diff --git a/skills/pr-management-triage/classify-and-act.md
b/skills/pr-management-triage/classify-and-act.md
index 2c76e631..70305da6 100644
--- a/skills/pr-management-triage/classify-and-act.md
+++ b/skills/pr-management-triage/classify-and-act.md
@@ -72,12 +72,12 @@ Action verbs are defined in [`actions.md`](actions.md).
| # | Precondition (all must hold)
| Classification | Action |
Reason template |
|----|-----------------------------------------------------------------------------------------------|---------------------------|------------------------|-----------------|
-| 0 | [`first_time_stale_abandoned`](#first_time_stale_abandoned) —
first-time contributor PR with a prior viewer triage marker and no commits
since the marker, ≥ 30 days old | `first_time_stale_abandoned` | `skip` |
First-time contributor's PR was triaged ≥ 30d ago, no push since — let the
stale-sweep retire it rather than re-approving CI |
+| 0 | [`first_time_stale_abandoned`](#first_time_stale_abandoned) —
first-time contributor PR with a prior viewer triage marker (comment marker
**or** [`viewer_triage_fold_present`](#viewer_triage_fold_present)) and no
commits since the marker, ≥ 30 days old | `first_time_stale_abandoned` | `skip`
| First-time contributor's PR was triaged ≥ 30d ago, no push since — let the
stale-sweep retire it rather than re-approving CI |
| 1 | `head_sha` appears in the per-page `action_required` REST index, OR
([`first_time_no_real_ci`](#first_time_no_real_ci)) |
`pending_workflow_approval` | `approve-workflow` | First-time contributor —
review the diff and approve CI, or flag suspicious |
| 2 | [`copilot_review_stale`](#copilot_review_stale)
| `stale_copilot_review` | `draft` (include the
specific Copilot thread URL in the violation body) | Unaddressed Copilot review
≥ 7 days old — convert to draft |
-| 3 | Viewer comment containing the triage marker exists, posted after last
commit, age < 7 days, sub-state `waiting` | `already_triaged` | `skip`
| Already triaged M days ago — still waiting on author |
-| 4 | Same as #3 but sub-state `responded`
| `already_triaged` | `skip` |
Already triaged M days ago — author responded, maintainer to re-engage |
-| 5 | Viewer triage marker exists, posted after last commit, sub-state
`waiting`, age ≥ 7 days, `isDraft == true` | `stale_draft` | (defer to
[`stale-sweeps.md`](stale-sweeps.md) Sweep 1a) | Draft triaged N days ago, no
author reply |
+| 3 | Viewer triage marker present, after last commit, age < 7 days,
sub-state `waiting`. **Marker = viewer comment with the `Pull Request quality
criteria` link (comment channel) OR
[`viewer_triage_fold_present`](#viewer_triage_fold_present) with matching
`head` (pr-body channel).** | `already_triaged` | `skip`
| Already triaged M days ago — still waiting on author |
+| 4 | Same as #3 (either channel) but sub-state `responded`
| `already_triaged` | `skip` |
Already triaged M days ago — author responded, maintainer to re-engage |
+| 5 | Viewer triage marker present (either channel — see row 3), after last
commit, sub-state `waiting`, age ≥ 7 days, `isDraft == true` | `stale_draft`
| (defer to [`stale-sweeps.md`](stale-sweeps.md) Sweep 1a) | Draft triaged N
days ago, no author reply |
| 6 | `viewer == pr.author.login`
| n/a | `skip` |
You are the PR author — triage skipped |
| 7a | `now - createdAt < 30min`
| n/a | `skip` |
Too fresh — CI still warming up |
| 7b | [`security_language_signal`](#security_language_signal)
| `security_language_signal` | `comment` |
Security-language in title / body / commits — ask contributor to neutralise or
confirm CVE disclosure complete |
@@ -182,9 +182,9 @@ Action verbs are defined in [`actions.md`](actions.md).
`feedback-ready-for-maintainer-review-label`.
Implementation: see
-
[`actions.md#draft`](actions.md#draft--convert-to-draft-and-post-violations-comment),
-
[`actions.md#comment`](actions.md#comment--post-violations--stale-review--ping-comment),
- and
[`actions.md#close`](actions.md#close--close-with-comment-and-quality-violations-label).
+
[`actions.md#draft`](actions.md#draft--convert-to-draft-and-fold-violations-into-the-pr-body),
+
[`actions.md#comment`](actions.md#comment--deliver-violations--stale-review--ping-feedback),
+ and
[`actions.md#close`](actions.md#close--close-with-fold-and-quality-violations-label).
---
@@ -323,6 +323,55 @@ All of:
Heuristic, conservative on purpose. Rationale:
[`rationale.md#unresolved_threads_only_likely_addressed-heuristic-detail`](rationale.md#unresolved_threads_only_likely_addressed-heuristic-detail).
+### `viewer_triage_fold_present`
+
+True when the PR **body** contains a `pr-triage-fold` managed
+block — the body-fold feedback channel (default
+`triage_feedback_channel: pr-body`; see
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering)).
+The block is delimited by `<!-- pr-triage-fold: … -->` … `<!-- /pr-triage-fold
-->`;
+parse the opening marker's space-separated metadata:
+
+- `triaged=<ISO-8601 UTC>` — the fold timestamp. **This is the
+ fold channel's equivalent of a triage comment's `createdAt`** —
+ every age / "age < 7 days" / "age ≥ 7 days" test in rows 3–5,
+ 0, and the stale-sweeps reads it.
+- `head=<sha7>` — the PR head SHA at fold time. **`head` equal to
+ the current `commits(last:1).oid` (first 7 chars) is the fold
+ channel's equivalent of "posted after last commit"** — equal ⇒
+ the author has not pushed since the fold, so the fold is current
+ and the PR is genuinely already-triaged; not equal ⇒ the author
+ pushed after the fold, the fold is stale, and the PR must be
+ re-classified against the new state (treat
+ `viewer_triage_fold_present` as **false** for the already-triaged
+ rows in that case).
+- `action=<draft|comment|close>` — informational.
+
+**The "viewer triage marker exists, posted after last commit"
+precondition in rows 0, 3, 4, and 5 is satisfied by EITHER** a
+viewer comment containing the `Pull Request quality criteria`
+marker with `createdAt` after the head `committedDate` (the
+`comment` channel / legacy PRs) **OR** `viewer_triage_fold_present`
+with `head=` matching the current head (the `pr-body` channel).
+The downstream age and sub-state logic is identical once the
+"triaged-at" anchor is resolved (the comment's `createdAt`, or the
+fold's `triaged=`).
+
+Sub-state, fold channel: `responded` when the PR author has a
+comment (issue-level in `comments(last:10)` or a review-thread
+reply) or a commit with timestamp **after** `triaged=`; otherwise
+`waiting`. Same rule as the comment channel, just anchored on
+`triaged=` instead of the comment `createdAt`.
+
+The marker tokens `pr-triage-fold` / `/pr-triage-fold` and the
+field names `triaged` / `head` / `action` are framework-fixed and
+must match byte-for-byte what
+[`actions.md`](actions.md) and
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering)
+write — a mismatch silently breaks already-triaged detection and
+the PR gets re-flagged every sweep (the exact noise this channel
+exists to remove).
+
### `viewer_confirmation_request_present`
True when the viewer (the authenticated maintainer running the
@@ -437,12 +486,16 @@ All of:
- `authorAssociation == FIRST_TIME_CONTRIBUTOR` or
`FIRST_TIMER`.
-- A viewer triage marker exists in `comments(last:10)` (i.e.
- a comment by the viewer containing the literal string
- `Pull Request quality criteria`).
+- A viewer triage marker exists, in **either channel**: a comment
+ by the viewer in `comments(last:10)` containing the literal
+ string `Pull Request quality criteria` (comment channel), OR
+ [`viewer_triage_fold_present`](#viewer_triage_fold_present) in
+ the PR body (pr-body channel).
- The PR's `commits(last:1).committedDate` is **at or before**
- the triage marker's `createdAt` — author has not pushed
- since the maintainer's feedback.
+ the marker's anchor timestamp (the comment's `createdAt`, or
+ the fold's `triaged=`) — author has not pushed since the
+ maintainer's feedback. For the fold channel this is equivalent
+ to the fold's `head=` still matching the current head SHA.
- `<now> - committedDate >= 30 days`.
Order matters: this precondition is evaluated by **row 0**,
@@ -630,7 +683,7 @@ applies — rows do not get to reach back for more data.
| `copilot_review_stale` (row 2) |
`reviewThreads.nodes.{isResolved,comments.nodes.{author.login,createdAt,url}}`,
`comments(last:10).nodes.{author.login,createdAt}` |
| `has_deterministic_signal`, `ci_failures_only`, `unresolved_threads_only`,
`unresolved_threads_only_likely_addressed` (rows 8–17) | `mergeable`,
`statusCheckRollup.{state,contexts}`,
`reviewThreads.nodes.{isResolved,comments(first:5).nodes.{author.login,authorAssociation,createdAt}}`,
`updatedAt`,
`comments(last:10).nodes.{author.login,authorAssociation,createdAt}`,
`commits(last:1).nodes.commit.committedDate`, `author.login` |
| Row 18 (`stale_review`) |
`latestReviews.nodes.{state,author.login,submittedAt}`,
`commits(last:1).nodes.commit.committedDate`, `comments(last:10)`,
`reviewThreads.nodes.comments(first:5).nodes.{author.login,createdAt}` |
-| Rows 3–5 (`already_triaged` / `stale_draft` from triage marker) |
`comments(last:10).nodes.{author.login,bodyText,createdAt}`, viewer login,
`commits(last:1).nodes.commit.committedDate` |
+| Rows 3–5 (`already_triaged` / `stale_draft` from triage marker) |
`comments(last:10).nodes.{author.login,bodyText,createdAt}`, viewer login,
`commits(last:1).nodes.commit.{oid,committedDate}`, **`body`** (raw — for
[`viewer_triage_fold_present`](#viewer_triage_fold_present), the pr-body
channel) |
| Rows 19, 20 (`passing`) | `statusCheckRollup.state`,
`statusCheckRollup.contexts`, `mergeable`, `reviewThreads.totalCount`, `labels`
|
---
diff --git a/skills/pr-management-triage/comment-templates.md
b/skills/pr-management-triage/comment-templates.md
index 62c678e7..6c7d8b80 100644
--- a/skills/pr-management-triage/comment-templates.md
+++ b/skills/pr-management-triage/comment-templates.md
@@ -111,6 +111,12 @@ Rules for the footer:
`suspicious-changes` template, which is short, operationally
sensitive, and already directs the contributor to maintainers
on Slack — adding the footer there would dilute the signal.
+- **When a body is folded into the PR description** instead of
+ posted as a comment (the `draft` / `comment-only` / `close`
+ bodies under `triage_feedback_channel: pr-body`), use the
+ parallel `<ai_attribution_footer_body>` variant defined in
+ [Body-fold rendering](#body-fold-rendering) — same rules,
+ worded for a description edit rather than a comment.
- **Do not paraphrase it.** Post the block verbatim. If the
wording needs to change, update this section and propagate —
do not drift per-template.
@@ -127,6 +133,115 @@ Rules for the footer:
---
+## Body-fold rendering
+
+When `<project-config>/pr-management-config.md` sets
+`triage_feedback_channel: pr-body` (the default — see
+[`pr-management-config.md`](../../projects/_template/pr-management-config.md)
+"Workflow choices"), the deterministic quality-violation feedback
+for the **`draft`**, **`comment`** (deterministic-flag only), and
+**`close`** actions is **not posted as a PR comment**. Instead the
+exact same body is **folded into the PR description** as a managed,
+marker-delimited block. Editing a PR body does not notify
+subscribers, so this keeps the maintainer mailbox quiet — the
+denoise change motivated by the dev@ thread with Elad (see
+[`rationale.md#why-fold-feedback-into-the-pr-body-denoise`](rationale.md#why-fold-feedback-into-the-pr-body-denoise)).
+
+The other contributor-facing templates (`security-language`,
+`review-nudge`, `reviewer-ping`, `request-author-confirmation`,
+`suspicious-changes`, and the stale-sweep close/convert bodies)
+**always post as comments regardless of this setting** — their
+purpose is to notify a human, which a silent body edit would
+defeat.
+
+### The managed block
+
+The folded content is wrapped in a single HTML-comment-delimited
+span, appended at the **end** of the PR body (the author's own
+description is preserved above it, untouched):
+
+```markdown
+<!-- pr-triage-fold: triaged=2026-06-11T14:22:00Z head=abc1234 action=draft -->
+
+<the rendered template body — same content the comment channel would post,
+ minus the @-mention; see the no-@-mention rule below>
+
+<!-- /pr-triage-fold -->
+```
+
+- **Opening marker metadata** (one HTML comment, space-separated
+ `key=value` fields — all required):
+ - `triaged=<ISO-8601 UTC>` — the moment the block was written.
+ This replaces a comment's `createdAt` for every downstream
+ age / "posted after last commit" check (see
+
[`classify-and-act.md#viewer_triage_fold_present`](classify-and-act.md#viewer_triage_fold_present)).
+ - `head=<sha7>` — the PR head SHA at fold time. Re-triage
+ compares this against the current head: equal ⇒ the author has
+ not pushed since the fold (the fold is current); different ⇒
+ the author pushed (the fold is stale, re-classify against the
+ new state).
+ - `action=<draft|comment|close>` — which action wrote the block,
+ for human readability and stats attribution.
+- **`pr-triage-fold` and `/pr-triage-fold` are the literal,
+ framework-fixed marker tokens.** They must appear byte-for-byte
+ identical everywhere they are written or searched
+ ([`actions.md`](actions.md), this file,
+ [`classify-and-act.md`](classify-and-act.md),
+ [`stale-sweeps.md`](stale-sweeps.md), and the
+ `pr-management-stats` classifier). A single typo breaks both the
+ idempotent replace and the re-triage skip — do not paraphrase.
+- The visible content still contains the literal
+ `Pull Request quality criteria` link text, so the existing
+ already-triaged marker search (which scans the PR body as well
+ as comments) keeps working.
+
+### No `@`-mention in the folded block
+
+The block **must not** contain an `@`-mention of anyone. The
+opening `@<author>` that the comment templates use is dropped in
+fold mode; reference the author as a backtick-quoted login
+(`` `<author>` ``) instead, the same convention the
+[Reviewer-mention policy](#reviewer-mention-policy) uses for
+`<reviewer_logins>`. A body edit that introduces a fresh
+`@`-mention can generate the very notification this change exists
+to avoid, so the no-`@`-mention rule is what makes the fold truly
+silent. The author owns the PR and sees its description — they do
+not need pinging to read it.
+
+### Idempotent replace, never append
+
+The fold is a **read-modify-write**, not an append. Before writing,
+strip any existing span from the opening `<!-- pr-triage-fold: … -->`
+marker through the closing `<!-- /pr-triage-fold -->` marker
+(inclusive, plus surrounding blank lines), then append the freshly
+rendered block. This keeps exactly one fold block in the body no
+matter how many sweeps touch the PR — the violation list is always
+the current one, never a stack of stale ones. The recipe lives in
+[`actions.md#draft`](actions.md#draft--convert-to-draft-and-fold-violations-into-the-pr-body).
+
+### Body-fold attribution footer
+
+Where the comment channel ends a body with the
+[`<ai_attribution_footer>`](#ai-attribution-footer), the fold uses
+the parallel `<ai_attribution_footer_body>` variant — same
+calibration of trust, worded for a description edit rather than a
+comment:
+
+```markdown
+---
+
+_Note: This note was added to the PR description by an AI-assisted triage tool
and may contain mistakes. Once you have addressed the points above, mark the PR
"Ready for review" and a <PROJECT> maintainer — a real person — will take the
next look. We use this [two-stage triage
process](<two_stage_triage_rationale_url>) so that our maintainers' limited
time is spent where it matters most: the conversation with you._
+```
+
+(`<two_stage_triage_rationale_url>` and `<PROJECT>` resolve from
+`<project-config>/pr-management-triage-comment-templates.md`, same
+as the comment footer.) All other footer rules from the
+[AI-attribution footer](#ai-attribution-footer) section apply
+verbatim — post the block as-is, place it last, italicise it as
+one block.
+
+---
+
## Security-language comment
*(`security_language_signal` — security-disclosure warning)*
@@ -165,7 +280,7 @@ If you haven't already followed the [ASF security reporting
process](https://www
## Draft comment
-*(`draft` — convert-to-draft comment)*
+*(`draft` — convert-to-draft body)*
Used when the action is `draft` (see
[`actions.md#draft`](actions.md)).
@@ -182,6 +297,15 @@ See the linked criteria for how to fix each item, then
mark the PR "Ready for re
<ai_attribution_footer>
```
+**Channel.** Under the default `triage_feedback_channel: pr-body`
+this body is **folded into the PR description**, not posted as a
+comment — wrap it per [Body-fold rendering](#body-fold-rendering):
+drop the leading `@<author>` and open with `` `<author>` `` instead
+(no `@`-mention), and replace the trailing `<ai_attribution_footer>`
+with `<ai_attribution_footer_body>`. Under
+`triage_feedback_channel: comment` the body above is posted verbatim
+as a comment.
+
`<rebase_note_if_needed>` is present **only** when
`commits_behind > 50`:
@@ -216,6 +340,15 @@ is being drafted/closed here, so it doesn't apply). The
classification marker ("Pull Request quality criteria" link
text) is still present — re-triage logic recognises both.
+**Channel.** This is a deterministic-flag `comment` body, so it
+follows `triage_feedback_channel` exactly like `draft`: under the
+default `pr-body` it is **folded into the PR description** per
+[Body-fold rendering](#body-fold-rendering) (drop the `@`, use
+`` `<author>` ``, swap to `<ai_attribution_footer_body>`); under
+`comment` it is posted verbatim as a comment. (The `comment`
+action's other, non-deterministic-flag uses — e.g.
+`security-language` — always post as comments.)
+
---
## Close
@@ -243,6 +376,17 @@ it. If `flagged_count <= 3` (which shouldn't happen on this
template per [`classify-and-act.md`](classify-and-act.md) row 8),
render the close comment without this extra line.
+**Channel.** `close` follows `triage_feedback_channel`: under the
+default `pr-body` the body is **folded into the PR description**
+per [Body-fold rendering](#body-fold-rendering) (drop the `@`, use
+`` `<author>` ``, swap to `<ai_attribution_footer_body>`) *before*
+the PR is closed — see the action order in
+[`actions.md#close`](actions.md#close--close-with-fold-and-quality-violations-label).
+Under `comment` the body is posted as a comment as before. The
+close event itself notifies subscribers in either channel (that is
+inherent to closing); the fold only removes the *extra* comment
+notification.
+
---
## Review nudge
diff --git a/skills/pr-management-triage/fetch-and-batch.md
b/skills/pr-management-triage/fetch-and-batch.md
index 03a0627c..ac89ee3a 100644
--- a/skills/pr-management-triage/fetch-and-batch.md
+++ b/skills/pr-management-triage/fetch-and-batch.md
@@ -40,6 +40,7 @@ query(
updatedAt
id # node_id — needed for mutations
isDraft
+ body # raw markdown — carries the pr-triage-fold
block (see below)
mergeable # MERGEABLE / CONFLICTING / UNKNOWN
baseRefName
author { login }
@@ -145,9 +146,18 @@ pick action). Nothing here is speculative:
thread count + ping targets
- `latestReviews` → stale `CHANGES_REQUESTED` detection and
`has_collaborator_review` flag (extended grace period)
+- `body` → the `pr-triage-fold` managed block (the body-fold
+ feedback channel, default `triage_feedback_channel: pr-body`).
+ Read for "already triaged" detection when the feedback lives in
+ the PR description rather than a comment — the
+
[`viewer_triage_fold_present`](classify-and-act.md#viewer_triage_fold_present)
+ glossary entry parses its `triaged=` / `head=` metadata. Use the
+ **raw `body`** (not `bodyText`): `bodyText` strips HTML comments,
+ which would erase the markers.
- `comments(last: 10)` → "already triaged" detection (viewer's
- prior triage comment), "author responded after triage"
- detection, and the active-maintainer-conversation pre-filter
+ prior triage comment, the `comment` channel / legacy), "author
+ responded after triage" detection, and the
+ active-maintainer-conversation pre-filter
(recent collaborator comment + maintainer-to-maintainer
`@`-ping detection — see
[`classify-and-act.md#pre-filters`](classify-and-act.md), F5a/F5b)
diff --git a/skills/pr-management-triage/rationale.md
b/skills/pr-management-triage/rationale.md
index f0b7f43c..755a2ebe 100644
--- a/skills/pr-management-triage/rationale.md
+++ b/skills/pr-management-triage/rationale.md
@@ -573,6 +573,76 @@ draft is an overreach.
---
+## Why fold feedback into the PR body (denoise)
+
+By default (`triage_feedback_channel: pr-body`) the deterministic
+quality-violation feedback for `draft`, `comment`, and `close` is
+**folded into the PR description** as a managed marker block rather
+than posted as a PR comment. See
+[`comment-templates.md#body-fold-rendering`](comment-templates.md#body-fold-rendering)
+for the mechanism and [`actions.md`](actions.md) for the recipe.
+
+**The motivating problem.** On the [email protected] thread
+*"[DISCUSS] What do we do with unreviewed PRs"* (2026-06-10), Elad
+Kalif raised that the triage process posts so many comments that
+it floods maintainer mailboxes — and that the resulting noise
+causes maintainers to **miss the real human comments** on the PRs
+they actively track. A comment on a PR notifies every subscriber;
+across a queue of hundreds of PRs, the routine "here are your
+violations" comments drown out the conversations that need a human.
+
+**The fix.** A new PR *comment* notifies; editing the PR
+*description* does **not**. Folding the same violation text into
+the body delivers identical information to the contributor (who
+sees it on their PR) and remains fully deterministic, while
+producing zero notifications. Jarek proposed exactly this on the
+thread, noting it is the technique already used for security
+issues. The change keeps the whole triage process intact — PRs
+that need fixing are still drafted, the criteria are still
+surfaced — it only moves the delivery from a notifying channel to
+a silent one.
+
+**Why these three actions and not the pings.** `draft`,
+`comment` (deterministic-flag), and `close` carry the same
+mechanical violation feedback — high-volume, deterministic, and
+addressed to the PR author who already watches their own PR. They
+are the bulk of the noise. The ping family (`review-nudge`,
+`reviewer-ping`), `request-author-confirmation`, the
+security-language warning, suspicious-changes, and the stale-sweep
+notices are deliberately the opposite: their *entire purpose* is
+to reach a specific human's inbox. Folding those into the body
+would defeat them, so they always post a comment regardless of the
+setting.
+
+**Why no `@`-mention in the fold.** A body edit is only silent if
+it does not introduce a fresh `@`-mention — an added mention can
+itself notify. So the folded block references the author as a
+backtick-quoted login, never `@author`. The author owns the PR and
+sees its description; they do not need pinging to read it. This is
+the same reasoning behind the
+[Reviewer-mention policy](comment-templates.md#reviewer-mention-policy)
+for `<reviewer_logins>`.
+
+**Why an idempotent marker block, with metadata.** The block
+carries `triaged=` / `head=` in its opening marker so re-triage can
+tell, from the body alone, *when* the PR was last triaged and
+*whether the author has pushed since* — the exact facts a triage
+*comment* used to supply via `createdAt` and "posted after last
+commit". Without that, a folded PR would be re-flagged every sweep
+— turning a denoise change into a *re-noising* one. The block is
+rewritten in place (never appended) so the body holds exactly one
+current violation list, and the legacy comment-marker detection is
+kept so PRs triaged under the old channel still classify correctly.
+See
[`viewer_triage_fold_present`](classify-and-act.md#viewer_triage_fold_present).
+
+The behaviour is a project-config switch
+([`triage_feedback_channel`](../../projects/_template/pr-management-config.md))
+defaulting to `pr-body`; an adopter that prefers the notifying
+comment channel can set it to `comment` and get the prior
+behaviour unchanged.
+
+---
+
## Merit-discussion exception to `strip-ready-on-downgrade`
The `strip-ready-on-downgrade` hard rule
diff --git a/skills/pr-management-triage/stale-sweeps.md
b/skills/pr-management-triage/stale-sweeps.md
index f55250ec..6f489d80 100644
--- a/skills/pr-management-triage/stale-sweeps.md
+++ b/skills/pr-management-triage/stale-sweeps.md
@@ -38,9 +38,16 @@ Each stale sweep needs these timestamps per PR:
- `updated_at` — the PR's `updatedAt` field (already in the
batch query)
-- `last_triage_comment_at` — the `createdAt` of the most
- recent comment by the viewer containing the
- `Pull Request quality criteria` marker, if any
+- `last_triage_comment_at` — the most recent triage-marker
+ timestamp from **either feedback channel**, if any: the
+ `createdAt` of the most recent viewer comment containing the
+ `Pull Request quality criteria` marker (comment channel), OR
+ the `triaged=` timestamp parsed from the `pr-triage-fold` block
+ in the PR `body` (pr-body channel — the default; see
+
[`viewer_triage_fold_present`](classify-and-act.md#viewer_triage_fold_present)).
+ When both are present (a project that switched channels), take
+ the later of the two. Despite the legacy field name, this is
+ "last triaged at", not strictly a comment.
- `last_author_activity_at` — the max of three timestamps,
all already in the batch query:
1. the head commit's `committedDate` from `commits(last: 1)`
@@ -288,7 +295,7 @@ per-PR confirm.
[`stale-ready-label-close`](comment-templates.md#stale-ready-label-close)
comment template; **skip the quality-violations label step**
(close reason is bitrot, not policy violation). Otherwise
-[`actions.md#close`](actions.md#close--close-with-comment-and-quality-violations-label)
+[`actions.md#close`](actions.md#close--close-with-fold-and-quality-violations-label)
unchanged.
**Reason string.** *"Ready-for-review label stale — N days
diff --git a/tools/skill-evals/evals/pr-management-triage/README.md
b/tools/skill-evals/evals/pr-management-triage/README.md
index accece1f..355833b2 100644
--- a/tools/skill-evals/evals/pr-management-triage/README.md
+++ b/tools/skill-evals/evals/pr-management-triage/README.md
@@ -2,12 +2,19 @@
Behavioral evals for the `pr-management-triage` skill.
-## Suites (26 cases total)
+## Suites (28 cases total)
| Suite | Step | Cases | What it covers |
|---|---|---|---|
| pre-filter | Step 2 (pre-filters) | 10 | F1 (collaborator), F2 (bot), F3
(draft recent), F4 (already ready), F5a (active maintainer comment), F5b
(maintainer ping unanswered), F6 (maintainer co-drafted), row-6 (viewer is
author), row-7a (fresh PR); clean contributor continues |
-| decision-table | Step 2 (decision table) | 16 | Row 7b (security signal), 9
(conflict→draft), 10 (all systemic→rerun), 11 (partial systemic→rerun), 12
(static-only→comment), 13 (flaky ≤2→rerun), 14a (author confirmed→mark-ready),
14b (pending confirmation→skip), 14c (threads
addressed→request-author-confirmation), 15 (threads→ping), 16 (no CI→rebase),
18 (changes-requested+new-commits→ping), 19 (already ready→skip), 20
(passing→mark-ready), 21 (stale draft sweep→close), 22 (rollup anom [...]
+| decision-table | Step 2 (decision table) | 18 | Rows 3/4 (already-triaged
via body-fold block→skip), 7b (security signal), 9 (conflict→draft), 10 (all
systemic→rerun), 11 (partial systemic→rerun), 12 (static-only→comment), 13
(flaky ≤2→rerun), 14a (author confirmed→mark-ready), 14b (pending
confirmation→skip), 14c (threads addressed→request-author-confirmation), 15
(threads→ping), 16 (no CI→rebase), 18 (changes-requested+new-commits→ping), 19
(already ready→skip), 20 (passing→mark-read [...]
+
+The two body-fold cases (`case-17-fold-already-triaged`,
+`case-18-fold-stale-after-push`) are the regression guard for the denoise
+change: triage feedback folded into the PR body must be recognised as a
+triage marker so a freshly-folded PR is skipped (not re-flagged every
+sweep), while a fold whose `head=` no longer matches the current head
+(the author pushed since) is treated as stale and the PR is re-classified.
## Run
diff --git
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/expected.json
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/expected.json
new file mode 100644
index 00000000..4756676a
--- /dev/null
+++
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/expected.json
@@ -0,0 +1,5 @@
+{
+ "classification": "already_triaged",
+ "action": "skip",
+ "reason": "Row 3: the PR body carries a pr-triage-fold block whose head
(abc1234) matches the current head SHA and was triaged 2 days ago with no
author activity since — already triaged, skip rather than re-flag the folded
violation."
+}
diff --git
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/report.md
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/report.md
new file mode 100644
index 00000000..f940c353
--- /dev/null
+++
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-17-fold-already-triaged/report.md
@@ -0,0 +1,21 @@
+PR #17042
+Author: nora-contributor
+AuthorAssociation: FIRST_TIME_CONTRIBUTOR
+StatusCheckRollup: FAILURE
+FailedChecks: ["mypy-core"]
+RecentMainFailures: []
+Mergeable: MERGEABLE
+UnresolvedThreads: 0
+IsDraft: true
+CommitsBehind: 5
+RealCIRan: true
+Labels: []
+Now: 2026-05-18T10:00:00Z
+HeadSHA: abc1234
+
+PRBodyFoldBlock (pr-triage-fold; triaged=2026-05-16T09:00:00Z head=abc1234
action=draft):
+ "Converting to draft — this PR doesn't yet meet our Pull Request quality
criteria.
+ - mypy (type checking): failing mypy-core. See the linked criteria, then
mark the
+ PR Ready for review."
+
+LastAuthorActivity: 2026-05-15T08:00:00Z
diff --git
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/expected.json
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/expected.json
new file mode 100644
index 00000000..9313a9d3
--- /dev/null
+++
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/expected.json
@@ -0,0 +1,5 @@
+{
+ "classification": "deterministic_flag",
+ "action": "draft",
+ "reason": "Row 9: the pr-triage-fold block is stale — its head (abc1234) no
longer matches the current head (def5678), so the author pushed since the fold
and viewer_triage_fold_present is false; the branch is now CONFLICTING, so
re-draft."
+}
diff --git
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/report.md
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/report.md
new file mode 100644
index 00000000..4ff171e8
--- /dev/null
+++
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/case-18-fold-stale-after-push/report.md
@@ -0,0 +1,20 @@
+PR #17118
+Author: sam-contributor
+AuthorAssociation: FIRST_TIME_CONTRIBUTOR
+StatusCheckRollup: FAILURE
+FailedChecks: ["unit-tests"]
+RecentMainFailures: []
+Mergeable: CONFLICTING
+UnresolvedThreads: 0
+IsDraft: false
+CommitsBehind: 12
+RealCIRan: true
+Labels: []
+Now: 2026-05-18T10:00:00Z
+HeadSHA: def5678
+
+PRBodyFoldBlock (pr-triage-fold; triaged=2026-05-16T09:00:00Z head=abc1234
action=draft):
+ "Converting to draft — this PR doesn't yet meet our Pull Request quality
criteria.
+ - Merge conflicts: rebase onto the base branch."
+
+LastAuthorActivity: 2026-05-17T14:00:00Z
diff --git
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/system-prompt.md
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/system-prompt.md
index 915fb0a0..807c639a 100644
---
a/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/system-prompt.md
+++
b/tools/skill-evals/evals/pr-management-triage/decision-table/fixtures/system-prompt.md
@@ -34,11 +34,28 @@ and reason.
- **`follow_up_ping`** — at least one comment from
`OWNER`/`MEMBER`/`COLLABORATOR`
was posted after the CHANGES_REQUESTED review and after the author's
subsequent
commits.
+- **triage marker** — evidence the PR was already triaged by a maintainer, in
+ either feedback channel: (a) a comment by `OWNER`/`MEMBER`/`COLLABORATOR`
+ containing the literal text "Pull Request quality criteria" (the comment
+ channel), or (b) `viewer_triage_fold_present` (the default body-fold
channel).
+ Its timestamp (`triage_marker_at`) is the comment's `createdAt`, or the fold
+ block's `triaged=` value.
+- **`viewer_triage_fold_present`** — the PR body contains a `pr-triage-fold`
+ managed block (the body-fold feedback channel, default
+ `triage_feedback_channel: pr-body`). The block's opening marker carries
+ `triaged=<ISO>` (the triage timestamp) and `head=<sha7>`. The block counts as
+ an after-last-commit triage marker **only when `head=` equals the PR's
current
+ `HeadSHA`** (the author has not pushed since the fold). If `head=` no longer
+ matches the current head, treat `viewer_triage_fold_present` as **false** —
the
+ author pushed after the fold, so the fold is stale and the PR is
re-classified
+ on its current state.
## Decision table (first-match wins)
| # | Precondition | Classification | Action |
|---|---|---|---|
+| 3 | triage marker present AND after last commit AND `(now -
triage_marker_at) < 7 days` AND no author activity since the marker (sub-state
`waiting`) | `already_triaged` | `skip` |
+| 4 | triage marker present AND after last commit AND `(now -
triage_marker_at) < 7 days` AND author has commented/pushed since the marker
but `head=` still matches (sub-state `responded`) | `already_triaged` | `skip` |
| 7b | `security_language_signal` | `security_language_signal` | `comment` |
| 9 | `mergeable == CONFLICTING` | `deterministic_flag` | `draft` |
| 10 | `ci_failures_only` AND every failure ∈ `recent_main_failures` |
`deterministic_flag` | `rerun` |