# PR-Comment Follow-up Dispatch

Status: shipped in **W-mpej3cox00099466** (replaces the marker-based "Option A"
proposal investigated in CC turn `cct-mpeira1y0001ddff`).

## Why

`engine/{github,ado}.js` `pollPrHumanComments` routes every actionable human PR
comment into `pr.humanFeedback.pendingFix:true`, which `discover-from-prs`
turns into a `fix` dispatch on the **same** branch. The `fix.md` playbook then
forbids broadening the PR or creating a replacement branch. The net result was
a binary outcome for every comment:

1. **In-scope** → fix on the current branch, push, reply on the thread.
2. **Out of scope / invalid / harmful** → post a rebuttal, leave the thread
   open, do not change code.

There was no third option for the common case: *"Yes, this is a real ask, but
it doesn't belong in this PR — track it as a separate WI."* Agents either
silently rebutted those comments (frustrating reviewers) or broadened the PR
(making it un-reviewable).

This is **Option B** from the CC investigation: agent judgment + a documented
playbook contract. The poller is unchanged; the carve-out is purely additive.

## End-to-end walkthrough

1. **Human leaves a comment on PR #2400** asking for a related-but-distinct
   refactor: *"Can you also extract the markdown sanitizer into a shared module?
   Probably worth a separate PR though."*
2. **Engine pollers** (`engine/github.js` `pollPrHumanComments`) flag the
   comment, set `pr.humanFeedback.pendingFix:true`, and `discover-from-prs`
   queues a `fix` dispatch against PR #2400's branch.
3. **Fix agent (e.g. lambert)** starts working. After fetching the diff and
   the comment, it classifies each ask using the decision tree in
   `playbooks/templates/followup-dispatch.md`:
   - The in-scope comments → fix on this branch.
   - The "extract sanitizer" comment → **follow-up**.
4. **Follow-up dispatch.** The agent calls the dashboard API with the four
   `meta.pr_followup` fields and the two identification headers:
   ```bash
   curl -sS -X POST http://localhost:7331/api/work-items \
     -H 'Content-Type: application/json' \
     -H 'X-Minions-Agent: lambert' \
     -H 'X-Minions-Origin-Wi: W-mp7originalwi' \
     -d '{
       "title": "Extract markdown sanitizer into shared module",
       "type": "implement",
       "priority": "medium",
       "project": "minions",
       "description": "Reviewer asked to track separately from PR #2400…",
       "references": [
         {"url": "https://github.com/opg-microsoft/minions/pull/2400", "label": "Originated from PR #2400 comment"}
       ],
       "meta": {
         "pr_followup": {
           "parent_pr_url": "https://github.com/opg-microsoft/minions/pull/2400",
           "parent_pr_id":  "github:opg-microsoft/minions#2400",
           "parent_comment_id": "4567890",
           "parent_comment_author": "alice"
         }
       }
     }'
   ```
5. **Server (`dashboard.js handleWorkItemsCreate`)**:
   - Validates `meta.pr_followup` shape (all four fields required, ≤ 512
     chars each). Malformed → `400 { error: "<message>" }`.
   - Checks for an existing follow-up with the same `parent_comment_id` in
     the target project's `work-items.json`. Duplicate → `409 {
     error: "followup_already_dispatched", existingWiId: "W-…" }` (no second WI
     created).
   - Persists the validated `meta.pr_followup`, and stores
     `X-Minions-Agent` / `X-Minions-Origin-Wi` as `_originAgent` /
     `_originWi` on the WI for the dashboard timeline.
   - Returns `200 { ok: true, id: "W-mp9abcdef" }`.
6. **Reply on the thread.** The agent posts a comment on the original PR
   thread:
   > Tracked as follow-up: **W-mp9abcdef** — see the Minions dashboard.
   > Continuing with the in-place fix on this PR.

   …and resolves that sub-thread. The rest of the PR review proceeds.
7. **Dashboard chips.**
   - The new WI's row shows `↪ from PR #2400`, linking back to the parent PR.
     (`dashboard/js/render-work-items.js` `prFollowup` block.)
   - The PR row for #2400 shows `+1 follow-up`, computed live from
     `window._lastWorkItems` and rendered in
     `dashboard/js/render-prs.js _countPrFollowups`.
8. **Completion report.** When the fix agent finishes its in-place work and
   writes the completion JSON, it includes a `followups` array:
   ```json
   {
     "status": "success",
     "summary": "Fixed reviewer's blocking finding; dispatched W-mp9abcdef as out-of-scope follow-up.",
     "pr": "https://github.com/opg-microsoft/minions/pull/2400",
     "followups": [
       {
         "wi_id": "W-mp9abcdef",
         "title": "Extract markdown sanitizer into shared module",
         "reason": "Out-of-scope ask from reviewer on PR #2400 comment #4567890",
         "parent_comment_id": "4567890"
       }
     ]
   }
   ```
   `engine/lifecycle.js processCompletionFollowups` logs each entry at `info`
   and warns if the claimed `wi_id` is missing from any project's
   `work-items.json` (catches reverted or non-landing dispatches).

## Server contract reference

**Headers** (`dashboard.js handleWorkItemsCreate`):

| Header | Type | Persisted as | Notes |
|---|---|---|---|
| `X-Minions-Agent` | `^[A-Za-z0-9._:-]{1,128}$` | `_originAgent` | Agent id (`lambert`, `temp-…`). Both headers are optional but recommended for traceability. |
| `X-Minions-Origin-Wi` | `^[A-Za-z0-9._:-]{1,128}$` | `_originWi` | The dispatch's parent WI id. |

**`meta.pr_followup` shape** (validated by `validatePrFollowupShape` in
`dashboard.js`):

```ts
{
  parent_pr_url: string,        // required, ≤ 512 chars
  parent_pr_id:  string,        // required, ≤ 512 chars (canonical id, e.g. github:owner/repo#123 or PR-123)
  parent_comment_id: string,    // required, ≤ 512 chars (host comment/thread id; key for dedup)
  parent_comment_author: string // required, ≤ 512 chars
}
```

Any missing/empty field, oversized field, or non-string value → `400` with a
descriptive message.

**Dedup.** A second `POST /api/work-items` with the same
`meta.pr_followup.parent_comment_id` in the same project's `work-items.json`
returns `409 { error: "followup_already_dispatched", existingWiId: "<id>" }`.
This is checked under the same `mutateWorkItems` lock used to insert the new
WI, so two pollers racing on the same comment cannot both win.

Treat 409 as a **success** for the agent's purposes — the follow-up already
exists; you still reply on the comment thread with the returned id.

## What does NOT change

- The pollers (`engine/github.js`, `engine/ado.js`) still set
  `pr.humanFeedback.pendingFix:true` for every actionable human comment.
- `discover-from-prs` still queues `fix` dispatches against the original
  branch; nothing prevents the in-place fix from proceeding.
- Follow-up routing is not auto-pinned to any agent — `routing.md` resolves
  the new WI to the appropriate agent based on `type` + `project`.
- The follow-up is **descriptive** in the completion report: the engine does
  not auto-create the WI from the report. Creation happens when the agent
  calls the API; the `followups` array is the auditable record.

## When to use this carve-out (and when not)

**Use it when:**

- The reviewer explicitly asks to "track separately", "do in a follow-up", or
  "open a new PR".
- The ask is in a different file/system/feature area than the current diff.
- Adding the change to this PR would more than double the review surface.

**Do NOT use it for:**

- Nits, style notes, or things you were already going to do in this PR.
- Asks that block correctness of the current PR (those are the original
  "fix it" path).
- Asks that are invalid, stale, or harmful (those are the "rebut with
  rationale" path — no follow-up).

## Where the wiring lives

| File | Role |
|---|---|
| `playbooks/templates/followup-dispatch.md` | Canonical decision tree + curl shape + dedup contract. Referenced from `fix.md` and `review.md`. |
| `playbooks/fix.md` (`## Follow-up Dispatch`) | Carve-out for fix agents; updated decision tree splitting "fix it / rebut it / spin off". |
| `playbooks/review.md` (`## Follow-up Dispatch`) | Same carve-out for review agents who spot trackable out-of-scope work. |
| `playbooks/shared-rules.md` (`## Minions API access`) | Documents the dashboard URL, the route registry endpoint, and the `X-Minions-Agent` / `X-Minions-Origin-Wi` headers. |
| `dashboard.js` (`validatePrFollowupShape`, `findExistingFollowupForComment`, `extractMinionsAgentHeader`, `extractMinionsOriginWiHeader`, `handleWorkItemsCreate`) | Shape validation, parent_comment_id dedup, header persistence, 200/400/409 responses. |
| `dashboard/js/render-work-items.js` (`prFollowup` block in `wiRow`) | `↪ from PR #N` chip on follow-up WIs. |
| `dashboard/js/render-prs.js` (`_countPrFollowups`) | `+N follow-ups` chip on PR rows. |
| `engine/lifecycle.js` (`processCompletionFollowups`) | Parses the optional `followups` array on completion reports; warns on mismatch. |
| `docs/completion-reports.md` (`PR-comment follow-ups`) | Schema for the optional `followups` array. |

## Migration / rollout

This is purely additive. Existing fix/review playbooks continue to work
without setting `meta.pr_followup`; the validator runs only when the field is
present. Agents that don't know about the carve-out keep doing in-place fixes
and rebuttals exactly as before.

To adopt:

1. Update agent prompts (already done in this PR via `playbooks/fix.md` and
   `playbooks/review.md`).
2. Agents read the contract in `playbooks/templates/followup-dispatch.md`
   when they reach the `## Follow-up Dispatch` section of their playbook.
3. The next time a reviewer asks for an out-of-scope follow-up, the fix
   agent fans out a new WI instead of refusing or broadening the PR.

## Related

- `engine/github.js pollPrHumanComments` (human-comment routing — unchanged)
- `engine.js HUMAN_FEEDBACK fix dispatch` (in-place fix flow — unchanged)
- `playbooks/templates/followup-dispatch.md` (the contract agents read)
- `docs/completion-reports.md` `PR-comment follow-ups` section
