@yemi33/minions 0.1.1999 → 0.1.2001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,24 @@ let allPrs = [];
4
4
  let prPage = 0;
5
5
  const PR_PER_PAGE = 25;
6
6
 
7
+ function _countPrFollowups(pr) {
8
+ // PR follow-up chip (W-mpej3cox00099466) — counts WIs whose
9
+ // meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
10
+ if (!pr) return 0;
11
+ var wis = (window._lastWorkItems) || [];
12
+ if (!wis.length) return 0;
13
+ var prUrl = pr.url || '';
14
+ var prId = pr.id || '';
15
+ var n = 0;
16
+ for (var i = 0; i < wis.length; i++) {
17
+ var f = wis[i] && wis[i].meta && wis[i].meta.pr_followup;
18
+ if (!f) continue;
19
+ if (prUrl && f.parent_pr_url === prUrl) { n++; continue; }
20
+ if (prId && f.parent_pr_id === prId) { n++; }
21
+ }
22
+ return n;
23
+ }
24
+
7
25
  function prRow(pr) {
8
26
  // Minions review (agent) state — separate from ADO human review
9
27
  const sq = pr.minionsReview || {};
@@ -27,9 +45,13 @@ function prRow(pr) {
27
45
  const pendingReasonHtml = pendingReason
28
46
  ? '<div style="font-size:9px;color:var(--muted);margin-top:2px" title="Pending reason: ' + escapeHtml(pendingReason) + '">' + escapeHtml(pendingReason.replace(/_/g, ' ')) + '</div>'
29
47
  : '';
48
+ var followupCount = _countPrFollowups(pr);
49
+ var followupChip = followupCount > 0
50
+ ? ' <span class="pr-badge draft" style="font-size:8px" title="' + followupCount + ' follow-up work item(s) dispatched from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
51
+ : '';
30
52
  return '<tr>' +
31
53
  '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
32
- '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
54
+ '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + followupChip + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
33
55
  '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
34
56
  '<td><span class="' + branchClass + '"' + (branchError ? ' title="' + escapeHtml(branchError) + '"' : '') + '>' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
35
57
  '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
@@ -40,9 +40,19 @@ function wiRow(item) {
40
40
  : (item.branchStrategy === 'shared-branch' && item.status === 'done')
41
41
  ? '<span style="font-size:9px;color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
42
42
  : '<span style="color:var(--muted)">—</span>';
43
+ // PR follow-up chip (W-mpej3cox00099466) — surfaced when the WI was spun
44
+ // off from a PR comment via meta.pr_followup. Links to the parent PR.
45
+ var prFollowup = item.meta && item.meta.pr_followup;
46
+ var followupChip = '';
47
+ if (prFollowup && prFollowup.parent_pr_url) {
48
+ var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
49
+ var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
50
+ var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
51
+ followupChip = ' <a class="pr-badge draft" style="font-size:8px;text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (prFollowup.parent_comment_author ? ' by ' + escapeHtml(prFollowup.parent_comment_author) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
52
+ }
43
53
  return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\')">' +
44
54
  '<td><span class="pr-id">' + escapeHtml(item.id || '') + '</span></td>' +
45
- '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + '</td>' +
55
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
46
56
  '<td><span style="font-size:10px;color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
47
57
  (item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:8px">fan-out</span>' : '') + '</td>' +
48
58
  '<td>' + typeBadge(item.type) + '</td>' +
package/dashboard.js CHANGED
@@ -281,6 +281,62 @@ function copyWorkItemPrFields(item, input, pr = null) {
281
281
  if (pr?.url || input.prUrl) item.prUrl = pr?.url || input.prUrl;
282
282
  }
283
283
 
284
+ // ─── PR follow-up dispatch (W-mpej3cox00099466) ─────────────────────────────
285
+ // Validates the meta.pr_followup shape and enforces parent_comment_id-keyed
286
+ // dedup across the project's work-items so retries from the fix/review agent
287
+ // (or two pollers racing) don't fan out duplicate follow-up WIs. See
288
+ // playbooks/templates/followup-dispatch.md and docs/pr-comment-followup.md.
289
+
290
+ const PR_FOLLOWUP_REQUIRED_FIELDS = ['parent_pr_url', 'parent_pr_id', 'parent_comment_id', 'parent_comment_author'];
291
+ const PR_FOLLOWUP_MAX_FIELD_LEN = 512;
292
+
293
+ function validatePrFollowupShape(value) {
294
+ if (value === undefined || value === null) return { valid: true, value: null };
295
+ if (typeof value !== 'object' || Array.isArray(value)) {
296
+ return { valid: false, error: 'meta.pr_followup must be an object with parent_pr_url, parent_pr_id, parent_comment_id, parent_comment_author (strings)' };
297
+ }
298
+ const cleaned = {};
299
+ for (const field of PR_FOLLOWUP_REQUIRED_FIELDS) {
300
+ const raw = value[field];
301
+ if (typeof raw !== 'string' || !raw.trim()) {
302
+ return { valid: false, error: `meta.pr_followup.${field} is required and must be a non-empty string` };
303
+ }
304
+ if (raw.length > PR_FOLLOWUP_MAX_FIELD_LEN) {
305
+ return { valid: false, error: `meta.pr_followup.${field} exceeds ${PR_FOLLOWUP_MAX_FIELD_LEN} characters` };
306
+ }
307
+ cleaned[field] = raw.trim();
308
+ }
309
+ return { valid: true, value: cleaned };
310
+ }
311
+
312
+ function findExistingFollowupForComment(items, parentCommentId) {
313
+ if (!Array.isArray(items) || !parentCommentId) return null;
314
+ const needle = String(parentCommentId).trim();
315
+ if (!needle) return null;
316
+ return items.find(item => {
317
+ const seen = item?.meta?.pr_followup?.parent_comment_id;
318
+ return typeof seen === 'string' && seen.trim() === needle;
319
+ }) || null;
320
+ }
321
+
322
+ function extractMinionsAgentHeader(req) {
323
+ const raw = req?.headers?.['x-minions-agent'];
324
+ if (typeof raw !== 'string') return '';
325
+ const trimmed = raw.trim();
326
+ if (!trimmed || trimmed.length > 128) return '';
327
+ // Restrict to safe characters; agent ids are kebab-case or `temp-…`.
328
+ return /^[A-Za-z0-9._:-]+$/.test(trimmed) ? trimmed : '';
329
+ }
330
+
331
+ function extractMinionsOriginWiHeader(req) {
332
+ const raw = req?.headers?.['x-minions-origin-wi'];
333
+ if (typeof raw !== 'string') return '';
334
+ const trimmed = raw.trim();
335
+ if (!trimmed || trimmed.length > 128) return '';
336
+ return /^[A-Za-z0-9._:-]+$/.test(trimmed) ? trimmed : '';
337
+ }
338
+
339
+
284
340
  function normalizeWorkItemDedupText(value) {
285
341
  return String(value == null ? '' : value)
286
342
  .replace(/\r\n/g, '\n')
@@ -4681,9 +4737,25 @@ const server = http.createServer(async (req, res) => {
4681
4737
  // meta.keep_processes documentation in prompts/cc-system.md and
4682
4738
  // the documented `meta?` /api/routes parameter would silently no-op.
4683
4739
  // Shallow copy of plain objects only — arrays/null/primitives are dropped.
4740
+ let validatedFollowup = null;
4684
4741
  if (body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta)) {
4742
+ // PR follow-up validation (W-mpej3cox00099466) — verify meta.pr_followup
4743
+ // shape BEFORE the WI is written so a malformed sidecar can't slip in.
4744
+ const followupCheck = validatePrFollowupShape(body.meta.pr_followup);
4745
+ if (!followupCheck.valid) {
4746
+ return jsonReply(res, 400, { error: followupCheck.error });
4747
+ }
4685
4748
  item.meta = { ...body.meta };
4749
+ if (followupCheck.value) {
4750
+ item.meta.pr_followup = followupCheck.value;
4751
+ validatedFollowup = followupCheck.value;
4752
+ }
4686
4753
  }
4754
+ // PR follow-up traceability headers (W-mpej3cox00099466).
4755
+ const originAgent = extractMinionsAgentHeader(req);
4756
+ const originWi = extractMinionsOriginWiHeader(req);
4757
+ if (originAgent) item._originAgent = originAgent;
4758
+ if (originWi) item._originWi = originWi;
4687
4759
  copyWorkItemPrFields(item, body);
4688
4760
  // W-mpejf0fq000e84d6: pre-compute the canonical branch name at create
4689
4761
  // time so the persisted WI carries `branch` from the moment it hits
@@ -4698,6 +4770,28 @@ const server = http.createServer(async (req, res) => {
4698
4770
  if (derived) item.branch = derived;
4699
4771
  } catch (e) { /* identity resolver best-effort; engine will derive on dispatch */ }
4700
4772
  }
4773
+ // PR follow-up parent_comment_id dedup (W-mpej3cox00099466) — must run
4774
+ // inside the same lock as createWorkItemWithDedup so a racing pair of
4775
+ // pollers/agents can't both win. Standard title/type/project dedup runs
4776
+ // afterward; the follow-up check is stricter (project-wide, all
4777
+ // statuses) and takes precedence when matched.
4778
+ if (validatedFollowup) {
4779
+ let followupConflict = null;
4780
+ mutateWorkItems(wiPath, items => {
4781
+ followupConflict = findExistingFollowupForComment(items, validatedFollowup.parent_comment_id);
4782
+ if (followupConflict) return items;
4783
+ items.push(item);
4784
+ return items;
4785
+ });
4786
+ if (followupConflict) {
4787
+ return jsonReply(res, 409, {
4788
+ error: 'followup_already_dispatched',
4789
+ existingWiId: followupConflict.id,
4790
+ });
4791
+ }
4792
+ recordCcTurnIfPresent(req, { kind: 'work-item', id, title: item.title, project: item.project || null });
4793
+ return jsonReply(res, 200, { ok: true, id });
4794
+ }
4701
4795
  const createResult = createWorkItemWithDedup(wiPath, item);
4702
4796
  if (!createResult.created) {
4703
4797
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
@@ -9112,7 +9206,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9112
9206
  { method: 'DELETE', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Delete a QA runbook by id.', handler: handleQaRunbooksDelete },
9113
9207
 
9114
9208
  // Work items
9115
- { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?', handler: handleWorkItemsCreate },
9209
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
9116
9210
  { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
9117
9211
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
9118
9212
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
@@ -9925,6 +10019,10 @@ module.exports = {
9925
10019
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
9926
10020
  _createWorkItemWithDedup: createWorkItemWithDedup,
9927
10021
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
10022
+ _validatePrFollowupShape: validatePrFollowupShape,
10023
+ _findExistingFollowupForComment: findExistingFollowupForComment,
10024
+ _extractMinionsAgentHeader: extractMinionsAgentHeader,
10025
+ _extractMinionsOriginWiHeader: extractMinionsOriginWiHeader,
9928
10026
  _buildPlanWorkItem: buildPlanWorkItem,
9929
10027
  _buildManualPrdItemPlan: buildManualPrdItemPlan,
9930
10028
  _resolveScheduleProjectValue: resolveScheduleProjectValue,
@@ -83,6 +83,7 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
83
83
  | `files_changed` | string \| array | Comma-separated list (or array) of key files changed. |
84
84
  | `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
85
85
  | `pending` | string | Any remaining work, or `none`. |
86
+ | `followups` | array | Optional. PR-comment follow-up work items the agent dispatched via `POST /api/work-items` with `meta.pr_followup` set. Each entry: `{wi_id, title, reason, parent_comment_id}`. See [PR-comment follow-ups](#pr-comment-follow-ups). |
86
87
 
87
88
  ## `failure_class` enum
88
89
 
@@ -129,6 +130,38 @@ Engine behavior when `noop: true` (`engine/lifecycle.js` `parseCompletionNoop` +
129
130
 
130
131
  Without `noop: true`, an empty PR field will be flagged as a missing-PR-attachment failure and auto-retried up to `ENGINE_DEFAULTS.maxRetries` times.
131
132
 
133
+ ## PR-comment follow-ups
134
+
135
+ Fix and review agents can spin off new Minions work items in response to PR
136
+ comments that request **legitimate but out-of-scope** work. See
137
+ `playbooks/templates/followup-dispatch.md` for the full contract (decision
138
+ tree, exact `POST /api/work-items` shape, and required `meta.pr_followup`
139
+ fields). When the agent does so, it MUST record the dispatched WIs in the
140
+ optional `followups` array on its completion report:
141
+
142
+ ```json
143
+ {
144
+ "status": "success",
145
+ "summary": "Fixed reviewer's blocking finding on PR #2400; dispatched W-mp9abcdef as out-of-scope follow-up.",
146
+ "pr": "https://github.com/yemi33/minions/pull/2400",
147
+ "followups": [
148
+ {
149
+ "wi_id": "W-mp9abcdef",
150
+ "title": "Extract markdown sanitizer into shared module",
151
+ "reason": "Reviewer asked to track as separate WI (out of scope for #2400 — see comment 4567890)",
152
+ "parent_comment_id": "4567890"
153
+ }
154
+ ]
155
+ }
156
+ ```
157
+
158
+ Engine behavior (`engine/lifecycle.js` `processCompletionFollowups`):
159
+
160
+ - Each entry is logged at `info`: `Followup audit (<agent> wi=<parent>): dispatched <wi_id> — <title> (comment=<id>) — <reason>`.
161
+ - If the claimed `wi_id` does not exist in any project's `work-items.json` at parse time, a `warn` is emitted. This catches dispatches that were deduped to a 409 / 200-duplicate response, reverted, or never actually landed.
162
+ - The `followups` array is **descriptive only** — the engine does not auto-create or auto-link these WIs from the report. Creation happens at the moment the agent calls `POST /api/work-items`; the report is just the auditable record.
163
+ - Malformed entries (non-object, missing `wi_id`) are logged and skipped.
164
+
132
165
  ## `retryable` and `needs_rerun`
133
166
 
134
167
  Both default to `false`. Together they let the agent override the engine's default retry policy:
@@ -0,0 +1,206 @@
1
+ # PR-Comment Follow-up Dispatch
2
+
3
+ Status: shipped in **W-mpej3cox00099466** (replaces the marker-based "Option A"
4
+ proposal investigated in CC turn `cct-mpeira1y0001ddff`).
5
+
6
+ ## Why
7
+
8
+ `engine/{github,ado}.js` `pollPrHumanComments` routes every actionable human PR
9
+ comment into `pr.humanFeedback.pendingFix:true`, which `discover-from-prs`
10
+ turns into a `fix` dispatch on the **same** branch. The `fix.md` playbook then
11
+ forbids broadening the PR or creating a replacement branch. The net result was
12
+ a binary outcome for every comment:
13
+
14
+ 1. **In-scope** → fix on the current branch, push, reply on the thread.
15
+ 2. **Out of scope / invalid / harmful** → post a rebuttal, leave the thread
16
+ open, do not change code.
17
+
18
+ There was no third option for the common case: *"Yes, this is a real ask, but
19
+ it doesn't belong in this PR — track it as a separate WI."* Agents either
20
+ silently rebutted those comments (frustrating reviewers) or broadened the PR
21
+ (making it un-reviewable).
22
+
23
+ This is **Option B** from the CC investigation: agent judgment + a documented
24
+ playbook contract. The poller is unchanged; the carve-out is purely additive.
25
+
26
+ ## End-to-end walkthrough
27
+
28
+ 1. **Human leaves a comment on PR #2400** asking for a related-but-distinct
29
+ refactor: *"Can you also extract the markdown sanitizer into a shared module?
30
+ Probably worth a separate PR though."*
31
+ 2. **Engine pollers** (`engine/github.js` `pollPrHumanComments`) flag the
32
+ comment, set `pr.humanFeedback.pendingFix:true`, and `discover-from-prs`
33
+ queues a `fix` dispatch against PR #2400's branch.
34
+ 3. **Fix agent (e.g. lambert)** starts working. After fetching the diff and
35
+ the comment, it classifies each ask using the decision tree in
36
+ `playbooks/templates/followup-dispatch.md`:
37
+ - The in-scope comments → fix on this branch.
38
+ - The "extract sanitizer" comment → **follow-up**.
39
+ 4. **Follow-up dispatch.** The agent calls the dashboard API with the four
40
+ `meta.pr_followup` fields and the two identification headers:
41
+ ```bash
42
+ curl -sS -X POST http://localhost:7331/api/work-items \
43
+ -H 'Content-Type: application/json' \
44
+ -H 'X-Minions-Agent: lambert' \
45
+ -H 'X-Minions-Origin-Wi: W-mp7originalwi' \
46
+ -d '{
47
+ "title": "Extract markdown sanitizer into shared module",
48
+ "type": "implement",
49
+ "priority": "medium",
50
+ "project": "minions",
51
+ "description": "Reviewer asked to track separately from PR #2400…",
52
+ "references": [
53
+ {"url": "https://github.com/yemi33/minions/pull/2400", "label": "Originated from PR #2400 comment"}
54
+ ],
55
+ "meta": {
56
+ "pr_followup": {
57
+ "parent_pr_url": "https://github.com/yemi33/minions/pull/2400",
58
+ "parent_pr_id": "github:yemi33/minions#2400",
59
+ "parent_comment_id": "4567890",
60
+ "parent_comment_author": "alice"
61
+ }
62
+ }
63
+ }'
64
+ ```
65
+ 5. **Server (`dashboard.js handleWorkItemsCreate`)**:
66
+ - Validates `meta.pr_followup` shape (all four fields required, ≤ 512
67
+ chars each). Malformed → `400 { error: "<message>" }`.
68
+ - Checks for an existing follow-up with the same `parent_comment_id` in
69
+ the target project's `work-items.json`. Duplicate → `409 {
70
+ error: "followup_already_dispatched", existingWiId: "W-…" }` (no second WI
71
+ created).
72
+ - Persists the validated `meta.pr_followup`, and stores
73
+ `X-Minions-Agent` / `X-Minions-Origin-Wi` as `_originAgent` /
74
+ `_originWi` on the WI for the dashboard timeline.
75
+ - Returns `200 { ok: true, id: "W-mp9abcdef" }`.
76
+ 6. **Reply on the thread.** The agent posts a comment on the original PR
77
+ thread:
78
+ > Tracked as follow-up: **W-mp9abcdef** — see the Minions dashboard.
79
+ > Continuing with the in-place fix on this PR.
80
+
81
+ …and resolves that sub-thread. The rest of the PR review proceeds.
82
+ 7. **Dashboard chips.**
83
+ - The new WI's row shows `↪ from PR #2400`, linking back to the parent PR.
84
+ (`dashboard/js/render-work-items.js` `prFollowup` block.)
85
+ - The PR row for #2400 shows `+1 follow-up`, computed live from
86
+ `window._lastWorkItems` and rendered in
87
+ `dashboard/js/render-prs.js _countPrFollowups`.
88
+ 8. **Completion report.** When the fix agent finishes its in-place work and
89
+ writes the completion JSON, it includes a `followups` array:
90
+ ```json
91
+ {
92
+ "status": "success",
93
+ "summary": "Fixed reviewer's blocking finding; dispatched W-mp9abcdef as out-of-scope follow-up.",
94
+ "pr": "https://github.com/yemi33/minions/pull/2400",
95
+ "followups": [
96
+ {
97
+ "wi_id": "W-mp9abcdef",
98
+ "title": "Extract markdown sanitizer into shared module",
99
+ "reason": "Out-of-scope ask from reviewer on PR #2400 comment #4567890",
100
+ "parent_comment_id": "4567890"
101
+ }
102
+ ]
103
+ }
104
+ ```
105
+ `engine/lifecycle.js processCompletionFollowups` logs each entry at `info`
106
+ and warns if the claimed `wi_id` is missing from any project's
107
+ `work-items.json` (catches reverted or non-landing dispatches).
108
+
109
+ ## Server contract reference
110
+
111
+ **Headers** (`dashboard.js handleWorkItemsCreate`):
112
+
113
+ | Header | Type | Persisted as | Notes |
114
+ |---|---|---|---|
115
+ | `X-Minions-Agent` | `^[A-Za-z0-9._:-]{1,128}$` | `_originAgent` | Agent id (`lambert`, `temp-…`). Both headers are optional but recommended for traceability. |
116
+ | `X-Minions-Origin-Wi` | `^[A-Za-z0-9._:-]{1,128}$` | `_originWi` | The dispatch's parent WI id. |
117
+
118
+ **`meta.pr_followup` shape** (validated by `validatePrFollowupShape` in
119
+ `dashboard.js`):
120
+
121
+ ```ts
122
+ {
123
+ parent_pr_url: string, // required, ≤ 512 chars
124
+ parent_pr_id: string, // required, ≤ 512 chars (canonical id, e.g. github:owner/repo#123 or PR-123)
125
+ parent_comment_id: string, // required, ≤ 512 chars (host comment/thread id; key for dedup)
126
+ parent_comment_author: string // required, ≤ 512 chars
127
+ }
128
+ ```
129
+
130
+ Any missing/empty field, oversized field, or non-string value → `400` with a
131
+ descriptive message.
132
+
133
+ **Dedup.** A second `POST /api/work-items` with the same
134
+ `meta.pr_followup.parent_comment_id` in the same project's `work-items.json`
135
+ returns `409 { error: "followup_already_dispatched", existingWiId: "<id>" }`.
136
+ This is checked under the same `mutateWorkItems` lock used to insert the new
137
+ WI, so two pollers racing on the same comment cannot both win.
138
+
139
+ Treat 409 as a **success** for the agent's purposes — the follow-up already
140
+ exists; you still reply on the comment thread with the returned id.
141
+
142
+ ## What does NOT change
143
+
144
+ - The pollers (`engine/github.js`, `engine/ado.js`) still set
145
+ `pr.humanFeedback.pendingFix:true` for every actionable human comment.
146
+ - `discover-from-prs` still queues `fix` dispatches against the original
147
+ branch; nothing prevents the in-place fix from proceeding.
148
+ - Follow-up routing is not auto-pinned to any agent — `routing.md` resolves
149
+ the new WI to the appropriate agent based on `type` + `project`.
150
+ - The follow-up is **descriptive** in the completion report: the engine does
151
+ not auto-create the WI from the report. Creation happens when the agent
152
+ calls the API; the `followups` array is the auditable record.
153
+
154
+ ## When to use this carve-out (and when not)
155
+
156
+ **Use it when:**
157
+
158
+ - The reviewer explicitly asks to "track separately", "do in a follow-up", or
159
+ "open a new PR".
160
+ - The ask is in a different file/system/feature area than the current diff.
161
+ - Adding the change to this PR would more than double the review surface.
162
+
163
+ **Do NOT use it for:**
164
+
165
+ - Nits, style notes, or things you were already going to do in this PR.
166
+ - Asks that block correctness of the current PR (those are the original
167
+ "fix it" path).
168
+ - Asks that are invalid, stale, or harmful (those are the "rebut with
169
+ rationale" path — no follow-up).
170
+
171
+ ## Where the wiring lives
172
+
173
+ | File | Role |
174
+ |---|---|
175
+ | `playbooks/templates/followup-dispatch.md` | Canonical decision tree + curl shape + dedup contract. Referenced from `fix.md` and `review.md`. |
176
+ | `playbooks/fix.md` (`## Follow-up Dispatch`) | Carve-out for fix agents; updated decision tree splitting "fix it / rebut it / spin off". |
177
+ | `playbooks/review.md` (`## Follow-up Dispatch`) | Same carve-out for review agents who spot trackable out-of-scope work. |
178
+ | `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. |
179
+ | `dashboard.js` (`validatePrFollowupShape`, `findExistingFollowupForComment`, `extractMinionsAgentHeader`, `extractMinionsOriginWiHeader`, `handleWorkItemsCreate`) | Shape validation, parent_comment_id dedup, header persistence, 200/400/409 responses. |
180
+ | `dashboard/js/render-work-items.js` (`prFollowup` block in `wiRow`) | `↪ from PR #N` chip on follow-up WIs. |
181
+ | `dashboard/js/render-prs.js` (`_countPrFollowups`) | `+N follow-ups` chip on PR rows. |
182
+ | `engine/lifecycle.js` (`processCompletionFollowups`) | Parses the optional `followups` array on completion reports; warns on mismatch. |
183
+ | `docs/completion-reports.md` (`PR-comment follow-ups`) | Schema for the optional `followups` array. |
184
+
185
+ ## Migration / rollout
186
+
187
+ This is purely additive. Existing fix/review playbooks continue to work
188
+ without setting `meta.pr_followup`; the validator runs only when the field is
189
+ present. Agents that don't know about the carve-out keep doing in-place fixes
190
+ and rebuttals exactly as before.
191
+
192
+ To adopt:
193
+
194
+ 1. Update agent prompts (already done in this PR via `playbooks/fix.md` and
195
+ `playbooks/review.md`).
196
+ 2. Agents read the contract in `playbooks/templates/followup-dispatch.md`
197
+ when they reach the `## Follow-up Dispatch` section of their playbook.
198
+ 3. The next time a reviewer asks for an out-of-scope follow-up, the fix
199
+ agent fans out a new WI instead of refusing or broadening the PR.
200
+
201
+ ## Related
202
+
203
+ - `engine/github.js pollPrHumanComments` (human-comment routing — unchanged)
204
+ - `engine.js HUMAN_FEEDBACK fix dispatch` (in-place fix flow — unchanged)
205
+ - `playbooks/templates/followup-dispatch.md` (the contract agents read)
206
+ - `docs/completion-reports.md` `PR-comment follow-ups` section
package/engine/github.js CHANGED
@@ -869,6 +869,13 @@ async function pollPrStatus(config) {
869
869
  ].filter(Boolean).join('\n') || buildFailReason, 80);
870
870
  } else if (allDone && allPassed) {
871
871
  buildStatus = 'passing';
872
+ } else if (allDone) {
873
+ // Terminal-but-not-passing conclusions (cancelled, action_required,
874
+ // stale, etc.) — map to 'none' rather than 'failing'. These are not
875
+ // actionable build failures, so we deliberately do not block merge or
876
+ // dispatch build-fix agents. Previously fell through to 'running'
877
+ // forever (P-bf02-github-cancelled-stuck).
878
+ buildStatus = 'none';
872
879
  } else {
873
880
  buildStatus = 'running';
874
881
  }
@@ -3360,6 +3360,74 @@ function extractDecompositionJson(stdout, runtimeName) {
3360
3360
  return null;
3361
3361
  }
3362
3362
 
3363
+ /**
3364
+ * PR follow-up audit (W-mpej3cox00099466).
3365
+ *
3366
+ * Parses the optional `followups` array on the agent's completion report,
3367
+ * logs each entry at `info` so operators can trace fan-out, and warns when
3368
+ * a claimed `wi_id` doesn't match any record in central or per-project
3369
+ * work-items.json. The mismatch warning catches dispatches that were reverted
3370
+ * (HTTP 409 from /api/work-items dedup), fingerprinted away by the standard
3371
+ * dedup window, or otherwise didn't actually land — situations where the
3372
+ * agent claimed a follow-up exists but the dashboard/engine has no record.
3373
+ *
3374
+ * Returns the array of well-formed followup entries; malformed entries (non-
3375
+ * object, missing `wi_id`) are logged and skipped. Failures inside the
3376
+ * existence check are non-fatal — they degrade to a warn.
3377
+ */
3378
+ function processCompletionFollowups(completion, agentId, dispatchItem, config) {
3379
+ if (!completion || typeof completion !== 'object') return [];
3380
+ const raw = completion.followups;
3381
+ if (raw === undefined || raw === null) return [];
3382
+ if (!Array.isArray(raw)) {
3383
+ log('warn', `Followup audit: completion.followups must be an array; got ${typeof raw}`);
3384
+ return [];
3385
+ }
3386
+ if (raw.length === 0) return [];
3387
+ const wiId = dispatchItem?.meta?.item?.id || 'N/A';
3388
+ let existingIds = null;
3389
+ function loadExistingIds() {
3390
+ if (existingIds !== null) return existingIds;
3391
+ try {
3392
+ existingIds = new Set(queries.getWorkItems(config).map(w => w && w.id).filter(Boolean));
3393
+ } catch (err) {
3394
+ log('warn', `Followup audit: getWorkItems failed (${err.message}); skipping existence checks`);
3395
+ existingIds = new Set();
3396
+ }
3397
+ return existingIds;
3398
+ }
3399
+ const accepted = [];
3400
+ for (const entry of raw) {
3401
+ if (!entry || typeof entry !== 'object') {
3402
+ log('warn', `Followup audit (${agentId || 'unknown'} wi=${wiId}): skipping malformed entry (not an object)`);
3403
+ continue;
3404
+ }
3405
+ const followupWiId = typeof entry.wi_id === 'string' ? entry.wi_id.trim()
3406
+ : (typeof entry.wiId === 'string' ? entry.wiId.trim() : '');
3407
+ if (!followupWiId) {
3408
+ log('warn', `Followup audit (${agentId || 'unknown'} wi=${wiId}): skipping entry without wi_id`);
3409
+ continue;
3410
+ }
3411
+ const title = typeof entry.title === 'string' ? entry.title.trim() : '';
3412
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
3413
+ const parentCommentId = typeof entry.parent_comment_id === 'string'
3414
+ ? entry.parent_comment_id.trim()
3415
+ : (typeof entry.parentCommentId === 'string' ? entry.parentCommentId.trim() : '');
3416
+ log('info', `Followup audit (${agentId || 'unknown'} wi=${wiId}): dispatched ${followupWiId}${title ? ` — ${title}` : ''}${parentCommentId ? ` (comment=${parentCommentId})` : ''}${reason ? ` — ${reason}` : ''}`);
3417
+ const allIds = loadExistingIds();
3418
+ if (allIds.size > 0 && !allIds.has(followupWiId)) {
3419
+ log('warn', `Followup audit (${agentId || 'unknown'} wi=${wiId}): claimed followup ${followupWiId} not found in any work-items.json — dispatch may have been deduped, reverted, or never landed`);
3420
+ }
3421
+ accepted.push({
3422
+ wi_id: followupWiId,
3423
+ title,
3424
+ reason,
3425
+ parent_comment_id: parentCommentId,
3426
+ });
3427
+ }
3428
+ return accepted;
3429
+ }
3430
+
3363
3431
  /**
3364
3432
  * Handle decomposition result — parse sub-items from agent output and create child work items.
3365
3433
  * Called from runPostCompletionHooks when type === 'decompose'.
@@ -3538,6 +3606,17 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3538
3606
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
3539
3607
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
3540
3608
  }
3609
+ // PR follow-up audit (W-mpej3cox00099466) — when the agent declares
3610
+ // `followups: [{wi_id, title, reason, parent_comment_id}]` in its completion
3611
+ // report, log each entry and verify the claimed WI ids actually landed in
3612
+ // some project's work-items.json. A mismatch (claimed wi_id with no matching
3613
+ // record) is logged at warn so operators can spot dispatches that were
3614
+ // reverted, fingerprinted-deduped, or otherwise didn't take.
3615
+ if (structuredCompletion) {
3616
+ try {
3617
+ processCompletionFollowups(structuredCompletion, agentId, dispatchItem, config);
3618
+ } catch (err) { log('warn', `Followup audit: ${err.message}`); }
3619
+ }
3541
3620
  // F5 (W-mpeklod3000we69c): if the agent flagged an injection attempt in the
3542
3621
  // structured completion, force the dispatch into a non-retryable failure
3543
3622
  // with `FAILURE_CLASS.INJECTION_FLAGGED`. Inbox note + WI stamp are written
@@ -4303,4 +4382,5 @@ module.exports = {
4303
4382
  isPrAttachmentRequired,
4304
4383
  extractDecompositionJson,
4305
4384
  handleDecompositionResult,
4385
+ processCompletionFollowups,
4306
4386
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1999",
3
+ "version": "0.1.2001",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
package/playbooks/fix.md CHANGED
@@ -35,6 +35,7 @@ Before editing, split the feedback into:
35
35
 
36
36
  - **Blocking findings to fix:** verified correctness, safety, build/test failure, missing requested behavior, broken compatibility, or approval-blocking comments whose claim is valid on the current branch.
37
37
  - **Findings to answer with rationale:** comments where the current approach is intentionally correct, the reviewer misunderstood the code, the issue is stale/already addressed, or the requested change would broaden the PR beyond its purpose.
38
+ - **Out-of-scope but legitimate follow-up requests:** asks for genuinely useful work that does **not** belong in this PR (a separate bug, a related-but-distinct feature, a refactor the reviewer explicitly said to "track separately"). Spin off a new Minions work item — see `## Follow-up Dispatch` below — and reply on the originating thread with the new WI id. This is a third option alongside "fix it" and "rebut it"; it does **not** loosen the don't-broaden-the-PR rule for in-PR work.
38
39
  - **Non-blocking suggestions:** style, optional refactors, extra docs, or enhancements that are not required for approval. Do not implement these unless they are necessary to resolve a verified blocking issue.
39
40
 
40
41
  ## Health Check
@@ -55,7 +56,7 @@ Handle this like the PR author responding directly from a CLI:
55
56
  - Fix it if the feedback is valid and improves correctness, safety, maintainability, or test coverage.
56
57
  - If the current approach is intentionally correct, stale, already fixed, out of scope, or the requested change would be harmful, reply with specific rationale instead of silently changing code or ignoring the thread.
57
58
  - Handle merge conflicts when needed, preserving the PR's intended changes while keeping the branch reviewable.
58
- - Do not add unrelated cleanups or broaden the PR beyond the review feedback unless that is necessary to make the fix correct.
59
+ - Do not add unrelated cleanups or broaden the PR beyond the review feedback unless that is necessary to make the fix correct. For legitimate-but-out-of-scope asks, see `## Follow-up Dispatch` below — spin off a separate WI instead of broadening this PR.
59
60
 
60
61
  ## Validation
61
62
 
@@ -94,6 +95,20 @@ After pushing, respond to each review comment/thread:
94
95
  - **GitHub**: Reply to each review comment, resolve conversations you've fixed
95
96
  - **ADO**: Use `az` CLI first to reply to each thread and update status when supported; use ADO MCP only as a fallback when `az` is unavailable or insufficient. Set status to `fixed` or `closed` for fixes; leave `active` for rationale replies
96
97
 
98
+ ## Follow-up Dispatch
99
+
100
+ When a comment asks for legitimate work that is **clearly out of scope** for this PR (separate bug, different feature, refactor the reviewer explicitly asked to "track separately"), spin off a new Minions work item via the dashboard API instead of broadening the PR or silently declining. Read the full contract — decision tree, exact curl shape, required `meta.pr_followup` fields, dedup behavior, and the mandatory comment-thread reply — at:
101
+
102
+ `{{team_root}}/playbooks/templates/followup-dispatch.md`
103
+
104
+ Key rules (full detail in the template):
105
+
106
+ - The follow-up is **additive**. You still fix the in-scope feedback in this PR and post the normal fix comment.
107
+ - Reply on the originating comment thread with the new WI id (e.g. `Tracked as follow-up: W-…`) and resolve that sub-thread.
108
+ - Always include `meta.pr_followup.parent_comment_id` — without it the server cannot dedup retries.
109
+ - Do not pick `agent`; let `routing.md` decide.
110
+ - Record every follow-up you dispatched in the `followups` array of your completion JSON.
111
+
97
112
  ## When to Stop
98
113
 
99
114
  Your task is complete when each review finding has either been fixed or answered with rationale, the validation story is truthful and sufficient for review, the fix is pushed if code changed, the PR is commented, and addressed threads are resolved. Do NOT continue into unrelated improvements.
@@ -98,6 +98,20 @@ After running the command, confirm it succeeded (check the command output for er
98
98
  If you encounter merge conflicts (e.g., the PR shows conflicts):
99
99
  1. Note the conflict in your review comment. Do NOT attempt to resolve — flag it for the author.
100
100
 
101
+ ## Follow-up Dispatch
102
+
103
+ If, while reviewing, you spot substantial work that should be tracked separately rather than blocking this PR (a related-but-distinct bug, a feature gap that didn't ship in this diff, a refactor that's worth doing later), you may dispatch a follow-up Minions work item instead of pinning it as a blocking finding. Read the full contract — decision tree, exact curl shape, required `meta.pr_followup` fields, dedup behavior, and the mandatory comment-thread reply — at:
104
+
105
+ `{{team_root}}/playbooks/templates/followup-dispatch.md`
106
+
107
+ Key rules (full detail in the template):
108
+
109
+ - Use this for **legitimate out-of-scope follow-ups**, not for findings that should block the PR.
110
+ - The review verdict (`APPROVE` / `REQUEST_CHANGES`) is unchanged by the follow-up — pick the verdict based on the PR's own contents.
111
+ - Reply on the originating thread with the new WI id and resolve that sub-thread (do not leave it open).
112
+ - Always include `meta.pr_followup.parent_comment_id` so the server can dedup if you (or another agent) retries.
113
+ - Record every follow-up you dispatched in the `followups` array of your completion JSON.
114
+
101
115
  ## Do not run git checkout on the main working tree. Use `git diff` and `git show` only.
102
116
 
103
117
  ## When to Stop
@@ -92,6 +92,27 @@ The engine provides a completion report path in the prompt and in `MINIONS_COMPL
92
92
 
93
93
  For the canonical schema — every field, the `failure_class` enum, `noop:true` semantics, `retryable` / `needs_rerun` shape, and the artifacts array — see `docs/completion-reports.md`. The JSON report is the primary signal; fenced `completion` blocks in stdout are accepted only as a fallback.
94
94
 
95
+ ## Minions API access
96
+
97
+ The Minions dashboard runs at `http://localhost:7331` whenever the engine is up. Agents may call its HTTP endpoints when (and only when) the playbook explicitly authorizes it for a particular task — most dispatches do not need any API access, and uninvited writes to engine-managed state are still prohibited (see "Do NOT write to `agents/*/status.json`" above).
98
+
99
+ When a playbook authorizes an API call (e.g. follow-up work-item dispatch from `playbooks/templates/followup-dispatch.md`), use the standard `curl` shape:
100
+
101
+ ```bash
102
+ curl -sS -X POST http://localhost:7331/api/<route> \
103
+ -H 'Content-Type: application/json' \
104
+ -H 'X-Minions-Agent: <your-agent-id>' \
105
+ -H 'X-Minions-Origin-Wi: <current-work-item-id>' \
106
+ -d '<json-payload>'
107
+ ```
108
+
109
+ Conventions:
110
+
111
+ - The full live route surface is available at `GET http://localhost:7331/api/routes` (the route registry includes method, path, and a one-line description for every handler).
112
+ - **Identification headers (for traceability, not auth).** Set `X-Minions-Agent` to your agent id and `X-Minions-Origin-Wi` to the work-item id you were dispatched against. Endpoints that persist these (e.g. `POST /api/work-items`) store them as `_originAgent` / `_originWi` for the dashboard timeline. Agents do **not** have a `X-CC-Turn-Id` — that header is reserved for Command Center turns.
113
+ - The dashboard binds to `127.0.0.1` only; no auth token is required for localhost calls.
114
+ - Treat HTTP 4xx as a contract violation: surface the response body in your completion summary instead of retrying blindly.
115
+
95
116
  ## Long-Running Commands
96
117
 
97
118
  Builds, dependency installs, tests, and local servers can be quiet for long periods. Run the repo's normal CLI commands and let them finish; do not add artificial progress output, heartbeat loops, or command-specific workarounds just to keep Minions active.
@@ -0,0 +1,157 @@
1
+ # Follow-up Dispatch (PR-comment → new WI)
2
+
3
+ When a human leaves a PR comment that asks for work that is **legitimate but
4
+ outside the scope of the current PR** (a different bug, a related-but-separate
5
+ feature, a follow-up cleanup that doesn't belong in the current diff), do **not**
6
+ broaden the PR and do **not** silently rebut the comment. Spin off a new Minions
7
+ work item via the dashboard API and reply on the originating comment thread with
8
+ a link to it.
9
+
10
+ This carve-out only enables creating a **separate** work item. It does not
11
+ loosen the rule that the *current* PR should stay focused on the original
12
+ review feedback.
13
+
14
+ ## Decision tree
15
+
16
+ For each human comment that asks for additional work, classify it before
17
+ editing:
18
+
19
+ 1. **Fix in this PR** — the request is in scope (regression of the current
20
+ diff, a flaw in the new code, missing requested behavior the PR was already
21
+ supposed to cover). Fix it on this branch, push, reply on the thread.
22
+ 2. **Answer with rationale** — the request is invalid, stale, already
23
+ addressed, or would harm the code. Post an evidence-backed rebuttal on the
24
+ thread; do **not** create a follow-up WI for it.
25
+ 3. **Follow-up WI** — the request is legitimate but clearly out of scope for
26
+ the current PR (different file/system, different bug, distinct feature, a
27
+ refactor the reviewer explicitly asked you to "track separately" or "do in a
28
+ follow-up"). Create a new work item via the API (see below) and reply on the
29
+ originating thread with the new WI id.
30
+
31
+ If you are unsure between #1 and #3, prefer **#3 (follow-up)** so the current
32
+ PR stays small and reviewable. If you are unsure between #2 and #3, prefer #2
33
+ and explain your reasoning — don't manufacture work.
34
+
35
+ ## The API call
36
+
37
+ The Minions dashboard runs on `http://localhost:7331`. Create a follow-up work
38
+ item with `POST /api/work-items`:
39
+
40
+ ```bash
41
+ curl -sS -X POST http://localhost:7331/api/work-items \
42
+ -H 'Content-Type: application/json' \
43
+ -H 'X-Minions-Agent: <your-agent-id>' \
44
+ -H 'X-Minions-Origin-Wi: <current-wi-id>' \
45
+ -d '{
46
+ "title": "Short imperative summary of the follow-up",
47
+ "type": "implement",
48
+ "priority": "medium",
49
+ "project": "<project-name>",
50
+ "description": "Why this is a follow-up, what should change, acceptance.\n\nOriginated from <reviewer> on PR #<N> (<url>): \"<verbatim quote of the ask>\"",
51
+ "references": [
52
+ {"url": "<originating-pr-url>", "label": "Originated from PR #<N> comment"}
53
+ ],
54
+ "meta": {
55
+ "pr_followup": {
56
+ "parent_pr_url": "<originating-pr-url>",
57
+ "parent_pr_id": "<canonical PR id, e.g. github:owner/repo#123 or PR-123>",
58
+ "parent_comment_id": "<exact comment/thread id from the host API>",
59
+ "parent_comment_author": "<reviewer login>"
60
+ }
61
+ }
62
+ }'
63
+ ```
64
+
65
+ Required fields and their conventions:
66
+
67
+ - **`title`** — short imperative ("Add foo to bar", "Fix off-by-one in baz").
68
+ - **`type`** — pick from the routing types (`implement`, `fix`, `test`,
69
+ `docs`, `ask`, `explore`, …). For new feature/fix work, `implement` is
70
+ almost always correct; use `fix` only when the follow-up is about an existing
71
+ PR's bug. Do **not** set `agent` — let `routing.md` pick.
72
+ - **`project`** — name of the same project the originating PR belongs to.
73
+ Required when more than one project is configured.
74
+ - **`description`** — explain *why* this is a follow-up and quote the exact
75
+ ask. Future readers should understand the scope without re-reading the PR
76
+ thread.
77
+ - **`references`** — at least one entry pointing back at the originating PR.
78
+ - **`meta.pr_followup`** — the four-field shape above. Used by the dashboard
79
+ for traceability chips and by the server for dedup (a second request with the
80
+ same `parent_comment_id` returns the existing WI id rather than creating a
81
+ duplicate).
82
+
83
+ Headers:
84
+
85
+ - **`X-Minions-Agent`** — your agent id (e.g. `lambert`, `ripley`,
86
+ `temp-mp3dop1v…`). Persisted as `_originAgent` on the new WI.
87
+ - **`X-Minions-Origin-Wi`** — the id of the work item you were dispatched
88
+ against (the one whose PR you are responding to). Persisted as `_originWi`.
89
+
90
+ Both headers are optional but strongly recommended — they let the dashboard
91
+ correlate the follow-up to the dispatch that created it.
92
+
93
+ ## Server responses
94
+
95
+ | Status | Body | Meaning |
96
+ |---|---|---|
97
+ | 200 | `{ ok: true, id: "W-…" }` | New follow-up WI created. |
98
+ | 200 | `{ ok: true, id: "W-…", duplicate: true, duplicateOf: "W-…" }` | The standard title/type/project dedup window matched an in-flight WI. Same WI id is returned; reply on the comment thread with that id. |
99
+ | 409 | `{ error: "followup_already_dispatched", existingWiId: "W-…" }` | Another agent (or your previous attempt) already dispatched a follow-up for this exact `parent_comment_id`. Reply on the comment thread linking the existing WI; do not retry. |
100
+ | 400 | `{ error: "<message>" }` | Validation failed. Inspect the message: the most common cause is a malformed `meta.pr_followup` shape. Fix and retry. |
101
+
102
+ Treat 409 as a **success** for your purposes — the follow-up exists, you just
103
+ weren't the one who created it. Still post the reply on the comment thread with
104
+ the returned `existingWiId`.
105
+
106
+ ## Mandatory post-dispatch step
107
+
108
+ After the API call returns a WI id (whether `id` from 200 or `existingWiId`
109
+ from 409), reply on the originating comment thread with a link back:
110
+
111
+ > Tracked as follow-up: **W-…** — see the Minions dashboard. Continuing with
112
+ > the in-place fix on this PR.
113
+
114
+ Then **resolve that sub-thread** (GitHub: "Resolve conversation"; ADO: set
115
+ status to `closed`) even if other review threads on the PR are still open. The
116
+ follow-up WI is the durable artifact; the comment thread should not be left
117
+ hanging.
118
+
119
+ ## Record the follow-up in your completion report
120
+
121
+ Add a `followups` array to your JSON completion report so the engine has an
122
+ auditable record of what you fanned out to:
123
+
124
+ ```json
125
+ {
126
+ "status": "success",
127
+ "summary": "…",
128
+ "followups": [
129
+ {
130
+ "wi_id": "W-mp7abc…",
131
+ "title": "Add foo to bar",
132
+ "reason": "Out-of-scope ask from reviewer on PR #123 comment #4567890",
133
+ "parent_comment_id": "4567890"
134
+ }
135
+ ]
136
+ }
137
+ ```
138
+
139
+ The engine logs each entry at `info` and warns if the array references a WI id
140
+ that doesn't exist in any project's `work-items.json` (mismatch between what
141
+ you claim and what the API actually created — usually a sign that the dispatch
142
+ was reverted or the response was misread).
143
+
144
+ ## Do NOT
145
+
146
+ - Do not pick `agent` for the follow-up; the routing layer assigns one based
147
+ on `type` + `project`.
148
+ - Do not set `X-CC-Turn-Id` (that header is for Command Center turns; agents
149
+ don't have a turn id). Use `X-Minions-Agent`/`X-Minions-Origin-Wi` instead.
150
+ - Do not skip the comment-thread reply — without it, the human has no signal
151
+ that their ask was tracked and the engine will keep re-routing the comment
152
+ as `pendingFix`.
153
+ - Do not spin off follow-ups for nits, style notes, or things you are about
154
+ to fix in this PR anyway. Reserve this for asks that genuinely belong in a
155
+ separate WI.
156
+ - Do not omit `meta.pr_followup.parent_comment_id`; without it the server
157
+ cannot dedup and you will create duplicates on every retry.