You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/28 10:01:42 UTC

(magpie) 02/02: perf(security): write rollups and body fields without loading them into context (#1451)

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/magpie.git

commit da660f14e69670145e6096aa27626845ae910327
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Mon Sep 28 12:01:19 2026 +0200

    perf(security): write rollups and body fields without loading them into context (#1451)
    
    The optimize-skill out-of-context pass. Skills read the whole rollup
    comment or issue body into context to append one entry or change one
    field, and wrote it back with a hand-rolled PATCH. They now call the
    vetted-ops tracker procedures, so the rollup and the body never enter
    context, and the calls work under the secure setup.
    
    - rollup-append replaces the hand-built rollup posts and PATCH upserts
      in import, import-from-md, import-from-pr, invalidate, deduplicate,
      cve-allocate, issue-fix and sync. Entry templates keep their body text
      and drop the `<details>` envelope and marker line, which the tool
      writes.
    - rollup-amend-latest fills invalidate's draft id into the entry it
      just wrote.
    - rollup-fold replaces the read / wrap / PATCH / DELETE fold-legacy
      sequences (deduplicate, cve-allocate, issue-fix, sync). The legacy
      comment is deleted only after the append succeeded.
    - body-field-get / body-field-set replace whole-body fetches and
      rewrites that touched a single field (cve-allocate, issue-fix, sync).
      Sync keeps a whole-body write only where it adds sections the body
      lacks.
    - rollup-append and rollup-amend-latest (and github-rollup append /
      amend-latest) now print the rollup comment's URL, so invalidate's
      permalink and import-from-pr's recap do not need a second lookup.
    
    Each skill notes once how to run the same operations without the
    secure setup, through the github-rollup and github-body-field CLIs. The
    vetted-ops policy example grants each security caller exactly the
    operations it uses.
    
    Generated-by: Claude Code (Opus 5.5)
---
 .../magpie-security/skills/cve-allocate/SKILL.md   | 30 ++++++-----
 .../skills/cve-allocate/tracker-updates.md         | 50 ++++++++---------
 .../skills/issue-deduplicate/SKILL.md              | 48 +++++++++++------
 .../skills/issue-deduplicate/merge-templates.md    | 18 +++----
 plugins/magpie-security/skills/issue-fix/SKILL.md  |  6 +--
 .../skills/issue-fix/pre-implementation.md         | 12 ++++-
 .../skills/issue-fix/tracker-update.md             | 35 ++++++++----
 .../skills/issue-import-from-md/SKILL.md           | 37 ++++++-------
 .../skills/issue-import-from-pr/SKILL.md           | 39 ++++++++------
 .../magpie-security/skills/issue-import/apply.md   | 39 ++++++++------
 .../skills/issue-invalidate/SKILL.md               | 18 +++----
 .../skills/issue-invalidate/apply.md               | 41 ++++++++------
 plugins/magpie-security/skills/issue-sync/SKILL.md |  4 +-
 .../skills/issue-sync/apply-and-push.md            | 62 +++++++++++++++-------
 .../skills/issue-sync/signals-to-actions.md        | 51 ++++++++----------
 tools/github-rollup/README.md                      |  2 +
 tools/github-rollup/src/github_rollup/cli.py       | 16 +++++-
 .../step-4-propose-updates/fixtures/output-spec.md |  2 +-
 .../step-5e-rollup-entry/fixtures/output-spec.md   |  2 +-
 tools/vetted-ops/README.md                         | 13 +++--
 tools/vetted-ops/src/vetted_ops/procedures.py      | 33 ++++++++----
 tools/vetted-ops/tests/test_procedures.py          | 21 ++++++++
 22 files changed, 352 insertions(+), 227 deletions(-)

diff --git a/plugins/magpie-security/skills/cve-allocate/SKILL.md b/plugins/magpie-security/skills/cve-allocate/SKILL.md
index 56bfa2f2..bf747057 100644
--- a/plugins/magpie-security/skills/cve-allocate/SKILL.md
+++ b/plugins/magpie-security/skills/cve-allocate/SKILL.md
@@ -20,7 +20,7 @@ argument-hint: "[issue-number] [CVE-YYYY-NNNNN]"
 capability: capability:resolve
 surface_hash: sha256:f2f46bc0428395a7
 license: Apache-2.0
-measured_tokens: 7005
+measured_tokens: 7222
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -284,17 +284,23 @@ no allocation at all.
 
 ```bash
 gh issue view <N> --repo <tracker> \
-  --json number,title,state,body,labels,milestone,assignees,author
+  --json number,title,state,labels,milestone,assignees,author
+uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-cve-allocate body-field-get <N> "CVE tool link"
 ```
 
+The body itself is not fetched: the one field the blocker checks need comes from `body-field-get`, which prints only that value.
+This and the Step 5 writes run through vetted-ops' `vetted-op-read` / `vetted-op-tracker` entry points, which the secure setup lets out of the sandbox (every write still asks).
+Without the secure setup, the same operations are `uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …` and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`.
+See [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
+
 Blocker checks — if any fail, stop and surface the failure:
 
 - **Issue is open.** Allocating a CVE for a closed tracker is
   almost always a mistake (the tracker may be closed as `invalid`,
   `duplicate` or already-announced). Surface as a blocker and ask
   the user what they intend.
-- **No CVE already allocated.** Extract the *CVE tool link* body
-  field; if it contains a `CVE-\d{4}-\d+` token, abort with a
+- **No CVE already allocated.** If the *CVE tool link* value
+  printed by `body-field-get` contains a `CVE-\d{4}-\d+` token, abort with a
   message pointing at the existing CVE. Also abort if the issue
   already carries the `cve allocated` label.
 - **Not marked `duplicate`.** If the `duplicate` label is set, the
@@ -439,18 +445,14 @@ Confirmation forms mirror the other skills:
 After confirmation, apply **sequentially** (not in parallel) so
 partial failures stay legible:
 
-1. The `github-body-field` `set` call from Step 4 item 1
-   (`uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> set <N> --field "CVE tool link" --value "<record-url>"`)
+1. The `body-field-set` call from Step 4 item 1
+   (`uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate body-field-set <N> "CVE tool link" <scratch>/cve-tool-link-<N>.md`)
    — patches only the *CVE tool link* field; the rest of the body is untouched.
 2. `gh issue edit <N> --repo <tracker> --add-label "cve allocated"`.
-3. Rollup-comment upsert per
-   [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md#upsert-recipe--append-to-an-existing-rollup-or-create-one)
-   — append the new `CVE allocated` entry to the tracker's
-   existing rollup (`gh api -X PATCH
-   repos/<tracker>/issues/comments/<id> --input …`), or create
-   the rollup (`gh issue comment <N> --repo <tracker>
-   --body-file <scratch>/rollup-<N>.md`) if none exists yet.
-   `<scratch>` is the session scratch directory as an absolute path (fall back to `$TMPDIR`); `gh` may run outside the sandbox, where `$TMPDIR` differs, so pass it absolute paths.
+3. The `rollup-fold` calls (one per accepted legacy comment, oldest first), then the `rollup-append` call from Step 4 item 3
+   (`uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate rollup-append <N> "CVE allocated (<CVE-YYYY-NNNNN>)" <scratch>/rollup-entry-<N>.md`)
+   — appends the entry to the tracker's rollup, creating the rollup if none exists yet.
+   `<scratch>` is the session scratch directory as an absolute path (fall back to `$TMPDIR`); the entry point runs outside the sandbox, where `$TMPDIR` differs, so pass it absolute paths.
 4. `uv run --project <framework>/<cve-tool>/generate-cve-json generate-cve-json <N> --attach`
    — embeds the CVE JSON in the body.
 5. Create draft on the original thread (reporter notification, if
diff --git a/plugins/magpie-security/skills/cve-allocate/tracker-updates.md b/plugins/magpie-security/skills/cve-allocate/tracker-updates.md
index 58a0e199..47f40898 100644
--- a/plugins/magpie-security/skills/cve-allocate/tracker-updates.md
+++ b/plugins/magpie-security/skills/cve-allocate/tracker-updates.md
@@ -11,20 +11,15 @@ user to confirm. Numbered items:
 1. **Set the *CVE tool link* body field** to
    `cve_authority.record_url_template` substituted with the
    allocated CVE ID. Patch
-   only this one field; do not touch the rest of the body. Use
-   the
-   [`github-body-field`](../../../../tools/github-body-field/README.md)
-   tool — it reads, parses, and rewrites just the targeted
-   `### CVE tool link` section without bringing the issue body
+   only this one field; do not touch the rest of the body.
+   Write `<record-url>` to `<scratch>/cve-tool-link-<N>.md` with the Write tool, then
+   `body-field-set` rewrites just the `### CVE tool link` section without bringing the issue body
    into agent context:
    ```bash
-   uv run --directory <framework>/tools/github-body-field \
-     body-field --repo <tracker> set <N> \
-       --field "CVE tool link" \
-       --value "<record-url>"
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate body-field-set <N> "CVE tool link" <scratch>/cve-tool-link-<N>.md
    ```
    Exit code `0` means written; exit `3` means the heading was
-   absent — fall through to manual edit only if that fires
+   absent (or duplicated) — fall through to manual edit only if that fires
    (legacy trackers from before the issue template existed).
 2. **Add the `cve allocated` label.** `gh issue edit <N> --repo
    <tracker> --add-label "cve allocated"`.
@@ -35,14 +30,21 @@ user to confirm. Numbered items:
    [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md);
    re-read it before composing the entry. This skill emits the
    `CVE allocated (<CVE-YYYY-NNNNN>)` action label per the summary
-   table there. If the tracker has no rollup comment yet (legacy
-   tracker that pre-dates the convention), the recipe's Step 2b
-   creates one; on the way, the skill must also fold any
-   pre-existing legacy bot comments into the new rollup per the
+   table there. Write the entry body (template below) to
+   `<scratch>/rollup-entry-<N>.md` with the Write tool, then:
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate rollup-append <N> "CVE allocated (<CVE-YYYY-NNNNN>)" <scratch>/rollup-entry-<N>.md
+   ```
+   The tool adds the `<details>` envelope and creates the rollup when the tracker has none yet
+   (legacy tracker that pre-dates the convention).
+   Before the append, also propose folding any
+   pre-existing legacy bot comments into the rollup per the
    fold-legacy sub-step described in
    [`security-issue-sync`](../issue-sync/SKILL.md) —
    security-cve-allocate is a common first write after a long pause, so
    the legacy comments are often there.
+   Each accepted fold is one call, oldest first:
+   `uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate rollup-fold <N> <comment-id> "<Action>"`.
 4. **Regenerate the CVE JSON attachment** in the tracker body by
    running the adapter's `generate-cve-json` sub-tool:
    ```bash
@@ -114,7 +116,8 @@ user to confirm. Numbered items:
    credentials-missing fallback, its `replyToMessageId` is the
    chronologically-last message ID on the inbound thread (resolve it
    via `get_thread`). Resolve the
-   `threadId` from the tracker's *security-thread* body field;
+   `threadId` from the tracker's *security-thread* body field
+   (`uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-cve-allocate body-field-get <N> "<security-thread field>"`);
    subject is always
    `Re: <root subject of the inbound report>`, never fabricated.
    When the field records multiple threads — a primary reporter thread
@@ -131,13 +134,12 @@ user to confirm. Numbered items:
 
 ### Rollup entry template
 
-The entry is a single `<details>` block appended to the tracker's
-rollup comment (or — when the rollup does not exist yet — the
-first entry of a new rollup seeded with the marker from
+This is the entry **body** only; `rollup-append` wraps it in the
+`<details><summary>YYYY-MM-DD · @user · CVE allocated (<CVE-YYYY-NNNNN>)</summary>` envelope
+(and seeds the rollup marker when the tracker has none yet, per
 [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)).
 Follow the zero-whitespace rules from that spec: no leading
-spaces inside the block, one blank line after
-`<summary>…</summary>`, one blank line before `</details>`.
+spaces on any line.
 
 The `<record-url>` and `<source-tab-url>` tokens below are
 substituted from `cve_authority.record_url_template` and
@@ -145,8 +147,6 @@ substituted from `cve_authority.record_url_template` and
 [`<project-config>/project.md`](../../../../<project-config>/project.md#cve-authority).
 
 ```markdown
-<details><summary><YYYY-MM-DD> · @<author-handle> · CVE allocated (<CVE-YYYY-NNNNN>)</summary>
-
 **Sync <YYYY-MM-DD> — CVE [`<CVE-YYYY-NNNNN>`](<record-url>) allocated for [<tracker>#<N>](https://github.com/<tracker>/issues/<N>).**
 
 - Body *CVE tool link* field now points at the configured CVE tool.
@@ -160,10 +160,12 @@ substituted from `cve_authority.record_url_template` and
 Allocated via the project's CVE tool at <cve_authority.allocate_url>; the CVE ID is now the canonical reference in every downstream artifact (CVE JSON, advisory email, credit lines, cross-links). Scope `<scope label>` → product `<product>` → `packageName` `<packageName>`.
 
 Paste-ready JSON was regenerated from the current body state (CWE `<CWE>`, severity `<severity>`, affected `<affected versions>`, `<N>` credits, `<N>` references) and embedded in the issue body. Re-run `uv run --project <framework>/<cve-tool>/generate-cve-json generate-cve-json <N> --attach` after any body change to keep the JSON in sync.
-
-</details>
 ```
 
+The CWE, severity and affected-versions values come from their body fields,
+each read with `body-field-get` (field names per the project manifest's issue-template mapping),
+so the body never enters context for this entry either.
+
 Apply the Golden rule 2 self-check (clickable `<tracker>`
 references) to the entire entry before emitting.
 
diff --git a/plugins/magpie-security/skills/issue-deduplicate/SKILL.md b/plugins/magpie-security/skills/issue-deduplicate/SKILL.md
index b95cb7a8..7de08a96 100644
--- a/plugins/magpie-security/skills/issue-deduplicate/SKILL.md
+++ b/plugins/magpie-security/skills/issue-deduplicate/SKILL.md
@@ -25,7 +25,7 @@ argument-hint: "[kept-issue] [duplicate-issue]"
 capability: capability:resolve
 surface_hash: sha256:ea8092b0eb507603
 license: Apache-2.0
-measured_tokens: 5721
+measured_tokens: 6023
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -365,16 +365,38 @@ After confirmation, apply **sequentially** (never in parallel):
 
 1. `gh issue edit <keep> --body-file <tmpfile>` — updated body
 2. Rollup-comment upsert on the kept tracker per
-   [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md#upsert-recipe--append-to-an-existing-rollup-or-create-one)
-   — append the `Merge (kept)` entry (`gh api -X PATCH
-   repos/<tracker>/issues/comments/<id> --input …`) or create
-   the rollup if none exists yet. The same step folds any legacy
-   bot comments on the kept tracker into the rollup first, per
-   the fold-legacy sub-step in
-   [`security-issue-sync`](../issue-sync/SKILL.md).
-3. Rollup-comment upsert on the dropped tracker — append the
-   `Merge (dropped)` entry (same recipe; fold legacy comments
-   first when needed).
+   [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md#upsert-recipe--append-to-an-existing-rollup-or-create-one).
+   First fold any legacy bot comments on the kept tracker into the
+   rollup, oldest first, per the fold-legacy sub-step in
+   [`security-issue-sync`](../issue-sync/SKILL.md) — one call per
+   legacy comment, which deletes the original only after its append
+   succeeded:
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-deduplicate rollup-fold <keep> <legacy-comment-id> "<Action>"
+   ```
+
+   Then write the `Merge (kept)` entry body to
+   `<scratch>/dedupe-<keep>-rollup.md` with the Write tool and append
+   it (the call creates the rollup if none exists yet):
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-deduplicate rollup-append <keep> "Merge (kept) (from #<drop>)" <scratch>/dedupe-<keep>-rollup.md
+   ```
+
+   These run through vetted-ops' `vetted-op-tracker` entry point,
+   which the secure setup lets out of the sandbox (every write still asks).
+   Without the secure setup, the same operations are
+   `uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …`
+   and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`;
+   see [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
+3. Rollup-comment upsert on the dropped tracker — fold its legacy
+   comments first when needed (`rollup-fold <drop> …`, same shape),
+   then append the `Merge (dropped)` entry:
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-deduplicate rollup-append <drop> "Merge (dropped) (into #<keep>)" <scratch>/dedupe-<drop>-rollup.md
+   ```
 4. `gh issue edit <drop> --repo <tracker> --add-label duplicate`
 5. `gh issue close <drop> --repo <tracker> --reason "not planned"`
    (GitHub's `duplicate` close-reason is not exposed by `gh` on
@@ -409,10 +431,6 @@ After confirmation, apply **sequentially** (never in parallel):
    firing. The contract refuses retraction of any record already
    at the `public` state — the Step 0 / Inputs pre-check above
    should already have blocked the merge in that case.
-8. For each legacy bot comment folded in steps 2 / 3, delete the
-   original with `gh api -X DELETE
-   repos/<tracker>/issues/comments/<id>` — only after the
-   matching rollup PATCH succeeded.
 
 If any step fails, stop and ask the user how to proceed — do not
 guess. Partial merges are recoverable as long as the body update
diff --git a/plugins/magpie-security/skills/issue-deduplicate/merge-templates.md b/plugins/magpie-security/skills/issue-deduplicate/merge-templates.md
index 607efc30..9101320e 100644
--- a/plugins/magpie-security/skills/issue-deduplicate/merge-templates.md
+++ b/plugins/magpie-security/skills/issue-deduplicate/merge-templates.md
@@ -156,16 +156,16 @@ When either tracker does not yet carry a rollup (legacy tracker
 pre-dating the convention), the upsert recipe's Step 2b creates
 one and folds any pre-existing legacy bot comments in on the way.
 
-Each entry is a single `<details>` block. Follow the zero-whitespace
-rules from the shared spec — no leading spaces inside the block,
-one blank line after `<summary>…</summary>`, one blank line
-before `</details>`.
+Draft only each entry's body; the Step 5 tool call writes the
+`<details><summary>YYYY-MM-DD · @user · <Action></summary>` envelope.
+The action labels are `Merge (kept) (from #<drop>)` and
+`Merge (dropped) (into #<keep>)`.
+Start every body line at column 0 — leading spaces inside the
+envelope render as a code block.
 
 ### Entry appended to the kept tracker's rollup
 
 ```markdown
-<details><summary><YYYY-MM-DD> · @<author-handle> · Merge (kept) (from #<drop>)</summary>
-
 **Merged [<tracker>#<drop>](https://github.com/<tracker>/issues/<drop>) into this tracker.** <one-sentence headline: same root-cause bug, different attack vector / affected process.>
 
 - Body: <keep.reporter>'s original report preserved; <drop.reporter>'s report appended as *"Second independent report"*.
@@ -185,15 +185,11 @@ Full analysis of why the two reports are the same root-cause bug (same function,
 - *CWE*: <set to <value> | kept as _No response_ | BLOCKER: conflict between <keep.cwe> and <drop.cwe> — triager to resolve>.
 - *Affected versions*: widened to <value>.
 - CVE JSON attachment regenerated: <comment URL>.
-
-</details>
 ```
 
 ### Entry appended to the dropped tracker's rollup
 
 ```markdown
-<details><summary><YYYY-MM-DD> · @<author-handle> · Merge (dropped) (into #<keep>)</summary>
-
 **Closing as duplicate of [<tracker>#<keep>](https://github.com/<tracker>/issues/<keep>).** <one-sentence headline.>
 
 Full content merged into [<tracker>#<keep>](https://github.com/<tracker>/issues/<N>) as *"Second independent report"*; <drop.reporter> credited alongside <keep.reporter> there.
@@ -207,8 +203,6 @@ Specific artifacts merged: <CVSS scoring, attack chain, PoC, remediation options
 See [the merge entry on <tracker>#<keep>](https://github.com/<tracker>/issues/<N>) for the full hand-off record.
 
 <Reporter-notification line — one of the four canonical options from the sync skill.>
-
-</details>
 ```
 
 Both entries must render every cross-issue reference as a
diff --git a/plugins/magpie-security/skills/issue-fix/SKILL.md b/plugins/magpie-security/skills/issue-fix/SKILL.md
index c0991ee0..1ce4be0e 100644
--- a/plugins/magpie-security/skills/issue-fix/SKILL.md
+++ b/plugins/magpie-security/skills/issue-fix/SKILL.md
@@ -22,7 +22,7 @@ capability:
   - capability:resolve
 surface_hash: sha256:9884ef304a3fad88
 license: Apache-2.0
-measured_tokens: 6768
+measured_tokens: 6775
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -291,8 +291,8 @@ continue.
      privacy-llm-check
    ```
 
-   This skill reads the `<tracker>` issue body to update the
-   "PR with the fix" field; the redact-after-fetch protocol
+   This skill reads the `<tracker>` issue body and comments through the
+   Step 1 sync, for the Step 3 fixability assessment; the redact-after-fetch protocol
    (see [`tools/privacy-llm/wiring.md`](../../../../tools/privacy-llm/wiring.md))
    applies to that fetch.
 
diff --git a/plugins/magpie-security/skills/issue-fix/pre-implementation.md b/plugins/magpie-security/skills/issue-fix/pre-implementation.md
index bf19ed37..480a5805 100644
--- a/plugins/magpie-security/skills/issue-fix/pre-implementation.md
+++ b/plugins/magpie-security/skills/issue-fix/pre-implementation.md
@@ -14,9 +14,17 @@ search. **This check is mandatory before any new code is written.**
 
 Run (in order, stop at the first that produces results):
 
-1. **Tracker body field** — parse the issue body for a "PR with the fix"
-   field value. If it contains a `<upstream>` PR URL or `#NNN`
+1. **Tracker body field** — read the "PR with the fix" field value:
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-issue-fix body-field-get <N> "PR with the fix"
+   ```
+
+   If it contains a `<upstream>` PR URL or `#NNN`
    reference, that is the candidate.
+   This and the Step 10 writes run through vetted-ops' `vetted-op-read` / `vetted-op-tracker` entry points, which the secure setup lets out of the sandbox (every write still asks).
+   Without the secure setup, the same operations are `uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …` and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`.
+   See [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
 2. **Tracker comments** — scan the comment thread for `<upstream>` PR
    URLs posted by tracker collaborators.
 3. **GitHub search** — query the `<upstream>` repo for open PRs that
diff --git a/plugins/magpie-security/skills/issue-fix/tracker-update.md b/plugins/magpie-security/skills/issue-fix/tracker-update.md
index 72545384..05c9a2a3 100644
--- a/plugins/magpie-security/skills/issue-fix/tracker-update.md
+++ b/plugins/magpie-security/skills/issue-fix/tracker-update.md
@@ -11,9 +11,15 @@ Now that a public PR exists, update the private tracking issue:
    comment** — not a new top-level comment. The rollup-upsert
    recipe (detection, append, zero-whitespace rules) lives in
    [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md).
-   Emit a single `<details>` block with summary
-   `<YYYY-MM-DD> · @<author-handle> · Fix PR (<upstream>#<PR>)`;
-   the entry body announces the new PR, the branch name, and the
+   Write the entry body to `<scratch>/rollup-entry-<N>.md` with the Write tool and append it:
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-fix rollup-append <N> "Fix PR (<upstream>#<PR>)" <scratch>/rollup-entry-<N>.md
+   ```
+
+   The tool adds the `<YYYY-MM-DD> · @<author-handle> · Fix PR (<upstream>#<PR>)` `<details>` envelope,
+   so the file holds the body only.
+   The entry body announces the new PR, the branch name, and the
    intended backport (if any). Render the issue reference, the PR
    reference, and any CVE as clickable markdown links per the
    "Linking CVEs" and "Linking `<tracker>` issues and PRs" rules in
@@ -22,10 +28,11 @@ Now that a public PR exists, update the private tracking issue:
    the branch name, and the CVE reference.
 
    If the tracker has no rollup yet (legacy tracker pre-dating the
-   convention), run the upsert recipe's Step 2b to create it and
-   fold any pre-existing bot comments into the new rollup first —
+   convention), `rollup-append` creates it; propose folding any
+   pre-existing bot comments into it first —
    see the fold-legacy sub-step in
-   [`security-issue-sync`](../issue-sync/SKILL.md).
+   [`security-issue-sync`](../issue-sync/SKILL.md) — one call per accepted comment, oldest first:
+   `uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-fix rollup-fold <N> <comment-id> "<Action>"`.
 
    Before writing the entry, **scrub the body for bare-name
    mentions** of project maintainers, release managers, and
@@ -42,10 +49,15 @@ Now that a public PR exists, update the private tracking issue:
    alongside the mention).
 
 2. **Update the issue body "PR with the fix" field** if it is empty
-   or points to a stale PR. Use `gh issue view --json body`, patch
-   only that field, and apply via `gh issue edit --body-file`, as
-   in the [`security-issue-sync`](../issue-sync/SKILL.md)
-   skill.
+   or points to a stale PR (the Step 2a value tells you which).
+   Write the PR URL to `<scratch>/pr-with-fix-<N>.md` with the Write tool, then patch
+   only that field:
+
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-fix body-field-set <N> "PR with the fix" <scratch>/pr-with-fix-<N>.md
+   ```
+
+   Exit `3` means the heading is absent or duplicated — surface it and fall back to a manual edit.
 
 3. **Assign the tracker to the fix owner.** Now that a PR exists,
    propose setting the tracking issue's assignee so the board
@@ -211,7 +223,8 @@ Before leaving the tracking issue, verify:
 - the issue body "PR with the fix" field points at the newly-opened
   public PR;
 - the `cve allocated` label is present if the issue body contains a
-  CVE tool link, and absent if it does not;
+  CVE tool link, and absent if it does not
+  (read both fields with `body-field-get`, never the whole body);
 - `needs triage` is gone.
 
 Surface any remaining inconsistency in the Step 11 recap.
diff --git a/plugins/magpie-security/skills/issue-import-from-md/SKILL.md b/plugins/magpie-security/skills/issue-import-from-md/SKILL.md
index 9a31c6db..bdbe77ca 100644
--- a/plugins/magpie-security/skills/issue-import-from-md/SKILL.md
+++ b/plugins/magpie-security/skills/issue-import-from-md/SKILL.md
@@ -27,7 +27,7 @@ argument-hint: "[path-to-markdown-file]"
 capability: capability:intake
 surface_hash: sha256:a0bf5966806f210a
 license: Apache-2.0
-measured_tokens: 7236
+measured_tokens: 7369
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -212,7 +212,7 @@ Before running, the skill needs:
 
 - **`gh` CLI authenticated** with collaborator access to
   `<tracker>`. The skill calls `gh api repos/<tracker>/issues`,
-  `gh search issues`, and `gh issue comment`.
+  `gh search issues`, and vetted-ops' `rollup-append`.
 - **Project-board write access** for the `addProjectV2ItemById` /
   `updateProjectV2ItemFieldValue` mutations from
   [`tools/github/project-board.md`](../../../../tools/github/project-board.md).
@@ -403,13 +403,13 @@ orphan-issue path in
 
 ### 3e — Status-rollup comment
 
-The first entry on the tracker's status rollup. Shape per
-[`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md):
+The first entry on the tracker's status rollup
+([`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md)),
+with the action label `Import from markdown (<basename>, finding <K>/<N>)`.
+Draft only the entry body; Step 5d's tool writes the `<details>` envelope
+and creates the rollup with its marker line:
 
 ```markdown
-<!-- <tracker> status rollup v1 — all bot-authored status updates fold into this single comment. -->
-<details><summary><YYYY-MM-DD> · @<author-handle> · Import from markdown (<basename>, finding <K>/<N>)</summary>
-
 **Imported from markdown file `<basename>` on <YYYY-MM-DD>** (severity: `<severity>`, category: `<category>`).
 
 This tracker was deliberately opened by the security team from a batch findings file. The validity of the report has **not** been assessed yet — the tracker landed in the `Needs triage` column accordingly. Standard Step 3 discussion applies.
@@ -418,14 +418,10 @@ This tracker was deliberately opened by the security team from a batch findings
 **Location reference:** <location_url>
 **Severity (from source):** `<severity>` (informational; CVSS scoring happens at allocation).
 **Category (from source):** `<category>` (informational; CWE assignment happens at allocation).
-</details>
 ```
 
-Zero-whitespace rules from
-[`status-rollup.md`](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)
-apply: no leading spaces on any line inside the `<details>`
-block, exactly one blank line after `<summary>…</summary>`,
-exactly one blank line before `</details>`.
+Start every body line at column 0 — leading spaces inside the `<details>`
+envelope render as a code block.
 
 ---
 
@@ -592,14 +588,19 @@ either mutation returns `not found`.
 
 ### 5d — Post the status-rollup comment
 
+Write the Step 3e entry body, placeholders filled, to
+`<scratch>/import-md-<basename>-<index>-rollup.md` with the Write tool, then:
+
 ```bash
-gh issue comment <new-issue-number> \
-  --repo <tracker> \
-  --body-file <scratch>/import-md-<basename>-<index>-rollup.md
+uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-import-from-md rollup-append <new-issue-number> "Import from markdown (<basename>, finding <K>/<N>)" <scratch>/import-md-<basename>-<index>-rollup.md
 ```
 
-The rollup body is the one drafted in Step 3e with placeholders
-filled.
+These run through vetted-ops' `vetted-op-tracker` entry point,
+which the secure setup lets out of the sandbox (every write still asks).
+Without the secure setup, the same operations are
+`uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …`
+and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`;
+see [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
 
 ### 5e — Cleanup (per finding)
 
diff --git a/plugins/magpie-security/skills/issue-import-from-pr/SKILL.md b/plugins/magpie-security/skills/issue-import-from-pr/SKILL.md
index 0773451f..371c6cef 100644
--- a/plugins/magpie-security/skills/issue-import-from-pr/SKILL.md
+++ b/plugins/magpie-security/skills/issue-import-from-pr/SKILL.md
@@ -20,7 +20,7 @@ argument-hint: "[pr-number] [repo:owner/name]"
 capability: capability:intake
 surface_hash: sha256:249e4ff2ba6b1d91
 license: Apache-2.0
-measured_tokens: 9431
+measured_tokens: 9605
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -199,7 +199,8 @@ Before running, the skill needs:
 - **`gh` CLI authenticated** (`gh auth status` returns OK) with
   collaborator access to `<tracker>` **and** read access to
   `<upstream>`. The skill calls `gh pr view`, `gh search issues`,
-  `gh api repos/<tracker>/issues`, and `gh issue edit`.
+  `gh api repos/<tracker>/issues`, `gh issue edit`, and vetted-ops'
+  `rollup-append`.
 - **Project-board write access.** Setting the `Assessed` column
   uses the `addProjectV2ItemById` /
   `updateProjectV2ItemFieldValue` GraphQL mutations from
@@ -528,13 +529,13 @@ This validates the *Label + body state → Status* mapping:
 
 ### 5e — Status-rollup comment
 
-The first entry on the tracker's status rollup. Shape per
-[`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md):
+The first entry on the tracker's status rollup
+([`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md)),
+with the action label `Import from PR (<scope>, <upstream>#<N>)`.
+Draft only the entry body; Step 7e's tool writes the `<details>` envelope
+and creates the rollup with its marker line:
 
 ```markdown
-<!-- <tracker> status rollup v1 — all bot-authored status updates fold into this single comment. -->
-<details><summary><YYYY-MM-DD> · @<author-handle> · Import from PR (<scope>, <upstream>#<N>)</summary>
-
 **Imported from public PR `<upstream>#<N>` on <YYYY-MM-DD>** (scope: `<scope>`, PR state: `<state>`).
 
 This tracker was deliberately opened by the security team for a public fix that did **not** arrive on `<security-list>`. The validity assessment was made informally before invocation; the tracker landed in the `Assessed` column accordingly.
@@ -547,11 +548,8 @@ Extracted fields: scope=`<scope>`, *PR with the fix*=<pr.url>, *Remediation deve
 *Reporter credited as* intentionally left blank — public-PR imports do not credit the PR author as the CVE reporter (no responsible disclosure). See the [Reporter credit policy](https://github.com/<tracker>/blob/<tracker-default-branch>/.claude/skills/security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports) section of the skill for the rationale.
 ```
 
-Zero-whitespace rules from
-[`status-rollup.md`](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)
-apply: no leading spaces on any line inside the `<details>`
-block, exactly one blank line after `<summary>…</summary>`,
-exactly one blank line before `</details>`.
+Start every body line at column 0 — leading spaces inside the `<details>`
+envelope render as a code block.
 
 ---
 
@@ -777,14 +775,21 @@ either mutation returns `not found`.
 
 ### 7e — Post the status-rollup comment
 
+Write the Step 5e entry body, placeholders filled, to
+`<scratch>/import-pr-<N>-rollup.md` with the Write tool, then:
+
 ```bash
-gh issue comment <new-issue-number> \
-  --repo <tracker> \
-  --body-file <scratch>/import-pr-<N>-rollup.md
+uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-import-from-pr rollup-append <new-issue-number> "Import from PR (<scope>, <upstream>#<N>)" <scratch>/import-pr-<N>-rollup.md
 ```
 
-The rollup body is the one drafted in Step 5e with placeholders
-filled.
+These run through vetted-ops' `vetted-op-tracker` entry point,
+which the secure setup lets out of the sandbox (every write still asks).
+Without the secure setup, the same operations are
+`uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …`
+and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`;
+see [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
+
+The tool prints the rollup comment's URL (`…#issuecomment-<id>`) on stdout and nothing else; keep it for the Step 8 recap.
 
 ### 7f — Cleanup
 
diff --git a/plugins/magpie-security/skills/issue-import/apply.md b/plugins/magpie-security/skills/issue-import/apply.md
index fdd210f4..ffa6af11 100644
--- a/plugins/magpie-security/skills/issue-import/apply.md
+++ b/plugins/magpie-security/skills/issue-import/apply.md
@@ -305,13 +305,23 @@ For each confirmed `Report` or forwarder-relayed candidate:
    The full shape, upsert recipe, and legacy-comment folding rules
    live in
    [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md).
-   Emit the rollup body below and post via
-   `gh issue comment <N> --repo <tracker> --body-file <tmpfile>`:
+   Write the entry body below to
+   `<scratch>/import-<threadId>-rollup.md` with the Write tool and
+   append it; the tool creates the rollup, with its marker line,
+   because the tracker has none yet:
 
-   ```markdown
-   <!-- <tracker> status rollup v1 — all bot-authored status updates fold into this single comment. -->
-   <details><summary><YYYY-MM-DD> · @<author-handle> · Import (<classification>, <reporter>)</summary>
+   ```bash
+   uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-import rollup-append <N> "Import (<classification>, <reporter>)" <scratch>/import-<threadId>-rollup.md
+   ```
 
+   These run through vetted-ops' `vetted-op-tracker` entry point,
+   which the secure setup lets out of the sandbox (every write still asks).
+   Without the secure setup, the same operations are
+   `uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …`
+   and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`;
+   see [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
+
+   ```markdown
    **Imported from Gmail thread `<threadId>` on <YYYY-MM-DD>** (class: `<classification>`, reporter: `<reporter>`).
 
    **Next:** Step 3 — start the validity / CVE-worthiness discussion; tag at least one other security-team member.
@@ -319,23 +329,18 @@ For each confirmed `Report` or forwarder-relayed candidate:
    Provenance: <forwarder-relay chain if any (e.g. ASF-security adapter for ASF adopters), GHSA reference if any, mail-archive URL if recorded>.
    Extracted fields: <summary of what landed in the template — Affected versions pre-filled, reporter-credited-as placeholder, Severity=Unknown, etc.>.
    Receipt-of-confirmation reply: draft `<draftId>` waiting for user review in Gmail.
-
-   </details>
    ```
 
-   Zero-whitespace rules from
-   [`status-rollup.md`](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)
-   apply: no leading spaces on any line inside the `<details>`
-   block, exactly one blank line after `<summary>…</summary>`,
-   exactly one blank line before `</details>`. Clickable
-   `<tracker>` references (Golden rule 2 in
+   The action label must not contain `<`, `>` or `·`.
+   Start every body line at column 0 — the tool writes the `<details>` envelope,
+   and leading spaces inside it render as a code block.
+   Clickable `<tracker>` references (Golden rule 2 in
    [`AGENTS.md`](../../../../AGENTS.md)) apply inside the entry the
    same way they did in the pre-rollup shape.
 
-   Capture the returned comment ID — the recap (Step 8) links it,
-   and if a later skill pass in the same invocation (for example,
-   dedupe into an existing tracker surfaced by Step 2a) needs to
-   append another entry, it can skip the Step 1 lookup.
+   A later skill pass in the same invocation that appends another entry
+   (for example, dedupe into an existing tracker surfaced by Step 2a)
+   calls `rollup-append` the same way; no comment ID is needed.
 
 For each confirmed non-import (automated-scanner / consolidated /
 media / cross-thread-followup / fix-already-public):
diff --git a/plugins/magpie-security/skills/issue-invalidate/SKILL.md b/plugins/magpie-security/skills/issue-invalidate/SKILL.md
index efa8d691..b3e71ba6 100644
--- a/plugins/magpie-security/skills/issue-invalidate/SKILL.md
+++ b/plugins/magpie-security/skills/issue-invalidate/SKILL.md
@@ -20,7 +20,7 @@ argument-hint: "[issue-number]"
 capability: capability:resolve
 surface_hash: sha256:7a3f19e382d842b6
 license: Apache-2.0
-measured_tokens: 7698
+measured_tokens: 7694
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -445,14 +445,13 @@ Skip cases, recipients, subject, body, backend selection, and the existing-draft
 
 ### 5e — Status-rollup entry
 
-Append a new `<details>` block to the existing rollup comment
+Append a new entry to the existing rollup comment
 (per
 [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md)
-upsert recipe). Shape:
+upsert recipe) with the action label `Closed as invalid`.
+Draft only the entry body; Step 6a's tool writes the `<details>` envelope. Shape:
 
 ```markdown
-<details><summary><YYYY-MM-DD> · @<author-handle> · Closed as invalid</summary>
-
 **Closed as `invalid` on <YYYY-MM-DD>** (decided in [comment](#issuecomment-<id>)).
 
 **Reasoning** (verbatim from the team's discussion, capped at ~5 quotes):
@@ -482,13 +481,10 @@ entry without the line.
 **Project board:** archived (item `<item-id>`).
 
 **Next:** none — terminal disposition.
-
-</details>
 ```
 
-Zero-whitespace rules from
-[`status-rollup.md`](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)
-apply. The reasoning quotes section is trimmed to ~5 entries
+Start every body line at column 0 — leading spaces inside the `<details>`
+envelope render as a code block. The reasoning quotes section is trimmed to ~5 entries
 even when more material exists in the discussion — the rollup is
 a navigation aid, not an archive.
 
@@ -596,7 +592,7 @@ Hand-off line:
 | `archiveProjectV2Item` returns `not found` for the item | Project-board item ID has changed (rare; usually because the item was manually moved) | Re-run the introspection query. If the tracker is genuinely not on the board, skip 6e and note in the rollup. |
 | Gmail draft creation fails with `oauth_curl` 401 | OAuth token expired | Re-run the credential refresh per [`tools/gmail/oauth-draft/README.md`](../../../../tools/gmail/oauth-draft/README.md); do not fall back to `claude_ai_mcp` unless the [backend selection rule](../../../../tools/gmail/draft-backends.md#how-the-skills-pick-a-backend) permits it (no links in the body). |
 | The tracker title contains characters that break heredoc / shell quoting | Title with `'` or backticks | Use `--body-file` paths everywhere (already the convention); never inline issue titles into shell strings. |
-| Rollup comment not found (very old tracker, pre-convention) | Rollup didn't exist yet | Create one fresh with just the close entry (per the *create* branch of the upsert recipe). |
+| Rollup comment not found (very old tracker, pre-convention) | Rollup didn't exist yet | Nothing to do: Step 6a's `rollup-append` creates it with just the close entry. |
 | The tracker is `security@`-imported but the inbound thread can't be located in Gmail | Thread was archived / Gmail account changed / threadId is stale | Surface to the user; offer the `silent` confirmation form — the close still happens, the rollup notes the missing reply. |
 
 ---
diff --git a/plugins/magpie-security/skills/issue-invalidate/apply.md b/plugins/magpie-security/skills/issue-invalidate/apply.md
index 92df6aca..46da38b9 100644
--- a/plugins/magpie-security/skills/issue-invalidate/apply.md
+++ b/plugins/magpie-security/skills/issue-invalidate/apply.md
@@ -10,27 +10,27 @@
 `<scratch>` is the session scratch directory as an absolute path (fall back to `$TMPDIR`); `gh` may run outside the sandbox, where `$TMPDIR` differs, so pass it absolute paths.
 
 Posting the rollup before the closing comment lets the closing
-comment link to the rollup's permalink. Append to the existing
-rollup comment via the upsert recipe in
+comment link to the rollup's permalink. Write the Step 5e entry body to
+`<scratch>/invalidate-<N>-rollup.md` with the Write tool and append it
+per the upsert recipe in
 [`status-rollup.md`](../../../../tools/github/status-rollup.md):
 
 ```bash
-EXISTING=$(gh api repos/<tracker>/issues/comments/<rollup-comment-id> --jq .body)
-cat > <scratch>/invalidate-<N>-rollup.md <<EOF
-${EXISTING}
-
-<new <details> block from Step 5e>
-EOF
-gh api -X PATCH repos/<tracker>/issues/comments/<rollup-comment-id> \
-  -F body=@<scratch>/invalidate-<N>-rollup.md \
-  --jq .html_url
+uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-invalidate rollup-append <N> "Closed as invalid" <scratch>/invalidate-<N>-rollup.md
 ```
 
-If no rollup comment exists (very old trackers predating the
-rollup convention), create one fresh with just the new entry —
-same as the *create* branch of the upsert recipe.
+These run through vetted-ops' `vetted-op-tracker` entry point,
+which the secure setup lets out of the sandbox (every write still asks).
+Without the secure setup, the same operations are
+`uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …`
+and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`;
+see [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
 
-Capture the rollup permalink for use in the closing comment.
+On a very old tracker with no rollup comment (predating the rollup
+convention), the same call creates one with just the new entry.
+
+The tool prints the rollup comment's URL (`…#issuecomment-<id>`) on stdout and nothing else.
+Keep it: the closing comment's permalink uses that comment ID.
 
 ### 6b — Post the closing comment
 
@@ -91,8 +91,15 @@ Use the backend chosen in Step 5d:
 
 Capture the returned `draftId`. Update the rollup entry's
 *Reporter notification* line with the actual draft ID
-(re-PATCH the rollup comment if the draft ID was a placeholder
-when 6a ran).
+if the draft ID was a placeholder when 6a ran:
+rewrite `<scratch>/invalidate-<N>-rollup.md` with the real ID and run
+
+```bash
+uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-invalidate rollup-amend-latest <N> "Closed as invalid" <scratch>/invalidate-<N>-rollup.md
+```
+
+It keeps the entry's date and author, and refuses if someone else's
+entry has landed after it.
 
 ### 6g — Cleanup
 
diff --git a/plugins/magpie-security/skills/issue-sync/SKILL.md b/plugins/magpie-security/skills/issue-sync/SKILL.md
index 260f4dc9..6dcd9dcb 100644
--- a/plugins/magpie-security/skills/issue-sync/SKILL.md
+++ b/plugins/magpie-security/skills/issue-sync/SKILL.md
@@ -25,7 +25,7 @@ argument-hint: "[issue-number]"
 capability: capability:intake
 surface_hash: sha256:b0ff65771ca4650a
 license: Apache-2.0
-measured_tokens: 7507
+measured_tokens: 7508
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -444,7 +444,7 @@ Never assume confirmation. If the user replies ambiguously, ask again.
 
 Run the confirmed items sequentially. The apply mechanics (label
 edits, milestone create / assign / close, assignee swaps, body
-PATCH, rollup append, RM hand-off comment, project-board moves,
+field writes, rollup append, RM hand-off comment, project-board moves,
 GHSA write paths, Gmail draft creation), the CVE JSON regen flow
 (Step 5 / 5a), the OAuth-API push including the six pre-push
 hygiene gates (Step 5b), the RM hand-off comment reconciliation
diff --git a/plugins/magpie-security/skills/issue-sync/apply-and-push.md b/plugins/magpie-security/skills/issue-sync/apply-and-push.md
index 6530dc10..8771f8d7 100644
--- a/plugins/magpie-security/skills/issue-sync/apply-and-push.md
+++ b/plugins/magpie-security/skills/issue-sync/apply-and-push.md
@@ -21,17 +21,37 @@ before moving on to the next item. Use:
 - **Milestone (create then assign):** run the create call from 2b, then the edit. The create call mirrors `due_on` from the matching upstream milestone when available — see the *Read the due date from upstream* rule in [`<project-config>/milestones.md`](../../../../<project-config>/milestones.md#read-the-due-date-from-upstream).
 - **Milestone (close):** `gh api -X PATCH repos/<tracker>/milestones/<N> -f state=closed`. Only when the last open tracker on that milestone just closed via Step 15 (cve.org PUBLISHED). See the condition set in [`<project-config>/milestones.md`](../../../../<project-config>/milestones.md#closing-the-milestone).
 - **Assignees:** `gh issue edit <N> --repo <tracker> --add-assignee @me` (or a named user).
-- **Description:** `gh issue edit <N> --repo <tracker> --body-file <tmpfile>` — write the
+- **Body fields (one or a few changed fields):** one call per confirmed field.
+  Write the new value to `<scratch>/field-<N>-<slug>.md` with the Write tool, then:
+
+  ```bash
+  uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync body-field-set <N> "<Field>" <scratch>/field-<N>-<slug>.md
+  ```
+
+  This covers every Step 2b body-field item — CWE, Severity, Affected versions,
+  Reporter credited as, Short public summary for publish, PR with the fix,
+  Remediation developer, Public advisory URL, CVE tool link, and backtick-wrapping an existing value.
+  Exit `3` means the heading is absent or duplicated: fall back to the whole-body path below for that tracker.
+  These run through vetted-ops' `vetted-op-tracker` entry point, which the secure setup lets out of the sandbox (every write still asks).
+  Without the secure setup, the same operations are `uv run --directory <framework>/tools/github-rollup github-rollup --repo <tracker> append|amend-latest|fold …` and `uv run --directory <framework>/tools/github-body-field body-field --repo <tracker> get|set …`.
+  See [`tools/vetted-ops/README.md`](../../../../tools/vetted-ops/README.md#tracker-procedures-rollup-and-body-field-writes).
+- **Description (whole body):** `gh issue edit <N> --repo <tracker> --body-file <tmpfile>` — write the
   new body to a temporary file first so nothing is lost to shell quoting.
-- **Status-rollup comment:** use the upsert recipe in
-  [`tools/github/status-rollup.md`](../../../../tools/github/status-rollup.md#upsert-recipe--append-to-an-existing-rollup-or-create-one).
-  On a tracker that already carries a rollup, this is
-  `gh api -X PATCH repos/<tracker>/issues/comments/<id> --input
-  <json>` with the old body + `\n\n---\n\n` + the new entry; on a
-  legacy tracker with no rollup yet, it is a one-off `gh issue
-  comment <N> --repo <tracker> --body-file <tmpfile>` seeded with
-  the marker + the new entry + any folded legacy entries.
-  Before PATCHing / posting, **scrub the entry body for bare-name
+  Only for proposals that change the body's structure rather than field values:
+  adding `### Field` sections the body lacks (the *Description fields* item in
+  [`signals-to-actions.md`](signals-to-actions.md), which shows the full replacement body),
+  adding the `### Related references` audit section from the title cleanup,
+  or a field `body-field-set` refused with exit `3`.
+- **Status-rollup comment:** write the entry body (no `<details>` envelope, no marker, no ruler)
+  to `<scratch>/rollup-entry-<N>.md` with the Write tool, then:
+
+  ```bash
+  uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-append <N> "Sync (<short headline>)" <scratch>/rollup-entry-<N>.md
+  ```
+
+  The tool adds the envelope and ruler, and creates the rollup with its marker on a
+  legacy tracker that has none yet; the rollup body never enters context.
+  Before writing the entry file, **scrub the entry body for bare-name
   mentions** of anyone on the release-manager and
   security-team rosters in
   [`<project-config>/release-trains.md`](../../../../<project-config>/release-trains.md).
@@ -49,7 +69,7 @@ before moving on to the next item. Use:
   reviewer comment found in
   [Step 1e](gather.md#1e-check-the-cve-record-for-reviewer-comments) —
   including comments that were pure acknowledgements and needed no
-  other change — append the ledger marker to the same rollup entry
+  other change — put the ledger marker in the same rollup entry body file
   the rest of the run's changes land in:
 
   ```markdown
@@ -58,18 +78,22 @@ before moving on to the next item. Use:
 
   One marker per CVE ID, slugs comma-separated with no spaces. This
   is what makes Step 1e idempotent, so it must land **in the same
-  PATCH** as the changes it records — writing the ledger in a
+  `rollup-append`** as the entry recording the changes — writing the ledger in a
   separate later call risks a run that applies the body update and
   then fails before ledgering it, which re-proposes the same comment
   on the next sync. Conversely, never ledger a slug whose
   accompanying body update was *not* confirmed: an unprocessed
   comment recorded as processed disappears from every future run.
 
-- **Fold-legacy deletes:** after the rollup PATCH succeeds and
-  carries the folded entries, delete each original legacy bot
-  comment with `gh api -X DELETE
-  repos/<tracker>/issues/comments/<id>`. Never delete before the
-  PATCH lands.
+- **Fold legacy comments:** one call per confirmed legacy comment, oldest first,
+  before the pass's own `rollup-append`:
+
+  ```bash
+  uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-fold <N> <comment-id> "<Action>"
+  ```
+
+  The tool appends the legacy body as an entry under its original date and author,
+  and deletes the legacy comment only after the append succeeded.
 - **Release-manager hand-off comment:** pick the body template
   per the variant decision from
   [Step 5c](#step-5c--reconcile-the-release-manager-hand-off-comment) —
@@ -119,7 +143,7 @@ before moving on to the next item. Use:
   Capture the comment URL (POST or PATCH) for the Step 6 recap.
   Before posting / PATCHing, **scrub the resolved body** for the
   same bare-name → `@`-handle replacements documented for the rollup
-  PATCH above, so the `RM_HANDLE` substitution actually notifies
+  entry above, so the `RM_HANDLE` substitution actually notifies
   the release manager.
 - **Publication-ready notification comment:** same recipe as the
   hand-off comment above (same variant decision, same POST-vs-PATCH
@@ -183,7 +207,7 @@ before moving on to the next item. Use:
   entirely.
 
   Before posting, apply the same bare-name → `@handle` scrub used
-  for the rollup PATCH and hand-off comment, so the `RM_HANDLE`
+  for the rollup entry and hand-off comment, so the `RM_HANDLE`
   substitution actually notifies the release manager.
 - **Vulnogram state transition (`REVIEW → PUBLIC`):** invoke the
   [`vulnogram-api-record-publish`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md)
diff --git a/plugins/magpie-security/skills/issue-sync/signals-to-actions.md b/plugins/magpie-security/skills/issue-sync/signals-to-actions.md
index 500a4fd5..efdbebbd 100644
--- a/plugins/magpie-security/skills/issue-sync/signals-to-actions.md
+++ b/plugins/magpie-security/skills/issue-sync/signals-to-actions.md
@@ -276,6 +276,8 @@ will change and *why*. Group them by category:
   CVE ID, credits, links to PRs, short public summary for publish), propose a
   patched description. Show the full replacement body in the proposal, not a
   diff, so the user can review it.
+  This is the whole-body write in Step 4; a field whose `### ` heading already exists
+  and only needs a value is a per-field `body-field-set` item instead.
 
   **Every `_No response_` field must be explicitly reviewed in every sync
   run.** Before presenting the proposal, scan the issue body for remaining
@@ -716,12 +718,11 @@ will change and *why*. Group them by category:
   new standalone-comment shape because something "feels important
   enough".
 
-  **Entry shape for a sync pass.** Inside the rollup's
-  `<details>` block, emit:
+  **Entry shape for a sync pass.** Emit the entry body below;
+  `rollup-append` wraps it in the rollup's
+  `<details><summary><YYYY-MM-DD> · @<author-handle> · Sync (<short headline>)</summary>` block:
 
   ```markdown
-  <details><summary><YYYY-MM-DD> · @<author-handle> · Sync (<short headline>)</summary>
-
   **Sync <YYYY-MM-DD> — <one-sentence bold headline>.**
 
   - <Action 1: short, imperative, links only when load-bearing>
@@ -736,8 +737,6 @@ will change and *why*. Group them by category:
   comments, CVSS rationale, RM-attribution trail, label-transition
   reasoning, stale-draft flags, cross-links, prior-entry pointers.
   Flush-left, no leading spaces, no sub-`<details>` blocks.>
-
-  </details>
   ```
 
   Because the entire entry is already inside a `<details>`
@@ -777,19 +776,17 @@ will change and *why*. Group them by category:
   `Reformat (N legacy comments folded)` when this pass's primary
   purpose is migrating pre-rollup bot comments (see below).
 
-  **Apply recipe** — use the upsert recipe in
-  [`status-rollup.md` — Upsert recipe](../../../../tools/github/status-rollup.md#upsert-recipe--append-to-an-existing-rollup-or-create-one).
+  **Apply recipe** — `rollup-append` (the Status-rollup comment item in
+  [Step 4](apply-and-push.md#step-4--apply-confirmed-changes)).
   For a tracker that already carries a rollup (the common case)
-  this is `gh api -X PATCH repos/<tracker>/issues/comments/<id>
-  --input <json-body>` — a single PATCH on the existing rollup,
-  not a fresh `gh issue comment`. The PATCH surfaces on the
+  it edits the existing rollup, not a fresh `gh issue comment`. The edit surfaces on the
   tracker as an *edit* of the rollup comment, not as a new
   timeline event, which is exactly the noise reduction the
   rollup is for.
 
   For a tracker with **no rollup yet** (legacy tracker pre-dating
-  the convention), the sync pass creates it via Step 2b of the
-  upsert recipe and immediately runs the legacy-fold sub-step
+  the convention), the same call creates it; the pass
+  also runs the legacy-fold sub-step
   below so the new rollup absorbs every pre-existing bot
   comment.
 
@@ -807,19 +804,17 @@ will change and *why*. Group them by category:
   skill`). For each hit, the Step 2 proposal carries a numbered
   item: *"fold legacy comment `<url>` (`<YYYY-MM-DD>`, first line
   <first-line>) into the rollup as a `<Action>` entry, then
-  delete the original"*. On user confirmation:
-
-  1. Read the legacy comment's body and `createdAt`.
-  2. Wrap in a rollup entry with summary
-     `<createdAt-date> · @<author-login> · <derived-Action>`.
-  3. Left-trim every line in the body (a single stray leading
-     space wrecks markdown rendering inside `<details>`).
-  4. Append to the rollup via the upsert recipe (oldest-first,
-     preserving chronological order).
-  5. **Only after the PATCH succeeds**, delete the original with
-     `gh api -X DELETE repos/<tracker>/issues/comments/<id>`.
-
-  Never delete a legacy comment before the append lands. Never
+  delete the original"*. On user confirmation, apply each with one
+  `rollup-fold` call, oldest first (the Fold legacy comments item in
+  [Step 4](apply-and-push.md#step-4--apply-confirmed-changes)):
+
+  ```bash
+  uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-fold <N> <comment-id> "<derived-Action>"
+  ```
+
+  The tool keeps the legacy comment's `createdAt` date and author in the summary,
+  left-trims every line (a single stray leading space wrecks markdown rendering inside `<details>`),
+  and deletes the original only after the append lands. Never
   touch a comment authored by someone outside the security-team
   roster (that is reporter discussion, not bot noise).
 
@@ -833,8 +828,8 @@ will change and *why*. Group them by category:
 
   **Before emitting any rollup body — run the zero-whitespace
   self-check.** `<details>` blocks in GitHub markdown break
-  silently when any line inside carries leading whitespace, or
-  when the blank-line-after-`<summary>` is missing. Re-read
+  silently when any line inside carries leading whitespace
+  (the tool supplies the blank line after `<summary>`, not the entry body's indentation). Re-read
   [`status-rollup.md` — The rollup comment shape](../../../../tools/github/status-rollup.md#the-rollup-comment-shape)
   before posting; the bug manifests as the entry rendering as a
   single preformatted block and hiding every link. Do not
diff --git a/tools/github-rollup/README.md b/tools/github-rollup/README.md
index 6da9a4d7..4fe8dbbc 100644
--- a/tools/github-rollup/README.md
+++ b/tools/github-rollup/README.md
@@ -64,6 +64,8 @@ context every sync pass.
 This tool does the read / append / PATCH in a subprocess. Only a
 one-line confirmation lands on the agent's stderr. The body never
 crosses the boundary.
+`append` and `amend-latest` print the rollup comment's URL on stdout, so a
+caller can link to it without reading the rollup.
 
 ## Invocation
 
diff --git a/tools/github-rollup/src/github_rollup/cli.py b/tools/github-rollup/src/github_rollup/cli.py
index c5ecce01..c26b3950 100644
--- a/tools/github-rollup/src/github_rollup/cli.py
+++ b/tools/github-rollup/src/github_rollup/cli.py
@@ -130,7 +130,8 @@ def _gh_delete_comment(comment_id: str, repo: str) -> None:
         raise SystemExit(result.returncode or 1)
 
 
-def _gh_post_comment(issue: str, repo: str | None, body: str) -> None:
+def _gh_post_comment(issue: str, repo: str | None, body: str) -> str:
+    """Post a comment; return its URL (``gh issue comment`` prints it)."""
     cmd = ["gh", "issue", "comment", issue, "--body-file", "-"]
     if repo:
         cmd.extend(["--repo", repo])
@@ -138,6 +139,14 @@ def _gh_post_comment(issue: str, repo: str | None, body: str) -> None:
     if result.returncode != 0:
         sys.stderr.write(result.stderr)
         raise SystemExit(result.returncode or 1)
+    return (result.stdout or "").strip()
+
+
+def _print_url(url: str) -> None:
+    """The rollup comment URL is the only stdout, so a caller can link to it
+    without reading the rollup body."""
+    if url:
+        sys.stdout.write(f"{url}\n")
 
 
 def _find_rollup(comments: list[dict]) -> dict | None:
@@ -197,11 +206,13 @@ def _write_entry(issue: str, repo: str, entry: str, action: str, date: str, dry_
             sys.stderr.write(f"dry-run: would APPEND to existing rollup comment {rollup.get('url')}\n")
         return True
     if rollup is None:
-        _gh_post_comment(issue, repo, build_new_rollup_body(entry, repo.split("/")[-1]))
+        url = _gh_post_comment(issue, repo, build_new_rollup_body(entry, repo.split("/")[-1]))
         sys.stderr.write(f"created rollup on {repo}#{issue} ({action!r}, date={date})\n")
+        _print_url(url)
         return True
     _gh_patch_comment(rollup, repo, rebuild_with_appended_entry(rollup["body"], entry))
     sys.stderr.write(f"appended to rollup on {repo}#{issue} ({action!r}, date={date})\n")
+    _print_url(str(rollup.get("url") or ""))
     return True
 
 
@@ -241,6 +252,7 @@ def _cmd_amend_latest(args: argparse.Namespace) -> int:
         return 0
     _gh_patch_comment(rollup, repo, replace_latest_entry(rollup["body"], entry))
     sys.stderr.write(f"amended latest entry on {repo}#{args.issue} ({latest.action!r})\n")
+    _print_url(str(rollup.get("url") or ""))
     return 0
 
 
diff --git a/tools/skill-evals/evals/security-cve-allocate/step-4-propose-updates/fixtures/output-spec.md b/tools/skill-evals/evals/security-cve-allocate/step-4-propose-updates/fixtures/output-spec.md
index a03c9bf6..9ac79397 100644
--- a/tools/skill-evals/evals/security-cve-allocate/step-4-propose-updates/fixtures/output-spec.md
+++ b/tools/skill-evals/evals/security-cve-allocate/step-4-propose-updates/fixtures/output-spec.md
@@ -24,7 +24,7 @@ Field rules:
 - `proposal_items_count`: 4 when this step creates no NEW reporter Gmail draft — either no reporter thread is known, or the draft-check result shows a draft already exists on the inbound thread; 5 only when this step creates a new reporter Gmail draft.
 - `has_cve_tool_link_update`: proposal includes setting the *CVE tool link* body field to the ASF CVE tool URL.
 - `has_cve_allocated_label`: proposal includes adding the `cve allocated` label.
-- `has_rollup_entry`: proposal includes a rollup `<details>` block with the `CVE allocated` summary line.
+- `has_rollup_entry`: proposal includes a rollup entry (the entry body `rollup-append` posts under the `CVE allocated (<CVE-ID>)` action).
 - `has_cve_json_regeneration`: proposal includes regenerating the CVE JSON attachment.
 - `has_reporter_draft`: proposal includes creating a NEW Gmail draft on the inbound thread. If the draft-check result shows a draft already exists on that thread, do NOT create another — set this `false`, do not add a draft item, and use the `draft_pending` line referencing the existing draft.
 - `reporter_notification_line`: which of the four reporter-notification-line options appears in the rollup entry. This skill only ever creates a Gmail *draft* — it never sends mail. So whenever this step creates or surfaces a reporter draft, the reporter has not yet been notified: use `draft_pending`, not `notified`. Reserve `notified` for the case where the provided state shows the reporter was already notified by a prior *sent* message before this step ran.
diff --git a/tools/skill-evals/evals/security-issue-invalidate/step-5e-rollup-entry/fixtures/output-spec.md b/tools/skill-evals/evals/security-issue-invalidate/step-5e-rollup-entry/fixtures/output-spec.md
index c6d8a38e..9be29367 100644
--- a/tools/skill-evals/evals/security-issue-invalidate/step-5e-rollup-entry/fixtures/output-spec.md
+++ b/tools/skill-evals/evals/security-issue-invalidate/step-5e-rollup-entry/fixtures/output-spec.md
@@ -7,7 +7,7 @@ Return a JSON object with these structural assertion fields.
 
 ```json
 {
-  "has_closed_as_invalid_summary": <bool — <details> summary line contains "Closed as invalid">,
+  "has_closed_as_invalid_summary": <bool — the entry's action label (the tool writes it into the `<details>` summary line) is "Closed as invalid">,
   "has_reasoning_quotes": <bool — entry includes verbatim quotes from the team discussion>,
   "has_canned_response_ref": <bool — entry names the canned response template selected in Step 4>,
   "reporter_notification": "draft_created" | "pr_imported_none" | "indeterminate_none",
diff --git a/tools/vetted-ops/README.md b/tools/vetted-ops/README.md
index b9df0f9c..d0a129a1 100644
--- a/tools/vetted-ops/README.md
+++ b/tools/vetted-ops/README.md
@@ -204,10 +204,17 @@ board_status_field_id = "PVTSSF_…"    # its Status field id
                            "body-field-get", "body-field-set",
                            "cve-check-published"]
 "security-cve-allocate" = ["issue-view", "issue-add-label", "comment-update",
-                           "rollup-append", "body-field-get", "body-field-set"]
+                           "rollup-append", "rollup-fold",
+                           "body-field-get", "body-field-set"]
+"security-issue-fix"    = ["rollup-append", "rollup-fold",
+                           "body-field-get", "body-field-set"]
+"security-issue-import" = ["rollup-append"]
+"security-issue-import-from-md" = ["rollup-append"]
+"security-issue-import-from-pr" = ["rollup-append"]
+"security-issue-invalidate" = ["rollup-append", "rollup-amend-latest"]
 "security-issue-triage" = ["issue-view", "issue-comments",
                            "osv-get-vuln", "osv-query-package"]
-"security-issue-deduplicate" = ["osv-get-vuln"]
+"security-issue-deduplicate" = ["osv-get-vuln", "rollup-append", "rollup-fold"]
 "dependency-audit"      = ["osv-query-package", "osv-query-commit", "osv-query-batch"]
 "pr-management-triage"  = ["pr-list", "pr-view", "pr-checks", "gql-pr-liveness",
                            "pr-add-label", "pr-remove-label", "pr-draft", "pr-ready",
@@ -338,7 +345,7 @@ prompts. `vetted-op` can run the same operations, but only inside the sandbox.
   the legacy comment only after the append succeeded.
 - `body-field-set` refuses when the field is absent or appears more than once, and
   writes nothing when the value is unchanged.
-- Only a one-line summary reaches stderr; `body-field-get` prints the one value.
+- Only a one-line summary reaches stderr. On stdout, `body-field-get` prints the one value, and `rollup-append` / `rollup-amend-latest` print the rollup comment's URL (`…#issuecomment-<id>`) so a caller can link to it without reading the rollup.
   Refusals exit `3`, a failed `gh` call exits `4`.
 - `--dry-run` runs the reads (the plan depends on them) and prints each write
   instead of running it, with any body reported by size, never by content.
diff --git a/tools/vetted-ops/src/vetted_ops/procedures.py b/tools/vetted-ops/src/vetted_ops/procedures.py
index 72ddb878..0069434b 100644
--- a/tools/vetted-ops/src/vetted_ops/procedures.py
+++ b/tools/vetted-ops/src/vetted_ops/procedures.py
@@ -228,7 +228,7 @@ class Runner:
         self.calls.append(("read", list(argv)))
         return self._run(argv, None)
 
-    def write(self, argv: list[str], stdin: str | None = None) -> None:
+    def write(self, argv: list[str], stdin: str | None = None) -> str:
         self._check(argv)
         if not self.allow_writes:
             raise RunnerRefused("this operation is read-only; it may not write")
@@ -238,8 +238,8 @@ class Runner:
         self.calls.append(("write", list(argv)))
         if self.dry_run:
             print(f"dry-run: would run: {self._render(argv, data)}", file=self._out or sys.stdout)
-            return
-        self._run(argv, data)
+            return ""
+        return self._run(argv, data)
 
 
 @dataclass(frozen=True)
@@ -319,17 +319,28 @@ def _patch_comment(r: Runner, comment_id: str, body: str) -> None:
     )
 
 
-def _write_entry(r: Runner, number: str, entry: str) -> str:
-    """Append ``entry`` to the rollup, creating it when absent. Returns a summary."""
+def _write_entry(r: Runner, number: str, entry: str) -> tuple[str, str]:
+    """Append ``entry`` to the rollup, creating it when absent.
+
+    Returns a summary and the rollup comment's URL (empty in a dry run).
+    """
     rollup = _find_rollup(_list_comments(r, number))
     if rollup is None:
-        r.write(
+        out = r.write(
             ["gh", "issue", "comment", number, "--repo", r.tracker, "--body-file", "-"],
             stdin=build_new_rollup_body(entry, r.tracker.split("/")[-1]),
         )
-        return f"created rollup on {r.tracker}#{number}"
+        # `gh issue comment` prints the new comment's URL.
+        return f"created rollup on {r.tracker}#{number}", out.strip()
     _patch_comment(r, _rest_id(r, rollup), rebuild_with_appended_entry(str(rollup.get("body") or ""), entry))
-    return f"appended to rollup on {r.tracker}#{number}"
+    return f"appended to rollup on {r.tracker}#{number}", "" if r.dry_run else str(rollup.get("url") or "")
+
+
+def _print_url(url: str) -> None:
+    """The rollup comment URL is the only stdout: a caller links to it
+    (`#issuecomment-<id>`) without reading the rollup body."""
+    if url:
+        sys.stdout.write(f"{url}\n")
 
 
 def _today() -> str:
@@ -342,8 +353,9 @@ def rollup_append(r: Runner, *, number: str, action: str, text: str, date: str |
         raise ProcedureRefused("could not determine the authenticated gh login")
     day = date or _today()
     entry = build_entry(date=day, user=login, action=action, body=text)
-    summary = _write_entry(r, number, entry)
+    summary, url = _write_entry(r, number, entry)
     _say(f"{'dry-run: ' if r.dry_run else ''}{summary} ({action!r}, date={day})")
+    _print_url(url)
     return 0
 
 
@@ -361,6 +373,7 @@ def rollup_amend_latest(r: Runner, *, number: str, action: str, text: str) -> in
     entry = build_entry(date=latest.date, user=latest.user, action=latest.action, body=text)
     _patch_comment(r, _rest_id(r, rollup), replace_latest_entry(existing, entry))
     _say(f"{'dry-run: ' if r.dry_run else ''}amended latest entry on {r.tracker}#{number} ({action!r})")
+    _print_url("" if r.dry_run else str(rollup.get("url") or ""))
     return 0
 
 
@@ -379,7 +392,7 @@ def rollup_fold(r: Runner, *, number: str, comment_id: str, action: str) -> int:
     if not login or not date:
         raise ProcedureRefused(f"comment {comment_id} has no author or creation date")
     entry = build_entry(date=date, user=login, action=action, body=left_trim_lines(legacy_body))
-    summary = _write_entry(r, number, entry)
+    summary, _ = _write_entry(r, number, entry)
     # Only reached when the append succeeded: a failed write raises first.
     r.write(["gh", "api", f"repos/{r.tracker}/issues/comments/{comment_id}", "-X", "DELETE"])
     prefix = "dry-run: " if r.dry_run else ""
diff --git a/tools/vetted-ops/tests/test_procedures.py b/tools/vetted-ops/tests/test_procedures.py
index 6b1880b1..d694ea78 100644
--- a/tools/vetted-ops/tests/test_procedures.py
+++ b/tools/vetted-ops/tests/test_procedures.py
@@ -87,6 +87,8 @@ class FakeGh:
             return 0, (self.body + "\n").encode(), b""
         if argv[:2] == ["gh", "api"] and "-X" not in argv:
             return 0, json.dumps(self.legacy).encode(), b""
+        if argv[:3] == ["gh", "issue", "comment"]:
+            return 0, b"https://github.com/acme/tracker/issues/7#issuecomment-901\n", b""
         return 0, b"", b""
 
     @property
@@ -213,6 +215,24 @@ def test_append_patches_the_existing_rollup_by_its_url_id() -> None:
     assert stdin.decode() == expected
 
 
+def test_append_prints_the_new_rollup_url_and_nothing_else(capsys: pytest.CaptureFixture[str]) -> None:
+    fake = FakeGh(comments=[])
+    procedures.rollup_append(runner(fake), number="7", action="Sync", text="hello", date="2026-09-28")
+    assert capsys.readouterr().out == "https://github.com/acme/tracker/issues/7#issuecomment-901\n"
+
+
+def test_append_to_an_existing_rollup_prints_its_url(capsys: pytest.CaptureFixture[str]) -> None:
+    fake = FakeGh(comments=[rollup_comment(existing_rollup("Import"))])
+    procedures.rollup_append(runner(fake), number="7", action="Sync", text="new", date="2026-09-28")
+    assert capsys.readouterr().out == "https://github.com/acme/tracker/issues/7#issuecomment-555\n"
+
+
+def test_amend_prints_the_rollup_url(capsys: pytest.CaptureFixture[str]) -> None:
+    fake = FakeGh(comments=[rollup_comment(existing_rollup("Import"))])
+    procedures.rollup_amend_latest(runner(fake), number="7", action="Import", text="filled in")
+    assert capsys.readouterr().out == "https://github.com/acme/tracker/issues/7#issuecomment-555\n"
+
+
 def test_append_finds_a_rollup_written_for_another_tracker_name() -> None:
     old = existing_rollup("Import").replace("tracker status rollup", "airflow-s status rollup")
     fake = FakeGh(comments=[rollup_comment(old)])
@@ -620,6 +640,7 @@ def test_tracker_dispatcher_runs_the_procedures(
     base = ["--caller", "security-issue-sync", "--config", str(wide_policy)]
     assert cli.main_tracker([*base, "rollup-append", "7", "Sync", str(entry)]) == cli.EXIT_OK
     assert [a[:3] for a, _ in fake.writes] == [["gh", "issue", "comment"]]
+    assert capsys.readouterr().out == "https://github.com/acme/tracker/issues/7#issuecomment-901\n"
     assert cli.main_tracker([*base, "body-field-get", "7", "Severity"]) == cli.EXIT_OK
     assert capsys.readouterr().out == "High\n"