@yemi33/minions 0.1.2447 → 0.1.2449

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.
Files changed (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +66 -7
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +186 -45
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +52 -6
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -0,0 +1,869 @@
1
+ /**
2
+ * watch-plugins/teams-channel.js — Watch a Microsoft Teams channel for messages
3
+ *
4
+ * Use case: trigger a registered watch action for each new root message or
5
+ * reply in a configured Microsoft Teams channel.
6
+ *
7
+ * ── Target syntax ───────────────────────────────────────────────────────────
8
+ * target: 'teamId:channelId'
9
+ * → bare colon-delimited string. The first ':' splits team / channel.
10
+ * Channel ids have additional colons (`19:foo@thread.skype`); the
11
+ * parser uses indexOf — NOT lastIndexOf — so the channel side keeps its
12
+ * internal `:`.
13
+ *
14
+ * target: { teamId, channelId }
15
+ * → explicit form. Same semantics.
16
+ *
17
+ * ── Conditions ──────────────────────────────────────────────────────────────
18
+ * new-message — fires once per newly-observed message id. The candidate
19
+ * set includes BOTH root messages and thread replies, with
20
+ * system messages excluded. Each candidate carries
21
+ * `rootMessageId` (= the root for replies, = `id` for roots)
22
+ * and an `isReply` boolean.
23
+ *
24
+ * Burst handling: when N new messages land between ticks, the plugin
25
+ * advances its seen-set by exactly ONE per evaluation. The watch fires once
26
+ * per tick until the backlog drains — zero messages are lost even if a
27
+ * human posts five in the same minute. Costs ~30s × N to drain (one tick
28
+ * per message at the interval cadence).
29
+ *
30
+ * First tick after watch creation MUST NOT fire — the engine seeds
31
+ * `_lastState` by calling captureState() before evaluate() on the first
32
+ * check, so the entire baseline message set lands in prevState before
33
+ * evaluate runs. New messages only start firing from tick 2 onward.
34
+ *
35
+ * ── Templating vars exposed to actions ──────────────────────────────────────
36
+ * {{messageId}}, {{messageBody}} (HTML stripped, truncated to 2KB),
37
+ * {{authorDisplayName}}, {{authorEmail}}, {{createdDateTime}},
38
+ * {{messageWebUrl}}, {{teamId}}, {{channelId}},
39
+ * {{rootMessageId}} — for a root: same as {{messageId}}; for a reply: the
40
+ * parent root's id. Use this to thread follow-ups.
41
+ * {{isReply}} — boolean string ('true' / 'false'). Lets the action
42
+ * prompt distinguish reply context from a new bug
43
+ * report on the root.
44
+ * Plus standard watch vars (target, condition, watchId, message, …).
45
+ *
46
+ * ── Idempotency contract for downstream actions ─────────────────────────────
47
+ * Any work item created by an action wired to this watch SHOULD stamp the
48
+ * following provenance shape on `meta.teams_source` so consumers can
49
+ * deduplicate WIs that map back to the same Teams message:
50
+ *
51
+ * meta.teams_source = {
52
+ * teamId, // GUID
53
+ * channelId, // 19:…@thread.skype
54
+ * messageId, // the candidate id (root OR reply)
55
+ * rootMessageId, // the parent root id (== messageId for roots)
56
+ * messageWebUrl, // deep link back to the chat message
57
+ * authorEmail, // may be '' — Graph doesn't always expose it
58
+ * authorDisplayName, // may be '' — same caveat
59
+ * reportedAt, // ISO-8601 — the message's createdDateTime
60
+ * }
61
+ *
62
+ * This is a CONTRACT for action authors (e.g. the cc-triage prompt), not
63
+ * code in the plugin itself: this plugin does no WI mutation. The shape
64
+ * is intentionally flat — no nested objects — so it round-trips cleanly
65
+ * through JSON storage and is easy to query with `meta.teams_source.messageId`
66
+ * lookups. See the dispatched cc-triage prompt template stored on the watch
67
+ * action params for the canonical "always stamp before dispatching" rule.
68
+ *
69
+ * ── Fetch model ─────────────────────────────────────────────────────────────
70
+ * The engine's checkWatches loop is synchronous and runs inside a
71
+ * mutateJsonFileLocked callback, so this plugin maintains its own
72
+ * background-fetch cache. `fetchEntity` is sync: it returns the cached
73
+ * entity and, when the cached value is older than 60s (or absent), kicks
74
+ * off a background Graph refresh. First tick after a watch is created
75
+ * therefore returns null (no entity yet); the second tick captures the
76
+ * baseline; subsequent ticks detect new messages.
77
+ *
78
+ * Tests short-circuit this entirely by passing a pre-populated map at
79
+ * `state.teamsChannelMessages[targetKey]` (see `_targetKey`) — the plugin
80
+ * uses that shape directly, no shell-out required. Tests can also
81
+ * override the underlying Graph fetcher via `setLister(fn)` to drive the
82
+ * cache path.
83
+ *
84
+ * ── Auth ────────────────────────────────────────────────────────────────────
85
+ * Microsoft Graph bearer token, acquired via (default chain):
86
+ *
87
+ * 1. `az account get-access-token --resource https://graph.microsoft.com
88
+ * --query accessToken -o tsv` (primary)
89
+ * 2. `azureauth aad --resource https://graph.microsoft.com
90
+ * --timeout 1 --output token` (fallback when `az` is missing)
91
+ *
92
+ * The default chain uses the Azure CLI's well-known client app registration
93
+ * (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`), which IS NOT preauthorized for
94
+ * the `ChannelMessage.Read.All` Graph permission required to list channel
95
+ * messages. With the default chain a `403 Forbidden` is the expected outcome
96
+ * on `GET /teams/{id}/channels/{id}/messages` even when the user is a
97
+ * tenant member — the bearer carries delegated scopes like Group.ReadWrite.All
98
+ * (enough to POST messages, per the team's teams-graph posting recipe) but
99
+ * NOT ChannelMessage.Read.All. To make this watch actually fire, configure
100
+ * a custom app registration on the watch target:
101
+ *
102
+ * target: {
103
+ * teamId: "<guid>", channelId: "19:...@thread.skype",
104
+ * auth: {
105
+ * method: "azureauth", // or "az"
106
+ * client: "<app-reg-guid>", // app reg with ChannelMessage.Read.All
107
+ * // admin-consented in the tenant
108
+ * tenant: "<tenant-guid>", // optional — defaults to home tenant
109
+ * scope: ".default" // optional — defaults to <resource>/.default
110
+ * }
111
+ * }
112
+ *
113
+ * Tokens are cached in-process for 25 min (Entra access tokens normally last
114
+ * ~1 h). Cache is keyed by the serialized auth-config so two watches with
115
+ * different `target.auth` never share a token.
116
+ *
117
+ * Security: the token is ONLY passed to Graph via the request
118
+ * `Authorization: Bearer …` header. It never appears on argv, never in
119
+ * logs, never in persisted watch records. Plugin trust level matches playbooks
120
+ * (user-trusted). `target.auth.client` / `tenant` are only forwarded as
121
+ * argv values to `az` or `azureauth` (argv-array spawn — no shell
122
+ * interpolation) so values containing shell metacharacters cannot escape.
123
+ *
124
+ * ── Error surfacing ─────────────────────────────────────────────────────────
125
+ * Graph error responses are parsed (`error.code` + `error.message` +
126
+ * `error.innerError.request-id`) and surfaced verbatim on `_lastState.lastError`
127
+ * alongside a one-line remediation hint — "Missing Graph permission
128
+ * ChannelMessage.Read.All — set target.auth.client to an app registration
129
+ * that has admin-consented this scope" — so the dashboard / `notes/inbox`
130
+ * reader can act on the failure without grepping engine logs.
131
+ *
132
+ * ── Out of scope (left as TODOs) ────────────────────────────────────────────
133
+ * - Attachments, reactions, edit/delete detection, multi-channel watch.
134
+ */
135
+
136
+ const { spawn } = require('child_process');
137
+ const https = require('https');
138
+
139
+ function ts() { return new Date().toISOString(); }
140
+ function _warn(msg) { try { console.warn(`[${ts()}] [warn] ${msg}`); } catch {} }
141
+
142
+ const CACHE_FRESHNESS_MS = 60 * 1000;
143
+ const TOKEN_TTL_MS = 25 * 60 * 1000;
144
+ const SHELL_TIMEOUT_MS = 15 * 1000;
145
+ const GRAPH_TIMEOUT_MS = 15 * 1000;
146
+ const SEEN_IDS_CAP = 500; // bound state growth per watch
147
+ const MESSAGE_BODY_MAX_BYTES = 2048;
148
+ const GRAPH_RESOURCE = 'https://graph.microsoft.com';
149
+
150
+ // Module-level caches. Survive across ticks; reset on engine restart.
151
+ const _entityCache = new Map(); // key -> { messages, fetchedAt, error? }
152
+ const _inFlight = new Set();
153
+ // Token cache is keyed by serialized auth config so different watches with
154
+ // different `target.auth` settings never clobber each other's tokens. Default
155
+ // (no auth specified) lives under the 'default' key. Tokens never appear in
156
+ // the key itself — only the auth-config shape that produced them.
157
+ const _cachedTokens = new Map(); // authKey -> { token, expiresAt }
158
+
159
+ // Override-able listers — defaults use real Graph fetch. Tests inject mocks.
160
+ //
161
+ // THREE injection points (use whichever fits the test surface):
162
+ // setLister(fn) → override the ENTIRE composed pipeline. The fn
163
+ // receives `cfg` and must return the full
164
+ // { messages: [...], fetchedAt, error? } envelope.
165
+ // Bypasses both rootsLister and replyLister.
166
+ // setRootsLister(fn) → override just the roots fetch. Composer still
167
+ // fans out per-root replies via _replyLister.
168
+ // setReplyLister(fn) → override the per-root replies fetcher used by
169
+ // the default composer. Throwing simulates per-root
170
+ // Graph failure — must NOT break the tick.
171
+ //
172
+ // The composer is the production wiring; test it end-to-end by stubbing
173
+ // rootsLister + replyLister together.
174
+ let _lister = _defaultComposedList;
175
+ let _rootsLister = _defaultGraphListRoots;
176
+ let _replyLister = _defaultGraphListReplies;
177
+ function setLister(fn) { _lister = typeof fn === 'function' ? fn : _defaultComposedList; }
178
+ function setRootsLister(fn) { _rootsLister = typeof fn === 'function' ? fn : _defaultGraphListRoots; }
179
+ function setReplyLister(fn) { _replyLister = typeof fn === 'function' ? fn : _defaultGraphListReplies; }
180
+ function _resetCache() { _entityCache.clear(); _inFlight.clear(); _cachedTokens.clear(); }
181
+
182
+ /**
183
+ * Validate + normalize `target.auth`. Returns null when `auth` is absent or
184
+ * malformed (caller falls back to the default az → azureauth chain). The
185
+ * returned shape is { method, client?, tenant?, scope? } with strings only —
186
+ * forwarded as argv values to `az` / `azureauth` so all fields MUST be
187
+ * strings (no shell interpolation, but we still reject non-string inputs
188
+ * defensively so an accidental `{ client: { foo: 'bar' } }` doesn't crash
189
+ * the spawn call).
190
+ */
191
+ function _normalizeAuthCfg(auth) {
192
+ if (!auth || typeof auth !== 'object') return null;
193
+ const method = typeof auth.method === 'string' ? auth.method.trim().toLowerCase() : '';
194
+ if (method !== 'az' && method !== 'azureauth') return null;
195
+ const out = { method };
196
+ for (const k of ['client', 'tenant', 'scope']) {
197
+ if (auth[k] != null) {
198
+ if (typeof auth[k] !== 'string') return null;
199
+ const v = auth[k].trim();
200
+ if (v) out[k] = v;
201
+ }
202
+ }
203
+ if (method === 'azureauth' && !out.client) {
204
+ // azureauth requires --client; without it the tool prompts interactively
205
+ // and we'd hang the tick. Reject up-front so the warn surfaces the cause.
206
+ return null;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ function _authCacheKey(authCfg) {
212
+ if (!authCfg) return 'default';
213
+ // Stable serialization — keys are sorted, so { tenant, client } and
214
+ // { client, tenant } collapse to the same cache entry.
215
+ const keys = Object.keys(authCfg).sort();
216
+ return keys.map((k) => `${k}=${authCfg[k]}`).join('|');
217
+ }
218
+
219
+ function _resolveCfg(target) {
220
+ if (typeof target === 'string' && target.includes(':')) {
221
+ // First ':' splits team / channel — channel ids contain internal colons
222
+ // like `19:abc@thread.skype` so lastIndexOf would mangle them.
223
+ const idx = target.indexOf(':');
224
+ const teamId = target.slice(0, idx).trim();
225
+ const channelId = target.slice(idx + 1).trim();
226
+ if (!teamId || !channelId) return null;
227
+ return { teamId, channelId, auth: null };
228
+ }
229
+ if (target && typeof target === 'object'
230
+ && typeof target.teamId === 'string' && target.teamId
231
+ && typeof target.channelId === 'string' && target.channelId) {
232
+ return {
233
+ teamId: target.teamId.trim(),
234
+ channelId: target.channelId.trim(),
235
+ auth: _normalizeAuthCfg(target.auth),
236
+ };
237
+ }
238
+ return null;
239
+ }
240
+
241
+ function _validateTarget(target) {
242
+ const cfg = _resolveCfg(target);
243
+ if (!cfg || !cfg.teamId || !cfg.channelId) {
244
+ return 'target must be "teamId:channelId" or an object with teamId and channelId';
245
+ }
246
+ if (target && typeof target === 'object'
247
+ && target.auth !== undefined
248
+ && target.auth !== null
249
+ && !_normalizeAuthCfg(target.auth)) {
250
+ return 'target.auth must declare a valid az or azureauth configuration';
251
+ }
252
+ return null;
253
+ }
254
+
255
+ function _targetKey(target) {
256
+ const cfg = _resolveCfg(target);
257
+ if (!cfg) return typeof target === 'string' ? target : JSON.stringify(target);
258
+ // Include auth in the cache key so two watches against the same channel
259
+ // but with different `target.auth` cache entities independently — otherwise
260
+ // a 403 on one would leak to the other's _lastState.lastError.
261
+ const authKey = _authCacheKey(cfg.auth);
262
+ return authKey === 'default'
263
+ ? `${cfg.teamId}::${cfg.channelId}`
264
+ : `${cfg.teamId}::${cfg.channelId}::${authKey}`;
265
+ }
266
+
267
+ /**
268
+ * Run a shell command with captured stdout. Never throws on non-zero exit.
269
+ * Returns { code, stdout, stderr }.
270
+ */
271
+ function _runCmd(cmd, args, timeoutMs) {
272
+ return new Promise((resolve) => {
273
+ // Windows: enable shell so .cmd shims like `az.cmd` resolve via PATHEXT.
274
+ // All args here are hardcoded constants — no injection surface.
275
+ const child = spawn(cmd, args, {
276
+ env: process.env,
277
+ windowsHide: true,
278
+ shell: process.platform === 'win32',
279
+ });
280
+ let stdout = '';
281
+ let stderr = '';
282
+ let settled = false;
283
+ const timer = setTimeout(() => {
284
+ if (settled) return;
285
+ settled = true;
286
+ try { child.kill('SIGTERM'); } catch {}
287
+ resolve({ code: -1, stdout, stderr: stderr + `\n[teams-channel] ${cmd} timeout after ${timeoutMs}ms` });
288
+ }, timeoutMs);
289
+ child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
290
+ child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
291
+ child.on('error', (err) => {
292
+ if (settled) return;
293
+ settled = true;
294
+ clearTimeout(timer);
295
+ resolve({ code: -1, stdout, stderr: stderr + `\n[teams-channel] ${cmd} spawn error: ${err.message}` });
296
+ });
297
+ child.on('close', (code) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ clearTimeout(timer);
301
+ resolve({ code: code == null ? -1 : code, stdout, stderr });
302
+ });
303
+ });
304
+ }
305
+
306
+ async function _resolveToken(authCfg) {
307
+ const cacheKey = _authCacheKey(authCfg);
308
+ const cached = _cachedTokens.get(cacheKey);
309
+ if (cached && cached.expiresAt > Date.now()) return cached.token;
310
+
311
+ // Explicit per-watch auth config — single provider, no fallback chain
312
+ // (the user picked the method; chaining would mask the misconfiguration).
313
+ if (authCfg) {
314
+ const cmd = authCfg.method === 'azureauth' ? 'azureauth' : 'az';
315
+ const args = authCfg.method === 'azureauth'
316
+ ? _buildAzureauthArgs(authCfg)
317
+ : _buildAzArgs(authCfg);
318
+ const res = await _runCmd(cmd, args, SHELL_TIMEOUT_MS);
319
+ const token = res.code === 0 ? _extractTokenFromOutput(authCfg.method, res.stdout) : '';
320
+ if (token) {
321
+ _cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
322
+ return token;
323
+ }
324
+ _warn(`teams-channel: token fetch failed via ${authCfg.method}. exit=${res.code} (${(res.stderr || '').trim().slice(0, 200)})`);
325
+ return null;
326
+ }
327
+
328
+ // Default chain: az then azureauth. NOTE: this chain uses the Azure CLI's
329
+ // well-known client app reg, which lacks ChannelMessage.Read.All. Expect
330
+ // 403 on channel-message reads unless the watch overrides via target.auth.
331
+ const azRes = await _runCmd(
332
+ 'az',
333
+ ['account', 'get-access-token', '--resource', GRAPH_RESOURCE, '--query', 'accessToken', '-o', 'tsv'],
334
+ SHELL_TIMEOUT_MS,
335
+ );
336
+ if (azRes.code === 0 && azRes.stdout.trim()) {
337
+ const token = azRes.stdout.trim();
338
+ _cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
339
+ return token;
340
+ }
341
+ // Fallback: azureauth (1ES helper). `azureauth aad --output token` prints
342
+ // ONLY the raw bearer token on stdout (no extra status text). The `--timeout 1`
343
+ // caps interactive prompts to 1 minute. Note the v0.9 CLI uses `aad`, not
344
+ // `aad token` (there is no `token` subcommand).
345
+ const aaRes = await _runCmd(
346
+ 'azureauth',
347
+ ['aad', '--resource', GRAPH_RESOURCE, '--timeout', '1', '--output', 'token'],
348
+ SHELL_TIMEOUT_MS,
349
+ );
350
+ if (aaRes.code === 0 && aaRes.stdout.trim()) {
351
+ const token = aaRes.stdout.trim();
352
+ _cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
353
+ return token;
354
+ }
355
+ _warn(`teams-channel: token fetch failed. az exit=${azRes.code} (${(azRes.stderr || '').trim().slice(0, 200)}); azureauth exit=${aaRes.code} (${(aaRes.stderr || '').trim().slice(0, 200)})`);
356
+ return null;
357
+ }
358
+
359
+ function _buildAzArgs(authCfg) {
360
+ // `az account get-access-token` produces TSV when --query is used. For
361
+ // user-supplied scope/tenant we forward as argv values (no shell).
362
+ const args = ['account', 'get-access-token'];
363
+ if (authCfg.scope) args.push('--scope', authCfg.scope);
364
+ else args.push('--resource', GRAPH_RESOURCE);
365
+ if (authCfg.tenant) args.push('--tenant', authCfg.tenant);
366
+ args.push('--query', 'accessToken', '-o', 'tsv');
367
+ return args;
368
+ }
369
+
370
+ function _buildAzureauthArgs(authCfg) {
371
+ // azureauth aad --client <id> --resource <url> [--tenant <id>] [--scope <s>]
372
+ // --timeout 1 --output token
373
+ // --output token prints ONLY the raw bearer on stdout.
374
+ const args = ['aad', '--client', authCfg.client, '--resource', GRAPH_RESOURCE];
375
+ if (authCfg.tenant) args.push('--tenant', authCfg.tenant);
376
+ if (authCfg.scope) args.push('--scope', authCfg.scope);
377
+ args.push('--timeout', '1', '--output', 'token');
378
+ return args;
379
+ }
380
+
381
+ function _extractTokenFromOutput(method, stdout) {
382
+ const s = String(stdout || '').trim();
383
+ if (!s) return '';
384
+ // Both `az ... -o tsv` and `azureauth ... --output token` emit just the
385
+ // bearer on stdout. Strip whitespace + trailing newlines defensively.
386
+ return s.split(/\s+/)[0] || '';
387
+ }
388
+
389
+ /**
390
+ * Strip HTML to plain text, collapse whitespace, truncate to MESSAGE_BODY_MAX_BYTES.
391
+ * No regex-based sanitization is needed for security — the body is treated as
392
+ * untrusted input wrapped in <UNTRUSTED-INPUT> when handed to CC.
393
+ */
394
+ function _stripHtml(html) {
395
+ if (typeof html !== 'string' || !html) return '';
396
+ // Remove <script> / <style> blocks outright.
397
+ let s = html
398
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, ' ')
399
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, ' ');
400
+ // Convert <br> and block-level closers to newlines so structure survives strip.
401
+ s = s.replace(/<\s*(br|\/p|\/div|\/li|\/h[1-6])\b[^>]*>/gi, '\n');
402
+ // Drop all remaining tags.
403
+ s = s.replace(/<[^>]+>/g, '');
404
+ // Decode the most common HTML entities (Teams only emits these handful).
405
+ s = s
406
+ .replace(/&nbsp;/g, ' ')
407
+ .replace(/&amp;/g, '&')
408
+ .replace(/&lt;/g, '<')
409
+ .replace(/&gt;/g, '>')
410
+ .replace(/&quot;/g, '"')
411
+ .replace(/&#39;/g, "'")
412
+ .replace(/&apos;/g, "'");
413
+ // Collapse runs of whitespace, trim.
414
+ s = s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').trim();
415
+ if (s.length > MESSAGE_BODY_MAX_BYTES) {
416
+ s = s.slice(0, MESSAGE_BODY_MAX_BYTES) + '…';
417
+ }
418
+ return s;
419
+ }
420
+
421
+ /**
422
+ * GET https://graph.microsoft.com/v1.0/teams/{teamId}/channels/{channelId}/messages
423
+ * ?$top=20&$orderby=createdDateTime desc
424
+ * Token passed via Authorization header — never on argv, never logged.
425
+ */
426
+ function _graphGet(pathPart, token) {
427
+ return new Promise((resolve) => {
428
+ const opts = {
429
+ method: 'GET',
430
+ hostname: 'graph.microsoft.com',
431
+ port: 443,
432
+ path: pathPart,
433
+ headers: {
434
+ Authorization: `Bearer ${token}`,
435
+ Accept: 'application/json',
436
+ 'User-Agent': 'minions-watch-teams-channel',
437
+ },
438
+ timeout: GRAPH_TIMEOUT_MS,
439
+ };
440
+ const req = https.request(opts, (res) => {
441
+ const chunks = [];
442
+ res.on('data', (c) => chunks.push(c));
443
+ res.on('end', () => {
444
+ resolve({
445
+ status: res.statusCode || 0,
446
+ body: Buffer.concat(chunks).toString('utf8'),
447
+ });
448
+ });
449
+ });
450
+ req.on('error', (err) => resolve({ status: 0, body: '', error: err.message || String(err) }));
451
+ req.on('timeout', () => { req.destroy(new Error(`graph timeout after ${GRAPH_TIMEOUT_MS}ms`)); });
452
+ req.end();
453
+ });
454
+ }
455
+
456
+ /**
457
+ * Parse a Graph JSON error body into structured fields. Graph always returns
458
+ * { "error": { "code": "...", "message": "...", "innerError": { "request-id": "..." } } }
459
+ * on 4xx/5xx, so this normalizes the shape. Returns null when the body isn't
460
+ * parseable JSON or doesn't carry an error envelope — caller falls back to
461
+ * the raw body snippet.
462
+ *
463
+ * Also detects the canonical "Missing scope permissions on the request. API
464
+ * requires one of 'X, Y'" message and lifts the required-scope list into a
465
+ * dedicated `missingScopes` array for the remediation hint.
466
+ */
467
+ function _parseGraphError(body) {
468
+ if (typeof body !== 'string' || !body) return null;
469
+ let parsed;
470
+ try { parsed = JSON.parse(body); } catch { return null; }
471
+ const err = parsed && parsed.error;
472
+ if (!err || typeof err !== 'object') return null;
473
+ const code = typeof err.code === 'string' ? err.code : '';
474
+ const message = typeof err.message === 'string' ? err.message : '';
475
+ const inner = err.innerError && typeof err.innerError === 'object' ? err.innerError : {};
476
+ // Graph uses both `request-id` (kebab) and `requestId` (camel) in different
477
+ // surfaces — accept either so the request-id always makes it into the log.
478
+ const requestId = String(inner['request-id'] || inner.requestId || '');
479
+ // Lift "API requires one of 'X[, Y]'" — single-quoted comma list.
480
+ const scopeMatch = message.match(/API requires one of '([^']+)'/i);
481
+ const missingScopes = scopeMatch
482
+ ? scopeMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
483
+ : [];
484
+ return { code, message, requestId, missingScopes };
485
+ }
486
+
487
+ /**
488
+ * One-line remediation hint for a Graph auth failure. Designed to be
489
+ * actionable in the dashboard tooltip without needing to read code or logs.
490
+ * Returns '' when no hint is available (caller appends nothing).
491
+ */
492
+ function _remediationHint(status, parsed, authCfg) {
493
+ if (parsed && parsed.missingScopes && parsed.missingScopes.length > 0) {
494
+ const scopes = parsed.missingScopes.join(', ');
495
+ if (authCfg && authCfg.method === 'azureauth' && authCfg.client) {
496
+ return `Missing Graph permission ${scopes} on app reg ${authCfg.client} — admin-consent this scope in the tenant.`;
497
+ }
498
+ if (authCfg && authCfg.method === 'az') {
499
+ return `Missing Graph permission ${scopes} — the az CLI client app does not preauthorize this scope. Switch target.auth.method to 'azureauth' with a custom app reg that has ${scopes} admin-consented.`;
500
+ }
501
+ return `Missing Graph permission ${scopes} — default az/azureauth chain uses the Azure CLI client which does not preauthorize this scope. Set target.auth = { method: 'azureauth', client: '<app-reg-guid>' } pointing at an app registration that has ${scopes} admin-consented.`;
502
+ }
503
+ if (status === 401) {
504
+ return authCfg
505
+ ? `Graph token rejected (401) — re-acquire via '${authCfg.method} ...' (token may be expired or revoked).`
506
+ : `Graph token rejected (401) — refresh sign-in: 'az login' or 'azureauth aad --client <app-reg>'.`;
507
+ }
508
+ if (status === 403 && parsed && /Authorization_RequestDenied/i.test(parsed.code || '')) {
509
+ return `Graph rejected the request (Authorization_RequestDenied) — verify the signed-in identity is a member of team ${''} and that the app registration is admin-consented in the tenant.`;
510
+ }
511
+ return '';
512
+ }
513
+
514
+ /**
515
+ * Build the final error string surfaced on _lastState.lastError. Always
516
+ * prefixed with `graph auth failed: HTTP <status>` for 401/403 to preserve
517
+ * backward-compatible substring matching (existing tests assert /401/ or
518
+ * /403/). The parsed Graph code/message and the remediation hint are
519
+ * appended after a separator.
520
+ */
521
+ function _buildGraphAuthError(status, body, authCfg) {
522
+ const parsed = _parseGraphError(body);
523
+ const baseline = `graph auth failed: HTTP ${status}`;
524
+ const parts = [baseline];
525
+ if (parsed && parsed.code) parts.push(parsed.code);
526
+ if (parsed && parsed.message) parts.push(parsed.message.slice(0, 240));
527
+ const hint = _remediationHint(status, parsed, authCfg);
528
+ if (hint) parts.push(`Hint: ${hint}`);
529
+ if (parsed && parsed.requestId) parts.push(`[request-id ${parsed.requestId}]`);
530
+ return parts.join(' — ');
531
+ }
532
+
533
+ /**
534
+ * Build the error for non-auth Graph failures (5xx, 4xx other than 401/403,
535
+ * network errors). Same structure but without the auth-specific hint.
536
+ */
537
+ function _buildGraphHttpError(status, body) {
538
+ const parsed = _parseGraphError(body);
539
+ const baseline = `graph HTTP ${status}`;
540
+ if (!parsed) return `${baseline}: ${(body || '').slice(0, 300)}`;
541
+ const parts = [baseline];
542
+ if (parsed.code) parts.push(parsed.code);
543
+ if (parsed.message) parts.push(parsed.message.slice(0, 240));
544
+ if (parsed.requestId) parts.push(`[request-id ${parsed.requestId}]`);
545
+ return parts.join(' — ');
546
+ }
547
+
548
+ /**
549
+ * Normalize a raw Graph chatMessage payload to the plugin's compact shape.
550
+ * `rootIdOverride` is supplied when this is a reply — replies in Graph carry
551
+ * `replyToId` / no `webUrl` of their own (Graph uses the root's webUrl with a
552
+ * fragment), so the parent root id is threaded in by the caller.
553
+ */
554
+ function _shapeMessage(m, rootIdOverride) {
555
+ const isReply = rootIdOverride != null && rootIdOverride !== m.id;
556
+ const rootMessageId = rootIdOverride != null ? String(rootIdOverride) : String(m.id);
557
+ return {
558
+ id: String(m.id),
559
+ rootMessageId,
560
+ isReply,
561
+ bodyHtml: m.body && typeof m.body.content === 'string' ? m.body.content : '',
562
+ contentType: m.body && m.body.contentType ? String(m.body.contentType) : 'text',
563
+ createdDateTime: String(m.createdDateTime || ''),
564
+ webUrl: String(m.webUrl || ''),
565
+ authorDisplayName: m.from && m.from.user && m.from.user.displayName
566
+ ? String(m.from.user.displayName) : '',
567
+ // Graph's chatMessage `from.user` exposes id (oid) but NOT email — that
568
+ // lives in `.from.user.userIdentityType`-dependent extension fields. The
569
+ // dashboard cc-triage prompt links the webUrl so a missing email is not
570
+ // load-bearing; surfacing whatever Graph returns is enough.
571
+ authorEmail: m.from && m.from.user && (m.from.user.email || m.from.user.userPrincipalName)
572
+ ? String(m.from.user.email || m.from.user.userPrincipalName) : '',
573
+ };
574
+ }
575
+
576
+ /**
577
+ * Fetch the top-20 root messages for a channel.
578
+ * Returns { messages, error?, fetchedAt }. Never throws.
579
+ */
580
+ async function _defaultGraphListRoots(cfg) {
581
+ const token = await _resolveToken(cfg && cfg.auth);
582
+ if (!token) {
583
+ return { messages: [], error: 'no graph token available', fetchedAt: ts() };
584
+ }
585
+ // Use encodeURIComponent on ids — channel ids contain `:` and `@` which
586
+ // are reserved in URL paths. teamId is a plain GUID but encode for safety.
587
+ const team = encodeURIComponent(cfg.teamId);
588
+ const channel = encodeURIComponent(cfg.channelId);
589
+ const path = `/v1.0/teams/${team}/channels/${channel}/messages?$top=20&$orderby=createdDateTime%20desc`;
590
+ const res = await _graphGet(path, token);
591
+ if (res.error) {
592
+ return { messages: [], error: `graph request error: ${res.error}`, fetchedAt: ts() };
593
+ }
594
+ if (res.status === 401 || res.status === 403) {
595
+ // Force a token re-fetch on next tick — current token might be expired.
596
+ _cachedTokens.delete(_authCacheKey(cfg && cfg.auth));
597
+ return {
598
+ messages: [],
599
+ error: _buildGraphAuthError(res.status, res.body, cfg && cfg.auth),
600
+ fetchedAt: ts(),
601
+ };
602
+ }
603
+ if (res.status < 200 || res.status >= 300) {
604
+ return {
605
+ messages: [],
606
+ error: _buildGraphHttpError(res.status, res.body),
607
+ fetchedAt: ts(),
608
+ };
609
+ }
610
+ let parsed;
611
+ try { parsed = JSON.parse(res.body || '{}'); }
612
+ catch (e) {
613
+ return { messages: [], error: `graph JSON parse failed: ${e.message}`, fetchedAt: ts() };
614
+ }
615
+ const arr = Array.isArray(parsed && parsed.value) ? parsed.value : [];
616
+ const messages = arr
617
+ .filter((m) => m && m.id && m.messageType === 'message' && m.from)
618
+ .map((m) => _shapeMessage(m, m.id));
619
+ return { messages, fetchedAt: ts() };
620
+ }
621
+
622
+ /**
623
+ * Fetch replies for a single root message. Returns { messages: [...] } or
624
+ * throws — the caller is responsible for swallowing errors so one bad root
625
+ * doesn't break the whole tick.
626
+ */
627
+ async function _defaultGraphListReplies(cfg, rootId) {
628
+ const token = await _resolveToken(cfg && cfg.auth);
629
+ if (!token) {
630
+ const err = new Error('no graph token available');
631
+ err.code = 'NO_TOKEN';
632
+ throw err;
633
+ }
634
+ const team = encodeURIComponent(cfg.teamId);
635
+ const channel = encodeURIComponent(cfg.channelId);
636
+ const root = encodeURIComponent(rootId);
637
+ const path = `/v1.0/teams/${team}/channels/${channel}/messages/${root}/replies?$top=20&$orderby=createdDateTime%20desc`;
638
+ const res = await _graphGet(path, token);
639
+ if (res.error) {
640
+ throw new Error(`graph request error: ${res.error}`);
641
+ }
642
+ if (res.status === 401 || res.status === 403) {
643
+ _cachedTokens.delete(_authCacheKey(cfg && cfg.auth));
644
+ throw new Error(_buildGraphAuthError(res.status, res.body, cfg && cfg.auth));
645
+ }
646
+ if (res.status < 200 || res.status >= 300) {
647
+ throw new Error(_buildGraphHttpError(res.status, res.body));
648
+ }
649
+ let parsed;
650
+ try { parsed = JSON.parse(res.body || '{}'); }
651
+ catch (e) {
652
+ throw new Error(`graph JSON parse failed: ${e.message}`);
653
+ }
654
+ const arr = Array.isArray(parsed && parsed.value) ? parsed.value : [];
655
+ const messages = arr
656
+ .filter((m) => m && m.id && m.messageType === 'message' && m.from)
657
+ .map((m) => _shapeMessage(m, rootId));
658
+ return { messages };
659
+ }
660
+
661
+ /**
662
+ * Default composed lister — fetch roots (via `_rootsLister`), then in
663
+ * parallel fetch replies for each root (via `_replyLister`). Per-root
664
+ * reply-fetch failures are logged and skipped so one bad root doesn't
665
+ * break the whole tick. Returns the merged candidate set with the
666
+ * root-level error (if any) preserved on the envelope.
667
+ */
668
+ async function _defaultComposedList(cfg) {
669
+ const rootEnv = await _rootsLister(cfg);
670
+ const messages = (rootEnv && Array.isArray(rootEnv.messages))
671
+ ? rootEnv.messages.slice() : [];
672
+ // If the roots fetch errored, surface the error but skip reply fetches —
673
+ // there's nothing to fetch replies for.
674
+ if ((rootEnv && rootEnv.error) || messages.length === 0) {
675
+ return {
676
+ messages,
677
+ error: (rootEnv && rootEnv.error) || null,
678
+ fetchedAt: (rootEnv && rootEnv.fetchedAt) || ts(),
679
+ };
680
+ }
681
+ const settled = await Promise.allSettled(
682
+ messages.map((root) => _replyLister(cfg, root.id))
683
+ );
684
+ for (let i = 0; i < settled.length; i++) {
685
+ const s = settled[i];
686
+ const root = messages[i];
687
+ if (s.status === 'fulfilled' && s.value && Array.isArray(s.value.messages)) {
688
+ for (const r of s.value.messages) messages.push(r);
689
+ } else if (s.status === 'rejected') {
690
+ const reason = s.reason && s.reason.message ? s.reason.message : String(s.reason);
691
+ _warn(`teams-channel: replies fetch failed for root ${root.id}: ${reason}`);
692
+ }
693
+ }
694
+ return { messages, fetchedAt: rootEnv.fetchedAt || ts() };
695
+ }
696
+
697
+ function _scheduleRefresh(target, key) {
698
+ if (_inFlight.has(key)) return;
699
+ const cfg = _resolveCfg(target);
700
+ if (!cfg) return;
701
+ _inFlight.add(key);
702
+ Promise.resolve()
703
+ .then(() => _lister(cfg))
704
+ .then((entity) => { _entityCache.set(key, entity); })
705
+ .catch((e) => {
706
+ _entityCache.set(key, {
707
+ messages: [],
708
+ error: e && e.message ? e.message : String(e),
709
+ fetchedAt: ts(),
710
+ });
711
+ })
712
+ .then(() => { _inFlight.delete(key); });
713
+ }
714
+
715
+ function fetchEntity(target, state) {
716
+ const key = _targetKey(target);
717
+ // Test/external override path — state.teamsChannelMessages[key] short-circuits
718
+ // the background-fetch cache so unit tests can drive the plugin without
719
+ // ever invoking the Graph fetcher.
720
+ if (state && state.teamsChannelMessages
721
+ && Object.prototype.hasOwnProperty.call(state.teamsChannelMessages, key)) {
722
+ const inj = state.teamsChannelMessages[key];
723
+ return inj ? { ...inj, _target: target } : null;
724
+ }
725
+ const cached = _entityCache.get(key);
726
+ const cachedAge = cached && cached.fetchedAt
727
+ ? Date.now() - new Date(cached.fetchedAt).getTime()
728
+ : Infinity;
729
+ if (!cached || cachedAge > CACHE_FRESHNESS_MS) {
730
+ _scheduleRefresh(target, key);
731
+ }
732
+ return cached ? { ...cached, _target: target } : null;
733
+ }
734
+
735
+ /**
736
+ * State shape: { ids: string[], lastFetchedAt?: string, lastError?: string }
737
+ * `ids` is the all-time-seen set (insertion-ordered, capped at SEEN_IDS_CAP).
738
+ *
739
+ * First-observation semantics: when `prevState` has no `ids` key the entire
740
+ * current candidate set is absorbed as the baseline so an existing channel
741
+ * backlog never floods actions (per W-mq00x0hx0007acaa spec).
742
+ *
743
+ * Burst handling on subsequent calls: when N new ids appear between ticks,
744
+ * captureState advances by AT MOST ONE per call so bursts drain losslessly
745
+ * across consecutive checks (one fire per tick, in concert with evaluate).
746
+ */
747
+ function captureState(entity, prevState) {
748
+ // The engine seeds _lastState with `{}` on watch creation, then later calls
749
+ // captureState with the prior `{ ids: [...] }`. Use the presence of the
750
+ // `ids` key — not its length — to distinguish "first observation" from
751
+ // "channel happens to be empty right now". This is what lets a brand-new
752
+ // watch on an empty channel still drain a later burst one-per-tick.
753
+ const isFirstObservation = !prevState || !Object.prototype.hasOwnProperty.call(prevState, 'ids');
754
+ const prevIds = Array.isArray(prevState && prevState.ids) ? prevState.ids : [];
755
+ if (!entity) return { ids: prevIds };
756
+ const seen = new Set(prevIds);
757
+ // Sort by createdDateTime ascending so the oldest unseen message drains
758
+ // first (FIFO across bursts).
759
+ const current = (entity.messages || [])
760
+ .slice()
761
+ .sort((a, b) => String(a.createdDateTime).localeCompare(String(b.createdDateTime)));
762
+ let nextIds;
763
+ if (isFirstObservation) {
764
+ nextIds = current.map((m) => m.id);
765
+ } else {
766
+ const newOnes = current.filter((m) => !seen.has(m.id));
767
+ if (newOnes.length === 0) {
768
+ nextIds = prevIds.slice();
769
+ } else {
770
+ nextIds = [...prevIds, newOnes[0].id];
771
+ }
772
+ }
773
+ if (nextIds.length > SEEN_IDS_CAP) {
774
+ nextIds = nextIds.slice(nextIds.length - SEEN_IDS_CAP);
775
+ }
776
+ return {
777
+ ids: nextIds,
778
+ lastFetchedAt: entity.fetchedAt || null,
779
+ lastError: entity.error || null,
780
+ };
781
+ }
782
+
783
+ function _firstUnseenMessage(entity, prevState) {
784
+ if (!entity) return null;
785
+ const prevIds = Array.isArray(prevState && prevState.ids) ? prevState.ids : [];
786
+ const seen = new Set(prevIds);
787
+ const candidates = (entity.messages || [])
788
+ .filter((m) => !seen.has(m.id))
789
+ .sort((a, b) => String(a.createdDateTime).localeCompare(String(b.createdDateTime)));
790
+ return candidates[0] || null;
791
+ }
792
+
793
+ function evaluate(condition, entity, prevState, target) {
794
+ if (condition !== 'new-message') {
795
+ return { triggered: false, message: '' };
796
+ }
797
+ if (!entity) return { triggered: false, message: '' };
798
+ if (entity.error) {
799
+ // Surface the fetch error so engine/watches/index.js writes it to
800
+ // _history[].reason — otherwise the watch silently sleeps forever
801
+ // with reason="" and no visible signal that Graph auth is broken
802
+ // (e.g. HTTP 403 missing ChannelMessage.Read.All scope).
803
+ return { triggered: false, message: `teams-channel fetch error: ${entity.error}` };
804
+ }
805
+ const next = _firstUnseenMessage(entity, prevState);
806
+ if (!next) return { triggered: false, message: '' };
807
+ const cfg = _resolveCfg(target);
808
+ const channelLabel = cfg ? cfg.channelId : '<unknown>';
809
+ const author = next.authorDisplayName || 'unknown';
810
+ const preview = _stripHtml(next.bodyHtml).slice(0, 80);
811
+ return {
812
+ triggered: true,
813
+ message: `New Teams message in ${channelLabel} from ${author}: ${preview}`,
814
+ };
815
+ }
816
+
817
+ function contextVars(entity, prevState, _newState) {
818
+ const next = _firstUnseenMessage(entity, prevState);
819
+ if (!next) return {};
820
+ const cfg = _resolveCfg(entity && entity._target);
821
+ return {
822
+ messageId: next.id,
823
+ messageBody: _stripHtml(next.bodyHtml),
824
+ authorDisplayName: next.authorDisplayName,
825
+ authorEmail: next.authorEmail,
826
+ createdDateTime: next.createdDateTime,
827
+ messageWebUrl: next.webUrl,
828
+ teamId: cfg ? cfg.teamId : '',
829
+ channelId: cfg ? cfg.channelId : '',
830
+ rootMessageId: next.rootMessageId || next.id,
831
+ // Templating substitutes via String(), so booleans surface as 'true'/'false'.
832
+ // Downstream prompt authors can branch on the literal string.
833
+ isReply: next.isReply === true,
834
+ };
835
+ }
836
+
837
+ module.exports = {
838
+ name: 'teams-channel',
839
+ spec: {
840
+ label: 'Microsoft Teams Channel',
841
+ description: 'Poll a Microsoft Teams channel for new messages. Tracks both root messages and thread replies; fires once per newly-observed message id (system messages excluded). First tick captures baseline without firing.',
842
+ conditions: ['new-message'],
843
+ absoluteConditions: [],
844
+ validateTarget: _validateTarget,
845
+ fetchEntity,
846
+ captureState,
847
+ evaluate,
848
+ contextVars,
849
+ },
850
+ // Exported for tests
851
+ _resolveCfg,
852
+ _targetKey,
853
+ _firstUnseenMessage,
854
+ _stripHtml,
855
+ _shapeMessage,
856
+ _defaultComposedList,
857
+ _normalizeAuthCfg,
858
+ _authCacheKey,
859
+ _parseGraphError,
860
+ _remediationHint,
861
+ _buildGraphAuthError,
862
+ _buildGraphHttpError,
863
+ _buildAzArgs,
864
+ _buildAzureauthArgs,
865
+ setLister,
866
+ setRootsLister,
867
+ setReplyLister,
868
+ _resetCache,
869
+ };