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` |

Reply via email to