@yemi33/minions 0.1.1998 → 0.1.2000

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.
@@ -1,11 +1,58 @@
1
1
  <section>
2
2
  <h2>QA</h2>
3
- <p class="empty" style="margin:4px 0 12px 0">Canonical home for human-driven and agent-driven validation against running managed instances. Runbook dispatch lands in a follow-up WI.</p>
3
+ <p class="empty" style="margin:4px 0 12px 0">Validation runbooks dispatched against live managed instances. Targets, runbooks, and run history with artifact previews.</p>
4
4
  </section>
5
- <section id="qa-runbooks-section">
6
- <h2>Validation Runbooks <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">human or agent-driven smoke / E2E flows against the live instances above</span></h2>
7
- <div style="border:1px dashed var(--border);border-radius:6px;padding:16px;background:var(--surface2);text-align:center">
8
- <p class="empty" style="margin:0 0 12px 0">Validation runbooks will live here. Coming soon: dispatch a human or agent to validate a running instance.</p>
9
- <button id="qa-new-runbook-btn" disabled title="Coming soon — runbook schema lands in the next WI" style="padding:6px 14px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;cursor:not-allowed;font-size:12px">+ New runbook</button>
5
+ <section id="qa-targets-section" class="qa-section">
6
+ <h2>Targets <span class="qa-section-subtitle">live managed/keep processes available as runbook targets full inventory lives on <a href="/engine" class="qa-engine-link">/engine</a></span></h2>
7
+ <div id="qa-targets-content" class="qa-targets-list">
8
+ <p class="empty">Loading targets…</p>
9
+ </div>
10
+ </section>
11
+ <section id="qa-runbooks-section" class="qa-section">
12
+ <h2>Validation Runbooks <span class="qa-section-subtitle">human or agent-driven smoke / E2E flows against the targets above</span></h2>
13
+ <div class="qa-runbooks-actions">
14
+ <button id="qa-new-runbook-btn" class="qa-btn-primary" onclick="qaShowNewRunbookForm()">+ New runbook</button>
15
+ </div>
16
+ <div id="qa-runbook-form-wrap" class="qa-runbook-form" style="display:none">
17
+ <form id="qa-runbook-form" onsubmit="event.preventDefault();qaSaveRunbook();">
18
+ <div class="qa-form-row">
19
+ <label class="qa-form-label" for="qa-runbook-name">Name</label>
20
+ <input id="qa-runbook-name" type="text" class="qa-form-input" placeholder="login-smoke" required>
21
+ </div>
22
+ <div class="qa-form-row">
23
+ <label class="qa-form-label" for="qa-runbook-target">Target</label>
24
+ <select id="qa-runbook-target" class="qa-form-input" required>
25
+ <option value="">— select a target —</option>
26
+ </select>
27
+ </div>
28
+ <div class="qa-form-row">
29
+ <label class="qa-form-label" for="qa-runbook-steps">Steps</label>
30
+ <textarea id="qa-runbook-steps" class="qa-form-input qa-form-textarea" placeholder="1. Open the app&#10;2. Click login&#10;3. Verify dashboard loads" required></textarea>
31
+ </div>
32
+ <div class="qa-form-row">
33
+ <label class="qa-form-label">Expected artifacts</label>
34
+ <div id="qa-runbook-artifacts" class="qa-artifacts-repeater">
35
+ <div class="qa-artifact-row">
36
+ <input type="text" class="qa-form-input qa-artifact-input" placeholder="screenshot:dashboard.png">
37
+ <button type="button" class="qa-btn-ghost" onclick="qaRemoveArtifactRow(this)">−</button>
38
+ </div>
39
+ </div>
40
+ <button type="button" class="qa-btn-ghost qa-add-artifact-btn" onclick="qaAddArtifactRow()">+ Add artifact</button>
41
+ </div>
42
+ <div class="qa-form-row qa-form-actions">
43
+ <button type="submit" class="qa-btn-primary">Save</button>
44
+ <button type="button" class="qa-btn-ghost" onclick="qaHideNewRunbookForm()">Cancel</button>
45
+ <span id="qa-runbook-form-msg" class="qa-form-msg"></span>
46
+ </div>
47
+ </form>
48
+ </div>
49
+ <div id="qa-runbooks-content" class="qa-runbooks-list">
50
+ <p class="empty">Loading runbooks…</p>
51
+ </div>
52
+ </section>
53
+ <section id="qa-runs-section" class="qa-section">
54
+ <h2>Recent Runs <span class="qa-section-subtitle">latest 50 dispatched validation runs — polled every 5s while this page is active</span></h2>
55
+ <div id="qa-runs-content" class="qa-runs-list">
56
+ <p class="empty">Loading runs…</p>
10
57
  </div>
11
58
  </section>
@@ -736,3 +736,152 @@
736
736
  display: inline-block; max-width: 240px; overflow: hidden;
737
737
  text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
738
738
  }
739
+
740
+ /* QA tab (W-mpeiwz6k0005bf34-d) — targets / runbooks / runs sections.
741
+ * Reuses surface/border/text tokens defined in :root so the QA page
742
+ * blends with the rest of the dashboard. */
743
+ .qa-section { padding: var(--space-7) var(--space-9); }
744
+ .qa-section h2 { display: flex; align-items: baseline; gap: var(--space-4); }
745
+ .qa-section-subtitle {
746
+ font-size: var(--text-sm); color: var(--muted);
747
+ font-weight: 400; text-transform: none; letter-spacing: 0;
748
+ }
749
+ .qa-engine-link { color: var(--blue); text-decoration: none; }
750
+ .qa-engine-link:hover { text-decoration: underline; }
751
+
752
+ /* Targets list — slim row per dedup'd target. */
753
+ .qa-targets-list {
754
+ display: flex; flex-direction: column; gap: var(--space-2);
755
+ border: 1px solid var(--border); border-radius: var(--radius-md);
756
+ background: var(--surface2); padding: var(--space-4);
757
+ }
758
+ .qa-target-row {
759
+ display: flex; align-items: center; gap: var(--space-5);
760
+ padding: var(--space-3) var(--space-4);
761
+ background: var(--surface); border: 1px solid var(--border);
762
+ border-radius: var(--radius-sm); font-size: var(--text-md);
763
+ }
764
+ .qa-target-main { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
765
+ .qa-target-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; color: var(--text); }
766
+ .qa-target-project { color: var(--muted); font-size: var(--text-base); }
767
+ .qa-target-source {
768
+ display: inline-block; margin-left: var(--space-3);
769
+ font-size: var(--text-xs); color: var(--muted);
770
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
771
+ padding: 1px var(--space-3); text-transform: uppercase;
772
+ }
773
+ .qa-target-meta { font-size: var(--text-base); color: var(--muted); white-space: nowrap; }
774
+ .qa-target-link {
775
+ font-size: var(--text-sm); color: var(--blue); text-decoration: none;
776
+ padding: var(--space-1) var(--space-4); border: 1px solid var(--border);
777
+ border-radius: var(--radius-sm); background: var(--surface2);
778
+ }
779
+ .qa-target-link:hover { background: var(--bg); }
780
+ .qa-health-dot { font-size: var(--text-md); }
781
+ .qa-health-ok { color: var(--green); }
782
+ .qa-health-warn { color: var(--yellow); }
783
+ .qa-health-down { color: var(--muted); }
784
+
785
+ /* Runbooks list + inline new-runbook form. */
786
+ .qa-runbooks-actions { margin-bottom: var(--space-5); }
787
+ .qa-runbook-form {
788
+ border: 1px solid var(--border); border-radius: var(--radius-md);
789
+ background: var(--surface2); padding: var(--space-5);
790
+ margin-bottom: var(--space-5);
791
+ }
792
+ .qa-form-row { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-5); }
793
+ .qa-form-label { font-size: var(--text-base); color: var(--muted); font-weight: 600; }
794
+ .qa-form-input {
795
+ background: var(--bg); color: var(--text);
796
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
797
+ padding: var(--space-3) var(--space-4); font-size: var(--text-md);
798
+ font-family: inherit;
799
+ }
800
+ .qa-form-input:focus { border-color: var(--blue); outline: none; }
801
+ .qa-form-textarea { min-height: 88px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
802
+ .qa-artifacts-repeater { display: flex; flex-direction: column; gap: var(--space-2); }
803
+ .qa-artifact-row { display: flex; gap: var(--space-3); align-items: center; }
804
+ .qa-artifact-input { flex: 1; }
805
+ .qa-add-artifact-btn { align-self: flex-start; margin-top: var(--space-2); }
806
+ .qa-form-actions { flex-direction: row; align-items: center; gap: var(--space-4); }
807
+ .qa-form-msg { font-size: var(--text-base); margin-left: var(--space-3); }
808
+ .qa-btn-primary {
809
+ background: var(--blue); color: #fff; border: none;
810
+ border-radius: var(--radius-sm); padding: var(--space-3) var(--space-7);
811
+ font-size: var(--text-md); font-weight: 600; cursor: pointer;
812
+ }
813
+ .qa-btn-primary:hover { opacity: 0.9; }
814
+ .qa-btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
815
+ .qa-btn-ghost {
816
+ background: transparent; color: var(--muted);
817
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
818
+ padding: var(--space-2) var(--space-5); font-size: var(--text-base);
819
+ cursor: pointer;
820
+ }
821
+ .qa-btn-ghost:hover { color: var(--text); background: var(--surface); }
822
+
823
+ .qa-runbooks-list { display: flex; flex-direction: column; gap: var(--space-3); }
824
+ .qa-runbook-row {
825
+ display: flex; align-items: center; gap: var(--space-5);
826
+ padding: var(--space-4) var(--space-5);
827
+ background: var(--surface2); border: 1px solid var(--border);
828
+ border-radius: var(--radius-sm);
829
+ }
830
+ .qa-runbook-main { flex: 1; min-width: 0; }
831
+ .qa-runbook-name { font-weight: 600; color: var(--text); font-size: var(--text-md); }
832
+ .qa-runbook-meta { font-size: var(--text-base); color: var(--muted); margin-top: var(--space-1); }
833
+
834
+ /* Runs list — status badge + inline artifact previews. */
835
+ .qa-runs-list { display: flex; flex-direction: column; gap: var(--space-4); }
836
+ .qa-run-row {
837
+ border: 1px solid var(--border); border-radius: var(--radius-md);
838
+ background: var(--surface2); padding: var(--space-4) var(--space-5);
839
+ }
840
+ .qa-run-head { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; font-size: var(--text-md); }
841
+ .qa-run-status {
842
+ display: inline-block; font-size: var(--text-xs); font-weight: 700;
843
+ padding: 2px var(--space-3); border-radius: var(--radius-sm);
844
+ text-transform: uppercase; letter-spacing: 0.5px;
845
+ background: var(--surface); color: var(--muted); border: 1px solid var(--border);
846
+ }
847
+ .qa-status-passed, .qa-status-success { color: var(--green); border-color: var(--green); }
848
+ .qa-status-failed, .qa-status-error { color: var(--red); border-color: var(--red); }
849
+ .qa-status-running, .qa-status-dispatched { color: var(--blue); border-color: var(--blue); }
850
+ .qa-status-pending { color: var(--yellow); border-color: var(--yellow); }
851
+ .qa-run-name { font-weight: 600; color: var(--text); }
852
+ .qa-run-target { color: var(--muted); font-size: var(--text-base); }
853
+ .qa-run-ts { color: var(--muted); font-size: var(--text-base); margin-left: auto; }
854
+ .qa-run-actions { margin-left: var(--space-5); }
855
+ .qa-run-link { color: var(--blue); text-decoration: none; font-size: var(--text-base); }
856
+ .qa-run-link:hover { text-decoration: underline; }
857
+
858
+ .qa-run-artifacts {
859
+ display: flex; flex-wrap: wrap; gap: var(--space-4);
860
+ margin-top: var(--space-4); padding-top: var(--space-4);
861
+ border-top: 1px solid var(--border);
862
+ }
863
+ .qa-artifact {
864
+ display: flex; flex-direction: column; gap: var(--space-2);
865
+ background: var(--surface); border: 1px solid var(--border);
866
+ border-radius: var(--radius-sm); padding: var(--space-3);
867
+ max-width: 320px;
868
+ }
869
+ .qa-artifact img, .qa-artifact video {
870
+ max-width: 100%; max-height: 200px; border-radius: var(--radius-sm);
871
+ background: var(--bg);
872
+ }
873
+ .qa-artifact-caption {
874
+ font-size: var(--text-xs); color: var(--muted);
875
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
876
+ word-break: break-all;
877
+ }
878
+ .qa-artifact-fulllink { color: var(--blue); text-decoration: none; margin-left: var(--space-3); }
879
+ .qa-artifact-fulllink:hover { text-decoration: underline; }
880
+ .qa-artifact-log-preview {
881
+ background: var(--bg); color: var(--text);
882
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
883
+ font-size: var(--text-xs); padding: var(--space-3);
884
+ border-radius: var(--radius-sm); margin: 0;
885
+ max-height: 160px; overflow: auto; white-space: pre;
886
+ }
887
+ .qa-artifact-log { max-width: 480px; }
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