PR-mention trigger for the PM agent — built, tested offline, shipped off
The trigger-and-reply half of #116 is done: a mention of @pm-agent on a pull request (in
the conversation or in an inline review thread) makes the PM read the PR, its changed
files, its diff and the thread, and reply in that same thread. Once a thread holds a
mention, follow-ups in it continue the conversation with no new mention. It can only
reply — the change-authoring half (your point 6) is not in here and wants its own issue.
One correction to the PM's brief, in your favour
The brief expected the dispatcher under ci/** or .gitea/**. It isn't there — it's infra/agent/pm-dispatch.py, the program in the pm-agent Deployment. Those two directories
are the merge gate and are unrelated. So this PR touches no approval-policy sensitive
path and should merge on a green gate with no human review needed. I checked every changed
path against infra/gitea/approval-policy.json by hand; the gate's approval-policy stage
is the authority, so read its verdict on the PR rather than my word. Changed paths are infra/agent/, platform/pm-agent/, runbooks/ and decisions/.
There was a poll loop to extend, so nothing was improvised.
The two judgement calls you asked to be made explicitly
"The same thread" — I did not need to stop and ask. The brief said to ask only if the
repo had no existing convention. It has one: the issue path already treats a flat comment
list as one conversation ("act iff the newest thing in this thread was not written by me").
I applied that reading, but refined the arming rule to your words rather than the PM's
paraphrase. The PM guessed "once the bot has replied, follow-ups continue"; you wrote "a
thread that starts with a mention should continue". Those differ: under the PM's version,
someone who types three comments and tags the bot only in the first would have the last two
silently ignored. So continuation is armed by the mention, not by the reply, per thread.
It's still a policy, not a Gitea fact — tell me if you want it the other way, or want an
end condition; there currently is none, which is the open question below.
Idempotency. The marker lives inside the reply itself
(<!-- pm-agent:replied-to=comment-9001 -->), so it is one atomic write with the reply and
cannot get out of step with it. The failure mode I chose is at-least-once model call,
at-most-once reply: a crash before posting costs one repeated decision; a crash after
posting is a no-op. A mention is never silently dropped and a reply is never duplicated. The
alternative (mark first, ask second) is cheaper but lets a crash swallow a mention with
nothing in the thread saying so.
Loop prevention. The self-authored filter is the first check on every comment, and it's
structural — a bot comment can only ever clear work, never create it, and it cannot even
arm continuation, so a reply of ours containing our own handle is inert. The reply also never
writes @pm-agent. Both are tested.
What I verified
PM_SELFTEST=1 pm-dispatch → exit 0, 87 assertions (55 new), including all 15 new
fixture cases: your six required ones plus near-miss handles (@pm-agent-worker, [email protected]), a marker pasted by a non-bot author not suppressing a mention, a
burst of three comments becoming one reply, mentions in a review body, two inline threads
on one file staying separate, per-thread continuation, and a comment made before the
mention not being pulled in.
A hostile payload test: a PR body and a diff that each try to close the code fence
and issue orders — the assembled payload keeps every fence balanced, carries the
untrusted-input banner, and quotes the hostile text verbatim inside a fence. Nothing
outside a fence in that payload comes from a user.
The plumbing, against a stub Gitea: plan mode printed and posted nothing; a
conversation mention posted into the conversation with the right marker; an inline mention
posted inline on the right file and position; an out-of-vocabulary action was refused with
a marked explanation and no Job; a 403 on /notifications fell back to listing open PRs.
./ci/gate.sh → exit 3 (UNDETERMINED), nothing failed. Stages that did not run: spec-immutability and gitea-settings only — the two that cannot run in this container
(no sibling spec repo reachable, no SETTINGS_API_TOKEN). lint, l0, render-golden, l1, decisions-log all green.
What I did NOT verify — and need you to measure
I held no Gitea credential, no kubeconfig and no cluster token, so nothing here has run
against the live Gitea and nothing is deployed.
The cheap mention query needs a token scope your bot doesn't have. Gitea puts /api/v1/notifications behind its notification scope; the pm-agent token is minted read:user, write:repository, write:issue, read:package. I read that from Gitea's
documented scope model, not from the live server. The code handles it: it logs the
refusal, names read:notification as the remedy, and falls back to listing open PRs
(which only needs read:repository), so the feature works either way — just not cheaply. If you want the cheap query, re-mint the token with read:notification added and
update both Secrets. Steps are in runbooks/pm-agent.md.
All five new API calls are unexercised, written to Gitea's published 1.24 swagger. The
read-only curl commands to settle them in a minute are in platform/pm-agent/README.md → "Mentions on a pull request". Replacing the fixtures with a
real capture is the cheapest way to close the gap.
The likeliest actual gap: does Gitea raise a notification for a mention inside an inline review comment, with a usable subject.url? If not, the cheap query will miss
inline mentions and only the open-PR fallback finds them.
Does POST /pulls/{n}/reviews land in the existing inline thread, or open a second
one at the same line? Gitea groups by (path, position), which is why it should — but I
haven't seen it. There's a fallback for outright refusal, not for mis-placement.
No image was built (no container engine here), so the Containerfile change that copies
the fixtures into the verify stage hasn't been run. CI's images workflow exercises it.
It ships OFF — the rollout I'd suggest
PM_PR_MENTIONS: "false" in the ConfigMap. Given point 1 above and §2b, shipping it on would
be asserting it works. Two ConfigMap edits (each with a config-generation bump), in order:
PM_PLAN: "true" — discovers, assembles and prints the exact payload and the exact
thread it would post into, asks no model, posts nothing. Mention the bot on a live PR,
read kubectl -n pm-agent logs deploy/pm-agent, and you'll see exactly what it would have
done. (PM_DRY_RUN now implies this, so your "stop it taking new work" lever silences this
path too — on this path the reply is the action under test.)
PM_PR_MENTIONS: "true" once that looks right.
Open questions for you
No end condition on continuation. Once a thread holds a mention, every later non-bot
comment in it triggers a run (bounded to one reply per comment id, one decision per PR per
15 min). I didn't invent a termination rule. Named consequence: another bot commenting on a
PR where the agent was once mentioned would get a reply. There is no ignore-list for
automated authors — adding one is your call, not mine.
Spend.PM_PR_DIFF_MAX_CHARS defaults to 20000, so a mention costs a decision over a
noticeably bigger payload than an issue triage. Set it to 0 to send only the changed-file
list. No real measurement exists.
The second issue. As the PM said on the thread, the change-authoring half (revise an
agent-owned branch vs. open a fresh PR against main) is deliberately out of this branch
and wants its own issue.
No R-* requirement is claimed — the brief is right that nothing in the set covers agent
tooling, and inventing one would be worse than the gap.
Full detail, including every unverified item: decisions/2026-11-07-pm-agent-pr-mentions.md.
Gate run inside the worker (the pinned toolbox, on this exact commit, before the
branch was pushed): exit 3, stages that did not run: spec-immutability gitea-settings.
That is the worker's own run, not this pull request's CI run — read the check on this
pull request for that. A green tick is not proof anything ran; read what executed.
Opened by the PM agent from issue #116. It does not merge its own pull requests.
Closes #116.
## PR-mention trigger for the PM agent — built, tested offline, shipped **off**
The trigger-and-reply half of #116 is done: a mention of `@pm-agent` on a pull request (in
the conversation **or** in an inline review thread) makes the PM read the PR, its changed
files, its diff and the thread, and reply in that same thread. Once a thread holds a
mention, follow-ups in it continue the conversation with no new mention. It can **only**
reply — the change-authoring half (your point 6) is not in here and wants its own issue.
### One correction to the PM's brief, in your favour
The brief expected the dispatcher under `ci/**` or `.gitea/**`. It isn't there — it's
`infra/agent/pm-dispatch.py`, the program in the pm-agent Deployment. Those two directories
are the merge gate and are unrelated. **So this PR touches no approval-policy sensitive
path** and should merge on a green gate with no human review needed. I checked every changed
path against `infra/gitea/approval-policy.json` by hand; the gate's `approval-policy` stage
is the authority, so read its verdict on the PR rather than my word. Changed paths are
`infra/agent/`, `platform/pm-agent/`, `runbooks/` and `decisions/`.
There *was* a poll loop to extend, so nothing was improvised.
### The two judgement calls you asked to be made explicitly
**"The same thread" — I did not need to stop and ask.** The brief said to ask only if the
repo had no existing convention. It has one: the issue path already treats a flat comment
list as one conversation ("act iff the newest thing in this thread was not written by me").
I applied that reading, but refined the arming rule to **your** words rather than the PM's
paraphrase. The PM guessed "once the bot has *replied*, follow-ups continue"; you wrote "a
thread that *starts with a mention* should continue". Those differ: under the PM's version,
someone who types three comments and tags the bot only in the first would have the last two
silently ignored. So **continuation is armed by the mention, not by the reply**, per thread.
It's still a policy, not a Gitea fact — **tell me if you want it the other way, or want an
end condition**; there currently is none, which is the open question below.
**Idempotency.** The marker lives inside the reply itself
(`<!-- pm-agent:replied-to=comment-9001 -->`), so it is one atomic write with the reply and
cannot get out of step with it. The failure mode I chose is **at-least-once model call,
at-most-once reply**: a crash before posting costs one repeated decision; a crash after
posting is a no-op. A mention is never silently dropped and a reply is never duplicated. The
alternative (mark first, ask second) is cheaper but lets a crash swallow a mention with
nothing in the thread saying so.
**Loop prevention.** The self-authored filter is the first check on every comment, and it's
structural — a bot comment can only ever *clear* work, never create it, and it cannot even
arm continuation, so a reply of ours containing our own handle is inert. The reply also never
writes `@pm-agent`. Both are tested.
### What I verified
- **`PM_SELFTEST=1 pm-dispatch` → exit 0**, 87 assertions (55 new), including all 15 new
fixture cases: your six required ones plus near-miss handles (`@pm-agent-worker`,
`[email protected]`), a marker pasted by a non-bot author not suppressing a mention, a
burst of three comments becoming one reply, mentions in a review *body*, two inline threads
on one file staying separate, per-thread continuation, and a comment made *before* the
mention not being pulled in.
- **A hostile payload test**: a PR body *and* a diff that each try to close the code fence
and issue orders — the assembled payload keeps every fence balanced, carries the
untrusted-input banner, and quotes the hostile text verbatim *inside* a fence. Nothing
outside a fence in that payload comes from a user.
- **The plumbing, against a stub Gitea**: plan mode printed and posted nothing; a
conversation mention posted into the conversation with the right marker; an inline mention
posted inline on the right file and position; an out-of-vocabulary action was refused with
a marked explanation and no Job; a 403 on `/notifications` fell back to listing open PRs.
- **`./ci/gate.sh` → exit 3 (UNDETERMINED)**, nothing failed. Stages that did not run:
`spec-immutability` and `gitea-settings` only — the two that cannot run in this container
(no sibling spec repo reachable, no `SETTINGS_API_TOKEN`). `lint`, `l0`, `render-golden`,
`l1`, `decisions-log` all green.
### What I did NOT verify — and need you to measure
I held no Gitea credential, no kubeconfig and no cluster token, so **nothing here has run
against the live Gitea and nothing is deployed.**
1. **The cheap mention query needs a token scope your bot doesn't have.** Gitea puts
`/api/v1/notifications` behind its `notification` scope; the pm-agent token is minted
`read:user, write:repository, write:issue, read:package`. I read that from Gitea's
documented scope model, **not** from the live server. The code handles it: it logs the
refusal, names `read:notification` as the remedy, and falls back to listing open PRs
(which only needs `read:repository`), so the feature works either way — just not cheaply.
**If you want the cheap query, re-mint the token with `read:notification` added** and
update both Secrets. Steps are in `runbooks/pm-agent.md`.
2. **All five new API calls are unexercised**, written to Gitea's published 1.24 swagger. The
read-only curl commands to settle them in a minute are in
`platform/pm-agent/README.md` → "Mentions on a pull request". Replacing the fixtures with a
real capture is the cheapest way to close the gap.
3. **The likeliest actual gap: does Gitea raise a notification for a mention inside an
*inline review* comment, with a usable `subject.url`?** If not, the cheap query will miss
inline mentions and only the open-PR fallback finds them.
4. **Does `POST /pulls/{n}/reviews` land in the *existing* inline thread**, or open a second
one at the same line? Gitea groups by (path, position), which is why it should — but I
haven't seen it. There's a fallback for outright refusal, not for mis-placement.
5. **No image was built** (no container engine here), so the Containerfile change that copies
the fixtures into the `verify` stage hasn't been run. CI's `images` workflow exercises it.
### It ships OFF — the rollout I'd suggest
`PM_PR_MENTIONS: "false"` in the ConfigMap. Given point 1 above and §2b, shipping it on would
be asserting it works. Two ConfigMap edits (each with a `config-generation` bump), in order:
1. `PM_PLAN: "true"` — discovers, assembles and **prints** the exact payload and the exact
thread it would post into, asks no model, posts nothing. Mention the bot on a live PR,
read `kubectl -n pm-agent logs deploy/pm-agent`, and you'll see exactly what it would have
done. (`PM_DRY_RUN` now implies this, so your "stop it taking new work" lever silences this
path too — on this path the reply *is* the action under test.)
2. `PM_PR_MENTIONS: "true"` once that looks right.
### Open questions for you
- **No end condition on continuation.** Once a thread holds a mention, every later non-bot
comment in it triggers a run (bounded to one reply per comment id, one decision per PR per
15 min). I didn't invent a termination rule. Named consequence: another bot commenting on a
PR where the agent was once mentioned would get a reply. There is no ignore-list for
automated authors — adding one is your call, not mine.
- **Spend.** `PM_PR_DIFF_MAX_CHARS` defaults to 20000, so a mention costs a decision over a
noticeably bigger payload than an issue triage. Set it to `0` to send only the changed-file
list. No real measurement exists.
- **The second issue.** As the PM said on the thread, the change-authoring half (revise an
agent-owned branch vs. open a fresh PR against `main`) is deliberately out of this branch
and wants its own issue.
No `R-*` requirement is claimed — the brief is right that nothing in the set covers agent
tooling, and inventing one would be worse than the gap.
Full detail, including every unverified item: `decisions/2026-11-07-pm-agent-pr-mentions.md`.
---
**Gate run inside the worker** (the pinned toolbox, on this exact commit, before the
branch was pushed): exit `3`, stages that did not run: `spec-immutability gitea-settings`.
That is the worker's own run, not this pull request's CI run — read the check on this
pull request for that. A green tick is not proof anything ran; read what executed.
Opened by the PM agent from issue #116. It does not merge its own pull requests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #116.
PR-mention trigger for the PM agent — built, tested offline, shipped off
The trigger-and-reply half of #116 is done: a mention of
@pm-agenton a pull request (inthe conversation or in an inline review thread) makes the PM read the PR, its changed
files, its diff and the thread, and reply in that same thread. Once a thread holds a
mention, follow-ups in it continue the conversation with no new mention. It can only
reply — the change-authoring half (your point 6) is not in here and wants its own issue.
One correction to the PM's brief, in your favour
The brief expected the dispatcher under
ci/**or.gitea/**. It isn't there — it'sinfra/agent/pm-dispatch.py, the program in the pm-agent Deployment. Those two directoriesare the merge gate and are unrelated. So this PR touches no approval-policy sensitive
path and should merge on a green gate with no human review needed. I checked every changed
path against
infra/gitea/approval-policy.jsonby hand; the gate'sapproval-policystageis the authority, so read its verdict on the PR rather than my word. Changed paths are
infra/agent/,platform/pm-agent/,runbooks/anddecisions/.There was a poll loop to extend, so nothing was improvised.
The two judgement calls you asked to be made explicitly
"The same thread" — I did not need to stop and ask. The brief said to ask only if the
repo had no existing convention. It has one: the issue path already treats a flat comment
list as one conversation ("act iff the newest thing in this thread was not written by me").
I applied that reading, but refined the arming rule to your words rather than the PM's
paraphrase. The PM guessed "once the bot has replied, follow-ups continue"; you wrote "a
thread that starts with a mention should continue". Those differ: under the PM's version,
someone who types three comments and tags the bot only in the first would have the last two
silently ignored. So continuation is armed by the mention, not by the reply, per thread.
It's still a policy, not a Gitea fact — tell me if you want it the other way, or want an
end condition; there currently is none, which is the open question below.
Idempotency. The marker lives inside the reply itself
(
<!-- pm-agent:replied-to=comment-9001 -->), so it is one atomic write with the reply andcannot get out of step with it. The failure mode I chose is at-least-once model call,
at-most-once reply: a crash before posting costs one repeated decision; a crash after
posting is a no-op. A mention is never silently dropped and a reply is never duplicated. The
alternative (mark first, ask second) is cheaper but lets a crash swallow a mention with
nothing in the thread saying so.
Loop prevention. The self-authored filter is the first check on every comment, and it's
structural — a bot comment can only ever clear work, never create it, and it cannot even
arm continuation, so a reply of ours containing our own handle is inert. The reply also never
writes
@pm-agent. Both are tested.What I verified
PM_SELFTEST=1 pm-dispatch→ exit 0, 87 assertions (55 new), including all 15 newfixture cases: your six required ones plus near-miss handles (
@pm-agent-worker,[email protected]), a marker pasted by a non-bot author not suppressing a mention, aburst of three comments becoming one reply, mentions in a review body, two inline threads
on one file staying separate, per-thread continuation, and a comment made before the
mention not being pulled in.
and issue orders — the assembled payload keeps every fence balanced, carries the
untrusted-input banner, and quotes the hostile text verbatim inside a fence. Nothing
outside a fence in that payload comes from a user.
conversation mention posted into the conversation with the right marker; an inline mention
posted inline on the right file and position; an out-of-vocabulary action was refused with
a marked explanation and no Job; a 403 on
/notificationsfell back to listing open PRs../ci/gate.sh→ exit 3 (UNDETERMINED), nothing failed. Stages that did not run:spec-immutabilityandgitea-settingsonly — the two that cannot run in this container(no sibling spec repo reachable, no
SETTINGS_API_TOKEN).lint,l0,render-golden,l1,decisions-logall green.What I did NOT verify — and need you to measure
I held no Gitea credential, no kubeconfig and no cluster token, so nothing here has run
against the live Gitea and nothing is deployed.
/api/v1/notificationsbehind itsnotificationscope; the pm-agent token is mintedread:user, write:repository, write:issue, read:package. I read that from Gitea'sdocumented scope model, not from the live server. The code handles it: it logs the
refusal, names
read:notificationas the remedy, and falls back to listing open PRs(which only needs
read:repository), so the feature works either way — just not cheaply.If you want the cheap query, re-mint the token with
read:notificationadded andupdate both Secrets. Steps are in
runbooks/pm-agent.md.read-only curl commands to settle them in a minute are in
platform/pm-agent/README.md→ "Mentions on a pull request". Replacing the fixtures with areal capture is the cheapest way to close the gap.
inline review comment, with a usable
subject.url? If not, the cheap query will missinline mentions and only the open-PR fallback finds them.
POST /pulls/{n}/reviewsland in the existing inline thread, or open a secondone at the same line? Gitea groups by (path, position), which is why it should — but I
haven't seen it. There's a fallback for outright refusal, not for mis-placement.
the fixtures into the
verifystage hasn't been run. CI'simagesworkflow exercises it.It ships OFF — the rollout I'd suggest
PM_PR_MENTIONS: "false"in the ConfigMap. Given point 1 above and §2b, shipping it on wouldbe asserting it works. Two ConfigMap edits (each with a
config-generationbump), in order:PM_PLAN: "true"— discovers, assembles and prints the exact payload and the exactthread it would post into, asks no model, posts nothing. Mention the bot on a live PR,
read
kubectl -n pm-agent logs deploy/pm-agent, and you'll see exactly what it would havedone. (
PM_DRY_RUNnow implies this, so your "stop it taking new work" lever silences thispath too — on this path the reply is the action under test.)
PM_PR_MENTIONS: "true"once that looks right.Open questions for you
comment in it triggers a run (bounded to one reply per comment id, one decision per PR per
15 min). I didn't invent a termination rule. Named consequence: another bot commenting on a
PR where the agent was once mentioned would get a reply. There is no ignore-list for
automated authors — adding one is your call, not mine.
PM_PR_DIFF_MAX_CHARSdefaults to 20000, so a mention costs a decision over anoticeably bigger payload than an issue triage. Set it to
0to send only the changed-filelist. No real measurement exists.
agent-owned branch vs. open a fresh PR against
main) is deliberately out of this branchand wants its own issue.
No
R-*requirement is claimed — the brief is right that nothing in the set covers agenttooling, and inventing one would be worse than the gap.
Full detail, including every unverified item:
decisions/2026-11-07-pm-agent-pr-mentions.md.Gate run inside the worker (the pinned toolbox, on this exact commit, before the
branch was pushed): exit
3, stages that did not run:spec-immutability gitea-settings.That is the worker's own run, not this pull request's CI run — read the check on this
pull request for that. A green tick is not proof anything ran; read what executed.
Opened by the PM agent from issue #116. It does not merge its own pull requests.
🤖 Generated with Claude Code