@yemi33/minions 0.1.813 → 0.1.815

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.
@@ -0,0 +1,300 @@
1
+ # Teams Integration Setup
2
+
3
+ End-to-end guide for connecting Minions to Microsoft Teams via Azure Bot Framework. After completing these steps, messages sent to a Teams channel will be routed to the Minions Command Center, and agent events (completions, PR merges, plan updates) will be posted back to Teams.
4
+
5
+ **Prerequisites:**
6
+
7
+ - An Azure subscription (free tier works)
8
+ - Azure CLI installed (`az --version`) or access to the [Azure Portal](https://portal.azure.com)
9
+ - Dev Tunnel CLI installed (`devtunnel --version`) — see [Step 5](#step-5-set-up-dev-tunnel) for installation
10
+ - Minions dashboard running locally (`minions dash`)
11
+ - Admin or owner permissions on the target Teams team (for side-loading)
12
+
13
+ ## Step 1: Create an Azure Bot Resource
14
+
15
+ 1. Open the [Azure Portal](https://portal.azure.com) and search for **Azure Bot** in the top search bar.
16
+ 2. Click **Create**.
17
+ 3. Fill in the basics:
18
+ - **Bot handle**: A unique name (e.g., `minions-bot`). This is the internal identifier — it doesn't appear in Teams.
19
+ - **Subscription**: Select your Azure subscription.
20
+ - **Resource group**: Create a new one (e.g., `rg-minions`) or use an existing one.
21
+ - **Data residency**: Choose **Global** unless you have regional compliance requirements.
22
+ - **Pricing tier**: Select **F0 (Free)** for development.
23
+ 4. Under **Microsoft App ID**, select **Single Tenant**.
24
+ - Choose **Create new Microsoft App ID**.
25
+ - This creates a new Entra ID (Azure AD) app registration for the bot.
26
+
27
+ > **Why Single Tenant?** Single-tenant bots only accept tokens from your Azure AD tenant, which is more secure for an internal tool like Minions. Multi-tenant is needed only if the bot must work across organizations.
28
+
29
+ 5. Click **Review + create**, then **Create**.
30
+ 6. Wait for deployment to complete (usually under 1 minute), then click **Go to resource**.
31
+
32
+ ## Step 2: Configure the Teams Channel
33
+
34
+ 1. In your Azure Bot resource, click **Channels** in the left sidebar.
35
+ 2. Click the **Microsoft Teams** icon in the available channels list.
36
+ 3. Accept the Terms of Service.
37
+ 4. Leave the channel settings at defaults:
38
+ - **Messaging** tab: Enabled (default).
39
+ - **Calling** and **Group Chat** tabs: Leave disabled unless needed.
40
+ 5. Click **Apply**.
41
+
42
+ The Teams channel should now appear as **Running** in the channels list.
43
+
44
+ ## Step 3: Obtain App ID and App Password
45
+
46
+ You need two credentials: the **App ID** (also called Microsoft App ID or Client ID) and the **App Password** (a client secret).
47
+
48
+ ### Get the App ID
49
+
50
+ 1. In your Azure Bot resource, click **Configuration** in the left sidebar.
51
+ 2. Copy the **Microsoft App ID** value. This is a GUID like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`.
52
+ 3. Save it — you'll add it to your Minions config.
53
+
54
+ ### Create an App Password (Client Secret)
55
+
56
+ 1. On the same **Configuration** page, click **Manage Password** next to the Microsoft App ID. This opens the Entra ID app registration.
57
+ 2. In the app registration, click **Certificates & secrets** in the left sidebar.
58
+ 3. Click **New client secret**.
59
+ 4. Enter a description (e.g., `minions-bot-secret`) and choose an expiry (e.g., 24 months).
60
+ 5. Click **Add**.
61
+ 6. **Immediately copy the secret Value** (not the Secret ID). It is shown only once — if you navigate away, you cannot retrieve it again.
62
+
63
+ ### Add Credentials to Minions Config
64
+
65
+ Add a `teams` section to your `config.json`:
66
+
67
+ ```json
68
+ {
69
+ "projects": [ ... ],
70
+ "agents": { ... },
71
+ "engine": { ... },
72
+ "teams": {
73
+ "enabled": true,
74
+ "appId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
75
+ "appPassword": "your-client-secret-value-here",
76
+ "notifyEvents": ["pr-merged", "agent-completed", "plan-completed", "agent-failed"],
77
+ "ccMirror": true,
78
+ "inboxPollInterval": 15000
79
+ }
80
+ }
81
+ ```
82
+
83
+ | Field | Description |
84
+ |-------|-------------|
85
+ | `enabled` | Master switch — `true` to activate Teams integration |
86
+ | `appId` | Microsoft App ID from the Azure Bot Configuration page |
87
+ | `appPassword` | Client secret value from Entra ID Certificates & secrets |
88
+ | `notifyEvents` | Which events trigger Teams notifications (see below) |
89
+ | `ccMirror` | Mirror CC dashboard responses to Teams (`true`/`false`) |
90
+ | `inboxPollInterval` | How often to check for new Teams messages, in ms (default: 15000) |
91
+
92
+ **Available notification events:** `pr-merged`, `agent-completed`, `plan-completed`, `agent-failed`, `pr-abandoned`, `pr-approved`, `pr-build-failed`, `plan-approved`, `plan-rejected`, `verify-created`
93
+
94
+ > **Security note:** `config.json` is gitignored by default and should never be committed. For shared machines, consider setting the app password via an environment variable and reading it in your config setup.
95
+
96
+ ## Step 4: Set the Messaging Endpoint
97
+
98
+ The messaging endpoint is the URL that Azure Bot Framework sends incoming messages to. It must point to your Minions dashboard's `/api/bot` route.
99
+
100
+ 1. In your Azure Bot resource, click **Configuration** in the left sidebar.
101
+ 2. Set the **Messaging endpoint** to:
102
+ ```
103
+ https://<your-tunnel-url>/api/bot
104
+ ```
105
+ For local development, this will be your Dev Tunnel URL (see [Step 5](#step-5-set-up-dev-tunnel)).
106
+
107
+ For example: `https://abc123.devtunnels.ms/api/bot`
108
+
109
+ 3. Click **Apply** to save.
110
+
111
+ > The endpoint must be **HTTPS**. Bot Framework will not send messages to plain HTTP URLs. Dev Tunnels and production hosting (Azure App Service, etc.) both provide HTTPS by default.
112
+
113
+ > Changes to the messaging endpoint take effect immediately — no need to reinstall the bot in Teams.
114
+
115
+ ## Step 5: Set Up Dev Tunnel
116
+
117
+ [Dev Tunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/overview) create a public HTTPS URL that forwards traffic to your local machine. This lets Azure Bot Framework reach your locally running dashboard.
118
+
119
+ ### Install Dev Tunnel CLI
120
+
121
+ If you don't have it installed:
122
+
123
+ ```bash
124
+ # Windows (winget)
125
+ winget install Microsoft.devtunnel
126
+
127
+ # macOS (Homebrew)
128
+ brew install --cask devtunnel
129
+
130
+ # Linux (curl)
131
+ curl -sL https://aka.ms/DevTunnelCliInstall | bash
132
+ ```
133
+
134
+ ### Authenticate
135
+
136
+ ```bash
137
+ devtunnel user login
138
+ ```
139
+
140
+ This opens a browser for Microsoft account authentication. Sign in with the same account that has access to your Azure subscription.
141
+
142
+ ### Start the Tunnel
143
+
144
+ ```bash
145
+ devtunnel host -p 7331 --allow-anonymous
146
+ ```
147
+
148
+ - `-p 7331` forwards to the Minions dashboard port.
149
+ - `--allow-anonymous` allows Bot Framework to reach the endpoint without additional auth (the Bot Framework adapter handles its own authentication via the App ID and Password).
150
+
151
+ You'll see output like:
152
+
153
+ ```
154
+ Connect via browser: https://abc123.devtunnels.ms
155
+ Inspect network activity: https://abc123-7331.devtunnels.ms
156
+
157
+ Hosting port: 7331
158
+ Connect via browser: https://abc123-7331.devtunnels.ms
159
+ ```
160
+
161
+ ### Copy the Forwarding URL to Azure Bot
162
+
163
+ 1. Copy the **port-specific** tunnel URL from the output (e.g., `https://abc123-7331.devtunnels.ms`).
164
+ 2. Go back to your Azure Bot resource > **Configuration**.
165
+ 3. Set the **Messaging endpoint** to:
166
+ ```
167
+ https://abc123-7331.devtunnels.ms/api/bot
168
+ ```
169
+ 4. Click **Apply**.
170
+
171
+ ### Persistent Tunnel (Optional)
172
+
173
+ By default, the tunnel URL changes each time you restart `devtunnel host`. To get a persistent URL:
174
+
175
+ ```bash
176
+ # Create a named tunnel
177
+ devtunnel create minions-tunnel
178
+
179
+ # Add the port
180
+ devtunnel port create minions-tunnel -p 7331
181
+
182
+ # Host with anonymous access
183
+ devtunnel host minions-tunnel --allow-anonymous
184
+ ```
185
+
186
+ The named tunnel keeps the same URL across restarts — you won't need to update the Azure Bot messaging endpoint each time.
187
+
188
+ ### Verify Connectivity
189
+
190
+ With the tunnel running and the dashboard started (`minions dash`), open the tunnel URL in a browser:
191
+
192
+ ```
193
+ https://abc123-7331.devtunnels.ms/api/routes
194
+ ```
195
+
196
+ You should see the Minions API route list as JSON. If you get a connection error, check that:
197
+ - The dashboard is running (`minions dash`)
198
+ - The tunnel is active (`devtunnel host` is still running)
199
+ - The port number matches (7331)
200
+
201
+ ## Step 6: Install the Bot in Teams
202
+
203
+ There are two ways to add the bot to a Teams channel: via App Studio / Teams Developer Portal (recommended for development) or via the Teams Admin Center (for org-wide deployment).
204
+
205
+ ### Option A: Teams Developer Portal (Recommended for Development)
206
+
207
+ 1. Open [Teams Developer Portal](https://dev.teams.microsoft.com/apps) in a browser.
208
+ 2. Click **New app**.
209
+ 3. Fill in the app details:
210
+ - **Name**: `Minions Bot` (or any display name)
211
+ - **Short description**: `Minions agent orchestration`
212
+ - **Developer name**: Your name or team name
213
+ - **Website**: `https://github.com/yemi33/minions` (or any URL)
214
+ - **Privacy policy** and **Terms of use**: Can be any URL for development
215
+ 4. Under **App features**, click **Bot**.
216
+ 5. Select **Enter a bot ID manually** and paste the **App ID** from [Step 3](#step-3-obtain-app-id-and-app-password).
217
+ 6. Check the scopes:
218
+ - **Team** — enables the bot in team channels
219
+ - **Group chat** — optional, enables the bot in group chats
220
+ 7. Click **Save**.
221
+ 8. Click **Publish** > **Publish to your org** (or **Download app package** to side-load manually).
222
+
223
+ ### Side-Load the App Package
224
+
225
+ If your organization doesn't allow direct publishing:
226
+
227
+ 1. In the Developer Portal, click **Download app package** to get a `.zip` file.
228
+ 2. Open Microsoft Teams.
229
+ 3. Click **Apps** in the left sidebar.
230
+ 4. Click **Manage your apps** > **Upload an app**.
231
+ 5. Select **Upload a custom app** (or **Upload a custom app for [org name]** if you have admin rights).
232
+ 6. Choose the downloaded `.zip` file.
233
+ 7. In the app details dialog, click **Add to a team**.
234
+ 8. Select the team and channel where you want the bot.
235
+ 9. Click **Set up a bot**.
236
+
237
+ ### Option B: Teams Admin Center (Org-Wide Deployment)
238
+
239
+ For deploying to all users or specific groups:
240
+
241
+ 1. Open [Teams Admin Center](https://admin.teams.microsoft.com/).
242
+ 2. Go to **Teams apps** > **Manage apps**.
243
+ 3. Click **Upload new app** and upload the app package `.zip`.
244
+ 4. Go to **Teams apps** > **Setup policies**.
245
+ 5. Edit the relevant policy and add the Minions Bot to the **Installed apps** list.
246
+
247
+ ### Verify the Bot Works
248
+
249
+ 1. Open the Teams channel where you installed the bot.
250
+ 2. Send a message that @mentions the bot:
251
+ ```
252
+ @Minions Bot hello
253
+ ```
254
+ 3. If everything is configured correctly, the message will:
255
+ - Reach your dashboard via the Dev Tunnel
256
+ - Be written to `engine/teams-inbox.json`
257
+ - Be processed by the CC on the next poll cycle
258
+ - The CC response will appear as a thread reply in Teams
259
+
260
+ If you don't see a response, check:
261
+ - **Dashboard logs**: Look for incoming `/api/bot` requests
262
+ - **Dev Tunnel**: Ensure it's still running and the URL matches the Azure Bot messaging endpoint
263
+ - **Azure Bot**: Test the bot connection in the Azure Portal via **Test in Web Chat** (Configuration page)
264
+ - **Config**: Verify `config.json` has `teams.enabled: true` and correct `appId`/`appPassword`
265
+
266
+ ## Troubleshooting
267
+
268
+ ### "Unauthorized" or 401 Errors
269
+
270
+ The App ID or App Password in `config.json` doesn't match what's registered in Azure. Double-check:
271
+ - The App ID matches the Azure Bot's **Microsoft App ID** on the Configuration page.
272
+ - The App Password is the client secret **Value** (not the Secret ID) from Entra ID.
273
+ - The client secret hasn't expired.
274
+
275
+ ### "Endpoint not reachable" in Azure Bot Test
276
+
277
+ - Verify the Dev Tunnel is running: `devtunnel host -p 7331 --allow-anonymous`
278
+ - Verify the messaging endpoint URL ends with `/api/bot`
279
+ - Verify the dashboard is running: `minions dash`
280
+ - Try opening the tunnel URL in a browser to confirm it's accessible
281
+
282
+ ### Bot Doesn't Respond in Teams
283
+
284
+ - Check `engine/teams-inbox.json` — if messages appear there, the webhook is working but the CC processing loop may not be running.
285
+ - Check that the engine is running: `minions start`
286
+ - Check the engine logs for Teams-related errors.
287
+ - Ensure `config.teams.enabled` is `true`.
288
+
289
+ ### "App not found" When Side-Loading
290
+
291
+ - Your Teams admin may have disabled custom app side-loading. Contact your Teams admin to enable **Upload custom apps** in the Teams Admin Center > Setup policies.
292
+ - Alternatively, ask an admin to upload the app via the Admin Center (Option B above).
293
+
294
+ ## What's Next
295
+
296
+ Once the bot is responding to messages in Teams:
297
+
298
+ - **Agent notifications** will appear in the Teams channel when agents complete work, PRs are merged, or plans are finished.
299
+ - **CC mirror** mode (enabled by default) posts Command Center responses from the dashboard to Teams so the whole team sees orchestration activity.
300
+ - For production deployment (replacing Dev Tunnel with a stable public URL), see [docs/teams-production.md](teams-production.md).
package/engine/ado.js CHANGED
@@ -441,6 +441,13 @@ async function pollPrStatus(config) {
441
441
  pr.buildErrorLog = logParts.join('\n\n');
442
442
  log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
443
443
  }
444
+
445
+ // Teams notification for build failure — non-blocking
446
+ try {
447
+ const teams = require('./teams');
448
+ const prFilePath = shared.projectPrPath(project);
449
+ teams.teamsNotifyPrEvent(pr, 'build-failed', project, prFilePath).catch(() => {});
450
+ } catch {}
444
451
  }
445
452
  }
446
453
 
package/engine/cleanup.js CHANGED
@@ -582,6 +582,41 @@ function runCleanup(config, verbose = false) {
582
582
  }
583
583
  } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
584
584
 
585
+ // Reset orphaned PRD item statuses — dispatched/failed with no matching work item (#779)
586
+ cleaned.orphanedPrdStatuses = 0;
587
+ try {
588
+ const wiIds = new Set();
589
+ for (const project of projects) {
590
+ const items = safeJson(projectWorkItemsPath(project)) || [];
591
+ for (const wi of items) { if (wi?.id) wiIds.add(wi.id); }
592
+ }
593
+ try {
594
+ const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
595
+ for (const wi of centralWi) { if (wi?.id) wiIds.add(wi.id); }
596
+ } catch { /* optional */ }
597
+
598
+ let orphanPrdEntries;
599
+ try { orphanPrdEntries = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); }
600
+ catch { orphanPrdEntries = []; }
601
+ for (const pf of orphanPrdEntries) {
602
+ const prdPath = path.join(PRD_DIR, pf);
603
+ const prd = safeJson(prdPath);
604
+ if (!prd?.missing_features) continue;
605
+ let reset = 0;
606
+ for (const feat of prd.missing_features) {
607
+ if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
608
+ feat.status = shared.WI_STATUS.PENDING;
609
+ reset++;
610
+ }
611
+ }
612
+ if (reset > 0) {
613
+ safeWrite(prdPath, prd);
614
+ log('info', `Reset ${reset} orphaned PRD item status(es) → pending in ${pf}`);
615
+ cleaned.orphanedPrdStatuses += reset;
616
+ }
617
+ }
618
+ } catch (e) { log('warn', 'orphan PRD status reset: ' + e.message); }
619
+
585
620
  // 10. Prune CC tab sessions — cap at 50 entries, remove oldest beyond cap
586
621
  cleaned.ccSessions = 0;
587
622
  try {
@@ -606,7 +641,7 @@ function runCleanup(config, verbose = false) {
606
641
  const entries = Object.entries(docSessions);
607
642
  const DOC_SESSIONS_CAP = 100;
608
643
  if (entries.length > DOC_SESSIONS_CAP) {
609
- entries.sort((a, b) => new Date(b[1].lastActiveAt || 0) - new Date(a[1].lastActiveAt || 0));
644
+ entries.sort((a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0));
610
645
  const keep = Object.fromEntries(entries.slice(0, DOC_SESSIONS_CAP));
611
646
  cleaned.docSessions = entries.length - DOC_SESSIONS_CAP;
612
647
  safeWrite(docSessionsPath, keep);
package/engine/cli.js CHANGED
@@ -372,6 +372,19 @@ const commands = {
372
372
  }
373
373
  }, 3000);
374
374
 
375
+ // Teams inbox poll timer — process incoming Teams messages through CC
376
+ const teams = require('./teams');
377
+ const teamsInboxInterval = config.teams?.inboxPollInterval ?? shared.ENGINE_DEFAULTS.teams.inboxPollInterval;
378
+ const teamsInboxTimer = teams.isTeamsEnabled() ? setInterval(() => {
379
+ try {
380
+ const ctrl = getControl();
381
+ if (ctrl.state !== 'running') return;
382
+ teams.processTeamsInbox().catch(err => {
383
+ shared.log('warn', `Teams inbox poll error: ${err.message}`);
384
+ });
385
+ } catch {}
386
+ }, teamsInboxInterval) : null;
387
+
375
388
  console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
376
389
  console.log('Press Ctrl+C to stop');
377
390
 
@@ -422,6 +435,7 @@ const commands = {
422
435
  console.log(`\n${signal} received — initiating graceful shutdown...`);
423
436
  clearInterval(tickTimer);
424
437
  clearInterval(fastPollTimer);
438
+ if (teamsInboxTimer) clearInterval(teamsInboxTimer);
425
439
  for (const f of _watchedFiles) { try { fs.unwatchFile(f); } catch { /* cleanup */ } }
426
440
  safeWrite(CONTROL_PATH, { state: 'stopping', pid: process.pid, stopping_at: e.ts() });
427
441
  e.log('info', `Graceful shutdown initiated (${signal})`);
@@ -894,9 +908,9 @@ const commands = {
894
908
  const e = engine();
895
909
  console.log('\n=== Kill All Active Work ===\n');
896
910
  const config = getConfig();
897
- const dispatch = getDispatch();
898
911
  const shared = require('./shared');
899
912
 
913
+ // Kill processes via PID files (expensive — outside dispatch lock)
900
914
  const pidFiles = fs.readdirSync(ENGINE_DIR).filter(f => f.startsWith('pid-'));
901
915
  for (const f of pidFiles) {
902
916
  const pid = safeRead(path.join(ENGINE_DIR, f)).trim();
@@ -904,7 +918,15 @@ const commands = {
904
918
  fs.unlinkSync(path.join(ENGINE_DIR, f));
905
919
  }
906
920
 
907
- const killed = dispatch.active || [];
921
+ // Atomically read and clear dispatch.active (locked read-modify-write)
922
+ let killed = [];
923
+ e.mutateDispatch((dispatch) => {
924
+ killed = dispatch.active || [];
925
+ dispatch.active = [];
926
+ return dispatch;
927
+ });
928
+
929
+ // Reset work items outside the dispatch lock (work-items.json has its own lock)
908
930
  for (const item of killed) {
909
931
  if (item.meta) {
910
932
  e.updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
@@ -932,8 +954,6 @@ const commands = {
932
954
 
933
955
  console.log(`Killed dispatch: ${item.id} (${item.agent}) — work item reset to pending`);
934
956
  }
935
- dispatch.active = [];
936
- safeWrite(DISPATCH_PATH, dispatch);
937
957
 
938
958
  // Agent status derived from dispatch.json — clearing dispatch.active is sufficient.
939
959
  console.log('All agents reset to idle (dispatch cleared)');
package/engine/github.js CHANGED
@@ -411,6 +411,13 @@ async function pollPrStatus(config) {
411
411
  pr.buildErrorLog = errorLog;
412
412
  log('info', `PR ${pr.id}: fetched ${errorLog.split('\n').length} lines of build error log`);
413
413
  }
414
+
415
+ // Teams notification for build failure — non-blocking
416
+ try {
417
+ const teams = require('./teams');
418
+ const prFilePath = shared.projectPrPath(project);
419
+ teams.teamsNotifyPrEvent(pr, 'build-failed', project, prFilePath).catch(() => {});
420
+ } catch {}
414
421
  }
415
422
  }
416
423
  }
@@ -277,10 +277,25 @@ function checkPlanCompletion(meta, config) {
277
277
  });
278
278
  });
279
279
  log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
280
+
281
+ // Teams notification for verify creation — non-blocking
282
+ try {
283
+ const teams = require('./teams');
284
+ teams.teamsNotifyPlanEvent({ name: plan.plan_summary || planFile, file: planFile }, 'verify-created').catch(() => {});
285
+ } catch {}
280
286
  }
281
287
 
282
288
  // Archive deferred until verify completes
283
289
 
290
+ // Teams notification for plan completion — non-blocking
291
+ try {
292
+ const teams = require('./teams');
293
+ teams.teamsNotifyPlanEvent({
294
+ name: plan.plan_summary || planFile, file: planFile, project: plan.project,
295
+ doneCount: doneItems.length, totalCount: planFeatureIds.size,
296
+ }, 'plan-completed').catch(() => {});
297
+ } catch {}
298
+
284
299
  log('info', `PRD ${planFile} completed: ${doneItems.length} done, ${failedItems.length} failed, runtime ${runtimeMin}m`);
285
300
  return true;
286
301
  }
@@ -751,9 +766,13 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary)
751
766
  if (!Array.isArray(prs)) return prs;
752
767
  const target = prs.find(p => p.id === pr.id);
753
768
  if (!target) return prs;
754
- // Once approved, stays approved permanently no path can downgrade
755
- if (postReviewStatus && target.reviewStatus !== 'approved') {
756
- target.reviewStatus = postReviewStatus;
769
+ // Once approved, stays approved — only changes-requested can override
770
+ if (postReviewStatus) {
771
+ if (target.reviewStatus === 'approved' && postReviewStatus !== 'changes-requested') {
772
+ // Keep approved — don't downgrade
773
+ } else {
774
+ target.reviewStatus = postReviewStatus;
775
+ }
757
776
  }
758
777
  target.lastReviewedAt = ts();
759
778
  target.minionsReview = {
@@ -1019,16 +1038,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
1019
1038
  });
1020
1039
  }
1021
1040
 
1022
- const teamsUrl = process.env.TEAMS_PLAN_FLOW_URL;
1023
- if (teamsUrl) {
1024
- try {
1025
- await fetch(teamsUrl, {
1026
- method: 'POST',
1027
- headers: { 'Content-Type': 'application/json' },
1028
- body: JSON.stringify({ text: `PR ${pr.id} merged: ${pr.title} (${project.name}) by ${pr.agent || 'unknown'}` })
1029
- });
1030
- } catch (err) { log('warn', `Teams post-merge notify failed: ${err.message}`); }
1031
- }
1041
+ // Teams PR lifecycle notification — non-blocking
1042
+ try {
1043
+ const teams = require('./teams');
1044
+ const prEvent = newStatus === PR_STATUS.MERGED ? 'pr-merged' : 'pr-abandoned';
1045
+ const prFilePath = project ? projectPrPath(project) : null;
1046
+ teams.teamsNotifyPrEvent(pr, prEvent, project, prFilePath).catch(() => {});
1047
+ } catch {}
1032
1048
 
1033
1049
  log('info', `Post-merge hooks completed for ${pr.id}`);
1034
1050
  }
@@ -1630,6 +1646,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1630
1646
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
1631
1647
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
1632
1648
 
1649
+ // Teams notification — non-blocking
1650
+ try {
1651
+ const teams = require('./teams');
1652
+ teams.teamsNotifyCompletion(dispatchItem, finalResult, agentId).catch(() => {});
1653
+ } catch {}
1654
+
1633
1655
  return { resultSummary, taskUsage, autoRecovered, structuredCompletion };
1634
1656
  }
1635
1657
 
@@ -192,6 +192,19 @@ function doctor(minionsHome) {
192
192
  } else {
193
193
  runtimeResults.push({ name: 'Agents configured', ok: false, message: 'no agents in config.json' });
194
194
  }
195
+
196
+ // Check Teams integration
197
+ const teams = config.teams;
198
+ if (teams && teams.enabled === true) {
199
+ if (!teams.appId || !teams.appPassword) {
200
+ const missing = [!teams.appId && 'appId', !teams.appPassword && 'appPassword'].filter(Boolean).join(', ');
201
+ runtimeResults.push({ name: 'Teams integration', ok: 'warn', message: `enabled but missing: ${missing}` });
202
+ } else {
203
+ runtimeResults.push({ name: 'Teams integration', ok: true, message: 'configured' });
204
+ }
205
+ } else {
206
+ runtimeResults.push({ name: 'Teams integration', ok: 'warn', message: 'disabled — see docs/teams-setup.md' });
207
+ }
195
208
  } catch {
196
209
  runtimeResults.push({ name: 'Config', ok: false, message: `missing or invalid — run: minions init` });
197
210
  }
package/engine/queries.js CHANGED
@@ -953,7 +953,9 @@ function getPrdInfo(config) {
953
953
  for (const item of items) {
954
954
  const wi = wiById[item.id];
955
955
  // Work item status is source of truth when available (PRD JSON may lag behind)
956
- const rawStatus = wi ? (wi.status || item.status) : item.status;
956
+ // If PRD says dispatched/failed but no work item exists, treat as pending (orphaned — #779)
957
+ const rawStatus = wi ? (wi.status || item.status)
958
+ : ((item.status === WI_STATUS.DISPATCHED || item.status === WI_STATUS.FAILED) ? WI_STATUS.PENDING : item.status);
957
959
  item.status = statusDisplay[rawStatus] || rawStatus || 'missing';
958
960
  // Attach execution metadata for display (agent, PR link, fail reason)
959
961
  if (wi) {
package/engine/shared.js CHANGED
@@ -553,6 +553,15 @@ const ENGINE_DEFAULTS = {
553
553
  ccModel: 'sonnet', // model for Command Center and doc-chat (sonnet, haiku, opus)
554
554
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
555
555
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
556
+ // Teams integration — config.teams shape: { enabled, appId, appPassword, notifyEvents, ccMirror, inboxPollInterval }
557
+ teams: {
558
+ enabled: false,
559
+ appId: '',
560
+ appPassword: '',
561
+ notifyEvents: ['pr-merged', 'agent-completed', 'plan-completed', 'agent-failed'],
562
+ ccMirror: true,
563
+ inboxPollInterval: 15000,
564
+ },
556
565
  };
557
566
 
558
567
  // ─── Status & Type Constants ─────────────────────────────────────────────────