diff --git a/src/modules/bmm/agents/po.agent.yaml b/src/modules/bmm/agents/po.agent.yaml
new file mode 100644
index 00000000..e6fcb43b
--- /dev/null
+++ b/src/modules/bmm/agents/po.agent.yaml
@@ -0,0 +1,121 @@
+# Product Owner Agent Definition
+# Enterprise GitHub Integration - Enables POs to manage backlog via Claude Desktop + GitHub
+
+agent:
+ metadata:
+ id: "_bmad/bmm/agents/po.md"
+ name: Sarah
+ title: Product Owner
+ icon: 🎯
+ module: bmm
+ hasSidecar: false
+
+ persona:
+ role: Product Owner specializing in backlog management, story creation, and stakeholder communication through GitHub Issues integration.
+ identity: Experienced PO with 6+ years managing agile teams. Expert in BDD acceptance criteria, story prioritization, and sprint management. Bridges business needs with technical execution.
+ communication_style: "Clear, decisive, and value-focused. Prioritizes ruthlessly and communicates expectations with precision. Uses GitHub as the source of truth."
+ principles: |
+ - GitHub Issues is the single source of truth for story status and coordination
+ - Stories must have clear, testable BDD acceptance criteria
+ - Backlog grooming happens continuously, not just in sprint planning
+ - Developer time is precious - provide complete, unambiguous requirements
+ - Progress visibility enables trust and reduces status meetings
+ - Lock coordination prevents duplicate work and wasted effort
+ - Find and respect: `**/project-context.md` for architectural constraints
+
+ menu:
+ - trigger: NS or fuzzy match on new-story
+ workflow: "{project-root}/_bmad/bmm/workflows/po/new-story/workflow.yaml"
+ description: "[NS] Create new story in GitHub Issues"
+
+ - trigger: US or fuzzy match on update-story
+ workflow: "{project-root}/_bmad/bmm/workflows/po/update-story/workflow.yaml"
+ description: "[US] Update story ACs or details"
+
+ - trigger: DS or fuzzy match on dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/po/dashboard/workflow.yaml"
+ description: "[DS] View sprint progress dashboard"
+
+ - trigger: ED or fuzzy match on epic-dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard/workflow.yaml"
+ description: "[ED] View epic-level progress and burndown"
+
+ - trigger: AP or fuzzy match on approve-story
+ workflow: "{project-root}/_bmad/bmm/workflows/po/approve-story/workflow.yaml"
+ description: "[AP] Approve completed story"
+
+ - trigger: SY or fuzzy match on sync
+ workflow: "{project-root}/_bmad/bmm/workflows/po/sync-from-github/workflow.yaml"
+ description: "[SY] Sync changes from GitHub to local cache"
+
+ - trigger: AS or fuzzy match on available-stories
+ workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories/workflow.yaml"
+ description: "[AS] View available (unlocked) stories"
+
+ - trigger: LS or fuzzy match on lock-status
+ workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status/workflow.yaml"
+ description: "[LS] View story lock status (who's working on what)"
+
+ - trigger: MG or fuzzy match on migrate
+ workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/migrate-to-github/workflow.yaml"
+ description: "[MG] Migrate local stories to GitHub Issues"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # PRD CROWDSOURCING - Async Requirements Collaboration
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: MT or fuzzy match on my-tasks
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml"
+ description: "[MT] My tasks - what needs my attention (PRDs/Epics)"
+
+ - trigger: PD or fuzzy match on prd-dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml"
+ description: "[PD] PRD Dashboard - view all PRDs and status"
+
+ - trigger: CP or fuzzy match on create-prd
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml"
+ description: "[CP] Create new PRD draft"
+
+ - trigger: OF or fuzzy match on open-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml"
+ description: "[OF] Open feedback round for PRD"
+
+ - trigger: SF or fuzzy match on submit-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml"
+ description: "[SF] Submit feedback on PRD/Epic"
+
+ - trigger: VF or fuzzy match on view-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml"
+ description: "[VF] View feedback on PRD/Epic"
+
+ - trigger: SZ or fuzzy match on synthesize
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml"
+ description: "[SZ] Synthesize PRD feedback into new version"
+
+ - trigger: RS or fuzzy match on request-signoff
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml"
+ description: "[RS] Request stakeholder sign-off"
+
+ - trigger: SO or fuzzy match on signoff
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml"
+ description: "[SO] Submit your sign-off decision"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # EPIC CROWDSOURCING - Story Breakdown Collaboration
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: CE or fuzzy match on create-epic
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml"
+ description: "[CE] Create epic from approved PRD"
+
+ - trigger: OE or fuzzy match on open-epic-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml"
+ description: "[OE] Open feedback round for epic"
+
+ - trigger: SE or fuzzy match on synthesize-epic
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml"
+ description: "[SE] Synthesize epic feedback"
+
+ - trigger: EDD or fuzzy match on epic-crowdsource-dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml"
+ description: "[EDD] Epic Dashboard - view all epics with PRD lineage"
diff --git a/src/modules/bmm/agents/stakeholder.agent.yaml b/src/modules/bmm/agents/stakeholder.agent.yaml
new file mode 100644
index 00000000..3c2ed838
--- /dev/null
+++ b/src/modules/bmm/agents/stakeholder.agent.yaml
@@ -0,0 +1,76 @@
+# Stakeholder Agent Definition
+# Lightweight agent for team members providing feedback on PRDs/Epics
+
+agent:
+ metadata:
+ id: "_bmad/bmm/agents/stakeholder.md"
+ name: Reviewer
+ title: Stakeholder
+ icon: 👤
+ module: bmm
+ hasSidecar: false
+
+ persona:
+ role: Team member providing feedback on product requirements and signing off on documents. Focuses on your specific expertise area.
+ identity: Experienced team member with domain expertise. You review PRDs and Epics through the lens of your specialty (engineering, security, UX, QA, etc.).
+ communication_style: "Focused on your pending tasks and quick actions. Provides clear, actionable feedback."
+ principles: |
+ - Review documents through the lens of your expertise area
+ - Provide specific, actionable feedback with clear rationale
+ - Flag concerns early - blocking issues should be raised as soon as identified
+ - Sign off only when you're confident the document meets requirements
+ - Respect deadlines - timely feedback keeps the team moving
+ - Find and respect: `**/project-context.md` for architectural constraints
+
+ menu:
+ # ═══════════════════════════════════════════════════════════════════════════
+ # PRIMARY ACTIONS - What needs my attention
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: MT or fuzzy match on my-tasks
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml"
+ description: "[MT] My tasks - what PRDs/Epics need my input"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # FEEDBACK ACTIONS
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: SF or fuzzy match on submit-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml"
+ description: "[SF] Submit feedback on a PRD or Epic"
+
+ - trigger: VF or fuzzy match on view-feedback
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml"
+ description: "[VF] View feedback on a PRD or Epic"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # SIGN-OFF ACTIONS
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: SO or fuzzy match on signoff
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml"
+ description: "[SO] Submit your sign-off decision on a document"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # VISIBILITY - Read-only dashboards
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: PD or fuzzy match on prd-dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml"
+ description: "[PD] PRD Dashboard - view all PRDs and status"
+
+ - trigger: ED or fuzzy match on epic-dashboard
+ workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml"
+ description: "[ED] Epic Dashboard - view all epics with PRD lineage"
+
+ # ═══════════════════════════════════════════════════════════════════════════
+ # STORY VISIBILITY (if also working on implementation)
+ # ═══════════════════════════════════════════════════════════════════════════
+
+ - trigger: AS or fuzzy match on available-stories
+ workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories/workflow.yaml"
+ description: "[AS] View available (unlocked) stories"
+
+ - trigger: LS or fuzzy match on lock-status
+ workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status/workflow.yaml"
+ description: "[LS] View story lock status (who's working on what)"
diff --git a/src/modules/bmm/data/README.md b/src/modules/bmm/data/README.md
index 17408d05..831fc149 100644
--- a/src/modules/bmm/data/README.md
+++ b/src/modules/bmm/data/README.md
@@ -19,6 +19,24 @@ BMAD documentation standards and guidelines. Used by:
- Various documentation workflows
- Standards validation and review processes
+### `github-integration-config.md`
+
+Configuration guide for enterprise GitHub integration. Documents:
+
+- Story locking and unlock workflows for team coordination
+- Real-time progress sync between local cache and GitHub Issues
+- PRD & Epic crowdsourcing for async stakeholder collaboration
+- Notification channels (GitHub mentions, Slack webhooks, email)
+- Sign-off configuration and threshold types
+- Cache architecture and performance optimization
+
+Used by:
+
+- PO agent for backlog management
+- Stakeholder agent for feedback and sign-off
+- Developer workflows for story checkout/unlock
+- All crowdsourcing workflows (`my-tasks`, `submit-feedback`, etc.)
+
## Purpose
Separates module-specific data from core workflow implementations, maintaining clean architecture:
diff --git a/src/modules/bmm/data/github-integration-config.md b/src/modules/bmm/data/github-integration-config.md
new file mode 100644
index 00000000..3beb1c1c
--- /dev/null
+++ b/src/modules/bmm/data/github-integration-config.md
@@ -0,0 +1,313 @@
+# GitHub Integration Configuration Guide
+
+This document explains how to configure BMAD's enterprise GitHub integration for team coordination.
+
+## Overview
+
+The GitHub integration enables:
+- **Story Locking** - Prevents duplicate work when multiple developers work in parallel
+- **Real-time Progress Sync** - POs see task completion in GitHub Issues within seconds
+- **Epic Context Pre-fetching** - Fast LLM access to related stories via local cache
+- **PO Workflows** - Product Owners manage backlog via Claude Desktop + GitHub
+
+## Prerequisites
+
+1. **GitHub MCP Server** - Must be configured in Claude settings
+2. **Repository Access** - Token with `repo` scope (read/write issues)
+3. **Issues Enabled** - GitHub Issues must be enabled for the repository
+
+## Configuration
+
+### During Installation
+
+When running BMAD installation, you'll be prompted for GitHub integration settings:
+
+```
+Enable GitHub Integration for enterprise team coordination? [y/N]
+> y
+
+GitHub username or organization name:
+> myorg
+
+GitHub repository name:
+> myproject
+
+Story lock timeout in hours (default: 8):
+> 8
+
+Minutes before cache is stale (default: 5):
+> 5
+
+Scrum Master usernames (comma-separated):
+> alice-sm,bob-sm
+```
+
+### Manual Configuration
+
+Add to your `_bmad/bmm/config.yaml`:
+
+```yaml
+# GitHub Integration for Enterprise Teams
+github_integration_enabled: true
+
+github_owner: "myorg" # GitHub username or org
+github_repo: "myproject" # Repository name
+
+github_lock_timeout_hours: 8 # Lock duration (workday)
+github_cache_staleness_minutes: 5
+
+github_scrum_masters: "alice-sm,bob-sm" # Can force-unlock
+```
+
+## How It Works
+
+### Three-Tier Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ TIER 1: GitHub Issues (Source of Truth) │
+│ - Centralized coordination │
+│ - Story assignment = lock │
+│ - Real-time status labels │
+└────────────┬────────────────────────────────────────────────┘
+ │ Smart Sync (incremental)
+┌────────────┴────────────────────────────────────────────────┐
+│ TIER 2: Local Cache (Performance) │
+│ - Fast LLM Read tool access (<100ms) │
+│ - Full 12-section story content │
+│ - Epic context pre-fetch │
+└────────────┬────────────────────────────────────────────────┘
+ │ Committed after completion
+┌────────────┴────────────────────────────────────────────────┐
+│ TIER 3: Git Repository (Audit Trail) │
+│ - Historical story files │
+│ - Implementation code │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Story Locking Flow
+
+1. **Developer A** runs `/checkout-story story_key=2-5-auth`
+2. System assigns GitHub Issue to Developer A
+3. System adds `status:in-progress` label
+4. System creates local lock file with 8-hour timeout
+5. System pre-fetches all Epic 2 stories to cache
+
+6. **Developer B** tries `/checkout-story story_key=2-5-auth`
+7. System sees issue assigned to Developer A
+8. Returns error: "Story locked by @developerA"
+
+### Progress Sync Flow
+
+1. Developer completes task 3 of 10
+2. Workflow marks task `[x]` in story file
+3. Workflow updates sprint-status.yaml with progress
+4. **NEW:** Workflow posts comment to GitHub Issue:
+ ```
+ 📊 Task 3/10 complete (30%)
+ > Implement OAuth token refresh
+
+ _Progress synced at 2026-01-08T15:30:00Z_
+ ```
+5. PO sees progress in GitHub within seconds
+
+## Workflows
+
+### For Developers
+
+| Command | Description |
+|---------|-------------|
+| `/checkout-story story_key=X-Y-slug` | Lock story for development |
+| `/unlock-story story_key=X-Y-slug` | Release lock when done/blocked |
+| `/available-stories` | See unlocked stories |
+| `/lock-status` | View who's working on what |
+
+### For Scrum Masters
+
+| Command | Description |
+|---------|-------------|
+| `/lock-status` | View all locks, identify stale ones |
+| `/unlock-story story_key=X-Y-slug --force` | Force-unlock stale story |
+
+### For Product Owners
+
+| Command | Description |
+|---------|-------------|
+| `/new-story` | Create story in GitHub Issues |
+| `/update-story` | Modify ACs |
+| `/dashboard` | Sprint progress overview |
+| `/approve-story` | Sign off completed work |
+
+### PRD Crowdsourcing (Async Requirements)
+
+| Command | Description |
+|---------|-------------|
+| `/my-tasks` | View PRDs/Epics needing your attention |
+| `/prd-dashboard` | View all PRDs and their status |
+| `/create-prd` | Create new PRD draft |
+| `/open-feedback` | Open feedback round for PRD |
+| `/submit-feedback` | Submit feedback on PRD/Epic |
+| `/view-feedback` | View all feedback on PRD/Epic |
+| `/synthesize` | LLM synthesizes feedback into new version |
+| `/request-signoff` | Request stakeholder sign-off |
+| `/signoff` | Submit your sign-off decision |
+
+### Epic Crowdsourcing (Story Breakdown)
+
+| Command | Description |
+|---------|-------------|
+| `/create-epic` | Create epic from approved PRD |
+| `/open-epic-feedback` | Open feedback round for epic |
+| `/synthesize-epic` | Synthesize epic feedback |
+| `/epic-dashboard` | View epics with PRD lineage |
+
+## Cache Location
+
+Stories are cached in: `{output_folder}/cache/stories/`
+
+Cache metadata in: `{output_folder}/cache/.bmad-cache-meta.json`
+
+## Lock Files
+
+Local locks stored in: `.bmad/locks/{story_key}.lock`
+
+Lock file format:
+```yaml
+story_key: 2-5-auth
+github_issue: 105
+locked_by: developer-username
+locked_at: 2026-01-08T10:00:00Z
+timeout_at: 2026-01-08T18:00:00Z
+last_heartbeat: 2026-01-08T12:30:00Z
+epic_number: 2
+```
+
+## Troubleshooting
+
+### "GitHub MCP not accessible"
+
+1. Verify GitHub MCP is configured in Claude settings
+2. Check token has `repo` scope
+3. Test with: `mcp__github__get_me()`
+
+### "Story not found in GitHub"
+
+Run `/migrate-to-github` to sync local stories to GitHub Issues.
+
+### "Lock stolen"
+
+Another user was assigned in GitHub UI. Coordinate with your team.
+
+### Stale locks blocking sprint
+
+Scrum Master can force-unlock:
+```
+/unlock-story story_key=2-5-auth --force reason="Developer unavailable"
+```
+
+## Performance
+
+| Operation | Without Cache | With Cache | Improvement |
+|-----------|---------------|------------|-------------|
+| Read story | 2-3 seconds | <100ms | 20-30x faster |
+| Epic context | 16 seconds | 650ms | 25x faster |
+| API calls/hour | 500+ | <50 | 90% reduction |
+
+## Security
+
+- Lock verification before each task prevents unauthorized changes
+- GitHub assignment is source of truth (verified against)
+- Scrum Master override requires explicit permission
+- All operations use authenticated GitHub MCP
+
+## PRD & Epic Crowdsourcing
+
+GitHub Integration enables async stakeholder collaboration on requirements:
+
+### How It Works
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ CROWDSOURCE HIERARCHY │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ 📄 PRD (Product Requirements) │
+│ └── Feedback on: vision, goals, FRs, NFRs │
+│ Sign-off: All key stakeholders │
+│ │
+│ 📦 EPIC (Feature Breakdown) │
+│ └── Feedback on: scope, story split, priorities │
+│ Sign-off: Tech Lead + PO + domain expert │
+│ │
+│ 📝 STORY (Implementation Detail) │
+│ └── Refinement: Developer adjusts during checkout │
+│ Major issues escalate to epic revision │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Storage Architecture
+
+PRD content lives in markdown files, coordination happens via GitHub Issues:
+
+| Artifact | Where It Lives | Can Close? |
+|----------|---------------|------------|
+| PRD Document | `docs/prd/{prd-key}.md` | Never (it's a doc) |
+| Review Round | Issue: "PRD Review v2" | ✅ Yes, when round ends |
+| Feedback Item | Issue: linked to review | ✅ Yes, when processed |
+| Epic Document | `docs/epics/epic-{n}.md` | Never (it's a doc) |
+
+### Feedback Types
+
+| Type | Emoji | Description |
+|------|-------|-------------|
+| clarification | 📋 | Something is unclear |
+| concern | ⚠️ | Potential issue/risk |
+| suggestion | 💡 | Improvement idea |
+| addition | ➕ | Missing requirement |
+| priority | 🔢 | Disagree with prioritization |
+| scope | 📐 | Epic scope concern |
+| dependency | 🔗 | Blocking relationship |
+| technical_risk | 🔧 | Architectural concern |
+| story_split | ✂️ | Different breakdown suggested |
+
+### Sign-off Configuration
+
+PRDs support flexible sign-off thresholds:
+
+```yaml
+signoff_config:
+ threshold_type: "required_approvers" # or "count" or "percentage"
+ required: ["@po", "@tech-lead", "@security"]
+ optional: ["@ux", "@qa"]
+ minimum_optional: 1
+ block_threshold: 1
+```
+
+### Notification Channels
+
+Configure notifications in `module.yaml`:
+
+```yaml
+prd_notifications:
+ github_mentions:
+ enabled: true # Always on as baseline
+
+ slack:
+ enabled: false
+ webhook_url: ""
+ channel: "#prd-updates"
+
+ email:
+ enabled: false
+ smtp_config: ""
+```
+
+### Workflow Example
+
+1. **PO creates PRD** → Markdown file + Draft status
+2. **Open feedback round** → Creates GitHub Issue, notifies stakeholders
+3. **Stakeholders submit feedback** → Linked issues with labels
+4. **PO synthesizes feedback** → LLM merges input, resolves conflicts
+5. **Request sign-off** → Updates status, tracks approvals
+6. **All signed off** → PRD approved, ready for epic breakdown
diff --git a/src/modules/bmm/lib/cache/cache-manager.js b/src/modules/bmm/lib/cache/cache-manager.js
new file mode 100644
index 00000000..bdc330a1
--- /dev/null
+++ b/src/modules/bmm/lib/cache/cache-manager.js
@@ -0,0 +1,918 @@
+/**
+ * BMAD Enterprise Cache Manager
+ *
+ * Provides fast local caching for BMAD stories with GitHub as source of truth.
+ * Enables instant LLM Read tool access (<100ms vs 2-3s API calls).
+ *
+ * Architecture:
+ * - GitHub Issues = source of truth (coordination)
+ * - Local cache = performance optimization
+ * - Git repository = audit trail
+ *
+ * @module cache-manager
+ */
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+/**
+ * Cache metadata structure stored in .bmad-cache-meta.json
+ * Tracks sync state, staleness, and lock information per story, PRD, and Epic
+ */
+const CACHE_META_FILENAME = '.bmad-cache-meta.json';
+const DEFAULT_STALENESS_THRESHOLD_MINUTES = 5;
+
+/**
+ * Document types supported by the cache
+ */
+const DOCUMENT_TYPES = {
+ story: 'story',
+ prd: 'prd',
+ epic: 'epic'
+};
+
+/**
+ * CacheManager class - handles local story caching with GitHub sync
+ */
+class CacheManager {
+ /**
+ * @param {Object} config - Configuration object
+ * @param {string} config.cacheDir - Directory for cached story files
+ * @param {number} config.stalenessThresholdMinutes - Minutes before cache is considered stale
+ * @param {Object} config.github - GitHub configuration (owner, repo)
+ */
+ constructor(config) {
+ this.cacheDir = config.cacheDir;
+ this.stalenessThresholdMinutes = config.stalenessThresholdMinutes || DEFAULT_STALENESS_THRESHOLD_MINUTES;
+ this.github = config.github || {};
+ this.metaPath = path.join(this.cacheDir, CACHE_META_FILENAME);
+
+ // Ensure cache directory exists
+ this._ensureCacheDir();
+ }
+
+ /**
+ * Ensure cache directory exists
+ * @private
+ */
+ _ensureCacheDir() {
+ if (!fs.existsSync(this.cacheDir)) {
+ fs.mkdirSync(this.cacheDir, { recursive: true });
+ }
+
+ // Create subdirectories for each document type
+ const subdirs = ['stories', 'prds', 'epics'];
+ for (const subdir of subdirs) {
+ const dirPath = path.join(this.cacheDir, subdir);
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
+ }
+ }
+ }
+
+ /**
+ * Load cache metadata
+ * @returns {Object} Cache metadata object
+ */
+ loadMeta() {
+ if (!fs.existsSync(this.metaPath)) {
+ return this._initializeMeta();
+ }
+
+ try {
+ const content = fs.readFileSync(this.metaPath, 'utf8');
+ const meta = JSON.parse(content);
+ return this._migrateMeta(meta);
+ } catch (error) {
+ console.error(`Warning: Failed to parse cache meta, reinitializing: ${error.message}`);
+ return this._initializeMeta();
+ }
+ }
+
+ /**
+ * Initialize empty cache metadata
+ * @private
+ * @returns {Object} Fresh metadata object
+ */
+ _initializeMeta() {
+ const meta = {
+ version: '2.0.0',
+ last_sync: null,
+ github_owner: this.github.owner || null,
+ github_repo: this.github.repo || null,
+ stories: {},
+ prds: {},
+ epics: {}
+ };
+ this.saveMeta(meta);
+ return meta;
+ }
+
+ /**
+ * Migrate metadata from v1 to v2 if needed
+ * @private
+ * @param {Object} meta - Metadata object to migrate
+ * @returns {Object} Migrated metadata
+ */
+ _migrateMeta(meta) {
+ // If already v2 or higher, no migration needed
+ if (meta.version && meta.version.startsWith('2.')) {
+ return meta;
+ }
+
+ // Add PRD and Epic sections if missing
+ if (!meta.prds) {
+ meta.prds = {};
+ }
+ if (!meta.epics) {
+ meta.epics = {};
+ }
+ meta.version = '2.0.0';
+
+ this.saveMeta(meta);
+ return meta;
+ }
+
+ /**
+ * Save cache metadata atomically
+ * @param {Object} meta - Metadata object to save
+ */
+ saveMeta(meta) {
+ const tempPath = `${this.metaPath}.tmp`;
+
+ // Atomic write: write to temp file, then rename
+ fs.writeFileSync(tempPath, JSON.stringify(meta, null, 2), 'utf8');
+ fs.renameSync(tempPath, this.metaPath);
+ }
+
+ /**
+ * Get path for a cached story file
+ * @param {string} storyKey - Story identifier (e.g., "2-5-auth")
+ * @returns {string} Full path to cached story file
+ */
+ getStoryPath(storyKey) {
+ return path.join(this.cacheDir, 'stories', `${storyKey}.md`);
+ }
+
+ /**
+ * Read story from cache with staleness check
+ * @param {string} storyKey - Story identifier
+ * @param {Object} options - Options
+ * @param {boolean} options.ignoreStale - Return content even if stale
+ * @returns {Object|null} { content, meta, isStale } or null if not cached
+ */
+ readStory(storyKey, options = {}) {
+ const storyPath = this.getStoryPath(storyKey);
+ const meta = this.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!fs.existsSync(storyPath)) {
+ return null;
+ }
+
+ const content = fs.readFileSync(storyPath, 'utf8');
+ const isStale = this.isStale(storyKey);
+
+ if (isStale && !options.ignoreStale) {
+ return {
+ content,
+ meta: storyMeta,
+ isStale: true,
+ warning: `Story cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`
+ };
+ }
+
+ return {
+ content,
+ meta: storyMeta,
+ isStale
+ };
+ }
+
+ /**
+ * Write story to cache with metadata update
+ * @param {string} storyKey - Story identifier
+ * @param {string} content - Story file content
+ * @param {Object} storyMeta - Metadata from GitHub issue
+ * @param {number} storyMeta.github_issue - Issue number
+ * @param {string} storyMeta.github_updated_at - Last update timestamp from GitHub
+ * @param {string} storyMeta.locked_by - Username who has the story locked (optional)
+ * @param {string} storyMeta.locked_until - Lock expiration timestamp (optional)
+ */
+ writeStory(storyKey, content, storyMeta = {}) {
+ const storyPath = this.getStoryPath(storyKey);
+ const tempPath = `${storyPath}.tmp`;
+
+ // Calculate content hash for change detection
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
+
+ // Atomic write
+ fs.writeFileSync(tempPath, content, 'utf8');
+ fs.renameSync(tempPath, storyPath);
+
+ // Update metadata
+ const meta = this.loadMeta();
+ meta.stories[storyKey] = {
+ github_issue: storyMeta.github_issue || meta.stories[storyKey]?.github_issue,
+ github_updated_at: storyMeta.github_updated_at || new Date().toISOString(),
+ cache_timestamp: new Date().toISOString(),
+ local_hash: contentHash,
+ locked_by: storyMeta.locked_by || null,
+ locked_until: storyMeta.locked_until || null
+ };
+
+ this.saveMeta(meta);
+
+ return {
+ storyKey,
+ path: storyPath,
+ hash: contentHash,
+ timestamp: meta.stories[storyKey].cache_timestamp
+ };
+ }
+
+ /**
+ * Invalidate cache for a story (force refresh on next access)
+ * @param {string} storyKey - Story identifier
+ */
+ invalidate(storyKey) {
+ const meta = this.loadMeta();
+
+ if (meta.stories[storyKey]) {
+ // Mark as stale by setting old timestamp
+ meta.stories[storyKey].cache_timestamp = '1970-01-01T00:00:00Z';
+ this.saveMeta(meta);
+ }
+ }
+
+ /**
+ * Invalidate all cached stories
+ */
+ invalidateAll() {
+ const meta = this.loadMeta();
+
+ for (const storyKey of Object.keys(meta.stories)) {
+ meta.stories[storyKey].cache_timestamp = '1970-01-01T00:00:00Z';
+ }
+
+ meta.last_sync = null;
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Check if a story's cache is stale
+ * @param {string} storyKey - Story identifier
+ * @returns {boolean} True if cache is stale or missing
+ */
+ isStale(storyKey) {
+ const meta = this.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.cache_timestamp) {
+ return true;
+ }
+
+ const cacheTime = new Date(storyMeta.cache_timestamp);
+ const now = new Date();
+ const ageMinutes = (now - cacheTime) / (1000 * 60);
+
+ return ageMinutes > this.stalenessThresholdMinutes;
+ }
+
+ /**
+ * Get cache age in minutes
+ * @param {string} storyKey - Story identifier
+ * @returns {number|null} Age in minutes or null if not cached
+ */
+ getCacheAge(storyKey) {
+ const meta = this.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.cache_timestamp) {
+ return null;
+ }
+
+ const cacheTime = new Date(storyMeta.cache_timestamp);
+ const now = new Date();
+ return Math.floor((now - cacheTime) / (1000 * 60));
+ }
+
+ /**
+ * Get last global sync timestamp
+ * @returns {string|null} ISO timestamp of last sync
+ */
+ getLastSync() {
+ const meta = this.loadMeta();
+ return meta.last_sync;
+ }
+
+ /**
+ * Update last sync timestamp
+ * @param {string} timestamp - ISO timestamp (defaults to now)
+ */
+ updateLastSync(timestamp = new Date().toISOString()) {
+ const meta = this.loadMeta();
+ meta.last_sync = timestamp;
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Get all cached story keys
+ * @returns {string[]} Array of story keys
+ */
+ listCachedStories() {
+ const meta = this.loadMeta();
+ return Object.keys(meta.stories);
+ }
+
+ /**
+ * Get stories that need refresh (stale or missing)
+ * @returns {string[]} Array of stale story keys
+ */
+ getStaleStories() {
+ const meta = this.loadMeta();
+ return Object.keys(meta.stories).filter(key => this.isStale(key));
+ }
+
+ /**
+ * Update lock information for a story
+ * @param {string} storyKey - Story identifier
+ * @param {Object} lockInfo - Lock information
+ * @param {string} lockInfo.locked_by - Username
+ * @param {string} lockInfo.locked_until - Expiration timestamp
+ */
+ updateLock(storyKey, lockInfo) {
+ const meta = this.loadMeta();
+
+ if (!meta.stories[storyKey]) {
+ meta.stories[storyKey] = {};
+ }
+
+ meta.stories[storyKey].locked_by = lockInfo.locked_by;
+ meta.stories[storyKey].locked_until = lockInfo.locked_until;
+
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Clear lock information for a story
+ * @param {string} storyKey - Story identifier
+ */
+ clearLock(storyKey) {
+ const meta = this.loadMeta();
+
+ if (meta.stories[storyKey]) {
+ meta.stories[storyKey].locked_by = null;
+ meta.stories[storyKey].locked_until = null;
+ this.saveMeta(meta);
+ }
+ }
+
+ /**
+ * Get lock status for a story
+ * @param {string} storyKey - Story identifier
+ * @returns {Object|null} Lock info or null if not locked
+ */
+ getLockStatus(storyKey) {
+ const meta = this.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.locked_by) {
+ return null;
+ }
+
+ // Check if lock expired
+ if (storyMeta.locked_until) {
+ const expiry = new Date(storyMeta.locked_until);
+ if (expiry < new Date()) {
+ return { expired: true, previously_locked_by: storyMeta.locked_by };
+ }
+ }
+
+ return {
+ locked_by: storyMeta.locked_by,
+ locked_until: storyMeta.locked_until,
+ expired: false
+ };
+ }
+
+ /**
+ * Get all locked stories
+ * @returns {Object[]} Array of { storyKey, locked_by, locked_until }
+ */
+ getLockedStories() {
+ const meta = this.loadMeta();
+ const locked = [];
+
+ for (const [storyKey, storyMeta] of Object.entries(meta.stories)) {
+ if (storyMeta.locked_by) {
+ const expiry = storyMeta.locked_until ? new Date(storyMeta.locked_until) : null;
+ const expired = expiry && expiry < new Date();
+
+ locked.push({
+ storyKey,
+ locked_by: storyMeta.locked_by,
+ locked_until: storyMeta.locked_until,
+ expired
+ });
+ }
+ }
+
+ return locked;
+ }
+
+ /**
+ * Check if content has changed from cached version
+ * @param {string} storyKey - Story identifier
+ * @param {string} newContent - New content to compare
+ * @returns {boolean} True if content differs
+ */
+ hasContentChanged(storyKey, newContent) {
+ const meta = this.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.local_hash) {
+ return true;
+ }
+
+ const newHash = crypto.createHash('sha256').update(newContent).digest('hex');
+ return newHash !== storyMeta.local_hash;
+ }
+
+ /**
+ * Delete a story from cache
+ * @param {string} storyKey - Story identifier
+ */
+ deleteStory(storyKey) {
+ const storyPath = this.getStoryPath(storyKey);
+
+ if (fs.existsSync(storyPath)) {
+ fs.unlinkSync(storyPath);
+ }
+
+ const meta = this.loadMeta();
+ delete meta.stories[storyKey];
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Get cache statistics
+ * @returns {Object} Cache statistics
+ */
+ getStats() {
+ const meta = this.loadMeta();
+ const storyCount = Object.keys(meta.stories).length;
+ const staleCount = this.getStaleStories().length;
+ const lockedCount = this.getLockedStories().filter(s => !s.expired).length;
+
+ let totalSize = 0;
+ const storiesDir = path.join(this.cacheDir, 'stories');
+
+ if (fs.existsSync(storiesDir)) {
+ const files = fs.readdirSync(storiesDir);
+ for (const file of files) {
+ const stats = fs.statSync(path.join(storiesDir, file));
+ totalSize += stats.size;
+ }
+ }
+
+ return {
+ story_count: storyCount,
+ stale_count: staleCount,
+ locked_count: lockedCount,
+ fresh_count: storyCount - staleCount,
+ total_size_bytes: totalSize,
+ total_size_kb: Math.round(totalSize / 1024),
+ last_sync: meta.last_sync,
+ staleness_threshold_minutes: this.stalenessThresholdMinutes
+ };
+ }
+
+ // ============ PRD Methods ============
+
+ /**
+ * Get path for a cached PRD file
+ * @param {string} prdKey - PRD identifier (e.g., "user-auth")
+ * @returns {string} Full path to cached PRD file
+ */
+ getPrdPath(prdKey) {
+ return path.join(this.cacheDir, 'prds', `${prdKey}.md`);
+ }
+
+ /**
+ * Read PRD from cache with staleness check
+ * @param {string} prdKey - PRD identifier
+ * @param {Object} options - Options
+ * @param {boolean} options.ignoreStale - Return content even if stale
+ * @returns {Object|null} { content, meta, isStale } or null if not cached
+ */
+ readPrd(prdKey, options = {}) {
+ const prdPath = this.getPrdPath(prdKey);
+ const meta = this.loadMeta();
+ const prdMeta = meta.prds[prdKey];
+
+ if (!fs.existsSync(prdPath)) {
+ return null;
+ }
+
+ const content = fs.readFileSync(prdPath, 'utf8');
+ const isStale = this._isDocumentStale(prdMeta);
+
+ if (isStale && !options.ignoreStale) {
+ return {
+ content,
+ meta: prdMeta,
+ isStale: true,
+ warning: `PRD cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`
+ };
+ }
+
+ return { content, meta: prdMeta, isStale };
+ }
+
+ /**
+ * Write PRD to cache with metadata update
+ * @param {string} prdKey - PRD identifier
+ * @param {string} content - PRD markdown content
+ * @param {Object} prdMeta - Metadata
+ * @param {number} prdMeta.review_issue - Review round issue number
+ * @param {number} prdMeta.version - PRD version number
+ * @param {string} prdMeta.status - PRD status (draft, feedback, synthesis, signoff, approved)
+ * @param {string[]} prdMeta.stakeholders - Array of stakeholder usernames
+ */
+ writePrd(prdKey, content, prdMeta = {}) {
+ const prdPath = this.getPrdPath(prdKey);
+ const tempPath = `${prdPath}.tmp`;
+
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
+
+ // Atomic write
+ fs.writeFileSync(tempPath, content, 'utf8');
+ fs.renameSync(tempPath, prdPath);
+
+ // Update metadata
+ const meta = this.loadMeta();
+ meta.prds[prdKey] = {
+ review_issue: prdMeta.review_issue || meta.prds[prdKey]?.review_issue,
+ version: prdMeta.version || meta.prds[prdKey]?.version || 1,
+ status: prdMeta.status || meta.prds[prdKey]?.status || 'draft',
+ stakeholders: prdMeta.stakeholders || meta.prds[prdKey]?.stakeholders || [],
+ owner: prdMeta.owner || meta.prds[prdKey]?.owner,
+ feedback_deadline: prdMeta.feedback_deadline || meta.prds[prdKey]?.feedback_deadline,
+ signoff_deadline: prdMeta.signoff_deadline || meta.prds[prdKey]?.signoff_deadline,
+ cache_timestamp: new Date().toISOString(),
+ local_hash: contentHash
+ };
+
+ this.saveMeta(meta);
+
+ return {
+ prdKey,
+ path: prdPath,
+ hash: contentHash,
+ timestamp: meta.prds[prdKey].cache_timestamp
+ };
+ }
+
+ /**
+ * Update PRD status
+ * @param {string} prdKey - PRD identifier
+ * @param {string} status - New status
+ */
+ updatePrdStatus(prdKey, status) {
+ const meta = this.loadMeta();
+
+ if (!meta.prds[prdKey]) {
+ throw new Error(`PRD not found in cache: ${prdKey}`);
+ }
+
+ meta.prds[prdKey].status = status;
+ meta.prds[prdKey].cache_timestamp = new Date().toISOString();
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Get all cached PRD keys
+ * @returns {string[]} Array of PRD keys
+ */
+ listCachedPrds() {
+ const meta = this.loadMeta();
+ return Object.keys(meta.prds);
+ }
+
+ /**
+ * Get PRDs by status
+ * @param {string} status - Filter by status (draft, feedback, synthesis, signoff, approved)
+ * @returns {Object[]} Array of { prdKey, meta }
+ */
+ getPrdsByStatus(status) {
+ const meta = this.loadMeta();
+ return Object.entries(meta.prds)
+ .filter(([_, prdMeta]) => prdMeta.status === status)
+ .map(([prdKey, prdMeta]) => ({ prdKey, meta: prdMeta }));
+ }
+
+ /**
+ * Get PRDs needing attention from a user
+ * @param {string} username - GitHub username
+ * @returns {Object} { pendingFeedback: [], pendingSignoff: [] }
+ */
+ getPrdsNeedingAttention(username) {
+ const meta = this.loadMeta();
+ const normalizedUser = username.replace('@', '');
+
+ const pendingFeedback = [];
+ const pendingSignoff = [];
+
+ for (const [prdKey, prdMeta] of Object.entries(meta.prds)) {
+ const isStakeholder = prdMeta.stakeholders?.some(s =>
+ s.replace('@', '') === normalizedUser
+ );
+
+ if (!isStakeholder) continue;
+
+ if (prdMeta.status === 'feedback') {
+ pendingFeedback.push({ prdKey, meta: prdMeta });
+ } else if (prdMeta.status === 'signoff') {
+ pendingSignoff.push({ prdKey, meta: prdMeta });
+ }
+ }
+
+ return { pendingFeedback, pendingSignoff };
+ }
+
+ /**
+ * Delete a PRD from cache
+ * @param {string} prdKey - PRD identifier
+ */
+ deletePrd(prdKey) {
+ const prdPath = this.getPrdPath(prdKey);
+
+ if (fs.existsSync(prdPath)) {
+ fs.unlinkSync(prdPath);
+ }
+
+ const meta = this.loadMeta();
+ delete meta.prds[prdKey];
+ this.saveMeta(meta);
+ }
+
+ // ============ Epic Methods ============
+
+ /**
+ * Get path for a cached Epic file
+ * @param {string} epicKey - Epic identifier (e.g., "2")
+ * @returns {string} Full path to cached Epic file
+ */
+ getEpicPath(epicKey) {
+ return path.join(this.cacheDir, 'epics', `epic-${epicKey}.md`);
+ }
+
+ /**
+ * Read Epic from cache with staleness check
+ * @param {string} epicKey - Epic identifier
+ * @param {Object} options - Options
+ * @param {boolean} options.ignoreStale - Return content even if stale
+ * @returns {Object|null} { content, meta, isStale } or null if not cached
+ */
+ readEpic(epicKey, options = {}) {
+ const epicPath = this.getEpicPath(epicKey);
+ const meta = this.loadMeta();
+ const epicMeta = meta.epics[epicKey];
+
+ if (!fs.existsSync(epicPath)) {
+ return null;
+ }
+
+ const content = fs.readFileSync(epicPath, 'utf8');
+ const isStale = this._isDocumentStale(epicMeta);
+
+ if (isStale && !options.ignoreStale) {
+ return {
+ content,
+ meta: epicMeta,
+ isStale: true,
+ warning: `Epic cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`
+ };
+ }
+
+ return { content, meta: epicMeta, isStale };
+ }
+
+ /**
+ * Write Epic to cache with metadata update
+ * @param {string} epicKey - Epic identifier
+ * @param {string} content - Epic markdown content
+ * @param {Object} epicMeta - Metadata
+ * @param {number} epicMeta.github_issue - Epic GitHub issue number
+ * @param {string} epicMeta.prd_key - Source PRD key (lineage)
+ * @param {number} epicMeta.version - Epic version number
+ * @param {string} epicMeta.status - Epic status
+ * @param {string[]} epicMeta.stories - Array of story keys in this epic
+ */
+ writeEpic(epicKey, content, epicMeta = {}) {
+ const epicPath = this.getEpicPath(epicKey);
+ const tempPath = `${epicPath}.tmp`;
+
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
+
+ // Atomic write
+ fs.writeFileSync(tempPath, content, 'utf8');
+ fs.renameSync(tempPath, epicPath);
+
+ // Update metadata
+ const meta = this.loadMeta();
+ meta.epics[epicKey] = {
+ github_issue: epicMeta.github_issue || meta.epics[epicKey]?.github_issue,
+ prd_key: epicMeta.prd_key || meta.epics[epicKey]?.prd_key,
+ version: epicMeta.version || meta.epics[epicKey]?.version || 1,
+ status: epicMeta.status || meta.epics[epicKey]?.status || 'draft',
+ stories: epicMeta.stories || meta.epics[epicKey]?.stories || [],
+ review_issue: epicMeta.review_issue || meta.epics[epicKey]?.review_issue,
+ stakeholders: epicMeta.stakeholders || meta.epics[epicKey]?.stakeholders || [],
+ feedback_deadline: epicMeta.feedback_deadline || meta.epics[epicKey]?.feedback_deadline,
+ cache_timestamp: new Date().toISOString(),
+ local_hash: contentHash
+ };
+
+ this.saveMeta(meta);
+
+ return {
+ epicKey,
+ path: epicPath,
+ hash: contentHash,
+ timestamp: meta.epics[epicKey].cache_timestamp
+ };
+ }
+
+ /**
+ * Update Epic status
+ * @param {string} epicKey - Epic identifier
+ * @param {string} status - New status
+ */
+ updateEpicStatus(epicKey, status) {
+ const meta = this.loadMeta();
+
+ if (!meta.epics[epicKey]) {
+ throw new Error(`Epic not found in cache: ${epicKey}`);
+ }
+
+ meta.epics[epicKey].status = status;
+ meta.epics[epicKey].cache_timestamp = new Date().toISOString();
+ this.saveMeta(meta);
+ }
+
+ /**
+ * Get all cached Epic keys
+ * @returns {string[]} Array of Epic keys
+ */
+ listCachedEpics() {
+ const meta = this.loadMeta();
+ return Object.keys(meta.epics);
+ }
+
+ /**
+ * Get Epics by PRD (lineage tracking)
+ * @param {string} prdKey - PRD key to filter by
+ * @returns {Object[]} Array of { epicKey, meta }
+ */
+ getEpicsByPrd(prdKey) {
+ const meta = this.loadMeta();
+ return Object.entries(meta.epics)
+ .filter(([_, epicMeta]) => epicMeta.prd_key === prdKey)
+ .map(([epicKey, epicMeta]) => ({ epicKey, meta: epicMeta }));
+ }
+
+ /**
+ * Get Epics needing attention from a user
+ * @param {string} username - GitHub username
+ * @returns {Object} { pendingFeedback: [] }
+ */
+ getEpicsNeedingAttention(username) {
+ const meta = this.loadMeta();
+ const normalizedUser = username.replace('@', '');
+
+ const pendingFeedback = [];
+
+ for (const [epicKey, epicMeta] of Object.entries(meta.epics)) {
+ const isStakeholder = epicMeta.stakeholders?.some(s =>
+ s.replace('@', '') === normalizedUser
+ );
+
+ if (!isStakeholder) continue;
+
+ if (epicMeta.status === 'feedback') {
+ pendingFeedback.push({ epicKey, meta: epicMeta });
+ }
+ }
+
+ return { pendingFeedback };
+ }
+
+ /**
+ * Delete an Epic from cache
+ * @param {string} epicKey - Epic identifier
+ */
+ deleteEpic(epicKey) {
+ const epicPath = this.getEpicPath(epicKey);
+
+ if (fs.existsSync(epicPath)) {
+ fs.unlinkSync(epicPath);
+ }
+
+ const meta = this.loadMeta();
+ delete meta.epics[epicKey];
+ this.saveMeta(meta);
+ }
+
+ // ============ Generic Document Methods ============
+
+ /**
+ * Check if a document's cache is stale
+ * @private
+ * @param {Object} docMeta - Document metadata
+ * @returns {boolean} True if stale or missing
+ */
+ _isDocumentStale(docMeta) {
+ if (!docMeta || !docMeta.cache_timestamp) {
+ return true;
+ }
+
+ const cacheTime = new Date(docMeta.cache_timestamp);
+ const now = new Date();
+ const ageMinutes = (now - cacheTime) / (1000 * 60);
+
+ return ageMinutes > this.stalenessThresholdMinutes;
+ }
+
+ /**
+ * Get unified "my tasks" for a user across PRDs and Epics
+ * @param {string} username - GitHub username
+ * @returns {Object} { prds: { pendingFeedback, pendingSignoff }, epics: { pendingFeedback } }
+ */
+ getMyTasks(username) {
+ return {
+ prds: this.getPrdsNeedingAttention(username),
+ epics: this.getEpicsNeedingAttention(username)
+ };
+ }
+
+ /**
+ * Get extended cache statistics including PRDs and Epics
+ * @returns {Object} Extended cache statistics
+ */
+ getExtendedStats() {
+ const meta = this.loadMeta();
+ const baseStats = this.getStats();
+
+ // Calculate PRD stats
+ const prdCount = Object.keys(meta.prds).length;
+ const prdsByStatus = {};
+ for (const prdMeta of Object.values(meta.prds)) {
+ prdsByStatus[prdMeta.status] = (prdsByStatus[prdMeta.status] || 0) + 1;
+ }
+
+ // Calculate Epic stats
+ const epicCount = Object.keys(meta.epics).length;
+ const epicsByStatus = {};
+ for (const epicMeta of Object.values(meta.epics)) {
+ epicsByStatus[epicMeta.status] = (epicsByStatus[epicMeta.status] || 0) + 1;
+ }
+
+ // Calculate total size including PRDs and Epics
+ let prdSize = 0;
+ let epicSize = 0;
+
+ const prdsDir = path.join(this.cacheDir, 'prds');
+ if (fs.existsSync(prdsDir)) {
+ const files = fs.readdirSync(prdsDir);
+ for (const file of files) {
+ const stats = fs.statSync(path.join(prdsDir, file));
+ prdSize += stats.size;
+ }
+ }
+
+ const epicsDir = path.join(this.cacheDir, 'epics');
+ if (fs.existsSync(epicsDir)) {
+ const files = fs.readdirSync(epicsDir);
+ for (const file of files) {
+ const stats = fs.statSync(path.join(epicsDir, file));
+ epicSize += stats.size;
+ }
+ }
+
+ return {
+ ...baseStats,
+ prd_count: prdCount,
+ prds_by_status: prdsByStatus,
+ prd_size_kb: Math.round(prdSize / 1024),
+ epic_count: epicCount,
+ epics_by_status: epicsByStatus,
+ epic_size_kb: Math.round(epicSize / 1024),
+ total_size_kb: baseStats.total_size_kb + Math.round(prdSize / 1024) + Math.round(epicSize / 1024)
+ };
+ }
+}
+
+module.exports = { CacheManager, CACHE_META_FILENAME, DOCUMENT_TYPES };
diff --git a/src/modules/bmm/lib/cache/index.js b/src/modules/bmm/lib/cache/index.js
new file mode 100644
index 00000000..2b44528f
--- /dev/null
+++ b/src/modules/bmm/lib/cache/index.js
@@ -0,0 +1,44 @@
+/**
+ * BMAD Enterprise Cache System
+ *
+ * Provides fast local caching for BMAD stories with GitHub as source of truth.
+ *
+ * Usage:
+ * ```javascript
+ * const { CacheManager, SyncEngine } = require('./lib/cache');
+ *
+ * const cache = new CacheManager({
+ * cacheDir: '/path/to/cache',
+ * stalenessThresholdMinutes: 5,
+ * github: { owner: 'myorg', repo: 'myrepo' }
+ * });
+ *
+ * const sync = new SyncEngine({
+ * cacheManager: cache,
+ * github: { owner: 'myorg', repo: 'myrepo' },
+ * githubClient: async (method, params) => { ... }
+ * });
+ *
+ * // Incremental sync
+ * await sync.incrementalSync();
+ *
+ * // Read story from cache
+ * const story = cache.readStory('2-5-auth');
+ *
+ * // Pre-fetch epic context
+ * await sync.preFetchEpic(2);
+ * ```
+ *
+ * @module cache
+ */
+
+const { CacheManager, CACHE_META_FILENAME } = require('./cache-manager');
+const { SyncEngine, RETRY_BACKOFF_MS, MAX_RETRIES } = require('./sync-engine');
+
+module.exports = {
+ CacheManager,
+ SyncEngine,
+ CACHE_META_FILENAME,
+ RETRY_BACKOFF_MS,
+ MAX_RETRIES
+};
diff --git a/src/modules/bmm/lib/cache/sync-engine.js b/src/modules/bmm/lib/cache/sync-engine.js
new file mode 100644
index 00000000..4ad38c97
--- /dev/null
+++ b/src/modules/bmm/lib/cache/sync-engine.js
@@ -0,0 +1,659 @@
+/**
+ * BMAD Enterprise Sync Engine
+ *
+ * Handles synchronization between GitHub Issues (source of truth)
+ * and local cache (performance optimization).
+ *
+ * Features:
+ * - Incremental sync (only fetch changed stories)
+ * - Epic pre-fetch (batch load for context)
+ * - Retry with exponential backoff
+ * - Write verification
+ *
+ * @module sync-engine
+ */
+
+const { CacheManager } = require('./cache-manager');
+
+/**
+ * Retry configuration matching migrate-to-github patterns
+ */
+const RETRY_BACKOFF_MS = [1000, 3000, 9000]; // 1s, 3s, 9s
+const MAX_RETRIES = 3;
+
+/**
+ * SyncEngine class - handles GitHub <-> Cache synchronization
+ */
+class SyncEngine {
+ /**
+ * @param {Object} config - Configuration object
+ * @param {CacheManager} config.cacheManager - Cache manager instance
+ * @param {Object} config.github - GitHub configuration
+ * @param {string} config.github.owner - Repository owner
+ * @param {string} config.github.repo - Repository name
+ * @param {Function} config.githubClient - GitHub MCP client function
+ */
+ constructor(config) {
+ this.cache = config.cacheManager;
+ this.github = config.github;
+ this.githubClient = config.githubClient;
+ this.syncInProgress = false;
+ }
+
+ /**
+ * Sleep utility for retry backoff
+ * @private
+ */
+ async _sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ /**
+ * Retry operation with exponential backoff
+ * @private
+ */
+ async _retryWithBackoff(operation, operationName = 'operation') {
+ let lastError;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ return await operation();
+ } catch (error) {
+ lastError = error;
+
+ if (attempt < MAX_RETRIES) {
+ const backoffMs = RETRY_BACKOFF_MS[attempt];
+ console.log(`⚠️ ${operationName} failed, retry ${attempt + 1}/${MAX_RETRIES} in ${backoffMs}ms: ${error.message}`);
+ await this._sleep(backoffMs);
+ }
+ }
+ }
+
+ throw new Error(`${operationName} failed after ${MAX_RETRIES} retries: ${lastError.message}`);
+ }
+
+ /**
+ * Parse story key from GitHub issue
+ * @private
+ */
+ _extractStoryKey(issue) {
+ // Look for story: label first
+ const storyLabel = issue.labels?.find(l =>
+ (typeof l === 'string' ? l : l.name)?.startsWith('story:')
+ );
+
+ if (storyLabel) {
+ const labelName = typeof storyLabel === 'string' ? storyLabel : storyLabel.name;
+ return labelName.replace('story:', '');
+ }
+
+ // Fallback: extract from title "Story X-Y-name: ..."
+ const titleMatch = issue.title?.match(/Story\s+(\d+-\d+-[a-zA-Z0-9-]+)/i);
+ if (titleMatch) {
+ return titleMatch[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Convert GitHub issue to story file content
+ * @private
+ */
+ _convertIssueToStoryContent(issue) {
+ const storyKey = this._extractStoryKey(issue);
+ const lines = [];
+
+ // Extract sections from issue body
+ lines.push(`# Story ${storyKey}: ${issue.title.replace(/Story\s+[\d-]+[a-zA-Z-]+:\s*/i, '')}`);
+ lines.push('');
+ lines.push(`**GitHub Issue:** #${issue.number}`);
+ lines.push(`**Status:** ${this._extractStatus(issue)}`);
+ lines.push(`**Assignee:** ${issue.assignee?.login || 'Unassigned'}`);
+ lines.push(`**Last Updated:** ${issue.updated_at}`);
+ lines.push('');
+
+ // Include original body
+ if (issue.body) {
+ lines.push(issue.body);
+ }
+
+ lines.push('');
+ lines.push('---');
+ lines.push(`_Synced from GitHub at ${new Date().toISOString()}_`);
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Extract status from issue labels
+ * @private
+ */
+ _extractStatus(issue) {
+ const statusLabel = issue.labels?.find(l => {
+ const name = typeof l === 'string' ? l : l.name;
+ return name?.startsWith('status:');
+ });
+
+ if (statusLabel) {
+ const name = typeof statusLabel === 'string' ? statusLabel : statusLabel.name;
+ return name.replace('status:', '');
+ }
+
+ return issue.state === 'closed' ? 'done' : 'backlog';
+ }
+
+ /**
+ * Incremental sync - fetch only stories changed since last sync
+ * This is the primary sync method, called every 5 minutes or on-demand
+ *
+ * @param {Object} options - Sync options
+ * @param {boolean} options.force - Force full sync even if cache is fresh
+ * @returns {Object} Sync result { updated: [], unchanged: [], errors: [] }
+ */
+ async incrementalSync(options = {}) {
+ if (this.syncInProgress) {
+ console.log('⏳ Sync already in progress, skipping...');
+ return { skipped: true, reason: 'sync_in_progress' };
+ }
+
+ this.syncInProgress = true;
+ const result = { updated: [], unchanged: [], errors: [], startTime: new Date() };
+
+ try {
+ const lastSync = options.force ? null : this.cache.getLastSync();
+
+ console.log(`🔄 Starting incremental sync...`);
+ console.log(` Last sync: ${lastSync || 'never'}`);
+
+ // Build search query for changed stories
+ let query = `repo:${this.github.owner}/${this.github.repo} label:type:story`;
+
+ if (lastSync) {
+ // Only fetch stories updated since last sync
+ const since = new Date(lastSync).toISOString().split('T')[0];
+ query += ` updated:>=${since}`;
+ }
+
+ // Search for updated stories (single API call)
+ const searchResult = await this._retryWithBackoff(
+ async () => this.githubClient('search_issues', { query }),
+ 'Search for updated stories'
+ );
+
+ const issues = searchResult.items || [];
+ console.log(` Found ${issues.length} stories to sync`);
+
+ // Sync each updated story
+ for (const issue of issues) {
+ const storyKey = this._extractStoryKey(issue);
+
+ if (!storyKey) {
+ console.log(` ⚠️ Skipping issue #${issue.number} - no story key found`);
+ result.errors.push({ issue: issue.number, error: 'No story key' });
+ continue;
+ }
+
+ try {
+ await this.syncStory(storyKey, issue);
+ result.updated.push(storyKey);
+ } catch (error) {
+ console.log(` ❌ Failed to sync ${storyKey}: ${error.message}`);
+ result.errors.push({ storyKey, error: error.message });
+ }
+ }
+
+ // Update last sync timestamp
+ this.cache.updateLastSync();
+
+ result.endTime = new Date();
+ result.duration = result.endTime - result.startTime;
+
+ console.log(`✅ Sync complete: ${result.updated.length} updated, ${result.errors.length} errors`);
+
+ return result;
+
+ } finally {
+ this.syncInProgress = false;
+ }
+ }
+
+ /**
+ * Full sync - fetch all stories (initial cache population)
+ *
+ * @returns {Object} Sync result
+ */
+ async fullSync() {
+ console.log('🔄 Starting full sync (initial cache population)...');
+
+ // Invalidate all and force sync
+ this.cache.invalidateAll();
+ return this.incrementalSync({ force: true });
+ }
+
+ /**
+ * Sync a single story from GitHub to cache
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {Object} issue - Optional pre-fetched issue object
+ * @returns {Object} Sync result
+ */
+ async syncStory(storyKey, issue = null) {
+ // Fetch issue if not provided
+ if (!issue) {
+ const searchResult = await this._retryWithBackoff(
+ async () => this.githubClient('search_issues', {
+ query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`
+ }),
+ `Fetch story ${storyKey}`
+ );
+
+ const issues = searchResult.items || [];
+ if (issues.length === 0) {
+ throw new Error(`Story ${storyKey} not found in GitHub`);
+ }
+
+ issue = issues[0];
+ }
+
+ // Convert to story content
+ const content = this._convertIssueToStoryContent(issue);
+
+ // Check if content actually changed
+ if (!this.cache.hasContentChanged(storyKey, content)) {
+ console.log(` ⏭️ ${storyKey} unchanged`);
+ return { storyKey, status: 'unchanged' };
+ }
+
+ // Write to cache
+ const writeResult = this.cache.writeStory(storyKey, content, {
+ github_issue: issue.number,
+ github_updated_at: issue.updated_at,
+ locked_by: issue.assignee?.login || null,
+ locked_until: issue.assignee ? this._calculateLockExpiry() : null
+ });
+
+ console.log(` ✅ ${storyKey} synced (Issue #${issue.number})`);
+
+ return { storyKey, status: 'updated', issue: issue.number };
+ }
+
+ /**
+ * Calculate lock expiry (8 hours from now)
+ * @private
+ */
+ _calculateLockExpiry() {
+ const expiry = new Date();
+ expiry.setHours(expiry.getHours() + 8);
+ return expiry.toISOString();
+ }
+
+ /**
+ * Pre-fetch all stories in an epic (batch operation for context)
+ * Called on story checkout to give LLM full epic context
+ *
+ * @param {string|number} epicNumber - Epic number to pre-fetch
+ * @returns {Object} Pre-fetch result
+ */
+ async preFetchEpic(epicNumber) {
+ console.log(`📦 Pre-fetching Epic ${epicNumber} for context...`);
+
+ const result = { epicNumber, stories: [], errors: [] };
+
+ // Single API call for all stories in epic
+ const searchResult = await this._retryWithBackoff(
+ async () => this.githubClient('search_issues', {
+ query: `repo:${this.github.owner}/${this.github.repo} label:epic:${epicNumber} label:type:story`
+ }),
+ `Pre-fetch Epic ${epicNumber}`
+ );
+
+ const issues = searchResult.items || [];
+ console.log(` Found ${issues.length} stories in Epic ${epicNumber}`);
+
+ // Cache all stories in epic
+ for (const issue of issues) {
+ const storyKey = this._extractStoryKey(issue);
+
+ if (!storyKey) {
+ continue;
+ }
+
+ try {
+ await this.syncStory(storyKey, issue);
+ result.stories.push(storyKey);
+ } catch (error) {
+ result.errors.push({ storyKey, error: error.message });
+ }
+ }
+
+ console.log(`✅ Epic ${epicNumber} pre-fetched: ${result.stories.length} stories cached`);
+
+ return result;
+ }
+
+ /**
+ * Push local changes to GitHub (write-through)
+ * Used after task completion to update GitHub issue
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {Object} update - Update data
+ * @param {string} update.comment - Comment to add
+ * @param {string[]} update.addLabels - Labels to add
+ * @param {string[]} update.removeLabels - Labels to remove
+ * @param {string} update.assignee - New assignee (optional)
+ * @returns {Object} Update result
+ */
+ async pushToGitHub(storyKey, update) {
+ const meta = this.cache.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.github_issue) {
+ throw new Error(`Story ${storyKey} not synced - no GitHub issue number`);
+ }
+
+ const issueNumber = storyMeta.github_issue;
+
+ // Add comment if provided
+ if (update.comment) {
+ await this._retryWithBackoff(
+ async () => this.githubClient('add_issue_comment', {
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber,
+ body: update.comment
+ }),
+ `Add comment to issue #${issueNumber}`
+ );
+ }
+
+ // Update labels if provided
+ if (update.addLabels || update.removeLabels) {
+ // First get current issue
+ const issue = await this.githubClient('issue_read', {
+ method: 'get',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber
+ });
+
+ let labels = issue.labels?.map(l => typeof l === 'string' ? l : l.name) || [];
+
+ // Remove labels
+ if (update.removeLabels) {
+ labels = labels.filter(l => !update.removeLabels.includes(l));
+ }
+
+ // Add labels
+ if (update.addLabels) {
+ for (const label of update.addLabels) {
+ if (!labels.includes(label)) {
+ labels.push(label);
+ }
+ }
+ }
+
+ await this._retryWithBackoff(
+ async () => this.githubClient('issue_write', {
+ method: 'update',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber,
+ labels
+ }),
+ `Update labels on issue #${issueNumber}`
+ );
+ }
+
+ // Verify write succeeded
+ await this._sleep(1000); // GitHub eventual consistency
+
+ const verify = await this.githubClient('issue_read', {
+ method: 'get',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber
+ });
+
+ console.log(`✅ GitHub issue #${issueNumber} updated and verified`);
+
+ return {
+ storyKey,
+ issueNumber,
+ verified: true
+ };
+ }
+
+ /**
+ * Sync progress update to GitHub
+ * Called after each task completion in dev-story workflow
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {Object} progress - Progress data
+ * @param {number} progress.taskNum - Current task number
+ * @param {number} progress.totalTasks - Total tasks
+ * @param {string} progress.taskDescription - Task description
+ * @param {number} progress.percentage - Completion percentage
+ */
+ async syncProgress(storyKey, progress) {
+ const comment =
+ `📊 **Task ${progress.taskNum}/${progress.totalTasks} complete** (${progress.percentage}%)\n\n` +
+ `> ${progress.taskDescription}\n\n` +
+ `_Progress synced at ${new Date().toISOString()}_`;
+
+ return this.pushToGitHub(storyKey, { comment });
+ }
+
+ /**
+ * Assign story to user (lock acquisition)
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {string} username - GitHub username
+ * @returns {Object} Assignment result
+ */
+ async assignStory(storyKey, username) {
+ const meta = this.cache.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.github_issue) {
+ // Need to find the issue first
+ const searchResult = await this.githubClient('search_issues', {
+ query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`
+ });
+
+ if (!searchResult.items?.length) {
+ throw new Error(`Story ${storyKey} not found in GitHub`);
+ }
+
+ storyMeta.github_issue = searchResult.items[0].number;
+ }
+
+ const issueNumber = storyMeta.github_issue;
+
+ // Assign user and update status label
+ await this._retryWithBackoff(
+ async () => this.githubClient('issue_write', {
+ method: 'update',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber,
+ assignees: [username]
+ }),
+ `Assign issue #${issueNumber} to ${username}`
+ );
+
+ // Update status label to in-progress
+ await this.pushToGitHub(storyKey, {
+ addLabels: ['status:in-progress'],
+ removeLabels: ['status:backlog', 'status:ready-for-dev'],
+ comment: `🔒 **Story locked by @${username}**\n\nLock expires in 8 hours.`
+ });
+
+ // Update cache
+ const lockExpiry = this._calculateLockExpiry();
+ this.cache.updateLock(storyKey, {
+ locked_by: username,
+ locked_until: lockExpiry
+ });
+
+ // Verify assignment
+ await this._sleep(1000);
+
+ const verify = await this.githubClient('issue_read', {
+ method: 'get',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber
+ });
+
+ if (!verify.assignees?.some(a => a.login === username)) {
+ throw new Error('Assignment verification failed');
+ }
+
+ console.log(`✅ Story ${storyKey} assigned to @${username}`);
+
+ return {
+ storyKey,
+ issueNumber,
+ assignee: username,
+ lockExpiry
+ };
+ }
+
+ /**
+ * Unassign story (lock release)
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {string} reason - Reason for unlock (optional)
+ * @returns {Object} Unassignment result
+ */
+ async unassignStory(storyKey, reason = null) {
+ const meta = this.cache.loadMeta();
+ const storyMeta = meta.stories[storyKey];
+
+ if (!storyMeta || !storyMeta.github_issue) {
+ throw new Error(`Story ${storyKey} not synced - cannot unassign`);
+ }
+
+ const issueNumber = storyMeta.github_issue;
+
+ // Remove assignees
+ await this._retryWithBackoff(
+ async () => this.githubClient('issue_write', {
+ method: 'update',
+ owner: this.github.owner,
+ repo: this.github.repo,
+ issue_number: issueNumber,
+ assignees: []
+ }),
+ `Unassign issue #${issueNumber}`
+ );
+
+ // Update status label
+ await this.pushToGitHub(storyKey, {
+ addLabels: ['status:ready-for-dev'],
+ removeLabels: ['status:in-progress'],
+ comment: `🔓 **Story unlocked**${reason ? `\n\nReason: ${reason}` : ''}`
+ });
+
+ // Clear cache lock
+ this.cache.clearLock(storyKey);
+
+ console.log(`✅ Story ${storyKey} unlocked`);
+
+ return { storyKey, issueNumber, unlocked: true };
+ }
+
+ /**
+ * Check if story is available (not locked by another user)
+ *
+ * @param {string} storyKey - Story identifier
+ * @param {string} currentUser - Current user's username
+ * @returns {Object} Availability info
+ */
+ async checkAvailability(storyKey, currentUser) {
+ // First check cache (fast)
+ const cacheLock = this.cache.getLockStatus(storyKey);
+
+ if (cacheLock && !cacheLock.expired && cacheLock.locked_by !== currentUser) {
+ return {
+ available: false,
+ locked_by: cacheLock.locked_by,
+ locked_until: cacheLock.locked_until,
+ source: 'cache'
+ };
+ }
+
+ // Verify with GitHub (source of truth)
+ const searchResult = await this.githubClient('search_issues', {
+ query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`
+ });
+
+ if (!searchResult.items?.length) {
+ return { available: false, error: 'Story not found in GitHub' };
+ }
+
+ const issue = searchResult.items[0];
+
+ if (issue.assignee && issue.assignee.login !== currentUser) {
+ // Update cache with GitHub truth
+ this.cache.updateLock(storyKey, {
+ locked_by: issue.assignee.login,
+ locked_until: this._calculateLockExpiry()
+ });
+
+ return {
+ available: false,
+ locked_by: issue.assignee.login,
+ github_issue: issue.number,
+ source: 'github'
+ };
+ }
+
+ return {
+ available: true,
+ github_issue: issue.number
+ };
+ }
+
+ /**
+ * Get all available (unlocked) stories
+ *
+ * @param {Object} options - Filter options
+ * @param {string} options.epicNumber - Filter by epic
+ * @param {string} options.status - Filter by status
+ * @returns {Object[]} Array of available stories
+ */
+ async getAvailableStories(options = {}) {
+ let query = `repo:${this.github.owner}/${this.github.repo} label:type:story no:assignee`;
+
+ if (options.epicNumber) {
+ query += ` label:epic:${options.epicNumber}`;
+ }
+
+ if (options.status) {
+ query += ` label:status:${options.status}`;
+ } else {
+ // Default: ready-for-dev or backlog
+ query += ` (label:status:ready-for-dev OR label:status:backlog)`;
+ }
+
+ const searchResult = await this._retryWithBackoff(
+ async () => this.githubClient('search_issues', { query }),
+ 'Search for available stories'
+ );
+
+ const stories = (searchResult.items || []).map(issue => ({
+ storyKey: this._extractStoryKey(issue),
+ title: issue.title,
+ issueNumber: issue.number,
+ status: this._extractStatus(issue),
+ labels: issue.labels?.map(l => typeof l === 'string' ? l : l.name) || [],
+ url: issue.html_url
+ })).filter(s => s.storyKey); // Filter out any without valid story keys
+
+ return stories;
+ }
+}
+
+module.exports = { SyncEngine, RETRY_BACKOFF_MS, MAX_RETRIES };
diff --git a/src/modules/bmm/lib/crowdsource/feedback-manager.js b/src/modules/bmm/lib/crowdsource/feedback-manager.js
new file mode 100644
index 00000000..8771c728
--- /dev/null
+++ b/src/modules/bmm/lib/crowdsource/feedback-manager.js
@@ -0,0 +1,414 @@
+/**
+ * Feedback Manager - Generic feedback operations for PRD and Epic crowdsourcing
+ *
+ * Handles creation, querying, and status updates of feedback issues.
+ * Works with both PRDs and Epics through a common interface.
+ */
+
+const FEEDBACK_TYPES = {
+ clarification: {
+ label: 'feedback-type:clarification',
+ emoji: '📋',
+ description: 'Something is unclear or needs more detail'
+ },
+ concern: {
+ label: 'feedback-type:concern',
+ emoji: '⚠️',
+ description: 'Potential issue, risk, or problem'
+ },
+ suggestion: {
+ label: 'feedback-type:suggestion',
+ emoji: '💡',
+ description: 'Improvement idea or alternative approach'
+ },
+ addition: {
+ label: 'feedback-type:addition',
+ emoji: '➕',
+ description: 'Missing requirement or feature'
+ },
+ priority: {
+ label: 'feedback-type:priority',
+ emoji: '🔢',
+ description: 'Disagree with prioritization or ordering'
+ },
+ // Epic-specific types
+ scope: {
+ label: 'feedback-type:scope',
+ emoji: '📐',
+ description: 'Epic scope is too large or should be split'
+ },
+ dependency: {
+ label: 'feedback-type:dependency',
+ emoji: '🔗',
+ description: 'Dependency or blocking relationship'
+ },
+ technical_risk: {
+ label: 'feedback-type:technical-risk',
+ emoji: '🔧',
+ description: 'Technical or architectural concern'
+ },
+ story_split: {
+ label: 'feedback-type:story-split',
+ emoji: '✂️',
+ description: 'Suggest different story breakdown'
+ }
+};
+
+const FEEDBACK_STATUS = {
+ new: 'feedback-status:new',
+ reviewed: 'feedback-status:reviewed',
+ incorporated: 'feedback-status:incorporated',
+ deferred: 'feedback-status:deferred'
+};
+
+const PRIORITY_LEVELS = {
+ high: 'priority:high',
+ medium: 'priority:medium',
+ low: 'priority:low'
+};
+
+class FeedbackManager {
+ constructor(githubConfig) {
+ this.owner = githubConfig.owner;
+ this.repo = githubConfig.repo;
+ }
+
+ /**
+ * Create a new feedback issue linked to a review round
+ */
+ async createFeedback({
+ reviewIssueNumber,
+ documentKey, // prd:user-auth or epic:2
+ documentType, // 'prd' or 'epic'
+ section, // e.g., 'User Stories', 'FR-3'
+ feedbackType, // 'clarification', 'concern', etc.
+ priority, // 'high', 'medium', 'low'
+ title, // Brief title
+ content, // Detailed feedback
+ suggestedChange, // Optional proposed change
+ rationale, // Why this matters
+ submittedBy // @username
+ }) {
+ const typeConfig = FEEDBACK_TYPES[feedbackType];
+ if (!typeConfig) {
+ throw new Error(`Unknown feedback type: ${feedbackType}`);
+ }
+
+ const labels = [
+ `type:${documentType}-feedback`,
+ `${documentType}:${documentKey.split(':')[1]}`,
+ `linked-review:${reviewIssueNumber}`,
+ `feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`,
+ typeConfig.label,
+ FEEDBACK_STATUS.new,
+ PRIORITY_LEVELS[priority] || PRIORITY_LEVELS.medium
+ ];
+
+ const body = this._formatFeedbackBody({
+ reviewIssueNumber,
+ documentKey,
+ section,
+ feedbackType,
+ typeConfig,
+ priority,
+ content,
+ suggestedChange,
+ rationale,
+ submittedBy
+ });
+
+ // Create the feedback issue
+ const issue = await this._createIssue({
+ title: `${typeConfig.emoji} Feedback: ${title}`,
+ body,
+ labels
+ });
+
+ // Add comment to review issue linking to this feedback
+ await this._addLinkComment(reviewIssueNumber, issue.number, title, feedbackType, submittedBy);
+
+ return {
+ feedbackId: issue.number,
+ url: issue.html_url,
+ documentKey,
+ section,
+ feedbackType,
+ status: 'new'
+ };
+ }
+
+ /**
+ * Query all feedback for a document or review round
+ */
+ async getFeedback({
+ documentKey, // Optional: filter by document
+ reviewIssueNumber, // Optional: filter by review round
+ documentType, // 'prd' or 'epic'
+ status, // Optional: filter by status
+ section, // Optional: filter by section
+ feedbackType // Optional: filter by type
+ }) {
+ let query = `repo:${this.owner}/${this.repo} type:issue is:open`;
+ query += ` label:type:${documentType}-feedback`;
+
+ if (documentKey) {
+ const key = documentKey.includes(':') ? documentKey.split(':')[1] : documentKey;
+ query += ` label:${documentType}:${key}`;
+ }
+
+ if (reviewIssueNumber) {
+ query += ` label:linked-review:${reviewIssueNumber}`;
+ }
+
+ if (status) {
+ query += ` label:${FEEDBACK_STATUS[status] || status}`;
+ }
+
+ if (section) {
+ query += ` label:feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`;
+ }
+
+ if (feedbackType) {
+ const typeConfig = FEEDBACK_TYPES[feedbackType];
+ if (typeConfig) {
+ query += ` label:${typeConfig.label}`;
+ }
+ }
+
+ const results = await this._searchIssues(query);
+
+ return results.map(issue => this._parseFeedbackIssue(issue));
+ }
+
+ /**
+ * Group feedback by section for synthesis
+ */
+ async getFeedbackBySection(documentKey, documentType) {
+ const allFeedback = await this.getFeedback({ documentKey, documentType });
+
+ const bySection = {};
+ for (const fb of allFeedback) {
+ if (!bySection[fb.section]) {
+ bySection[fb.section] = [];
+ }
+ bySection[fb.section].push(fb);
+ }
+
+ return bySection;
+ }
+
+ /**
+ * Group feedback by type for analysis
+ */
+ async getFeedbackByType(documentKey, documentType) {
+ const allFeedback = await this.getFeedback({ documentKey, documentType });
+
+ const byType = {};
+ for (const fb of allFeedback) {
+ if (!byType[fb.feedbackType]) {
+ byType[fb.feedbackType] = [];
+ }
+ byType[fb.feedbackType].push(fb);
+ }
+
+ return byType;
+ }
+
+ /**
+ * Detect conflicts (multiple feedback on same section with different opinions)
+ */
+ async detectConflicts(documentKey, documentType) {
+ const bySection = await this.getFeedbackBySection(documentKey, documentType);
+ const conflicts = [];
+
+ for (const [section, feedbackList] of Object.entries(bySection)) {
+ if (feedbackList.length < 2) continue;
+
+ // Check for opposing views on the same topic
+ const concerns = feedbackList.filter(f => f.feedbackType === 'concern');
+ const suggestions = feedbackList.filter(f => f.feedbackType === 'suggestion');
+
+ if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) {
+ conflicts.push({
+ section,
+ feedbackItems: feedbackList,
+ conflictType: 'multiple_opinions',
+ summary: `${feedbackList.length} stakeholders have input on ${section}`
+ });
+ }
+ }
+
+ return conflicts;
+ }
+
+ /**
+ * Update feedback status
+ */
+ async updateFeedbackStatus(feedbackIssueNumber, newStatus, resolution = null) {
+ const statusLabel = FEEDBACK_STATUS[newStatus];
+ if (!statusLabel) {
+ throw new Error(`Unknown status: ${newStatus}`);
+ }
+
+ // Get current labels
+ const issue = await this._getIssue(feedbackIssueNumber);
+ const currentLabels = issue.labels.map(l => l.name);
+
+ // Remove old status labels, add new one
+ const newLabels = currentLabels
+ .filter(l => !l.startsWith('feedback-status:'))
+ .concat([statusLabel]);
+
+ await this._updateIssue(feedbackIssueNumber, { labels: newLabels });
+
+ // Add resolution comment if provided
+ if (resolution) {
+ await this._addComment(feedbackIssueNumber,
+ `**Status Updated: ${newStatus}**\n\n${resolution}`
+ );
+ }
+
+ // Close issue if incorporated or deferred
+ if (newStatus === 'incorporated' || newStatus === 'deferred') {
+ await this._closeIssue(feedbackIssueNumber,
+ newStatus === 'incorporated' ? 'completed' : 'not_planned'
+ );
+ }
+
+ return { feedbackId: feedbackIssueNumber, status: newStatus };
+ }
+
+ /**
+ * Get feedback statistics for a document
+ */
+ async getStats(documentKey, documentType) {
+ const allFeedback = await this.getFeedback({ documentKey, documentType });
+
+ const stats = {
+ total: allFeedback.length,
+ byType: {},
+ byStatus: {},
+ bySection: {},
+ byPriority: {},
+ submitters: new Set()
+ };
+
+ for (const fb of allFeedback) {
+ // By type
+ stats.byType[fb.feedbackType] = (stats.byType[fb.feedbackType] || 0) + 1;
+
+ // By status
+ stats.byStatus[fb.status] = (stats.byStatus[fb.status] || 0) + 1;
+
+ // By section
+ stats.bySection[fb.section] = (stats.bySection[fb.section] || 0) + 1;
+
+ // By priority
+ stats.byPriority[fb.priority] = (stats.byPriority[fb.priority] || 0) + 1;
+
+ // Unique submitters
+ stats.submitters.add(fb.submittedBy);
+ }
+
+ stats.submitterCount = stats.submitters.size;
+ stats.submitters = Array.from(stats.submitters);
+
+ return stats;
+ }
+
+ // ============ Private Methods ============
+
+ _formatFeedbackBody({ reviewIssueNumber, documentKey, section, feedbackType, typeConfig, priority, content, suggestedChange, rationale, submittedBy }) {
+ let body = `# ${typeConfig.emoji} Feedback: ${feedbackType.charAt(0).toUpperCase() + feedbackType.slice(1)}\n\n`;
+ body += `**Review:** #${reviewIssueNumber}\n`;
+ body += `**Document:** \`${documentKey}\`\n`;
+ body += `**Section:** ${section}\n`;
+ body += `**Type:** ${typeConfig.description}\n`;
+ body += `**Priority:** ${priority}\n\n`;
+ body += `---\n\n`;
+ body += `## Feedback\n\n${content}\n\n`;
+
+ if (suggestedChange) {
+ body += `## Suggested Change\n\n${suggestedChange}\n\n`;
+ }
+
+ if (rationale) {
+ body += `## Context/Rationale\n\n${rationale}\n\n`;
+ }
+
+ body += `---\n\n`;
+ body += `_Submitted by @${submittedBy} on ${new Date().toISOString().split('T')[0]}_\n`;
+
+ return body;
+ }
+
+ _parseFeedbackIssue(issue) {
+ const labels = issue.labels.map(l => l.name);
+
+ return {
+ id: issue.number,
+ url: issue.html_url,
+ title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''),
+ section: this._extractLabel(labels, 'feedback-section:'),
+ feedbackType: this._extractLabel(labels, 'feedback-type:'),
+ status: this._extractLabel(labels, 'feedback-status:'),
+ priority: this._extractLabel(labels, 'priority:'),
+ submittedBy: issue.user?.login,
+ createdAt: issue.created_at,
+ updatedAt: issue.updated_at,
+ body: issue.body
+ };
+ }
+
+ _extractLabel(labels, prefix) {
+ const label = labels.find(l => l.startsWith(prefix));
+ return label ? label.replace(prefix, '') : null;
+ }
+
+ async _addLinkComment(reviewIssueNumber, feedbackIssueNumber, title, feedbackType, submittedBy) {
+ const typeConfig = FEEDBACK_TYPES[feedbackType];
+ const comment = `${typeConfig.emoji} **New Feedback** from @${submittedBy}\n\n` +
+ `**${title}** → #${feedbackIssueNumber}\n` +
+ `Type: ${feedbackType}`;
+
+ await this._addComment(reviewIssueNumber, comment);
+ }
+
+ // GitHub API wrappers (to be called via MCP)
+ async _createIssue({ title, body, labels }) {
+ // This would be: mcp__github__issue_write({ method: 'create', ... })
+ throw new Error('_createIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _getIssue(issueNumber) {
+ // This would be: mcp__github__issue_read({ method: 'get', ... })
+ throw new Error('_getIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _updateIssue(issueNumber, updates) {
+ // This would be: mcp__github__issue_write({ method: 'update', ... })
+ throw new Error('_updateIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _closeIssue(issueNumber, reason) {
+ // This would be: mcp__github__issue_write({ method: 'update', state: 'closed', ... })
+ throw new Error('_closeIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _addComment(issueNumber, body) {
+ // This would be: mcp__github__add_issue_comment({ ... })
+ throw new Error('_addComment must be implemented by caller via GitHub MCP');
+ }
+
+ async _searchIssues(query) {
+ // This would be: mcp__github__search_issues({ query })
+ throw new Error('_searchIssues must be implemented by caller via GitHub MCP');
+ }
+}
+
+module.exports = {
+ FeedbackManager,
+ FEEDBACK_TYPES,
+ FEEDBACK_STATUS,
+ PRIORITY_LEVELS
+};
diff --git a/src/modules/bmm/lib/crowdsource/index.js b/src/modules/bmm/lib/crowdsource/index.js
new file mode 100644
index 00000000..64007656
--- /dev/null
+++ b/src/modules/bmm/lib/crowdsource/index.js
@@ -0,0 +1,28 @@
+/**
+ * Crowdsource Module - Shared infrastructure for PRD and Epic crowdsourcing
+ *
+ * This module provides generic feedback collection, LLM synthesis,
+ * and sign-off management that works for both PRDs and Epics.
+ */
+
+const { FeedbackManager, FEEDBACK_TYPES, FEEDBACK_STATUS, PRIORITY_LEVELS } = require('./feedback-manager');
+const { SynthesisEngine, SYNTHESIS_PROMPTS } = require('./synthesis-engine');
+const { SignoffManager, SIGNOFF_STATUS, THRESHOLD_TYPES, DEFAULT_CONFIG } = require('./signoff-manager');
+
+module.exports = {
+ // Feedback Management
+ FeedbackManager,
+ FEEDBACK_TYPES,
+ FEEDBACK_STATUS,
+ PRIORITY_LEVELS,
+
+ // Synthesis Engine
+ SynthesisEngine,
+ SYNTHESIS_PROMPTS,
+
+ // Sign-off Management
+ SignoffManager,
+ SIGNOFF_STATUS,
+ THRESHOLD_TYPES,
+ DEFAULT_CONFIG
+};
diff --git a/src/modules/bmm/lib/crowdsource/signoff-manager.js b/src/modules/bmm/lib/crowdsource/signoff-manager.js
new file mode 100644
index 00000000..6b5ab154
--- /dev/null
+++ b/src/modules/bmm/lib/crowdsource/signoff-manager.js
@@ -0,0 +1,457 @@
+/**
+ * Sign-off Manager - Configurable sign-off logic for PRDs and Epics
+ *
+ * Supports three threshold types:
+ * - count: Minimum number of approvals needed
+ * - percentage: Percentage of stakeholders must approve
+ * - required_approvers: Specific people must approve + minimum optional
+ */
+
+const SIGNOFF_STATUS = {
+ pending: 'signoff:pending',
+ approved: 'signoff:approved',
+ approved_with_note: 'signoff:approved-with-note',
+ blocked: 'signoff:blocked'
+};
+
+const THRESHOLD_TYPES = {
+ count: 'count',
+ percentage: 'percentage',
+ required_approvers: 'required_approvers'
+};
+
+const DEFAULT_CONFIG = {
+ threshold_type: THRESHOLD_TYPES.count,
+ minimum_approvals: 2,
+ approval_percentage: 66,
+ required: [],
+ optional: [],
+ minimum_optional: 0,
+ allow_blocks: true,
+ block_threshold: 1
+};
+
+class SignoffManager {
+ constructor(githubConfig) {
+ this.owner = githubConfig.owner;
+ this.repo = githubConfig.repo;
+ }
+
+ /**
+ * Request sign-off from stakeholders
+ */
+ async requestSignoff({
+ documentKey,
+ documentType, // 'prd' or 'epic'
+ reviewIssueNumber,
+ stakeholders, // Array of @usernames
+ deadline, // ISO date string
+ config = {} // Sign-off configuration
+ }) {
+ const signoffConfig = { ...DEFAULT_CONFIG, ...config };
+
+ // Validate configuration
+ this._validateConfig(signoffConfig, stakeholders);
+
+ // Update the review issue to signoff status
+ const labels = [
+ `type:${documentType}-review`,
+ `${documentType}:${documentKey.split(':')[1]}`,
+ 'review-status:signoff'
+ ];
+
+ // Build stakeholder checklist
+ const checklist = stakeholders.map(user =>
+ `- [ ] @${user.replace('@', '')} - ⏳ Pending`
+ ).join('\n');
+
+ const body = this._formatSignoffRequestBody({
+ documentKey,
+ documentType,
+ stakeholders,
+ deadline,
+ config: signoffConfig,
+ checklist
+ });
+
+ // Add comment to review issue
+ await this._addComment(reviewIssueNumber, body);
+
+ return {
+ reviewIssueNumber,
+ documentKey,
+ stakeholders,
+ deadline,
+ config: signoffConfig,
+ status: 'signoff_requested'
+ };
+ }
+
+ /**
+ * Submit a sign-off decision
+ */
+ async submitSignoff({
+ reviewIssueNumber,
+ documentKey,
+ documentType,
+ user,
+ decision, // 'approved' | 'approved_with_note' | 'blocked'
+ note = null, // Optional note or blocking reason
+ feedbackIssueNumber = null // If blocked, link to feedback issue
+ }) {
+ if (!Object.keys(SIGNOFF_STATUS).includes(decision)) {
+ throw new Error(`Invalid decision: ${decision}. Must be one of: ${Object.keys(SIGNOFF_STATUS).join(', ')}`);
+ }
+
+ const emoji = this._getDecisionEmoji(decision);
+ const statusText = this._getDecisionText(decision);
+
+ let comment = `### ${emoji} Sign-off from @${user.replace('@', '')}\n\n`;
+ comment += `**Decision:** ${statusText}\n`;
+ comment += `**Date:** ${new Date().toISOString().split('T')[0]}\n`;
+
+ if (note) {
+ comment += `\n**Note:**\n${note}\n`;
+ }
+
+ if (decision === 'blocked' && feedbackIssueNumber) {
+ comment += `\n**Blocking Issue:** #${feedbackIssueNumber}\n`;
+ }
+
+ await this._addComment(reviewIssueNumber, comment);
+
+ // Store signoff in labels for queryability
+ await this._addSignoffLabel(reviewIssueNumber, user, decision);
+
+ return {
+ reviewIssueNumber,
+ user,
+ decision,
+ note,
+ timestamp: new Date().toISOString()
+ };
+ }
+
+ /**
+ * Get all sign-offs for a review
+ */
+ async getSignoffs(reviewIssueNumber) {
+ const issue = await this._getIssue(reviewIssueNumber);
+ const labels = issue.labels.map(l => l.name);
+
+ // Parse signoff labels: signoff-{user}-{status}
+ const signoffs = [];
+ for (const label of labels) {
+ const match = label.match(/^signoff-(.+)-(approved|approved-with-note|blocked|pending)$/);
+ if (match) {
+ signoffs.push({
+ user: match[1],
+ status: match[2].replace(/-/g, '_'),
+ label: label
+ });
+ }
+ }
+
+ return signoffs;
+ }
+
+ /**
+ * Calculate sign-off status based on configuration
+ */
+ calculateStatus(signoffs, stakeholders, config = DEFAULT_CONFIG) {
+ const approvals = signoffs.filter(s =>
+ s.status === 'approved' || s.status === 'approved_with_note'
+ );
+ const blocks = signoffs.filter(s => s.status === 'blocked');
+ const pending = stakeholders.filter(user =>
+ !signoffs.some(s => s.user === user.replace('@', ''))
+ );
+
+ // Check for blockers first
+ if (config.allow_blocks && blocks.length >= config.block_threshold) {
+ return {
+ status: 'blocked',
+ blockers: blocks.map(b => b.user),
+ message: `Blocked by ${blocks.length} stakeholder(s)`
+ };
+ }
+
+ switch (config.threshold_type) {
+ case THRESHOLD_TYPES.count:
+ return this._calculateCountStatus(approvals, config, pending);
+
+ case THRESHOLD_TYPES.percentage:
+ return this._calculatePercentageStatus(approvals, stakeholders, config, pending);
+
+ case THRESHOLD_TYPES.required_approvers:
+ return this._calculateRequiredApproversStatus(approvals, config, pending);
+
+ default:
+ throw new Error(`Unknown threshold type: ${config.threshold_type}`);
+ }
+ }
+
+ /**
+ * Check if document is fully approved
+ */
+ isApproved(signoffs, stakeholders, config = DEFAULT_CONFIG) {
+ const status = this.calculateStatus(signoffs, stakeholders, config);
+ return status.status === 'approved';
+ }
+
+ /**
+ * Get sign-off progress summary
+ */
+ getProgressSummary(signoffs, stakeholders, config = DEFAULT_CONFIG) {
+ const status = this.calculateStatus(signoffs, stakeholders, config);
+
+ const approvalCount = signoffs.filter(s =>
+ s.status === 'approved' || s.status === 'approved_with_note'
+ ).length;
+
+ const blockCount = signoffs.filter(s => s.status === 'blocked').length;
+
+ const pendingUsers = stakeholders.filter(user =>
+ !signoffs.some(s => s.user === user.replace('@', ''))
+ );
+
+ return {
+ ...status,
+ total_stakeholders: stakeholders.length,
+ approved_count: approvalCount,
+ blocked_count: blockCount,
+ pending_count: pendingUsers.length,
+ pending_users: pendingUsers,
+ progress_percent: Math.round((approvalCount / stakeholders.length) * 100)
+ };
+ }
+
+ /**
+ * Send reminder to pending stakeholders
+ */
+ async sendReminder(reviewIssueNumber, pendingUsers, deadline) {
+ const mentions = pendingUsers.map(u => `@${u.replace('@', '')}`).join(', ');
+
+ const comment = `### ⏰ Reminder: Sign-off Needed\n\n` +
+ `${mentions}\n\n` +
+ `Your sign-off is still pending for this review.\n` +
+ `**Deadline:** ${deadline}\n\n` +
+ `Please review and submit your decision.`;
+
+ await this._addComment(reviewIssueNumber, comment);
+
+ return { reminded: pendingUsers, deadline };
+ }
+
+ /**
+ * Extend sign-off deadline
+ */
+ async extendDeadline(reviewIssueNumber, newDeadline, reason = null) {
+ let comment = `### 📅 Deadline Extended\n\n`;
+ comment += `**New Deadline:** ${newDeadline}\n`;
+
+ if (reason) {
+ comment += `**Reason:** ${reason}\n`;
+ }
+
+ await this._addComment(reviewIssueNumber, comment);
+
+ return { reviewIssueNumber, newDeadline };
+ }
+
+ // ============ Private Methods ============
+
+ _validateConfig(config, stakeholders) {
+ if (config.threshold_type === THRESHOLD_TYPES.count) {
+ if (config.minimum_approvals > stakeholders.length) {
+ throw new Error(
+ `minimum_approvals (${config.minimum_approvals}) cannot exceed stakeholder count (${stakeholders.length})`
+ );
+ }
+ }
+
+ if (config.threshold_type === THRESHOLD_TYPES.required_approvers) {
+ const allRequired = config.required.every(r =>
+ stakeholders.some(s => s.replace('@', '') === r.replace('@', ''))
+ );
+ if (!allRequired) {
+ throw new Error('All required approvers must be in stakeholder list');
+ }
+ }
+ }
+
+ _calculateCountStatus(approvals, config, pending) {
+ if (approvals.length >= config.minimum_approvals) {
+ return { status: 'approved', message: 'Minimum approvals reached' };
+ }
+
+ return {
+ status: 'pending',
+ needed: config.minimum_approvals - approvals.length,
+ pending_users: pending,
+ message: `Need ${config.minimum_approvals - approvals.length} more approval(s)`
+ };
+ }
+
+ _calculatePercentageStatus(approvals, stakeholders, config, pending) {
+ const percent = (approvals.length / stakeholders.length) * 100;
+
+ if (percent >= config.approval_percentage) {
+ return {
+ status: 'approved',
+ message: `${Math.round(percent)}% approved (threshold: ${config.approval_percentage}%)`
+ };
+ }
+
+ const needed = Math.ceil((config.approval_percentage / 100) * stakeholders.length) - approvals.length;
+ return {
+ status: 'pending',
+ current_percent: Math.round(percent),
+ needed_percent: config.approval_percentage,
+ needed: needed,
+ pending_users: pending,
+ message: `${Math.round(percent)}% approved, need ${config.approval_percentage}%`
+ };
+ }
+
+ _calculateRequiredApproversStatus(approvals, config, pending) {
+ const approvedUsers = approvals.map(a => a.user);
+
+ // Check required approvers
+ const missingRequired = config.required.filter(r =>
+ !approvedUsers.includes(r.replace('@', ''))
+ );
+
+ if (missingRequired.length > 0) {
+ return {
+ status: 'pending',
+ missing_required: missingRequired,
+ pending_users: pending,
+ message: `Waiting for required approvers: ${missingRequired.join(', ')}`
+ };
+ }
+
+ // Check optional approvers
+ const optionalApproved = approvals.filter(a =>
+ config.optional.some(o => o.replace('@', '') === a.user)
+ ).length;
+
+ if (optionalApproved < config.minimum_optional) {
+ const neededOptional = config.minimum_optional - optionalApproved;
+ const pendingOptional = config.optional.filter(o =>
+ !approvedUsers.includes(o.replace('@', ''))
+ );
+
+ return {
+ status: 'pending',
+ optional_needed: neededOptional,
+ pending_optional: pendingOptional,
+ pending_users: pending,
+ message: `Need ${neededOptional} more optional approver(s)`
+ };
+ }
+
+ return { status: 'approved', message: 'All required + minimum optional approvers satisfied' };
+ }
+
+ _getDecisionEmoji(decision) {
+ switch (decision) {
+ case 'approved': return '✅';
+ case 'approved_with_note': return '✅📝';
+ case 'blocked': return '🚫';
+ default: return '⏳';
+ }
+ }
+
+ _getDecisionText(decision) {
+ switch (decision) {
+ case 'approved': return 'Approved';
+ case 'approved_with_note': return 'Approved with Note';
+ case 'blocked': return 'Blocked';
+ default: return 'Pending';
+ }
+ }
+
+ _formatSignoffRequestBody({ documentKey, documentType, stakeholders, deadline, config, checklist }) {
+ let body = `## ✍️ Sign-off Requested\n\n`;
+ body += `**Document:** \`${documentKey}\`\n`;
+ body += `**Type:** ${documentType.toUpperCase()}\n`;
+ body += `**Deadline:** ${deadline}\n\n`;
+
+ body += `### Sign-off Configuration\n`;
+ body += `- **Threshold:** ${this._formatThreshold(config)}\n`;
+ if (config.allow_blocks) {
+ body += `- **Block Threshold:** ${config.block_threshold} block(s) will halt approval\n`;
+ }
+ body += '\n';
+
+ body += `### Stakeholder Status\n\n`;
+ body += checklist;
+ body += '\n\n';
+
+ body += `---\n\n`;
+ body += `**To sign off:**\n`;
+ body += `- ✅ **Approve**: Comment with \`/signoff approve\`\n`;
+ body += `- ✅📝 **Approve with Note**: Comment with \`/signoff approve-note: [your note]\`\n`;
+ body += `- 🚫 **Block**: Comment with \`/signoff block: [reason]\`\n`;
+
+ return body;
+ }
+
+ _formatThreshold(config) {
+ switch (config.threshold_type) {
+ case THRESHOLD_TYPES.count:
+ return `${config.minimum_approvals} approval(s) required`;
+ case THRESHOLD_TYPES.percentage:
+ return `${config.approval_percentage}% must approve`;
+ case THRESHOLD_TYPES.required_approvers:
+ return `Required: ${config.required.join(', ')} + ${config.minimum_optional} optional`;
+ default:
+ return 'Unknown';
+ }
+ }
+
+ async _addSignoffLabel(issueNumber, user, decision) {
+ // Normalize user and decision for label
+ const normalizedUser = user.replace('@', '').replace(/[^a-zA-Z0-9-]/g, '-');
+ const normalizedDecision = decision.replace(/_/g, '-');
+ const label = `signoff-${normalizedUser}-${normalizedDecision}`;
+
+ // Get current labels
+ const issue = await this._getIssue(issueNumber);
+ const currentLabels = issue.labels.map(l => l.name);
+
+ // Remove any existing signoff label for this user
+ const newLabels = currentLabels.filter(l =>
+ !l.startsWith(`signoff-${normalizedUser}-`)
+ );
+
+ // Add new signoff label
+ newLabels.push(label);
+
+ await this._updateIssue(issueNumber, { labels: newLabels });
+ }
+
+ // GitHub API wrappers (to be called via MCP)
+ async _getIssue(issueNumber) {
+ // This would be: mcp__github__issue_read({ method: 'get', ... })
+ throw new Error('_getIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _updateIssue(issueNumber, updates) {
+ // This would be: mcp__github__issue_write({ method: 'update', ... })
+ throw new Error('_updateIssue must be implemented by caller via GitHub MCP');
+ }
+
+ async _addComment(issueNumber, body) {
+ // This would be: mcp__github__add_issue_comment({ ... })
+ throw new Error('_addComment must be implemented by caller via GitHub MCP');
+ }
+}
+
+module.exports = {
+ SignoffManager,
+ SIGNOFF_STATUS,
+ THRESHOLD_TYPES,
+ DEFAULT_CONFIG
+};
diff --git a/src/modules/bmm/lib/crowdsource/synthesis-engine.js b/src/modules/bmm/lib/crowdsource/synthesis-engine.js
new file mode 100644
index 00000000..0b209b10
--- /dev/null
+++ b/src/modules/bmm/lib/crowdsource/synthesis-engine.js
@@ -0,0 +1,405 @@
+/**
+ * Synthesis Engine - LLM-powered feedback synthesis with conflict resolution
+ *
+ * Groups feedback by section, identifies conflicts/tensions,
+ * and generates proposed resolutions with rationale.
+ */
+
+const SYNTHESIS_PROMPTS = {
+ prd: {
+ grouping: `Analyze the following feedback items for a PRD section and group them by theme:
+
+SECTION: {{section}}
+
+FEEDBACK ITEMS:
+{{feedbackItems}}
+
+Group these into themes and identify:
+1. Common requests (multiple people asking for similar things)
+2. Conflicts (opposing viewpoints)
+3. Quick wins (low-effort, high-value changes)
+4. Major changes (significant scope implications)
+
+Format your response as JSON.`,
+
+ resolution: `You are helping synthesize stakeholder feedback on a PRD.
+
+SECTION: {{section}}
+ORIGINAL TEXT:
+{{originalText}}
+
+CONFLICT DETECTED:
+{{conflictDescription}}
+
+FEEDBACK FROM STAKEHOLDERS:
+{{feedbackDetails}}
+
+Propose a resolution that:
+1. Addresses the core concerns of all parties
+2. Maintains product coherence
+3. Is actionable and specific
+
+Provide:
+- proposed_text: The updated section text
+- rationale: Why this resolution works (2-3 sentences)
+- trade_offs: What compromises were made
+- confidence: high/medium/low
+
+Format as JSON.`,
+
+ merge: `Incorporate the following approved feedback into the PRD section:
+
+SECTION: {{section}}
+ORIGINAL TEXT:
+{{originalText}}
+
+FEEDBACK TO INCORPORATE:
+{{feedbackToIncorporate}}
+
+Generate the updated section text that:
+1. Addresses all feedback points
+2. Maintains consistent tone and format
+3. Is clear and actionable
+
+Return the complete updated section text.`
+ },
+
+ epic: {
+ grouping: `Analyze the following feedback items for an Epic and group them by theme:
+
+EPIC: {{epicKey}}
+
+FEEDBACK ITEMS:
+{{feedbackItems}}
+
+Group these into:
+1. Scope concerns (too big, should split)
+2. Story split suggestions
+3. Dependency/blocking issues
+4. Technical risks
+5. Missing stories
+6. Priority questions
+
+Format your response as JSON.`,
+
+ storySplit: `Based on stakeholder feedback, suggest how to split this epic into stories:
+
+EPIC: {{epicKey}}
+EPIC DESCRIPTION:
+{{epicDescription}}
+
+CURRENT STORIES:
+{{currentStories}}
+
+FEEDBACK SUGGESTING CHANGES:
+{{feedbackItems}}
+
+Propose an updated story breakdown that:
+1. Addresses the split/scope concerns
+2. Maintains logical grouping
+3. Respects dependencies
+4. Keeps stories appropriately sized (3-8 tasks each)
+
+Format as JSON with:
+- stories: Array of { key, title, description, tasks_estimate }
+- changes_made: What changed from original
+- rationale: Why this split works better`
+ }
+};
+
+class SynthesisEngine {
+ constructor(options = {}) {
+ this.documentType = options.documentType || 'prd'; // 'prd' or 'epic'
+ }
+
+ /**
+ * Analyze feedback and generate a synthesis report
+ */
+ async analyzeFeedback(feedbackBySection, originalDocument) {
+ const analysis = {
+ sections: {},
+ conflicts: [],
+ themes: [],
+ suggestedChanges: [],
+ summary: {}
+ };
+
+ for (const [section, feedbackList] of Object.entries(feedbackBySection)) {
+ const sectionAnalysis = await this._analyzeSection(
+ section,
+ feedbackList,
+ originalDocument[section]
+ );
+
+ analysis.sections[section] = sectionAnalysis;
+
+ if (sectionAnalysis.conflicts.length > 0) {
+ analysis.conflicts.push(...sectionAnalysis.conflicts.map(c => ({
+ ...c,
+ section
+ })));
+ }
+
+ analysis.suggestedChanges.push(...sectionAnalysis.suggestedChanges.map(c => ({
+ ...c,
+ section
+ })));
+ }
+
+ // Generate overall summary
+ analysis.summary = this._generateSummary(analysis);
+
+ return analysis;
+ }
+
+ /**
+ * Analyze a single section's feedback
+ */
+ async _analyzeSection(section, feedbackList, originalText) {
+ const result = {
+ feedbackCount: feedbackList.length,
+ byType: this._groupByType(feedbackList),
+ themes: [],
+ conflicts: [],
+ suggestedChanges: []
+ };
+
+ // Identify conflicts (multiple feedback on same aspect)
+ result.conflicts = this._identifyConflicts(feedbackList);
+
+ // Group into themes
+ result.themes = this._identifyThemes(feedbackList);
+
+ // Generate suggested changes for non-conflicting feedback
+ const nonConflicting = feedbackList.filter(
+ f => !result.conflicts.some(c => c.feedbackIds.includes(f.id))
+ );
+
+ for (const feedback of nonConflicting) {
+ result.suggestedChanges.push({
+ feedbackId: feedback.id,
+ type: feedback.feedbackType,
+ priority: feedback.priority,
+ description: feedback.title,
+ suggestedChange: feedback.suggestedChange,
+ submittedBy: feedback.submittedBy
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Identify conflicts in feedback
+ */
+ _identifyConflicts(feedbackList) {
+ const conflicts = [];
+
+ // Group by topic/keywords
+ const byTopic = {};
+ for (const fb of feedbackList) {
+ const keywords = this._extractKeywords(fb.title + ' ' + (fb.body || ''));
+ for (const kw of keywords) {
+ if (!byTopic[kw]) byTopic[kw] = [];
+ byTopic[kw].push(fb);
+ }
+ }
+
+ // Find topics with multiple conflicting opinions
+ for (const [topic, items] of Object.entries(byTopic)) {
+ if (items.length < 2) continue;
+
+ // Check if they have different suggestions
+ const uniqueSuggestions = new Set(items.map(i => i.suggestedChange).filter(Boolean));
+ if (uniqueSuggestions.size > 1) {
+ conflicts.push({
+ topic,
+ feedbackIds: items.map(i => i.id),
+ stakeholders: items.map(i => ({ user: i.submittedBy, position: i.title })),
+ description: `Conflicting views on ${topic}`
+ });
+ }
+ }
+
+ return conflicts;
+ }
+
+ /**
+ * Identify common themes in feedback
+ */
+ _identifyThemes(feedbackList) {
+ const themes = {};
+
+ for (const fb of feedbackList) {
+ const keywords = this._extractKeywords(fb.title);
+ for (const kw of keywords) {
+ if (!themes[kw]) {
+ themes[kw] = { keyword: kw, count: 0, feedbackIds: [], types: new Set() };
+ }
+ themes[kw].count++;
+ themes[kw].feedbackIds.push(fb.id);
+ themes[kw].types.add(fb.feedbackType);
+ }
+ }
+
+ // Return themes mentioned by multiple people
+ return Object.values(themes)
+ .filter(t => t.count >= 2)
+ .map(t => ({
+ ...t,
+ types: Array.from(t.types)
+ }))
+ .sort((a, b) => b.count - a.count);
+ }
+
+ /**
+ * Generate resolution proposal for a conflict
+ */
+ generateConflictResolution(conflict, originalText, feedbackDetails) {
+ // This returns a prompt for the LLM to process
+ const prompt = SYNTHESIS_PROMPTS[this.documentType].resolution
+ .replace('{{section}}', conflict.section || 'Unknown')
+ .replace('{{originalText}}', originalText || 'N/A')
+ .replace('{{conflictDescription}}', conflict.description)
+ .replace('{{feedbackDetails}}', JSON.stringify(feedbackDetails, null, 2));
+
+ return {
+ prompt,
+ conflict,
+ // The LLM response should be parsed into:
+ expectedFormat: {
+ proposed_text: 'string',
+ rationale: 'string',
+ trade_offs: 'string[]',
+ confidence: 'high|medium|low'
+ }
+ };
+ }
+
+ /**
+ * Generate merge prompt for incorporating feedback
+ */
+ generateMergePrompt(section, originalText, approvedFeedback) {
+ const feedbackText = approvedFeedback.map(f =>
+ `- ${f.feedbackType}: ${f.title}\n Change: ${f.suggestedChange || 'Address the concern'}`
+ ).join('\n\n');
+
+ return SYNTHESIS_PROMPTS[this.documentType].merge
+ .replace('{{section}}', section)
+ .replace('{{originalText}}', originalText)
+ .replace('{{feedbackToIncorporate}}', feedbackText);
+ }
+
+ /**
+ * Generate story split prompt for epics
+ */
+ generateStorySplitPrompt(epicKey, epicDescription, currentStories, feedback) {
+ if (this.documentType !== 'epic') {
+ throw new Error('Story split is only available for epics');
+ }
+
+ return SYNTHESIS_PROMPTS.epic.storySplit
+ .replace('{{epicKey}}', epicKey)
+ .replace('{{epicDescription}}', epicDescription)
+ .replace('{{currentStories}}', JSON.stringify(currentStories, null, 2))
+ .replace('{{feedbackItems}}', JSON.stringify(feedback, null, 2));
+ }
+
+ /**
+ * Generate synthesis summary
+ */
+ _generateSummary(analysis) {
+ const totalFeedback = Object.values(analysis.sections)
+ .reduce((sum, s) => sum + s.feedbackCount, 0);
+
+ const allTypes = {};
+ for (const section of Object.values(analysis.sections)) {
+ for (const [type, count] of Object.entries(section.byType)) {
+ allTypes[type] = (allTypes[type] || 0) + count;
+ }
+ }
+
+ return {
+ totalFeedback,
+ sectionsWithFeedback: Object.keys(analysis.sections).length,
+ conflictCount: analysis.conflicts.length,
+ themeCount: analysis.themes ? analysis.themes.length : 0,
+ changeCount: analysis.suggestedChanges.length,
+ feedbackByType: allTypes,
+ needsAttention: analysis.conflicts.length > 0
+ };
+ }
+
+ /**
+ * Group feedback by type
+ */
+ _groupByType(feedbackList) {
+ const byType = {};
+ for (const fb of feedbackList) {
+ byType[fb.feedbackType] = (byType[fb.feedbackType] || 0) + 1;
+ }
+ return byType;
+ }
+
+ /**
+ * Extract keywords from text for theme detection
+ */
+ _extractKeywords(text) {
+ if (!text) return [];
+
+ // Simple keyword extraction - can be enhanced
+ const stopWords = new Set([
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
+ 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
+ 'would', 'could', 'should', 'may', 'might', 'must', 'shall',
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
+ 'this', 'that', 'these', 'those', 'it', 'its', 'and', 'or',
+ 'but', 'not', 'no', 'if', 'then', 'else', 'when', 'where',
+ 'why', 'how', 'what', 'which', 'who', 'whom', 'whose'
+ ]);
+
+ return text
+ .toLowerCase()
+ .replace(/[^\w\s]/g, ' ')
+ .split(/\s+/)
+ .filter(word => word.length > 3 && !stopWords.has(word))
+ .slice(0, 10); // Limit to top 10 keywords
+ }
+
+ /**
+ * Format synthesis results for display
+ */
+ formatForDisplay(analysis) {
+ let output = '';
+
+ output += `## Synthesis Analysis\n\n`;
+ output += `**Total Feedback:** ${analysis.summary.totalFeedback}\n`;
+ output += `**Sections with Feedback:** ${analysis.summary.sectionsWithFeedback}\n`;
+ output += `**Conflicts Detected:** ${analysis.summary.conflictCount}\n`;
+ output += `**Suggested Changes:** ${analysis.summary.changeCount}\n\n`;
+
+ if (analysis.summary.needsAttention) {
+ output += `### ⚠️ Conflicts Requiring Resolution\n\n`;
+ for (const conflict of analysis.conflicts) {
+ output += `**${conflict.section}**: ${conflict.description}\n`;
+ for (const stakeholder of conflict.stakeholders) {
+ output += ` - @${stakeholder.user}: "${stakeholder.position}"\n`;
+ }
+ output += '\n';
+ }
+ }
+
+ output += `### By Section\n\n`;
+ for (const [section, data] of Object.entries(analysis.sections)) {
+ output += `**${section}** (${data.feedbackCount} items)\n`;
+ for (const [type, count] of Object.entries(data.byType)) {
+ output += ` - ${type}: ${count}\n`;
+ }
+ output += '\n';
+ }
+
+ return output;
+ }
+}
+
+module.exports = { SynthesisEngine, SYNTHESIS_PROMPTS };
diff --git a/src/modules/bmm/lib/notifications/email-notifier.js b/src/modules/bmm/lib/notifications/email-notifier.js
new file mode 100644
index 00000000..8c214db4
--- /dev/null
+++ b/src/modules/bmm/lib/notifications/email-notifier.js
@@ -0,0 +1,578 @@
+/**
+ * Email Notifier - Optional email notification integration
+ *
+ * Sends email notifications for important events.
+ * Supports SMTP and common email service providers.
+ */
+
+const EMAIL_TEMPLATES = {
+ feedback_round_opened: {
+ subject: '📣 [{{document_type}}:{{document_key}}] Feedback Requested',
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
Please review the document and provide your feedback by {{deadline}}.
+
+
+ View Document
+
+
+
+
+
+
+`,
+ text: `
+📣 FEEDBACK REQUESTED
+
+Document: {{document_type}}:{{document_key}}
+Version: v{{version}}
+Deadline: {{deadline}}
+
+Please review the document and provide your feedback by {{deadline}}.
+
+View Document: {{document_url}}
+
+---
+PRD Crowdsourcing System
+`
+ },
+
+ signoff_requested: {
+ subject: '✍️ [{{document_type}}:{{document_key}}] Sign-off Requested',
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
Please review the document and provide your sign-off decision by {{deadline}}.
+
+
+
Sign-off Options:
+
+ ✅ Approve - Sign off without concerns
+
+
+ ✅📝 Approve with Note - Sign off with a minor note
+
+
+ 🚫 Block - Cannot approve, has blocking concern
+
+
+
+
+ Review & Sign Off
+
+
+
+
+
+
+`,
+ text: `
+✍️ SIGN-OFF REQUESTED
+
+Document: {{document_type}}:{{document_key}}
+Version: v{{version}}
+Deadline: {{deadline}}
+
+Please review the document and provide your sign-off decision by {{deadline}}.
+
+Sign-off Options:
+- ✅ Approve - Sign off without concerns
+- ✅📝 Approve with Note - Sign off with a minor note
+- 🚫 Block - Cannot approve, has blocking concern
+
+Review & Sign Off: {{document_url}}
+
+---
+PRD Crowdsourcing System
+`
+ },
+
+ document_approved: {
+ subject: '✅ [{{document_type}}:{{document_key}}] Document Approved!',
+ html: `
+
+
+
+
+
+
+
+
+
+
🎉
+
+
+
+
All required sign-offs have been received. This document is now approved and ready for implementation!
+
+
+ View Approved Document
+
+
+
+
+
+
+`,
+ text: `
+✅ DOCUMENT APPROVED! 🎉
+
+Document: {{document_type}}:{{document_key}}
+Title: {{title}}
+Final Version: v{{version}}
+Approvals: {{approval_count}}/{{stakeholder_count}}
+
+All required sign-offs have been received. This document is now approved and ready for implementation!
+
+View Approved Document: {{document_url}}
+
+---
+PRD Crowdsourcing System
+`
+ },
+
+ document_blocked: {
+ subject: '🚫 [{{document_type}}:{{document_key}}] Document Blocked',
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
Blocking Reason:
+
{{reason}}
+
+
+
⚠️ This blocking concern must be resolved before the document can be approved.
+
+
+ View Blocking Issue
+
+
+
+
+
+
+`,
+ text: `
+🚫 DOCUMENT BLOCKED
+
+Document: {{document_type}}:{{document_key}}
+Blocked by: {{user}}
+
+Blocking Reason:
+{{reason}}
+
+⚠️ This blocking concern must be resolved before the document can be approved.
+
+View Blocking Issue: {{feedback_url}}
+
+---
+PRD Crowdsourcing System
+`
+ },
+
+ reminder: {
+ subject: '⏰ [{{document_type}}:{{document_key}}] Reminder: {{action_needed}}',
+ html: `
+
+
+
+
+
+
+
+
+
+
+ {{time_remaining}} remaining until deadline
+
+
+
+
+
Please complete your {{action_needed}} by {{deadline}}.
+
+
+ Take Action
+
+
+
+
+
+
+`,
+ text: `
+⏰ REMINDER: ACTION NEEDED
+
+{{time_remaining}} remaining until deadline
+
+Document: {{document_type}}:{{document_key}}
+Action: {{action_needed}}
+Deadline: {{deadline}}
+
+Please complete your {{action_needed}} by {{deadline}}.
+
+Take Action: {{document_url}}
+
+---
+PRD Crowdsourcing System
+`
+ }
+};
+
+class EmailNotifier {
+ /**
+ * Create a new EmailNotifier
+ * @param {Object} config - Configuration object
+ * @param {string} config.provider - Email provider ('smtp', 'sendgrid', 'ses', etc.)
+ * @param {Object} config.smtp - SMTP configuration (if provider is 'smtp')
+ * @param {string} config.apiKey - API key (for sendgrid, ses, etc.)
+ * @param {string} config.fromAddress - Sender email address
+ * @param {string} config.fromName - Sender name
+ */
+ constructor(config) {
+ this.provider = config.provider || 'smtp';
+ this.smtp = config.smtp;
+ this.apiKey = config.apiKey;
+ this.fromAddress = config.fromAddress || 'noreply@example.com';
+ this.fromName = config.fromName || 'PRD Crowdsourcing';
+ this.enabled = !!(config.smtp || config.apiKey);
+
+ // User email lookup (should be configured externally)
+ this.userEmails = config.userEmails || {};
+ }
+
+ /**
+ * Check if email notifications are enabled
+ * @returns {boolean}
+ */
+ isEnabled() {
+ return this.enabled;
+ }
+
+ /**
+ * Send a notification via email
+ * @param {string} eventType - Type of notification event
+ * @param {Object} data - Event data
+ * @param {Object} options - Additional options
+ * @returns {Object} Notification result
+ */
+ async send(eventType, data, options = {}) {
+ if (!this.enabled) {
+ return {
+ success: false,
+ channel: 'email',
+ error: 'Email notifications not enabled'
+ };
+ }
+
+ const template = EMAIL_TEMPLATES[eventType];
+ if (!template) {
+ return {
+ success: false,
+ channel: 'email',
+ error: `Unknown notification event type: ${eventType}`
+ };
+ }
+
+ // Get recipient emails
+ const recipients = options.recipients || [];
+ if (data.users) {
+ recipients.push(...data.users.map(u => this.userEmails[u]).filter(Boolean));
+ }
+
+ if (recipients.length === 0) {
+ return {
+ success: false,
+ channel: 'email',
+ error: 'No recipients specified'
+ };
+ }
+
+ const subject = this._renderTemplate(template.subject, data);
+ const html = this._renderTemplate(template.html, data);
+ const text = this._renderTemplate(template.text, data);
+
+ try {
+ await this._sendEmail({
+ to: recipients,
+ subject,
+ html,
+ text
+ });
+
+ return {
+ success: true,
+ channel: 'email',
+ recipientCount: recipients.length
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'email',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Send a custom email
+ * @param {string[]} recipients - Email addresses
+ * @param {string} subject - Email subject
+ * @param {string} body - Email body (HTML or text)
+ * @param {Object} options - Additional options
+ * @returns {Object} Notification result
+ */
+ async sendCustom(recipients, subject, body, options = {}) {
+ if (!this.enabled) {
+ return {
+ success: false,
+ channel: 'email',
+ error: 'Email notifications not enabled'
+ };
+ }
+
+ try {
+ await this._sendEmail({
+ to: recipients,
+ subject,
+ html: options.html ? body : undefined,
+ text: options.html ? undefined : body
+ });
+
+ return {
+ success: true,
+ channel: 'email',
+ recipientCount: recipients.length
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'email',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Get email address for a username
+ * @param {string} username - GitHub username
+ * @returns {string|null} Email address or null if not found
+ */
+ getEmailForUser(username) {
+ return this.userEmails[username] || null;
+ }
+
+ /**
+ * Set email address for a username
+ * @param {string} username - GitHub username
+ * @param {string} email - Email address
+ */
+ setEmailForUser(username, email) {
+ this.userEmails[username] = email;
+ }
+
+ /**
+ * Send email via configured provider
+ * @private
+ */
+ async _sendEmail({ to, subject, html, text }) {
+ // Note: In a real implementation, this would use nodemailer, sendgrid, ses, etc.
+ // For the workflow engine, this will be handled by the runtime
+
+ const emailPayload = {
+ from: {
+ name: this.fromName,
+ address: this.fromAddress
+ },
+ to: Array.isArray(to) ? to : [to],
+ subject,
+ html,
+ text
+ };
+
+ switch (this.provider) {
+ case 'smtp':
+ return await this._sendViaSMTP(emailPayload);
+ case 'sendgrid':
+ return await this._sendViaSendGrid(emailPayload);
+ case 'ses':
+ return await this._sendViaSES(emailPayload);
+ default:
+ throw new Error(`Unknown email provider: ${this.provider}`);
+ }
+ }
+
+ /**
+ * Send via SMTP
+ * @private
+ */
+ async _sendViaSMTP(payload) {
+ // Placeholder - would use nodemailer in real implementation
+ console.log('[EMAIL] Would send via SMTP:', payload.subject, 'to', payload.to.join(', '));
+ return { messageId: `smtp-${Date.now()}` };
+ }
+
+ /**
+ * Send via SendGrid
+ * @private
+ */
+ async _sendViaSendGrid(payload) {
+ // Placeholder - would use @sendgrid/mail in real implementation
+ console.log('[EMAIL] Would send via SendGrid:', payload.subject, 'to', payload.to.join(', '));
+ return { messageId: `sg-${Date.now()}` };
+ }
+
+ /**
+ * Send via AWS SES
+ * @private
+ */
+ async _sendViaSES(payload) {
+ // Placeholder - would use @aws-sdk/client-ses in real implementation
+ console.log('[EMAIL] Would send via SES:', payload.subject, 'to', payload.to.join(', '));
+ return { messageId: `ses-${Date.now()}` };
+ }
+
+ /**
+ * Render a template with data
+ * @private
+ */
+ _renderTemplate(template, data) {
+ let result = template;
+
+ // Simple mustache-like replacement
+ result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
+ return data[key] !== undefined ? String(data[key]) : match;
+ });
+
+ return result;
+ }
+}
+
+module.exports = {
+ EmailNotifier,
+ EMAIL_TEMPLATES
+};
diff --git a/src/modules/bmm/lib/notifications/github-notifier.js b/src/modules/bmm/lib/notifications/github-notifier.js
new file mode 100644
index 00000000..881fc42d
--- /dev/null
+++ b/src/modules/bmm/lib/notifications/github-notifier.js
@@ -0,0 +1,382 @@
+/**
+ * GitHub Notifier - Baseline notification via GitHub @mentions
+ *
+ * This is the primary notification channel that's always available.
+ * Uses GitHub Issues and comments to notify stakeholders via @mentions.
+ */
+
+const NOTIFICATION_TEMPLATES = {
+ feedback_round_opened: {
+ subject: '📣 Feedback Requested',
+ template: `## 📣 Feedback Round Open
+
+{{mentions}}
+
+**Document:** {{document_type}}:{{document_key}}
+**Version:** v{{version}}
+**Deadline:** {{deadline}}
+
+Please review and provide your feedback by {{deadline}}.
+
+---
+
+[View Document]({{document_url}})
+{{#if actions}}
+**Quick Actions:**
+{{actions}}
+{{/if}}
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ feedback_submitted: {
+ subject: '💬 New Feedback',
+ template: `## 💬 New Feedback Submitted
+
+**From:** @{{user}}
+**Document:** {{document_type}}:{{document_key}}
+**Type:** {{feedback_type}}
+**Section:** {{section}}
+
+---
+
+{{summary}}
+
+---
+
+[View Feedback #{{feedback_issue}}]({{feedback_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ synthesis_complete: {
+ subject: '🔄 Synthesis Complete',
+ template: `## 🔄 Synthesis Complete
+
+**Document:** {{document_type}}:{{document_key}}
+**Version:** v{{old_version}} → v{{new_version}}
+**Feedback Processed:** {{feedback_count}} items
+
+---
+
+### Summary of Changes
+
+{{summary}}
+
+---
+
+[View Updated Document]({{document_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ signoff_requested: {
+ subject: '✍️ Sign-off Requested',
+ template: `## ✍️ Sign-off Requested
+
+{{mentions}}
+
+**Document:** {{document_type}}:{{document_key}}
+**Version:** v{{version}}
+**Deadline:** {{deadline}}
+
+Please review and provide your sign-off decision by {{deadline}}.
+
+### How to Sign Off
+
+- ✅ **Approve**: Comment with \`/signoff approve\`
+- ✅📝 **Approve with Note**: Comment with \`/signoff approve-note: [your note]\`
+- 🚫 **Block**: Comment with \`/signoff block: [reason]\`
+
+---
+
+[View Document]({{document_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ signoff_received: {
+ subject: '{{emoji}} Sign-off Received',
+ template: `## {{emoji}} Sign-off from @{{user}}
+
+**Decision:** {{decision}}
+**Document:** {{document_type}}:{{document_key}}
+**Progress:** {{progress_current}}/{{progress_total}} approvals
+
+{{#if note}}
+**Note:**
+{{note}}
+{{/if}}
+
+---
+
+[View Review Issue #{{review_issue}}]({{review_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ document_approved: {
+ subject: '✅ Document Approved',
+ template: `## ✅ Document Approved!
+
+**Document:** {{document_type}}:{{document_key}}
+**Title:** {{title}}
+**Final Version:** v{{version}}
+**Approvals:** {{approval_count}}/{{stakeholder_count}}
+
+All required sign-offs have been received. This document is now approved and ready for implementation.
+
+---
+
+[View Approved Document]({{document_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ document_blocked: {
+ subject: '🚫 Document Blocked',
+ template: `## 🚫 Document Blocked
+
+**Document:** {{document_type}}:{{document_key}}
+**Blocked by:** @{{user}}
+
+### Blocking Reason
+
+{{reason}}
+
+---
+
+This blocking concern must be resolved before the document can be approved.
+
+{{#if feedback_issue}}
+[View Blocking Issue #{{feedback_issue}}]({{feedback_url}})
+{{/if}}
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ reminder: {
+ subject: '⏰ Reminder',
+ template: `## ⏰ Reminder: Action Needed
+
+{{mentions}}
+
+**Document:** {{document_type}}:{{document_key}}
+**Action:** {{action_needed}}
+**Deadline:** {{deadline}} ({{time_remaining}})
+
+Please complete your {{action_needed}} by {{deadline}}.
+
+---
+
+[View Document]({{document_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ },
+
+ deadline_extended: {
+ subject: '📅 Deadline Extended',
+ template: `## 📅 Deadline Extended
+
+**Document:** {{document_type}}:{{document_key}}
+**Previous Deadline:** {{old_deadline}}
+**New Deadline:** {{new_deadline}}
+
+{{#if reason}}
+**Reason:** {{reason}}
+{{/if}}
+
+---
+
+[View Document]({{document_url}})
+
+_Notification from PRD Crowdsourcing System_`
+ }
+};
+
+class GitHubNotifier {
+ /**
+ * Create a new GitHubNotifier
+ * @param {Object} config - Configuration object
+ * @param {string} config.owner - Repository owner
+ * @param {string} config.repo - Repository name
+ * @param {Object} config.github - GitHub MCP client
+ */
+ constructor(config) {
+ this.owner = config.owner;
+ this.repo = config.repo;
+ this.github = config.github;
+ }
+
+ /**
+ * Send a notification via GitHub
+ * @param {string} eventType - Type of notification event
+ * @param {Object} data - Event data
+ * @param {Object} options - Additional options
+ * @returns {Object} Notification result
+ */
+ async send(eventType, data, options = {}) {
+ const template = NOTIFICATION_TEMPLATES[eventType];
+ if (!template) {
+ throw new Error(`Unknown notification event type: ${eventType}`);
+ }
+
+ const message = this._renderTemplate(template.template, data);
+
+ // Determine where to post the notification
+ if (options.issueNumber) {
+ // Post as comment on existing issue
+ return await this._postComment(options.issueNumber, message);
+ } else if (options.createIssue) {
+ // Create a new issue
+ return await this._createIssue(
+ this._renderTemplate(template.subject, data),
+ message,
+ options.labels || []
+ );
+ } else if (data.review_issue) {
+ // Default to review issue if available
+ return await this._postComment(data.review_issue, message);
+ }
+
+ // If no target specified, return the message for manual handling
+ return {
+ success: true,
+ channel: 'github',
+ message,
+ note: 'No target issue specified, message returned for manual handling'
+ };
+ }
+
+ /**
+ * Send a reminder to pending users
+ * @param {number} issueNumber - Issue to post reminder on
+ * @param {string[]} users - Users to remind
+ * @param {Object} data - Reminder data
+ * @returns {Object} Notification result
+ */
+ async sendReminder(issueNumber, users, data) {
+ const reminderData = {
+ ...data,
+ mentions: users.map(u => `@${u}`).join(' ')
+ };
+
+ return await this.send('reminder', reminderData, { issueNumber });
+ }
+
+ /**
+ * Notify stakeholders via @mentions in issue body or comment
+ * @param {string[]} users - Users to notify
+ * @param {string} message - Notification message
+ * @param {number} issueNumber - Issue to post on
+ * @returns {Object} Notification result
+ */
+ async notifyStakeholders(users, message, issueNumber) {
+ const mentions = users.map(u => `@${u}`).join(' ');
+ const fullMessage = `${mentions}\n\n${message}`;
+
+ return await this._postComment(issueNumber, fullMessage);
+ }
+
+ /**
+ * Post a comment on an issue
+ * @private
+ */
+ async _postComment(issueNumber, body) {
+ try {
+ const result = await this.github.addIssueComment({
+ owner: this.owner,
+ repo: this.repo,
+ issue_number: issueNumber,
+ body
+ });
+
+ return {
+ success: true,
+ channel: 'github',
+ type: 'comment',
+ issueNumber,
+ commentId: result.id
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'github',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Create a new issue
+ * @private
+ */
+ async _createIssue(title, body, labels) {
+ try {
+ const result = await this.github.createIssue({
+ owner: this.owner,
+ repo: this.repo,
+ title,
+ body,
+ labels
+ });
+
+ return {
+ success: true,
+ channel: 'github',
+ type: 'issue',
+ issueNumber: result.number
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'github',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Render a template with data
+ * @private
+ */
+ _renderTemplate(template, data) {
+ let result = template;
+
+ // Simple mustache-like replacement
+ // Replace {{variable}}
+ result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
+ return data[key] !== undefined ? String(data[key]) : match;
+ });
+
+ // Handle {{#if condition}}...{{/if}}
+ result = result.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, key, content) => {
+ return data[key] ? content : '';
+ });
+
+ // Handle {{#each array}}...{{/each}}
+ result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, content) => {
+ const arr = data[key];
+ if (!Array.isArray(arr)) return '';
+ return arr.map((item, index) => {
+ let itemContent = content;
+ if (typeof item === 'object') {
+ Object.entries(item).forEach(([k, v]) => {
+ itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v));
+ });
+ } else {
+ itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
+ }
+ itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index));
+ return itemContent;
+ }).join('');
+ });
+
+ return result;
+ }
+}
+
+module.exports = {
+ GitHubNotifier,
+ NOTIFICATION_TEMPLATES
+};
diff --git a/src/modules/bmm/lib/notifications/index.js b/src/modules/bmm/lib/notifications/index.js
new file mode 100644
index 00000000..b438f38c
--- /dev/null
+++ b/src/modules/bmm/lib/notifications/index.js
@@ -0,0 +1,57 @@
+/**
+ * Notifications Module
+ *
+ * Multi-channel notification system for PRD/Epic crowdsourcing.
+ * Supports GitHub @mentions (baseline), Slack webhooks, and Email.
+ *
+ * Usage:
+ * ```javascript
+ * const { NotificationService } = require('./notifications');
+ *
+ * const notifier = new NotificationService({
+ * github: {
+ * owner: 'myorg',
+ * repo: 'myrepo',
+ * github: githubMcpClient
+ * },
+ * slack: {
+ * enabled: true,
+ * webhookUrl: 'https://hooks.slack.com/...',
+ * channel: '#prd-updates'
+ * },
+ * email: {
+ * enabled: true,
+ * provider: 'smtp',
+ * smtp: { host: 'smtp.example.com', port: 587, ... },
+ * fromAddress: 'prd-bot@example.com'
+ * }
+ * });
+ *
+ * // Send notification
+ * await notifier.notifyFeedbackRoundOpened(document, stakeholders, deadline);
+ * ```
+ */
+
+const { NotificationService, NOTIFICATION_EVENTS, PRIORITY_BEHAVIOR } = require('./notification-service');
+const { GitHubNotifier, NOTIFICATION_TEMPLATES: GITHUB_TEMPLATES } = require('./github-notifier');
+const { SlackNotifier, SLACK_TEMPLATES } = require('./slack-notifier');
+const { EmailNotifier, EMAIL_TEMPLATES } = require('./email-notifier');
+
+module.exports = {
+ // Main service
+ NotificationService,
+
+ // Individual notifiers (for custom usage)
+ GitHubNotifier,
+ SlackNotifier,
+ EmailNotifier,
+
+ // Constants
+ NOTIFICATION_EVENTS,
+ PRIORITY_BEHAVIOR,
+
+ // Templates (for customization)
+ GITHUB_TEMPLATES,
+ SLACK_TEMPLATES,
+ EMAIL_TEMPLATES
+};
diff --git a/src/modules/bmm/lib/notifications/notification-service.js b/src/modules/bmm/lib/notifications/notification-service.js
new file mode 100644
index 00000000..f8074841
--- /dev/null
+++ b/src/modules/bmm/lib/notifications/notification-service.js
@@ -0,0 +1,422 @@
+/**
+ * Notification Service - Multi-channel notification orchestration
+ *
+ * Coordinates notifications across GitHub, Slack, and Email channels.
+ * GitHub @mentions are always used as baseline; Slack and Email are optional.
+ */
+
+const { GitHubNotifier } = require('./github-notifier');
+const { SlackNotifier } = require('./slack-notifier');
+const { EmailNotifier } = require('./email-notifier');
+
+/**
+ * Notification event types and their default channels
+ */
+const NOTIFICATION_EVENTS = {
+ feedback_round_opened: {
+ description: 'PRD/Epic is open for feedback',
+ defaultChannels: ['github', 'slack', 'email'],
+ priority: 'normal'
+ },
+ feedback_submitted: {
+ description: 'New feedback submitted',
+ defaultChannels: ['github', 'slack'],
+ priority: 'normal'
+ },
+ synthesis_complete: {
+ description: 'Feedback synthesis completed',
+ defaultChannels: ['github', 'slack'],
+ priority: 'normal'
+ },
+ signoff_requested: {
+ description: 'Sign-off requested from stakeholders',
+ defaultChannels: ['github', 'slack', 'email'],
+ priority: 'high'
+ },
+ signoff_received: {
+ description: 'Sign-off decision received',
+ defaultChannels: ['github', 'slack'],
+ priority: 'normal'
+ },
+ document_approved: {
+ description: 'Document fully approved',
+ defaultChannels: ['github', 'slack', 'email'],
+ priority: 'high'
+ },
+ document_blocked: {
+ description: 'Document blocked by stakeholder',
+ defaultChannels: ['github', 'slack', 'email'],
+ priority: 'urgent'
+ },
+ reminder: {
+ description: 'Reminder for pending action',
+ defaultChannels: ['github', 'slack', 'email'],
+ priority: 'normal'
+ },
+ deadline_extended: {
+ description: 'Deadline has been extended',
+ defaultChannels: ['github'],
+ priority: 'low'
+ }
+};
+
+/**
+ * Priority levels and their behavior
+ */
+const PRIORITY_BEHAVIOR = {
+ urgent: {
+ retryOnFailure: true,
+ maxRetries: 3,
+ allChannels: true // Send on all available channels
+ },
+ high: {
+ retryOnFailure: true,
+ maxRetries: 2,
+ allChannels: false
+ },
+ normal: {
+ retryOnFailure: false,
+ maxRetries: 1,
+ allChannels: false
+ },
+ low: {
+ retryOnFailure: false,
+ maxRetries: 1,
+ allChannels: false
+ }
+};
+
+class NotificationService {
+ /**
+ * Create a new NotificationService
+ * @param {Object} config - Configuration object
+ * @param {Object} config.github - GitHub notifier config (required)
+ * @param {Object} config.slack - Slack notifier config (optional)
+ * @param {Object} config.email - Email notifier config (optional)
+ */
+ constructor(config) {
+ // GitHub is always required and enabled
+ this.channels = {
+ github: new GitHubNotifier(config.github)
+ };
+
+ // Optional channels
+ if (config.slack?.enabled && config.slack?.webhookUrl) {
+ this.channels.slack = new SlackNotifier(config.slack);
+ }
+
+ if (config.email?.enabled && (config.email?.smtp || config.email?.apiKey)) {
+ this.channels.email = new EmailNotifier(config.email);
+ }
+
+ this.config = config;
+ }
+
+ /**
+ * Get available notification channels
+ * @returns {string[]} Array of channel names
+ */
+ getAvailableChannels() {
+ return Object.keys(this.channels);
+ }
+
+ /**
+ * Check if a channel is available
+ * @param {string} channel - Channel name
+ * @returns {boolean}
+ */
+ isChannelAvailable(channel) {
+ return !!this.channels[channel];
+ }
+
+ /**
+ * Send a notification across configured channels
+ * @param {string} eventType - Type of notification event
+ * @param {Object} data - Event data
+ * @param {Object} options - Additional options
+ * @returns {Object} Results from all channels
+ */
+ async notify(eventType, data, options = {}) {
+ const eventConfig = NOTIFICATION_EVENTS[eventType];
+ if (!eventConfig) {
+ throw new Error(`Unknown notification event type: ${eventType}`);
+ }
+
+ // Determine which channels to use
+ let channels = options.channels || eventConfig.defaultChannels;
+
+ // Filter to only available channels
+ channels = channels.filter(ch => this.isChannelAvailable(ch));
+
+ // For urgent priority, use all available channels
+ const priority = options.priority || eventConfig.priority;
+ const priorityBehavior = PRIORITY_BEHAVIOR[priority];
+
+ if (priorityBehavior.allChannels) {
+ channels = this.getAvailableChannels();
+ }
+
+ // Ensure GitHub is always included (baseline)
+ if (!channels.includes('github')) {
+ channels.unshift('github');
+ }
+
+ // Send to all channels
+ const results = await Promise.all(
+ channels.map(async (channel) => {
+ return await this._sendToChannel(channel, eventType, data, options, priorityBehavior);
+ })
+ );
+
+ // Aggregate results
+ const aggregated = {
+ success: results.some(r => r.success),
+ eventType,
+ results: results.reduce((acc, r) => {
+ acc[r.channel] = r;
+ return acc;
+ }, {})
+ };
+
+ return aggregated;
+ }
+
+ /**
+ * Send a reminder to specific users
+ * @param {string} documentType - 'prd' or 'epic'
+ * @param {string} documentKey - Document key
+ * @param {string[]} users - Users to remind
+ * @param {Object} reminderData - Reminder data
+ * @returns {Object} Notification results
+ */
+ async sendReminder(documentType, documentKey, users, reminderData) {
+ const data = {
+ document_type: documentType,
+ document_key: documentKey,
+ mentions: users.map(u => `@${u}`).join(' '),
+ users,
+ ...reminderData
+ };
+
+ return await this.notify('reminder', data);
+ }
+
+ /**
+ * Notify about feedback round opening
+ * @param {Object} document - Document data
+ * @param {string[]} stakeholders - Stakeholders to notify
+ * @param {string} deadline - Deadline date
+ * @returns {Object} Notification results
+ */
+ async notifyFeedbackRoundOpened(document, stakeholders, deadline) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ title: document.title,
+ version: document.version,
+ deadline,
+ stakeholder_count: stakeholders.length,
+ mentions: stakeholders.map(s => `@${s}`).join(' '),
+ users: stakeholders,
+ document_url: document.url,
+ review_issue: document.reviewIssue
+ };
+
+ return await this.notify('feedback_round_opened', data);
+ }
+
+ /**
+ * Notify about new feedback submission
+ * @param {Object} feedback - Feedback data
+ * @param {Object} document - Document data
+ * @returns {Object} Notification results
+ */
+ async notifyFeedbackSubmitted(feedback, document) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ user: feedback.submittedBy,
+ feedback_type: feedback.type,
+ section: feedback.section,
+ summary: feedback.summary || feedback.title,
+ feedback_issue: feedback.issueNumber,
+ feedback_url: feedback.url,
+ review_issue: document.reviewIssue
+ };
+
+ // Only notify PO (not all stakeholders)
+ return await this.notify('feedback_submitted', data, {
+ notifyOnly: [document.owner]
+ });
+ }
+
+ /**
+ * Notify about synthesis completion
+ * @param {Object} document - Document data
+ * @param {Object} synthesis - Synthesis results
+ * @returns {Object} Notification results
+ */
+ async notifySynthesisComplete(document, synthesis) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ old_version: synthesis.oldVersion,
+ new_version: synthesis.newVersion,
+ feedback_count: synthesis.feedbackCount,
+ conflicts_resolved: synthesis.conflictsResolved,
+ summary: synthesis.summary,
+ document_url: document.url,
+ review_issue: document.reviewIssue
+ };
+
+ return await this.notify('synthesis_complete', data);
+ }
+
+ /**
+ * Notify about sign-off request
+ * @param {Object} document - Document data
+ * @param {string[]} stakeholders - Stakeholders to request sign-off from
+ * @param {string} deadline - Sign-off deadline
+ * @param {Object} config - Sign-off configuration
+ * @returns {Object} Notification results
+ */
+ async notifySignoffRequested(document, stakeholders, deadline, config) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ title: document.title,
+ version: document.version,
+ deadline,
+ approvals_needed: config.minimum_approvals || Math.ceil(stakeholders.length * 0.5),
+ mentions: stakeholders.map(s => `@${s}`).join(' '),
+ users: stakeholders,
+ document_url: document.url,
+ signoff_url: document.signoffUrl,
+ review_issue: document.reviewIssue
+ };
+
+ return await this.notify('signoff_requested', data);
+ }
+
+ /**
+ * Notify about sign-off received
+ * @param {Object} signoff - Sign-off data
+ * @param {Object} document - Document data
+ * @param {Object} progress - Current progress
+ * @returns {Object} Notification results
+ */
+ async notifySignoffReceived(signoff, document, progress) {
+ const emojis = {
+ approved: '✅',
+ 'approved-with-note': '✅📝',
+ blocked: '🚫'
+ };
+
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ user: signoff.user,
+ decision: signoff.decision,
+ emoji: emojis[signoff.decision] || '❓',
+ note: signoff.note,
+ progress_current: progress.current,
+ progress_total: progress.total,
+ review_issue: document.reviewIssue,
+ review_url: document.reviewUrl
+ };
+
+ return await this.notify('signoff_received', data);
+ }
+
+ /**
+ * Notify about document approval
+ * @param {Object} document - Document data
+ * @param {number} approvalCount - Number of approvals
+ * @param {number} stakeholderCount - Total stakeholders
+ * @returns {Object} Notification results
+ */
+ async notifyDocumentApproved(document, approvalCount, stakeholderCount) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ title: document.title,
+ version: document.version,
+ approval_count: approvalCount,
+ stakeholder_count: stakeholderCount,
+ document_url: document.url
+ };
+
+ return await this.notify('document_approved', data);
+ }
+
+ /**
+ * Notify about document being blocked
+ * @param {Object} document - Document data
+ * @param {Object} block - Block data
+ * @returns {Object} Notification results
+ */
+ async notifyDocumentBlocked(document, block) {
+ const data = {
+ document_type: document.type,
+ document_key: document.key,
+ user: block.user,
+ reason: block.reason,
+ feedback_issue: block.feedbackIssue,
+ feedback_url: block.feedbackUrl
+ };
+
+ return await this.notify('document_blocked', data);
+ }
+
+ /**
+ * Send to a specific channel with retry logic
+ * @private
+ */
+ async _sendToChannel(channel, eventType, data, options, priorityBehavior) {
+ const notifier = this.channels[channel];
+ if (!notifier) {
+ return {
+ success: false,
+ channel,
+ error: 'Channel not available'
+ };
+ }
+
+ let lastError = null;
+ const maxRetries = priorityBehavior.retryOnFailure ? priorityBehavior.maxRetries : 1;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ const result = await notifier.send(eventType, data, options);
+ result.channel = channel;
+ result.attempt = attempt;
+
+ if (result.success) {
+ return result;
+ }
+
+ lastError = result.error;
+ } catch (error) {
+ lastError = error.message;
+ }
+
+ // Wait before retry (exponential backoff)
+ if (attempt < maxRetries) {
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
+ }
+ }
+
+ return {
+ success: false,
+ channel,
+ error: lastError,
+ attempts: maxRetries
+ };
+ }
+}
+
+module.exports = {
+ NotificationService,
+ NOTIFICATION_EVENTS,
+ PRIORITY_BEHAVIOR
+};
diff --git a/src/modules/bmm/lib/notifications/slack-notifier.js b/src/modules/bmm/lib/notifications/slack-notifier.js
new file mode 100644
index 00000000..efe0e694
--- /dev/null
+++ b/src/modules/bmm/lib/notifications/slack-notifier.js
@@ -0,0 +1,457 @@
+/**
+ * Slack Notifier - Optional Slack webhook integration
+ *
+ * Sends notifications to Slack channels via incoming webhooks.
+ * This is an optional notification channel that can be enabled in config.
+ */
+
+const SLACK_TEMPLATES = {
+ feedback_round_opened: {
+ color: '#36a64f', // Green
+ title: '📣 Feedback Round Open',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '📣 Feedback Round Open', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
+ { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
+ { type: 'mrkdwn', text: `*Stakeholders:*\n${data.stakeholder_count}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: `Please review and provide feedback by *${data.deadline}*.` }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Document', emoji: true },
+ url: data.document_url,
+ style: 'primary'
+ }
+ ]
+ }
+ ]
+ },
+
+ feedback_submitted: {
+ color: '#1e90ff', // Blue
+ title: '💬 New Feedback Submitted',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '💬 New Feedback', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*From:*\n${data.user}` },
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Type:*\n${data.feedback_type}` },
+ { type: 'mrkdwn', text: `*Section:*\n${data.section}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: `> ${data.summary.substring(0, 200)}${data.summary.length > 200 ? '...' : ''}` }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Feedback', emoji: true },
+ url: data.feedback_url
+ }
+ ]
+ }
+ ]
+ },
+
+ synthesis_complete: {
+ color: '#9932cc', // Purple
+ title: '🔄 Synthesis Complete',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '🔄 Synthesis Complete', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Version:*\nv${data.old_version} → v${data.new_version}` },
+ { type: 'mrkdwn', text: `*Feedback Processed:*\n${data.feedback_count} items` },
+ { type: 'mrkdwn', text: `*Conflicts Resolved:*\n${data.conflicts_resolved || 0}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: data.summary.substring(0, 500) }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Document', emoji: true },
+ url: data.document_url,
+ style: 'primary'
+ }
+ ]
+ }
+ ]
+ },
+
+ signoff_requested: {
+ color: '#ffa500', // Orange
+ title: '✍️ Sign-off Requested',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '✍️ Sign-off Requested', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
+ { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
+ { type: 'mrkdwn', text: `*Approvals Needed:*\n${data.approvals_needed}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: 'Please review and provide your sign-off decision.' }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Document', emoji: true },
+ url: data.document_url,
+ style: 'primary'
+ },
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'Sign Off', emoji: true },
+ url: data.signoff_url
+ }
+ ]
+ }
+ ]
+ },
+
+ signoff_received: {
+ color: (data) => data.decision === 'blocked' ? '#dc3545' : '#28a745',
+ title: (data) => `${data.emoji} Sign-off from ${data.user}`,
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: `${data.emoji} Sign-off Received`, emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*From:*\n${data.user}` },
+ { type: 'mrkdwn', text: `*Decision:*\n${data.decision}` },
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Progress:*\n${data.progress_current}/${data.progress_total}` }
+ ]
+ },
+ ...(data.note ? [{
+ type: 'section',
+ text: { type: 'mrkdwn', text: `*Note:* ${data.note}` }
+ }] : []),
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Progress', emoji: true },
+ url: data.review_url
+ }
+ ]
+ }
+ ]
+ },
+
+ document_approved: {
+ color: '#28a745', // Green
+ title: '✅ Document Approved!',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '✅ Document Approved!', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Title:*\n${data.title}` },
+ { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
+ { type: 'mrkdwn', text: `*Approvals:*\n${data.approval_count}/${data.stakeholder_count}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: '🎉 All required sign-offs received. Ready for implementation!' }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Document', emoji: true },
+ url: data.document_url,
+ style: 'primary'
+ }
+ ]
+ }
+ ]
+ },
+
+ document_blocked: {
+ color: '#dc3545', // Red
+ title: '🚫 Document Blocked',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '🚫 Document Blocked', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Blocked by:*\n${data.user}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: `*Reason:*\n${data.reason}` }
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: '⚠️ This blocking concern must be resolved before approval.' }
+ },
+ ...(data.feedback_url ? [{
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Issue', emoji: true },
+ url: data.feedback_url,
+ style: 'danger'
+ }
+ ]
+ }] : [])
+ ]
+ },
+
+ reminder: {
+ color: '#ffc107', // Yellow
+ title: '⏰ Reminder: Action Needed',
+ blocks: (data) => [
+ {
+ type: 'header',
+ text: { type: 'plain_text', text: '⏰ Reminder: Action Needed', emoji: true }
+ },
+ {
+ type: 'section',
+ fields: [
+ { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
+ { type: 'mrkdwn', text: `*Action:*\n${data.action_needed}` },
+ { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
+ { type: 'mrkdwn', text: `*Time Remaining:*\n${data.time_remaining}` }
+ ]
+ },
+ {
+ type: 'section',
+ text: { type: 'mrkdwn', text: `Pending: ${data.pending_users?.join(', ') || 'Unknown'}` }
+ },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: { type: 'plain_text', text: 'View Document', emoji: true },
+ url: data.document_url,
+ style: 'primary'
+ }
+ ]
+ }
+ ]
+ }
+};
+
+class SlackNotifier {
+ /**
+ * Create a new SlackNotifier
+ * @param {Object} config - Configuration object
+ * @param {string} config.webhookUrl - Slack incoming webhook URL
+ * @param {string} config.channel - Default channel (optional, webhook may have default)
+ * @param {string} config.username - Bot username (optional)
+ * @param {string} config.iconEmoji - Bot icon emoji (optional)
+ */
+ constructor(config) {
+ this.webhookUrl = config.webhookUrl;
+ this.channel = config.channel;
+ this.username = config.username || 'PRD Crowdsource Bot';
+ this.iconEmoji = config.iconEmoji || ':clipboard:';
+ this.enabled = !!config.webhookUrl;
+ }
+
+ /**
+ * Check if Slack notifications are enabled
+ * @returns {boolean}
+ */
+ isEnabled() {
+ return this.enabled;
+ }
+
+ /**
+ * Send a notification via Slack
+ * @param {string} eventType - Type of notification event
+ * @param {Object} data - Event data
+ * @param {Object} options - Additional options
+ * @returns {Object} Notification result
+ */
+ async send(eventType, data, options = {}) {
+ if (!this.enabled) {
+ return {
+ success: false,
+ channel: 'slack',
+ error: 'Slack notifications not enabled'
+ };
+ }
+
+ const template = SLACK_TEMPLATES[eventType];
+ if (!template) {
+ return {
+ success: false,
+ channel: 'slack',
+ error: `Unknown notification event type: ${eventType}`
+ };
+ }
+
+ const payload = this._buildPayload(template, data, options);
+
+ try {
+ const response = await this._sendWebhook(payload);
+ return {
+ success: true,
+ channel: 'slack',
+ eventType
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'slack',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Send a custom message to Slack
+ * @param {string} text - Message text
+ * @param {Object} options - Additional options (channel, attachments, blocks)
+ * @returns {Object} Notification result
+ */
+ async sendCustom(text, options = {}) {
+ if (!this.enabled) {
+ return {
+ success: false,
+ channel: 'slack',
+ error: 'Slack notifications not enabled'
+ };
+ }
+
+ const payload = {
+ text,
+ channel: options.channel || this.channel,
+ username: this.username,
+ icon_emoji: this.iconEmoji,
+ ...options
+ };
+
+ try {
+ await this._sendWebhook(payload);
+ return {
+ success: true,
+ channel: 'slack'
+ };
+ } catch (error) {
+ return {
+ success: false,
+ channel: 'slack',
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Build Slack payload from template
+ * @private
+ */
+ _buildPayload(template, data, options) {
+ const color = typeof template.color === 'function'
+ ? template.color(data)
+ : template.color;
+
+ const title = typeof template.title === 'function'
+ ? template.title(data)
+ : template.title;
+
+ const blocks = template.blocks(data);
+
+ return {
+ channel: options.channel || this.channel,
+ username: this.username,
+ icon_emoji: this.iconEmoji,
+ text: title, // Fallback for notifications
+ attachments: [
+ {
+ color,
+ fallback: title,
+ blocks
+ }
+ ]
+ };
+ }
+
+ /**
+ * Send webhook request
+ * @private
+ */
+ async _sendWebhook(payload) {
+ // Note: In a real implementation, this would use fetch or axios
+ // For the workflow engine, this will be handled by the runtime
+ const response = await fetch(this.webhookUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ throw new Error(`Slack webhook failed: ${response.status} ${response.statusText}`);
+ }
+
+ return response;
+ }
+}
+
+module.exports = {
+ SlackNotifier,
+ SLACK_TEMPLATES
+};
diff --git a/src/modules/bmm/module.yaml b/src/modules/bmm/module.yaml
index e6b8dee1..0357fc26 100644
--- a/src/modules/bmm/module.yaml
+++ b/src/modules/bmm/module.yaml
@@ -54,3 +54,44 @@ tea_use_playwright_utils:
- "Are you using playwright-utils (@seontechnologies/playwright-utils) in your project?\nYou must install packages yourself, or use test architect's *framework command."
default: false
result: "{value}"
+
+# GitHub Integration for Enterprise Teams (Phase 1)
+github_integration_enabled:
+ prompt:
+ - "Enable GitHub Integration for enterprise team coordination?"
+ - "This enables story locking, real-time progress sync, and PO workflows."
+ - "Requires: GitHub MCP configured, repository with Issues enabled."
+ default: false
+ result: "{value}"
+
+github_owner:
+ prompt: "GitHub username or organization name for your repository"
+ default: ""
+ result: "{value}"
+ condition: "github_integration_enabled == true"
+
+github_repo:
+ prompt: "GitHub repository name"
+ default: "{project_name}"
+ result: "{value}"
+ condition: "github_integration_enabled == true"
+
+github_lock_timeout_hours:
+ prompt: "Story lock timeout in hours (default: 8 = full workday)"
+ default: 8
+ result: "{value}"
+ condition: "github_integration_enabled == true"
+
+github_cache_staleness_minutes:
+ prompt: "Minutes before cached story is considered stale (default: 5)"
+ default: 5
+ result: "{value}"
+ condition: "github_integration_enabled == true"
+
+github_scrum_masters:
+ prompt:
+ - "GitHub usernames of Scrum Masters (comma-separated)"
+ - "These users can force-unlock stories locked by others."
+ default: ""
+ result: "{value}"
+ condition: "github_integration_enabled == true"
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md
new file mode 100644
index 00000000..00a30db7
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md
@@ -0,0 +1,600 @@
+# Create Epic Draft - From PRD to Implementation-Ready Epics
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review label:review-status:approved is:closed"
+ })
+
+
+
+ HALT
+
+
+
+ approved_prds = response.items.map(issue => {
+ const labels = issue.labels.map(l => l.name)
+ const prd_key = labels.find(l => l.startsWith('prd:'))?.replace('prd:', '')
+ return {
+ key: prd_key,
+ title: issue.title.replace(/^(PRD Review|Sign-off):\s*/, '').replace(/\s+v\d+$/, ''),
+ issue_number: issue.number,
+ approved_date: issue.closed_at
+ }
+ }).filter(p => p.key)
+
+
+
+
+
+ Select PRD to create epic from (1-{{approved_prds.length}}):
+ source_prd = approved_prds[parseInt(response) - 1].key
+
+
+
+
+
+
+ prd_path = `${docs_dir}/prd/${source_prd}.md`
+ Read prd_path
+
+
+
+ HALT
+
+
+ prd_content = file_content
+
+ prd_title = extract_title(prd_content)
+ prd_version = extract_version(prd_content)
+ user_stories = extract_user_stories(prd_content)
+ functional_reqs = extract_functional_requirements(prd_content)
+ nfrs = extract_non_functional_requirements(prd_content)
+ constraints = extract_constraints(prd_content)
+ stakeholders_from_prd = extract_stakeholders(prd_content)
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:source-prd:{{source_prd}}"
+ })
+
+
+
+ existing_epics = response.items.map(issue => {
+ const labels = issue.labels.map(l => l.name)
+ return {
+ key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''),
+ title: issue.title,
+ state: issue.state
+ }
+ })
+
+
+
+
+ Choice:
+
+ HALT
+
+
+
+
+
+
+
+
+
+
+ Stories to include:
+
+ if (response.toLowerCase() === 'all') {
+ selected_stories = user_stories
+ } else {
+ const indices = response.split(',').map(s => parseInt(s.trim()) - 1)
+ selected_stories = indices.map(i => user_stories[i]).filter(Boolean)
+ }
+
+
+
+
+
+
+
+ // Generate suggested epic key
+ epic_number = (existing_epics?.length || 0) + 1
+ suggested_key = `${source_prd}-epic-${epic_number}`
+
+ // Generate suggested title based on selected stories
+ if (selected_stories.length === 1) {
+ suggested_title = selected_stories[0].title
+ } else {
+ suggested_title = `${prd_title} - Phase ${epic_number}`
+ }
+
+
+
+
+ Epic title (or press Enter for suggested):
+ epic_title = response || suggested_title
+
+
+ Epic key (or press Enter for suggested):
+ epic_key = response || suggested_key
+
+
+
+
+
+
+
+ Epic stakeholders (comma-separated usernames, or 'same' for PRD stakeholders):
+
+ if (response.toLowerCase() === 'same') {
+ stakeholders = stakeholders_from_prd
+ } else {
+ stakeholders = response.split(',').map(s => s.trim().replace('@', ''))
+ }
+
+
+
+
+
+
+
+
+
+ // Use LLM to break down user stories into implementation stories
+ prompt = `Based on the following PRD user stories, generate a set of implementation stories for an epic.
+
+PRD Title: ${prd_title}
+Epic Title: ${epic_title}
+
+Selected User Stories:
+${selected_stories.map(s => `- ${s.id}: ${s.title}\n As a ${s.role}, I want ${s.capability}, so that ${s.benefit}`).join('\n')}
+
+Related Functional Requirements:
+${functional_reqs.map(fr => `- ${fr.id}: ${fr.title}`).join('\n')}
+
+Non-Functional Requirements to consider:
+${nfrs.map(nfr => `- ${nfr.id}: ${nfr.title}`).join('\n')}
+
+Generate 3-7 implementation stories that:
+1. Are independently deliverable
+2. Follow a logical implementation order
+3. Include clear acceptance criteria
+4. Reference the source user stories
+5. Consider technical dependencies
+
+Output format:
+---
+### Story 1: [Title]
+**Source US:** [user story id]
+**Description:** [brief description]
+**Acceptance Criteria:**
+- [ ] [criterion 1]
+- [ ] [criterion 2]
+**Dependencies:** [any dependencies]
+**Estimated Complexity:** [S/M/L/XL]
+---`
+
+ // LLM generates story breakdown
+ generated_stories = await llm_generate(prompt)
+
+
+
+
+ Choice:
+
+ Describe your modifications:
+
+ // Regenerate with user feedback
+ modification_prompt = `${prompt}\n\nUser requested modifications:\n${response}\n\nPlease regenerate the story breakdown incorporating this feedback.`
+ generated_stories = await llm_generate(modification_prompt)
+
+
+
+
+
+ What approach should be used?
+
+ approach_prompt = `${prompt}\n\nApproach to use:\n${response}\n\nPlease regenerate using this approach.`
+ generated_stories = await llm_generate(approach_prompt)
+
+
+
+
+
+ HALT
+
+
+
+
+
+ epic_doc = `# Epic: ${epic_title}
+
+**Epic Key:** \`epic:${epic_key}\`
+**Source PRD:** \`prd:${source_prd}\` (v${prd_version})
+**Version:** 1
+**Status:** Draft
+**Created:** ${new Date().toISOString().split('T')[0]}
+**Last Updated:** ${new Date().toISOString().split('T')[0]}
+
+---
+
+## Metadata
+| Field | Value |
+|-------|-------|
+| Product Owner | @${current_user} |
+| Stakeholders | ${stakeholders.map(s => '@' + s).join(', ')} |
+| Tech Lead | TBD |
+| Feedback Deadline | TBD |
+| Sign-off Deadline | TBD |
+
+---
+
+## Overview
+
+This epic implements the following user stories from prd:${source_prd}:
+
+${selected_stories.map(s => `- **${s.id}:** ${s.title}`).join('\n')}
+
+---
+
+## Goals
+
+${selected_stories.map((s, i) => `${i + 1}. ${s.capability}`).join('\n')}
+
+---
+
+## Implementation Stories
+
+${generated_stories}
+
+---
+
+## Dependencies
+
+
+
+- TBD
+
+---
+
+## Technical Considerations
+
+
+
+### From NFRs:
+${nfrs.map(nfr => `- ${nfr.title}`).join('\n')}
+
+### Constraints:
+${constraints.map(c => `- ${c}`).join('\n')}
+
+---
+
+## Out of Scope
+
+
+
+- TBD
+
+---
+
+## Version History
+| Version | Date | Changes | Feedback Incorporated |
+|---------|------|---------|----------------------|
+| 1 | ${new Date().toISOString().split('T')[0]} | Initial draft from PRD | - |
+
+---
+
+## Sign-off Status
+| Stakeholder | Status | Date | Notes |
+|-------------|--------|------|-------|
+${stakeholders.map(s => `| @${s} | ⏳ Pending | - | - |`).join('\n')}
+`
+
+
+ epic_path = `${docs_dir}/epics/epic-${epic_key}.md`
+ Write epic_doc to epic_path
+
+
+
+
+
+
+ issue_body = `# 📦 Epic Review: ${epic_title}
+
+**Epic Key:** \`epic:${epic_key}\`
+**Source PRD:** prd:${source_prd}
+**Version:** 1
+**Status:** 📝 Draft
+
+---
+
+## Included Stories
+
+This epic implements user stories from the approved PRD:
+${selected_stories.map(s => `- ${s.id}: ${s.title}`).join('\n')}
+
+---
+
+## Document Link
+
+📄 [Epic Document](${epic_path})
+
+---
+
+## Story Breakdown
+
+${generated_stories}
+
+---
+
+## Stakeholders
+
+${stakeholders.map(s => `- @${s}`).join('\n')}
+
+---
+
+_Created by @${current_user} on ${new Date().toISOString().split('T')[0]}_
+_Ready for feedback round when PO opens it._`
+
+
+ Call: mcp__github__issue_write({
+ method: 'create',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ title: "Epic Review: {{epic_title}}",
+ body: issue_body,
+ labels: ['type:epic-review', `epic:${epic_key}`, `source-prd:${source_prd}`, 'version:1', 'review-status:draft']
+ })
+
+ review_issue = response
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+function extract_title(content) {
+ const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m);
+ return match ? match[2].trim() : 'Untitled';
+}
+
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+function extract_user_stories(content) {
+ const stories = [];
+ const usRegex = /###\s+(US\d+):\s*(.+)\n+As a (.+), I want (.+), so that (.+)/g;
+ let match;
+ while ((match = usRegex.exec(content)) !== null) {
+ stories.push({
+ id: match[1],
+ title: match[2].trim(),
+ role: match[3].trim(),
+ capability: match[4].trim(),
+ benefit: match[5].trim()
+ });
+ }
+ return stories;
+}
+
+function extract_functional_requirements(content) {
+ const reqs = [];
+ const frRegex = /###\s+(FR\d+):\s*(.+)\n+([\s\S]*?)(?=###|$)/g;
+ let match;
+ while ((match = frRegex.exec(content)) !== null) {
+ reqs.push({
+ id: match[1],
+ title: match[2].trim(),
+ description: match[3].trim()
+ });
+ }
+ return reqs;
+}
+
+function extract_non_functional_requirements(content) {
+ const nfrs = [];
+ const nfrRegex = /###\s+(NFR\d+):\s*(.+)/g;
+ let match;
+ while ((match = nfrRegex.exec(content)) !== null) {
+ nfrs.push({
+ id: match[1],
+ title: match[2].trim()
+ });
+ }
+ return nfrs;
+}
+
+function extract_constraints(content) {
+ const section = content.match(/## Constraints\n+([\s\S]*?)(?=\n##|$)/);
+ if (!section) return [];
+
+ return section[1]
+ .split('\n')
+ .filter(line => line.startsWith('-'))
+ .map(line => line.replace(/^-\s*/, '').trim());
+}
+
+function extract_stakeholders(content) {
+ const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/);
+ if (!field) return [];
+
+ return field[1]
+ .split(/[,\s]+/)
+ .filter(s => s.startsWith('@'))
+ .map(s => s.replace('@', ''));
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Create epic from PRD"
+- "Break down PRD into epics"
+- "Start epic from [prd]"
+- Menu trigger: `CE`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml
new file mode 100644
index 00000000..3e79fde2
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml
@@ -0,0 +1,23 @@
+name: create-epic-draft
+description: "Create an epic from an approved PRD, splitting user stories into implementation-ready epics"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+source_prd: "" # PRD key to create epic from
+epic_key: "" # Optional: override generated epic key
+stakeholders: [] # Override default stakeholders
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md
new file mode 100644
index 00000000..9ba9e8f2
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md
@@ -0,0 +1,432 @@
+# Create PRD Draft - Start Async Requirements Collaboration
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+ Choice (1-4):
+ creation_method = choice
+
+
+
+
+
+ PRD Title (e.g., "User Authentication System"):
+ prd_title = response
+
+ PRD Key (short identifier, e.g., "user-auth", no spaces):
+ prd_key = response.toLowerCase().replace(/\s+/g, '-')
+
+
+ Check if file exists at: {{docs_dir}}/{{prd_key}}.md
+
+
+ Choice:
+
+ Goto step 2
+
+
+ is_new_version = true
+ Load existing PRD and increment version
+
+
+ HALT
+
+
+
+
+
+
+
+
+
+
+ What is the vision for this product/feature? (1-2 sentences)
+ vision = response
+
+ What problem does this solve? What pain points does it address?
+ problem_statement = response
+
+
+
+
+
+ List the primary goals (one per line, or comma-separated):
+ goals = parse_list(response)
+
+
+
+
+
+ user_stories = []
+
+ User Story (or press Enter to finish):
+
+ break loop
+
+ user_stories.push(response)
+
+
+
+
+
+
+ Functional Requirements:
+ functional_reqs = parse_list(response)
+
+
+
+ Non-Functional Requirements:
+ non_functional_reqs = parse_list(response)
+
+
+
+
+
+ What constraints apply? (technical, timeline, budget, etc.):
+ constraints = parse_list(response)
+
+ What is explicitly out of scope for this PRD?:
+ out_of_scope = parse_list(response)
+
+
+
+
+
+
+ Glob for: **/prd-output.md, **/prd-*.md in project
+ List found files for user selection
+
+ Select file number to import (or path):
+ import_path = response
+ Read and parse imported PRD content
+
+
+
+
+
+ Path to product brief document:
+ brief_path = response
+ Read product brief and extract PRD sections using LLM
+
+
+
+
+ // Minimal template - just placeholders
+ vision = "[To be defined]"
+ problem_statement = "[To be defined]"
+ goals = ["[Goal 1]", "[Goal 2]"]
+ user_stories = []
+ functional_reqs = []
+ non_functional_reqs = []
+ constraints = []
+ out_of_scope = []
+
+
+
+
+
+
+
+ stakeholders = [current_user]
+
+ Stakeholder username (or press Enter to finish):
+
+ break loop
+
+
+ username = response.replace('@', '')
+ if (!stakeholders.includes(username)) {
+ stakeholders.push(username)
+ }
+
+
+
+
+
+
+
+
+prd_content = `# PRD: ${prd_title}
+
+**PRD Key:** \`prd:${prd_key}\`
+**Version:** 1
+**Status:** Draft
+**Created:** ${new Date().toISOString().split('T')[0]}
+**Last Updated:** ${new Date().toISOString().split('T')[0]}
+
+---
+
+## Metadata
+| Field | Value |
+|-------|-------|
+| Product Owner | @${current_user} |
+| Stakeholders | ${stakeholders.map(s => '@' + s).join(', ')} |
+| Feedback Deadline | [To be set] |
+| Sign-off Deadline | [To be set] |
+
+---
+
+## Vision
+
+${vision}
+
+## Problem Statement
+
+${problem_statement}
+
+## Goals
+
+${goals.map((g, i) => `${i + 1}. ${g}`).join('\n')}
+
+## User Stories
+
+${user_stories.length > 0
+ ? user_stories.map((s, i) => `### US${i + 1}: ${extract_story_title(s)}\n${s}\n`).join('\n')
+ : '*No user stories defined yet.*'}
+
+## Functional Requirements
+
+${functional_reqs.length > 0
+ ? functional_reqs.map((r, i) => `### FR${i + 1}\n${r}\n`).join('\n')
+ : '*No functional requirements defined yet.*'}
+
+## Non-Functional Requirements
+
+${non_functional_reqs.length > 0
+ ? non_functional_reqs.map((r, i) => `### NFR${i + 1}\n${r}\n`).join('\n')
+ : '*No non-functional requirements defined yet.*'}
+
+## Constraints
+
+${constraints.length > 0
+ ? constraints.map(c => `- ${c}`).join('\n')
+ : '*No constraints defined.*'}
+
+## Out of Scope
+
+${out_of_scope.length > 0
+ ? out_of_scope.map(c => `- ${c}`).join('\n')
+ : '*Nothing explicitly marked out of scope.*'}
+
+---
+
+## Version History
+
+| Version | Date | Changes | Feedback Incorporated |
+|---------|------|---------|----------------------|
+| 1 | ${new Date().toISOString().split('T')[0]} | Initial draft | - |
+
+---
+
+## Sign-off Status
+
+| Stakeholder | Status | Date | Notes |
+|-------------|--------|------|-------|
+${stakeholders.map(s => `| @${s} | ⏳ Pending | - | - |`).join('\n')}
+`
+
+
+
+
+
+ Ensure directory exists: {{docs_dir}}
+
+
+
+ prd_path = {{docs_dir}}/{{prd_key}}.md
+ Write prd_content to prd_path
+
+
+
+
+ // Using CacheManager
+ cacheManager.writePrd(prd_key, prd_content, {
+ version: 1,
+ status: 'draft',
+ stakeholders: stakeholders,
+ owner: current_user
+ })
+
+
+
+
+
+
+
+
+
+ Choice:
+
+
+
+ Load workflow: open-feedback-round with prd_key = prd_key
+
+
+
+
+ Exit
+
+
+
+ Read and display prd_path
+ Goto step 7
+
+
+
+
+ Exit
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Parse comma or newline separated list
+function parse_list(input) {
+ if (!input || input.trim() === '') return [];
+ return input
+ .split(/[,\n]/)
+ .map(s => s.trim())
+ .filter(s => s.length > 0);
+}
+
+// Extract title from user story
+function extract_story_title(story) {
+ const match = story.match(/I want\s+(.+?),?\s+so that/i);
+ return match ? match[1].slice(0, 40) : 'User Story';
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Create a new PRD"
+- "Start a PRD for [feature]"
+- "I need to write requirements for..."
+- Menu trigger: `CP`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml
new file mode 100644
index 00000000..31909e70
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml
@@ -0,0 +1,21 @@
+name: create-prd-draft
+description: "Create a new PRD draft with stakeholder list - stored as markdown, coordinated via GitHub"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs/prd"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# PRD creation options
+import_from: "" # 'scratch', 'existing-prd', 'product-brief'
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md
new file mode 100644
index 00000000..5c893b73
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md
@@ -0,0 +1,508 @@
+# Epic Dashboard - Central Epic Visibility with PRD Lineage
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+
+
+ query = "repo:{{github_owner}}/{{github_repo}} label:type:epic-review"
+ if (source_prd) {
+ query += ` label:source-prd:${source_prd}`
+ }
+
+
+ Call: mcp__github__search_issues({
+ query: query
+ })
+ review_issues = response.items || []
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback is:open"
+ })
+ feedback_issues = response.items || []
+
+
+
+
+ epics = {}
+ status_counts = { draft: 0, feedback: 0, synthesis: 0, signoff: 0, approved: 0 }
+ prds_with_epics = {}
+
+ // Process review issues to get epic status
+ for (issue of review_issues) {
+ const labels = issue.labels.map(l => l.name)
+ const epic_key = extract_label(labels, 'epic:')
+ const source_prd_key = extract_label(labels, 'source-prd:')
+
+ if (!epic_key) continue
+
+ if (!epics[epic_key]) {
+ epics[epic_key] = {
+ key: epic_key,
+ title: issue.title.replace(/^Epic Review:\s*/, '').replace(/\s+v\d+$/, ''),
+ source_prd: source_prd_key,
+ reviews: [],
+ feedback: [],
+ status: 'draft',
+ stories: 0,
+ last_activity: issue.updated_at,
+ issue_number: issue.number
+ }
+ }
+
+ epics[epic_key].reviews.push(issue)
+
+ // Track PRDs with epics
+ if (source_prd_key) {
+ if (!prds_with_epics[source_prd_key]) {
+ prds_with_epics[source_prd_key] = []
+ }
+ if (!prds_with_epics[source_prd_key].includes(epic_key)) {
+ prds_with_epics[source_prd_key].push(epic_key)
+ }
+ }
+
+ // Determine current status from most recent review
+ const review_status = extract_label(labels, 'review-status:')
+ if (review_status === 'open' && issue.state === 'open') {
+ epics[epic_key].status = 'feedback'
+ } else if (review_status === 'synthesis' && issue.state === 'open') {
+ epics[epic_key].status = 'synthesis'
+ } else if (review_status === 'signoff' && issue.state === 'open') {
+ epics[epic_key].status = 'signoff'
+ } else if (review_status === 'approved' || issue.state === 'closed') {
+ epics[epic_key].status = 'approved'
+ } else if (review_status === 'draft') {
+ epics[epic_key].status = 'draft'
+ }
+
+ if (new Date(issue.updated_at) > new Date(epics[epic_key].last_activity)) {
+ epics[epic_key].last_activity = issue.updated_at
+ }
+ }
+
+ // Attach feedback to epics
+ for (issue of feedback_issues) {
+ const labels = issue.labels.map(l => l.name)
+ const epic_key = extract_label(labels, 'epic:')
+
+ if (epic_key && epics[epic_key]) {
+ epics[epic_key].feedback.push({
+ id: issue.number,
+ title: issue.title,
+ type: extract_label(labels, 'feedback-type:'),
+ status: extract_label(labels, 'feedback-status:'),
+ submittedBy: issue.user?.login
+ })
+ }
+ }
+
+ // Count by status
+ for (epic of Object.values(epics)) {
+ status_counts[epic.status] = (status_counts[epic.status] || 0) + 1
+ }
+
+ epic_list = Object.values(epics).sort((a, b) =>
+ new Date(b.last_activity) - new Date(a.last_activity)
+ )
+
+
+
+
+
+
+
+
+ Goto step 4 (interactive menu)
+
+
+
+
+
+ epic = epics[epic_key]
+
+
+
+ epic_key = ''
+ Goto step 2
+
+
+
+ // Load epic document for story count
+ epic_path = `${docs_dir}/epics/epic-${epic_key}.md`
+ Read epic_path
+ epic_content = file_content || ''
+
+ stories = extract_epic_stories(epic_content)
+ tech_lead = extract_tech_lead(epic_content)
+ dependencies = extract_dependencies(epic_content)
+
+ // Get active review issue
+ active_review = epic.reviews.find(r => r.state === 'open')
+
+ // Count feedback by status
+ new_feedback = epic.feedback.filter(f => f.status === 'new').length
+ reviewed_feedback = epic.feedback.filter(f => f.status === 'reviewed').length
+
+ // Get stakeholders who haven't responded
+ if (active_review) {
+ stakeholders = active_review.assignees?.map(a => a.login) || []
+
+ // Parse sign-off labels
+ signoff_labels = active_review.labels
+ .map(l => l.name)
+ .filter(l => l.startsWith('signoff-'))
+
+ signed_off = signoff_labels.map(l => {
+ const match = l.match(/^signoff-(.+)-(approved|approved-with-note|blocked)$/)
+ return match ? { user: match[1], status: match[2] } : null
+ }).filter(Boolean)
+
+ pending_stakeholders = stakeholders.filter(s =>
+ !signed_off.some(so => so.user === s)
+ )
+ }
+
+ // Count feedback by type
+ feedback_by_type = {}
+ for (f of epic.feedback) {
+ const type = f.type || 'general'
+ feedback_by_type[type] = (feedback_by_type[type] || 0) + 1
+ }
+
+
+
+
+
+
+
+
+
+ Choice:
+
+
+ selected_epic = epic_list[parseInt(choice) - 1]
+ epic_key = selected_epic.key
+ Goto step 3
+
+
+
+ Load workflow: create-epic-draft
+
+
+
+ Enter epic key:
+ Load workflow: view-feedback with document_key = response, document_type = 'epic'
+
+
+
+ Enter epic key:
+ Load workflow: synthesize-epic-feedback with epic_key = response
+
+
+
+ Enter PRD key to filter by (or 'all'):
+ source_prd = (response.toLowerCase() === 'all') ? '' : response
+ Goto step 1
+
+
+
+ Goto step 1
+
+
+
+ epic_key = ''
+ Goto step 2
+
+
+
+
+ Exit
+
+
+ Goto step 4
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract label value by prefix
+function extract_label(labels, prefix) {
+ for (const label of labels) {
+ if (label.startsWith(prefix)) {
+ return label.replace(prefix, '');
+ }
+ }
+ return null;
+}
+
+// Get status emoji
+function status_emoji(status) {
+ const emojis = {
+ draft: '📝',
+ feedback: '💬',
+ synthesis: '🔄',
+ signoff: '✍️',
+ approved: '✅'
+ };
+ return emojis[status] || '❓';
+}
+
+// Format time ago
+function time_ago(timestamp) {
+ const now = new Date();
+ const then = new Date(timestamp);
+ const hours = Math.floor((now - then) / (1000 * 60 * 60));
+
+ if (hours < 1) return 'just now';
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days === 1) return '1 day ago';
+ if (days < 7) return `${days} days ago`;
+ return then.toISOString().split('T')[0];
+}
+
+// Pad string to right
+function pad_right(str, length) {
+ if (!str) str = '';
+ return (str + ' '.repeat(length)).slice(0, length);
+}
+
+// Extract epic stories from content
+function extract_epic_stories(content) {
+ const stories = [];
+ const storyRegex = /###\s+Story\s+\d+:\s*(.+)\n+([\s\S]*?)(?=###\s+Story|---|\n##|$)/gi;
+ let match;
+ while ((match = storyRegex.exec(content)) !== null) {
+ const title = match[1].trim();
+ const body = match[2];
+ const complexityMatch = body.match(/\*\*(?:Estimated )?Complexity:\*\*\s*(\w+)/i);
+
+ stories.push({
+ title: title,
+ complexity: complexityMatch ? complexityMatch[1] : null
+ });
+ }
+ return stories;
+}
+
+// Extract tech lead from content
+function extract_tech_lead(content) {
+ const match = content.match(/\|\s*Tech Lead\s*\|\s*(@?\w+)\s*\|/);
+ return match ? match[1] : null;
+}
+
+// Extract dependencies from content
+function extract_dependencies(content) {
+ const section = content.match(/## Dependencies\n+([\s\S]*?)(?=\n##|$)/);
+ if (!section) return [];
+
+ return section[1]
+ .split('\n')
+ .filter(line => line.startsWith('-'))
+ .map(line => line.replace(/^-\s*/, '').trim())
+ .filter(line => line && line !== 'TBD');
+}
+
+// Find items needing attention
+function find_attention_items(epics) {
+ const items = [];
+
+ for (const epic of Object.values(epics)) {
+ const hours_since_activity = (Date.now() - new Date(epic.last_activity)) / (1000 * 60 * 60);
+
+ // Check for stale feedback rounds
+ if (epic.status === 'feedback' && hours_since_activity > 72) { // 3 days
+ items.push({
+ epic_key: `epic:${epic.key}`,
+ message: `No activity for ${Math.floor(hours_since_activity / 24)} days`
+ });
+ }
+
+ // Check for blocked sign-offs
+ if (epic.status === 'signoff') {
+ const blocked = epic.reviews.some(r =>
+ r.labels.some(l => l.name.includes('-blocked'))
+ );
+ if (blocked) {
+ items.push({
+ epic_key: `epic:${epic.key}`,
+ message: 'Has blocking concerns'
+ });
+ }
+ }
+
+ // Check for epics with unprocessed feedback
+ const new_feedback = epic.feedback.filter(f => f.status === 'new').length;
+ if (new_feedback >= 5) {
+ items.push({
+ epic_key: `epic:${epic.key}`,
+ message: `${new_feedback} unprocessed feedback items`
+ });
+ }
+ }
+
+ return items;
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Show epic dashboard"
+- "What epics are in progress?"
+- "Epic status"
+- "View all epics"
+- "Epics from PRD [key]"
+- Menu trigger: `ED`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml
new file mode 100644
index 00000000..17205d87
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml
@@ -0,0 +1,22 @@
+name: epic-dashboard
+description: "Central visibility hub for tracking all epics with PRD lineage and story breakdown"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Optional filter
+epic_key: "" # Empty for all epics, or specific key for detail view
+source_prd: "" # Filter by source PRD
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md
new file mode 100644
index 00000000..4c9ed0fd
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md
@@ -0,0 +1,336 @@
+# My Tasks - Unified Inbox for PRD & Epic Collaboration
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review label:review-status:open assignee:{{current_user}} is:open"
+ })
+ prd_feedback_issues = response.items || []
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review label:review-status:signoff assignee:{{current_user}} is:open"
+ })
+ prd_signoff_issues = response.items || []
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review is:open mentions:{{current_user}}"
+ })
+ prd_mentioned_issues = response.items || []
+
+ // Filter by status
+ prd_feedback_issues = prd_mentioned_issues.filter(i =>
+ i.labels.some(l => l.name === 'review-status:open')
+ )
+ prd_signoff_issues = prd_mentioned_issues.filter(i =>
+ i.labels.some(l => l.name === 'review-status:signoff')
+ )
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:epic-review label:review-status:open assignee:{{current_user}} is:open"
+ })
+ epic_feedback_issues = response.items || []
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:epic-review label:review-status:open is:open mentions:{{current_user}}"
+ })
+ epic_feedback_issues = response.items || []
+
+
+
+
+
+
+now = new Date()
+all_tasks = []
+
+// Process PRD feedback tasks
+for (issue of prd_feedback_issues) {
+ deadline = extract_deadline(issue)
+ days_remaining = deadline ? days_until(deadline) : null
+
+ all_tasks.push({
+ type: 'prd-feedback',
+ issue: issue,
+ prd_key: extract_label(issue, 'prd:'),
+ title: issue.title.replace(/^PRD Review:\s*/, ''),
+ action: '💬 Give Feedback',
+ deadline: deadline,
+ days_remaining: days_remaining,
+ urgency: calculate_urgency(days_remaining)
+ })
+}
+
+// Process PRD sign-off tasks
+for (issue of prd_signoff_issues) {
+ deadline = extract_deadline(issue)
+ days_remaining = deadline ? days_until(deadline) : null
+
+ all_tasks.push({
+ type: 'prd-signoff',
+ issue: issue,
+ prd_key: extract_label(issue, 'prd:'),
+ title: issue.title.replace(/^PRD Review:\s*/, ''),
+ action: '✍️ Sign-off',
+ deadline: deadline,
+ days_remaining: days_remaining,
+ urgency: calculate_urgency(days_remaining)
+ })
+}
+
+// Process Epic feedback tasks
+for (issue of epic_feedback_issues) {
+ deadline = extract_deadline(issue)
+ days_remaining = deadline ? days_until(deadline) : null
+
+ all_tasks.push({
+ type: 'epic-feedback',
+ issue: issue,
+ epic_key: extract_label(issue, 'epic:'),
+ title: issue.title.replace(/^Epic Review:\s*/, ''),
+ action: '💬 Give Feedback',
+ deadline: deadline,
+ days_remaining: days_remaining,
+ urgency: calculate_urgency(days_remaining)
+ })
+}
+
+// Sort by urgency (urgent first, then deadline, then type)
+all_tasks.sort((a, b) => {
+ if (a.urgency !== b.urgency) return a.urgency - b.urgency
+ if (a.days_remaining !== b.days_remaining) {
+ if (a.days_remaining === null) return 1
+ if (b.days_remaining === null) return -1
+ return a.days_remaining - b.days_remaining
+ }
+ return 0
+})
+
+urgent_tasks = all_tasks.filter(t => t.urgency === 1)
+pending_tasks = all_tasks.filter(t => t.urgency === 2)
+no_deadline_tasks = all_tasks.filter(t => t.urgency === 3)
+
+
+
+
+
+
+ HALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choice (number or letter):
+
+
+ selected_task = all_tasks[parseInt(choice) - 1]
+
+
+
+ Load workflow: submit-feedback with document_key = selected_task.prd_key, document_type = 'prd'
+
+
+
+
+ Load workflow: submit-signoff with document_key = selected_task.prd_key, document_type = 'prd'
+
+
+
+
+ Load workflow: submit-feedback with document_key = selected_task.epic_key, document_type = 'epic'
+
+
+
+
+ Load workflow: prd-dashboard
+
+
+
+ Load workflow: epic-dashboard
+
+
+
+ Goto step 1 (refresh)
+
+
+
+
+ Exit
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract deadline from issue body (looks for **Deadline:** pattern)
+function extract_deadline(issue) {
+ const match = issue.body?.match(/\*\*Deadline:\*\*\s*(\d{4}-\d{2}-\d{2})/);
+ return match ? match[1] : null;
+}
+
+// Extract label value by prefix
+function extract_label(issue, prefix) {
+ for (const label of issue.labels) {
+ if (label.name.startsWith(prefix)) {
+ return label.name.replace(prefix, '');
+ }
+ }
+ return issue.number.toString();
+}
+
+// Calculate days until deadline
+function days_until(deadline) {
+ const target = new Date(deadline);
+ const now = new Date();
+ const diff = target - now;
+ return Math.ceil(diff / (1000 * 60 * 60 * 24));
+}
+
+// Calculate urgency level (1 = urgent, 2 = pending, 3 = no deadline)
+function calculate_urgency(days_remaining) {
+ if (days_remaining === null) return 3;
+ if (days_remaining <= 2) return 1;
+ return 2;
+}
+
+// Format deadline for display
+function format_deadline(deadline, days_remaining) {
+ if (!deadline) return 'No deadline';
+ if (days_remaining <= 0) return '⚠️ OVERDUE!';
+ if (days_remaining === 1) return 'Tomorrow!';
+ return `${days_remaining} days`;
+}
+
+// Pad string to right
+function pad_right(str, length) {
+ return (str + ' '.repeat(length)).slice(0, length);
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "What needs my attention?"
+- "What PRDs need my input?"
+- "What's waiting for me?"
+- "My tasks"
+- "Show my pending tasks"
+- Menu trigger: `MT`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml
new file mode 100644
index 00000000..401a379d
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml
@@ -0,0 +1,17 @@
+name: my-tasks
+description: "Unified inbox showing PRDs and Epics needing your feedback or sign-off"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md
new file mode 100644
index 00000000..10225161
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md
@@ -0,0 +1,405 @@
+# Open Epic Feedback - Collect Stakeholder Input on Story Breakdown
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:draft is:open"
+ })
+
+
+
+ HALT
+
+
+
+ draft_epics = response.items.map(issue => {
+ const labels = issue.labels.map(l => l.name)
+ return {
+ key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''),
+ title: issue.title.replace(/^Epic Review:\s*/, ''),
+ source_prd: labels.find(l => l.startsWith('source-prd:'))?.replace('source-prd:', ''),
+ issue_number: issue.number
+ }
+ }).filter(e => e.key)
+
+
+
+
+
+ Select epic (1-{{draft_epics.length}}):
+ epic_key = draft_epics[parseInt(response) - 1].key
+ review_issue_number = draft_epics[parseInt(response) - 1].issue_number
+
+
+
+
+
+
+ epic_path = `${docs_dir}/epics/epic-${epic_key}.md`
+ Read epic_path
+
+
+
+ HALT
+
+
+ epic_content = file_content
+
+ title = extract_title(epic_content)
+ version = extract_version(epic_content)
+ stakeholders = extract_stakeholders(epic_content)
+ source_prd = extract_source_prd(epic_content)
+ stories = extract_epic_stories(epic_content)
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open"
+ })
+
+
+
+ HALT
+
+
+ review_issue = response.items[0]
+ review_issue_number = review_issue.number
+
+
+
+ Call: mcp__github__issue_read({
+ method: 'get',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: review_issue_number
+ })
+ review_issue = response
+
+
+
+
+
+
+
+
+ Days until feedback deadline (default: {{feedback_days}}):
+
+ feedback_days = parseInt(response) || feedback_days
+ deadline = new Date()
+ deadline.setDate(deadline.getDate() + feedback_days)
+ deadline_str = deadline.toISOString().split('T')[0]
+
+
+
+
+ Add additional stakeholders? (comma-separated or 'none'):
+
+ if (response.toLowerCase() !== 'none' && response.trim()) {
+ additional = response.split(',').map(s => s.trim().replace('@', ''))
+ stakeholders = [...new Set([...stakeholders, ...additional])]
+ }
+
+
+
+
+
+ updated_content = epic_content
+ .replace(/\*\*Status:\*\* .+/, '**Status:** Feedback')
+ .replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`)
+
+
+ Write updated_content to epic_path
+
+
+
+
+
+
+ // Get current labels
+ current_labels = review_issue.labels.map(l => l.name)
+
+ // Update status label
+ new_labels = current_labels
+ .filter(l => !l.startsWith('review-status:'))
+ .concat(['review-status:open'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue_number,
+ labels: new_labels,
+ assignees: stakeholders
+ })
+
+
+
+
+
+
+ feedback_comment = `## 💬 Feedback Round Open
+
+${stakeholders.map(s => '@' + s).join(' ')}
+
+**Epic:** epic:${epic_key}
+**Version:** v${version}
+**Deadline:** ${deadline_str}
+**Source PRD:** prd:${source_prd}
+
+---
+
+## 📦 Story Breakdown
+
+${stories.map((s, i) => `${i + 1}. **${s.title}** (${s.complexity || 'TBD'})\n ${s.description || ''}`).join('\n\n')}
+
+---
+
+## Feedback Types for Epics
+
+Please provide feedback on:
+
+- 🔍 **Scope**: Is this epic the right size? Should it be split or merged with another?
+- 📝 **Story Breakdown**: Are stories well-defined, independent, and testable?
+- 🔗 **Dependencies**: Are technical dependencies correctly identified?
+- ⚡ **Priority**: Is the story order optimal for delivery?
+- ⚠️ **Technical Risk**: Are there architectural or technical concerns?
+- ➕ **Missing Stories**: Should additional stories be added?
+
+---
+
+### How to Submit Feedback
+
+Reply with structured feedback or use the feedback workflow:
+
+\`\`\`
+/feedback epic:${epic_key}
+Section: [Story Breakdown / Dependencies / Technical Risk / etc.]
+Type: [scope / dependency / priority / technical_risk / story_split / missing_story]
+Feedback: [Your detailed feedback]
+\`\`\`
+
+Or simply comment with your thoughts.
+
+---
+
+_Feedback requested by @${current_user} on ${new Date().toISOString().split('T')[0]}_
+_Please respond by ${deadline_str}_`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue_number,
+ body: feedback_comment
+ })
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+function extract_title(content) {
+ const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m);
+ return match ? match[2].trim() : 'Untitled';
+}
+
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+function extract_source_prd(content) {
+ const match = content.match(/\*\*Source PRD:\*\*\s*`?prd:([^`\s]+)`?/);
+ return match ? match[1] : null;
+}
+
+function extract_stakeholders(content) {
+ const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/);
+ if (!field) return [];
+
+ return field[1]
+ .split(/[,\s]+/)
+ .filter(s => s.startsWith('@'))
+ .map(s => s.replace('@', ''));
+}
+
+function extract_epic_stories(content) {
+ const stories = [];
+ // Match story sections in various formats
+ const storyRegex = /###\s+Story\s+\d+:\s*(.+)\n+([\s\S]*?)(?=###|---|\n##|$)/gi;
+ let match;
+ while ((match = storyRegex.exec(content)) !== null) {
+ const title = match[1].trim();
+ const body = match[2];
+
+ // Extract complexity
+ const complexityMatch = body.match(/\*\*Estimated Complexity:\*\*\s*(\w+)/i);
+
+ // Extract description
+ const descMatch = body.match(/\*\*Description:\*\*\s*(.+)/);
+
+ stories.push({
+ title: title,
+ description: descMatch ? descMatch[1].trim() : '',
+ complexity: complexityMatch ? complexityMatch[1] : null
+ });
+ }
+ return stories;
+}
+```
+
+## Epic-Specific Feedback Types
+
+```yaml
+feedback_types:
+ scope_concern:
+ label: "Scope"
+ description: "Epic is too large/small, should be split/merged"
+ emoji: "🔍"
+
+ story_split:
+ label: "Story Breakdown"
+ description: "Story needs to be split, combined, or redefined"
+ emoji: "📝"
+
+ dependency:
+ label: "Dependency"
+ description: "Missing or incorrect dependency identification"
+ emoji: "🔗"
+
+ priority_question:
+ label: "Priority"
+ description: "Story order or priority should change"
+ emoji: "⚡"
+
+ technical_risk:
+ label: "Technical Risk"
+ description: "Architecture or technical feasibility concern"
+ emoji: "⚠️"
+
+ missing_story:
+ label: "Missing Story"
+ description: "An additional story should be added"
+ emoji: "➕"
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Open feedback for epic"
+- "Start epic feedback round"
+- "Get feedback on epic"
+- Menu trigger: `OE`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml
new file mode 100644
index 00000000..ca36f1e0
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml
@@ -0,0 +1,22 @@
+name: open-epic-feedback
+description: "Open feedback round for an epic, focusing on scope, story breakdown, and technical feasibility"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+epic_key: ""
+feedback_days: 3 # Default deadline in days
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md
new file mode 100644
index 00000000..c28af0b1
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md
@@ -0,0 +1,406 @@
+# Open Feedback Round - Start Async Stakeholder Review
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+ Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic):
+ document_key = response
+
+
+
+ doc_path = {{docs_dir}}/prd/{{document_key}}.md
+ document_type = 'prd'
+ doc_prefix = 'PRD'
+ doc_label_prefix = 'prd'
+
+
+
+ doc_path = {{docs_dir}}/epics/epic-{{document_key}}.md
+ doc_prefix = 'Epic'
+ doc_label_prefix = 'epic'
+
+
+
+ Read doc_path
+
+
+ HALT
+
+ doc_content = file_content
+
+
+
+
+ // Parse document metadata
+ title = extract_between(doc_content, '# PRD: ', '\n') ||
+ extract_between(doc_content, '# Epic: ', '\n') ||
+ document_key
+ version = extract_field(doc_content, 'Version') || '1'
+ status = extract_field(doc_content, 'Status') || 'draft'
+ stakeholders = extract_stakeholders(doc_content)
+ owner = extract_field(doc_content, 'Product Owner')?.replace('@', '')
+
+
+
+
+
+ Continue anyway? (y/n):
+
+ HALT
+
+
+
+
+
+
+
+
+
+ Days until deadline (default: 5):
+
+ days = parseInt(response) || 5
+ deadline = new Date()
+ deadline.setDate(deadline.getDate() + days)
+ deadline_str = deadline.toISOString().split('T')[0]
+
+
+
+
+
+
+
+ // Build stakeholder checklist
+ checklist = stakeholders.map(s =>
+ `- [ ] @${s.replace('@', '')} - ⏳ Pending feedback`
+ ).join('\n')
+
+ // Build issue body
+ issue_body = `# 📣 ${doc_prefix} Review: ${title} v${version}
+
+**Document Key:** \`${doc_label_prefix}:${document_key}\`
+**Version:** ${version}
+**Owner:** @${owner || current_user}
+**Status:** 🟡 Open for Feedback
+
+---
+
+## 📅 Deadline
+
+**Feedback Due:** ${deadline_str}
+
+---
+
+## 📋 Document Summary
+
+${extract_summary(doc_content)}
+
+---
+
+## 👥 Stakeholder Feedback Status
+
+${checklist}
+
+---
+
+## 📝 How to Provide Feedback
+
+1. Review the document: \`docs/${document_type}/${document_key}.md\`
+2. For each piece of feedback, create a new comment or linked issue:
+ - **Clarification**: Something unclear → \`/feedback clarification\`
+ - **Concern**: Potential issue → \`/feedback concern\`
+ - **Suggestion**: Improvement idea → \`/feedback suggestion\`
+ - **Addition**: Missing requirement → \`/feedback addition\`
+
+Or use the workflow: "Submit feedback on ${doc_label_prefix}:${document_key}"
+
+---
+
+## 🔄 Review Status
+
+- [ ] All stakeholders have provided feedback
+- [ ] Feedback synthesized into new version
+- [ ] Ready for sign-off
+
+---
+
+_This review round was opened by @${current_user} on ${new Date().toISOString().split('T')[0]}_
+`
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:{{doc_label_prefix}}-review label:{{doc_label_prefix}}:{{document_key}} is:open"
+ })
+
+
+
+ Choice:
+
+ review_issue = response.items[0]
+ Goto step 4 (skip issue creation)
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: response.items[0].number,
+ state: 'closed',
+ state_reason: 'not_planned'
+ })
+
+
+ HALT
+
+
+
+
+
+
+ labels = [
+ `type:${doc_label_prefix}-review`,
+ `${doc_label_prefix}:${document_key}`,
+ `version:${version}`,
+ 'review-status:open'
+ ]
+
+
+ Call: mcp__github__issue_write({
+ method: 'create',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ title: "{{doc_prefix}} Review: {{title}} v{{version}}",
+ body: issue_body,
+ labels: labels,
+ assignees: stakeholders.map(s => s.replace('@', ''))
+ })
+
+ review_issue = response
+
+
+
+
+
+
+
+
+ // Update the Status field in the document
+ updated_content = doc_content
+ .replace(/\*\*Status:\*\* .+/, '**Status:** Feedback')
+ .replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`)
+
+ Write updated_content to doc_path
+
+
+
+
+ if (document_type === 'prd') {
+ cacheManager.writePrd(document_key, updated_content, {
+ status: 'feedback',
+ review_issue: review_issue.number,
+ feedback_deadline: deadline_str
+ })
+ } else {
+ cacheManager.writeEpic(document_key, updated_content, {
+ status: 'feedback',
+ review_issue: review_issue.number,
+ feedback_deadline: deadline_str
+ })
+ }
+
+
+
+
+
+
+
+
+
+ mentions = stakeholders.map(s => `@${s.replace('@', '')}`).join(' ')
+ notification = `## 📣 Feedback Requested
+
+${mentions}
+
+You have been asked to review this ${doc_prefix}.
+
+**Deadline:** ${deadline_str}
+**Document:** \`docs/${document_type}/${document_key}.md\`
+
+Please review and provide your feedback by creating linked feedback issues or comments.
+
+---
+
+**Quick Actions:**
+- View document in repo
+- Use "Submit feedback" workflow
+- Comment directly on this issue
+
+Thank you for your input! 🙏`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: notification
+ })
+
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract text between markers
+function extract_between(content, start, end) {
+ const startIdx = content.indexOf(start);
+ if (startIdx === -1) return null;
+ const endIdx = content.indexOf(end, startIdx + start.length);
+ return content.slice(startIdx + start.length, endIdx).trim();
+}
+
+// Extract field from markdown table or bold format
+function extract_field(content, field) {
+ // Try bold format: **Field:** value
+ const boldMatch = content.match(new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+?)(?:\\n|$)`));
+ if (boldMatch) return boldMatch[1].trim();
+
+ // Try table format: | Field | value |
+ const tableMatch = content.match(new RegExp(`\\|\\s*${field}\\s*\\|\\s*(.+?)\\s*\\|`));
+ if (tableMatch) return tableMatch[1].trim();
+
+ return null;
+}
+
+// Extract stakeholders from document
+function extract_stakeholders(content) {
+ const field = extract_field(content, 'Stakeholders');
+ if (!field) return [];
+
+ return field
+ .split(/[,\s]+/)
+ .filter(s => s.startsWith('@'))
+ .map(s => s.replace('@', ''));
+}
+
+// Extract summary section from document
+function extract_summary(content) {
+ // Try to get Vision + Problem Statement
+ const vision = extract_between(content, '## Vision', '##') ||
+ extract_between(content, '## Vision', '\n---');
+ const problem = extract_between(content, '## Problem Statement', '##') ||
+ extract_between(content, '## Problem Statement', '\n---');
+
+ if (vision || problem) {
+ let summary = '';
+ if (vision) summary += `**Vision:** ${vision.slice(0, 200)}...\n\n`;
+ if (problem) summary += `**Problem:** ${problem.slice(0, 200)}...`;
+ return summary;
+ }
+
+ // Fallback: first 500 chars
+ return content.slice(0, 500) + '...';
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Open feedback for [prd-key]"
+- "Start feedback round for [document]"
+- "Request feedback on PRD"
+- Menu trigger: `OF`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml
new file mode 100644
index 00000000..dff95982
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml
@@ -0,0 +1,22 @@
+name: open-feedback-round
+description: "Open a feedback round for PRD or Epic - creates GitHub coordination issue"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters (can be passed in or prompted)
+document_key: "" # e.g., "user-auth" for PRD
+document_type: "prd" # "prd" or "epic"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md
new file mode 100644
index 00000000..4192e85e
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md
@@ -0,0 +1,392 @@
+# PRD Dashboard - Central Visibility Hub
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review"
+ })
+ review_issues = response.items || []
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-feedback is:open"
+ })
+ feedback_issues = response.items || []
+
+
+
+
+ prds = {}
+ status_counts = { draft: 0, feedback: 0, synthesis: 0, signoff: 0, approved: 0 }
+
+ // Process review issues to get PRD status
+ for (issue of review_issues) {
+ const labels = issue.labels.map(l => l.name)
+ const prd_key = extract_label(labels, 'prd:')
+
+ if (!prd_key) continue
+
+ if (!prds[prd_key]) {
+ prds[prd_key] = {
+ key: prd_key,
+ title: issue.title.replace(/^(PRD Review|Sign-off):\s*/, '').replace(/\s+v\d+$/, ''),
+ reviews: [],
+ feedback: [],
+ status: 'draft',
+ owner: null,
+ last_activity: issue.updated_at
+ }
+ }
+
+ prds[prd_key].reviews.push(issue)
+
+ // Determine current status from most recent review
+ const review_status = extract_label(labels, 'review-status:')
+ if (review_status === 'open' && issue.state === 'open') {
+ prds[prd_key].status = 'feedback'
+ } else if (review_status === 'signoff' && issue.state === 'open') {
+ prds[prd_key].status = 'signoff'
+ } else if (review_status === 'approved' || issue.state === 'closed') {
+ prds[prd_key].status = 'approved'
+ }
+
+ if (new Date(issue.updated_at) > new Date(prds[prd_key].last_activity)) {
+ prds[prd_key].last_activity = issue.updated_at
+ }
+ }
+
+ // Attach feedback to PRDs
+ for (issue of feedback_issues) {
+ const labels = issue.labels.map(l => l.name)
+ const prd_key = extract_label(labels, 'prd:')
+
+ if (prd_key && prds[prd_key]) {
+ prds[prd_key].feedback.push({
+ id: issue.number,
+ title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''),
+ type: extract_label(labels, 'feedback-type:'),
+ status: extract_label(labels, 'feedback-status:'),
+ submittedBy: issue.user?.login
+ })
+ }
+ }
+
+ // Count by status
+ for (prd of Object.values(prds)) {
+ status_counts[prd.status] = (status_counts[prd.status] || 0) + 1
+ }
+
+ prd_list = Object.values(prds).sort((a, b) =>
+ new Date(b.last_activity) - new Date(a.last_activity)
+ )
+
+
+
+
+
+
+
+
+ Goto step 4 (interactive menu)
+
+
+
+
+
+ prd = prds[prd_key]
+
+
+
+ prd_key = ''
+ Goto step 2
+
+
+
+ // Get active review issue
+ active_review = prd.reviews.find(r => r.state === 'open')
+
+ // Count feedback by status
+ new_feedback = prd.feedback.filter(f => f.status === 'new').length
+ reviewed_feedback = prd.feedback.filter(f => f.status === 'reviewed').length
+
+ // Get stakeholders who haven't responded
+ if (active_review) {
+ stakeholders = active_review.assignees?.map(a => a.login) || []
+
+ // Parse sign-off labels
+ signoff_labels = active_review.labels
+ .map(l => l.name)
+ .filter(l => l.startsWith('signoff-'))
+
+ signed_off = signoff_labels.map(l => {
+ const match = l.match(/^signoff-(.+)-(approved|approved-with-note|blocked)$/)
+ return match ? { user: match[1], status: match[2] } : null
+ }).filter(Boolean)
+
+ pending_stakeholders = stakeholders.filter(s =>
+ !signed_off.some(so => so.user === s)
+ )
+ }
+
+
+
+
+
+
+
+
+
+ Choice:
+
+
+ selected_prd = prd_list[parseInt(choice) - 1]
+ prd_key = selected_prd.key
+ Goto step 3
+
+
+
+ Load workflow: create-prd-draft
+
+
+
+ Enter PRD key:
+ Load workflow: view-feedback with document_key = response, document_type = 'prd'
+
+
+
+ Enter PRD key:
+ Load workflow: synthesize-feedback with document_key = response, document_type = 'prd'
+
+
+
+ Goto step 1
+
+
+
+ prd_key = ''
+ Goto step 2
+
+
+
+
+ Exit
+
+
+ Goto step 4
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract label value by prefix
+function extract_label(labels, prefix) {
+ for (const label of labels) {
+ if (label.startsWith(prefix)) {
+ return label.replace(prefix, '');
+ }
+ }
+ return null;
+}
+
+// Get status emoji
+function status_emoji(status) {
+ const emojis = {
+ draft: '📝',
+ feedback: '💬',
+ synthesis: '🔄',
+ signoff: '✍️',
+ approved: '✅'
+ };
+ return emojis[status] || '❓';
+}
+
+// Format time ago
+function time_ago(timestamp) {
+ const now = new Date();
+ const then = new Date(timestamp);
+ const hours = Math.floor((now - then) / (1000 * 60 * 60));
+
+ if (hours < 1) return 'just now';
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days === 1) return '1 day ago';
+ if (days < 7) return `${days} days ago`;
+ return then.toISOString().split('T')[0];
+}
+
+// Pad string to right
+function pad_right(str, length) {
+ if (!str) str = '';
+ return (str + ' '.repeat(length)).slice(0, length);
+}
+
+// Find items needing attention
+function find_attention_items(prds) {
+ const items = [];
+
+ for (const prd of Object.values(prds)) {
+ // Check for stale feedback rounds
+ const hours_since_activity = (Date.now() - new Date(prd.last_activity)) / (1000 * 60 * 60);
+
+ if (prd.status === 'feedback' && hours_since_activity > 120) { // 5 days
+ items.push({
+ prd_key: `prd:${prd.key}`,
+ message: `No activity for ${Math.floor(hours_since_activity / 24)} days`
+ });
+ }
+
+ // Check for blocked sign-offs
+ if (prd.status === 'signoff') {
+ const blocked = prd.reviews.some(r =>
+ r.labels.some(l => l.name.includes('-blocked'))
+ );
+ if (blocked) {
+ items.push({
+ prd_key: `prd:${prd.key}`,
+ message: 'Has blocking concerns'
+ });
+ }
+ }
+ }
+
+ return items;
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Show PRD dashboard"
+- "What PRDs are in progress?"
+- "PRD status"
+- "View all PRDs"
+- Menu trigger: `PD`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml
new file mode 100644
index 00000000..8173ac68
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml
@@ -0,0 +1,21 @@
+name: prd-dashboard
+description: "Central visibility hub for tracking all PRDs across the organization"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Optional filter
+prd_key: "" # Empty for all PRDs, or specific key for detail view
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md
new file mode 100644
index 00000000..8ce0b1f1
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md
@@ -0,0 +1,343 @@
+# Request Sign-off - Final Stakeholder Approval
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+ Which document needs sign-off? Enter key:
+ document_key = response
+
+
+
+ if (document_type === 'prd') {
+ doc_path = `${docs_dir}/prd/${document_key}.md`
+ doc_label = `prd:${document_key}`
+ review_label = 'type:prd-review'
+ } else {
+ doc_path = `${docs_dir}/epics/epic-${document_key}.md`
+ doc_label = `epic:${document_key}`
+ review_label = 'type:epic-review'
+ }
+
+
+ Read doc_path
+
+
+ HALT
+
+ doc_content = file_content
+
+ title = extract_title(doc_content)
+ version = extract_version(doc_content)
+ stakeholders = extract_stakeholders(doc_content)
+
+
+
+
+
+
+
+
+ Choice (1-3):
+ threshold_type = choice
+
+
+ Minimum approvals needed (out of {{stakeholders.length}}):
+
+ signoff_config = {
+ threshold_type: 'count',
+ minimum_approvals: parseInt(response),
+ allow_blocks: true,
+ block_threshold: 1
+ }
+
+
+
+
+ Percentage required (e.g., 66 for 66%):
+
+ signoff_config = {
+ threshold_type: 'percentage',
+ approval_percentage: parseInt(response),
+ allow_blocks: true,
+ block_threshold: 1
+ }
+
+
+
+
+
+ Required approvers (comma-separated usernames):
+ required_approvers = response.split(',').map(s => s.trim().replace('@', ''))
+
+
+ Minimum optional approvers needed:
+
+ optional_approvers = stakeholders.filter(s => !required_approvers.includes(s))
+ signoff_config = {
+ threshold_type: 'required_approvers',
+ required: required_approvers,
+ optional: optional_approvers,
+ minimum_optional: parseInt(response),
+ allow_blocks: true,
+ block_threshold: 1
+ }
+
+
+
+
+
+
+
+ Days until sign-off deadline (default: 3):
+
+ days = parseInt(response) || 3
+ deadline = new Date()
+ deadline.setDate(deadline.getDate() + days)
+ deadline_str = deadline.toISOString().split('T')[0]
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} is:open"
+ })
+
+
+ review_issue = response.items[0]
+
+
+
+
+
+
+ // Create new review issue for sign-off
+ issue_body = `# ✍️ Sign-off Request: ${title} v${version}
+
+**Document Key:** \`${doc_label}\`
+**Version:** ${version}
+**Status:** 🟡 Awaiting Sign-off
+
+---
+
+## 📅 Deadline
+
+**Sign-off Due:** ${deadline_str}
+
+---
+
+## 👥 Stakeholder Status
+
+${stakeholders.map(s => `- [ ] @${s} - ⏳ Pending`).join('\n')}
+
+---
+
+_Sign-off requested by @${current_user} on ${new Date().toISOString().split('T')[0]}_`
+
+
+ Call: mcp__github__issue_write({
+ method: 'create',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ title: "Sign-off: {{title}} v{{version}}",
+ body: issue_body,
+ labels: [review_label, doc_label, `version:${version}`, 'review-status:signoff'],
+ assignees: stakeholders
+ })
+
+ review_issue = response
+
+
+
+
+ // Get current labels and update status
+ current_labels = review_issue.labels.map(l => l.name)
+ new_labels = current_labels
+ .filter(l => !l.startsWith('review-status:'))
+ .concat(['review-status:signoff'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: new_labels
+ })
+
+
+
+
+
+ // Format configuration for display
+ config_display = ''
+ if (signoff_config.threshold_type === 'count') {
+ config_display = `${signoff_config.minimum_approvals} approval(s) required`
+ } else if (signoff_config.threshold_type === 'percentage') {
+ config_display = `${signoff_config.approval_percentage}% must approve`
+ } else {
+ config_display = `Required: ${signoff_config.required.map(r => '@' + r).join(', ')}; Optional: ${signoff_config.minimum_optional} of ${signoff_config.optional.length}`
+ }
+
+ signoff_comment = `## ✍️ Sign-off Requested
+
+${stakeholders.map(s => '@' + s).join(' ')}
+
+**Version:** v${version}
+**Deadline:** ${deadline_str}
+**Threshold:** ${config_display}
+
+---
+
+### How to Sign Off
+
+Reply to this issue with one of the following:
+
+- ✅ **Approve**: \`/signoff approve\` - Sign off without concerns
+- ✅📝 **Approve with Note**: \`/signoff approve-note: [your note]\` - Sign off with a minor note
+- 🚫 **Block**: \`/signoff block: [reason]\` - Cannot approve, has blocking concern
+
+---
+
+### Sign-off Status
+
+${stakeholders.map(s => `- [ ] @${s} - ⏳ Pending`).join('\n')}
+
+---
+
+_Please review the document and provide your sign-off decision by ${deadline_str}._`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: signoff_comment
+ })
+
+
+
+
+
+
+ updated_content = doc_content
+ .replace(/\*\*Status:\*\* .+/, '**Status:** Sign-off')
+ .replace(/\| Sign-off Deadline \| .+ \|/, `| Sign-off Deadline | ${deadline_str} |`)
+
+
+ Write updated_content to doc_path
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+function extract_title(content) {
+ const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m);
+ return match ? match[2].trim() : 'Untitled';
+}
+
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+function extract_stakeholders(content) {
+ const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/);
+ if (!field) return [];
+
+ return field[1]
+ .split(/[,\s]+/)
+ .filter(s => s.startsWith('@'))
+ .map(s => s.replace('@', ''));
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Request sign-off for [document]"
+- "Start sign-off round"
+- "Get approval on PRD"
+- Menu trigger: `RS`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml
new file mode 100644
index 00000000..0d44f0b4
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml
@@ -0,0 +1,22 @@
+name: request-signoff
+description: "Request formal sign-off from stakeholders - final approval stage"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+document_key: ""
+document_type: "prd"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/request-signoff"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md
new file mode 100644
index 00000000..99bc4ce2
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md
@@ -0,0 +1,432 @@
+# Submit Feedback - Structured Stakeholder Input
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+ Document key:
+ document_key = response
+
+
+
+
+
+ // Try to find document
+ prd_path = `${docs_dir}/prd/${document_key}.md`
+ epic_path = `${docs_dir}/epics/epic-${document_key}.md`
+
+ if (file_exists(prd_path)) {
+ document_type = 'prd'
+ doc_path = prd_path
+ } else if (file_exists(epic_path)) {
+ document_type = 'epic'
+ doc_path = epic_path
+ } else {
+ // Ask user
+ prompt_for_type = true
+ }
+
+
+
+ Is this a [P]RD or [E]pic?
+ document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic'
+
+
+
+
+
+
+ if (document_type === 'prd') {
+ doc_path = `${docs_dir}/prd/${document_key}.md`
+ doc_prefix = 'PRD'
+ doc_label = `prd:${document_key}`
+ review_label = 'type:prd-review'
+ feedback_label = 'type:prd-feedback'
+ } else {
+ doc_path = `${docs_dir}/epics/epic-${document_key}.md`
+ doc_prefix = 'Epic'
+ doc_label = `epic:${document_key}`
+ review_label = 'type:epic-review'
+ feedback_label = 'type:epic-feedback'
+ }
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:open is:open"
+ })
+
+
+
+ Choice:
+
+ Read and display doc_path
+ Goto step 1c
+
+
+ HALT
+
+ review_issue_number = null
+
+
+
+ review_issue = response.items[0]
+ review_issue_number = review_issue.number
+
+
+
+
+
+
+ Read doc_path
+ doc_content = file_content
+
+
+ // Extract sections for selection
+ sections = extract_sections(doc_content)
+
+
+
+
+ Section number:
+
+ section_idx = parseInt(response)
+ if (section_idx === 0) {
+ selected_section = 'General'
+ } else {
+ selected_section = sections[section_idx - 1] || 'General'
+ }
+
+
+
+
+
+
+
+
+
+
+ Feedback type (number):
+
+ type_map = {
+ '1': { key: 'clarification', emoji: '📋', label: 'feedback-type:clarification' },
+ '2': { key: 'concern', emoji: '⚠️', label: 'feedback-type:concern' },
+ '3': { key: 'suggestion', emoji: '💡', label: 'feedback-type:suggestion' },
+ '4': { key: 'addition', emoji: '➕', label: 'feedback-type:addition' },
+ '5': { key: 'priority', emoji: '🔢', label: 'feedback-type:priority' },
+ '6': { key: 'scope', emoji: '📐', label: 'feedback-type:scope' },
+ '7': { key: 'dependency', emoji: '🔗', label: 'feedback-type:dependency' },
+ '8': { key: 'technical_risk', emoji: '🔧', label: 'feedback-type:technical-risk' },
+ '9': { key: 'story_split', emoji: '✂️', label: 'feedback-type:story-split' }
+ }
+ feedback_type = type_map[response] || type_map['3']
+
+
+
+
+
+
+ Priority (1-3, default 2):
+
+ priority_map = {
+ '1': { key: 'high', label: 'priority:high' },
+ '2': { key: 'medium', label: 'priority:medium' },
+ '3': { key: 'low', label: 'priority:low' }
+ }
+ priority = priority_map[response] || priority_map['2']
+
+
+
+
+
+
+ Brief title for your feedback (one line):
+ feedback_title = response
+
+ Detailed feedback (describe your concern, question, or suggestion):
+ feedback_content = response
+
+
+ Suggested change (or press Enter to skip):
+ suggested_change = response || null
+
+
+ Rationale (or press Enter to skip):
+ rationale = response || null
+
+
+
+
+ // Build issue body
+ issue_body = `# ${feedback_type.emoji} Feedback: ${feedback_type.key.charAt(0).toUpperCase() + feedback_type.key.slice(1)}
+
+**Review:** ${review_issue_number ? '#' + review_issue_number : 'N/A'}
+**Document:** \`${doc_label}\`
+**Section:** ${selected_section}
+**Type:** ${feedback_type.key}
+**Priority:** ${priority.key}
+
+---
+
+## Feedback
+
+${feedback_content}
+`
+
+ if (suggested_change) {
+ issue_body += `
+## Suggested Change
+
+${suggested_change}
+`
+ }
+
+ if (rationale) {
+ issue_body += `
+## Context/Rationale
+
+${rationale}
+`
+ }
+
+ issue_body += `
+---
+
+_Submitted by @${current_user} on ${new Date().toISOString().split('T')[0]}_
+`
+
+ // Build labels
+ labels = [
+ feedback_label,
+ doc_label,
+ `feedback-section:${selected_section.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')}`,
+ feedback_type.label,
+ 'feedback-status:new',
+ priority.label
+ ]
+
+ if (review_issue_number) {
+ labels.push(`linked-review:${review_issue_number}`)
+ }
+
+
+ Call: mcp__github__issue_write({
+ method: 'create',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ title: "{{feedback_type.emoji}} Feedback: {{feedback_title}}",
+ body: issue_body,
+ labels: labels
+ })
+
+ feedback_issue = response
+
+
+
+
+
+
+
+ link_comment = `${feedback_type.emoji} **New Feedback** from @${current_user}
+
+**${feedback_title}** → #${feedback_issue.number}
+Type: ${feedback_type.key} | Priority: ${priority.key} | Section: ${selected_section}`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue_number,
+ body: link_comment
+ })
+
+
+
+
+
+
+
+
+ Choice:
+
+
+ Goto step 2 (submit more feedback)
+
+
+
+ Load workflow: view-feedback with document_key, document_type
+
+
+
+ Load workflow: my-tasks
+
+
+
+
+ Exit
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract section headers from document
+function extract_sections(content) {
+ const sections = [];
+ const lines = content.split('\n');
+
+ for (const line of lines) {
+ // Match ## headers but not metadata sections
+ const match = line.match(/^##\s+(.+)$/);
+ if (match) {
+ const section = match[1].trim();
+ // Skip metadata-like sections
+ if (!['Metadata', 'Version History', 'Sign-off Status'].includes(section)) {
+ sections.push(section);
+ }
+ }
+
+ // Also capture ### subsections for detailed feedback
+ const subMatch = line.match(/^###\s+(FR\d+|NFR\d+|US\d+):\s*(.+)$/);
+ if (subMatch) {
+ sections.push(`${subMatch[1]}: ${subMatch[2].slice(0, 40)}`);
+ }
+ }
+
+ return sections;
+}
+
+// Check if file exists
+function file_exists(path) {
+ // Implemented by runtime
+ return true;
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Submit feedback on [document]"
+- "I have feedback for the auth PRD"
+- "Give feedback on epic 2"
+- "Provide input on [document]"
+- Menu trigger: `SF`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml
new file mode 100644
index 00000000..2c2644d5
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml
@@ -0,0 +1,22 @@
+name: submit-feedback
+description: "Submit structured feedback on a PRD or Epic - creates linked GitHub issue"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters (can be passed in or prompted)
+document_key: "" # e.g., "user-auth" for PRD, "2" for Epic
+document_type: "" # "prd" or "epic" - will auto-detect if empty
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md
new file mode 100644
index 00000000..05810e70
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md
@@ -0,0 +1,427 @@
+# Submit Sign-off - Record Your Approval Decision
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+ Which document are you signing off on? Enter key:
+ document_key = response
+
+
+
+ if (document_type === 'prd') {
+ doc_path = `${docs_dir}/prd/${document_key}.md`
+ doc_label = `prd:${document_key}`
+ review_label = 'type:prd-review'
+ } else {
+ doc_path = `${docs_dir}/epics/epic-${document_key}.md`
+ doc_label = `epic:${document_key}`
+ review_label = 'type:epic-review'
+ }
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:signoff is:open"
+ })
+
+
+
+ HALT
+
+
+ review_issue = response.items[0]
+
+
+
+
+ Read doc_path
+
+
+ HALT
+
+ doc_content = file_content
+
+ title = extract_title(doc_content)
+ version = extract_version(doc_content)
+
+
+
+
+
+
+ // Check if user already signed off
+ signoff_label_prefix = `signoff-${current_user}-`
+ existing_signoff = review_issue.labels.some(l =>
+ l.name.startsWith(signoff_label_prefix)
+ )
+
+
+
+
+ Choice:
+
+ HALT
+
+
+
+
+
+
+
+
+ View document? (y/n):
+
+
+
+
+
+
+
+
+ Decision (1-3):
+
+ decision_map = {
+ '1': { key: 'approved', emoji: '✅', text: 'Approved' },
+ '2': { key: 'approved_with_note', emoji: '✅📝', text: 'Approved with Note' },
+ '3': { key: 'blocked', emoji: '🚫', text: 'Blocked' }
+ }
+ decision = decision_map[response] || decision_map['1']
+
+
+
+
+
+ Enter your note (this will be visible to all stakeholders):
+ note = response
+
+
+
+
+ Enter your blocking reason:
+ note = response
+
+
+ Create feedback issue? (y/n):
+
+ create_feedback_issue = true
+
+
+
+
+ note = null
+
+
+
+
+
+ // Build sign-off comment
+ signoff_comment = `### ${decision.emoji} Sign-off from @${current_user}
+
+**Decision:** ${decision.text}
+**Date:** ${new Date().toISOString().split('T')[0]}`
+
+ if (note) {
+ signoff_comment += `
+
+**Note:**
+${note}`
+ }
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: signoff_comment
+ })
+
+
+
+ // Get current labels
+ current_labels = review_issue.labels.map(l => l.name)
+
+ // Remove any existing signoff label for this user
+ new_labels = current_labels.filter(l =>
+ !l.startsWith(`signoff-${current_user}-`)
+ )
+
+ // Add new signoff label
+ decision_label = decision.key.replace(/_/g, '-')
+ new_labels.push(`signoff-${current_user}-${decision_label}`)
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: new_labels
+ })
+
+
+
+
+ feedback_body = `# 🚫 Blocking Concern
+
+**Document:** \`${doc_label}\`
+**Review:** #${review_issue.number}
+**Type:** Blocking concern requiring resolution
+
+---
+
+## Concern
+
+${note}
+
+---
+
+_Submitted by @${current_user} as part of sign-off for v${version}_`
+
+
+ Call: mcp__github__issue_write({
+ method: 'create',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ title: "🚫 Blocking: {{title}}",
+ body: feedback_body,
+ labels: ['type:{{document_type}}-feedback', doc_label, 'feedback-type:concern', 'priority:high', 'feedback-status:new', `linked-review:${review_issue.number}`]
+ })
+
+
+
+
+
+
+
+
+
+
+
+ // Refresh issue to get updated labels
+ Call: mcp__github__issue_read({
+ method: 'get',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: review_issue.number
+ })
+
+
+
+ // Count sign-offs
+ labels = response.labels.map(l => l.name)
+ approved_count = labels.filter(l =>
+ l.includes('-approved') || l.includes('-approved-with-note')
+ ).length
+ blocked_count = labels.filter(l => l.includes('-blocked')).length
+
+ // Get stakeholder count from document
+ stakeholder_count = extract_stakeholders(doc_content).length
+
+
+
+
+
+
+
+
+
+
+
+ Mark document as approved? (y/n):
+
+
+ // Update document status
+ updated_content = doc_content.replace(/\*\*Status:\*\* .+/, '**Status:** Approved')
+
+ Write updated_content to doc_path
+
+
+ // Update review issue
+ final_labels = labels
+ .filter(l => !l.startsWith('review-status:'))
+ .concat(['review-status:approved'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: final_labels,
+ state: 'closed',
+ state_reason: 'completed'
+ })
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: `## ✅ DOCUMENT APPROVED
+
+All stakeholders have signed off. This document is now approved and ready for implementation.
+
+**Final Version:** v${version}
+**Approved:** ${new Date().toISOString().split('T')[0]}
+**Approvals:** ${approved_count} / ${stakeholder_count}`
+ })
+
+
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+function extract_title(content) {
+ const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m);
+ return match ? match[2].trim() : 'Untitled';
+}
+
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+function extract_stakeholders(content) {
+ const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/);
+ if (!field) return [];
+
+ return field[1]
+ .split(/[,\s]+/)
+ .filter(s => s.startsWith('@'))
+ .map(s => s.replace('@', ''));
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Sign off on [document]"
+- "Submit my sign-off"
+- "Approve the PRD"
+- "I approve [document]"
+- Menu trigger: `SO`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml
new file mode 100644
index 00000000..a9aedae4
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml
@@ -0,0 +1,22 @@
+name: submit-signoff
+description: "Submit sign-off decision on a PRD or Epic"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+document_key: ""
+document_type: "prd"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md
new file mode 100644
index 00000000..3b2e39fb
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md
@@ -0,0 +1,684 @@
+# Synthesize Epic Feedback - LLM-Powered Story Refinement
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:open is:open"
+ })
+
+
+
+ HALT
+
+
+
+ feedback_epics = response.items.map(issue => {
+ const labels = issue.labels.map(l => l.name)
+ return {
+ key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''),
+ title: issue.title.replace(/^Epic Review:\s*/, ''),
+ source_prd: labels.find(l => l.startsWith('source-prd:'))?.replace('source-prd:', ''),
+ issue_number: issue.number
+ }
+ }).filter(e => e.key)
+
+
+
+
+
+ Select epic to synthesize (1-{{feedback_epics.length}}):
+ epic_key = feedback_epics[parseInt(response) - 1].key
+ review_issue_number = feedback_epics[parseInt(response) - 1].issue_number
+
+
+
+
+
+ epic_path = `${docs_dir}/epics/epic-${epic_key}.md`
+ Read epic_path
+
+
+
+ HALT
+
+
+ epic_content = file_content
+
+ title = extract_title(epic_content)
+ version = parseInt(extract_version(epic_content))
+ source_prd = extract_source_prd(epic_content)
+ current_stories = extract_epic_stories(epic_content)
+ dependencies = extract_dependencies(epic_content)
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback label:epic:{{epic_key}} label:feedback-status:new"
+ })
+
+
+
+
+ Continue anyway to create new version? (y/n):
+
+ HALT
+
+ feedback_items = []
+
+
+
+
+ feedback_items = response.items.map(issue => {
+ const labels = issue.labels.map(l => l.name)
+ return {
+ id: issue.number,
+ title: issue.title,
+ body: issue.body,
+ type: labels.find(l => l.startsWith('feedback-type:'))?.replace('feedback-type:', ''),
+ section: labels.find(l => l.startsWith('feedback-section:'))?.replace('feedback-section:', ''),
+ submittedBy: issue.user?.login,
+ created: issue.created_at
+ }
+ })
+
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open"
+ })
+
+ review_issue = response.items[0]
+
+
+ current_labels = review_issue.labels.map(l => l.name)
+ new_labels = current_labels
+ .filter(l => !l.startsWith('review-status:'))
+ .concat(['review-status:synthesis'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: new_labels
+ })
+
+
+
+
+
+
+
+
+ // Group feedback by type
+ feedback_by_type = {
+ scope: [],
+ story_split: [],
+ dependency: [],
+ priority: [],
+ technical_risk: [],
+ missing_story: []
+ }
+
+ for (item of feedback_items) {
+ const type = item.type || 'general'
+ if (!feedback_by_type[type]) feedback_by_type[type] = []
+ feedback_by_type[type].push(item)
+ }
+
+ // Identify story-specific feedback
+ story_feedback = {}
+ for (item of feedback_items) {
+ if (item.section && item.section.startsWith('story')) {
+ const story_id = item.section
+ if (!story_feedback[story_id]) story_feedback[story_id] = []
+ story_feedback[story_id].push(item)
+ }
+ }
+
+
+
+
+
+
+
+ conflicts = []
+
+ // Check for conflicting scope feedback
+ if (feedback_by_type.scope.length > 1) {
+ const split_requests = feedback_by_type.scope.filter(f =>
+ f.body?.toLowerCase().includes('split') || f.body?.toLowerCase().includes('too large')
+ )
+ const merge_requests = feedback_by_type.scope.filter(f =>
+ f.body?.toLowerCase().includes('merge') || f.body?.toLowerCase().includes('too small')
+ )
+
+ if (split_requests.length > 0 && merge_requests.length > 0) {
+ conflicts.push({
+ type: 'scope_direction',
+ description: 'Conflicting feedback on epic size',
+ items: [...split_requests, ...merge_requests]
+ })
+ }
+ }
+
+ // Check for conflicting priority feedback
+ if (feedback_by_type.priority.length > 1) {
+ conflicts.push({
+ type: 'priority_order',
+ description: 'Multiple priority reordering suggestions',
+ items: feedback_by_type.priority
+ })
+ }
+
+ // Check for conflicting story changes
+ for ([story_id, items] of Object.entries(story_feedback)) {
+ if (items.length > 1) {
+ conflicts.push({
+ type: 'story_conflict',
+ story: story_id,
+ description: `Multiple feedback on ${story_id}`,
+ items: items
+ })
+ }
+ }
+
+
+
+
+
+
+
+
+
+
+
+ synthesis_prompt = `You are synthesizing stakeholder feedback for an epic. Your goal is to:
+1. Incorporate valid feedback into story definitions
+2. Resolve conflicts with clear rationale
+3. Maintain story independence and testability
+4. Ensure technical feasibility
+
+## Epic Context
+
+**Title:** ${title}
+**Source PRD:** prd:${source_prd}
+
+## Current Stories
+
+${current_stories.map((s, i) => `### Story ${i + 1}: ${s.title}
+${s.description || ''}
+**Complexity:** ${s.complexity || 'TBD'}
+**Acceptance Criteria:**
+${s.acceptance_criteria || 'TBD'}
+`).join('\n')}
+
+## Dependencies
+
+${dependencies.join('\n') || 'None specified'}
+
+## Feedback to Process
+
+${feedback_items.map(f => `### Feedback #${f.id} from @${f.submittedBy}
+**Type:** ${f.type || 'general'}
+**Section:** ${f.section || 'general'}
+**Content:**
+${f.body}
+`).join('\n---\n')}
+
+${conflicts.length > 0 ? `
+## Conflicts to Resolve
+
+${conflicts.map(c => `**${c.type}:** ${c.description}
+Conflicting feedback:
+${c.items.map(i => `- @${i.submittedBy}: ${i.title}`).join('\n')}
+`).join('\n')}
+` : ''}
+
+## Your Task
+
+Generate an updated epic with:
+
+1. **Story Updates:** For each story, show:
+ - Original version
+ - Proposed changes
+ - Which feedback drove the change
+ - Confidence level (high/medium/low)
+
+2. **New Stories:** If missing_story feedback is valid, add new stories
+
+3. **Story Removals/Merges:** If story_split feedback suggests it
+
+4. **Dependency Updates:** Based on dependency feedback
+
+5. **Conflict Resolutions:** For each conflict, explain your resolution and rationale
+
+6. **Deferred Feedback:** Any feedback not incorporated and why
+
+Output format:
+---
+## Summary
+[Brief overview of changes]
+
+## Story Changes
+[For each modified story]
+
+## New Stories
+[Any new stories added]
+
+## Removed/Merged Stories
+[Any stories removed or merged]
+
+## Dependency Updates
+[Changes to dependencies]
+
+## Conflict Resolutions
+[How conflicts were resolved]
+
+## Deferred Feedback
+[Feedback not incorporated and why]
+---`
+
+ synthesis_result = await llm_generate(synthesis_prompt)
+
+
+
+
+
+
+
+
+ Choice:
+
+
+
+
+
+ new_labels = current_labels
+ .filter(l => !l.startsWith('review-status:'))
+ .concat(['review-status:open'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: new_labels
+ })
+
+ HALT
+
+
+
+ Describe your modifications:
+
+ modification_prompt = `${synthesis_prompt}
+
+Previous synthesis:
+${synthesis_result}
+
+User modifications requested:
+${response}
+
+Please regenerate the synthesis incorporating these modifications.`
+
+ synthesis_result = await llm_generate(modification_prompt)
+
+
+
+
+
+
+
+ diff_prompt = `Generate a detailed diff showing:
+1. Each story's original text vs proposed text
+2. New sections added
+3. Sections removed
+4. Specific line-by-line changes
+
+Current epic content:
+${epic_content}
+
+Proposed changes from synthesis:
+${synthesis_result}`
+
+ diff_output = await llm_generate(diff_prompt)
+
+
+
+
+ Accept these changes? (y/n):
+
+ HALT
+
+
+
+
+
+
+ new_version = version + 1
+
+ update_prompt = `Generate the complete updated epic document in markdown format.
+
+Original epic:
+${epic_content}
+
+Changes to apply:
+${synthesis_result}
+
+Requirements:
+1. Increment version to ${new_version}
+2. Update "Last Updated" to today's date
+3. Keep document structure intact
+4. Add entry to Version History table
+5. Update Status to "Feedback" (for next round) or "Ready for Sign-off"
+
+Output the complete markdown document.`
+
+ updated_epic = await llm_generate(update_prompt)
+
+
+
+
+ Write updated_epic to epic_path
+
+
+
+
+
+
+ // Mark processed feedback as incorporated
+ for (item of feedback_items) {
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: item.id,
+ labels: item.labels
+ .filter(l => !l.startsWith('feedback-status:'))
+ .concat(['feedback-status:incorporated']),
+ state: 'closed',
+ state_reason: 'completed'
+ })
+ }
+
+
+
+
+
+
+
+ synthesis_comment = `## 🔄 Synthesis Complete
+
+**Version:** v${version} → v${new_version}
+**Feedback Processed:** ${feedback_items.length} items
+**Conflicts Resolved:** ${conflicts.length}
+
+---
+
+### Summary of Changes
+
+${synthesis_result.split('## Summary')[1]?.split('##')[0] || 'See updated epic document'}
+
+---
+
+### Feedback Incorporated
+
+${feedback_items.map(f => `- ✅ #${f.id} (@${f.submittedBy}): ${f.title}`).join('\n')}
+
+---
+
+_Synthesized by @${current_user} on ${new Date().toISOString().split('T')[0]}_`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: synthesis_comment
+ })
+
+
+ // Update version label and unlock
+ final_labels = new_labels
+ .filter(l => !l.startsWith('version:') && !l.startsWith('review-status:'))
+ .concat([`version:${new_version}`, 'review-status:open'])
+
+
+ Call: mcp__github__issue_write({
+ method: 'update',
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ labels: final_labels
+ })
+
+
+
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+function extract_title(content) {
+ const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m);
+ return match ? match[2].trim() : 'Untitled';
+}
+
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+function extract_source_prd(content) {
+ const match = content.match(/\*\*Source PRD:\*\*\s*`?prd:([^`\s]+)`?/);
+ return match ? match[1] : null;
+}
+
+function extract_epic_stories(content) {
+ const stories = [];
+ const storyRegex = /###\s+Story\s+(\d+):\s*(.+)\n+([\s\S]*?)(?=###\s+Story|---|\n##|$)/gi;
+ let match;
+ while ((match = storyRegex.exec(content)) !== null) {
+ const number = match[1];
+ const title = match[2].trim();
+ const body = match[3];
+
+ const complexityMatch = body.match(/\*\*(?:Estimated )?Complexity:\*\*\s*(\w+)/i);
+ const descMatch = body.match(/\*\*Description:\*\*\s*(.+)/);
+ const acMatch = body.match(/\*\*Acceptance Criteria:\*\*\s*([\s\S]*?)(?=\*\*|$)/);
+
+ stories.push({
+ number: number,
+ title: title,
+ description: descMatch ? descMatch[1].trim() : '',
+ complexity: complexityMatch ? complexityMatch[1] : null,
+ acceptance_criteria: acMatch ? acMatch[1].trim() : null
+ });
+ }
+ return stories;
+}
+
+function extract_dependencies(content) {
+ const section = content.match(/## Dependencies\n+([\s\S]*?)(?=\n##|$)/);
+ if (!section) return [];
+
+ return section[1]
+ .split('\n')
+ .filter(line => line.startsWith('-'))
+ .map(line => line.replace(/^-\s*/, '').trim())
+ .filter(line => line && line !== 'TBD');
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Synthesize epic feedback"
+- "Process feedback for epic"
+- "Merge epic feedback"
+- Menu trigger: `SE`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml
new file mode 100644
index 00000000..127477cd
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml
@@ -0,0 +1,21 @@
+name: synthesize-epic-feedback
+description: "LLM-powered synthesis of epic feedback with story split and dependency resolution"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+epic_key: ""
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md
new file mode 100644
index 00000000..2066fa8e
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md
@@ -0,0 +1,610 @@
+# Synthesize Feedback - LLM-Powered Conflict Resolution
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ HALT
+
+
+
+
+
+ Which document? Enter key:
+ document_key = response
+
+
+
+ if (document_type === 'prd') {
+ doc_path = `${docs_dir}/prd/${document_key}.md`
+ doc_label = `prd:${document_key}`
+ feedback_label = 'type:prd-feedback'
+ } else {
+ doc_path = `${docs_dir}/epics/epic-${document_key}.md`
+ doc_label = `epic:${document_key}`
+ feedback_label = 'type:epic-feedback'
+ }
+
+
+ Read doc_path
+
+
+ HALT
+
+ original_content = file_content
+ current_version = extract_version(original_content)
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} label:feedback-status:new is:open"
+ })
+
+ feedback_issues = response.items || []
+
+
+
+ Choice:
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open"
+ })
+ feedback_issues = response.items || []
+
+
+ Load workflow: view-feedback with document_key, document_type
+
+
+ HALT
+
+
+
+
+
+
+
+
+ // Parse all feedback into structured format
+ all_feedback = []
+ by_section = {}
+
+ for (issue of feedback_issues) {
+ const labels = issue.labels.map(l => l.name)
+
+ const fb = {
+ id: issue.number,
+ title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''),
+ section: extract_label(labels, 'feedback-section:') || 'General',
+ type: extract_label(labels, 'feedback-type:') || 'suggestion',
+ priority: extract_label(labels, 'priority:') || 'medium',
+ submittedBy: issue.user?.login,
+ body: issue.body,
+ suggestedChange: extract_suggested_change(issue.body)
+ }
+
+ all_feedback.push(fb)
+
+ if (!by_section[fb.section]) by_section[fb.section] = []
+ by_section[fb.section].push(fb)
+ }
+
+ sections_with_feedback = Object.keys(by_section)
+
+
+
+
+
+
+ proposed_changes = []
+ section_index = 0
+
+
+ section_index++
+ section_feedback = by_section[section]
+
+
+
+
+
+ original_section_text = extract_section(original_content, section)
+
+
+
+
+
+ has_conflict = section_feedback.length >= 2 &&
+ section_feedback.some(f => f.type === 'concern') &&
+ section_feedback.some(f => f.type === 'suggestion' || f.type === 'concern')
+
+
+
+
+
+
+
+ // Build conflict resolution prompt
+ conflict_prompt = `You are helping resolve conflicting stakeholder feedback on a ${document_type.toUpperCase()}.
+
+SECTION: ${section}
+
+ORIGINAL TEXT:
+${original_section_text || '[Section not found in document]'}
+
+CONFLICTING FEEDBACK:
+${section_feedback.map(f => `
+- @${f.submittedBy} (${f.type}, ${f.priority}): "${f.title}"
+ ${f.suggestedChange ? 'Suggests: ' + f.suggestedChange : ''}
+`).join('\n')}
+
+Propose a resolution that:
+1. Addresses the core concerns of all parties
+2. Maintains document coherence
+3. Is actionable and specific
+
+Respond with:
+1. PROPOSED_TEXT: The updated section text
+2. RATIONALE: Why this resolution works (2-3 sentences)
+3. TRADE_OFFS: What compromises were made
+4. CONFIDENCE: high/medium/low`
+
+
+
+
+
+
+
+ // Non-conflicting feedback - straightforward merge
+ merge_prompt = `Incorporate the following feedback into this ${document_type.toUpperCase()} section:
+
+SECTION: ${section}
+
+ORIGINAL TEXT:
+${original_section_text || '[Section not found]'}
+
+FEEDBACK TO INCORPORATE:
+${section_feedback.map(f => `
+- ${f.type}: ${f.title}
+ ${f.suggestedChange ? 'Suggested change: ' + f.suggestedChange : 'Address this concern'}
+`).join('\n')}
+
+Generate updated section text that:
+1. Addresses all feedback points
+2. Maintains consistent tone and format
+3. Is clear and actionable
+
+Return the complete updated section text.`
+
+
+
+
+
+
+
+ Decision for {{section}}:
+
+
+
+ proposed_changes.push({
+ section: section,
+ decision: 'accept',
+ newText: proposed_text,
+ feedbackIds: section_feedback.map(f => f.id)
+ })
+
+
+
+
+
+ Enter your modified text for this section:
+
+ proposed_changes.push({
+ section: section,
+ decision: 'modified',
+ newText: response,
+ feedbackIds: section_feedback.map(f => f.id)
+ })
+
+
+
+
+
+
+ proposed_changes.push({
+ section: section,
+ decision: 'reject',
+ newText: null,
+ feedbackIds: section_feedback.map(f => f.id)
+ })
+
+
+
+
+
+
+
+
+
+
+
+
+ accepted_changes = proposed_changes.filter(c => c.decision === 'accept' || c.decision === 'modified')
+ rejected_changes = proposed_changes.filter(c => c.decision === 'reject')
+
+
+
+
+
+
+ HALT
+
+
+ Apply these changes to the document? (y/n):
+
+
+
+ HALT
+
+
+
+
+
+ new_version = parseInt(current_version) + 1
+ updated_content = original_content
+
+ // Apply each accepted change
+ for (change of accepted_changes) {
+ if (change.newText) {
+ updated_content = replace_section(updated_content, change.section, change.newText)
+ }
+ }
+
+ // Update version and timestamp
+ updated_content = updated_content
+ .replace(/\*\*Version:\*\* \d+/, `**Version:** ${new_version}`)
+ .replace(/\*\*Last Updated:\*\* .+/, `**Last Updated:** ${new Date().toISOString().split('T')[0]}`)
+ .replace(/\*\*Status:\*\* .+/, '**Status:** Draft')
+
+ // Add version history entry
+ version_entry = `| ${new_version} | ${new Date().toISOString().split('T')[0]} | Synthesized feedback from ${all_feedback.length} items | ${accepted_changes.map(c => c.section).join(', ')} |`
+ updated_content = updated_content.replace(
+ /(## Version History\n\n\|[^\n]+\|\n\|[^\n]+\|)/,
+ `$1\n${version_entry}`
+ )
+
+
+ Write updated_content to doc_path
+
+
+
+
+
+
+
+
+ // Mark accepted feedback as incorporated
+ for (change of accepted_changes) {
+ for (id of change.feedbackIds) {
+ await update_feedback_status(id, 'incorporated')
+ }
+ }
+
+ // Mark rejected feedback as reviewed
+ for (change of rejected_changes) {
+ for (id of change.feedbackIds) {
+ await update_feedback_status(id, 'reviewed')
+ }
+ }
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:{{document_type}}-review label:{{doc_label}} is:open"
+ })
+
+
+ review_issue = response.items[0]
+
+
+ synthesis_comment = `## 🔄 Synthesis Complete
+
+**New Version:** v${new_version}
+**Feedback Processed:** ${all_feedback.length} items
+**Changes Applied:** ${accepted_changes.length} sections
+
+## Summary of Changes
+
+${accepted_changes.map(c => `- ✅ **${c.section}**: Updated based on ${c.feedbackIds.length} feedback item(s)`).join('\n')}
+
+${rejected_changes.length > 0 ? `
+## Feedback Not Incorporated
+
+${rejected_changes.map(c => `- ❌ **${c.section}**: Kept original`).join('\n')}
+` : ''}
+
+---
+
+The document has been updated. Review the changes and proceed to sign-off when ready.
+
+_Synthesis performed by @${current_user} on ${new Date().toISOString().split('T')[0]}_`
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: "{{github_owner}}",
+ repo: "{{github_repo}}",
+ issue_number: review_issue.number,
+ body: synthesis_comment
+ })
+
+
+
+
+
+
+ Choice:
+
+
+ Load workflow: request-signoff with document_key, document_type
+
+
+
+ Load workflow: open-feedback-round with document_key, document_type
+
+
+
+ Read and display doc_path
+
+
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract version from document
+function extract_version(content) {
+ const match = content.match(/\*\*Version:\*\*\s*(\d+)/);
+ return match ? match[1] : '1';
+}
+
+// Extract label value by prefix
+function extract_label(labels, prefix) {
+ for (const label of labels) {
+ if (label.startsWith(prefix)) {
+ return label.replace(prefix, '');
+ }
+ }
+ return null;
+}
+
+// Extract suggested change from issue body
+function extract_suggested_change(body) {
+ if (!body) return null;
+ const match = body.match(/## Suggested Change\n\n([\s\S]*?)(?:\n##|$)/);
+ return match ? match[1].trim().slice(0, 150) : null;
+}
+
+// Extract section content from document
+function extract_section(content, sectionName) {
+ // Normalize section name for matching
+ const normalized = sectionName.toLowerCase().replace(/-/g, ' ');
+
+ // Try to find the section
+ const regex = new RegExp(`^##\\s+${normalized}\\s*$`, 'im');
+ const match = content.match(regex);
+
+ if (!match) return null;
+
+ const startIdx = match.index + match[0].length;
+ const nextSection = content.indexOf('\n## ', startIdx);
+ const endIdx = nextSection > -1 ? nextSection : content.length;
+
+ return content.slice(startIdx, endIdx).trim();
+}
+
+// Replace section content in document
+function replace_section(content, sectionName, newText) {
+ const normalized = sectionName.toLowerCase().replace(/-/g, ' ');
+ const regex = new RegExp(`(^##\\s+${normalized}\\s*$)([\\s\\S]*?)(?=\\n## |$)`, 'im');
+
+ return content.replace(regex, `$1\n\n${newText}\n\n`);
+}
+
+// Update feedback status via GitHub
+async function update_feedback_status(issueNumber, newStatus) {
+ // Get current labels
+ const issue = await mcp__github__issue_read({
+ method: 'get',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issueNumber
+ });
+
+ const labels = issue.labels
+ .map(l => l.name)
+ .filter(l => !l.startsWith('feedback-status:'));
+
+ labels.push(`feedback-status:${newStatus}`);
+
+ await mcp__github__issue_write({
+ method: 'update',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issueNumber,
+ labels: labels
+ });
+
+ // Close if incorporated or deferred
+ if (newStatus === 'incorporated' || newStatus === 'deferred') {
+ await mcp__github__issue_write({
+ method: 'update',
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issueNumber,
+ state: 'closed',
+ state_reason: newStatus === 'incorporated' ? 'completed' : 'not_planned'
+ });
+ }
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "Synthesize feedback for [document]"
+- "Process feedback on PRD"
+- "Incorporate feedback into [document]"
+- "Merge feedback"
+- Menu trigger: `SZ`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml
new file mode 100644
index 00000000..a98e48d3
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml
@@ -0,0 +1,22 @@
+name: synthesize-feedback
+description: "LLM-powered synthesis of feedback into document updates with conflict resolution"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+docs_dir: "{project-root}/docs"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+document_key: ""
+document_type: "prd" # "prd" or "epic"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md
new file mode 100644
index 00000000..d63d148f
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md
@@ -0,0 +1,411 @@
+# View Feedback - Review All Stakeholder Input
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+
+
+
+ Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic):
+ document_key = response
+
+
+
+ Is this a [P]RD or [E]pic?
+ document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic'
+
+
+
+ doc_label = `${document_type}:${document_key}`
+ feedback_label = `type:${document_type}-feedback`
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open"
+ })
+
+ feedback_issues = response.items || []
+
+
+
+ Choice:
+
+ Load workflow: submit-feedback with document_key, document_type
+
+
+ Load workflow: my-tasks
+
+ HALT
+
+
+
+
+
+ // Parse feedback issues into structured data
+ all_feedback = []
+ by_section = {}
+ by_type = {}
+ by_status = { new: [], reviewed: [], incorporated: [], deferred: [] }
+ conflicts = []
+
+ for (issue of feedback_issues) {
+ const labels = issue.labels.map(l => l.name)
+
+ const fb = {
+ id: issue.number,
+ url: issue.html_url,
+ title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''),
+ section: extract_label(labels, 'feedback-section:') || 'General',
+ type: extract_label(labels, 'feedback-type:') || 'suggestion',
+ status: extract_label(labels, 'feedback-status:') || 'new',
+ priority: extract_label(labels, 'priority:') || 'medium',
+ submittedBy: issue.user?.login,
+ createdAt: issue.created_at,
+ body: issue.body
+ }
+
+ all_feedback.push(fb)
+
+ // Group by section
+ if (!by_section[fb.section]) by_section[fb.section] = []
+ by_section[fb.section].push(fb)
+
+ // Group by type
+ if (!by_type[fb.type]) by_type[fb.type] = []
+ by_type[fb.type].push(fb)
+
+ // Group by status
+ if (by_status[fb.status]) by_status[fb.status].push(fb)
+ }
+
+ // Detect potential conflicts (multiple feedback on same section)
+ for (const [section, items] of Object.entries(by_section)) {
+ if (items.length >= 2) {
+ const concerns = items.filter(i => i.type === 'concern')
+ const suggestions = items.filter(i => i.type === 'suggestion')
+
+ if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) {
+ conflicts.push({
+ section,
+ count: items.length,
+ items: items
+ })
+ }
+ }
+ }
+
+
+
+
+
+
+
+
+
+
+ Choice:
+
+
+
+
+
+ Goto step 5
+
+
+
+
+ Goto step 5
+
+
+
+
+
+ Goto step 5
+
+
+
+ Goto step 5
+
+
+
+
+ Goto step 5
+
+
+
+
+ // Generate markdown export
+ export_content = `# Feedback Report: ${doc_label}
+
+Generated: ${new Date().toISOString()}
+Total Feedback: ${all_feedback.length}
+
+## Summary
+
+| Type | Count |
+|------|-------|
+${Object.entries(by_type).map(([t, items]) => `| ${t} | ${items.length} |`).join('\n')}
+
+## By Section
+
+${Object.entries(by_section).map(([section, items]) => `
+### ${section}
+
+${items.map(fb => `- **${fb.title}** (${fb.type}, ${fb.priority}) - @${fb.submittedBy} #${fb.id}`).join('\n')}
+`).join('\n')}
+
+## Conflicts
+
+${conflicts.length === 0 ? 'No conflicts detected.' : conflicts.map(c => `
+### ${c.section}
+
+${c.items.map(fb => `- @${fb.submittedBy}: "${fb.title}"`).join('\n')}
+`).join('\n')}
+`
+ export_path = `${cache_dir}/feedback-report-${document_key}.md`
+
+ Write export_content to export_path
+
+ Goto step 5
+
+
+
+
+ Load workflow: synthesize-feedback with document_key, document_type
+
+
+
+ Goto step 2
+
+
+
+
+ Exit
+
+
+ Goto step 5
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract label value by prefix
+function extract_label(labels, prefix) {
+ for (const label of labels) {
+ if (label.startsWith(prefix)) {
+ return label.replace(prefix, '');
+ }
+ }
+ return null;
+}
+
+// Get emoji for feedback type
+function get_type_emoji(type) {
+ const emojis = {
+ clarification: '📋',
+ concern: '⚠️',
+ suggestion: '💡',
+ addition: '➕',
+ priority: '🔢',
+ scope: '📐',
+ dependency: '🔗',
+ 'technical-risk': '🔧',
+ 'story-split': '✂️'
+ };
+ return emojis[type] || '📝';
+}
+
+// Format date for display
+function format_date(isoDate) {
+ return new Date(isoDate).toISOString().split('T')[0];
+}
+
+// Extract feedback content from issue body
+function extract_feedback(body) {
+ if (!body) return 'No details provided.';
+
+ // Try to extract the Feedback section
+ const match = body.match(/## Feedback\n\n([\s\S]*?)(?:\n##|$)/);
+ if (match) {
+ return match[1].trim().slice(0, 200) + (match[1].length > 200 ? '...' : '');
+ }
+
+ // Fallback to first 200 chars
+ return body.slice(0, 200) + (body.length > 200 ? '...' : '');
+}
+```
+
+## Natural Language Triggers
+
+This workflow responds to:
+- "View feedback for [document]"
+- "Show feedback on PRD"
+- "What feedback has been submitted?"
+- "See all feedback for [document]"
+- Menu trigger: `VF`
diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml
new file mode 100644
index 00000000..13786d31
--- /dev/null
+++ b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml
@@ -0,0 +1,21 @@
+name: view-feedback
+description: "View all feedback for a PRD or Epic - grouped by section and type"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Parameters
+document_key: ""
+document_type: "" # "prd" or "epic"
+
+installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md b/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md
new file mode 100644
index 00000000..a76fd4b5
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md
@@ -0,0 +1,161 @@
+# Available Stories - Find Unlocked Work
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ current_user = response.login
+
+
+
+
+
+
+ Build search query:
+
+query = "repo:{{github_owner}}/{{github_repo}} label:type:story no:assignee"
+
+# Add status filter
+IF status is provided:
+ query += " label:status:{{status}}"
+ELSE:
+ query += " (label:status:ready-for-dev OR label:status:backlog)"
+
+# Add epic filter
+IF epic is provided:
+ query += " label:epic:{{epic}}"
+
+# Sort by most recently updated
+query += " sort:updated-desc"
+
+
+ Call: mcp__github__search_issues({ query: query })
+
+ available_stories = response.items
+
+
+
+
+ Build locked query:
+
+locked_query = "repo:{{github_owner}}/{{github_repo}} label:type:story -no:assignee"
+
+IF epic is provided:
+ locked_query += " label:epic:{{epic}}"
+
+
+ Call: mcp__github__search_issues({ query: locked_query })
+ locked_stories = response.items
+
+
+
+
+
+
+
+
+
+
+
+
+ Group stories by epic:
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml b/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml
new file mode 100644
index 00000000..e10b041c
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml
@@ -0,0 +1,24 @@
+name: available-stories
+description: "List all stories that are unlocked and available for checkout. Helps developers find work without conflicts."
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_integration.repository.owner"
+ repo: "{config_source}:github_integration.repository.repo"
+
+# Filter parameters
+epic: "" # Optional: Filter by epic number (e.g., "2")
+status: "" # Optional: Filter by status (default: ready-for-dev, backlog)
+show_locked: false # Show locked stories too (grayed out)
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md b/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md
new file mode 100644
index 00000000..9c472607
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md
@@ -0,0 +1,404 @@
+# Checkout Story - Lock Story for Development
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+TEAM COORDINATION: This workflow prevents duplicate work by locking stories
+
+
+
+
+ Verify prerequisites before attempting lock acquisition
+
+
+
+
+
+
+ HALT
+
+
+ Validate story_key format: {{story_key}}
+ Expected format: {epic_number}-{story_number}-{slug}
+ Example: 2-5-auth, 3-1-user-profile
+
+
+
+
+
+
+
+
+
+ Test GitHub MCP connection:
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ Extract current user: {{current_user}} = response.login
+
+
+
+
+ Count user's currently locked stories
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} assignee:{{current_user}} label:status:in-progress label:type:story"
+ })
+
+ current_lock_count = response.total_count or response.items.length
+
+
+
+ HALT
+
+
+
+
+
+
+
+ Verify story exists and is not locked by another developer
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
+ })
+
+
+
+ HALT
+
+
+ issue = response.items[0]
+ issue_number = issue.number
+ issue_title = issue.title
+ current_assignee = issue.assignee?.login or null
+
+
+
+
+
+
+
+ HALT
+
+
+
+
+ Set refresh_mode = true
+
+
+
+
+ Set refresh_mode = false
+
+
+
+
+
+ Acquire lock with retry logic and verification
+
+
+
+
+ Ensure lock directory exists: {{lock_dir}}
+ Create lock file: {{lock_dir}}/{{story_key}}.lock
+
+ Lock file content:
+```yaml
+story_key: {{story_key}}
+github_issue: {{issue_number}}
+locked_by: {{current_user}}
+locked_at: {{current_timestamp}}
+timeout_at: {{current_timestamp + 8 hours}}
+last_heartbeat: {{current_timestamp}}
+epic_number: {{extract_epic_from_story_key(story_key)}}
+```
+
+
+
+
+
+
+ ATOMIC ASSIGNMENT with verification:
+
+
+attempt = 0
+max_attempts = 4 # 1 initial + 3 retries
+backoffs = [1000, 3000, 9000] # ms
+
+WHILE attempt < max_attempts:
+ TRY:
+ # 1. Assign issue to current user
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ assignees: ["{{current_user}}"]
+ })
+
+ # 2. Update status label
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ labels: [update: remove "status:backlog", "status:ready-for-dev"; add "status:in-progress"]
+ })
+
+ # 3. Add lock comment
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ body: "🔒 **Story locked by @{{current_user}}**\n\n" +
+ "Lock acquired at: {{current_timestamp}}\n" +
+ "Lock expires: {{timeout_at}}\n\n" +
+ "_This lock prevents duplicate work. Lock will auto-expire after 8 hours._"
+ })
+
+ # 4. Verify assignment (CRITICAL - read back)
+ sleep 1 second # GitHub eventual consistency
+
+ verification = Call: mcp__github__issue_read({
+ method: "get",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}}
+ })
+
+ # Check verification
+ IF verification.assignees does not include {{current_user}}:
+ THROW "Assignment verification failed - issue not assigned"
+
+ IF verification.labels does not include "status:in-progress":
+ THROW "Label verification failed - status not updated"
+
+ # SUCCESS!
+ output: "✅ GitHub Issue assigned and verified"
+ BREAK
+
+ CATCH error:
+ attempt++
+ IF attempt < max_attempts:
+ sleep backoffs[attempt - 1]
+ output: "⚠️ Retry {{attempt}}/3 after error: {{error}}"
+ ELSE:
+ # ROLLBACK: Remove local lock file
+ delete {{lock_dir}}/{{story_key}}.lock
+
+ output: "❌ FAILED to acquire lock after 3 retries"
+ output: "Error: {{error}}"
+ output: ""
+ output: "Local lock file removed (rollback)"
+ output: ""
+ output: "Possible causes:"
+ output: "- Network connectivity issues"
+ output: "- GitHub API rate limiting"
+ output: "- Race condition (another dev assigned first)"
+ output: ""
+ output: "Try again in a few minutes or check /available-stories"
+ HALT
+
+
+
+
+ Update cache meta with lock info:
+
+cache_meta = load {{cache_dir}}/.bmad-cache-meta.json
+
+cache_meta.stories[{{story_key}}] = {
+ github_issue: {{issue_number}},
+ locked_by: "{{current_user}}",
+ locked_until: "{{timeout_at}}",
+ locked_at: "{{current_timestamp}}"
+}
+
+save cache_meta
+
+
+
+
+
+
+
+
+ Skip to Step 4
+
+
+
+
+
+ epic_number = extract first segment from story_key
+ Example: "2-5-auth" → epic_number = 2
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story"
+ })
+
+ epic_stories = response.items
+
+
+
+
+ For each story in epic_stories:
+ - Extract story_key from labels
+ - Convert issue body to story content
+ - Write to cache: {{cache_dir}}/stories/{story_key}.md
+ - Update cache metadata
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml
new file mode 100644
index 00000000..79babf81
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml
@@ -0,0 +1,32 @@
+name: checkout-story
+description: "Lock a story for development - prevents other developers from working on it. Essential for enterprise team coordination."
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+lock_dir: "{project-root}/.bmad/locks"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_integration.repository.owner"
+ repo: "{config_source}:github_integration.repository.repo"
+
+# Locking settings
+locking:
+ default_timeout_hours: 8
+ heartbeat_interval_minutes: 30
+ stale_threshold_minutes: 15
+ max_locks_per_user: 3
+
+# Workflow parameters
+story_key: "" # Required: Story to checkout (e.g., "2-5-auth")
+epic_prefetch: true # Pre-fetch all stories in the same epic for context
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/checkout-story"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml b/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml
index 3f0c54ed..822543f0 100644
--- a/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml
+++ b/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml
@@ -382,6 +382,79 @@
+
+
+
+ MUST verify lock before each implementation session
+
+ Check local lock file: {{lock_dir}}/{{story_key}}.lock
+
+
+
+
+
+
+ Read lock file and verify:
+ - locked_by matches current user
+ - timeout_at has not passed
+
+
+
+ HALT - Cannot proceed with expired lock
+
+
+
+
+ HALT - Lock mismatch
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
+ })
+
+
+
+ HALT - Lock verification failed
+
+
+
+ Update lock file: last_heartbeat = now()
+
+
+
+
+
Load the FULL file: {{sprint_status}}
Read all development_status entries to find {{story_key}}
@@ -532,6 +605,57 @@
+
+
+
+ Load cache metadata to get github_issue number
+ cache_meta = load {{cache_dir}}/.bmad-cache-meta.json
+ issue_number = cache_meta.stories[{{story_key}}].github_issue
+
+
+ SYNC with retry:
+
+attempt = 0
+max_attempts = 4
+backoffs = [1000, 3000, 9000]
+
+WHILE attempt < max_attempts:
+ TRY:
+ # Add progress comment to GitHub Issue
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ body: "📊 **Task {{checked_tasks}}/{{total_tasks}} complete** ({{progress_pct}}%)\n\n" +
+ "> {{task_description}}\n\n" +
+ "_Progress synced at {{timestamp}}_"
+ })
+
+ output: "✅ Progress synced to GitHub Issue #{{issue_number}}"
+ BREAK
+
+ CATCH error:
+ attempt++
+ IF attempt < max_attempts:
+ sleep backoffs[attempt - 1]
+ output: "⚠️ GitHub sync retry {{attempt}}/3: {{error}}"
+ ELSE:
+ output: "⚠️ GitHub sync failed (non-blocking): {{error}}"
+ output: " Progress tracking continues in sprint-status.yaml"
+ # Non-blocking - don't halt for sync failures
+ BREAK
+
+
+
+ Update lock file: last_heartbeat = now()
+
+
+
+
+
+
+
+
Count total resolved review items in this session
Add Change Log entry: "Addressed code review findings - {{resolved_count}} items resolved (Date: {{date}})"
diff --git a/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml
index 9c54c125..cf4e758f 100644
--- a/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml
+++ b/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml
@@ -22,6 +22,23 @@ implementation_artifacts: "{config_source}:implementation_artifacts"
sprint_status: "{implementation_artifacts}/sprint-status.yaml"
project_context: "**/project-context.md"
+# GitHub Integration Settings (Enterprise)
+github_integration:
+ enabled: "{config_source}:github_integration_enabled"
+ repository:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+ cache:
+ dir: "{output_folder}/cache"
+ staleness_minutes: "{config_source}:github_cache_staleness_minutes"
+ locking:
+ timeout_hours: "{config_source}:github_lock_timeout_hours"
+ dir: "{project-root}/.bmad/locks"
+ sync:
+ progress_updates: true # Sync task completion to GitHub
+ permissions:
+ scrum_masters: "{config_source}:github_scrum_masters"
+
# Autonomous mode settings (passed from parent workflow like batch-super-dev)
auto_accept_gap_analysis: false # When true, skip gap analysis approval prompt
diff --git a/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md b/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md
new file mode 100644
index 00000000..b603b746
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md
@@ -0,0 +1,184 @@
+# Lock Status - View Story Assignments
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ current_user = response.login
+
+
+
+
+
+
+ Build search query:
+
+query = "repo:{{github_owner}}/{{github_repo}} label:type:story -no:assignee label:status:in-progress"
+
+IF user is provided:
+ query += " assignee:{{user}}"
+
+IF epic is provided:
+ query += " label:epic:{{epic}}"
+
+query += " sort:updated-desc"
+
+
+ Call: mcp__github__search_issues({ query: query })
+ locked_stories = response.items
+
+
+
+ For each locked story:
+
+for story in locked_stories:
+ updated_at = parse(story.updated_at)
+ age_minutes = (now - updated_at) / 60000
+
+ story.age_minutes = age_minutes
+ story.age_display = format_duration(age_minutes)
+ story.is_stale = age_minutes > stale_threshold_minutes
+
+ # Extract story key from labels
+ story_label = story.labels.find(l => l.name.startsWith("story:"))
+ story.story_key = story_label?.name.replace("story:", "") or "unknown"
+
+ # Extract epic
+ epic_label = story.labels.find(l => l.name.startsWith("epic:"))
+ story.epic = epic_label?.name.replace("epic:", "") or "?"
+
+
+
+
+ Group stories by assignee:
+
+locks_by_user = {}
+stale_locks = []
+
+for story in locked_stories:
+ assignee = story.assignee.login
+
+ if not locks_by_user[assignee]:
+ locks_by_user[assignee] = []
+
+ locks_by_user[assignee].push(story)
+
+ if story.is_stale:
+ stale_locks.push(story)
+
+
+
+
+
+
+
+ Exit
+
+
+
+
+ Display locks grouped by user:
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml b/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml
new file mode 100644
index 00000000..7459a026
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml
@@ -0,0 +1,28 @@
+name: lock-status
+description: "View current story lock status across the team. Shows who's working on what and identifies stale locks."
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_integration.repository.owner"
+ repo: "{config_source}:github_integration.repository.repo"
+
+# Lock settings
+locking:
+ stale_threshold_minutes: "{config_source}:github_integration.locking.stale_threshold_minutes"
+
+# Filter parameters
+user: "" # Optional: Filter by specific user
+epic: "" # Optional: Filter by epic number
+show_stale: true # Highlight stale locks
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md
index d92f71a8..e631f9c8 100644
--- a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md
+++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md
@@ -250,8 +250,19 @@ Before proceeding:
## CRITICAL STEP COMPLETION
-**ONLY WHEN** [commit created],
-load and execute `{nextStepFile}` for summary generation.
+**ONLY WHEN** [commit created]:
+
+1. **Check GitHub Integration:**
+ ```
+ IF github_integration.enabled == true:
+ load and execute `{workflow_path}/steps/step-06b-sync-github.md`
+ ELSE:
+ load and execute `{nextStepFile}` for summary generation
+ ```
+
+2. **Continue Pipeline:**
+ - If GitHub sync enabled: Create PR, update issue, then summary
+ - If GitHub sync disabled: Direct to summary generation
---
diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md
new file mode 100644
index 00000000..b912cae8
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md
@@ -0,0 +1,313 @@
+---
+name: 'step-06b-sync-github'
+description: 'Sync completion to GitHub - create PR, update issue, release lock'
+
+# Path Definitions
+workflow_path: '{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline'
+
+# File References
+thisStepFile: '{workflow_path}/steps/step-06b-sync-github.md'
+prevStepFile: '{workflow_path}/steps/step-06-complete.md'
+nextStepFile: '{workflow_path}/steps/step-07-summary.md'
+
+# GitHub Integration
+github_integration: "{config_source}:github_integration"
+
+# Role
+role: sm
+---
+
+# Step 6b: Sync to GitHub (Enterprise Integration)
+
+## CONDITIONAL EXECUTION
+
+**Only execute if GitHub integration is enabled:**
+
+```
+IF github_integration.enabled != true:
+ SKIP this step
+ GOTO step-07-summary
+```
+
+## STEP GOAL
+
+Close the development loop with GitHub:
+1. Create Pull Request linking to issue
+2. Update issue status to in-review
+3. Add completion comment to issue
+4. Release story lock (optional - keep until approved)
+
+## EXECUTION SEQUENCE
+
+### 1. Load GitHub Context
+
+```javascript
+// Load cache metadata
+cache_meta = load {{cache_dir}}/.bmad-cache-meta.json
+
+story_meta = cache_meta.stories[{{story_key}}]
+issue_number = story_meta.github_issue
+
+IF NOT issue_number:
+ output: "⚠️ Story not synced to GitHub - skipping PR creation"
+ output: "Run /migrate-to-github to sync, then create PR manually"
+ GOTO step-07-summary
+
+github_owner = {{github_integration.repository.owner}}
+github_repo = {{github_integration.repository.repo}}
+```
+
+### 2. Create Pull Request
+
+**Get current branch:**
+```bash
+current_branch=$(git rev-parse --abbrev-ref HEAD)
+echo "Current branch: $current_branch"
+```
+
+**Get commit information:**
+```bash
+commit_sha=$(git rev-parse HEAD)
+commit_msg=$(git log -1 --pretty=format:"%s")
+```
+
+**Create PR via GitHub MCP:**
+```javascript
+// Generate PR body
+pr_body = `
+## Story: ${story_key}
+
+Implements: #${issue_number}
+
+### Acceptance Criteria
+
+${format_acs_from_story(story_file)}
+
+### Implementation Summary
+
+${story.devAgentRecord?.summary || "See commit history for details."}
+
+### Changes
+
+${generate_file_list_from_story(story_file)}
+
+### Testing
+
+- [ ] All unit tests pass
+- [ ] Integration tests pass
+- [ ] Manual testing completed
+
+---
+Closes #${issue_number}
+`
+
+// Create PR
+pr = await mcp__github__create_pull_request({
+ owner: github_owner,
+ repo: github_repo,
+ title: `Story ${story_key}: ${story.title}`,
+ body: pr_body,
+ head: current_branch,
+ base: "main", // Or detected default branch
+ draft: false
+})
+
+pr_number = pr.number
+pr_url = pr.html_url
+
+output: "✅ PR #${pr_number} created"
+output: " URL: ${pr_url}"
+```
+
+**Handle PR creation failure:**
+```javascript
+CATCH (error) {
+ IF error.message.includes("already exists"):
+ // PR already exists - find it
+ existing = await mcp__github__search_pull_requests({
+ query: `repo:${github_owner}/${github_repo} head:${current_branch} is:open`
+ })
+ IF existing.items.length > 0:
+ pr = existing.items[0]
+ pr_number = pr.number
+ pr_url = pr.html_url
+ output: "ℹ️ Using existing PR #${pr_number}"
+ ELSE:
+ output: "⚠️ Could not create PR: ${error.message}"
+ output: " You can create it manually in GitHub"
+ // Continue without PR - not a blocker
+}
+```
+
+### 3. Update GitHub Issue
+
+**Add completion comment:**
+```javascript
+await mcp__github__add_issue_comment({
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issue_number,
+ body: `
+✅ **Implementation Complete**
+
+**Commit:** \`${commit_sha.substring(0, 7)}\`
+**Branch:** \`${current_branch}\`
+${pr_number ? `**PR:** #${pr_number}` : ''}
+
+---
+
+**Summary:**
+${story.devAgentRecord?.summary || commit_msg}
+
+**Files Changed:**
+${generate_file_list_markdown(story_file)}
+
+---
+_Completed by super-dev-pipeline at ${timestamp}_
+`
+})
+```
+
+**Update issue labels:**
+```javascript
+// Get current labels
+issue = await mcp__github__issue_read({
+ method: "get",
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issue_number
+})
+
+current_labels = issue.labels.map(l => l.name)
+
+// Update: remove in-progress, add in-review
+new_labels = current_labels
+ .filter(l => l != "status:in-progress")
+
+IF NOT new_labels.includes("status:in-review"):
+ new_labels.push("status:in-review")
+
+await mcp__github__issue_write({
+ method: "update",
+ owner: github_owner,
+ repo: github_repo,
+ issue_number: issue_number,
+ labels: new_labels
+})
+
+output: "✅ Issue #${issue_number} updated to in-review"
+```
+
+### 4. Update Cache Metadata
+
+```javascript
+// Update cache with PR link
+cache_meta.stories[story_key].pr_number = pr_number
+cache_meta.stories[story_key].pr_url = pr_url
+cache_meta.stories[story_key].completed_at = timestamp
+
+save_cache_meta(cache_meta)
+```
+
+### 5. Lock Decision
+
+**For batch mode:** Keep lock until PO approves
+```javascript
+IF batch_mode:
+ output: "ℹ️ Lock retained - will be released on PO approval"
+ // Lock stays with developer until /approve-story
+```
+
+**For interactive mode:** Offer choice
+```
+Story implementation complete.
+
+[K] Keep lock (you'll address review feedback)
+[R] Release lock (others can pick up review fixes)
+
+Choice:
+```
+
+```javascript
+IF choice == 'R':
+ await release_lock(story_key)
+ output: "✅ Lock released"
+ELSE:
+ output: "ℹ️ Lock retained until story approved"
+```
+
+### 6. Display Summary
+
+```
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+✅ GITHUB SYNC COMPLETE
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+**Story:** {{story_key}}
+**Issue:** #{{issue_number}} → status:in-review
+**PR:** #{{pr_number}}
+**URL:** {{pr_url}}
+
+**What Happens Next:**
+1. PO reviews PR in GitHub
+2. PO runs /approve-story to sign off
+3. PR merged, issue closed, lock released
+
+**View in GitHub:**
+- Issue: https://github.com/{{github_owner}}/{{github_repo}}/issues/{{issue_number}}
+- PR: {{pr_url}}
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+```
+
+### 7. Continue to Summary
+
+Load and execute `{nextStepFile}`.
+
+## ERROR HANDLING
+
+**Network failure:**
+```javascript
+TRY:
+ // All GitHub operations
+CATCH (network_error):
+ output: "⚠️ GitHub sync failed - network issue"
+ output: " Changes committed locally"
+ output: " Sync manually when network restored:"
+ output: " - Create PR: gh pr create"
+ output: " - Update issue: Use GitHub UI"
+ // Continue to summary - local commit is safe
+```
+
+**API rate limit:**
+```javascript
+IF error.status == 403 AND error.message.includes("rate limit"):
+ output: "⚠️ GitHub API rate limited"
+ output: " Wait a few minutes and run /sync-to-github"
+ // Continue - not a blocker
+```
+
+## QUALITY GATE
+
+Before proceeding:
+- [x] Commit created (from step-06)
+- [ ] PR created or existing PR found
+- [ ] Issue updated to in-review
+- [ ] Completion comment added
+- [ ] Cache metadata updated
+
+## SUCCESS METRICS
+
+### ✅ SUCCESS
+- PR links to issue with "Closes #N"
+- Issue status updated
+- Developer notified via issue comment
+- Cache reflects PR link
+
+### ⚠️ PARTIAL
+- PR created but issue not updated
+- Network issues (commit is safe)
+
+### ❌ FAILURE
+- Critical GitHub operation fails repeatedly
+- Should not block story completion (commit exists)
diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml
index ff5c28ad..45ff3274 100644
--- a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml
+++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml
@@ -10,6 +10,20 @@ sprint_artifacts: "{config_source}:sprint_artifacts"
communication_language: "{config_source}:communication_language"
date: system-generated
+# GitHub Enterprise Integration (optional - enables PR creation and issue sync)
+github_integration:
+ enabled: "{config_source}:github_integration_enabled"
+ repository:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+ cache:
+ dir: "{output_folder}/cache"
+ staleness_minutes: "{config_source}:github_cache_staleness_minutes"
+ sync:
+ create_pr: true # Create PR linking to GitHub Issue
+ update_issue_status: true # Update issue to in-review
+ add_completion_comment: true # Add implementation summary to issue
+
# Workflow paths
installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline"
steps_path: "{installed_path}/steps"
@@ -154,6 +168,14 @@ steps:
agent: sm
quality_gate: false
+ - step: "6b"
+ file: "{steps_path}/step-06b-sync-github.md"
+ name: "Sync GitHub"
+ description: "Create PR, update issue status, add completion comment"
+ agent: sm
+ quality_gate: false
+ conditional: "github_integration.enabled == true"
+
- step: 7
file: "{steps_path}/step-07-summary.md"
name: "Summary"
diff --git a/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md b/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md
new file mode 100644
index 00000000..e165f6cd
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md
@@ -0,0 +1,331 @@
+# Unlock Story - Release Story Lock
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+TEAM COORDINATION: Releasing locks makes stories available for others
+
+
+
+
+
+
+
+
+
+ HALT
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ current_user = response.login
+
+
+
+
+
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
+ })
+
+
+
+ HALT
+
+
+ issue = response.items[0]
+ issue_number = issue.number
+ current_assignee = issue.assignee?.login or null
+
+
+
+
+
+ Exit (already unlocked)
+
+
+
+
+ HALT
+
+
+
+ Verify current_user is in scrum_masters list
+
+
+
+ HALT
+
+
+
+
+ Set force_unlock = true
+ Set notify_owner = true
+
+
+
+
+ Set force_unlock = false
+
+
+
+
+
+
+
+
+ ATOMIC UNLOCK with retry:
+
+
+attempt = 0
+max_attempts = 4
+
+WHILE attempt < max_attempts:
+ TRY:
+ # 1. Remove assignee
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ assignees: []
+ })
+
+ # 2. Update status label back to ready-for-dev
+ # Get current labels first
+ current_labels = issue.labels.map(l => l.name)
+
+ # Remove in-progress, add ready-for-dev
+ new_labels = current_labels
+ .filter(l => l != "status:in-progress")
+
+ # Only add ready-for-dev if story wasn't completed
+ IF NOT current_labels.includes("status:done"):
+ new_labels.push("status:ready-for-dev")
+
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ labels: new_labels
+ })
+
+ # 3. Add unlock comment
+ comment_body = "🔓 **Story unlocked**\n\n"
+
+ IF force_unlock:
+ comment_body += "Unlocked by Scrum Master @{{current_user}}\n"
+ comment_body += "Previous owner: @{{current_assignee}}\n"
+ IF reason:
+ comment_body += "Reason: {{reason}}\n"
+ ELSE:
+ comment_body += "Released by @{{current_user}}\n"
+ IF reason:
+ comment_body += "Reason: {{reason}}\n"
+
+ comment_body += "\n_Story is now available for checkout._"
+
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}},
+ body: comment_body
+ })
+
+ # 4. Verify unlock
+ sleep 1 second
+
+ verification = Call: mcp__github__issue_read({
+ method: "get",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}}
+ })
+
+ IF verification.assignees.length > 0:
+ THROW "Unlock verification failed - still has assignees"
+
+ output: "✅ GitHub Issue unassigned and verified"
+ BREAK
+
+ CATCH error:
+ attempt++
+ IF attempt < max_attempts:
+ backoff = [1000, 3000, 9000][attempt - 1]
+ sleep backoff ms
+ output: "⚠️ Retry {{attempt}}/3: {{error}}"
+ ELSE:
+ output: "❌ FAILED to unlock after 3 retries: {{error}}"
+ output: ""
+ output: "The lock may still be active in GitHub."
+ output: "Try again or manually unassign in GitHub UI."
+ HALT
+
+
+
+
+ lock_file = {{lock_dir}}/{{story_key}}.lock
+
+
+ Delete lock_file
+
+
+
+
+
+
+
+
+
+ Update cache meta to clear lock:
+
+cache_meta = load {{cache_dir}}/.bmad-cache-meta.json
+
+IF cache_meta.stories[{{story_key}}]:
+ cache_meta.stories[{{story_key}}].locked_by = null
+ cache_meta.stories[{{story_key}}].locked_until = null
+
+save cache_meta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml
new file mode 100644
index 00000000..ef7a8a3c
--- /dev/null
+++ b/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml
@@ -0,0 +1,30 @@
+name: unlock-story
+description: "Release a story lock, making it available for other developers. Use after completing or abandoning a story."
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+lock_dir: "{project-root}/.bmad/locks"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_integration.repository.owner"
+ repo: "{config_source}:github_integration.repository.repo"
+
+# Permissions
+permissions:
+ scrum_masters: "{config_source}:github_integration.permissions.scrum_masters"
+
+# Workflow parameters
+story_key: "" # Required: Story to unlock (e.g., "2-5-auth")
+force: false # Scrum Master only: Force unlock of another user's story
+reason: "" # Optional: Reason for unlock (recommended for force unlocks)
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/unlock-story"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/approve-story/instructions.md b/src/modules/bmm/workflows/po/approve-story/instructions.md
new file mode 100644
index 00000000..18c4a1fb
--- /dev/null
+++ b/src/modules/bmm/workflows/po/approve-story/instructions.md
@@ -0,0 +1,246 @@
+# Approve Story - PO Sign-Off
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+PO WORKFLOW: Final approval closes issue and releases lock
+
+
+
+
+
+
+
+
+ HALT
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
+ })
+
+
+
+ HALT
+
+
+ issue = response.items[0]
+ status = extract_status(issue.labels)
+
+
+
+
+ Proceed anyway? [y/N]:
+
+ HALT
+
+
+
+ Search for linked PR:
+ Call: mcp__github__search_pull_requests({
+ query: "repo:{{github_owner}}/{{github_repo}} {{story_key}} OR closes:#{{issue.number}}"
+ })
+
+ pr = response.items[0] if exists
+
+
+
+
+
+
+**Have you verified the acceptance criteria are met?**
+
+[A] Approve - All ACs satisfied, story complete
+[R] Request Changes - Issues found, needs more work
+[D] Defer - Need more time to review
+[V] View Details - Show more information
+
+Choice:
+
+
+
+
+
+
+
+
+ Update labels: remove status:in-review, add status:done
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue.number}},
+ state: "closed",
+ state_reason: "completed",
+ labels: [update labels]
+ })
+
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue.number}},
+ body: "✅ **Story Approved by PO @{{current_user}}**\n\n" +
+ "All acceptance criteria verified.\n" +
+ "Story complete.\n\n" +
+ "_Approved at {{timestamp}}_"
+ })
+
+
+
+
+ Merge PR #{{pr.number}}? [Y/n]:
+
+
+ Call: mcp__github__merge_pull_request({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ pullNumber: {{pr.number}},
+ merge_method: "squash"
+ })
+
+
+
+
+
+
+ Unassign developer from issue
+ Clear local lock file if exists
+ Update cache metadata
+
+
+
+
+ Update development_status[{{story_key}}] = "done"
+
+
+
+
+
+
+
+
+ What changes are needed?
+ Store feedback
+
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue.number}},
+ body: "🔄 **Changes Requested by PO @{{current_user}}**\n\n" +
+ "{{feedback}}\n\n" +
+ "Please address and update for re-review.\n\n" +
+ "_Feedback at {{timestamp}}_"
+ })
+
+ Update label: status:in-review → status:in-progress
+
+
+
+
+
+
+
+
+
+ Show full issue body and all comments
+ Goto step 2 for another choice
+
+
+
+
diff --git a/src/modules/bmm/workflows/po/approve-story/workflow.yaml b/src/modules/bmm/workflows/po/approve-story/workflow.yaml
new file mode 100644
index 00000000..4ccf503a
--- /dev/null
+++ b/src/modules/bmm/workflows/po/approve-story/workflow.yaml
@@ -0,0 +1,21 @@
+name: approve-story
+description: "Product Owner sign-off on completed story - verifies ACs and closes the loop"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+implementation_artifacts: "{config_source}:implementation_artifacts"
+sprint_status: "{implementation_artifacts}/sprint-status.yaml"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+story_key: "" # Required: Story to approve
+
+installed_path: "{project-root}/_bmad/bmm/workflows/po/approve-story"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/dashboard/instructions.md b/src/modules/bmm/workflows/po/dashboard/instructions.md
new file mode 100644
index 00000000..e87bf3de
--- /dev/null
+++ b/src/modules/bmm/workflows/po/dashboard/instructions.md
@@ -0,0 +1,190 @@
+# Dashboard - Sprint Progress Overview
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+PO WORKFLOW: Real-time visibility into sprint progress from GitHub
+
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+
+
+
+
+
+
+ Fetch backlog stories:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:backlog{{#if epic}} label:epic:{{epic}}{{/if}}"
+ })
+ backlog_stories = response.items
+
+ Fetch ready-for-dev stories:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:ready-for-dev{{#if epic}} label:epic:{{epic}}{{/if}}"
+ })
+ ready_stories = response.items
+
+ Fetch in-progress stories:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-progress{{#if epic}} label:epic:{{epic}}{{/if}}"
+ })
+ in_progress_stories = response.items
+
+ Fetch in-review stories:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-review{{#if epic}} label:epic:{{epic}}{{/if}}"
+ })
+ review_stories = response.items
+
+ Fetch done stories:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:done{{#if epic}} label:epic:{{epic}}{{/if}}"
+ })
+ done_stories = response.items
+
+
+
+ total_stories = all stories count
+ completed_count = done_stories.length
+ completion_pct = (completed_count / total_stories) * 100
+ active_developers = unique assignees from in_progress_stories
+ blocked_count = count stories with label:blocked
+
+
+
+ For each in_progress story:
+ - Get latest comment matching "Task X/Y complete"
+ - Extract progress percentage
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/po/dashboard/workflow.yaml b/src/modules/bmm/workflows/po/dashboard/workflow.yaml
new file mode 100644
index 00000000..b39ed6bf
--- /dev/null
+++ b/src/modules/bmm/workflows/po/dashboard/workflow.yaml
@@ -0,0 +1,22 @@
+name: dashboard
+description: "View sprint progress dashboard with real-time GitHub data - story status, developer assignments, and blockers"
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Filter parameters
+epic: "" # Optional: Filter by specific epic
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/po/dashboard"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/epic-dashboard/instructions.md b/src/modules/bmm/workflows/po/epic-dashboard/instructions.md
new file mode 100644
index 00000000..1e4cf3e6
--- /dev/null
+++ b/src/modules/bmm/workflows/po/epic-dashboard/instructions.md
@@ -0,0 +1,294 @@
+# Epic Dashboard - Enterprise Progress Visibility
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+
+
+
+ Query all epics:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic is:open"
+ })
+
+
+
+ Query specific epic:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_key}}"
+ })
+
+
+ epics = response.items
+
+
+
+ HALT
+
+
+
+
+ For each epic, fetch stories:
+
+
+
+for epic in epics:
+ epic_label = extract_epic_key(epic) # e.g., "epic:2"
+
+ # Fetch all stories for this epic
+ stories_response = await mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:{{epic_label}}"
+ })
+
+ epic.stories = stories_response.items
+
+ # Calculate metrics
+ epic.metrics = {
+ total: epic.stories.length,
+ done: count_by_label(epic.stories, "status:done"),
+ in_review: count_by_label(epic.stories, "status:in-review"),
+ in_progress: count_by_label(epic.stories, "status:in-progress"),
+ backlog: count_by_label(epic.stories, "status:backlog"),
+ blocked: count_by_label(epic.stories, "priority:blocked")
+ }
+
+ epic.metrics.progress = (epic.metrics.done / epic.metrics.total * 100).toFixed(0) + "%"
+ epic.metrics.active_work = epic.metrics.in_progress + epic.metrics.in_review
+
+
+
+
+
+for epic in epics:
+ epic.risks = []
+
+ # Check for stale in-progress stories (no update in 24h)
+ for story in epic.stories:
+ if has_label(story, "status:in-progress"):
+ hours_since_update = calculate_hours_since(story.updated_at)
+ if hours_since_update > 24:
+ epic.risks.push({
+ story: story,
+ risk: "stale",
+ message: "No activity for " + hours_since_update + "h"
+ })
+
+ # Check for blocked stories
+ for story in epic.stories:
+ if has_label(story, "priority:blocked"):
+ epic.risks.push({
+ story: story,
+ risk: "blocked",
+ message: "Story blocked - needs attention"
+ })
+
+ # Check for stories in review too long (>48h)
+ for story in epic.stories:
+ if has_label(story, "status:in-review"):
+ hours_since_update = calculate_hours_since(story.updated_at)
+ if hours_since_update > 48:
+ epic.risks.push({
+ story: story,
+ risk: "review-delayed",
+ message: "In review for " + hours_since_update + "h"
+ })
+
+
+
+
+
+
+
+
+
+
+
+
+for epic in epics:
+ # Get closed stories with timestamps
+ closed_stories = filter(epic.stories, has_label("status:done"))
+
+ # Group by completion date
+ completion_by_date = {}
+ for story in closed_stories:
+ date = format_date(story.closed_at)
+ completion_by_date[date] = (completion_by_date[date] || 0) + 1
+
+ epic.burndown = {
+ total_scope: epic.metrics.total,
+ completed: epic.metrics.done,
+ remaining: epic.metrics.total - epic.metrics.done,
+ completion_history: completion_by_date
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choice:
+
+
+ Enter epic key (e.g., 2):
+ Set epic_key = input
+ Goto step 1 (refetch with filter)
+
+
+
+ Toggle show_details
+ Goto step 3
+
+
+
+ Toggle show_burndown
+ Goto step 3
+
+
+
+ Goto step 1 (refresh)
+
+
+
+
+ Exit
+
+
+
+
+
+## Helper Functions
+
+```javascript
+// Extract epic key from issue labels
+function extract_epic_key(epic) {
+ for (label of epic.labels) {
+ if (label.name.startsWith("epic:")) {
+ return label.name.replace("epic:", "")
+ }
+ }
+ return epic.number.toString()
+}
+
+// Count stories with specific label
+function count_by_label(stories, label_name) {
+ return stories.filter(s =>
+ s.labels.some(l => l.name === label_name)
+ ).length
+}
+
+// Check if story has label
+function has_label(story, label_name) {
+ return story.labels.some(l => l.name === label_name)
+}
+
+// Calculate hours since timestamp
+function calculate_hours_since(timestamp) {
+ const diff = Date.now() - new Date(timestamp).getTime()
+ return Math.floor(diff / (1000 * 60 * 60))
+}
+
+// Generate ASCII progress bar
+function generate_progress_bar(percent, width = 20) {
+ const filled = Math.floor(percent * width / 100)
+ const empty = width - filled
+ return '█'.repeat(filled) + '░'.repeat(empty)
+}
+```
diff --git a/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml
new file mode 100644
index 00000000..184d75a9
--- /dev/null
+++ b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml
@@ -0,0 +1,25 @@
+name: epic-dashboard
+description: "View epic-level progress with drill-down to stories - comprehensive visibility for POs"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Optional: Filter to specific epic
+epic_key: "" # e.g., "2" for epic 2, empty for all epics
+
+# Display options
+show_details: false # Show individual story details
+show_burndown: true # Show epic burndown metrics
+show_risks: true # Highlight at-risk stories
+
+installed_path: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/new-story/instructions.md b/src/modules/bmm/workflows/po/new-story/instructions.md
new file mode 100644
index 00000000..a1ed89e3
--- /dev/null
+++ b/src/modules/bmm/workflows/po/new-story/instructions.md
@@ -0,0 +1,381 @@
+# New Story - Create Story in GitHub Issues
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+PO WORKFLOW: Creates stories directly in GitHub as source of truth
+
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ current_user = response.login
+
+
+
+
+ Search for existing epics/milestones:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:type:epic"
+ })
+
+ existing_epics = response.items.map(e => extract epic number from labels)
+
+
+
+
+
+
+
+
+
+**Which epic does this story belong to?**
+
+{{#each existing_epics}}
+[{{number}}] Epic {{number}}: {{title}}
+{{/each}}
+[N] New epic (will create)
+
+Enter epic number:
+
+
+ Store {{epic_number}}
+
+
+**What is the story title?**
+
+Keep it concise and descriptive.
+Example: "User password reset via email"
+
+Title:
+
+
+ Store {{story_title}}
+
+
+**Write the user story:**
+
+Format: "As a [role], I want [capability], so that [benefit]"
+
+Example: "As a user, I want to reset my password via email, so that I can regain access to my account when I forget my credentials."
+
+User Story:
+
+
+ Store {{user_story}}
+
+
+**Provide business context:**
+
+Why is this story important? What problem does it solve?
+Include any relevant background that helps developers understand the need.
+
+Business Context:
+
+
+ Store {{business_context}}
+
+
+
+
+
+
+**Define acceptance criteria using BDD format:**
+
+Each AC should follow:
+- **Given** [context/precondition]
+- **When** [action/trigger]
+- **Then** [expected outcome]
+
+Example:
+```
+AC1: Password reset email sent
+- Given: User exists and has verified email
+- When: User clicks "Forgot Password" and enters email
+- Then: Reset email sent within 30 seconds with valid link
+
+AC2: Reset link expires
+- Given: Reset link was generated
+- When: More than 1 hour has passed
+- Then: Link shows expiry message, user must request new link
+```
+
+Enter your acceptance criteria (can enter multiple):
+
+
+ Store {{acceptance_criteria}}
+
+ Parse and validate ACs have Given/When/Then structure
+
+
+
+ Suggest BDD-formatted version of provided ACs
+ Use this restructured version? [Y/n]
+
+
+
+
+
+
+
+**What is the complexity of this story?**
+
+[1] Micro - 1-2 tasks, <1 hour (simple bug fix, text change)
+[2] Small - 3-5 tasks, 1-4 hours (single feature, limited scope)
+[3] Medium - 6-10 tasks, 4-8 hours (multiple components, integration)
+[4] Large - 11-15 tasks, 1-2 days (significant feature, cross-cutting)
+[5] Epic-sized - Should be broken into smaller stories
+
+Complexity [1-5]:
+
+
+ Map to complexity label:
+ 1 → complexity:micro
+ 2 → complexity:small
+ 3 → complexity:medium
+ 4 → complexity:large
+ 5 → "Story is too large, should be split"
+
+
+
+
+ Continue anyway [C] or Break down [B]?
+
+
+ Help user identify sub-stories
+ HALT - Create sub-stories instead
+
+
+
+
+
+
+ Search for existing stories in this epic:
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story"
+ })
+
+ Extract story numbers from labels (e.g., story:2-5-auth → 5)
+ next_story_number = max(story_numbers) + 1 OR 1 if no stories
+
+
+
+ Create slug from title:
+ slug = story_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
+ story_key = "{{epic_number}}-{{next_story_number}}-{{slug}}"
+
+
+
+
+
+
+
+
+
+ Format issue body:
+
+issue_body = """
+**Story Key:** `{{story_key}}`
+**Epic:** {{epic_number}}
+**Complexity:** {{complexity_label}}
+
+## User Story
+{{user_story}}
+
+## Business Context
+{{business_context}}
+
+## Acceptance Criteria
+
+{{#each acceptance_criteria}}
+### AC{{@index + 1}}: {{title}}
+- [ ] **Given:** {{given}}
+- [ ] **When:** {{when}}
+- [ ] **Then:** {{then}}
+
+{{/each}}
+
+## Tasks
+_Tasks will be generated by developer during checkout_
+
+## Definition of Done
+- [ ] All acceptance criteria verified
+- [ ] Unit tests written and passing
+- [ ] Integration tests where applicable
+- [ ] Code reviewed
+- [ ] Documentation updated if needed
+
+---
+_Created via BMAD PO workflow_
+_Story file: `{{story_key}}.md`_
+"""
+
+
+
+
+ Create GitHub Issue:
+
+labels = [
+ "type:story",
+ "story:{{story_key}}",
+ "epic:{{epic_number}}",
+ "status:backlog",
+ "{{complexity_label}}"
+]
+
+Call: mcp__github__issue_write({
+ method: "create",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ title: "Story {{story_key}}: {{story_title}}",
+ body: issue_body,
+ labels: labels
+})
+
+
+ issue_number = response.number
+ issue_url = response.html_url
+
+
+
+ Wait 1 second for GitHub eventual consistency
+ Call: mcp__github__issue_read({
+ method: "get",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue_number}}
+ })
+
+
+
+ HALT
+
+
+
+
+
+
+
+
+ Create cache file: {{cache_dir}}/stories/{{story_key}}.md
+ Content = converted issue body to BMAD story format
+
+
+
+
+ Update {{cache_dir}}/.bmad-cache-meta.json:
+
+meta.stories[{{story_key}}] = {
+ github_issue: {{issue_number}},
+ github_updated_at: now(),
+ cache_timestamp: now(),
+ locked_by: null,
+ locked_until: null
+}
+
+
+
+
+
+ Add story to sprint-status.yaml:
+
+development_status:
+ {{story_key}}: backlog # New story
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/po/new-story/workflow.yaml b/src/modules/bmm/workflows/po/new-story/workflow.yaml
new file mode 100644
index 00000000..5651a0ae
--- /dev/null
+++ b/src/modules/bmm/workflows/po/new-story/workflow.yaml
@@ -0,0 +1,25 @@
+name: new-story
+description: "Create a new story in GitHub Issues with proper BMAD format, labels, and epic assignment"
+author: "BMad"
+version: "1.0.0"
+
+# Critical variables from config
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+implementation_artifacts: "{config_source}:implementation_artifacts"
+sprint_status: "{implementation_artifacts}/sprint-status.yaml"
+
+# GitHub configuration
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Workflow components
+installed_path: "{project-root}/_bmad/bmm/workflows/po/new-story"
+instructions: "{installed_path}/instructions.md"
+
+# Story template reference
+story_template: "{project-root}/_bmad/bmm/data/story-template.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/sync-from-github/instructions.md b/src/modules/bmm/workflows/po/sync-from-github/instructions.md
new file mode 100644
index 00000000..d63d969d
--- /dev/null
+++ b/src/modules/bmm/workflows/po/sync-from-github/instructions.md
@@ -0,0 +1,149 @@
+# Sync from GitHub - Update Local Cache
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+
+
+
+
+
+
+ Call: mcp__github__get_me()
+
+
+
+ HALT
+
+
+ Load cache metadata from {{cache_dir}}/.bmad-cache-meta.json
+ last_sync = cache_meta.last_sync
+
+
+
+
+
+
+
+ Query all stories
+
+
+
+
+ Query stories updated since last_sync
+
+
+
+
+ Query all stories (first time sync)
+
+
+
+ Build query:
+
+query = "repo:{{github_owner}}/{{github_repo}} label:type:story"
+
+IF last_sync AND NOT full_sync:
+ query += " updated:>={{last_sync_date}}"
+
+IF epic:
+ query += " label:epic:{{epic}}"
+
+
+ Call: mcp__github__search_issues({ query: query })
+ updated_stories = response.items
+
+
+
+
+
+
+
+
+ Update last_sync timestamp
+ Exit
+
+
+
+
+ For each story in updated_stories:
+
+
+
+for issue in updated_stories:
+ story_key = extract_story_key(issue)
+
+ IF NOT story_key:
+ output: "⚠️ Skipping issue #{{issue.number}} - no story key"
+ CONTINUE
+
+ # Convert issue to story content
+ story_content = convert_issue_to_story(issue)
+
+ # Write to cache
+ cache_path = {{cache_dir}}/stories/{{story_key}}.md
+ write_file(cache_path, story_content)
+
+ # Update metadata
+ cache_meta.stories[story_key] = {
+ github_issue: issue.number,
+ github_updated_at: issue.updated_at,
+ cache_timestamp: now(),
+ locked_by: issue.assignee?.login,
+ locked_until: calculate_lock_expiry() if issue.assignee
+ }
+
+ output: "✅ {{story_key}} synced (Issue #{{issue.number}})"
+
+
+
+
+ cache_meta.last_sync = now()
+ Save cache_meta to {{cache_dir}}/.bmad-cache-meta.json
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml b/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml
new file mode 100644
index 00000000..cfaa1ce5
--- /dev/null
+++ b/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml
@@ -0,0 +1,21 @@
+name: sync-from-github
+description: "Pull latest changes from GitHub Issues to local cache - ensures local data is current"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+# Sync options
+full_sync: false # Force full sync instead of incremental
+epic: "" # Optional: Only sync specific epic
+
+installed_path: "{project-root}/_bmad/bmm/workflows/po/sync-from-github"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/src/modules/bmm/workflows/po/update-story/instructions.md b/src/modules/bmm/workflows/po/update-story/instructions.md
new file mode 100644
index 00000000..cc2d37d5
--- /dev/null
+++ b/src/modules/bmm/workflows/po/update-story/instructions.md
@@ -0,0 +1,214 @@
+# Update Story - Modify Story in GitHub
+
+The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml
+You MUST have already loaded and processed: {installed_path}/workflow.yaml
+PO WORKFLOW: Updates notify developers if story is in progress
+
+
+
+
+
+
+
+
+ HALT
+
+
+ Call: mcp__github__get_me()
+ current_user = response.login
+
+
+
+ Call: mcp__github__search_issues({
+ query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
+ })
+
+
+
+ HALT
+
+
+ issue = response.items[0]
+ is_locked = issue.assignee != null
+
+
+
+
+
+
+
+
+
+
+**What would you like to update?**
+
+[1] Acceptance Criteria - Add, modify, or remove ACs
+[2] Title - Change story title
+[3] Business Context - Update background/requirements
+[4] Status - Change status label
+[5] Priority/Complexity - Update sizing
+[6] Custom - Make any other changes
+
+Enter choice [1-6]:
+
+
+
+
+ Display current acceptance criteria from issue body
+
+
+How would you like to modify ACs?
+
+[A] Add new AC
+[M] Modify existing AC (specify number)
+[R] Remove AC (specify number)
+[W] Rewrite all ACs
+
+Choice:
+
+
+ Collect AC changes based on choice
+
+
+
+ New title:
+ Store new_title
+
+
+
+ Updated business context:
+ Store new_context
+
+
+
+
+New status:
+
+[1] backlog
+[2] ready-for-dev
+[3] in-progress (not recommended - use /checkout-story)
+[4] in-review
+[5] done (not recommended - use /approve-story)
+
+Choice:
+
+ Store new_status
+
+
+
+
+New complexity:
+
+[1] micro
+[2] small
+[3] medium
+[4] large
+
+Choice:
+
+ Store new_complexity
+
+
+
+ Describe the changes you want to make:
+ Parse and apply custom changes
+
+
+
+
+
+
+
+ Build updated issue data based on changes
+
+ Call: mcp__github__issue_write({
+ method: "update",
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue.number}},
+ title: {{new_title or issue.title}},
+ body: {{updated_body}},
+ labels: {{updated_labels}}
+ })
+
+
+
+
+
+
+ Call: mcp__github__add_issue_comment({
+ owner: {{github_owner}},
+ repo: {{github_repo}},
+ issue_number: {{issue.number}},
+ body: "📢 **Story Updated by PO @{{current_user}}**\n\n" +
+ "**Changes:**\n{{change_summary}}\n\n" +
+ "@{{issue.assignee.login}} - Please review these updates.\n\n" +
+ "_Updated at {{timestamp}}_"
+ })
+
+
+
+
+
+
+ Invalidate cache for {{story_key}}
+ Re-sync story to cache
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/bmm/workflows/po/update-story/workflow.yaml b/src/modules/bmm/workflows/po/update-story/workflow.yaml
new file mode 100644
index 00000000..912def3a
--- /dev/null
+++ b/src/modules/bmm/workflows/po/update-story/workflow.yaml
@@ -0,0 +1,19 @@
+name: update-story
+description: "Update story ACs, requirements, or details in GitHub Issues with change notification"
+author: "BMad"
+version: "1.0.0"
+
+config_source: "{project-root}/_bmad/bmm/config.yaml"
+output_folder: "{config_source}:output_folder"
+cache_dir: "{output_folder}/cache"
+
+github:
+ owner: "{config_source}:github_owner"
+ repo: "{config_source}:github_repo"
+
+story_key: "" # Required: Story to update
+
+installed_path: "{project-root}/_bmad/bmm/workflows/po/update-story"
+instructions: "{installed_path}/instructions.md"
+
+standalone: true
diff --git a/test/README.md b/test/README.md
index da2f2962..673dddbd 100644
--- a/test/README.md
+++ b/test/README.md
@@ -1,10 +1,35 @@
-# Agent Schema Validation Test Suite
+# BMAD Test Suite
-Comprehensive test coverage for the BMAD agent schema validation system.
+Comprehensive test coverage for the BMAD framework including schema validation and module unit tests.
## Overview
-This test suite validates the Zod-based schema validator (`tools/schema/agent.js`) that ensures all `*.agent.yaml` files conform to the BMAD agent specification.
+This test suite includes:
+- **Agent Schema Validation** - Validates `*.agent.yaml` files conform to specification
+- **Crowdsource Library Tests** - FeedbackManager, SynthesisEngine, SignoffManager
+- **Notification Service Tests** - Multi-channel notifications (GitHub, Slack, Email)
+- **Cache Manager Tests** - PRD/Epic extensions for crowdsourcing
+
+## Quick Start
+
+```bash
+# Run all tests
+npm test
+
+# Run with coverage report
+npm run test:coverage
+
+# Run specific test suites
+npx vitest run test/unit/crowdsource/
+npx vitest run test/unit/notifications/
+npx vitest run test/unit/cache/
+```
+
+---
+
+# Agent Schema Validation
+
+Validates the Zod-based schema validator (`tools/schema/agent.js`) that ensures all `*.agent.yaml` files conform to the BMAD agent specification.
## Test Statistics
@@ -293,3 +318,139 @@ All success criteria from the original task have been exceeded:
- **Validator Implementation**: `tools/schema/agent.js`
- **CLI Tool**: `tools/validate-agent-schema.js`
- **Project Guidelines**: `CLAUDE.md`
+
+---
+
+# Module Unit Tests
+
+Unit tests for BMAD library modules using Vitest.
+
+## Test Organization
+
+```
+test/unit/
+├── crowdsource/ # PRD/Epic crowdsourcing
+│ ├── feedback-manager.test.js # Feedback creation, querying, conflict detection
+│ ├── synthesis-engine.test.js # LLM synthesis, theme extraction
+│ └── signoff-manager.test.js # Sign-off thresholds, approval tracking
+├── notifications/ # Multi-channel notifications
+│ ├── github-notifier.test.js # GitHub @mentions, issue comments
+│ ├── slack-notifier.test.js # Slack webhook integration
+│ ├── email-notifier.test.js # SMTP, SendGrid, SES providers
+│ └── notification-service.test.js # Orchestration, retry logic
+├── cache/ # Cache management
+│ └── cache-manager-prd-epic.test.js # PRD/Epic extensions
+├── config/ # Configuration tests
+├── core/ # Core functionality
+├── file-ops/ # File operations
+├── transformations/ # Data transformations
+└── utils/ # Utility functions
+```
+
+## Crowdsource Tests
+
+Tests for async stakeholder collaboration on PRDs and Epics:
+
+### FeedbackManager
+- Feedback types and status constants
+- Creating feedback issues with proper labels
+- Querying feedback by section/type/status
+- Conflict detection between stakeholders
+- Statistics aggregation
+
+### SynthesisEngine
+- LLM prompt templates for PRD/Epic synthesis
+- Theme and keyword extraction
+- Conflict identification and resolution
+- Story split prompts for epics
+
+### SignoffManager
+- Three threshold types: count, percentage, required_approvers
+- Approval progress tracking
+- Blocking behavior
+- Deadline management
+
+## Notification Tests
+
+Tests for multi-channel notification delivery:
+
+### Notifiers
+- **GitHub**: Template rendering, issue comments, @mentions
+- **Slack**: Webhook payloads, Block Kit templates, dynamic colors
+- **Email**: HTML/text templates, provider abstraction
+
+### NotificationService
+- Channel orchestration (send to GitHub + Slack + Email)
+- Priority-based behavior (urgent = retry on failure)
+- Convenience methods for all event types
+- Graceful degradation when channels disabled
+
+## Cache Tests
+
+Tests for PRD/Epic extensions to cache-manager:
+
+- Read/write PRD and Epic documents
+- Metadata migration (v1 → v2)
+- Status filtering and updates
+- User task queries (`getMyTasks`, `getPrdsNeedingAttention`)
+- Extended statistics with PRD/Epic counts
+- Atomic file operations
+
+## Running Unit Tests
+
+```bash
+# All unit tests
+npx vitest run test/unit/
+
+# With watch mode
+npx vitest test/unit/
+
+# Specific module
+npx vitest run test/unit/crowdsource/
+npx vitest run test/unit/notifications/
+
+# With coverage
+npx vitest run test/unit/ --coverage
+```
+
+## Test Patterns
+
+### Testable Subclass Pattern
+
+For classes with GitHub MCP dependencies, tests use subclasses that allow mock injection:
+
+```javascript
+class TestableFeedbackManager extends FeedbackManager {
+ constructor(config, mocks = {}) {
+ super(config);
+ this.mocks = mocks;
+ }
+
+ async _createIssue(params) {
+ if (this.mocks.createIssue) return this.mocks.createIssue(params);
+ throw new Error('Mock not provided');
+ }
+}
+```
+
+### Global Fetch Mocking
+
+For Slack/Email tests that use `fetch`:
+
+```javascript
+global.fetch = vi.fn();
+global.fetch.mockResolvedValue({ ok: true });
+```
+
+### Module Mocking
+
+For NotificationService orchestration tests:
+
+```javascript
+vi.mock('../path/to/github-notifier.js', () => ({
+ GitHubNotifier: vi.fn().mockImplementation(() => ({
+ isEnabled: () => true,
+ send: vi.fn().mockResolvedValue({ success: true })
+ }))
+}));
+```
diff --git a/test/unit/cache/cache-manager-prd-epic.test.js b/test/unit/cache/cache-manager-prd-epic.test.js
new file mode 100644
index 00000000..ec015ea7
--- /dev/null
+++ b/test/unit/cache/cache-manager-prd-epic.test.js
@@ -0,0 +1,699 @@
+/**
+ * Tests for CacheManager PRD and Epic Extensions
+ *
+ * Tests cover:
+ * - PRD read/write operations
+ * - Epic read/write operations with PRD lineage
+ * - Status updates and filtering
+ * - User task queries (getPrdsNeedingAttention, getEpicsNeedingAttention)
+ * - Extended statistics
+ * - Document staleness checking
+ * - Atomic file operations
+ *
+ * Uses real temporary directory for testing actual file I/O operations.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+// Import the CacheManager (CommonJS module)
+const { CacheManager, DOCUMENT_TYPES, CACHE_META_FILENAME } = await import(
+ '../../../src/modules/bmm/lib/cache/cache-manager.js'
+);
+
+describe('CacheManager PRD/Epic Extensions', () => {
+ let cacheManager;
+ let testCacheDir;
+
+ beforeEach(() => {
+ // Create a real temporary directory for each test
+ testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-cache-test-'));
+
+ cacheManager = new CacheManager({
+ cacheDir: testCacheDir,
+ stalenessThresholdMinutes: 5,
+ github: { owner: 'test-org', repo: 'test-repo' }
+ });
+ });
+
+ afterEach(() => {
+ // Clean up the temporary directory
+ if (testCacheDir && fs.existsSync(testCacheDir)) {
+ fs.rmSync(testCacheDir, { recursive: true, force: true });
+ }
+ });
+
+ // ============ DOCUMENT_TYPES Tests ============
+
+ describe('DOCUMENT_TYPES', () => {
+ it('should define all document types', () => {
+ expect(DOCUMENT_TYPES.story).toBe('story');
+ expect(DOCUMENT_TYPES.prd).toBe('prd');
+ expect(DOCUMENT_TYPES.epic).toBe('epic');
+ });
+ });
+
+ // ============ Directory Initialization Tests ============
+
+ describe('directory initialization', () => {
+ it('should create subdirectories for all document types', () => {
+ expect(fs.existsSync(path.join(testCacheDir, 'stories'))).toBe(true);
+ expect(fs.existsSync(path.join(testCacheDir, 'prds'))).toBe(true);
+ expect(fs.existsSync(path.join(testCacheDir, 'epics'))).toBe(true);
+ });
+
+ it('should create meta file on first access', () => {
+ cacheManager.loadMeta();
+ expect(fs.existsSync(path.join(testCacheDir, CACHE_META_FILENAME))).toBe(true);
+ });
+ });
+
+ // ============ Metadata Migration Tests ============
+
+ describe('metadata migration', () => {
+ it('should migrate v1 metadata to v2', () => {
+ // Write v1 metadata directly
+ const v1Meta = {
+ version: '1.0.0',
+ stories: { 'story-1': { github_issue: 10 } }
+ };
+ fs.writeFileSync(
+ path.join(testCacheDir, CACHE_META_FILENAME),
+ JSON.stringify(v1Meta),
+ 'utf8'
+ );
+
+ // Create new manager to trigger migration
+ const manager = new CacheManager({
+ cacheDir: testCacheDir,
+ github: {}
+ });
+
+ const meta = manager.loadMeta();
+
+ expect(meta.version).toBe('2.0.0');
+ expect(meta.prds).toBeDefined();
+ expect(meta.epics).toBeDefined();
+ expect(meta.stories).toEqual({ 'story-1': { github_issue: 10 } });
+ });
+
+ it('should not migrate already v2 metadata', () => {
+ // Write v2 metadata directly
+ const v2Meta = {
+ version: '2.0.0',
+ prds: { 'existing-prd': { status: 'approved' } },
+ epics: { 'existing-epic': { status: 'draft' } },
+ stories: {}
+ };
+ fs.writeFileSync(
+ path.join(testCacheDir, CACHE_META_FILENAME),
+ JSON.stringify(v2Meta),
+ 'utf8'
+ );
+
+ const manager = new CacheManager({
+ cacheDir: testCacheDir,
+ github: {}
+ });
+
+ const meta = manager.loadMeta();
+
+ expect(meta.prds['existing-prd'].status).toBe('approved');
+ expect(meta.epics['existing-epic'].status).toBe('draft');
+ });
+ });
+
+ // ============ PRD Methods Tests ============
+
+ describe('PRD operations', () => {
+ describe('getPrdPath', () => {
+ it('should return correct path for PRD', () => {
+ const prdPath = cacheManager.getPrdPath('user-auth');
+ expect(prdPath).toBe(path.join(testCacheDir, 'prds', 'user-auth.md'));
+ });
+ });
+
+ describe('writePrd', () => {
+ it('should write PRD content and update metadata', () => {
+ const content = '# PRD: User Authentication\n\nThis is the content.';
+ const prdMeta = {
+ review_issue: 100,
+ version: 1,
+ status: 'draft',
+ stakeholders: ['@alice', '@bob'],
+ owner: '@sarah'
+ };
+
+ const result = cacheManager.writePrd('user-auth', content, prdMeta);
+
+ expect(result.prdKey).toBe('user-auth');
+ expect(result.hash).toBeDefined();
+ expect(result.hash.length).toBe(64); // SHA-256 hex
+
+ // Verify file was written
+ const prdPath = cacheManager.getPrdPath('user-auth');
+ expect(fs.existsSync(prdPath)).toBe(true);
+ expect(fs.readFileSync(prdPath, 'utf8')).toBe(content);
+
+ // Verify metadata was updated
+ const meta = cacheManager.loadMeta();
+ expect(meta.prds['user-auth'].status).toBe('draft');
+ expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice', '@bob']);
+ });
+
+ it('should preserve existing metadata when not provided', () => {
+ // First write with full metadata
+ cacheManager.writePrd('user-auth', 'Content v1', {
+ review_issue: 100,
+ version: 2,
+ status: 'feedback',
+ stakeholders: ['@alice']
+ });
+
+ // Write with partial metadata
+ cacheManager.writePrd('user-auth', 'Content v2', { version: 3 });
+
+ // Verify metadata was merged
+ const meta = cacheManager.loadMeta();
+ expect(meta.prds['user-auth'].version).toBe(3);
+ expect(meta.prds['user-auth'].review_issue).toBe(100);
+ expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice']);
+ });
+ });
+
+ describe('readPrd', () => {
+ it('should return null for non-existent PRD', () => {
+ const result = cacheManager.readPrd('non-existent');
+ expect(result).toBeNull();
+ });
+
+ it('should return PRD content with metadata', () => {
+ const content = '# PRD: User Auth';
+ cacheManager.writePrd('user-auth', content, {
+ version: 1,
+ status: 'draft'
+ });
+
+ const result = cacheManager.readPrd('user-auth');
+
+ expect(result.content).toBe(content);
+ expect(result.meta.version).toBe(1);
+ expect(result.isStale).toBe(false);
+ });
+
+ it('should mark stale PRDs with warning', () => {
+ // Write PRD first
+ cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' });
+
+ // Manually set old timestamp
+ const meta = cacheManager.loadMeta();
+ meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z';
+ cacheManager.saveMeta(meta);
+
+ const result = cacheManager.readPrd('user-auth');
+
+ expect(result.isStale).toBe(true);
+ expect(result.warning).toContain('stale');
+ });
+
+ it('should ignore staleness when option set', () => {
+ // Write PRD first
+ cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' });
+
+ // Manually set old timestamp
+ const meta = cacheManager.loadMeta();
+ meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z';
+ cacheManager.saveMeta(meta);
+
+ const result = cacheManager.readPrd('user-auth', { ignoreStale: true });
+
+ expect(result.isStale).toBe(true);
+ expect(result.warning).toBeUndefined();
+ });
+ });
+
+ describe('updatePrdStatus', () => {
+ it('should update PRD status', () => {
+ cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' });
+
+ cacheManager.updatePrdStatus('user-auth', 'feedback');
+
+ const meta = cacheManager.loadMeta();
+ expect(meta.prds['user-auth'].status).toBe('feedback');
+ });
+
+ it('should throw error for non-existent PRD', () => {
+ expect(() => {
+ cacheManager.updatePrdStatus('non-existent', 'feedback');
+ }).toThrow('PRD not found in cache: non-existent');
+ });
+ });
+
+ describe('listCachedPrds', () => {
+ it('should return all cached PRD keys', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', { status: 'draft' });
+ cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
+ cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
+
+ const prds = cacheManager.listCachedPrds();
+
+ expect(prds).toContain('user-auth');
+ expect(prds).toContain('payments');
+ expect(prds).toContain('mobile');
+ expect(prds.length).toBe(3);
+ });
+ });
+
+ describe('getPrdsByStatus', () => {
+ it('should filter PRDs by status', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' });
+ cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
+ cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
+
+ const feedbackPrds = cacheManager.getPrdsByStatus('feedback');
+
+ expect(feedbackPrds).toHaveLength(2);
+ expect(feedbackPrds.map(p => p.prdKey)).toContain('user-auth');
+ expect(feedbackPrds.map(p => p.prdKey)).toContain('mobile');
+ });
+ });
+
+ describe('getPrdsNeedingAttention', () => {
+ it('should find PRDs needing feedback from user', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', {
+ status: 'feedback',
+ stakeholders: ['@alice', '@bob']
+ });
+ cacheManager.writePrd('payments', '# PRD 2', {
+ status: 'signoff',
+ stakeholders: ['@alice', '@charlie']
+ });
+ cacheManager.writePrd('mobile', '# PRD 3', {
+ status: 'feedback',
+ stakeholders: ['@charlie']
+ });
+
+ const tasks = cacheManager.getPrdsNeedingAttention('alice');
+
+ expect(tasks.pendingFeedback).toHaveLength(1);
+ expect(tasks.pendingFeedback[0].prdKey).toBe('user-auth');
+ expect(tasks.pendingSignoff).toHaveLength(1);
+ expect(tasks.pendingSignoff[0].prdKey).toBe('payments');
+ });
+
+ it('should handle @ prefix in username', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', {
+ status: 'feedback',
+ stakeholders: ['alice', 'bob']
+ });
+
+ const tasks = cacheManager.getPrdsNeedingAttention('@alice');
+
+ expect(tasks.pendingFeedback).toHaveLength(1);
+ });
+ });
+
+ describe('deletePrd', () => {
+ it('should delete PRD file and metadata', () => {
+ cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' });
+ const prdPath = cacheManager.getPrdPath('user-auth');
+
+ expect(fs.existsSync(prdPath)).toBe(true);
+
+ cacheManager.deletePrd('user-auth');
+
+ expect(fs.existsSync(prdPath)).toBe(false);
+ expect(cacheManager.loadMeta().prds['user-auth']).toBeUndefined();
+ });
+ });
+ });
+
+ // ============ Epic Methods Tests ============
+
+ describe('Epic operations', () => {
+ describe('getEpicPath', () => {
+ it('should return correct path for Epic', () => {
+ const epicPath = cacheManager.getEpicPath('2');
+ expect(epicPath).toBe(path.join(testCacheDir, 'epics', 'epic-2.md'));
+ });
+ });
+
+ describe('writeEpic', () => {
+ it('should write Epic content with PRD lineage', () => {
+ const content = '# Epic 2: Core Authentication';
+ const epicMeta = {
+ github_issue: 50,
+ prd_key: 'user-auth',
+ version: 1,
+ status: 'draft',
+ stories: ['2-1-login', '2-2-logout']
+ };
+
+ const result = cacheManager.writeEpic('2', content, epicMeta);
+
+ expect(result.epicKey).toBe('2');
+ expect(result.hash).toBeDefined();
+ expect(result.hash.length).toBe(64);
+
+ // Verify file was written
+ const epicPath = cacheManager.getEpicPath('2');
+ expect(fs.existsSync(epicPath)).toBe(true);
+ expect(fs.readFileSync(epicPath, 'utf8')).toBe(content);
+ });
+
+ it('should track PRD lineage in metadata', () => {
+ cacheManager.writeEpic('2', 'Epic content', {
+ prd_key: 'user-auth',
+ status: 'draft'
+ });
+
+ const meta = cacheManager.loadMeta();
+ expect(meta.epics['2'].prd_key).toBe('user-auth');
+ });
+ });
+
+ describe('readEpic', () => {
+ it('should return null for non-existent Epic', () => {
+ const result = cacheManager.readEpic('999');
+ expect(result).toBeNull();
+ });
+
+ it('should return Epic content with metadata', () => {
+ const content = '# Epic 2: Auth';
+ cacheManager.writeEpic('2', content, {
+ prd_key: 'user-auth',
+ version: 1,
+ status: 'draft'
+ });
+
+ const result = cacheManager.readEpic('2');
+
+ expect(result.content).toBe(content);
+ expect(result.meta.prd_key).toBe('user-auth');
+ expect(result.isStale).toBe(false);
+ });
+ });
+
+ describe('updateEpicStatus', () => {
+ it('should update Epic status', () => {
+ cacheManager.writeEpic('2', '# Epic', { status: 'draft' });
+
+ cacheManager.updateEpicStatus('2', 'feedback');
+
+ const meta = cacheManager.loadMeta();
+ expect(meta.epics['2'].status).toBe('feedback');
+ });
+
+ it('should throw error for non-existent Epic', () => {
+ expect(() => {
+ cacheManager.updateEpicStatus('999', 'feedback');
+ }).toThrow('Epic not found in cache: 999');
+ });
+ });
+
+ describe('listCachedEpics', () => {
+ it('should return all cached Epic keys', () => {
+ cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' });
+ cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' });
+ cacheManager.writeEpic('3', '# Epic 3', { status: 'feedback' });
+
+ const epics = cacheManager.listCachedEpics();
+
+ expect(epics).toContain('1');
+ expect(epics).toContain('2');
+ expect(epics).toContain('3');
+ expect(epics.length).toBe(3);
+ });
+ });
+
+ describe('getEpicsByPrd', () => {
+ it('should filter Epics by source PRD', () => {
+ cacheManager.writeEpic('1', '# Epic 1', { prd_key: 'user-auth', status: 'approved' });
+ cacheManager.writeEpic('2', '# Epic 2', { prd_key: 'user-auth', status: 'draft' });
+ cacheManager.writeEpic('3', '# Epic 3', { prd_key: 'payments', status: 'draft' });
+
+ const authEpics = cacheManager.getEpicsByPrd('user-auth');
+
+ expect(authEpics).toHaveLength(2);
+ expect(authEpics.map(e => e.epicKey)).toContain('1');
+ expect(authEpics.map(e => e.epicKey)).toContain('2');
+ });
+ });
+
+ describe('getEpicsNeedingAttention', () => {
+ it('should find Epics needing feedback from user', () => {
+ cacheManager.writeEpic('1', '# Epic 1', {
+ status: 'feedback',
+ stakeholders: ['@alice', '@bob']
+ });
+ cacheManager.writeEpic('2', '# Epic 2', {
+ status: 'draft',
+ stakeholders: ['@alice']
+ });
+ cacheManager.writeEpic('3', '# Epic 3', {
+ status: 'feedback',
+ stakeholders: ['@charlie']
+ });
+
+ const tasks = cacheManager.getEpicsNeedingAttention('alice');
+
+ expect(tasks.pendingFeedback).toHaveLength(1);
+ expect(tasks.pendingFeedback[0].epicKey).toBe('1');
+ });
+ });
+
+ describe('deleteEpic', () => {
+ it('should delete Epic file and metadata', () => {
+ cacheManager.writeEpic('2', '# Epic', { status: 'draft' });
+ const epicPath = cacheManager.getEpicPath('2');
+
+ expect(fs.existsSync(epicPath)).toBe(true);
+
+ cacheManager.deleteEpic('2');
+
+ expect(fs.existsSync(epicPath)).toBe(false);
+ expect(cacheManager.loadMeta().epics['2']).toBeUndefined();
+ });
+ });
+ });
+
+ // ============ Unified Task Query Tests ============
+
+ describe('getMyTasks', () => {
+ it('should return combined PRD and Epic tasks', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', {
+ status: 'feedback',
+ stakeholders: ['@alice']
+ });
+ cacheManager.writePrd('payments', '# PRD 2', {
+ status: 'signoff',
+ stakeholders: ['@alice']
+ });
+ cacheManager.writeEpic('2', '# Epic 2', {
+ status: 'feedback',
+ stakeholders: ['@alice']
+ });
+
+ const tasks = cacheManager.getMyTasks('alice');
+
+ expect(tasks.prds.pendingFeedback).toHaveLength(1);
+ expect(tasks.prds.pendingSignoff).toHaveLength(1);
+ expect(tasks.epics.pendingFeedback).toHaveLength(1);
+ });
+
+ it('should return empty arrays when user has no tasks', () => {
+ cacheManager.writePrd('user-auth', '# PRD 1', {
+ status: 'feedback',
+ stakeholders: ['@bob']
+ });
+
+ const tasks = cacheManager.getMyTasks('alice');
+
+ expect(tasks.prds.pendingFeedback).toHaveLength(0);
+ expect(tasks.prds.pendingSignoff).toHaveLength(0);
+ expect(tasks.epics.pendingFeedback).toHaveLength(0);
+ });
+ });
+
+ // ============ Extended Statistics Tests ============
+
+ describe('getExtendedStats', () => {
+ it('should return comprehensive statistics', () => {
+ cacheManager.writeStory('2-1-login', '# Story', { github_issue: 10 });
+ cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' });
+ cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
+ cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
+ cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' });
+ cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' });
+
+ const stats = cacheManager.getExtendedStats();
+
+ expect(stats.story_count).toBe(1);
+ expect(stats.prd_count).toBe(3);
+ expect(stats.prds_by_status).toEqual({
+ feedback: 2,
+ approved: 1
+ });
+ expect(stats.epic_count).toBe(2);
+ expect(stats.epics_by_status).toEqual({
+ approved: 1,
+ draft: 1
+ });
+ expect(stats.prd_size_kb).toBeGreaterThanOrEqual(0);
+ expect(stats.epic_size_kb).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ // ============ Document Staleness Tests ============
+
+ describe('_isDocumentStale', () => {
+ it('should return true for missing metadata', () => {
+ expect(cacheManager._isDocumentStale(null)).toBe(true);
+ expect(cacheManager._isDocumentStale({})).toBe(true);
+ });
+
+ it('should return true for old cache timestamp', () => {
+ const oldMeta = {
+ cache_timestamp: '2020-01-01T00:00:00Z'
+ };
+
+ expect(cacheManager._isDocumentStale(oldMeta)).toBe(true);
+ });
+
+ it('should return false for recent cache timestamp', () => {
+ const recentMeta = {
+ cache_timestamp: new Date().toISOString()
+ };
+
+ expect(cacheManager._isDocumentStale(recentMeta)).toBe(false);
+ });
+ });
+
+ // ============ Atomic Write Tests ============
+
+ describe('atomic writes', () => {
+ it('should write PRD atomically (no temp files left)', () => {
+ cacheManager.writePrd('user-auth', '# Content', { status: 'draft' });
+
+ const prdPath = cacheManager.getPrdPath('user-auth');
+ const tempPath = `${prdPath}.tmp`;
+
+ expect(fs.existsSync(prdPath)).toBe(true);
+ expect(fs.existsSync(tempPath)).toBe(false);
+ });
+
+ it('should write Epic atomically (no temp files left)', () => {
+ cacheManager.writeEpic('2', '# Content', { status: 'draft' });
+
+ const epicPath = cacheManager.getEpicPath('2');
+ const tempPath = `${epicPath}.tmp`;
+
+ expect(fs.existsSync(epicPath)).toBe(true);
+ expect(fs.existsSync(tempPath)).toBe(false);
+ });
+
+ it('should save metadata atomically (no temp files left)', () => {
+ cacheManager.writePrd('user-auth', '# Content', { status: 'draft' });
+
+ const metaPath = path.join(testCacheDir, CACHE_META_FILENAME);
+ const tempPath = `${metaPath}.tmp`;
+
+ expect(fs.existsSync(metaPath)).toBe(true);
+ expect(fs.existsSync(tempPath)).toBe(false);
+ });
+ });
+
+ // ============ Edge Cases ============
+
+ describe('edge cases', () => {
+ it('should handle empty stakeholder arrays', () => {
+ cacheManager.writePrd('user-auth', '# PRD', {
+ status: 'feedback',
+ stakeholders: []
+ });
+
+ const tasks = cacheManager.getPrdsNeedingAttention('alice');
+
+ expect(tasks.pendingFeedback).toHaveLength(0);
+ });
+
+ it('should handle missing stakeholders property', () => {
+ cacheManager.writePrd('user-auth', '# PRD', { status: 'feedback' });
+
+ const tasks = cacheManager.getPrdsNeedingAttention('alice');
+
+ expect(tasks.pendingFeedback).toHaveLength(0);
+ });
+
+ it('should handle PRDs with no status', () => {
+ cacheManager.writePrd('user-auth', '# PRD', { version: 1 });
+
+ // Status defaults to 'draft' in writePrd
+ const feedbackPrds = cacheManager.getPrdsByStatus('feedback');
+ expect(feedbackPrds).toHaveLength(0);
+
+ const draftPrds = cacheManager.getPrdsByStatus('draft');
+ expect(draftPrds).toHaveLength(1);
+ });
+
+ it('should handle special characters in content', () => {
+ const content = '# PRD: Auth\n\n## Special chars: "quotes", , & ampersands';
+ cacheManager.writePrd('user-auth', content, { status: 'draft' });
+
+ const result = cacheManager.readPrd('user-auth');
+ expect(result.content).toBe(content);
+ });
+
+ it('should handle unicode content', () => {
+ const content = '# PRD: 认证系统\n\nUnicode: 日本語, 한국어, emoji 🚀';
+ cacheManager.writePrd('unicode-prd', content, { status: 'draft' });
+
+ const result = cacheManager.readPrd('unicode-prd');
+ expect(result.content).toBe(content);
+ });
+
+ it('should handle concurrent writes to different PRDs', () => {
+ // Write multiple PRDs in sequence (simulating concurrent writes)
+ for (let i = 0; i < 10; i++) {
+ cacheManager.writePrd(`prd-${i}`, `# PRD ${i}`, { status: 'draft' });
+ }
+
+ const prds = cacheManager.listCachedPrds();
+ expect(prds.length).toBe(10);
+
+ // Verify all are readable
+ for (let i = 0; i < 10; i++) {
+ const result = cacheManager.readPrd(`prd-${i}`);
+ expect(result.content).toBe(`# PRD ${i}`);
+ }
+ });
+ });
+
+ // ============ Content Hash Tests ============
+
+ describe('content hashing', () => {
+ it('should generate consistent hash for same content', () => {
+ const content = '# PRD Content';
+ const result1 = cacheManager.writePrd('prd-1', content, { status: 'draft' });
+ const result2 = cacheManager.writePrd('prd-2', content, { status: 'draft' });
+
+ expect(result1.hash).toBe(result2.hash);
+ });
+
+ it('should generate different hash for different content', () => {
+ const result1 = cacheManager.writePrd('prd-1', '# Content A', { status: 'draft' });
+ const result2 = cacheManager.writePrd('prd-2', '# Content B', { status: 'draft' });
+
+ expect(result1.hash).not.toBe(result2.hash);
+ });
+
+ it('should detect content changes via hasContentChanged (story method)', () => {
+ cacheManager.writeStory('story-1', '# Original', { github_issue: 10 });
+
+ expect(cacheManager.hasContentChanged('story-1', '# Original')).toBe(false);
+ expect(cacheManager.hasContentChanged('story-1', '# Modified')).toBe(true);
+ });
+ });
+});
diff --git a/test/unit/crowdsource/feedback-manager.test.js b/test/unit/crowdsource/feedback-manager.test.js
new file mode 100644
index 00000000..5f11ff1d
--- /dev/null
+++ b/test/unit/crowdsource/feedback-manager.test.js
@@ -0,0 +1,1092 @@
+/**
+ * Tests for FeedbackManager - Generic feedback operations for PRD/Epic crowdsourcing
+ *
+ * Tests cover:
+ * - Constants and type definitions
+ * - Feedback creation with proper label generation
+ * - Feedback querying with various filters
+ * - Grouping by section and type
+ * - Conflict detection
+ * - Status updates and issue closing
+ * - Statistics generation
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ FeedbackManager,
+ FEEDBACK_TYPES,
+ FEEDBACK_STATUS,
+ PRIORITY_LEVELS
+} from '../../../src/modules/bmm/lib/crowdsource/feedback-manager.js';
+
+// Create a testable subclass that allows injecting mock implementations
+class TestableFeedbackManager extends FeedbackManager {
+ constructor(githubConfig, mocks = {}) {
+ super(githubConfig);
+ this.mocks = mocks;
+ }
+
+ async _createIssue(params) {
+ if (this.mocks.createIssue) {
+ return this.mocks.createIssue(params);
+ }
+ throw new Error('Mock not provided for _createIssue');
+ }
+
+ async _getIssue(issueNumber) {
+ if (this.mocks.getIssue) {
+ return this.mocks.getIssue(issueNumber);
+ }
+ throw new Error('Mock not provided for _getIssue');
+ }
+
+ async _updateIssue(issueNumber, updates) {
+ if (this.mocks.updateIssue) {
+ return this.mocks.updateIssue(issueNumber, updates);
+ }
+ throw new Error('Mock not provided for _updateIssue');
+ }
+
+ async _closeIssue(issueNumber, reason) {
+ if (this.mocks.closeIssue) {
+ return this.mocks.closeIssue(issueNumber, reason);
+ }
+ throw new Error('Mock not provided for _closeIssue');
+ }
+
+ async _addComment(issueNumber, body) {
+ if (this.mocks.addComment) {
+ return this.mocks.addComment(issueNumber, body);
+ }
+ throw new Error('Mock not provided for _addComment');
+ }
+
+ async _searchIssues(query) {
+ if (this.mocks.searchIssues) {
+ return this.mocks.searchIssues(query);
+ }
+ throw new Error('Mock not provided for _searchIssues');
+ }
+}
+
+describe('FeedbackManager', () => {
+ // ============ Constants Tests ============
+
+ describe('FEEDBACK_TYPES', () => {
+ it('should define all standard feedback types', () => {
+ const expectedTypes = [
+ 'clarification',
+ 'concern',
+ 'suggestion',
+ 'addition',
+ 'priority'
+ ];
+
+ for (const type of expectedTypes) {
+ expect(FEEDBACK_TYPES[type]).toBeDefined();
+ expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/);
+ expect(FEEDBACK_TYPES[type].emoji).toBeTruthy();
+ expect(FEEDBACK_TYPES[type].description).toBeTruthy();
+ }
+ });
+
+ it('should define epic-specific feedback types', () => {
+ const epicTypes = ['scope', 'dependency', 'technical_risk', 'story_split'];
+
+ for (const type of epicTypes) {
+ expect(FEEDBACK_TYPES[type]).toBeDefined();
+ expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/);
+ }
+ });
+
+ it('should have correct label formats', () => {
+ expect(FEEDBACK_TYPES.clarification.label).toBe('feedback-type:clarification');
+ expect(FEEDBACK_TYPES.concern.label).toBe('feedback-type:concern');
+ expect(FEEDBACK_TYPES.technical_risk.label).toBe('feedback-type:technical-risk');
+ expect(FEEDBACK_TYPES.story_split.label).toBe('feedback-type:story-split');
+ });
+
+ it('should have descriptive emojis for visual identification', () => {
+ expect(FEEDBACK_TYPES.clarification.emoji).toBe('📋');
+ expect(FEEDBACK_TYPES.concern.emoji).toBe('⚠️');
+ expect(FEEDBACK_TYPES.suggestion.emoji).toBe('💡');
+ expect(FEEDBACK_TYPES.scope.emoji).toBe('📐');
+ });
+ });
+
+ describe('FEEDBACK_STATUS', () => {
+ it('should define all status values', () => {
+ expect(FEEDBACK_STATUS.new).toBe('feedback-status:new');
+ expect(FEEDBACK_STATUS.reviewed).toBe('feedback-status:reviewed');
+ expect(FEEDBACK_STATUS.incorporated).toBe('feedback-status:incorporated');
+ expect(FEEDBACK_STATUS.deferred).toBe('feedback-status:deferred');
+ });
+ });
+
+ describe('PRIORITY_LEVELS', () => {
+ it('should define all priority levels', () => {
+ expect(PRIORITY_LEVELS.high).toBe('priority:high');
+ expect(PRIORITY_LEVELS.medium).toBe('priority:medium');
+ expect(PRIORITY_LEVELS.low).toBe('priority:low');
+ });
+ });
+
+ // ============ Constructor Tests ============
+
+ describe('constructor', () => {
+ it('should initialize with github config', () => {
+ const manager = new FeedbackManager({
+ owner: 'test-org',
+ repo: 'test-repo'
+ });
+
+ expect(manager.owner).toBe('test-org');
+ expect(manager.repo).toBe('test-repo');
+ });
+ });
+
+ // ============ createFeedback Tests ============
+
+ describe('createFeedback', () => {
+ let manager;
+ let mockCreateIssue;
+ let mockAddComment;
+
+ beforeEach(() => {
+ mockCreateIssue = vi.fn().mockResolvedValue({
+ number: 42,
+ html_url: 'https://github.com/test-org/test-repo/issues/42'
+ });
+ mockAddComment = vi.fn().mockResolvedValue({});
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { createIssue: mockCreateIssue, addComment: mockAddComment }
+ );
+ });
+
+ it('should create feedback with correct labels for PRD', async () => {
+ const result = await manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:user-auth',
+ documentType: 'prd',
+ section: 'User Stories',
+ feedbackType: 'clarification',
+ priority: 'high',
+ title: 'Unclear login flow',
+ content: 'The login flow description is ambiguous',
+ submittedBy: 'alice'
+ });
+
+ expect(mockCreateIssue).toHaveBeenCalledTimes(1);
+ const createCall = mockCreateIssue.mock.calls[0][0];
+
+ expect(createCall.title).toBe('📋 Feedback: Unclear login flow');
+ expect(createCall.labels).toContain('type:prd-feedback');
+ expect(createCall.labels).toContain('prd:user-auth');
+ expect(createCall.labels).toContain('linked-review:100');
+ expect(createCall.labels).toContain('feedback-section:user-stories');
+ expect(createCall.labels).toContain('feedback-type:clarification');
+ expect(createCall.labels).toContain('feedback-status:new');
+ expect(createCall.labels).toContain('priority:high');
+
+ expect(result.feedbackId).toBe(42);
+ expect(result.status).toBe('new');
+ });
+
+ it('should create feedback with correct labels for Epic', async () => {
+ const result = await manager.createFeedback({
+ reviewIssueNumber: 200,
+ documentKey: 'epic:2',
+ documentType: 'epic',
+ section: 'Story Breakdown',
+ feedbackType: 'scope',
+ priority: 'medium',
+ title: 'Epic too large',
+ content: 'Should be split into smaller epics',
+ submittedBy: 'bob'
+ });
+
+ const createCall = mockCreateIssue.mock.calls[0][0];
+
+ expect(createCall.title).toBe('📐 Feedback: Epic too large');
+ expect(createCall.labels).toContain('type:epic-feedback');
+ expect(createCall.labels).toContain('epic:2');
+ expect(createCall.labels).toContain('feedback-type:scope');
+ });
+
+ it('should add link comment to review issue', async () => {
+ await manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:user-auth',
+ documentType: 'prd',
+ section: 'User Stories',
+ feedbackType: 'concern',
+ priority: 'high',
+ title: 'Security risk',
+ content: 'Missing security consideration',
+ submittedBy: 'security-team'
+ });
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ const commentCall = mockAddComment.mock.calls[0];
+
+ expect(commentCall[0]).toBe(100); // review issue number
+ expect(commentCall[1]).toContain('@security-team');
+ expect(commentCall[1]).toContain('Security risk');
+ expect(commentCall[1]).toContain('#42'); // feedback issue number
+ });
+
+ it('should include suggested change and rationale in body when provided', async () => {
+ await manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:payments',
+ documentType: 'prd',
+ section: 'FR-3',
+ feedbackType: 'suggestion',
+ priority: 'medium',
+ title: 'Better error handling',
+ content: 'Need better error messages',
+ suggestedChange: 'Add user-friendly error codes',
+ rationale: 'Improves debugging for support team',
+ submittedBy: 'dev-lead'
+ });
+
+ const createCall = mockCreateIssue.mock.calls[0][0];
+
+ expect(createCall.body).toContain('## Suggested Change');
+ expect(createCall.body).toContain('Add user-friendly error codes');
+ expect(createCall.body).toContain('## Context/Rationale');
+ expect(createCall.body).toContain('Improves debugging for support team');
+ });
+
+ it('should throw error for unknown feedback type', async () => {
+ await expect(manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ section: 'Test',
+ feedbackType: 'invalid-type',
+ priority: 'medium',
+ title: 'Test',
+ content: 'Test',
+ submittedBy: 'user'
+ })).rejects.toThrow('Unknown feedback type: invalid-type');
+ });
+
+ it('should default to medium priority when invalid priority provided', async () => {
+ await manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ section: 'Test',
+ feedbackType: 'clarification',
+ priority: 'invalid',
+ title: 'Test',
+ content: 'Test',
+ submittedBy: 'user'
+ });
+
+ const createCall = mockCreateIssue.mock.calls[0][0];
+ expect(createCall.labels).toContain('priority:medium');
+ });
+
+ it('should normalize section name for labels', async () => {
+ await manager.createFeedback({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ section: 'Non Functional Requirements',
+ feedbackType: 'clarification',
+ priority: 'low',
+ title: 'Test',
+ content: 'Test',
+ submittedBy: 'user'
+ });
+
+ const createCall = mockCreateIssue.mock.calls[0][0];
+ expect(createCall.labels).toContain('feedback-section:non-functional-requirements');
+ });
+ });
+
+ // ============ getFeedback Tests ============
+
+ describe('getFeedback', () => {
+ let manager;
+ let mockSearchIssues;
+
+ beforeEach(() => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'https://github.com/test/repo/issues/1',
+ title: '📋 Feedback: Test feedback',
+ labels: [
+ { name: 'type:prd-feedback' },
+ { name: 'prd:user-auth' },
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'alice' },
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-02T00:00:00Z',
+ body: 'Test body'
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+ });
+
+ it('should query feedback with document key filter', async () => {
+ await manager.getFeedback({
+ documentKey: 'prd:user-auth',
+ documentType: 'prd'
+ });
+
+ expect(mockSearchIssues).toHaveBeenCalledTimes(1);
+ const query = mockSearchIssues.mock.calls[0][0];
+
+ expect(query).toContain('repo:test-org/test-repo');
+ expect(query).toContain('type:issue');
+ expect(query).toContain('is:open');
+ expect(query).toContain('label:type:prd-feedback');
+ expect(query).toContain('label:prd:user-auth');
+ });
+
+ it('should query feedback with review issue filter', async () => {
+ await manager.getFeedback({
+ reviewIssueNumber: 100,
+ documentType: 'prd'
+ });
+
+ const query = mockSearchIssues.mock.calls[0][0];
+ expect(query).toContain('label:linked-review:100');
+ });
+
+ it('should query feedback with status filter', async () => {
+ await manager.getFeedback({
+ documentType: 'prd',
+ status: 'incorporated'
+ });
+
+ const query = mockSearchIssues.mock.calls[0][0];
+ expect(query).toContain('label:feedback-status:incorporated');
+ });
+
+ it('should query feedback with section filter', async () => {
+ await manager.getFeedback({
+ documentType: 'epic',
+ section: 'Story Breakdown'
+ });
+
+ const query = mockSearchIssues.mock.calls[0][0];
+ expect(query).toContain('label:feedback-section:story-breakdown');
+ });
+
+ it('should query feedback with type filter', async () => {
+ await manager.getFeedback({
+ documentType: 'prd',
+ feedbackType: 'concern'
+ });
+
+ const query = mockSearchIssues.mock.calls[0][0];
+ expect(query).toContain('label:feedback-type:concern');
+ });
+
+ it('should parse feedback issues correctly', async () => {
+ const results = await manager.getFeedback({
+ documentType: 'prd',
+ documentKey: 'prd:user-auth'
+ });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]).toMatchObject({
+ id: 1,
+ url: 'https://github.com/test/repo/issues/1',
+ title: 'Test feedback',
+ section: 'user-stories',
+ feedbackType: 'clarification',
+ status: 'new',
+ priority: 'high',
+ submittedBy: 'alice'
+ });
+ });
+
+ it('should handle document key with colon', async () => {
+ await manager.getFeedback({
+ documentKey: 'prd:complex-key',
+ documentType: 'prd'
+ });
+
+ const query = mockSearchIssues.mock.calls[0][0];
+ expect(query).toContain('label:prd:complex-key');
+ });
+ });
+
+ // ============ getFeedbackBySection Tests ============
+
+ describe('getFeedbackBySection', () => {
+ let manager;
+ let mockSearchIssues;
+
+ beforeEach(() => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '📋 Feedback: FB1',
+ labels: [
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'alice' },
+ created_at: '2026-01-01',
+ updated_at: '2026-01-01'
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '💡 Feedback: FB2',
+ labels: [
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:suggestion' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'bob' },
+ created_at: '2026-01-01',
+ updated_at: '2026-01-01'
+ },
+ {
+ number: 3,
+ html_url: 'url3',
+ title: '⚠️ Feedback: FB3',
+ labels: [
+ { name: 'feedback-section:fr-3' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'charlie' },
+ created_at: '2026-01-01',
+ updated_at: '2026-01-01'
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+ });
+
+ it('should group feedback by section', async () => {
+ const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd');
+
+ expect(Object.keys(bySection)).toHaveLength(2);
+ expect(bySection['user-stories']).toHaveLength(2);
+ expect(bySection['fr-3']).toHaveLength(1);
+ });
+
+ it('should preserve feedback details in grouped results', async () => {
+ const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd');
+
+ expect(bySection['user-stories'][0].submittedBy).toBe('alice');
+ expect(bySection['user-stories'][1].submittedBy).toBe('bob');
+ });
+ });
+
+ // ============ getFeedbackByType Tests ============
+
+ describe('getFeedbackByType', () => {
+ let manager;
+ let mockSearchIssues;
+
+ beforeEach(() => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '📋 Feedback: FB1',
+ labels: [
+ { name: 'feedback-section:test' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'alice' }
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '📋 Feedback: FB2',
+ labels: [
+ { name: 'feedback-section:test2' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'bob' }
+ },
+ {
+ number: 3,
+ html_url: 'url3',
+ title: '⚠️ Feedback: FB3',
+ labels: [
+ { name: 'feedback-section:test' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'charlie' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+ });
+
+ it('should group feedback by type', async () => {
+ const byType = await manager.getFeedbackByType('prd:user-auth', 'prd');
+
+ expect(Object.keys(byType)).toHaveLength(2);
+ expect(byType['clarification']).toHaveLength(2);
+ expect(byType['concern']).toHaveLength(1);
+ });
+ });
+
+ // ============ detectConflicts Tests ============
+
+ describe('detectConflicts', () => {
+ let manager;
+ let mockSearchIssues;
+
+ it('should detect conflicts when multiple concerns on same section', async () => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '⚠️ Feedback: Timeout too short',
+ labels: [
+ { name: 'feedback-section:fr-5' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'security' }
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '⚠️ Feedback: Timeout too long',
+ labels: [
+ { name: 'feedback-section:fr-5' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'ux-team' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+
+ const conflicts = await manager.detectConflicts('prd:user-auth', 'prd');
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0].section).toBe('fr-5');
+ expect(conflicts[0].conflictType).toBe('multiple_opinions');
+ expect(conflicts[0].feedbackItems).toHaveLength(2);
+ });
+
+ it('should detect conflicts when concern and suggestion on same section', async () => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '⚠️ Feedback: Risk',
+ labels: [
+ { name: 'feedback-section:security' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'security' }
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '💡 Feedback: Improvement',
+ labels: [
+ { name: 'feedback-section:security' },
+ { name: 'feedback-type:suggestion' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'dev' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+
+ const conflicts = await manager.detectConflicts('prd:test', 'prd');
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0].section).toBe('security');
+ });
+
+ it('should not detect conflicts for single feedback on section', async () => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '⚠️ Feedback: Single concern',
+ labels: [
+ { name: 'feedback-section:fr-1' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'user1' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+
+ const conflicts = await manager.detectConflicts('prd:test', 'prd');
+
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it('should not detect conflicts for multiple clarifications (not opposing)', async () => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '📋 Feedback: Question 1',
+ labels: [
+ { name: 'feedback-section:fr-1' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'user1' }
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '📋 Feedback: Question 2',
+ labels: [
+ { name: 'feedback-section:fr-1' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:low' }
+ ],
+ user: { login: 'user2' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+
+ const conflicts = await manager.detectConflicts('prd:test', 'prd');
+
+ expect(conflicts).toHaveLength(0);
+ });
+ });
+
+ // ============ updateFeedbackStatus Tests ============
+
+ describe('updateFeedbackStatus', () => {
+ let manager;
+ let mockGetIssue;
+ let mockUpdateIssue;
+ let mockAddComment;
+ let mockCloseIssue;
+
+ beforeEach(() => {
+ mockGetIssue = vi.fn().mockResolvedValue({
+ number: 42,
+ labels: [
+ { name: 'type:prd-feedback' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ]
+ });
+ mockUpdateIssue = vi.fn().mockResolvedValue({});
+ mockAddComment = vi.fn().mockResolvedValue({});
+ mockCloseIssue = vi.fn().mockResolvedValue({});
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ {
+ getIssue: mockGetIssue,
+ updateIssue: mockUpdateIssue,
+ addComment: mockAddComment,
+ closeIssue: mockCloseIssue
+ }
+ );
+ });
+
+ it('should update status labels correctly', async () => {
+ await manager.updateFeedbackStatus(42, 'reviewed');
+
+ expect(mockUpdateIssue).toHaveBeenCalledTimes(1);
+ const updateCall = mockUpdateIssue.mock.calls[0];
+
+ expect(updateCall[0]).toBe(42);
+ expect(updateCall[1].labels).toContain('feedback-status:reviewed');
+ expect(updateCall[1].labels).not.toContain('feedback-status:new');
+ expect(updateCall[1].labels).toContain('type:prd-feedback');
+ expect(updateCall[1].labels).toContain('priority:high');
+ });
+
+ it('should add resolution comment when provided', async () => {
+ await manager.updateFeedbackStatus(42, 'incorporated', 'Added to PRD v2');
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ expect(mockAddComment.mock.calls[0][0]).toBe(42);
+ expect(mockAddComment.mock.calls[0][1]).toContain('incorporated');
+ expect(mockAddComment.mock.calls[0][1]).toContain('Added to PRD v2');
+ });
+
+ it('should close issue when status is incorporated', async () => {
+ await manager.updateFeedbackStatus(42, 'incorporated');
+
+ expect(mockCloseIssue).toHaveBeenCalledTimes(1);
+ expect(mockCloseIssue.mock.calls[0][0]).toBe(42);
+ expect(mockCloseIssue.mock.calls[0][1]).toBe('completed');
+ });
+
+ it('should close issue when status is deferred', async () => {
+ await manager.updateFeedbackStatus(42, 'deferred');
+
+ expect(mockCloseIssue).toHaveBeenCalledTimes(1);
+ expect(mockCloseIssue.mock.calls[0][0]).toBe(42);
+ expect(mockCloseIssue.mock.calls[0][1]).toBe('not_planned');
+ });
+
+ it('should not close issue for reviewed status', async () => {
+ await manager.updateFeedbackStatus(42, 'reviewed');
+
+ expect(mockCloseIssue).not.toHaveBeenCalled();
+ });
+
+ it('should throw error for unknown status', async () => {
+ await expect(
+ manager.updateFeedbackStatus(42, 'invalid-status')
+ ).rejects.toThrow('Unknown status: invalid-status');
+ });
+
+ it('should return updated status info', async () => {
+ const result = await manager.updateFeedbackStatus(42, 'reviewed');
+
+ expect(result).toEqual({
+ feedbackId: 42,
+ status: 'reviewed'
+ });
+ });
+ });
+
+ // ============ getStats Tests ============
+
+ describe('getStats', () => {
+ let manager;
+ let mockSearchIssues;
+
+ beforeEach(() => {
+ mockSearchIssues = vi.fn().mockResolvedValue([
+ {
+ number: 1,
+ html_url: 'url1',
+ title: '📋 Feedback: FB1',
+ labels: [
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'alice' }
+ },
+ {
+ number: 2,
+ html_url: 'url2',
+ title: '⚠️ Feedback: FB2',
+ labels: [
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:concern' },
+ { name: 'feedback-status:reviewed' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'bob' }
+ },
+ {
+ number: 3,
+ html_url: 'url3',
+ title: '💡 Feedback: FB3',
+ labels: [
+ { name: 'feedback-section:fr-3' },
+ { name: 'feedback-type:suggestion' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:medium' }
+ ],
+ user: { login: 'alice' }
+ }
+ ]);
+
+ manager = new TestableFeedbackManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { searchIssues: mockSearchIssues }
+ );
+ });
+
+ it('should calculate total feedback count', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.total).toBe(3);
+ });
+
+ it('should group stats by type', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.byType).toEqual({
+ clarification: 1,
+ concern: 1,
+ suggestion: 1
+ });
+ });
+
+ it('should group stats by status', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.byStatus).toEqual({
+ new: 2,
+ reviewed: 1
+ });
+ });
+
+ it('should group stats by section', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.bySection).toEqual({
+ 'user-stories': 2,
+ 'fr-3': 1
+ });
+ });
+
+ it('should group stats by priority', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.byPriority).toEqual({
+ high: 2,
+ medium: 1
+ });
+ });
+
+ it('should count unique submitters', async () => {
+ const stats = await manager.getStats('prd:user-auth', 'prd');
+
+ expect(stats.submitterCount).toBe(2);
+ expect(stats.submitters).toContain('alice');
+ expect(stats.submitters).toContain('bob');
+ });
+ });
+
+ // ============ Private Method Tests ============
+
+ describe('_formatFeedbackBody', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new FeedbackManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should format body with all required sections', () => {
+ const body = manager._formatFeedbackBody({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ section: 'User Stories',
+ feedbackType: 'clarification',
+ typeConfig: FEEDBACK_TYPES.clarification,
+ priority: 'high',
+ content: 'This is unclear',
+ submittedBy: 'alice'
+ });
+
+ expect(body).toContain('# 📋 Feedback: Clarification');
+ expect(body).toContain('**Review:** #100');
+ expect(body).toContain('**Document:** `prd:test`');
+ expect(body).toContain('**Section:** User Stories');
+ expect(body).toContain('**Priority:** high');
+ expect(body).toContain('## Feedback');
+ expect(body).toContain('This is unclear');
+ expect(body).toContain('@alice');
+ });
+
+ it('should include suggested change when provided', () => {
+ const body = manager._formatFeedbackBody({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ section: 'FR-1',
+ feedbackType: 'suggestion',
+ typeConfig: FEEDBACK_TYPES.suggestion,
+ priority: 'medium',
+ content: 'Could be improved',
+ suggestedChange: 'Use async/await pattern',
+ submittedBy: 'bob'
+ });
+
+ expect(body).toContain('## Suggested Change');
+ expect(body).toContain('Use async/await pattern');
+ });
+
+ it('should include rationale when provided', () => {
+ const body = manager._formatFeedbackBody({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ section: 'NFR-1',
+ feedbackType: 'concern',
+ typeConfig: FEEDBACK_TYPES.concern,
+ priority: 'high',
+ content: 'Security risk',
+ rationale: 'OWASP Top 10 vulnerability',
+ submittedBy: 'security'
+ });
+
+ expect(body).toContain('## Context/Rationale');
+ expect(body).toContain('OWASP Top 10 vulnerability');
+ });
+ });
+
+ describe('_parseFeedbackIssue', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new FeedbackManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should parse issue into feedback object', () => {
+ const issue = {
+ number: 42,
+ html_url: 'https://github.com/test/repo/issues/42',
+ title: '📋 Feedback: Test feedback title',
+ labels: [
+ { name: 'feedback-section:user-stories' },
+ { name: 'feedback-type:clarification' },
+ { name: 'feedback-status:new' },
+ { name: 'priority:high' }
+ ],
+ user: { login: 'alice' },
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-02T00:00:00Z',
+ body: 'Test body content'
+ };
+
+ const parsed = manager._parseFeedbackIssue(issue);
+
+ expect(parsed).toEqual({
+ id: 42,
+ url: 'https://github.com/test/repo/issues/42',
+ title: 'Test feedback title',
+ section: 'user-stories',
+ feedbackType: 'clarification',
+ status: 'new',
+ priority: 'high',
+ submittedBy: 'alice',
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-02T00:00:00Z',
+ body: 'Test body content'
+ });
+ });
+
+ it('should strip emoji prefix from title', () => {
+ const issue = {
+ number: 1,
+ html_url: 'url',
+ title: '⚠️ Feedback: Important concern',
+ labels: [],
+ user: null
+ };
+
+ const parsed = manager._parseFeedbackIssue(issue);
+ expect(parsed.title).toBe('Important concern');
+ });
+
+ it('should handle missing labels gracefully', () => {
+ const issue = {
+ number: 1,
+ html_url: 'url',
+ title: 'Feedback: Missing labels',
+ labels: [],
+ user: { login: 'user' }
+ };
+
+ const parsed = manager._parseFeedbackIssue(issue);
+
+ expect(parsed.section).toBeNull();
+ expect(parsed.feedbackType).toBeNull();
+ expect(parsed.status).toBeNull();
+ expect(parsed.priority).toBeNull();
+ });
+ });
+
+ describe('_extractLabel', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new FeedbackManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should extract value from label with prefix', () => {
+ const labels = ['type:prd-feedback', 'feedback-type:concern', 'priority:high'];
+
+ expect(manager._extractLabel(labels, 'feedback-type:')).toBe('concern');
+ expect(manager._extractLabel(labels, 'priority:')).toBe('high');
+ });
+
+ it('should return null when label not found', () => {
+ const labels = ['type:prd-feedback'];
+
+ expect(manager._extractLabel(labels, 'feedback-type:')).toBeNull();
+ });
+ });
+
+ // ============ Error Handling Tests ============
+
+ describe('error handling', () => {
+ it('should throw when GitHub methods not implemented', async () => {
+ const manager = new FeedbackManager({ owner: 'test', repo: 'test' });
+
+ await expect(manager._createIssue({})).rejects.toThrow(
+ '_createIssue must be implemented by caller via GitHub MCP'
+ );
+
+ await expect(manager._getIssue(1)).rejects.toThrow(
+ '_getIssue must be implemented by caller via GitHub MCP'
+ );
+
+ await expect(manager._searchIssues('')).rejects.toThrow(
+ '_searchIssues must be implemented by caller via GitHub MCP'
+ );
+ });
+ });
+});
diff --git a/test/unit/crowdsource/signoff-manager.test.js b/test/unit/crowdsource/signoff-manager.test.js
new file mode 100644
index 00000000..5d600ab2
--- /dev/null
+++ b/test/unit/crowdsource/signoff-manager.test.js
@@ -0,0 +1,1015 @@
+/**
+ * Tests for SignoffManager - Configurable sign-off logic for PRDs and Epics
+ *
+ * Tests cover:
+ * - Constants and default configuration
+ * - Sign-off request creation
+ * - Sign-off submission with various decisions
+ * - Three threshold types: count, percentage, required_approvers
+ * - Status calculation with blocking logic
+ * - Progress tracking and summaries
+ * - Reminder and deadline extension
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ SignoffManager,
+ SIGNOFF_STATUS,
+ THRESHOLD_TYPES,
+ DEFAULT_CONFIG
+} from '../../../src/modules/bmm/lib/crowdsource/signoff-manager.js';
+
+// Create a testable subclass that allows injecting mock implementations
+class TestableSignoffManager extends SignoffManager {
+ constructor(githubConfig, mocks = {}) {
+ super(githubConfig);
+ this.mocks = mocks;
+ }
+
+ async _getIssue(issueNumber) {
+ if (this.mocks.getIssue) {
+ return this.mocks.getIssue(issueNumber);
+ }
+ throw new Error('Mock not provided for _getIssue');
+ }
+
+ async _updateIssue(issueNumber, updates) {
+ if (this.mocks.updateIssue) {
+ return this.mocks.updateIssue(issueNumber, updates);
+ }
+ throw new Error('Mock not provided for _updateIssue');
+ }
+
+ async _addComment(issueNumber, body) {
+ if (this.mocks.addComment) {
+ return this.mocks.addComment(issueNumber, body);
+ }
+ throw new Error('Mock not provided for _addComment');
+ }
+}
+
+describe('SignoffManager', () => {
+ // ============ Constants Tests ============
+
+ describe('SIGNOFF_STATUS', () => {
+ it('should define all status values', () => {
+ expect(SIGNOFF_STATUS.pending).toBe('signoff:pending');
+ expect(SIGNOFF_STATUS.approved).toBe('signoff:approved');
+ expect(SIGNOFF_STATUS.approved_with_note).toBe('signoff:approved-with-note');
+ expect(SIGNOFF_STATUS.blocked).toBe('signoff:blocked');
+ });
+ });
+
+ describe('THRESHOLD_TYPES', () => {
+ it('should define all threshold types', () => {
+ expect(THRESHOLD_TYPES.count).toBe('count');
+ expect(THRESHOLD_TYPES.percentage).toBe('percentage');
+ expect(THRESHOLD_TYPES.required_approvers).toBe('required_approvers');
+ });
+ });
+
+ describe('DEFAULT_CONFIG', () => {
+ it('should have sensible defaults', () => {
+ expect(DEFAULT_CONFIG.threshold_type).toBe('count');
+ expect(DEFAULT_CONFIG.minimum_approvals).toBe(2);
+ expect(DEFAULT_CONFIG.approval_percentage).toBe(66);
+ expect(DEFAULT_CONFIG.required).toEqual([]);
+ expect(DEFAULT_CONFIG.optional).toEqual([]);
+ expect(DEFAULT_CONFIG.minimum_optional).toBe(0);
+ expect(DEFAULT_CONFIG.allow_blocks).toBe(true);
+ expect(DEFAULT_CONFIG.block_threshold).toBe(1);
+ });
+ });
+
+ // ============ Constructor Tests ============
+
+ describe('constructor', () => {
+ it('should initialize with github config', () => {
+ const manager = new SignoffManager({
+ owner: 'test-org',
+ repo: 'test-repo'
+ });
+
+ expect(manager.owner).toBe('test-org');
+ expect(manager.repo).toBe('test-repo');
+ });
+ });
+
+ // ============ requestSignoff Tests ============
+
+ describe('requestSignoff', () => {
+ let manager;
+ let mockAddComment;
+
+ beforeEach(() => {
+ mockAddComment = vi.fn().mockResolvedValue({});
+
+ manager = new TestableSignoffManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { addComment: mockAddComment }
+ );
+ });
+
+ it('should create sign-off request with stakeholder checklist', async () => {
+ const result = await manager.requestSignoff({
+ documentKey: 'prd:user-auth',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob', 'charlie'],
+ deadline: '2026-01-15'
+ });
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('✍️ Sign-off Requested');
+ expect(comment).toContain('`prd:user-auth`');
+ expect(comment).toContain('PRD');
+ expect(comment).toContain('2026-01-15');
+ expect(comment).toContain('@alice');
+ expect(comment).toContain('@bob');
+ expect(comment).toContain('@charlie');
+ expect(comment).toContain('⏳ Pending');
+
+ expect(result.reviewIssueNumber).toBe(100);
+ expect(result.stakeholders).toHaveLength(3);
+ expect(result.status).toBe('signoff_requested');
+ });
+
+ it('should merge custom config with defaults', async () => {
+ const result = await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob', 'charlie', 'dave', 'eve'],
+ deadline: '2026-01-15',
+ config: {
+ minimum_approvals: 5,
+ block_threshold: 2
+ }
+ });
+
+ expect(result.config.minimum_approvals).toBe(5);
+ expect(result.config.block_threshold).toBe(2);
+ // Default values preserved
+ expect(result.config.threshold_type).toBe('count');
+ expect(result.config.allow_blocks).toBe(true);
+ });
+
+ it('should format threshold description for count type', async () => {
+ await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob', 'charlie'],
+ deadline: '2026-01-15',
+ config: { threshold_type: 'count', minimum_approvals: 2 }
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('2 approval(s) required');
+ });
+
+ it('should format threshold description for percentage type', async () => {
+ await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob', 'charlie'],
+ deadline: '2026-01-15',
+ config: { threshold_type: 'percentage', approval_percentage: 75 }
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('75% must approve');
+ });
+
+ it('should format threshold description for required_approvers type', async () => {
+ await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob', 'charlie', 'dave'],
+ deadline: '2026-01-15',
+ config: {
+ threshold_type: 'required_approvers',
+ required: ['alice', 'bob'],
+ optional: ['charlie', 'dave'],
+ minimum_optional: 1
+ }
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('Required: alice, bob');
+ expect(comment).toContain('1 optional');
+ });
+
+ it('should include sign-off instructions', async () => {
+ await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob'],
+ deadline: '2026-01-15'
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('/signoff approve');
+ expect(comment).toContain('/signoff approve-note');
+ expect(comment).toContain('/signoff block');
+ });
+
+ it('should validate count threshold against stakeholder list', async () => {
+ await expect(manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob'],
+ deadline: '2026-01-15',
+ config: { threshold_type: 'count', minimum_approvals: 5 }
+ })).rejects.toThrow('minimum_approvals (5) cannot exceed stakeholder count (2)');
+ });
+
+ it('should validate required approvers are in stakeholder list', async () => {
+ await expect(manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['alice', 'bob'],
+ deadline: '2026-01-15',
+ config: {
+ threshold_type: 'required_approvers',
+ required: ['alice', 'charlie'] // charlie not in stakeholders
+ }
+ })).rejects.toThrow('All required approvers must be in stakeholder list');
+ });
+
+ it('should handle @ prefix in stakeholder names', async () => {
+ await manager.requestSignoff({
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ reviewIssueNumber: 100,
+ stakeholders: ['@alice', '@bob'],
+ deadline: '2026-01-15'
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('@alice');
+ expect(comment).toContain('@bob');
+ expect(comment).not.toContain('@@'); // Should not double the @
+ });
+ });
+
+ // ============ submitSignoff Tests ============
+
+ describe('submitSignoff', () => {
+ let manager;
+ let mockAddComment;
+ let mockGetIssue;
+ let mockUpdateIssue;
+
+ beforeEach(() => {
+ mockAddComment = vi.fn().mockResolvedValue({});
+ mockGetIssue = vi.fn().mockResolvedValue({
+ labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }]
+ });
+ mockUpdateIssue = vi.fn().mockResolvedValue({});
+
+ manager = new TestableSignoffManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ {
+ addComment: mockAddComment,
+ getIssue: mockGetIssue,
+ updateIssue: mockUpdateIssue
+ }
+ );
+ });
+
+ it('should submit approved sign-off', async () => {
+ const result = await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:user-auth',
+ documentType: 'prd',
+ user: 'alice',
+ decision: 'approved'
+ });
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('✅');
+ expect(comment).toContain('@alice');
+ expect(comment).toContain('Approved');
+
+ expect(result.decision).toBe('approved');
+ expect(result.user).toBe('alice');
+ expect(result.timestamp).toBeDefined();
+ });
+
+ it('should submit approved with note', async () => {
+ await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: 'bob',
+ decision: 'approved_with_note',
+ note: 'Please update docs before implementation'
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('✅📝');
+ expect(comment).toContain('Approved with Note');
+ expect(comment).toContain('Please update docs before implementation');
+ });
+
+ it('should submit blocked sign-off with reason', async () => {
+ await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: 'security',
+ decision: 'blocked',
+ note: 'Security review required',
+ feedbackIssueNumber: 42
+ });
+
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('🚫');
+ expect(comment).toContain('Blocked');
+ expect(comment).toContain('Security review required');
+ expect(comment).toContain('#42');
+ });
+
+ it('should add signoff label to issue', async () => {
+ await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: 'alice',
+ decision: 'approved'
+ });
+
+ expect(mockUpdateIssue).toHaveBeenCalledTimes(1);
+ const updateCall = mockUpdateIssue.mock.calls[0];
+
+ expect(updateCall[0]).toBe(100);
+ expect(updateCall[1].labels).toContain('signoff-alice-approved');
+ });
+
+ it('should replace existing signoff label for user', async () => {
+ mockGetIssue.mockResolvedValue({
+ labels: [
+ { name: 'type:prd-review' },
+ { name: 'signoff-alice-pending' } // Previous status
+ ]
+ });
+
+ await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: 'alice',
+ decision: 'approved'
+ });
+
+ const updateCall = mockUpdateIssue.mock.calls[0];
+
+ expect(updateCall[1].labels).not.toContain('signoff-alice-pending');
+ expect(updateCall[1].labels).toContain('signoff-alice-approved');
+ });
+
+ it('should normalize user name for label', async () => {
+ await manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: '@alice',
+ decision: 'approved'
+ });
+
+ const updateCall = mockUpdateIssue.mock.calls[0];
+ expect(updateCall[1].labels).toContain('signoff-alice-approved');
+ });
+
+ it('should throw error for invalid decision', async () => {
+ await expect(manager.submitSignoff({
+ reviewIssueNumber: 100,
+ documentKey: 'prd:test',
+ documentType: 'prd',
+ user: 'alice',
+ decision: 'invalid'
+ })).rejects.toThrow('Invalid decision: invalid');
+ });
+ });
+
+ // ============ getSignoffs Tests ============
+
+ describe('getSignoffs', () => {
+ let manager;
+ let mockGetIssue;
+
+ beforeEach(() => {
+ mockGetIssue = vi.fn();
+
+ manager = new TestableSignoffManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { getIssue: mockGetIssue }
+ );
+ });
+
+ it('should parse signoff labels from issue', async () => {
+ mockGetIssue.mockResolvedValue({
+ labels: [
+ { name: 'type:prd-review' },
+ { name: 'signoff-alice-approved' },
+ { name: 'signoff-bob-approved-with-note' },
+ { name: 'signoff-charlie-blocked' },
+ { name: 'signoff-dave-pending' }
+ ]
+ });
+
+ const signoffs = await manager.getSignoffs(100);
+
+ expect(signoffs).toHaveLength(4);
+ expect(signoffs).toContainEqual({
+ user: 'alice',
+ status: 'approved',
+ label: 'signoff-alice-approved'
+ });
+ expect(signoffs).toContainEqual({
+ user: 'bob',
+ status: 'approved_with_note',
+ label: 'signoff-bob-approved-with-note'
+ });
+ expect(signoffs).toContainEqual({
+ user: 'charlie',
+ status: 'blocked',
+ label: 'signoff-charlie-blocked'
+ });
+ expect(signoffs).toContainEqual({
+ user: 'dave',
+ status: 'pending',
+ label: 'signoff-dave-pending'
+ });
+ });
+
+ it('should return empty array when no signoff labels', async () => {
+ mockGetIssue.mockResolvedValue({
+ labels: [
+ { name: 'type:prd-review' },
+ { name: 'review-status:signoff' }
+ ]
+ });
+
+ const signoffs = await manager.getSignoffs(100);
+
+ expect(signoffs).toHaveLength(0);
+ });
+
+ it('should ignore non-signoff labels', async () => {
+ mockGetIssue.mockResolvedValue({
+ labels: [
+ { name: 'signoff-alice-approved' },
+ { name: 'priority:high' },
+ { name: 'type:prd-feedback' }
+ ]
+ });
+
+ const signoffs = await manager.getSignoffs(100);
+
+ expect(signoffs).toHaveLength(1);
+ expect(signoffs[0].user).toBe('alice');
+ });
+ });
+
+ // ============ calculateStatus Tests - Count Threshold ============
+
+ describe('calculateStatus - count threshold', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return approved when minimum approvals reached', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved');
+ expect(status.message).toContain('Minimum approvals reached');
+ });
+
+ it('should return pending when more approvals needed', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('pending');
+ expect(status.needed).toBe(1);
+ expect(status.message).toContain('Need 1 more approval');
+ });
+
+ it('should count approved_with_note as approval', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved_with_note' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved');
+ });
+
+ it('should return blocked when block threshold reached', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'blocked' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 1 };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('blocked');
+ expect(status.blockers).toContain('bob');
+ });
+
+ it('should not block when allow_blocks is false', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' },
+ { user: 'charlie', status: 'blocked' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, allow_blocks: false };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved'); // Blocks ignored
+ });
+
+ it('should respect higher block_threshold', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' },
+ { user: 'charlie', status: 'blocked' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
+ const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 2 };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved'); // Only 1 block, threshold is 2
+ });
+ });
+
+ // ============ calculateStatus Tests - Percentage Threshold ============
+
+ describe('calculateStatus - percentage threshold', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return approved when percentage threshold met', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie']; // 2/3 = 66.67%
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'percentage',
+ approval_percentage: 66
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved');
+ expect(status.message).toContain('67%');
+ expect(status.message).toContain('66%');
+ });
+
+ it('should return pending when percentage not met', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave']; // 1/4 = 25%
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'percentage',
+ approval_percentage: 50
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('pending');
+ expect(status.current_percent).toBe(25);
+ expect(status.needed_percent).toBe(50);
+ expect(status.needed).toBe(1); // Need 1 more to reach 50%
+ });
+
+ it('should calculate correctly for 100% threshold', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'percentage',
+ approval_percentage: 100
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('pending');
+ expect(status.needed).toBe(1);
+ });
+ });
+
+ // ============ calculateStatus Tests - Required Approvers Threshold ============
+
+ describe('calculateStatus - required_approvers threshold', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return approved when all required + minimum optional approved', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' },
+ { user: 'charlie', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'required_approvers',
+ required: ['alice', 'bob'],
+ optional: ['charlie', 'dave'],
+ minimum_optional: 1
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved');
+ expect(status.message).toContain('All required + minimum optional');
+ });
+
+ it('should return pending when required approver missing', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'charlie', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'required_approvers',
+ required: ['alice', 'bob'],
+ optional: ['charlie', 'dave'],
+ minimum_optional: 1
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('pending');
+ expect(status.missing_required).toContain('bob');
+ expect(status.message).toContain('bob');
+ });
+
+ it('should return pending when optional threshold not met', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ // No optional approvers
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'required_approvers',
+ required: ['alice', 'bob'],
+ optional: ['charlie', 'dave'],
+ minimum_optional: 1
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('pending');
+ expect(status.optional_needed).toBe(1);
+ expect(status.message).toContain('optional approver');
+ });
+
+ it('should handle @ prefix in required list', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+ const stakeholders = ['@alice', '@bob'];
+ const config = {
+ ...DEFAULT_CONFIG,
+ threshold_type: 'required_approvers',
+ required: ['@alice', '@bob'],
+ optional: [],
+ minimum_optional: 0
+ };
+
+ const status = manager.calculateStatus(signoffs, stakeholders, config);
+
+ expect(status.status).toBe('approved');
+ });
+ });
+
+ // ============ isApproved Tests ============
+
+ describe('isApproved', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return true when approved', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+
+ const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
+ ...DEFAULT_CONFIG,
+ minimum_approvals: 2
+ });
+
+ expect(approved).toBe(true);
+ });
+
+ it('should return false when pending', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' }
+ ];
+
+ const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
+ ...DEFAULT_CONFIG,
+ minimum_approvals: 2
+ });
+
+ expect(approved).toBe(false);
+ });
+
+ it('should return false when blocked', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'blocked' }
+ ];
+
+ const approved = manager.isApproved(signoffs, ['alice', 'bob'], {
+ ...DEFAULT_CONFIG,
+ minimum_approvals: 1
+ });
+
+ expect(approved).toBe(false);
+ });
+ });
+
+ // ============ getProgressSummary Tests ============
+
+ describe('getProgressSummary', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should calculate progress summary', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved_with_note' },
+ { user: 'charlie', status: 'blocked' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie', 'dave', 'eve'];
+
+ const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG);
+
+ expect(summary.total_stakeholders).toBe(5);
+ expect(summary.approved_count).toBe(2);
+ expect(summary.blocked_count).toBe(1);
+ expect(summary.pending_count).toBe(2);
+ expect(summary.pending_users).toContain('dave');
+ expect(summary.pending_users).toContain('eve');
+ expect(summary.progress_percent).toBe(40); // 2/5 = 40%
+ });
+
+ it('should include status info from calculateStatus', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' },
+ { user: 'bob', status: 'approved' }
+ ];
+ const stakeholders = ['alice', 'bob', 'charlie'];
+
+ const summary = manager.getProgressSummary(signoffs, stakeholders, {
+ ...DEFAULT_CONFIG,
+ minimum_approvals: 2
+ });
+
+ expect(summary.status).toBe('approved');
+ expect(summary.message).toBeDefined();
+ });
+
+ it('should handle @ prefix in stakeholder names', () => {
+ const signoffs = [
+ { user: 'alice', status: 'approved' }
+ ];
+ const stakeholders = ['@alice', '@bob'];
+
+ const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG);
+
+ expect(summary.pending_users).toContain('@bob');
+ expect(summary.pending_count).toBe(1);
+ });
+ });
+
+ // ============ sendReminder Tests ============
+
+ describe('sendReminder', () => {
+ let manager;
+ let mockAddComment;
+
+ beforeEach(() => {
+ mockAddComment = vi.fn().mockResolvedValue({});
+
+ manager = new TestableSignoffManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { addComment: mockAddComment }
+ );
+ });
+
+ it('should send reminder to pending users', async () => {
+ const result = await manager.sendReminder(
+ 100,
+ ['alice', 'bob'],
+ '2026-01-15'
+ );
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('⏰ Reminder');
+ expect(comment).toContain('@alice');
+ expect(comment).toContain('@bob');
+ expect(comment).toContain('2026-01-15');
+
+ expect(result.reminded).toEqual(['alice', 'bob']);
+ expect(result.deadline).toBe('2026-01-15');
+ });
+
+ it('should handle @ prefix in user names', async () => {
+ await manager.sendReminder(100, ['@charlie'], '2026-01-20');
+
+ const comment = mockAddComment.mock.calls[0][1];
+ expect(comment).toContain('@charlie');
+ expect(comment).not.toContain('@@');
+ });
+ });
+
+ // ============ extendDeadline Tests ============
+
+ describe('extendDeadline', () => {
+ let manager;
+ let mockAddComment;
+
+ beforeEach(() => {
+ mockAddComment = vi.fn().mockResolvedValue({});
+
+ manager = new TestableSignoffManager(
+ { owner: 'test-org', repo: 'test-repo' },
+ { addComment: mockAddComment }
+ );
+ });
+
+ it('should post deadline extension comment', async () => {
+ const result = await manager.extendDeadline(100, '2026-01-20');
+
+ expect(mockAddComment).toHaveBeenCalledTimes(1);
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('📅 Deadline Extended');
+ expect(comment).toContain('2026-01-20');
+
+ expect(result.reviewIssueNumber).toBe(100);
+ expect(result.newDeadline).toBe('2026-01-20');
+ });
+
+ it('should include reason when provided', async () => {
+ await manager.extendDeadline(100, '2026-01-25', 'Holiday period');
+
+ const comment = mockAddComment.mock.calls[0][1];
+
+ expect(comment).toContain('Holiday period');
+ });
+ });
+
+ // ============ Private Method Tests ============
+
+ describe('_getDecisionEmoji', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return correct emoji for each decision', () => {
+ expect(manager._getDecisionEmoji('approved')).toBe('✅');
+ expect(manager._getDecisionEmoji('approved_with_note')).toBe('✅📝');
+ expect(manager._getDecisionEmoji('blocked')).toBe('🚫');
+ expect(manager._getDecisionEmoji('pending')).toBe('⏳');
+ expect(manager._getDecisionEmoji('unknown')).toBe('⏳');
+ });
+ });
+
+ describe('_getDecisionText', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should return correct text for each decision', () => {
+ expect(manager._getDecisionText('approved')).toBe('Approved');
+ expect(manager._getDecisionText('approved_with_note')).toBe('Approved with Note');
+ expect(manager._getDecisionText('blocked')).toBe('Blocked');
+ expect(manager._getDecisionText('pending')).toBe('Pending');
+ });
+ });
+
+ describe('_formatThreshold', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new SignoffManager({ owner: 'test', repo: 'test' });
+ });
+
+ it('should format count threshold', () => {
+ const config = { threshold_type: 'count', minimum_approvals: 3 };
+ expect(manager._formatThreshold(config)).toBe('3 approval(s) required');
+ });
+
+ it('should format percentage threshold', () => {
+ const config = { threshold_type: 'percentage', approval_percentage: 75 };
+ expect(manager._formatThreshold(config)).toBe('75% must approve');
+ });
+
+ it('should format required_approvers threshold', () => {
+ const config = {
+ threshold_type: 'required_approvers',
+ required: ['alice', 'bob'],
+ minimum_optional: 2
+ };
+ expect(manager._formatThreshold(config)).toBe('Required: alice, bob + 2 optional');
+ });
+
+ it('should return Unknown for invalid threshold type', () => {
+ const config = { threshold_type: 'invalid' };
+ expect(manager._formatThreshold(config)).toBe('Unknown');
+ });
+ });
+
+ // ============ Error Handling Tests ============
+
+ describe('error handling', () => {
+ it('should throw when GitHub methods not implemented', async () => {
+ const manager = new SignoffManager({ owner: 'test', repo: 'test' });
+
+ await expect(manager._getIssue(1)).rejects.toThrow(
+ '_getIssue must be implemented by caller via GitHub MCP'
+ );
+
+ await expect(manager._addComment(1, 'test')).rejects.toThrow(
+ '_addComment must be implemented by caller via GitHub MCP'
+ );
+ });
+
+ it('should throw for unknown threshold type in calculateStatus', () => {
+ const manager = new SignoffManager({ owner: 'test', repo: 'test' });
+
+ expect(() => {
+ manager.calculateStatus([], ['alice'], { threshold_type: 'invalid' });
+ }).toThrow('Unknown threshold type: invalid');
+ });
+ });
+});
diff --git a/test/unit/crowdsource/synthesis-engine.test.js b/test/unit/crowdsource/synthesis-engine.test.js
new file mode 100644
index 00000000..75249769
--- /dev/null
+++ b/test/unit/crowdsource/synthesis-engine.test.js
@@ -0,0 +1,810 @@
+/**
+ * Tests for SynthesisEngine - LLM-powered feedback synthesis with conflict resolution
+ *
+ * Tests cover:
+ * - LLM prompt templates for PRD and Epic synthesis
+ * - Feedback analysis and section grouping
+ * - Conflict detection with keyword extraction
+ * - Theme identification
+ * - Prompt generation for conflict resolution and merging
+ * - Summary generation and formatting
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ SynthesisEngine,
+ SYNTHESIS_PROMPTS
+} from '../../../src/modules/bmm/lib/crowdsource/synthesis-engine.js';
+
+describe('SynthesisEngine', () => {
+ // ============ SYNTHESIS_PROMPTS Tests ============
+
+ describe('SYNTHESIS_PROMPTS', () => {
+ describe('PRD prompts', () => {
+ it('should have grouping prompt with placeholders', () => {
+ const prompt = SYNTHESIS_PROMPTS.prd.grouping;
+
+ expect(prompt).toContain('{{section}}');
+ expect(prompt).toContain('{{feedbackItems}}');
+ expect(prompt).toContain('Common requests');
+ expect(prompt).toContain('Conflicts');
+ expect(prompt).toContain('Quick wins');
+ expect(prompt).toContain('Major changes');
+ expect(prompt).toContain('JSON');
+ });
+
+ it('should have resolution prompt with placeholders', () => {
+ const prompt = SYNTHESIS_PROMPTS.prd.resolution;
+
+ expect(prompt).toContain('{{section}}');
+ expect(prompt).toContain('{{originalText}}');
+ expect(prompt).toContain('{{conflictDescription}}');
+ expect(prompt).toContain('{{feedbackDetails}}');
+ expect(prompt).toContain('proposed_text');
+ expect(prompt).toContain('rationale');
+ expect(prompt).toContain('trade_offs');
+ expect(prompt).toContain('confidence');
+ });
+
+ it('should have merge prompt with placeholders', () => {
+ const prompt = SYNTHESIS_PROMPTS.prd.merge;
+
+ expect(prompt).toContain('{{section}}');
+ expect(prompt).toContain('{{originalText}}');
+ expect(prompt).toContain('{{feedbackToIncorporate}}');
+ });
+ });
+
+ describe('Epic prompts', () => {
+ it('should have grouping prompt with epic-specific categories', () => {
+ const prompt = SYNTHESIS_PROMPTS.epic.grouping;
+
+ expect(prompt).toContain('{{epicKey}}');
+ expect(prompt).toContain('{{feedbackItems}}');
+ expect(prompt).toContain('Scope concerns');
+ expect(prompt).toContain('Story split suggestions');
+ expect(prompt).toContain('Dependency');
+ expect(prompt).toContain('Technical risks');
+ expect(prompt).toContain('Missing stories');
+ expect(prompt).toContain('Priority questions');
+ });
+
+ it('should have storySplit prompt with placeholders', () => {
+ const prompt = SYNTHESIS_PROMPTS.epic.storySplit;
+
+ expect(prompt).toContain('{{epicKey}}');
+ expect(prompt).toContain('{{epicDescription}}');
+ expect(prompt).toContain('{{currentStories}}');
+ expect(prompt).toContain('{{feedbackItems}}');
+ expect(prompt).toContain('stories');
+ expect(prompt).toContain('changes_made');
+ expect(prompt).toContain('rationale');
+ });
+ });
+ });
+
+ // ============ Constructor Tests ============
+
+ describe('constructor', () => {
+ it('should default to PRD document type', () => {
+ const engine = new SynthesisEngine();
+ expect(engine.documentType).toBe('prd');
+ });
+
+ it('should accept document type option', () => {
+ const engine = new SynthesisEngine({ documentType: 'epic' });
+ expect(engine.documentType).toBe('epic');
+ });
+ });
+
+ // ============ analyzeFeedback Tests ============
+
+ describe('analyzeFeedback', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine({ documentType: 'prd' });
+ });
+
+ it('should analyze feedback by section', async () => {
+ const feedbackBySection = {
+ 'user-stories': [
+ {
+ id: 1,
+ title: 'Add login flow',
+ feedbackType: 'suggestion',
+ priority: 'high',
+ submittedBy: 'alice',
+ body: 'Need login flow description'
+ }
+ ],
+ 'fr-3': [
+ {
+ id: 2,
+ title: 'Timeout concern',
+ feedbackType: 'concern',
+ priority: 'high',
+ submittedBy: 'bob',
+ body: 'Session timeout too long'
+ }
+ ]
+ };
+
+ const originalDocument = {
+ 'user-stories': 'Current user story text',
+ 'fr-3': 'FR-3 original text'
+ };
+
+ const analysis = await engine.analyzeFeedback(feedbackBySection, originalDocument);
+
+ expect(analysis.sections).toBeDefined();
+ expect(Object.keys(analysis.sections)).toHaveLength(2);
+ expect(analysis.sections['user-stories'].feedbackCount).toBe(1);
+ expect(analysis.sections['fr-3'].feedbackCount).toBe(1);
+ });
+
+ it('should collect conflicts from all sections', async () => {
+ const feedbackBySection = {
+ 'security': [
+ {
+ id: 1,
+ title: 'Short timeout',
+ feedbackType: 'concern',
+ priority: 'high',
+ submittedBy: 'security',
+ body: 'timeout should be 15 min',
+ suggestedChange: '15 minute timeout'
+ },
+ {
+ id: 2,
+ title: 'Long timeout',
+ feedbackType: 'concern',
+ priority: 'medium',
+ submittedBy: 'ux',
+ body: 'timeout should be 30 min',
+ suggestedChange: '30 minute timeout'
+ }
+ ]
+ };
+
+ const analysis = await engine.analyzeFeedback(feedbackBySection, {});
+
+ expect(analysis.conflicts.length).toBeGreaterThanOrEqual(0);
+ // Conflicts are detected based on keyword matching
+ });
+
+ it('should generate summary statistics', async () => {
+ const feedbackBySection = {
+ 'section1': [
+ { id: 1, title: 'FB1', feedbackType: 'clarification', submittedBy: 'user1' },
+ { id: 2, title: 'FB2', feedbackType: 'concern', submittedBy: 'user2' }
+ ],
+ 'section2': [
+ { id: 3, title: 'FB3', feedbackType: 'suggestion', submittedBy: 'user3' }
+ ]
+ };
+
+ const analysis = await engine.analyzeFeedback(feedbackBySection, {});
+
+ expect(analysis.summary.totalFeedback).toBe(3);
+ expect(analysis.summary.sectionsWithFeedback).toBe(2);
+ expect(analysis.summary.feedbackByType).toBeDefined();
+ });
+ });
+
+ // ============ _analyzeSection Tests ============
+
+ describe('_analyzeSection', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine({ documentType: 'prd' });
+ });
+
+ it('should count feedback and group by type', async () => {
+ const feedbackList = [
+ { id: 1, feedbackType: 'clarification', title: 'Q1' },
+ { id: 2, feedbackType: 'clarification', title: 'Q2' },
+ { id: 3, feedbackType: 'concern', title: 'C1' }
+ ];
+
+ const result = await engine._analyzeSection('test-section', feedbackList, '');
+
+ expect(result.feedbackCount).toBe(3);
+ expect(result.byType.clarification).toBe(2);
+ expect(result.byType.concern).toBe(1);
+ });
+
+ it('should generate suggested changes for non-conflicting feedback', async () => {
+ const feedbackList = [
+ {
+ id: 1,
+ title: 'Add validation',
+ feedbackType: 'suggestion',
+ priority: 'high',
+ suggestedChange: 'Add input validation',
+ submittedBy: 'alice'
+ }
+ ];
+
+ const result = await engine._analyzeSection('test-section', feedbackList, '');
+
+ expect(result.suggestedChanges).toHaveLength(1);
+ expect(result.suggestedChanges[0].feedbackId).toBe(1);
+ expect(result.suggestedChanges[0].type).toBe('suggestion');
+ expect(result.suggestedChanges[0].suggestedChange).toBe('Add input validation');
+ });
+ });
+
+ // ============ _identifyConflicts Tests ============
+
+ describe('_identifyConflicts', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should detect conflicts when same topic has different suggestions', () => {
+ const feedbackList = [
+ {
+ id: 1,
+ title: 'timeout should be shorter',
+ body: 'Session timeout configuration',
+ suggestedChange: 'Set to 15 minutes'
+ },
+ {
+ id: 2,
+ title: 'timeout should be longer',
+ body: 'Session timeout configuration',
+ suggestedChange: 'Set to 30 minutes'
+ }
+ ];
+
+ const conflicts = engine._identifyConflicts(feedbackList);
+
+ expect(conflicts.length).toBeGreaterThan(0);
+ const timeoutConflict = conflicts.find(c => c.topic === 'timeout');
+ expect(timeoutConflict).toBeDefined();
+ expect(timeoutConflict.feedbackIds).toContain(1);
+ expect(timeoutConflict.feedbackIds).toContain(2);
+ });
+
+ it('should not detect conflict when suggestions are the same', () => {
+ const feedbackList = [
+ {
+ id: 1,
+ title: 'auth improvement',
+ body: 'Authentication flow',
+ suggestedChange: 'Add OAuth'
+ },
+ {
+ id: 2,
+ title: 'auth needed',
+ body: 'Authentication required',
+ suggestedChange: 'Add OAuth'
+ }
+ ];
+
+ const conflicts = engine._identifyConflicts(feedbackList);
+
+ // Same suggestion = no conflict
+ const authConflict = conflicts.find(c =>
+ c.feedbackIds.includes(1) && c.feedbackIds.includes(2) &&
+ c.description.includes('Conflicting')
+ );
+ expect(authConflict).toBeUndefined();
+ });
+
+ it('should not detect conflict for single feedback item', () => {
+ const feedbackList = [
+ {
+ id: 1,
+ title: 'unique topic here',
+ body: 'Only one feedback on this',
+ suggestedChange: 'Some change'
+ }
+ ];
+
+ const conflicts = engine._identifyConflicts(feedbackList);
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it('should handle feedback without suggestedChange', () => {
+ const feedbackList = [
+ {
+ id: 1,
+ title: 'question about feature',
+ body: 'What does this do?'
+ // No suggestedChange
+ },
+ {
+ id: 2,
+ title: 'another question feature',
+ body: 'How does this work?'
+ // No suggestedChange
+ }
+ ];
+
+ // Should not throw, and no conflicts detected (no different suggestions)
+ const conflicts = engine._identifyConflicts(feedbackList);
+ expect(Array.isArray(conflicts)).toBe(true);
+ });
+ });
+
+ // ============ _identifyThemes Tests ============
+
+ describe('_identifyThemes', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should identify themes mentioned by multiple people', () => {
+ const feedbackList = [
+ { id: 1, title: 'authentication needs work', feedbackType: 'concern' },
+ { id: 2, title: 'authentication is unclear', feedbackType: 'clarification' },
+ { id: 3, title: 'completely different topic', feedbackType: 'suggestion' }
+ ];
+
+ const themes = engine._identifyThemes(feedbackList);
+
+ const authTheme = themes.find(t => t.keyword === 'authentication');
+ expect(authTheme).toBeDefined();
+ expect(authTheme.count).toBe(2);
+ expect(authTheme.feedbackIds).toContain(1);
+ expect(authTheme.feedbackIds).toContain(2);
+ });
+
+ it('should track feedback types for each theme', () => {
+ const feedbackList = [
+ { id: 1, title: 'security concern here', feedbackType: 'concern' },
+ { id: 2, title: 'security suggestion', feedbackType: 'suggestion' }
+ ];
+
+ const themes = engine._identifyThemes(feedbackList);
+
+ const securityTheme = themes.find(t => t.keyword === 'security');
+ expect(securityTheme).toBeDefined();
+ expect(securityTheme.types).toContain('concern');
+ expect(securityTheme.types).toContain('suggestion');
+ });
+
+ it('should sort themes by count descending', () => {
+ const feedbackList = [
+ { id: 1, title: 'rare topic', feedbackType: 'concern' },
+ { id: 2, title: 'common topic', feedbackType: 'concern' },
+ { id: 3, title: 'common topic again', feedbackType: 'suggestion' },
+ { id: 4, title: 'common topic still', feedbackType: 'clarification' }
+ ];
+
+ const themes = engine._identifyThemes(feedbackList);
+
+ if (themes.length > 0) {
+ // First theme should have highest count
+ for (let i = 1; i < themes.length; i++) {
+ expect(themes[i - 1].count).toBeGreaterThanOrEqual(themes[i].count);
+ }
+ }
+ });
+
+ it('should filter out themes with count < 2', () => {
+ const feedbackList = [
+ { id: 1, title: 'unique topic alpha', feedbackType: 'concern' },
+ { id: 2, title: 'unique topic beta', feedbackType: 'suggestion' },
+ { id: 3, title: 'unique topic gamma', feedbackType: 'clarification' }
+ ];
+
+ const themes = engine._identifyThemes(feedbackList);
+
+ // All unique words should be filtered out (count < 2)
+ for (const theme of themes) {
+ expect(theme.count).toBeGreaterThanOrEqual(2);
+ }
+ });
+ });
+
+ // ============ _extractKeywords Tests ============
+
+ describe('_extractKeywords', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should extract meaningful keywords from text', () => {
+ const keywords = engine._extractKeywords('The authentication flow needs improvement');
+
+ expect(keywords).toContain('authentication');
+ expect(keywords).toContain('flow');
+ expect(keywords).toContain('needs');
+ expect(keywords).toContain('improvement');
+ });
+
+ it('should filter out stop words', () => {
+ const keywords = engine._extractKeywords('The user should be able to login');
+
+ expect(keywords).not.toContain('the');
+ expect(keywords).not.toContain('should');
+ expect(keywords).not.toContain('be');
+ expect(keywords).not.toContain('to');
+ });
+
+ it('should filter out short words (length <= 3)', () => {
+ const keywords = engine._extractKeywords('API is not working');
+
+ expect(keywords).not.toContain('api');
+ expect(keywords).not.toContain('is');
+ expect(keywords).not.toContain('not');
+ expect(keywords).toContain('working');
+ });
+
+ it('should convert to lowercase', () => {
+ const keywords = engine._extractKeywords('SECURITY Authentication');
+
+ expect(keywords).toContain('security');
+ expect(keywords).toContain('authentication');
+ expect(keywords).not.toContain('SECURITY');
+ });
+
+ it('should remove punctuation', () => {
+ const keywords = engine._extractKeywords('User-authentication, session.timeout!');
+
+ // Should normalize punctuation
+ const hasAuth = keywords.some(k => k.includes('auth'));
+ expect(hasAuth).toBe(true);
+ });
+
+ it('should handle null/undefined input', () => {
+ expect(engine._extractKeywords(null)).toEqual([]);
+ expect(engine._extractKeywords(undefined)).toEqual([]);
+ expect(engine._extractKeywords('')).toEqual([]);
+ });
+
+ it('should limit to 10 keywords', () => {
+ const longText = 'authentication authorization validation configuration implementation documentation optimization visualization serialization deserialization normalization denormalization extra words here';
+
+ const keywords = engine._extractKeywords(longText);
+
+ expect(keywords.length).toBeLessThanOrEqual(10);
+ });
+ });
+
+ // ============ generateConflictResolution Tests ============
+
+ describe('generateConflictResolution', () => {
+ it('should generate resolution prompt for PRD', () => {
+ const engine = new SynthesisEngine({ documentType: 'prd' });
+
+ const conflict = {
+ section: 'FR-5',
+ description: 'Conflicting views on session timeout'
+ };
+
+ const result = engine.generateConflictResolution(
+ conflict,
+ 'Session timeout is 30 minutes.',
+ [
+ { user: 'security', position: '15 minutes for security' },
+ { user: 'ux', position: '30 minutes for usability' }
+ ]
+ );
+
+ expect(result.prompt).toContain('FR-5');
+ expect(result.prompt).toContain('Session timeout is 30 minutes');
+ expect(result.prompt).toContain('Conflicting views on session timeout');
+ expect(result.conflict).toEqual(conflict);
+ expect(result.expectedFormat).toHaveProperty('proposed_text');
+ expect(result.expectedFormat).toHaveProperty('rationale');
+ expect(result.expectedFormat).toHaveProperty('trade_offs');
+ expect(result.expectedFormat).toHaveProperty('confidence');
+ });
+
+ it('should throw error for Epic (no resolution prompt available)', () => {
+ const engine = new SynthesisEngine({ documentType: 'epic' });
+
+ const conflict = {
+ section: 'Story Breakdown',
+ description: 'Disagreement on story granularity'
+ };
+
+ // Epic prompts only have grouping and storySplit, not resolution
+ expect(() => {
+ engine.generateConflictResolution(
+ conflict,
+ 'Epic contains 5 stories',
+ []
+ );
+ }).toThrow();
+ });
+
+ it('should handle missing originalText', () => {
+ const engine = new SynthesisEngine({ documentType: 'prd' });
+
+ const conflict = {
+ section: 'New Section',
+ description: 'Need new content'
+ };
+
+ const result = engine.generateConflictResolution(conflict, null, []);
+
+ expect(result.prompt).toContain('N/A');
+ });
+ });
+
+ // ============ generateMergePrompt Tests ============
+
+ describe('generateMergePrompt', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine({ documentType: 'prd' });
+ });
+
+ it('should generate merge prompt with feedback details', () => {
+ const approvedFeedback = [
+ {
+ feedbackType: 'suggestion',
+ title: 'Add error handling',
+ suggestedChange: 'Include try-catch blocks'
+ },
+ {
+ feedbackType: 'addition',
+ title: 'Missing validation',
+ suggestedChange: 'Add input validation'
+ }
+ ];
+
+ const prompt = engine.generateMergePrompt(
+ 'FR-3',
+ 'Original function implementation',
+ approvedFeedback
+ );
+
+ expect(prompt).toContain('FR-3');
+ expect(prompt).toContain('Original function implementation');
+ expect(prompt).toContain('suggestion: Add error handling');
+ expect(prompt).toContain('Include try-catch blocks');
+ expect(prompt).toContain('addition: Missing validation');
+ expect(prompt).toContain('Add input validation');
+ });
+
+ it('should handle feedback without suggestedChange', () => {
+ const approvedFeedback = [
+ {
+ feedbackType: 'concern',
+ title: 'Security risk'
+ // No suggestedChange
+ }
+ ];
+
+ const prompt = engine.generateMergePrompt('Security', 'Current text', approvedFeedback);
+
+ expect(prompt).toContain('concern: Security risk');
+ expect(prompt).toContain('Address the concern');
+ });
+ });
+
+ // ============ generateStorySplitPrompt Tests ============
+
+ describe('generateStorySplitPrompt', () => {
+ it('should generate story split prompt for epic', () => {
+ const engine = new SynthesisEngine({ documentType: 'epic' });
+
+ const prompt = engine.generateStorySplitPrompt(
+ 'epic:2',
+ 'Authentication epic for user login and session management',
+ [
+ { key: '2-1', title: 'Login Form' },
+ { key: '2-2', title: 'Session Management' }
+ ],
+ [
+ { id: 1, title: 'Story 2-2 too large', suggestedChange: 'Split into 3 stories' }
+ ]
+ );
+
+ expect(prompt).toContain('epic:2');
+ expect(prompt).toContain('Authentication epic');
+ expect(prompt).toContain('2-1');
+ expect(prompt).toContain('Login Form');
+ expect(prompt).toContain('Story 2-2 too large');
+ });
+
+ it('should throw error when called for PRD', () => {
+ const engine = new SynthesisEngine({ documentType: 'prd' });
+
+ expect(() => {
+ engine.generateStorySplitPrompt('prd:1', 'desc', [], []);
+ }).toThrow('Story split is only available for epics');
+ });
+ });
+
+ // ============ _generateSummary Tests ============
+
+ describe('_generateSummary', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should calculate total feedback count', () => {
+ const analysis = {
+ sections: {
+ 'section1': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } },
+ 'section2': { feedbackCount: 2, byType: { clarification: 2 } }
+ },
+ conflicts: [],
+ suggestedChanges: []
+ };
+
+ const summary = engine._generateSummary(analysis);
+
+ expect(summary.totalFeedback).toBe(5);
+ });
+
+ it('should count sections with feedback', () => {
+ const analysis = {
+ sections: {
+ 'section1': { feedbackCount: 1, byType: {} },
+ 'section2': { feedbackCount: 2, byType: {} },
+ 'section3': { feedbackCount: 1, byType: {} }
+ },
+ conflicts: [],
+ suggestedChanges: []
+ };
+
+ const summary = engine._generateSummary(analysis);
+
+ expect(summary.sectionsWithFeedback).toBe(3);
+ });
+
+ it('should aggregate feedback by type across sections', () => {
+ const analysis = {
+ sections: {
+ 'section1': { feedbackCount: 2, byType: { concern: 1, suggestion: 1 } },
+ 'section2': { feedbackCount: 2, byType: { concern: 1, clarification: 1 } }
+ },
+ conflicts: [],
+ suggestedChanges: []
+ };
+
+ const summary = engine._generateSummary(analysis);
+
+ expect(summary.feedbackByType.concern).toBe(2);
+ expect(summary.feedbackByType.suggestion).toBe(1);
+ expect(summary.feedbackByType.clarification).toBe(1);
+ });
+
+ it('should set needsAttention when conflicts exist', () => {
+ const analysisWithConflicts = {
+ sections: {},
+ conflicts: [{ section: 'test', description: 'conflict' }],
+ suggestedChanges: []
+ };
+
+ const analysisWithoutConflicts = {
+ sections: {},
+ conflicts: [],
+ suggestedChanges: []
+ };
+
+ expect(engine._generateSummary(analysisWithConflicts).needsAttention).toBe(true);
+ expect(engine._generateSummary(analysisWithoutConflicts).needsAttention).toBe(false);
+ });
+
+ it('should count conflicts and changes', () => {
+ const analysis = {
+ sections: {},
+ conflicts: [{ id: 1 }, { id: 2 }],
+ suggestedChanges: [{ id: 1 }, { id: 2 }, { id: 3 }]
+ };
+
+ const summary = engine._generateSummary(analysis);
+
+ expect(summary.conflictCount).toBe(2);
+ expect(summary.changeCount).toBe(3);
+ });
+ });
+
+ // ============ _groupByType Tests ============
+
+ describe('_groupByType', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should count feedback by type', () => {
+ const feedbackList = [
+ { feedbackType: 'concern' },
+ { feedbackType: 'concern' },
+ { feedbackType: 'suggestion' },
+ { feedbackType: 'clarification' }
+ ];
+
+ const byType = engine._groupByType(feedbackList);
+
+ expect(byType.concern).toBe(2);
+ expect(byType.suggestion).toBe(1);
+ expect(byType.clarification).toBe(1);
+ });
+
+ it('should handle empty list', () => {
+ const byType = engine._groupByType([]);
+ expect(byType).toEqual({});
+ });
+ });
+
+ // ============ formatForDisplay Tests ============
+
+ describe('formatForDisplay', () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = new SynthesisEngine();
+ });
+
+ it('should format analysis as markdown', () => {
+ const analysis = {
+ summary: {
+ totalFeedback: 5,
+ sectionsWithFeedback: 2,
+ conflictCount: 1,
+ changeCount: 3,
+ needsAttention: true
+ },
+ sections: {
+ 'user-stories': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } },
+ 'fr-3': { feedbackCount: 2, byType: { clarification: 2 } }
+ },
+ conflicts: [
+ {
+ section: 'user-stories',
+ description: 'Timeout conflict',
+ stakeholders: [
+ { user: 'security', position: '15 min' },
+ { user: 'ux', position: '30 min' }
+ ]
+ }
+ ]
+ };
+
+ const output = engine.formatForDisplay(analysis);
+
+ expect(output).toContain('## Synthesis Analysis');
+ expect(output).toContain('**Total Feedback:** 5');
+ expect(output).toContain('**Sections with Feedback:** 2');
+ expect(output).toContain('**Conflicts Detected:** 1');
+ expect(output).toContain('**Suggested Changes:** 3');
+ expect(output).toContain('⚠️ Conflicts Requiring Resolution');
+ expect(output).toContain('user-stories');
+ expect(output).toContain('@security');
+ expect(output).toContain('@ux');
+ expect(output).toContain('### By Section');
+ });
+
+ it('should not show conflicts section when none exist', () => {
+ const analysis = {
+ summary: {
+ totalFeedback: 1,
+ sectionsWithFeedback: 1,
+ conflictCount: 0,
+ changeCount: 1,
+ needsAttention: false
+ },
+ sections: {
+ 'test': { feedbackCount: 1, byType: { suggestion: 1 } }
+ },
+ conflicts: []
+ };
+
+ const output = engine.formatForDisplay(analysis);
+
+ expect(output).not.toContain('⚠️ Conflicts Requiring Resolution');
+ });
+ });
+});
diff --git a/test/unit/notifications/email-notifier.test.js b/test/unit/notifications/email-notifier.test.js
new file mode 100644
index 00000000..eba326bd
--- /dev/null
+++ b/test/unit/notifications/email-notifier.test.js
@@ -0,0 +1,578 @@
+/**
+ * Tests for EmailNotifier - Email notification integration
+ *
+ * Tests cover:
+ * - Email templates with HTML and text formats
+ * - Template rendering
+ * - Enable/disable behavior based on config
+ * - Recipient lookup from userEmails mapping
+ * - Multiple email providers (SMTP, SendGrid, SES)
+ * - Error handling
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ EmailNotifier,
+ EMAIL_TEMPLATES
+} from '../../../src/modules/bmm/lib/notifications/email-notifier.js';
+
+describe('EmailNotifier', () => {
+ // ============ EMAIL_TEMPLATES Tests ============
+
+ describe('EMAIL_TEMPLATES', () => {
+ it('should define all required event types', () => {
+ const expectedTypes = [
+ 'feedback_round_opened',
+ 'signoff_requested',
+ 'document_approved',
+ 'document_blocked',
+ 'reminder'
+ ];
+
+ for (const type of expectedTypes) {
+ expect(EMAIL_TEMPLATES[type]).toBeDefined();
+ expect(EMAIL_TEMPLATES[type].subject).toBeTruthy();
+ expect(EMAIL_TEMPLATES[type].html).toBeTruthy();
+ expect(EMAIL_TEMPLATES[type].text).toBeTruthy();
+ }
+ });
+
+ it('should have placeholders in subject lines', () => {
+ expect(EMAIL_TEMPLATES.feedback_round_opened.subject).toContain('{{document_type}}');
+ expect(EMAIL_TEMPLATES.feedback_round_opened.subject).toContain('{{document_key}}');
+ });
+
+ it('should have matching placeholders in HTML and text', () => {
+ const template = EMAIL_TEMPLATES.document_approved;
+
+ // Both should contain key placeholders
+ expect(template.html).toContain('{{document_type}}');
+ expect(template.html).toContain('{{document_key}}');
+ expect(template.html).toContain('{{title}}');
+ expect(template.html).toContain('{{version}}');
+
+ expect(template.text).toContain('{{document_type}}');
+ expect(template.text).toContain('{{document_key}}');
+ expect(template.text).toContain('{{title}}');
+ expect(template.text).toContain('{{version}}');
+ });
+
+ it('should have valid HTML structure', () => {
+ const template = EMAIL_TEMPLATES.signoff_requested;
+
+ expect(template.html).toContain('');
+ expect(template.html).toContain('');
+ expect(template.html).toContain('');
+ expect(template.html).toContain('');
+ expect(template.html).toContain('');
+ });
+
+ it('should have styled content in HTML templates', () => {
+ const template = EMAIL_TEMPLATES.feedback_round_opened;
+
+ expect(template.html).toContain('