feat: add multi-scope parallel artifacts system and fork customization

- Add scope command for managing multiple artifact scopes in parallel
- Add pre-push hook to enforce single commit per branch
- Fix publish workflow to check version before publishing
- Remove redundant publish script from package.json
This commit is contained in:
fairyhunter13 2026-01-21 20:05:31 +07:00
parent 01bbe2a3ef
commit 6874ced1f6
44 changed files with 12774 additions and 23 deletions

129
.githooks/post-checkout Executable file
View File

@ -0,0 +1,129 @@
#!/bin/bash
# .githooks/post-checkout
# Git hook for BMAD-METHOD contributors to provide sync reminders
#
# This hook provides helpful reminders when:
# 1. Switching to main branch
# 2. Switching from main to feature branch
# 3. Creating new branches
# Color codes for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
UPSTREAM_REMOTE="upstream"
MAIN_BRANCH="main"
# Arguments passed by git
# $1: ref of previous HEAD
# $2: ref of new HEAD
# $3: flag indicating branch checkout (1) or file checkout (0)
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_CHECKOUT=$3
# Only run for branch checkouts, not file checkouts
if [ "$BRANCH_CHECKOUT" != "1" ]; then
exit 0
fi
# Get branch names
NEW_BRANCH=$(git branch --show-current)
PREV_BRANCH=$(git reflog -1 | grep -oP 'moving from \K[^ ]+' || echo "")
# Skip if we couldn't determine branches
if [ -z "$NEW_BRANCH" ]; then
exit 0
fi
echo -e "${BLUE}[post-checkout] Switched to branch: $NEW_BRANCH${NC}"
# Check if upstream remote exists
HAS_UPSTREAM=false
if git remote | grep -q "^${UPSTREAM_REMOTE}$"; then
HAS_UPSTREAM=true
fi
# Case 1: Switched TO main branch
if [ "$NEW_BRANCH" = "$MAIN_BRANCH" ]; then
echo ""
if [ "$HAS_UPSTREAM" = true ]; then
# Check if main is behind upstream
git fetch "$UPSTREAM_REMOTE" --quiet 2>/dev/null || true
LOCAL_MAIN=$(git rev-parse "$MAIN_BRANCH" 2>/dev/null || echo "")
UPSTREAM_MAIN=$(git rev-parse "${UPSTREAM_REMOTE}/${MAIN_BRANCH}" 2>/dev/null || echo "")
if [ -n "$LOCAL_MAIN" ] && [ -n "$UPSTREAM_MAIN" ]; then
if [ "$LOCAL_MAIN" != "$UPSTREAM_MAIN" ]; then
if git merge-base --is-ancestor "$LOCAL_MAIN" "$UPSTREAM_MAIN"; then
echo -e "${YELLOW}💡 Your local '$MAIN_BRANCH' is behind upstream${NC}"
echo -e "${YELLOW} Sync with: git pull $UPSTREAM_REMOTE $MAIN_BRANCH${NC}"
elif ! git merge-base --is-ancestor "$UPSTREAM_MAIN" "$LOCAL_MAIN"; then
echo -e "${RED}⚠️ Your local '$MAIN_BRANCH' has diverged from upstream${NC}"
echo -e "${YELLOW} Reset with: git reset --hard $UPSTREAM_REMOTE/$MAIN_BRANCH${NC}"
else
echo -e "${GREEN}✓ Your local '$MAIN_BRANCH' is synced with upstream${NC}"
fi
else
echo -e "${GREEN}✓ Your local '$MAIN_BRANCH' is synced with upstream${NC}"
fi
fi
else
echo -e "${YELLOW}💡 Tip: Add upstream remote for easier syncing:${NC}"
echo -e " git remote add $UPSTREAM_REMOTE git@github.com:bmad-code-org/BMAD-METHOD.git"
fi
echo ""
fi
# Case 2: Switched FROM main to feature branch
if [ "$PREV_BRANCH" = "$MAIN_BRANCH" ] && [ "$NEW_BRANCH" != "$MAIN_BRANCH" ]; then
echo ""
if [ "$HAS_UPSTREAM" = true ]; then
# Check if current branch is based on latest main
MERGE_BASE=$(git merge-base "$NEW_BRANCH" "$MAIN_BRANCH" 2>/dev/null || echo "")
MAIN_HEAD=$(git rev-parse "$MAIN_BRANCH" 2>/dev/null || echo "")
if [ -n "$MERGE_BASE" ] && [ -n "$MAIN_HEAD" ] && [ "$MERGE_BASE" != "$MAIN_HEAD" ]; then
echo -e "${YELLOW}💡 This branch may need rebasing on latest '$MAIN_BRANCH'${NC}"
echo -e " Rebase with: git rebase $MAIN_BRANCH"
fi
fi
# Remind about single-commit workflow
COMMIT_COUNT=$(git rev-list --count "${MAIN_BRANCH}..${NEW_BRANCH}" 2>/dev/null || echo "0")
if [ "$COMMIT_COUNT" -gt 1 ]; then
echo -e "${YELLOW}💡 This branch has $COMMIT_COUNT commits${NC}"
echo -e "${YELLOW} Remember to maintain single-commit workflow before pushing${NC}"
echo -e " Squash with: git reset --soft $MAIN_BRANCH && git commit"
fi
echo ""
fi
# Case 3: Creating a new branch (both refs are the same)
if [ "$PREV_HEAD" = "$NEW_HEAD" ] && [ "$PREV_BRANCH" != "$NEW_BRANCH" ]; then
echo ""
echo -e "${GREEN}✓ New branch created: $NEW_BRANCH${NC}"
echo -e "${BLUE}💡 Remember the single-commit workflow:${NC}"
echo -e " 1. Make your changes"
echo -e " 2. Commit once: git commit -m 'feat: description'"
echo -e " 3. Update with: git commit --amend (not new commits)"
echo -e " 4. Push with: git push --force-with-lease"
echo ""
fi
# General reminder for feature branches (not main)
if [ "$NEW_BRANCH" != "$MAIN_BRANCH" ]; then
# Check if hooks are configured
CURRENT_HOOKS_PATH=$(git config core.hooksPath || echo "")
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
echo -e "${YELLOW}💡 Tip: Enable git hooks with:${NC}"
echo -e " git config core.hooksPath .githooks"
echo ""
fi
fi
exit 0

63
.githooks/pre-commit Executable file
View File

@ -0,0 +1,63 @@
#!/bin/bash
# .githooks/pre-commit
# Git hook for BMAD-METHOD contributors to enforce clean commit practices
#
# This hook ensures:
# 1. No direct commits to main
# 2. Provides guidance on amend workflow
set -e
# Color codes for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
MAIN_BRANCH="main"
# Get current branch
CURRENT_BRANCH=$(git branch --show-current)
# 1. Block commits to main
if [ "$CURRENT_BRANCH" = "$MAIN_BRANCH" ]; then
echo -e "${RED}❌ ERROR: Direct commits to '$MAIN_BRANCH' are not allowed!${NC}"
echo ""
echo -e "${YELLOW}The main branch should only be updated by syncing with upstream.${NC}"
echo -e "${YELLOW}To work on changes:${NC}"
echo -e " 1. Create a feature branch: git checkout -b feat/your-feature"
echo -e " 2. Make your changes"
echo -e " 3. Commit: git commit -m 'feat: your feature description'"
echo -e " 4. Push: git push -u origin feat/your-feature"
exit 1
fi
# 2. Check if this is an amend
if [ -f ".git/COMMIT_EDITMSG" ]; then
# Get the count of commits ahead of main
COMMIT_COUNT=$(git rev-list --count "${MAIN_BRANCH}..${CURRENT_BRANCH}" 2>/dev/null || echo "0")
# If we have exactly 1 commit and user is making another commit (not amending),
# suggest using amend instead
if [ "$COMMIT_COUNT" -eq 1 ] && [ -z "$GIT_REFLOG_ACTION" ]; then
# Check if this is likely a new commit (not an amend)
# by seeing if the commit message is being edited
if ! git diff --cached --quiet; then
echo -e "${BLUE}[pre-commit] Info: You have 1 commit on this branch${NC}"
echo -e "${YELLOW}💡 Tip: Consider using 'git commit --amend' to update your existing commit${NC}"
echo -e "${YELLOW} This maintains the single-commit-per-branch workflow.${NC}"
echo ""
echo -e "${YELLOW}To amend:${NC}"
echo -e " git add <your-files>"
echo -e " git commit --amend"
echo ""
echo -e "${YELLOW}Proceeding with new commit...${NC}"
# This is just a helpful tip, not blocking
fi
fi
fi
echo -e "${GREEN}✓ Pre-commit checks passed${NC}"
exit 0

135
.githooks/pre-push Executable file
View File

@ -0,0 +1,135 @@
#!/bin/bash
# .githooks/pre-push
# Git hook for BMAD-METHOD contributors to enforce clean git workflow
#
# This hook ensures:
# 1. Upstream remote is configured
# 2. No direct pushes to main
# 3. Local main is synced with upstream
# 4. Branch is rebased on main
# 5. Single-commit-per-branch workflow
set -e
# Color codes for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
UPSTREAM_REMOTE="upstream"
UPSTREAM_URL="git@github.com:bmad-code-org/BMAD-METHOD.git"
MAIN_BRANCH="main"
echo -e "${BLUE}[pre-push] Running pre-push checks...${NC}"
# Get current branch
CURRENT_BRANCH=$(git branch --show-current)
# Read push details from stdin (remote and url)
while read local_ref local_sha remote_ref remote_sha; do
# Extract branch name from ref
if [[ "$remote_ref" =~ refs/heads/(.+) ]]; then
PUSH_BRANCH="${BASH_REMATCH[1]}"
else
continue
fi
# 1. Block direct push to main
if [ "$PUSH_BRANCH" = "$MAIN_BRANCH" ]; then
echo -e "${RED}❌ ERROR: Direct push to '$MAIN_BRANCH' is not allowed!${NC}"
echo -e "${YELLOW}The main branch should only be updated by syncing with upstream.${NC}"
echo -e "${YELLOW}To update main, run:${NC}"
echo -e " git checkout main && git pull upstream main"
exit 1
fi
done
# 2. Ensure upstream remote exists
if ! git remote | grep -q "^${UPSTREAM_REMOTE}$"; then
echo -e "${RED}❌ ERROR: Upstream remote '${UPSTREAM_REMOTE}' not configured!${NC}"
echo -e "${YELLOW}Add it with:${NC}"
echo -e " git remote add ${UPSTREAM_REMOTE} ${UPSTREAM_URL}"
exit 1
fi
# Verify upstream URL
CURRENT_UPSTREAM=$(git remote get-url "$UPSTREAM_REMOTE" 2>/dev/null || echo "")
if [[ "$CURRENT_UPSTREAM" != *"bmad-code-org/BMAD-METHOD"* ]]; then
echo -e "${YELLOW}⚠️ WARNING: Upstream remote doesn't point to bmad-code-org/BMAD-METHOD${NC}"
echo -e "${YELLOW}Current: $CURRENT_UPSTREAM${NC}"
echo -e "${YELLOW}Expected: ${UPSTREAM_URL}${NC}"
fi
# 3. Fetch upstream
echo -e "${BLUE}Fetching upstream...${NC}"
if ! git fetch "$UPSTREAM_REMOTE" --quiet 2>/dev/null; then
echo -e "${RED}❌ ERROR: Failed to fetch from upstream${NC}"
echo -e "${YELLOW}Check your network connection and SSH keys${NC}"
exit 1
fi
# 4. Check if local main is synced with upstream
LOCAL_MAIN=$(git rev-parse "$MAIN_BRANCH" 2>/dev/null || echo "")
UPSTREAM_MAIN=$(git rev-parse "${UPSTREAM_REMOTE}/${MAIN_BRANCH}" 2>/dev/null || echo "")
if [ -n "$LOCAL_MAIN" ] && [ -n "$UPSTREAM_MAIN" ] && [ "$LOCAL_MAIN" != "$UPSTREAM_MAIN" ]; then
# Check if local main is behind
if git merge-base --is-ancestor "$LOCAL_MAIN" "$UPSTREAM_MAIN"; then
echo -e "${YELLOW}⚠️ WARNING: Your local '$MAIN_BRANCH' is behind upstream${NC}"
echo -e "${YELLOW}Sync it with:${NC}"
echo -e " git checkout $MAIN_BRANCH && git pull $UPSTREAM_REMOTE $MAIN_BRANCH"
echo -e "${YELLOW}Then rebase your branch:${NC}"
echo -e " git checkout $CURRENT_BRANCH && git rebase $MAIN_BRANCH"
echo ""
# This is a warning, not blocking (allows push but warns)
elif ! git merge-base --is-ancestor "$UPSTREAM_MAIN" "$LOCAL_MAIN"; then
echo -e "${RED}❌ ERROR: Your local '$MAIN_BRANCH' has diverged from upstream${NC}"
echo -e "${YELLOW}Reset it with:${NC}"
echo -e " git checkout $MAIN_BRANCH"
echo -e " git reset --hard $UPSTREAM_REMOTE/$MAIN_BRANCH"
exit 1
fi
fi
# 5. Check branch is rebased on main
MERGE_BASE=$(git merge-base "$CURRENT_BRANCH" "$MAIN_BRANCH")
MAIN_HEAD=$(git rev-parse "$MAIN_BRANCH")
if [ "$MERGE_BASE" != "$MAIN_HEAD" ]; then
echo -e "${RED}❌ ERROR: Branch '$CURRENT_BRANCH' is not rebased on latest '$MAIN_BRANCH'${NC}"
echo -e "${YELLOW}Rebase with:${NC}"
echo -e " git rebase $MAIN_BRANCH"
exit 1
fi
# 6. Enforce single commit rule
COMMIT_COUNT=$(git rev-list --count "${MAIN_BRANCH}..${CURRENT_BRANCH}")
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo -e "${RED}❌ ERROR: No commits to push (branch is at same state as $MAIN_BRANCH)${NC}"
exit 1
elif [ "$COMMIT_COUNT" -gt 1 ]; then
echo -e "${RED}❌ ERROR: Too many commits! Found $COMMIT_COUNT commits, expected exactly 1${NC}"
echo -e "${YELLOW}This repo uses a single-commit-per-branch workflow.${NC}"
echo ""
echo -e "${YELLOW}Option 1: Squash all commits into one:${NC}"
echo -e " git reset --soft $MAIN_BRANCH"
echo -e " git commit -m 'feat: your feature description'"
echo ""
echo -e "${YELLOW}Option 2: Amend existing commits:${NC}"
echo -e " git add <modified-files>"
echo -e " git commit --amend --no-edit"
echo ""
echo -e "${YELLOW}Then force push with:${NC}"
echo -e " git push --force-with-lease"
exit 1
fi
echo -e "${GREEN}✓ All pre-push checks passed!${NC}"
echo -e "${GREEN}✓ Upstream is configured and synced${NC}"
echo -e "${GREEN}✓ Branch is rebased on main${NC}"
echo -e "${GREEN}✓ Single commit workflow maintained ($COMMIT_COUNT commit)${NC}"
exit 0

68
.github/workflows/publish.yaml vendored Normal file
View File

@ -0,0 +1,68 @@
name: Publish
on:
push:
branches:
- feat/multi-artifact-support
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: npm
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm ci
- name: Set version with commit hash
id: version
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
SHORT_SHA=$(git rev-parse --short HEAD)
NEW_VERSION="${BASE_VERSION}.${SHORT_SHA}"
npm version "${NEW_VERSION}" --no-git-tag-version
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Publish to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Checking if bmad-fh@${VERSION} already exists..."
# Check if version already exists on npm
if npm view "bmad-fh@${VERSION}" version 2>/dev/null; then
echo "Version ${VERSION} already exists on npm, skipping publish"
echo "SKIPPED=true" >> $GITHUB_ENV
else
echo "Publishing bmad-fh@${VERSION}"
npm publish --ignore-scripts
echo "SKIPPED=false" >> $GITHUB_ENV
fi
- name: Summary
run: |
if [ "$SKIPPED" = "true" ]; then
echo "## Skipped - bmad-fh@${{ steps.version.outputs.version }} already exists" >> $GITHUB_STEP_SUMMARY
else
echo "## Published bmad-fh@${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Installation" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "npx bmad-fh install" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

12
.husky/post-checkout Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env sh
# =============================================================================
# Call .githooks/post-checkout first (if exists)
# =============================================================================
if [ -x ".githooks/post-checkout" ]; then
.githooks/post-checkout "$@"
fi
# =============================================================================
# Husky-specific post-checkout logic can be added below
# =============================================================================

View File

@ -1,5 +1,20 @@
#!/usr/bin/env sh
# =============================================================================
# Call .githooks/pre-commit first (if exists)
# =============================================================================
if [ -x ".githooks/pre-commit" ]; then
.githooks/pre-commit "$@"
GITHOOKS_EXIT=$?
if [ $GITHOOKS_EXIT -ne 0 ]; then
exit $GITHOOKS_EXIT
fi
fi
# =============================================================================
# Husky-specific: lint-staged and tests
# =============================================================================
# Auto-fix changed files and stage them
npx --no-install lint-staged
@ -10,11 +25,11 @@ npm test
if command -v rg >/dev/null 2>&1; then
if git diff --cached --name-only | rg -q '^docs/'; then
npm run docs:validate-links
npm run docs:build
npm run docs:build
fi
else
if git diff --cached --name-only | grep -Eq '^docs/'; then
npm run docs:validate-links
npm run docs:build
npm run docs:build
fi
fi

10
.husky/pre-push Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env sh
# Delegate to .githooks/pre-push for comprehensive checks
# (upstream sync, rebase check, single-commit enforcement)
if [ -x ".githooks/pre-push" ]; then
.githooks/pre-push "$@"
else
echo "Warning: .githooks/pre-push not found, skipping custom checks"
fi

View File

@ -128,6 +128,145 @@ Keep messages under 72 characters. Each commit = one logical change.
---
## Git Workflow for Contributors
### One-Time Setup
After forking and cloning the repository, set up your development environment:
```bash
# 1. Add the upstream remote (main repository)
git remote add upstream git@github.com:bmad-code-org/BMAD-METHOD.git
# 2. Enable git hooks (enforces workflow automatically)
git config core.hooksPath .githooks
```
### Single-Commit Workflow
**This repository uses a single-commit-per-branch workflow** to keep PR history clean.
#### Initial Development
```bash
# Create feature branch
git checkout -b feat/your-feature
# Make your changes
# ... edit files ...
# Commit once
git commit -m "feat: add your feature description"
# Push to your fork
git push -u origin feat/your-feature
```
#### Making Additional Changes
**Use amend instead of creating new commits:**
```bash
# Make more changes
# ... edit files ...
# Stage changes
git add .
# Amend the existing commit
git commit --amend --no-edit
# Force push (safely)
git push --force-with-lease
```
#### Addressing PR Feedback
```bash
# Make requested changes
# ... edit files ...
# Amend (don't create new commits)
git commit --amend --no-edit
# Force push
git push --force-with-lease
```
### Keeping Your Branch Updated
```bash
# Sync your local main with upstream
git checkout main
git pull upstream main
# Rebase your feature branch
git checkout feat/your-feature
git rebase main
# Force push (safely)
git push --force-with-lease
```
### What the Git Hooks Do
The hooks in `.githooks/` automate workflow enforcement:
| Hook | Purpose |
|------|---------|
| `pre-push` | Ensures upstream sync, blocks direct push to main, enforces single-commit |
| `pre-commit` | Blocks commits to main, reminds about amend workflow |
| `post-checkout` | Provides sync reminders when switching branches |
**The hooks will automatically:**
- Block commits directly to main branch
- Prevent pushing more than 1 commit per branch
- Warn if your local main is behind upstream
- Remind you to rebase before pushing
### Troubleshooting
#### "Too many commits" error
If you have multiple commits, squash them:
```bash
# Reset to main (keeps your changes)
git reset --soft main
# Create single commit
git commit -m "feat: your feature description"
# Force push
git push --force-with-lease
```
#### Local main diverged from upstream
```bash
# Reset your main to match upstream
git checkout main
git reset --hard upstream/main
git push --force-with-lease origin main
```
#### Branch not rebased on main
```bash
# Update main first
git checkout main
git pull upstream main
# Rebase your branch
git checkout feat/your-feature
git rebase main
# Force push
git push --force-with-lease
```
---
## What Makes a Good PR?
| ✅ Do | ❌ Don't |

144
README.md
View File

@ -23,7 +23,7 @@ Traditional AI tools do the thinking for you, producing average results. BMad ag
**Prerequisites**: [Node.js](https://nodejs.org) v20+
```bash
npx bmad-method@alpha install
npx bmad-fh install
```
Follow the installer prompts to configure your project. Then run:
@ -59,6 +59,148 @@ This analyzes your project and recommends a track:
- **[v4 Documentation](https://github.com/bmad-code-org/BMAD-METHOD/tree/V4/docs)**
## Multi-Scope Parallel Development
BMad supports running multiple workflows in parallel across different terminal sessions with isolated artifacts. Perfect for:
- **Multi-team projects** — Each team works in their own scope
- **Parallel feature development** — Develop auth, payments, and catalog simultaneously
- **Microservices** — One scope per service with shared contracts
- **Experimentation** — Create isolated scopes for spikes and prototypes
### Quick Start
```bash
# Initialize scope system
npx bmad-fh scope init
# Create a scope (you'll be prompted to activate it)
npx bmad-fh scope create auth --name "Authentication Service"
# ✓ Scope 'auth' created successfully!
# ? Set 'auth' as your active scope for this session? (Y/n)
# Run workflows - artifacts now go to _bmad-output/auth/
# The active scope is stored in .bmad-scope file
# For parallel development in different terminals:
# Terminal 1:
npx bmad-fh scope set auth # Activate auth scope
# Terminal 2:
npx bmad-fh scope set payments # Activate payments scope
# Share artifacts between scopes
npx bmad-fh scope sync-up auth # Promote to shared layer
npx bmad-fh scope sync-down payments # Pull shared updates
```
> **Important:** Workflows only use scoped directories when a scope is active.
> After creating a scope, accept the prompt to activate it, or run `scope set <id>` manually.
### CLI Reference
| Command | Description |
| ---------------------------------- | ------------------------------------------- |
| `npx bmad-fh scope init` | Initialize the scope system in your project |
| `npx bmad-fh scope list` | List all scopes (alias: `ls`) |
| `npx bmad-fh scope create <id>` | Create a new scope (alias: `new`) |
| `npx bmad-fh scope info <id>` | Show scope details (alias: `show`) |
| `npx bmad-fh scope set [id]` | Set active scope for session (alias: `use`) |
| `npx bmad-fh scope unset` | Clear active scope (alias: `clear`) |
| `npx bmad-fh scope remove <id>` | Remove a scope (aliases: `rm`, `delete`) |
| `npx bmad-fh scope archive <id>` | Archive a completed scope |
| `npx bmad-fh scope activate <id>` | Reactivate an archived scope |
| `npx bmad-fh scope sync-up <id>` | Promote artifacts to shared layer |
| `npx bmad-fh scope sync-down <id>` | Pull shared updates into scope |
| `npx bmad-fh scope help [cmd]` | Show help (add command for detailed help) |
### Create Options
```bash
npx bmad-fh scope create auth \
--name "Authentication Service" \
--description "User auth, SSO, and session management" \
--deps users,notifications \
--context # Create scope-specific project-context.md
```
### Directory Structure
After initialization and scope creation:
```
project-root/
├── _bmad/
│ ├── _config/
│ │ └── scopes.yaml # Scope registry and settings
│ └── _events/
│ ├── event-log.yaml # Event history
│ └── subscriptions.yaml # Cross-scope subscriptions
├── _bmad-output/
│ ├── _shared/ # Shared knowledge layer
│ │ ├── project-context.md # Global project context
│ │ ├── contracts/ # Integration contracts
│ │ └── principles/ # Architecture principles
│ │
│ ├── auth/ # Auth scope artifacts
│ │ ├── planning-artifacts/
│ │ ├── implementation-artifacts/
│ │ └── tests/
│ │
│ └── payments/ # Payments scope artifacts
│ └── ...
└── .bmad-scope # Session-sticky active scope (gitignored)
```
### Access Model
Scopes follow a "read-any, write-own" isolation model:
| Operation | Own Scope | Other Scopes | \_shared/ |
| --------- | --------- | ------------ | ----------- |
| **Read** | Allowed | Allowed | Allowed |
| **Write** | Allowed | Blocked | via sync-up |
### Workflow Integration
Workflows (run via agent menus like `CP` for Create PRD, `DS` for Dev Story) automatically detect and use scope context. Resolution order:
1. Session context from `.bmad-scope` file (set via `scope set`)
2. `BMAD_SCOPE` environment variable
3. Prompt user to select or create scope
**Setting your active scope:**
```bash
# Set scope for your terminal session
npx bmad-fh scope set auth
# Or use environment variable (useful for CI/CD)
export BMAD_SCOPE=auth
```
**Scope-aware path variables in workflows:**
- `{scope}` → Scope ID (e.g., "auth")
- `{scope_path}``_bmad-output/auth`
- `{scope_planning}``_bmad-output/auth/planning-artifacts`
- `{scope_implementation}``_bmad-output/auth/implementation-artifacts`
- `{scope_tests}``_bmad-output/auth/tests`
### Getting Help
```bash
# Show comprehensive help for all scope commands
npx bmad-fh scope help
# Get detailed help for a specific command
npx bmad-fh scope help create
npx bmad-fh scope help sync-up
```
See [Multi-Scope Guide](docs/multi-scope-guide.md) for complete documentation.
## Community
- [Discord](https://discord.gg/gk8jAdXWmj) — Get help, share ideas, collaborate

385
docs/migration-guide.md Normal file
View File

@ -0,0 +1,385 @@
---
title: 'Migration Guide: Multi-Scope Parallel Artifacts'
description: 'Guide for migrating existing BMAD installations to the multi-scope system'
---
# Migration Guide: Multi-Scope Parallel Artifacts
> Guide for migrating existing BMAD installations to the multi-scope system.
## Overview
The multi-scope system introduces isolated artifact workspaces while maintaining full backward compatibility. Existing installations can:
1. Continue working without any changes (legacy mode)
2. Migrate existing artifacts to a `default` scope
3. Create new scopes for parallel development
## Prerequisites
- BMAD v6+ installed
- Node.js 20+
- Backup of your `_bmad-output/` directory (recommended)
## Migration Paths
### Path 1: Continue Without Migration (Recommended for Simple Projects)
If you have a single-team, single-product workflow, you can continue using BMAD without migration. The scope system is entirely opt-in.
**When to choose this path:**
- Small to medium projects
- Single developer or tightly coordinated team
- No need for parallel feature development
**What happens:**
- Workflows continue to use `_bmad-output/` directly
- No scope variable in paths
- All existing commands work unchanged
### Path 2: Migrate to Default Scope (Recommended for Growing Projects)
Migrate existing artifacts to a `default` scope, enabling future parallel development.
```bash
# 1. Analyze current state
bmad scope migrate --analyze
# 2. Run migration (creates backup automatically)
bmad scope migrate
# 3. Verify migration
bmad scope list
bmad scope info default
```
**What happens:**
- Creates backup at `_bmad-output/_backup_migration_<timestamp>/`
- Initializes scope system
- Creates `default` scope
- Moves artifacts from `_bmad-output/` to `_bmad-output/default/`
- Updates references in state files
- Creates shared layer at `_bmad-output/_shared/`
### Path 3: Fresh Start with Scopes
For new projects or major rewrites, start fresh with the scope system.
```bash
# Initialize scope system
bmad scope init
# Create your first scope
bmad scope create main --name "Main Product"
# Run workflows with scope
bmad workflow create-prd --scope main
```
## Step-by-Step Migration
### Step 1: Backup (Automatic but Verify)
The migration creates an automatic backup, but we recommend creating your own:
```bash
# Manual backup
cp -r _bmad-output/ _bmad-output-backup-$(date +%Y%m%d)/
```
### Step 2: Analyze Current State
```bash
bmad scope migrate --analyze
```
**Example output:**
```
Migration Analysis
==================
Current Structure:
_bmad-output/
├── planning-artifacts/
│ ├── prd.md
│ ├── architecture.md
│ └── epics-stories.md
├── implementation-artifacts/
│ ├── sprint-status.yaml
│ └── stories/
└── tests/
└── ...
Detected Artifacts:
Planning: 3 files
Implementation: 15 files
Tests: 8 files
Migration Plan:
1. Create backup
2. Initialize scope system
3. Create 'default' scope
4. Move all artifacts to default/
5. Create shared layer
6. Update state references
Estimated time: < 30 seconds
```
### Step 3: Run Migration
```bash
bmad scope migrate
```
**Interactive prompts:**
```
? Ready to migrate existing artifacts to 'default' scope? (Y/n)
? Create scope-specific project-context.md? (y/N)
```
### Step 4: Verify Migration
```bash
# Check scope list
bmad scope list
# Verify directory structure
ls -la _bmad-output/
# Check default scope
bmad scope info default
```
**Expected structure after migration:**
```
_bmad-output/
├── _shared/
│ ├── project-context.md
│ ├── contracts/
│ └── principles/
├── default/
│ ├── planning-artifacts/
│ │ ├── prd.md
│ │ ├── architecture.md
│ │ └── epics-stories.md
│ ├── implementation-artifacts/
│ │ ├── sprint-status.yaml
│ │ └── stories/
│ ├── tests/
│ └── .scope-meta.yaml
└── _backup_migration_<timestamp>/
└── (original files)
```
### Step 5: Update Workflows (Optional)
If you have custom workflow configurations, update paths:
**Before:**
```yaml
output_dir: '{output_folder}/planning-artifacts'
```
**After:**
```yaml
output_dir: '{scope_planning}'
# Or: "{output_folder}/{scope}/planning-artifacts"
```
The migration script can update workflows automatically:
```bash
node tools/cli/scripts/migrate-workflows.js --dry-run --verbose
node tools/cli/scripts/migrate-workflows.js
```
## Rollback Procedure
If migration fails or you need to revert:
### Automatic Rollback
```bash
bmad scope migrate --rollback
```
### Manual Rollback
```bash
# Remove migrated structure
rm -rf _bmad-output/default/
rm -rf _bmad-output/_shared/
rm -rf _bmad/_config/scopes.yaml
rm -rf _bmad/_events/
# Restore from backup
cp -r _bmad-output/_backup_migration_<timestamp>/* _bmad-output/
rm -rf _bmad-output/_backup_migration_<timestamp>/
```
## Post-Migration Steps
### 1. Update .gitignore
Add scope-related files to ignore:
```gitignore
# Scope session file (user-specific)
.bmad-scope
# Lock files
*.lock
# Backup directories (optional)
_bmad-output/_backup_*/
```
### 2. Update Team Documentation
Inform your team about the new scope system:
- How to create scopes
- How to run workflows with scopes
- How to use sync-up/sync-down
### 3. Configure Scope Dependencies (Optional)
If your scopes have dependencies:
```bash
# Update scope with dependencies
bmad scope update default --deps shared-lib,core-api
```
### 4. Set Up Event Subscriptions (Optional)
For multi-scope projects:
```bash
# Edit subscriptions manually
# _bmad/_events/subscriptions.yaml
```
```yaml
subscriptions:
frontend:
watch:
- scope: api
patterns: ['contracts/*']
notify: true
```
## Troubleshooting
### Error: "Artifacts not found after migration"
**Cause:** Migration path resolution issue.
**Solution:**
```bash
# Check backup location
ls _bmad-output/_backup_migration_*/
# Manually move if needed
mv _bmad-output/_backup_migration_*/planning-artifacts/* _bmad-output/default/planning-artifacts/
```
### Error: "Scope not found"
**Cause:** Scope system not initialized.
**Solution:**
```bash
bmad scope init
```
### Error: "Cannot write to scope 'default' while in scope 'other'"
**Cause:** Cross-scope write protection.
**Solution:**
```bash
# Either switch scope
bmad workflow --scope default
# Or use sync to share
bmad scope sync-up other
bmad scope sync-down default
```
### State Files Show Old Paths
**Cause:** References not updated during migration.
**Solution:**
```bash
# Re-run migration with force update
bmad scope migrate --force --update-refs
```
## FAQ
### Q: Will my existing workflows break?
**A:** No. The scope system is backward compatible. Workflows without `{scope}` variables continue to work. Only workflows with scope variables require an active scope.
### Q: Can I have both scoped and non-scoped artifacts?
**A:** Yes, but not recommended. The `_shared/` layer is for cross-scope artifacts. Keep everything in scopes for consistency.
### Q: How do I share artifacts between team members?
**A:** Use git as usual. The `_bmad-output/` directory structure (including scopes) can be committed. Add `.bmad-scope` to `.gitignore` (session-specific).
### Q: Can I rename scopes?
**A:** Not directly. Create new scope, copy artifacts, remove old scope:
```bash
bmad scope create new-name --name "New Name"
cp -r _bmad-output/old-name/* _bmad-output/new-name/
bmad scope remove old-name --force
```
### Q: What happens to sprint-status.yaml?
**A:** Each scope gets its own `sprint-status.yaml` at `_bmad-output/{scope}/implementation-artifacts/sprint-status.yaml`. This enables parallel sprint planning.
### Q: Do I need to update my CI/CD?
**A:** Only if your CI/CD references specific artifact paths. Update paths to include scope:
```bash
# Before
cat _bmad-output/planning-artifacts/prd.md
# After
cat _bmad-output/default/planning-artifacts/prd.md
```
## Version History
| Version | Changes |
| ------- | ----------------------------- |
| 6.1.0 | Multi-scope system introduced |
| 6.0.0 | Initial v6 release |
---
For more details, see:
- [Multi-Scope Guide](multi-scope-guide.md)
- [Implementation Plan](plans/multi-scope-parallel-artifacts-plan.md)

415
docs/multi-scope-guide.md Normal file
View File

@ -0,0 +1,415 @@
---
title: 'Multi-Scope Parallel Artifacts Guide'
description: 'Run multiple workflows in parallel across different terminal sessions with isolated artifacts'
---
# Multi-Scope Parallel Artifacts Guide
> Run multiple workflows in parallel across different terminal sessions with isolated artifacts.
## Overview
The multi-scope system enables parallel development by isolating artifacts into separate "scopes". Each scope is an independent workspace with its own:
- Planning artifacts (PRDs, architecture, epics)
- Implementation artifacts (sprint status, stories)
- Test directories
- Optional scope-specific project context
## Quick Start
### Initialize Scope System
```bash
npx bmad-fh scope init
```
This creates:
- `_bmad/_config/scopes.yaml` - Scope registry
- `_bmad-output/_shared/` - Shared knowledge layer
- `_bmad/_events/` - Event system
### Create Your First Scope
```bash
npx bmad-fh scope create auth --name "Authentication Service"
```
**Important:** After creation, you'll be prompted to activate the scope:
```
✓ Scope 'auth' created successfully!
? Set 'auth' as your active scope for this session? (Y/n)
```
Accept this prompt (or run `npx bmad-fh scope set auth` later) to ensure workflows use the scoped directories.
### List Scopes
```bash
npx bmad-fh scope list
```
### Activate a Scope
```bash
# Set the active scope for your terminal session
npx bmad-fh scope set auth
# Or use environment variable (useful for CI/CD)
export BMAD_SCOPE=auth
```
Workflows automatically detect the active scope from:
1. `.bmad-scope` file (set by `scope set` command)
2. `BMAD_SCOPE` environment variable
> **Warning:** If no scope is active, artifacts go to root `_bmad-output/` directory (legacy mode).
## Directory Structure
```
project-root/
├── _bmad/
│ ├── _config/
│ │ └── scopes.yaml # Scope registry
│ └── _events/
│ ├── event-log.yaml # Event tracking
│ └── subscriptions.yaml # Event subscriptions
└── _bmad-output/
├── _shared/ # Shared knowledge layer
│ ├── project-context.md # Global "bible"
│ ├── contracts/ # Integration contracts
│ └── principles/ # Architecture principles
├── auth/ # Auth scope
│ ├── planning-artifacts/
│ ├── implementation-artifacts/
│ ├── tests/
│ └── project-context.md # Scope-specific context
└── payments/ # Payments scope
└── ...
```
## CLI Commands
### Scope Management
| Command | Description |
| --------------------------------- | -------------------------------------- |
| `npx bmad-fh scope init` | Initialize scope system |
| `npx bmad-fh scope create <id>` | Create new scope (prompts to activate) |
| `npx bmad-fh scope set <id>` | **Set active scope (required!)** |
| `npx bmad-fh scope list` | List all scopes |
| `npx bmad-fh scope info <id>` | Show scope details |
| `npx bmad-fh scope remove <id>` | Remove a scope |
| `npx bmad-fh scope archive <id>` | Archive a scope |
| `npx bmad-fh scope activate <id>` | Activate archived scope |
### Create Options
```bash
npx bmad-fh scope create auth \
--name "Authentication" \
--description "User auth and SSO" \
--deps payments,users \
--context # Create scope-specific project-context.md
```
> **Note:** After creation, you'll be prompted to set this as your active scope.
> Accept the prompt to ensure workflows use the scoped directories.
### Remove with Backup
```bash
# Creates backup in _bmad-output/_backup_auth_<timestamp>
bmad scope remove auth
# Force remove without backup
bmad scope remove auth --force --no-backup
```
## Syncing Between Scopes
### Promote to Shared Layer
```bash
# Promote artifacts to shared
bmad scope sync-up auth
```
Promotes:
- `architecture/*.md`
- `contracts/*.md`
- `principles/*.md`
- `project-context.md`
### Pull from Shared Layer
```bash
# Pull shared updates to scope
bmad scope sync-down payments
```
## Access Model
| Operation | Scope: auth | Scope: payments | \_shared |
| --------- | ----------- | --------------- | ----------- |
| **Read** | Any scope | Any scope | Yes |
| **Write** | auth only | payments only | Use sync-up |
### Isolation Modes
Configure in `_bmad/_config/scopes.yaml`:
```yaml
settings:
isolation_mode: strict # strict | warn | permissive
```
- **strict**: Block cross-scope writes (default)
- **warn**: Allow with warnings
- **permissive**: Allow all (not recommended)
## Workflow Integration
### Scope Variable
Workflows use `{scope}` variable:
```yaml
# workflow.yaml
variables:
test_dir: '{scope_tests}' # Resolves to _bmad-output/auth/tests
```
### Scope-Aware Paths
| Variable | Non-scoped | Scoped (auth) |
| ------------------------ | -------------------------------------- | ------------------------------------------- |
| `{scope}` | (empty) | auth |
| `{scope_path}` | \_bmad-output | \_bmad-output/auth |
| `{scope_planning}` | \_bmad-output/planning-artifacts | \_bmad-output/auth/planning-artifacts |
| `{scope_implementation}` | \_bmad-output/implementation-artifacts | \_bmad-output/auth/implementation-artifacts |
| `{scope_tests}` | \_bmad-output/tests | \_bmad-output/auth/tests |
## Session-Sticky Scope
The `.bmad-scope` file in project root stores active scope:
```yaml
# .bmad-scope (gitignored)
active_scope: auth
set_at: '2026-01-21T10:00:00Z'
```
Workflows automatically use this scope when no `--scope` flag provided.
## Event System
### Subscribing to Updates
Scopes can subscribe to events from other scopes:
```yaml
# _bmad/_events/subscriptions.yaml
subscriptions:
payments:
watch:
- scope: auth
patterns: ['contracts/*', 'architecture.md']
notify: true
```
### Event Types
- `artifact_created` - New artifact created
- `artifact_updated` - Artifact modified
- `artifact_promoted` - Promoted to shared
- `sync_up` / `sync_down` - Sync operations
- `scope_created` / `scope_archived` - Scope lifecycle
## Parallel Development Example
### Terminal 1: Auth Scope
```bash
# Set scope for session
bmad scope create auth --name "Authentication"
# Run workflows - all output goes to auth scope
bmad workflow create-prd --scope auth
bmad workflow create-epic --scope auth
```
### Terminal 2: Payments Scope
```bash
# Different scope, isolated artifacts
bmad scope create payments --name "Payment Processing"
bmad workflow create-prd --scope payments
bmad workflow create-epic --scope payments
```
### Sharing Work
```bash
# Terminal 1: Promote auth architecture to shared
bmad scope sync-up auth
# Terminal 2: Pull shared updates to payments
bmad scope sync-down payments
```
## Migration from Non-Scoped
Existing projects can migrate:
```bash
# Analyze existing artifacts
bmad scope migrate --analyze
# Migrate to 'default' scope
bmad scope migrate
```
This:
1. Creates backup
2. Creates `default` scope
3. Moves artifacts to `_bmad-output/default/`
4. Updates references
## Best Practices
### Naming Scopes
Use clear, descriptive IDs:
- `auth` - Authentication service
- `payments` - Payment processing
- `user-service` - User management
- `api-gateway` - API gateway
### Scope Granularity
Choose based on:
- **Team boundaries** - One scope per team
- **Deployment units** - One scope per service
- **Feature sets** - One scope per major feature
### Shared Layer Usage
- Keep `project-context.md` as the global "bible"
- Put integration contracts in `_shared/contracts/`
- Document architecture principles in `_shared/principles/`
- Promote mature, stable artifacts only
### Dependencies
Declare dependencies explicitly:
```bash
bmad scope create payments --deps auth,users
```
This helps:
- Track relationships
- Get notifications on dependency changes
- Plan integration work
## Troubleshooting
### "No scope set" Error
```bash
# Option 1: Specify scope explicitly
bmad workflow --scope auth
# Option 2: Set session scope
bmad scope create auth
```
### Cross-Scope Write Blocked
```
Error: Cannot write to scope 'payments' while in scope 'auth'
```
Solutions:
1. Switch to correct scope
2. Use sync-up to promote to shared
3. Change isolation mode (not recommended)
### Conflict During Sync
```bash
# Keep local version
bmad scope sync-down payments --resolution keep-local
# Keep shared version
bmad scope sync-down payments --resolution keep-shared
# Backup and update
bmad scope sync-down payments --resolution backup-and-update
```
## API Reference
### ScopeManager
```javascript
const { ScopeManager } = require('./src/core/lib/scope');
const manager = new ScopeManager({ projectRoot: '/path/to/project' });
await manager.initialize();
// CRUD operations
const scope = await manager.createScope('auth', { name: 'Auth' });
const scopes = await manager.listScopes();
await manager.archiveScope('auth');
await manager.removeScope('auth', { force: true });
```
### ScopeContext
```javascript
const { ScopeContext } = require('./src/core/lib/scope');
const context = new ScopeContext({ projectRoot: '/path/to/project' });
// Session management
await context.setScope('auth');
const current = await context.getCurrentScope();
// Load merged context
const projectContext = await context.loadProjectContext('auth');
```
### ArtifactResolver
```javascript
const { ArtifactResolver } = require('./src/core/lib/scope');
const resolver = new ArtifactResolver({
currentScope: 'auth',
basePath: '_bmad-output',
});
// Check access
const canWrite = resolver.canWrite('/path/to/file.md');
resolver.validateWrite('/path/to/file.md'); // Throws if not allowed
```
---
For more details, see the [Implementation Plan](plans/multi-scope-parallel-artifacts-plan.md).

View File

@ -0,0 +1,716 @@
---
title: 'Multi-Scope Parallel Artifacts System - Implementation Plan'
description: 'Implementation plan for the multi-scope parallel artifact system'
---
# Multi-Scope Parallel Artifacts System - Implementation Plan
> **Status:** Planning Complete
> **Created:** 2026-01-21
> **Last Updated:** 2026-01-21
> **Estimated Effort:** 17-22 days
## Executive Summary
This plan outlines the implementation of a **multi-scope parallel artifact system** for BMAD that enables:
- Running multiple workflows in parallel across different terminal sessions
- Each session works on a different sub-product (scope) with isolated artifacts
- Shared knowledge layer with bidirectional synchronization
- Event-based updates when dependencies change
- Strict write isolation with liberal read access
---
## Table of Contents
1. [Key Design Decisions](#key-design-decisions)
2. [Architecture Overview](#architecture-overview)
3. [Phase 0: Git Hooks (This Repo)](#phase-0-git-hooks-this-repo)
4. [Phase 1: Scope Foundation](#phase-1-scope-foundation)
5. [Phase 2: Variable Resolution](#phase-2-variable-resolution)
6. [Phase 3: Isolation & Locking](#phase-3-isolation--locking)
7. [Phase 4: Sync System](#phase-4-sync-system)
8. [Phase 5: Event System](#phase-5-event-system)
9. [Phase 6: IDE Integration & Documentation](#phase-6-ide-integration--documentation)
10. [Risk Mitigation](#risk-mitigation)
11. [Success Criteria](#success-criteria)
---
## Key Design Decisions
| Decision | Choice | Rationale |
| ---------------------------- | ------------------------- | --------------------------------------------------------------------------------- |
| **Sprint-status handling** | Per-scope | Each scope has independent sprint planning, no parallel conflicts |
| **Project-context location** | Both (global + per-scope) | Global "bible" in `_shared/`, optional scope-specific that extends |
| **Scope vs Module** | Different concepts | Module = code organization (bmm/core), Scope = artifact isolation (auth/payments) |
| **Cross-scope access** | Read any, write own | Liberal reads for dependency awareness, strict writes for isolation |
| **Test directories** | Scoped | `{output_folder}/{scope}/tests` for full isolation |
| **Workflow updates** | Automated script | Handle 22+ workflow.yaml files programmatically |
| **File locking** | proper-lockfile npm | Battle-tested, cross-platform locking |
| **Git hooks** | This repo only | For contributor workflow, NOT installed with bmad |
| **Migration strategy** | Auto-migrate to 'default' | Existing artifacts move to default scope automatically |
| **Scope ID format** | Strict | Lowercase alphanumeric + hyphens only |
---
## Architecture Overview
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ BMAD MULTI-SCOPE ARCHITECTURE │
│ │
│ MODULE (code organization) SCOPE (artifact isolation) │
│ ───────────────────────── ──────────────────────── │
│ src/core/ _bmad-output/auth/ │
│ src/bmm/ _bmad-output/payments/ │
│ (installed to _bmad/) _bmad-output/catalog/ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ DIRECTORY STRUCTURE (After Implementation): │
│ │
│ project-root/ │
│ ├── _bmad/ # BMAD installation │
│ │ ├── _config/ │
│ │ │ ├── scopes.yaml # NEW: Scope registry │
│ │ │ ├── manifest.yaml │
│ │ │ └── ides/ │
│ │ ├── _events/ # NEW: Event system │
│ │ │ ├── event-log.yaml │
│ │ │ └── subscriptions.yaml │
│ │ ├── core/ │
│ │ │ └── scope/ # NEW: Scope management │
│ │ │ ├── scope-manager.js │
│ │ │ ├── scope-context.js │
│ │ │ ├── artifact-resolver.js │
│ │ │ └── state-lock.js │
│ │ └── bmm/ │
│ │ │
│ └── _bmad-output/ # Scoped artifacts │
│ ├── _shared/ # Shared knowledge layer │
│ │ ├── project-context.md # Global "bible" │
│ │ ├── contracts/ # Integration contracts │
│ │ └── principles/ # Architecture principles │
│ ├── auth/ # Auth scope │
│ │ ├── planning-artifacts/ │
│ │ ├── implementation-artifacts/ │
│ │ │ └── sprint-status.yaml # PER-SCOPE sprint status │
│ │ ├── tests/ # Scoped tests │
│ │ └── project-context.md # Optional: extends global │
│ ├── payments/ # Payments scope │
│ │ └── ... │
│ └── default/ # Migrated existing artifacts │
│ └── ... │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ CROSS-SCOPE ACCESS MODEL: │
│ │
│ Scope: payments │
│ ├── CAN READ: auth/*, catalog/*, _shared/*, default/* │
│ ├── CAN WRITE: payments/* ONLY │
│ └── TO SHARE: bmad scope sync-up payments │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## Phase 0: Git Hooks (This Repo)
> **Estimate:** 0.5 day
> **Purpose:** Contributor workflow for BMAD-METHOD repository only
### Objectives
- Ensure main branch always synced with upstream (bmad-code-org)
- Enforce single-commit-per-branch workflow
- Require rebase on main before push
- Use amend + force-with-lease pattern
### Files to Create
```
BMAD-METHOD/
├── .githooks/
│ ├── pre-push # Main enforcement hook
│ ├── pre-commit # Block main commits, amend warnings
│ └── post-checkout # Sync reminders
└── docs/
└── CONTRIBUTING.md # Git workflow documentation
```
### Pre-Push Hook Logic
```bash
#!/bin/bash
# .githooks/pre-push
1. Ensure upstream remote exists (git@github.com:bmad-code-org/BMAD-METHOD.git)
2. Fetch upstream
3. Block direct push to main
4. Sync local main with upstream (if needed)
5. Check branch is rebased on main
6. Enforce single commit rule (max 1 commit ahead of main)
```
### Setup Instructions
```bash
# One-time setup for contributors
git config core.hooksPath .githooks
git remote add upstream git@github.com:bmad-code-org/BMAD-METHOD.git
```
---
## Phase 1: Scope Foundation
> **Estimate:** 3-4 days
### 1.1 Scopes.yaml Schema
**File:** `_bmad/_config/scopes.yaml`
```yaml
version: 1
settings:
allow_adhoc_scopes: true # Allow on-demand scope creation
isolation_mode: strict # strict | warn | permissive
default_output_base: '_bmad-output'
default_shared_path: '_bmad-output/_shared'
scopes:
auth:
id: 'auth'
name: 'Authentication Service'
description: 'User authentication, SSO, authorization'
status: active # active | archived
dependencies: [] # Scopes this depends on
created: '2026-01-21T10:00:00Z'
_meta:
last_activity: '2026-01-21T15:30:00Z'
artifact_count: 12
```
**Validation Rules:**
- Scope ID: `^[a-z][a-z0-9-]*[a-z0-9]$` (2-50 chars)
- Reserved IDs: `_shared`, `_events`, `_config`, `global`
- Circular dependency detection required
### 1.2 ScopeManager Class
**File:** `src/core/scope/scope-manager.js`
```javascript
class ScopeManager {
// CRUD Operations
async listScopes(filters)
async getScope(scopeId)
async createScope(scopeId, options)
async updateScope(scopeId, updates)
async removeScope(scopeId, options)
// Path Resolution
async getScopePaths(scopeId)
resolvePath(template, scopeId)
// Validation
validateScopeId(scopeId)
validateDependencies(scopeId, dependencies, allScopes)
// Dependencies
async getDependencyTree(scopeId)
findDependentScopes(scopeId, allScopes)
}
```
### 1.3 CLI Commands
**File:** `tools/cli/commands/scope.js`
| Command | Description |
| ------------------------ | ------------------------------------ |
| `bmad scope list` | List all scopes |
| `bmad scope create <id>` | Create new scope interactively |
| `bmad scope info <id>` | Show scope details |
| `bmad scope remove <id>` | Remove scope |
| `bmad scope migrate` | Migrate existing to scoped structure |
### 1.4 Directory Structure Generator
**File:** `src/core/scope/scope-initializer.js`
Creates on scope creation:
```
_bmad-output/{scope}/
├── planning-artifacts/
├── implementation-artifacts/
├── tests/
└── .scope-meta.yaml
```
Creates on first scope (one-time):
```
_bmad-output/_shared/
├── project-context.md # Global project context template
├── contracts/
└── principles/
_bmad/_events/
├── event-log.yaml
└── subscriptions.yaml
```
### 1.5 Migration Logic
**File:** `src/core/scope/scope-migrator.js`
Steps:
1. Create backup of `_bmad-output/`
2. Initialize scope system
3. Create `default` scope
4. Move existing artifacts to `_bmad-output/default/`
5. Update references in state files
6. Mark migration complete
---
## Phase 2: Variable Resolution
> **Estimate:** 4-5 days
### 2.1 workflow.xml Scope Initialization
**File:** `src/core/tasks/workflow.xml` (modify)
Add Step 0 before existing Step 1:
```xml
<step n="0" title="Initialize Scope Context" critical="true">
<substep n="0a" title="Check for Scope Requirement">
<action>Scan workflow.yaml for {scope} variable</action>
<action>If found → workflow requires scope</action>
</substep>
<substep n="0b" title="Resolve Scope">
<!-- Priority order: -->
<!-- 1. --scope argument from command -->
<!-- 2. Session context (if set) -->
<!-- 3. Prompt user to select/create -->
</substep>
<substep n="0c" title="Load Scope Context">
<action>Load scope config from scopes.yaml</action>
<action>Resolve scope paths</action>
<action>Load global project-context.md</action>
<action>Load scope project-context.md (if exists, merge)</action>
<action>Check for dependency updates (notify if pending)</action>
</substep>
</step>
```
### 2.2 Module.yaml Updates
**File:** `src/bmm/module.yaml` (modify)
```yaml
# BEFORE
planning_artifacts:
default: "{output_folder}/planning-artifacts"
result: "{project-root}/{value}"
# AFTER
planning_artifacts:
default: "{output_folder}/{scope}/planning-artifacts"
result: "{project-root}/{value}"
implementation_artifacts:
default: "{output_folder}/{scope}/implementation-artifacts"
result: "{project-root}/{value}"
```
### 2.3 Workflow.yaml Update Script
**File:** `tools/cli/scripts/migrate-workflows.js`
Updates for 22+ workflow files:
1. Update `test_dir` variables to use `{output_folder}/{scope}/tests`
2. Handle variations in path definitions
3. Preserve `{config_source}:` references (they'll work via updated module.yaml)
### 2.4 Agent Activation Updates
**File:** `src/utility/agent-components/activation-steps.txt` (modify)
```xml
<step n="2">🚨 IMMEDIATE ACTION REQUIRED:
- Load {project-root}/_bmad/{{module}}/config.yaml
- Store: {user_name}, {communication_language}, {output_folder}
- NEW: Check if scope is set for session
- NEW: Load global project-context: {output_folder}/_shared/project-context.md
- NEW: Load scope project-context (if exists): {output_folder}/{scope}/project-context.md
- NEW: Merge contexts (scope extends global)
</step>
```
### 2.5 invoke-workflow Scope Propagation
**Modification to workflow.xml:**
When `<invoke-workflow>` is encountered:
1. Pass current `{scope}` as implicit parameter
2. Child workflow inherits scope from parent
3. Can be overridden with explicit `<param>scope: other</param>`
---
## Phase 3: Isolation & Locking
> **Estimate:** 2-3 days
### 3.1 ArtifactResolver
**File:** `src/core/scope/artifact-resolver.js`
```javascript
class ArtifactResolver {
constructor(currentScope, basePath) {
this.currentScope = currentScope;
this.basePath = basePath;
}
// Read-any: Allow reading from any scope
canRead(path) {
return true; // All reads allowed
}
// Write-own: Only allow writing to current scope
canWrite(path) {
const targetScope = this.extractScopeFromPath(path);
if (targetScope === '_shared') {
throw new Error('Cannot write directly to _shared. Use: bmad scope sync-up');
}
if (targetScope !== this.currentScope) {
throw new Error(`Cannot write to scope '${targetScope}' while in scope '${this.currentScope}'`);
}
return true;
}
extractScopeFromPath(path) {
// Extract scope from path like _bmad-output/auth/...
}
}
```
### 3.2 File Locking
**File:** `src/core/scope/state-lock.js`
```javascript
const lockfile = require('proper-lockfile');
class StateLock {
async withLock(filePath, operation) {
const release = await lockfile.lock(filePath, {
stale: 30000, // 30s stale timeout
retries: { retries: 10, minTimeout: 100, maxTimeout: 1000 },
});
try {
return await operation();
} finally {
await release();
}
}
// Optimistic locking with version field
async updateYamlWithVersion(filePath, modifier) {
return this.withLock(filePath, async () => {
const data = await this.readYaml(filePath);
const currentVersion = data._version || 0;
const modified = await modifier(data);
modified._version = currentVersion + 1;
modified._lastModified = new Date().toISOString();
await this.writeYaml(filePath, modified);
return modified;
});
}
}
```
**Files requiring locking:**
- `{scope}/implementation-artifacts/sprint-status.yaml`
- `{scope}/planning-artifacts/bmm-workflow-status.yaml`
- `_shared/` files during sync operations
- `scopes.yaml` during scope CRUD
### 3.3 Package.json Update
Add dependency:
```json
{
"dependencies": {
"proper-lockfile": "^4.1.2"
}
}
```
---
## Phase 4: Sync System
> **Estimate:** 3-4 days
### 4.1 Sync-Up (Promote to Shared)
**Command:** `bmad scope sync-up <scope>`
**Logic:**
1. Identify promotable artifacts (configurable patterns)
2. Check for conflicts with existing shared files
3. Copy to `_shared/` with attribution metadata
4. Log event for dependent scope notification
**Metadata added to promoted files:**
```yaml
# _shared/architecture/auth-api.md.meta
source_scope: auth
promoted_at: '2026-01-21T10:00:00Z'
original_hash: abc123
version: 1
```
### 4.2 Sync-Down (Pull from Shared)
**Command:** `bmad scope sync-down <scope>`
**Logic:**
1. Find shared updates since last sync
2. Compare with local copies (if any)
3. Handle conflicts (prompt user for resolution)
4. Copy to scope directory
5. Update last-sync timestamp
### 4.3 Conflict Resolution
**Options when conflict detected:**
1. Keep local (overwrite shared)
2. Keep shared (discard local)
3. Merge (3-way diff if possible)
4. Skip this file
---
## Phase 5: Event System
> **Estimate:** 2 days
### 5.1 Event Log Structure
**File:** `_bmad/_events/event-log.yaml`
```yaml
version: 1
events:
- id: evt_001
type: artifact_created
scope: auth
artifact: planning-artifacts/prd.md
timestamp: '2026-01-21T10:30:00Z'
- id: evt_002
type: artifact_promoted
scope: auth
artifact: architecture.md
shared_path: _shared/auth/architecture.md
timestamp: '2026-01-21T11:00:00Z'
```
### 5.2 Subscriptions
**File:** `_bmad/_events/subscriptions.yaml`
```yaml
subscriptions:
payments:
watch:
- scope: auth
patterns: ['contracts/*', 'architecture.md']
notify: true
```
### 5.3 Notification on Activation
When agent/workflow activates with scope:
1. Check subscriptions for this scope
2. Find events since last activity
3. Display pending updates (if any)
4. Suggest `bmad scope sync-down` if updates available
---
## Phase 6: IDE Integration & Documentation
> **Estimate:** 2-3 days
### 6.1 IDE Command Generators
**File:** `tools/cli/installers/lib/ide/shared/scope-aware-command.js`
Updates to workflow-command-template.md:
```markdown
### Scope Resolution
This workflow requires a scope. Before proceeding:
1. Check for --scope argument (e.g., `/create-story --scope auth`)
2. Check session context for active scope
3. If none, prompt user to select/create scope
Store selected scope for session.
```
### 6.2 Session-Sticky Scope
**Mechanism:** File-based `.bmad-scope` in project root
```yaml
# .bmad-scope (gitignored)
active_scope: auth
set_at: '2026-01-21T10:00:00Z'
```
### 6.3 Agent Menu Updates
Add `scope_required` attribute:
```yaml
menu:
- trigger: 'prd'
workflow: '...'
scope_required: true # Enforce scope for this menu item
```
### 6.4 Documentation
Files to create:
1. `docs/multi-scope-guide.md` - User guide
2. `docs/migration-guide.md` - Upgrading existing installations
3. Update README with multi-scope overview
---
## Risk Mitigation
| Risk | Mitigation |
| ------------------------------- | ------------------------------------------------ |
| Breaking existing installations | Auto-migration with backup, rollback capability |
| Parallel write conflicts | File locking + optimistic versioning |
| Cross-scope data corruption | Write isolation enforcement in ArtifactResolver |
| Complex merge conflicts | Clear conflict resolution UI + skip option |
| IDE compatibility | Test with all supported IDEs, graceful fallbacks |
| Performance with many scopes | Lazy loading, scope caching |
---
## Success Criteria
### Functional Requirements
- [ ] Can create/list/remove scopes via CLI
- [ ] Workflows produce artifacts in correct scope directory
- [ ] Parallel workflows in different scopes don't conflict
- [ ] Cross-scope reads work (for dependencies)
- [ ] Cross-scope writes are blocked with clear error
- [ ] Sync-up promotes artifacts to shared
- [ ] Sync-down pulls shared updates
- [ ] Events logged and notifications shown
- [ ] Migration works for existing installations
- [ ] All IDEs support --scope flag
### Non-Functional Requirements
- [ ] No noticeable performance degradation
- [ ] Clear error messages for all failure modes
- [ ] Documentation complete
- [ ] Git hooks working for this repo
---
## Implementation Order
```
Phase 0 ─────► Phase 1 ─────► Phase 2 ─────► Phase 3 ─────► Phase 4 ─────► Phase 5 ─────► Phase 6
(Git hooks) (Foundation) (Variables) (Isolation) (Sync) (Events) (IDE/Docs)
│ │ │ │ │ │ │
│ │ │ │ │ │ │
0.5 day 3-4 days 4-5 days 2-3 days 3-4 days 2 days 2-3 days
```
**Critical Path:** Phase 0 → Phase 1 → Phase 2.1 → Phase 2.2 → Phase 3.1
MVP can be achieved with Phases 0-3 (isolation working, no sync/events yet).
---
## Appendix: Files to Create/Modify
### New Files
| Path | Purpose |
| --------------------------------------------------- | ----------------------------------- |
| `.githooks/pre-push` | Git hook for single-commit workflow |
| `.githooks/pre-commit` | Git hook to block main commits |
| `.githooks/post-checkout` | Git hook for sync reminders |
| `src/core/scope/scope-manager.js` | Scope CRUD operations |
| `src/core/scope/scope-initializer.js` | Directory creation |
| `src/core/scope/scope-migrator.js` | Migration logic |
| `src/core/scope/scope-context.js` | Session context |
| `src/core/scope/artifact-resolver.js` | Read/write enforcement |
| `src/core/scope/state-lock.js` | File locking utilities |
| `src/core/scope/scope-sync.js` | Sync-up/down logic |
| `src/core/scope/event-logger.js` | Event logging |
| `tools/cli/commands/scope.js` | CLI scope commands |
| `tools/cli/scripts/migrate-workflows.js` | Workflow update script |
| `docs/plans/multi-scope-parallel-artifacts-plan.md` | This file |
### Modified Files
| Path | Changes |
| --------------------------------------------------- | ---------------------------------- |
| `src/core/tasks/workflow.xml` | Add Step 0 for scope init |
| `src/core/module.yaml` | Add scope settings |
| `src/bmm/module.yaml` | Add {scope} to paths |
| `src/utility/agent-components/activation-steps.txt` | Add scope loading |
| `tools/cli/bmad-cli.js` | Register scope command |
| `tools/cli/installers/lib/ide/templates/*` | Scope-aware templates |
| `package.json` | Add proper-lockfile dependency |
| `22+ workflow.yaml files` | Update test_dir paths (via script) |
---
_End of Plan_

View File

@ -81,9 +81,9 @@ export default [
},
},
// CLI scripts under tools/** and test/**
// CLI scripts under tools/**, test/**, and src/core/lib/**
{
files: ['tools/**/*.js', 'tools/**/*.mjs', 'test/**/*.js'],
files: ['tools/**/*.js', 'tools/**/*.mjs', 'test/**/*.js', 'src/core/lib/**/*.js'],
rules: {
// Allow CommonJS patterns for Node CLI scripts
'unicorn/prefer-module': 'off',

19
package-lock.json generated
View File

@ -1,11 +1,11 @@
{
"name": "bmad-method",
"name": "bmad-fh",
"version": "6.0.0-alpha.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bmad-method",
"name": "bmad-fh",
"version": "6.0.0-alpha.23",
"license": "MIT",
"dependencies": {
@ -28,8 +28,7 @@
"yaml": "^2.7.0"
},
"bin": {
"bmad": "tools/bmad-npx-wrapper.js",
"bmad-method": "tools/bmad-npx-wrapper.js"
"bmad-fh": "tools/bmad-npx-wrapper.js"
},
"devDependencies": {
"@astrojs/sitemap": "^3.6.0",
@ -244,6 +243,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -3972,6 +3972,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4280,6 +4281,7 @@
"integrity": "sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@astrojs/compiler": "^2.13.0",
"@astrojs/internal-helpers": "0.7.5",
@ -5347,6 +5349,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -6662,6 +6665,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -10223,6 +10227,7 @@
"integrity": "sha512-p3JTemJJbkiMjXEMiFwgm0v6ym5g8K+b2oDny+6xdl300tUKySxvilJQLSea48C6OaYNmO30kH9KxpiAg5bWJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"globby": "15.0.0",
"js-yaml": "4.1.1",
@ -12286,6 +12291,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -12351,6 +12357,7 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13179,6 +13186,7 @@
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -14718,6 +14726,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -14991,6 +15000,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
@ -15170,6 +15180,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "bmad-method",
"name": "bmad-fh",
"version": "6.0.0-alpha.23",
"description": "Breakthrough Method of Agile AI-driven Development",
"keywords": [
@ -14,14 +14,13 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/bmad-code-org/BMAD-METHOD.git"
"url": "git+https://github.com/fairyhunter13/BMAD-METHOD.git"
},
"license": "MIT",
"author": "Brian (BMad) Madison",
"main": "tools/cli/bmad-cli.js",
"bin": {
"bmad": "tools/bmad-npx-wrapper.js",
"bmad-method": "tools/bmad-npx-wrapper.js"
"bmad-fh": "tools/bmad-npx-wrapper.js"
},
"scripts": {
"bmad:install": "node tools/cli/bmad-cli.js install",
@ -45,7 +44,8 @@
"release:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
"release:watch": "gh run watch",
"test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run test:schemas && npm run test:install && npm run test:cli-args && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
"test:cli-args": "node test/test-cli-arguments.js",
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
"test:install": "node test/test-installation-components.js",
"test:schemas": "node test/test-agent-schema.js",

View File

@ -28,16 +28,36 @@ user_skill_level:
- value: "expert"
label: "Expert - Be direct and technical"
# Scope variable - populated at runtime by workflow.xml Step 0
# When running multi-scope workflows, this is set to the active scope ID
# When empty, paths fall back to non-scoped structure for backward compatibility
scope:
default: ""
result: "{value}"
runtime: true # Indicates this is resolved at runtime, not during install
# Scope-aware path helper - resolves to output_folder/scope or just output_folder
scope_path:
default: "{output_folder}"
result: "{value}"
runtime: true # Updated at runtime when scope is active
planning_artifacts: # Phase 1-3 artifacts
prompt: "Where should planning artifacts be stored? (Brainstorming, Briefs, PRDs, UX Designs, Architecture, Epics)"
default: "{output_folder}/planning-artifacts"
default: "{scope_path}/planning-artifacts"
result: "{project-root}/{value}"
implementation_artifacts: # Phase 4 artifacts and quick-dev flow output
prompt: "Where should implementation artifacts be stored? (Sprint status, stories, reviews, retrospectives, Quick Flow output)"
default: "{output_folder}/implementation-artifacts"
default: "{scope_path}/implementation-artifacts"
result: "{project-root}/{value}"
# Scope-specific test directory
scope_tests:
default: "{scope_path}/tests"
result: "{project-root}/{value}"
runtime: true
project_knowledge: # Artifacts from research, document-project output, other long lived accurate knowledge
prompt: "Where should long-term project knowledge be stored? (docs, research, references)"
default: "docs"

View File

@ -0,0 +1,298 @@
const path = require('node:path');
/**
* Resolves and enforces scope-based artifact access
* Implements read-any/write-own access model
*
* @class ArtifactResolver
*
* @example
* const resolver = new ArtifactResolver({
* currentScope: 'auth',
* basePath: '/path/to/_bmad-output'
* });
*
* if (resolver.canWrite('/path/to/_bmad-output/auth/file.md')) {
* // Write operation allowed
* }
*/
class ArtifactResolver {
constructor(options = {}) {
this.currentScope = options.currentScope || null;
this.basePath = options.basePath || '_bmad-output';
this.isolationMode = options.isolationMode || 'strict'; // strict | warn | permissive
this.sharedPath = '_shared';
this.reservedPaths = ['_shared', '_events', '_config', '_backup'];
}
/**
* Set the current scope
* @param {string} scopeId - The current scope ID
*/
setCurrentScope(scopeId) {
this.currentScope = scopeId;
}
/**
* Set isolation mode
* @param {string} mode - Isolation mode (strict, warn, permissive)
*/
setIsolationMode(mode) {
if (!['strict', 'warn', 'permissive'].includes(mode)) {
throw new Error(`Invalid isolation mode: ${mode}`);
}
this.isolationMode = mode;
}
/**
* Extract scope from a file path
* @param {string} filePath - The file path to analyze
* @returns {string|null} Scope ID or null if not in a scope
*/
extractScopeFromPath(filePath) {
// Normalize path
const normalizedPath = path.normalize(filePath);
// Find the base path in the file path
const baseIndex = normalizedPath.indexOf(this.basePath);
if (baseIndex === -1) {
return null; // Not in output directory
}
// Get the relative path from base
const relativePath = normalizedPath.slice(Math.max(0, baseIndex + this.basePath.length + 1));
// Split to get the first segment (scope name)
const segments = relativePath.split(path.sep).filter(Boolean);
if (segments.length === 0) {
return null;
}
const firstSegment = segments[0];
// Check if it's a reserved path
if (this.reservedPaths.includes(firstSegment)) {
return firstSegment; // Return the reserved path name
}
return firstSegment;
}
/**
* Check if a path is in the shared directory
* @param {string} filePath - The file path
* @returns {boolean} True if path is in shared
*/
isSharedPath(filePath) {
const scope = this.extractScopeFromPath(filePath);
return scope === this.sharedPath;
}
/**
* Check if a path is in a reserved directory
* @param {string} filePath - The file path
* @returns {boolean} True if path is reserved
*/
isReservedPath(filePath) {
const scope = this.extractScopeFromPath(filePath);
return this.reservedPaths.includes(scope);
}
/**
* Check if read access is allowed to a path
* Read is always allowed in read-any model
* @param {string} filePath - The file path to check
* @returns {{allowed: boolean, reason: string}}
*/
canRead(filePath) {
// Read is always allowed for all paths
return {
allowed: true,
reason: 'Read access is always allowed in read-any model',
};
}
/**
* Check if write access is allowed to a path
* @param {string} filePath - The file path to check
* @returns {{allowed: boolean, reason: string, warning: string|null}}
*/
canWrite(filePath) {
// No current scope means legacy mode - allow all
if (!this.currentScope) {
return {
allowed: true,
reason: 'No scope active, operating in legacy mode',
warning: null,
};
}
const targetScope = this.extractScopeFromPath(filePath);
// Check for shared path write attempt
if (targetScope === this.sharedPath) {
return {
allowed: false,
reason: `Cannot write directly to '${this.sharedPath}'. Use: bmad scope sync-up`,
warning: null,
};
}
// Check for reserved path write attempt
if (this.reservedPaths.includes(targetScope) && targetScope !== this.currentScope) {
return {
allowed: false,
reason: `Cannot write to reserved path '${targetScope}'`,
warning: null,
};
}
// Check if writing to current scope
if (targetScope === this.currentScope) {
return {
allowed: true,
reason: `Write allowed to current scope '${this.currentScope}'`,
warning: null,
};
}
// Cross-scope write attempt
if (targetScope && targetScope !== this.currentScope) {
switch (this.isolationMode) {
case 'strict': {
return {
allowed: false,
reason: `Cannot write to scope '${targetScope}' while in scope '${this.currentScope}'`,
warning: null,
};
}
case 'warn': {
return {
allowed: true,
reason: 'Write allowed with warning in warn mode',
warning: `Warning: Writing to scope '${targetScope}' from scope '${this.currentScope}'`,
};
}
case 'permissive': {
return {
allowed: true,
reason: 'Write allowed in permissive mode',
warning: null,
};
}
default: {
return {
allowed: false,
reason: 'Unknown isolation mode',
warning: null,
};
}
}
}
// Path not in any scope - allow (it's outside the scope system)
return {
allowed: true,
reason: 'Path is outside scope system',
warning: null,
};
}
/**
* Validate a write operation and throw if not allowed
* @param {string} filePath - The file path to write to
* @throws {Error} If write is not allowed in strict mode
*/
validateWrite(filePath) {
const result = this.canWrite(filePath);
if (!result.allowed) {
throw new Error(result.reason);
}
if (result.warning) {
console.warn(result.warning);
}
}
/**
* Resolve a scope-relative path to absolute path
* @param {string} relativePath - Relative path within scope
* @param {string} scopeId - Scope ID (defaults to current)
* @returns {string} Absolute path
*/
resolveScopePath(relativePath, scopeId = null) {
const scope = scopeId || this.currentScope;
if (!scope) {
// No scope - return path relative to base
return path.join(this.basePath, relativePath);
}
return path.join(this.basePath, scope, relativePath);
}
/**
* Resolve path to shared directory
* @param {string} relativePath - Relative path within shared
* @returns {string} Absolute path to shared
*/
resolveSharedPath(relativePath) {
return path.join(this.basePath, this.sharedPath, relativePath);
}
/**
* Get all paths accessible for reading from current scope
* @returns {object} Object with path categories
*/
getReadablePaths() {
return {
currentScope: this.currentScope ? path.join(this.basePath, this.currentScope) : null,
shared: path.join(this.basePath, this.sharedPath),
allScopes: `${this.basePath}/*`,
description: 'Read access is allowed to all scopes and shared directories',
};
}
/**
* Get paths writable from current scope
* @returns {object} Object with writable paths
*/
getWritablePaths() {
if (!this.currentScope) {
return {
all: this.basePath,
description: 'No scope active - all paths writable (legacy mode)',
};
}
return {
currentScope: path.join(this.basePath, this.currentScope),
description: `Write access limited to scope '${this.currentScope}'`,
};
}
/**
* Check if current operation context is valid
* @returns {boolean} True if context is properly set up
*/
isContextValid() {
return this.basePath !== null;
}
/**
* Create a scoped path resolver for a specific scope
* @param {string} scopeId - The scope ID
* @returns {function} Path resolver function
*/
createScopedResolver(scopeId) {
const base = this.basePath;
return (relativePath) => path.join(base, scopeId, relativePath);
}
}
module.exports = { ArtifactResolver };

View File

@ -0,0 +1,400 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { StateLock } = require('./state-lock');
/**
* Logs and tracks events across scopes
* Handles event logging and subscription notifications
*
* @class EventLogger
* @requires fs-extra
* @requires yaml
* @requires StateLock
*
* @example
* const logger = new EventLogger({ projectRoot: '/path/to/project' });
* await logger.logEvent('artifact_created', 'auth', { artifact: 'prd.md' });
*/
class EventLogger {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.bmadPath = path.join(this.projectRoot, '_bmad');
this.eventsPath = path.join(this.bmadPath, '_events');
this.eventLogPath = path.join(this.eventsPath, 'event-log.yaml');
this.subscriptionsPath = path.join(this.eventsPath, 'subscriptions.yaml');
this.stateLock = new StateLock();
this.maxEvents = options.maxEvents || 1000; // Rotate after this many events
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.bmadPath = path.join(projectRoot, '_bmad');
this.eventsPath = path.join(this.bmadPath, '_events');
this.eventLogPath = path.join(this.eventsPath, 'event-log.yaml');
this.subscriptionsPath = path.join(this.eventsPath, 'subscriptions.yaml');
}
/**
* Initialize event system
* Creates event directories and files if they don't exist
*/
async initialize() {
await fs.ensureDir(this.eventsPath);
// Create event-log.yaml if not exists
if (!(await fs.pathExists(this.eventLogPath))) {
const eventLog = {
version: 1,
events: [],
};
await fs.writeFile(this.eventLogPath, yaml.stringify(eventLog), 'utf8');
}
// Create subscriptions.yaml if not exists
if (!(await fs.pathExists(this.subscriptionsPath))) {
const subscriptions = {
version: 1,
subscriptions: {},
};
await fs.writeFile(this.subscriptionsPath, yaml.stringify(subscriptions), 'utf8');
}
}
/**
* Generate unique event ID
* @returns {string} Event ID
*/
generateEventId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `evt_${timestamp}_${random}`;
}
/**
* Log an event
* @param {string} type - Event type
* @param {string} scopeId - Source scope ID
* @param {object} data - Event data
* @returns {Promise<object>} Created event
*/
async logEvent(type, scopeId, data = {}) {
const event = {
id: this.generateEventId(),
type,
scope: scopeId,
timestamp: new Date().toISOString(),
data,
};
return this.stateLock.withLock(this.eventLogPath, async () => {
const content = await fs.readFile(this.eventLogPath, 'utf8');
const log = yaml.parse(content);
// Add event
log.events.push(event);
// Rotate if needed
if (log.events.length > this.maxEvents) {
// Keep only recent events
log.events = log.events.slice(-this.maxEvents);
}
await fs.writeFile(this.eventLogPath, yaml.stringify(log), 'utf8');
return event;
});
}
/**
* Get events for a scope
* @param {string} scopeId - Scope ID
* @param {object} options - Filter options
* @returns {Promise<object[]>} Array of events
*/
async getEvents(scopeId = null, options = {}) {
try {
const content = await fs.readFile(this.eventLogPath, 'utf8');
const log = yaml.parse(content);
let events = log.events || [];
// Filter by scope
if (scopeId) {
events = events.filter((e) => e.scope === scopeId);
}
// Filter by type
if (options.type) {
events = events.filter((e) => e.type === options.type);
}
// Filter by time range
if (options.since) {
const sinceDate = new Date(options.since);
events = events.filter((e) => new Date(e.timestamp) >= sinceDate);
}
if (options.until) {
const untilDate = new Date(options.until);
events = events.filter((e) => new Date(e.timestamp) <= untilDate);
}
// Limit results
if (options.limit) {
events = events.slice(-options.limit);
}
return events;
} catch {
return [];
}
}
/**
* Subscribe a scope to events from other scopes
* @param {string} subscriberScope - Scope that wants to receive events
* @param {string} watchScope - Scope to watch
* @param {string[]} patterns - Artifact patterns to watch
* @param {object} options - Subscription options
*/
async subscribe(subscriberScope, watchScope, patterns = ['*'], options = {}) {
return this.stateLock.withLock(this.subscriptionsPath, async () => {
const content = await fs.readFile(this.subscriptionsPath, 'utf8');
const subs = yaml.parse(content);
// Initialize subscriber if not exists
if (!subs.subscriptions[subscriberScope]) {
subs.subscriptions[subscriberScope] = {
watch: [],
notify: true,
};
}
// Add or update watch entry
const existingWatch = subs.subscriptions[subscriberScope].watch.find((w) => w.scope === watchScope);
if (existingWatch) {
existingWatch.patterns = patterns;
} else {
subs.subscriptions[subscriberScope].watch.push({
scope: watchScope,
patterns,
});
}
if (options.notify !== undefined) {
subs.subscriptions[subscriberScope].notify = options.notify;
}
await fs.writeFile(this.subscriptionsPath, yaml.stringify(subs), 'utf8');
});
}
/**
* Unsubscribe from a scope
* @param {string} subscriberScope - Subscriber scope
* @param {string} watchScope - Scope to stop watching
*/
async unsubscribe(subscriberScope, watchScope) {
return this.stateLock.withLock(this.subscriptionsPath, async () => {
const content = await fs.readFile(this.subscriptionsPath, 'utf8');
const subs = yaml.parse(content);
if (subs.subscriptions[subscriberScope]) {
subs.subscriptions[subscriberScope].watch = subs.subscriptions[subscriberScope].watch.filter((w) => w.scope !== watchScope);
}
await fs.writeFile(this.subscriptionsPath, yaml.stringify(subs), 'utf8');
});
}
/**
* Get subscriptions for a scope
* @param {string} scopeId - Scope ID
* @returns {Promise<object>} Subscription data
*/
async getSubscriptions(scopeId) {
try {
const content = await fs.readFile(this.subscriptionsPath, 'utf8');
const subs = yaml.parse(content);
return subs.subscriptions[scopeId] || { watch: [], notify: true };
} catch {
return { watch: [], notify: true };
}
}
/**
* Get pending notifications for a scope
* Events from watched scopes since last activity
* @param {string} scopeId - Scope ID
* @param {string} since - ISO timestamp to check from
* @returns {Promise<object[]>} Array of relevant events
*/
async getPendingNotifications(scopeId, since = null) {
try {
const subs = await this.getSubscriptions(scopeId);
if (!subs.notify || subs.watch.length === 0) {
return [];
}
const notifications = [];
for (const watch of subs.watch) {
const events = await this.getEvents(watch.scope, {
since: since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Last 24h default
});
for (const event of events) {
// Check if event matches any pattern
const matches = watch.patterns.some((pattern) => this.matchesPattern(event.data?.artifact, pattern));
if (matches || watch.patterns.includes('*')) {
notifications.push({
...event,
watchedBy: scopeId,
pattern: watch.patterns,
});
}
}
}
// Sort by timestamp
notifications.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
return notifications;
} catch {
return [];
}
}
/**
* Check if artifact matches pattern
* @param {string} artifact - Artifact path
* @param {string} pattern - Pattern to match
* @returns {boolean} True if matches
*/
matchesPattern(artifact, pattern) {
if (!artifact) return false;
if (pattern === '*') return true;
const regexPattern = pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*');
const regex = new RegExp(regexPattern);
return regex.test(artifact);
}
/**
* Common event types
*/
static EventTypes = {
ARTIFACT_CREATED: 'artifact_created',
ARTIFACT_UPDATED: 'artifact_updated',
ARTIFACT_DELETED: 'artifact_deleted',
ARTIFACT_PROMOTED: 'artifact_promoted',
SCOPE_CREATED: 'scope_created',
SCOPE_ARCHIVED: 'scope_archived',
SCOPE_ACTIVATED: 'scope_activated',
SYNC_UP: 'sync_up',
SYNC_DOWN: 'sync_down',
WORKFLOW_STARTED: 'workflow_started',
WORKFLOW_COMPLETED: 'workflow_completed',
};
/**
* Log artifact creation event
* @param {string} scopeId - Scope ID
* @param {string} artifact - Artifact path
* @param {object} metadata - Additional metadata
*/
async logArtifactCreated(scopeId, artifact, metadata = {}) {
return this.logEvent(EventLogger.EventTypes.ARTIFACT_CREATED, scopeId, {
artifact,
...metadata,
});
}
/**
* Log artifact update event
* @param {string} scopeId - Scope ID
* @param {string} artifact - Artifact path
* @param {object} metadata - Additional metadata
*/
async logArtifactUpdated(scopeId, artifact, metadata = {}) {
return this.logEvent(EventLogger.EventTypes.ARTIFACT_UPDATED, scopeId, {
artifact,
...metadata,
});
}
/**
* Log artifact promotion event
* @param {string} scopeId - Scope ID
* @param {string} artifact - Artifact path
* @param {string} sharedPath - Path in shared layer
*/
async logArtifactPromoted(scopeId, artifact, sharedPath) {
return this.logEvent(EventLogger.EventTypes.ARTIFACT_PROMOTED, scopeId, {
artifact,
shared_path: sharedPath,
});
}
/**
* Log sync operation
* @param {string} type - 'up' or 'down'
* @param {string} scopeId - Scope ID
* @param {object} result - Sync result
*/
async logSync(type, scopeId, result) {
const eventType = type === 'up' ? EventLogger.EventTypes.SYNC_UP : EventLogger.EventTypes.SYNC_DOWN;
return this.logEvent(eventType, scopeId, {
files_count: result.promoted?.length || result.pulled?.length || 0,
conflicts_count: result.conflicts?.length || 0,
errors_count: result.errors?.length || 0,
});
}
/**
* Get event statistics
* @param {string} scopeId - Optional scope filter
* @returns {Promise<object>} Event statistics
*/
async getStats(scopeId = null) {
const events = await this.getEvents(scopeId);
const stats = {
total: events.length,
byType: {},
byScope: {},
last24h: 0,
lastEvent: null,
};
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const event of events) {
// Count by type
stats.byType[event.type] = (stats.byType[event.type] || 0) + 1;
// Count by scope
stats.byScope[event.scope] = (stats.byScope[event.scope] || 0) + 1;
// Count recent
if (new Date(event.timestamp) >= oneDayAgo) {
stats.last24h++;
}
}
if (events.length > 0) {
stats.lastEvent = events.at(-1);
}
return stats;
}
}
module.exports = { EventLogger };

View File

@ -0,0 +1,30 @@
/**
* Scope Management Module
*
* Provides multi-scope parallel artifact system functionality
* for isolated development workflows.
*
* @module scope
*/
const { ScopeValidator } = require('./scope-validator');
const { ScopeManager } = require('./scope-manager');
const { ScopeInitializer } = require('./scope-initializer');
const { ScopeMigrator } = require('./scope-migrator');
const { ScopeContext } = require('./scope-context');
const { ArtifactResolver } = require('./artifact-resolver');
const { StateLock } = require('./state-lock');
const { ScopeSync } = require('./scope-sync');
const { EventLogger } = require('./event-logger');
module.exports = {
ScopeValidator,
ScopeManager,
ScopeInitializer,
ScopeMigrator,
ScopeContext,
ArtifactResolver,
StateLock,
ScopeSync,
EventLogger,
};

View File

@ -0,0 +1,312 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
/**
* Manages session-sticky scope context
* Tracks the current active scope for workflows and agents
*
* @class ScopeContext
* @requires fs-extra
* @requires yaml
*
* @example
* const context = new ScopeContext({ projectRoot: '/path/to/project' });
* await context.setScope('auth');
* const current = await context.getCurrentScope();
*/
class ScopeContext {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.contextFileName = options.contextFileName || '.bmad-scope';
this.contextFilePath = path.join(this.projectRoot, this.contextFileName);
this.outputBase = options.outputBase || '_bmad-output';
this.sharedPath = path.join(this.projectRoot, this.outputBase, '_shared');
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.contextFilePath = path.join(projectRoot, this.contextFileName);
this.sharedPath = path.join(projectRoot, this.outputBase, '_shared');
}
/**
* Get the current active scope
* @returns {Promise<string|null>} Current scope ID or null
*/
async getCurrentScope() {
try {
if (!(await fs.pathExists(this.contextFilePath))) {
return null;
}
const content = await fs.readFile(this.contextFilePath, 'utf8');
const context = yaml.parse(content);
return context?.active_scope || null;
} catch {
return null;
}
}
/**
* Set the current active scope
* @param {string} scopeId - The scope ID to set as active
* @returns {Promise<boolean>} Success status
*/
async setScope(scopeId) {
try {
const context = {
active_scope: scopeId,
set_at: new Date().toISOString(),
set_by: process.env.USER || 'unknown',
};
await fs.writeFile(this.contextFilePath, yaml.stringify(context), 'utf8');
return true;
} catch (error) {
throw new Error(`Failed to set scope context: ${error.message}`);
}
}
/**
* Clear the current scope context
* @returns {Promise<boolean>} Success status
*/
async clearScope() {
try {
if (await fs.pathExists(this.contextFilePath)) {
await fs.remove(this.contextFilePath);
}
return true;
} catch (error) {
throw new Error(`Failed to clear scope context: ${error.message}`);
}
}
/**
* Get the full context object
* @returns {Promise<object|null>} Context object or null
*/
async getContext() {
try {
if (!(await fs.pathExists(this.contextFilePath))) {
return null;
}
const content = await fs.readFile(this.contextFilePath, 'utf8');
return yaml.parse(content);
} catch {
return null;
}
}
/**
* Check if a scope context is set
* @returns {Promise<boolean>} True if scope is set
*/
async hasScope() {
const scope = await this.getCurrentScope();
return scope !== null;
}
/**
* Load and merge project context files
* Loads global context and optionally scope-specific context
* @param {string} scopeId - The scope ID (optional, uses current if not provided)
* @returns {Promise<object>} Merged context object
*/
async loadProjectContext(scopeId = null) {
const scope = scopeId || (await this.getCurrentScope());
const context = {
global: null,
scope: null,
merged: '',
};
try {
// Load global project context
const globalContextPath = path.join(this.sharedPath, 'project-context.md');
if (await fs.pathExists(globalContextPath)) {
context.global = await fs.readFile(globalContextPath, 'utf8');
}
// Load scope-specific context if scope is set
if (scope) {
const scopeContextPath = path.join(this.projectRoot, this.outputBase, scope, 'project-context.md');
if (await fs.pathExists(scopeContextPath)) {
context.scope = await fs.readFile(scopeContextPath, 'utf8');
}
}
// Merge contexts (scope extends global)
if (context.global && context.scope) {
context.merged = `${context.global}\n\n---\n\n## Scope-Specific Context\n\n${context.scope}`;
} else if (context.global) {
context.merged = context.global;
} else if (context.scope) {
context.merged = context.scope;
}
} catch (error) {
throw new Error(`Failed to load project context: ${error.message}`);
}
return context;
}
/**
* Resolve scope from various sources
* Priority: explicit > session > environment > prompt
* @param {string} explicitScope - Explicitly provided scope (highest priority)
* @param {boolean} promptIfMissing - Whether to throw if no scope found
* @param {object} options - Additional options
* @param {boolean} options.silent - Suppress warning when no scope found
* @returns {Promise<string|null>} Resolved scope ID
*/
async resolveScope(explicitScope = null, promptIfMissing = false, options = {}) {
// 1. Explicit scope (from --scope flag or parameter)
if (explicitScope) {
return explicitScope;
}
// 2. Session context (.bmad-scope file)
const sessionScope = await this.getCurrentScope();
if (sessionScope) {
return sessionScope;
}
// 3. Environment variable
const envScope = process.env.BMAD_SCOPE;
if (envScope) {
return envScope;
}
// 4. No scope found
if (promptIfMissing) {
throw new Error('No scope set. Use --scope flag or run: npx bmad-fh scope set <id>');
}
// Warn user about missing scope (unless silent mode)
if (!options.silent) {
console.warn(
'\u001B[33mNo scope set. Artifacts will go to root _bmad-output/ directory.\u001B[0m\n' +
' To use scoped artifacts, run: npx bmad-fh scope set <scope-id>\n' +
' Or set BMAD_SCOPE environment variable.\n',
);
}
return null;
}
/**
* Get scope-specific variable substitutions
* Returns variables that can be used in workflow templates
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Variables object
*/
async getScopeVariables(scopeId) {
const scope = scopeId || (await this.getCurrentScope());
if (!scope) {
return {
scope: '',
scope_path: '',
scope_planning: '',
scope_implementation: '',
scope_tests: '',
};
}
const basePath = path.join(this.outputBase, scope);
return {
scope: scope,
scope_path: basePath,
scope_planning: path.join(basePath, 'planning-artifacts'),
scope_implementation: path.join(basePath, 'implementation-artifacts'),
scope_tests: path.join(basePath, 'tests'),
};
}
/**
* Create context initialization snippet for agents/workflows
* This returns text that can be injected into agent prompts
* @param {string} scopeId - The scope ID
* @returns {Promise<string>} Context snippet
*/
async createContextSnippet(scopeId) {
const scope = scopeId || (await this.getCurrentScope());
if (!scope) {
return '<!-- No scope context active -->';
}
const vars = await this.getScopeVariables(scope);
const context = await this.loadProjectContext(scope);
return `
<!-- SCOPE CONTEXT START -->
## Active Scope: ${scope}
### Scope Paths
- Planning: \`${vars.scope_planning}\`
- Implementation: \`${vars.scope_implementation}\`
- Tests: \`${vars.scope_tests}\`
### Project Context
${context.merged || 'No project context loaded.'}
<!-- SCOPE CONTEXT END -->
`;
}
/**
* Export context for use in shell/scripts
* @param {string} scopeId - The scope ID
* @returns {Promise<string>} Shell export statements
*/
async exportForShell(scopeId) {
const scope = scopeId || (await this.getCurrentScope());
if (!scope) {
return '# No scope set';
}
const vars = await this.getScopeVariables(scope);
return `
export BMAD_SCOPE="${vars.scope}"
export BMAD_SCOPE_PATH="${vars.scope_path}"
export BMAD_SCOPE_PLANNING="${vars.scope_planning}"
export BMAD_SCOPE_IMPLEMENTATION="${vars.scope_implementation}"
export BMAD_SCOPE_TESTS="${vars.scope_tests}"
`.trim();
}
/**
* Update context metadata
* @param {object} metadata - Metadata to update
* @returns {Promise<boolean>} Success status
*/
async updateMetadata(metadata) {
try {
const context = (await this.getContext()) || {};
const updated = {
...context,
...metadata,
updated_at: new Date().toISOString(),
};
await fs.writeFile(this.contextFilePath, yaml.stringify(updated), 'utf8');
return true;
} catch (error) {
throw new Error(`Failed to update context metadata: ${error.message}`);
}
}
}
module.exports = { ScopeContext };

View File

@ -0,0 +1,456 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
/**
* Initializes directory structure for scopes
* Creates scope directories, shared layer, and event system
*
* @class ScopeInitializer
* @requires fs-extra
* @requires yaml
*
* @example
* const initializer = new ScopeInitializer({ projectRoot: '/path/to/project' });
* await initializer.initializeScope('auth');
*/
class ScopeInitializer {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.outputBase = options.outputBase || '_bmad-output';
this.bmadPath = options.bmadPath || path.join(this.projectRoot, '_bmad');
this.outputPath = path.join(this.projectRoot, this.outputBase);
this.sharedPath = path.join(this.outputPath, '_shared');
this.eventsPath = path.join(this.bmadPath, '_events');
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.bmadPath = path.join(projectRoot, '_bmad');
this.outputPath = path.join(projectRoot, this.outputBase);
this.sharedPath = path.join(this.outputPath, '_shared');
this.eventsPath = path.join(this.bmadPath, '_events');
}
/**
* Initialize the scope system (one-time setup)
* Creates _shared and _events directories
* @returns {Promise<boolean>} Success status
*/
async initializeScopeSystem() {
try {
// Create shared knowledge layer
await this.initializeSharedLayer();
// Create event system
await this.initializeEventSystem();
return true;
} catch (error) {
throw new Error(`Failed to initialize scope system: ${error.message}`);
}
}
/**
* Initialize the shared knowledge layer
* Creates _shared directory and default files
* @returns {Promise<boolean>} Success status
*/
async initializeSharedLayer() {
try {
// Create shared directory structure
await fs.ensureDir(this.sharedPath);
await fs.ensureDir(path.join(this.sharedPath, 'contracts'));
await fs.ensureDir(path.join(this.sharedPath, 'principles'));
await fs.ensureDir(path.join(this.sharedPath, 'architecture'));
// Create README in shared directory
const sharedReadmePath = path.join(this.sharedPath, 'README.md');
if (!(await fs.pathExists(sharedReadmePath))) {
const readmeContent = this.generateSharedReadme();
await fs.writeFile(sharedReadmePath, readmeContent, 'utf8');
}
// Create global project-context.md template
const contextPath = path.join(this.sharedPath, 'project-context.md');
if (!(await fs.pathExists(contextPath))) {
const contextContent = this.generateGlobalContextTemplate();
await fs.writeFile(contextPath, contextContent, 'utf8');
}
return true;
} catch (error) {
throw new Error(`Failed to initialize shared layer: ${error.message}`);
}
}
/**
* Initialize the event system
* Creates _events directory and event files
* @returns {Promise<boolean>} Success status
*/
async initializeEventSystem() {
try {
// Create events directory
await fs.ensureDir(this.eventsPath);
// Create event-log.yaml
const eventLogPath = path.join(this.eventsPath, 'event-log.yaml');
if (!(await fs.pathExists(eventLogPath))) {
const eventLog = {
version: 1,
events: [],
};
await fs.writeFile(eventLogPath, yaml.stringify(eventLog), 'utf8');
}
// Create subscriptions.yaml
const subscriptionsPath = path.join(this.eventsPath, 'subscriptions.yaml');
if (!(await fs.pathExists(subscriptionsPath))) {
const subscriptions = {
version: 1,
subscriptions: {},
};
await fs.writeFile(subscriptionsPath, yaml.stringify(subscriptions), 'utf8');
}
return true;
} catch (error) {
throw new Error(`Failed to initialize event system: ${error.message}`);
}
}
/**
* Initialize a new scope directory structure
* @param {string} scopeId - The scope ID
* @param {object} options - Scope options
* @returns {Promise<object>} Created directory paths
*/
async initializeScope(scopeId, options = {}) {
try {
const scopePath = path.join(this.outputPath, scopeId);
// Check if scope directory already exists
if ((await fs.pathExists(scopePath)) && !options.force) {
throw new Error(`Scope directory '${scopeId}' already exists. Use force option to recreate.`);
}
// Create scope directory structure
const paths = {
root: scopePath,
planning: path.join(scopePath, 'planning-artifacts'),
implementation: path.join(scopePath, 'implementation-artifacts'),
tests: path.join(scopePath, 'tests'),
meta: path.join(scopePath, '.scope-meta.yaml'),
};
// Create directories
await fs.ensureDir(paths.planning);
await fs.ensureDir(paths.implementation);
await fs.ensureDir(paths.tests);
// Create scope metadata file
const metadata = {
scope_id: scopeId,
created: new Date().toISOString(),
version: 1,
structure: {
planning_artifacts: 'planning-artifacts/',
implementation_artifacts: 'implementation-artifacts/',
tests: 'tests/',
},
};
await fs.writeFile(paths.meta, yaml.stringify(metadata), 'utf8');
// Create README in scope directory
const readmePath = path.join(scopePath, 'README.md');
if (!(await fs.pathExists(readmePath))) {
const readmeContent = this.generateScopeReadme(scopeId, options);
await fs.writeFile(readmePath, readmeContent, 'utf8');
}
// Create optional scope-specific project-context.md
if (options.createContext) {
const contextPath = path.join(scopePath, 'project-context.md');
const contextContent = this.generateScopeContextTemplate(scopeId, options);
await fs.writeFile(contextPath, contextContent, 'utf8');
}
return paths;
} catch (error) {
throw new Error(`Failed to initialize scope '${scopeId}': ${error.message}`);
}
}
/**
* Remove a scope directory
* @param {string} scopeId - The scope ID
* @param {object} options - Removal options
* @returns {Promise<boolean>} Success status
*/
async removeScope(scopeId, options = {}) {
try {
const scopePath = path.join(this.outputPath, scopeId);
// Check if scope exists
if (!(await fs.pathExists(scopePath))) {
throw new Error(`Scope directory '${scopeId}' does not exist`);
}
// Create backup if requested
if (options.backup) {
const backupPath = path.join(this.outputPath, `_backup_${scopeId}_${Date.now()}`);
await fs.copy(scopePath, backupPath);
}
// Remove directory
await fs.remove(scopePath);
return true;
} catch (error) {
throw new Error(`Failed to remove scope '${scopeId}': ${error.message}`);
}
}
/**
* Check if scope system is initialized
* @returns {Promise<boolean>} True if initialized
*/
async isSystemInitialized() {
const sharedExists = await fs.pathExists(this.sharedPath);
const eventsExists = await fs.pathExists(this.eventsPath);
return sharedExists && eventsExists;
}
/**
* Check if a scope directory exists
* @param {string} scopeId - The scope ID
* @returns {Promise<boolean>} True if exists
*/
async scopeDirectoryExists(scopeId) {
const scopePath = path.join(this.outputPath, scopeId);
return fs.pathExists(scopePath);
}
/**
* Get scope directory paths
* @param {string} scopeId - The scope ID
* @returns {object} Scope paths
*/
getScopePaths(scopeId) {
const scopePath = path.join(this.outputPath, scopeId);
return {
root: scopePath,
planning: path.join(scopePath, 'planning-artifacts'),
implementation: path.join(scopePath, 'implementation-artifacts'),
tests: path.join(scopePath, 'tests'),
meta: path.join(scopePath, '.scope-meta.yaml'),
context: path.join(scopePath, 'project-context.md'),
};
}
/**
* Generate README content for shared directory
* @returns {string} README content
*/
generateSharedReadme() {
return `# Shared Knowledge Layer
This directory contains knowledge and artifacts that are shared across all scopes.
## Directory Structure
- **contracts/** - Integration contracts and APIs between scopes
- **principles/** - Architecture principles and design patterns
- **architecture/** - High-level architecture documents
- **project-context.md** - Global project context (the "bible")
## Purpose
The shared layer enables:
- Cross-scope integration without tight coupling
- Consistent architecture patterns across scopes
- Centralized project context and principles
- Dependency management through contracts
## Usage
1. **Reading**: All scopes can read from \`_shared/\`
2. **Writing**: Use \`bmad scope sync-up <scope>\` to promote artifacts
3. **Syncing**: Use \`bmad scope sync-down <scope>\` to pull updates
## Best Practices
- Keep contracts focused and minimal
- Document all shared artifacts clearly
- Version shared artifacts when making breaking changes
- Use sync commands rather than manual edits
`;
}
/**
* Generate global project-context.md template
* @returns {string} Context template content
*/
generateGlobalContextTemplate() {
return `# Global Project Context
> This is the global "bible" for the project. All scopes extend this context.
## Project Overview
**Name:** [Your Project Name]
**Purpose:** [Core purpose of the project]
**Status:** Active Development
## Architecture Principles
1. **Principle 1:** Description
2. **Principle 2:** Description
3. **Principle 3:** Description
## Technology Stack
- **Language:** [e.g., Node.js, Python]
- **Framework:** [e.g., Express, FastAPI]
- **Database:** [e.g., PostgreSQL, MongoDB]
- **Infrastructure:** [e.g., AWS, Docker]
## Key Decisions
### Decision 1: [Title]
- **Context:** Why this decision was needed
- **Decision:** What was decided
- **Consequences:** Impact and trade-offs
## Integration Patterns
Describe how scopes integrate with each other.
## Shared Resources
List shared resources, databases, APIs, etc.
## Contact & Documentation
- **Team Lead:** [Name]
- **Documentation:** [Link]
- **Repository:** [Link]
`;
}
/**
* Generate README content for scope directory
* @param {string} scopeId - The scope ID
* @param {object} options - Scope options
* @returns {string} README content
*/
generateScopeReadme(scopeId, options = {}) {
const scopeName = options.name || scopeId;
const description = options.description || 'No description provided';
return `# Scope: ${scopeName}
${description}
## Directory Structure
- **planning-artifacts/** - Planning documents, PRDs, specifications
- **implementation-artifacts/** - Sprint status, development artifacts
- **tests/** - Test files and test results
- **project-context.md** - Scope-specific context (extends global)
## Scope Information
- **ID:** ${scopeId}
- **Name:** ${scopeName}
- **Status:** ${options.status || 'active'}
- **Created:** ${new Date().toISOString().split('T')[0]}
## Dependencies
${options.dependencies && options.dependencies.length > 0 ? options.dependencies.map((dep) => `- ${dep}`).join('\n') : 'No dependencies'}
## Usage
### Working in this scope
\`\`\`bash
# Activate scope context
bmad workflow --scope ${scopeId}
# Check scope info
bmad scope info ${scopeId}
\`\`\`
### Sharing artifacts
\`\`\`bash
# Promote artifacts to shared layer
bmad scope sync-up ${scopeId}
# Pull updates from shared layer
bmad scope sync-down ${scopeId}
\`\`\`
## Related Documentation
- Global context: ../_shared/project-context.md
- Contracts: ../_shared/contracts/
`;
}
/**
* Generate scope-specific project-context.md template
* @param {string} scopeId - The scope ID
* @param {object} options - Scope options
* @returns {string} Context template content
*/
generateScopeContextTemplate(scopeId, options = {}) {
const scopeName = options.name || scopeId;
return `# Scope Context: ${scopeName}
> This context extends the global project context in ../_shared/project-context.md
## Scope Purpose
[Describe the specific purpose and boundaries of this scope]
## Scope-Specific Architecture
[Describe architecture specific to this scope]
## Technology Choices
[List any scope-specific technology choices]
## Integration Points
### Dependencies
${
options.dependencies && options.dependencies.length > 0
? options.dependencies.map((dep) => `- **${dep}**: [Describe dependency relationship]`).join('\n')
: 'No dependencies'
}
### Provides
[What this scope provides to other scopes]
## Key Files & Artifacts
- [File 1]: Description
- [File 2]: Description
## Development Notes
[Any important notes for developers working in this scope]
`;
}
}
module.exports = { ScopeInitializer };

View File

@ -0,0 +1,545 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { ScopeValidator } = require('./scope-validator');
const { ScopeInitializer } = require('./scope-initializer');
/**
* Manages scope lifecycle and CRUD operations
* Handles scope configuration in scopes.yaml file
*
* @class ScopeManager
* @requires fs-extra
* @requires yaml
* @requires ScopeValidator
*
* @example
* const manager = new ScopeManager({ projectRoot: '/path/to/project' });
* await manager.initialize();
* const scope = await manager.createScope('auth', { name: 'Authentication' });
*/
class ScopeManager {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.bmadPath = options.bmadPath || path.join(this.projectRoot, '_bmad');
this.configPath = options.configPath || path.join(this.bmadPath, '_config');
this.scopesFilePath = options.scopesFilePath || path.join(this.configPath, 'scopes.yaml');
this.validator = new ScopeValidator();
this.initializer = new ScopeInitializer({ projectRoot: this.projectRoot });
this._config = null; // Cached configuration
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.bmadPath = path.join(projectRoot, '_bmad');
this.configPath = path.join(this.bmadPath, '_config');
this.scopesFilePath = path.join(this.configPath, 'scopes.yaml');
this._config = null; // Clear cache
this.initializer.setProjectRoot(projectRoot);
}
/**
* Initialize the scope management system
* Creates scopes.yaml if it doesn't exist
* @returns {Promise<boolean>} Success status
*/
async initialize() {
try {
// Ensure directories exist
await fs.ensureDir(this.configPath);
// Check if scopes.yaml exists
const exists = await fs.pathExists(this.scopesFilePath);
if (!exists) {
// Create default configuration
const defaultConfig = this.validator.createDefaultConfig();
await this.saveConfig(defaultConfig);
}
// Initialize scope system directories (_shared, _events)
await this.initializer.initializeScopeSystem();
// Load and validate configuration
const config = await this.loadConfig();
return config !== null;
} catch (error) {
throw new Error(`Failed to initialize scope manager: ${error.message}`);
}
}
/**
* Load scopes configuration from file
* @param {boolean} forceReload - Force reload from disk (ignore cache)
* @returns {Promise<object|null>} Configuration object or null if invalid
*/
async loadConfig(forceReload = false) {
try {
// Return cached config if available
if (this._config && !forceReload) {
return this._config;
}
// Check if file exists
const exists = await fs.pathExists(this.scopesFilePath);
if (!exists) {
throw new Error('scopes.yaml does not exist. Run initialize() first.');
}
// Read and parse file
const content = await fs.readFile(this.scopesFilePath, 'utf8');
const config = yaml.parse(content);
// Validate configuration
const validation = this.validator.validateConfig(config);
if (!validation.valid) {
throw new Error(`Invalid scopes.yaml: ${validation.errors.join(', ')}`);
}
// Cache and return
this._config = config;
return config;
} catch (error) {
throw new Error(`Failed to load scopes configuration: ${error.message}`);
}
}
/**
* Save scopes configuration to file
* @param {object} config - Configuration object to save
* @returns {Promise<boolean>} Success status
*/
async saveConfig(config) {
try {
// Validate before saving
const validation = this.validator.validateConfig(config);
if (!validation.valid) {
throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
}
// Ensure directory exists
await fs.ensureDir(this.configPath);
// Write to file
const yamlContent = yaml.stringify(config, {
indent: 2,
lineWidth: 100,
});
await fs.writeFile(this.scopesFilePath, yamlContent, 'utf8');
// Update cache
this._config = config;
return true;
} catch (error) {
throw new Error(`Failed to save scopes configuration: ${error.message}`);
}
}
/**
* List all scopes
* @param {object} filters - Optional filters (status, etc.)
* @returns {Promise<object[]>} Array of scope objects
*/
async listScopes(filters = {}) {
try {
const config = await this.loadConfig();
let scopes = Object.values(config.scopes || {});
// Apply filters
if (filters.status) {
scopes = scopes.filter((scope) => scope.status === filters.status);
}
// Sort by created date (newest first)
scopes.sort((a, b) => {
const dateA = a.created ? new Date(a.created) : new Date(0);
const dateB = b.created ? new Date(b.created) : new Date(0);
return dateB - dateA;
});
return scopes;
} catch (error) {
throw new Error(`Failed to list scopes: ${error.message}`);
}
}
/**
* Get a specific scope by ID
* @param {string} scopeId - The scope ID
* @returns {Promise<object|null>} Scope object or null if not found
*/
async getScope(scopeId) {
try {
const config = await this.loadConfig();
return config.scopes?.[scopeId] || null;
} catch (error) {
throw new Error(`Failed to get scope '${scopeId}': ${error.message}`);
}
}
/**
* Check if a scope exists
* @param {string} scopeId - The scope ID
* @returns {Promise<boolean>} True if scope exists
*/
async scopeExists(scopeId) {
try {
const scope = await this.getScope(scopeId);
return scope !== null;
} catch {
return false;
}
}
/**
* Create a new scope
* @param {string} scopeId - The scope ID
* @param {object} options - Scope options (name, description, dependencies, etc.)
* @returns {Promise<object>} Created scope object
*/
async createScope(scopeId, options = {}) {
try {
// Validate scope ID
const idValidation = this.validator.validateScopeId(scopeId);
if (!idValidation.valid) {
throw new Error(idValidation.error);
}
// Check if scope already exists
const exists = await this.scopeExists(scopeId);
if (exists) {
throw new Error(`Scope '${scopeId}' already exists`);
}
// Load current configuration
const config = await this.loadConfig();
// Create scope object
const scope = {
id: scopeId,
name: options.name || scopeId,
description: options.description || '',
status: options.status || 'active',
dependencies: options.dependencies || [],
created: new Date().toISOString(),
_meta: {
last_activity: new Date().toISOString(),
artifact_count: 0,
},
};
// Validate scope with existing scopes
const scopeValidation = this.validator.validateScope(scope, config.scopes);
if (!scopeValidation.valid) {
throw new Error(`Invalid scope configuration: ${scopeValidation.errors.join(', ')}`);
}
// Add to configuration
config.scopes[scopeId] = scope;
// Save configuration
await this.saveConfig(config);
// Create scope directory structure
await this.initializer.initializeScope(scopeId, options);
return scope;
} catch (error) {
throw new Error(`Failed to create scope '${scopeId}': ${error.message}`);
}
}
/**
* Update an existing scope
* @param {string} scopeId - The scope ID
* @param {object} updates - Fields to update
* @returns {Promise<object>} Updated scope object
*/
async updateScope(scopeId, updates = {}) {
try {
// Load current configuration
const config = await this.loadConfig();
// Check if scope exists
if (!config.scopes[scopeId]) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
// Get current scope
const currentScope = config.scopes[scopeId];
// Apply updates (cannot change ID)
const updatedScope = {
...currentScope,
...updates,
id: scopeId, // Force ID to remain unchanged
_meta: {
...currentScope._meta,
...updates._meta,
last_activity: new Date().toISOString(),
},
};
// Validate updated scope
const scopeValidation = this.validator.validateScope(updatedScope, config.scopes);
if (!scopeValidation.valid) {
throw new Error(`Invalid scope update: ${scopeValidation.errors.join(', ')}`);
}
// Update in configuration
config.scopes[scopeId] = updatedScope;
// Save configuration
await this.saveConfig(config);
return updatedScope;
} catch (error) {
throw new Error(`Failed to update scope '${scopeId}': ${error.message}`);
}
}
/**
* Remove a scope
* @param {string} scopeId - The scope ID
* @param {object} options - Removal options (force, etc.)
* @returns {Promise<boolean>} Success status
*/
async removeScope(scopeId, options = {}) {
try {
// Load current configuration
const config = await this.loadConfig();
// Check if scope exists
if (!config.scopes[scopeId]) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
// Check if other scopes depend on this one
const dependentScopes = this.findDependentScopesSync(scopeId, config.scopes);
if (dependentScopes.length > 0 && !options.force) {
throw new Error(
`Cannot remove scope '${scopeId}'. The following scopes depend on it: ${dependentScopes.join(', ')}. Use force option to remove anyway.`,
);
}
// Remove scope
delete config.scopes[scopeId];
// If force remove, also remove dependencies from other scopes
if (options.force && dependentScopes.length > 0) {
for (const depScopeId of dependentScopes) {
const depScope = config.scopes[depScopeId];
depScope.dependencies = depScope.dependencies.filter((dep) => dep !== scopeId);
}
}
// Save configuration
await this.saveConfig(config);
return true;
} catch (error) {
throw new Error(`Failed to remove scope '${scopeId}': ${error.message}`);
}
}
/**
* Get scope paths for artifact resolution
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Object containing scope paths
*/
async getScopePaths(scopeId) {
try {
const config = await this.loadConfig();
const scope = config.scopes[scopeId];
if (!scope) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
const outputBase = config.settings.default_output_base;
const scopePath = path.join(this.projectRoot, outputBase, scopeId);
return {
root: scopePath,
planning: path.join(scopePath, 'planning-artifacts'),
implementation: path.join(scopePath, 'implementation-artifacts'),
tests: path.join(scopePath, 'tests'),
meta: path.join(scopePath, '.scope-meta.yaml'),
};
} catch (error) {
throw new Error(`Failed to get scope paths for '${scopeId}': ${error.message}`);
}
}
/**
* Resolve a path template with scope variable
* @param {string} template - Path template (e.g., "{output_folder}/{scope}/artifacts")
* @param {string} scopeId - The scope ID
* @returns {string} Resolved path
*/
resolvePath(template, scopeId) {
return template
.replaceAll('{scope}', scopeId)
.replaceAll('{output_folder}', this._config?.settings?.default_output_base || '_bmad-output');
}
/**
* Get dependency tree for a scope
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Dependency tree
*/
async getDependencyTree(scopeId) {
try {
const config = await this.loadConfig();
const scope = config.scopes[scopeId];
if (!scope) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
const tree = {
scope: scopeId,
dependencies: [],
dependents: this.findDependentScopesSync(scopeId, config.scopes),
};
// Build dependency tree recursively
if (scope.dependencies && scope.dependencies.length > 0) {
for (const depId of scope.dependencies) {
const depScope = config.scopes[depId];
if (depScope) {
tree.dependencies.push({
scope: depId,
name: depScope.name,
status: depScope.status,
});
}
}
}
return tree;
} catch (error) {
throw new Error(`Failed to get dependency tree for '${scopeId}': ${error.message}`);
}
}
/**
* Find scopes that depend on a given scope
* @param {string} scopeId - The scope ID
* @param {object} allScopes - All scopes object (optional, will load if not provided)
* @returns {Promise<string[]>|string[]} Array of dependent scope IDs
*/
async findDependentScopes(scopeId, allScopes = null) {
// If allScopes not provided, load from config
if (!allScopes) {
const config = await this.loadConfig();
allScopes = config.scopes || {};
}
const dependents = [];
for (const [sid, scope] of Object.entries(allScopes)) {
if (scope.dependencies && scope.dependencies.includes(scopeId)) {
dependents.push(sid);
}
}
return dependents;
}
/**
* Find scopes that depend on a given scope (synchronous version)
* @param {string} scopeId - The scope ID
* @param {object} allScopes - All scopes object (required)
* @returns {string[]} Array of dependent scope IDs
*/
findDependentScopesSync(scopeId, allScopes) {
const dependents = [];
for (const [sid, scope] of Object.entries(allScopes)) {
if (scope.dependencies && scope.dependencies.includes(scopeId)) {
dependents.push(sid);
}
}
return dependents;
}
/**
* Archive a scope (set status to archived)
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Updated scope object
*/
async archiveScope(scopeId) {
return this.updateScope(scopeId, { status: 'archived' });
}
/**
* Activate a scope (set status to active)
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Updated scope object
*/
async activateScope(scopeId) {
return this.updateScope(scopeId, { status: 'active' });
}
/**
* Update scope activity timestamp
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Updated scope object
*/
async touchScope(scopeId) {
return this.updateScope(scopeId, {
_meta: { last_activity: new Date().toISOString() },
});
}
/**
* Increment artifact count for a scope
* @param {string} scopeId - The scope ID
* @param {number} increment - Amount to increment (default: 1)
* @returns {Promise<object>} Updated scope object
*/
async incrementArtifactCount(scopeId, increment = 1) {
const scope = await this.getScope(scopeId);
if (!scope) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
const currentCount = scope._meta?.artifact_count || 0;
return this.updateScope(scopeId, {
_meta: { artifact_count: currentCount + increment },
});
}
/**
* Get scope settings
* @returns {Promise<object>} Settings object
*/
async getSettings() {
const config = await this.loadConfig();
return config.settings || {};
}
/**
* Update scope settings
* @param {object} settings - New settings
* @returns {Promise<object>} Updated settings
*/
async updateSettings(settings) {
const config = await this.loadConfig();
config.settings = {
...config.settings,
...settings,
};
await this.saveConfig(config);
return config.settings;
}
}
module.exports = { ScopeManager };

View File

@ -0,0 +1,434 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
/**
* Migrates existing artifacts to scoped structure
* Handles migration of legacy non-scoped installations
*
* @class ScopeMigrator
* @requires fs-extra
* @requires yaml
*
* @example
* const migrator = new ScopeMigrator({ projectRoot: '/path/to/project' });
* await migrator.migrate();
*/
class ScopeMigrator {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.outputBase = options.outputBase || '_bmad-output';
this.bmadPath = path.join(this.projectRoot, '_bmad');
this.outputPath = path.join(this.projectRoot, this.outputBase);
this.defaultScopeId = options.defaultScopeId || 'default';
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.bmadPath = path.join(projectRoot, '_bmad');
this.outputPath = path.join(projectRoot, this.outputBase);
}
/**
* Check if migration is needed
* Returns true if there are artifacts in non-scoped locations
* @returns {Promise<boolean>} True if migration needed
*/
async needsMigration() {
try {
// Check if output directory exists
if (!(await fs.pathExists(this.outputPath))) {
return false;
}
// Check for legacy structure indicators
const hasLegacyPlanning = await fs.pathExists(path.join(this.outputPath, 'planning-artifacts'));
const hasLegacyImplementation = await fs.pathExists(path.join(this.outputPath, 'implementation-artifacts'));
// Check if already migrated (scopes.yaml exists and has scopes)
const scopesYamlPath = path.join(this.bmadPath, '_config', 'scopes.yaml');
if (await fs.pathExists(scopesYamlPath)) {
const content = await fs.readFile(scopesYamlPath, 'utf8');
const config = yaml.parse(content);
if (config.scopes && Object.keys(config.scopes).length > 0) {
// Already has scopes, check if legacy directories still exist alongside
return hasLegacyPlanning || hasLegacyImplementation;
}
}
return hasLegacyPlanning || hasLegacyImplementation;
} catch {
return false;
}
}
/**
* Analyze existing artifacts for migration
* @returns {Promise<object>} Analysis results
*/
async analyzeExisting() {
const analysis = {
hasLegacyArtifacts: false,
directories: [],
files: [],
totalSize: 0,
suggestedScope: this.defaultScopeId,
};
try {
// Check for legacy directories
const legacyDirs = ['planning-artifacts', 'implementation-artifacts', 'tests'];
for (const dir of legacyDirs) {
const dirPath = path.join(this.outputPath, dir);
if (await fs.pathExists(dirPath)) {
analysis.hasLegacyArtifacts = true;
analysis.directories.push(dir);
// Count files and size
const stats = await this.getDirStats(dirPath);
analysis.files.push(...stats.files);
analysis.totalSize += stats.size;
}
}
// Check for root-level artifacts
const rootFiles = ['project-context.md', 'sprint-status.yaml', 'bmm-workflow-status.yaml'];
for (const file of rootFiles) {
const filePath = path.join(this.outputPath, file);
if (await fs.pathExists(filePath)) {
analysis.hasLegacyArtifacts = true;
const stat = await fs.stat(filePath);
analysis.files.push(file);
analysis.totalSize += stat.size;
}
}
} catch (error) {
throw new Error(`Failed to analyze existing artifacts: ${error.message}`);
}
return analysis;
}
/**
* Get directory statistics recursively
* @param {string} dirPath - Directory path
* @returns {Promise<object>} Stats object with files and size
*/
async getDirStats(dirPath) {
const stats = { files: [], size: 0 };
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subStats = await this.getDirStats(fullPath);
stats.files.push(...subStats.files.map((f) => path.join(entry.name, f)));
stats.size += subStats.size;
} else {
stats.files.push(entry.name);
const fileStat = await fs.stat(fullPath);
stats.size += fileStat.size;
}
}
} catch {
// Ignore permission errors
}
return stats;
}
/**
* Create backup of existing artifacts
* @returns {Promise<string>} Backup directory path
*/
async createBackup() {
const backupName = `_backup_migration_${Date.now()}`;
const backupPath = path.join(this.outputPath, backupName);
try {
await fs.ensureDir(backupPath);
// Copy legacy directories
const legacyDirs = ['planning-artifacts', 'implementation-artifacts', 'tests'];
for (const dir of legacyDirs) {
const sourcePath = path.join(this.outputPath, dir);
if (await fs.pathExists(sourcePath)) {
await fs.copy(sourcePath, path.join(backupPath, dir));
}
}
// Copy root-level files
const rootFiles = ['project-context.md', 'sprint-status.yaml', 'bmm-workflow-status.yaml'];
for (const file of rootFiles) {
const sourcePath = path.join(this.outputPath, file);
if (await fs.pathExists(sourcePath)) {
await fs.copy(sourcePath, path.join(backupPath, file));
}
}
return backupPath;
} catch (error) {
throw new Error(`Failed to create backup: ${error.message}`);
}
}
/**
* Migrate existing artifacts to default scope
* @param {object} options - Migration options
* @returns {Promise<object>} Migration result
*/
async migrate(options = {}) {
const scopeId = options.scopeId || this.defaultScopeId;
const createBackup = options.backup !== false;
const result = {
success: false,
scopeId,
backupPath: null,
migratedFiles: [],
errors: [],
};
try {
// Check if migration is needed
const needsMigration = await this.needsMigration();
if (!needsMigration) {
result.success = true;
result.message = 'No migration needed';
return result;
}
// Create backup
if (createBackup) {
result.backupPath = await this.createBackup();
}
// Create scope directory structure
const scopePath = path.join(this.outputPath, scopeId);
const scopeDirs = {
planning: path.join(scopePath, 'planning-artifacts'),
implementation: path.join(scopePath, 'implementation-artifacts'),
tests: path.join(scopePath, 'tests'),
};
for (const dir of Object.values(scopeDirs)) {
await fs.ensureDir(dir);
}
// Move legacy directories
const migrations = [
{ from: 'planning-artifacts', to: scopeDirs.planning },
{ from: 'implementation-artifacts', to: scopeDirs.implementation },
{ from: 'tests', to: scopeDirs.tests },
];
for (const migration of migrations) {
const sourcePath = path.join(this.outputPath, migration.from);
if (await fs.pathExists(sourcePath)) {
// Copy contents to scope directory
const entries = await fs.readdir(sourcePath, { withFileTypes: true });
for (const entry of entries) {
const sourceFile = path.join(sourcePath, entry.name);
const targetFile = path.join(migration.to, entry.name);
// Skip if target already exists
if (await fs.pathExists(targetFile)) {
result.errors.push(`Skipped ${entry.name}: already exists in target`);
continue;
}
await fs.copy(sourceFile, targetFile);
result.migratedFiles.push(path.join(migration.from, entry.name));
}
// Remove original directory
await fs.remove(sourcePath);
}
}
// Handle root-level files
const rootFileMigrations = [
{ from: 'project-context.md', to: path.join(scopePath, 'project-context.md') },
{ from: 'sprint-status.yaml', to: path.join(scopeDirs.implementation, 'sprint-status.yaml') },
{ from: 'bmm-workflow-status.yaml', to: path.join(scopeDirs.planning, 'bmm-workflow-status.yaml') },
];
for (const migration of rootFileMigrations) {
const sourcePath = path.join(this.outputPath, migration.from);
if (await fs.pathExists(sourcePath)) {
if (await fs.pathExists(migration.to)) {
result.errors.push(`Skipped ${migration.from}: already exists in target`);
await fs.remove(sourcePath);
} else {
await fs.move(sourcePath, migration.to);
result.migratedFiles.push(migration.from);
}
}
}
// Create scope metadata
const metaPath = path.join(scopePath, '.scope-meta.yaml');
const metadata = {
scope_id: scopeId,
migrated: true,
migrated_at: new Date().toISOString(),
original_backup: result.backupPath,
version: 1,
};
await fs.writeFile(metaPath, yaml.stringify(metadata), 'utf8');
// Create scope README
const readmePath = path.join(scopePath, 'README.md');
if (!(await fs.pathExists(readmePath))) {
const readme = this.generateMigrationReadme(scopeId, result.migratedFiles.length);
await fs.writeFile(readmePath, readme, 'utf8');
}
result.success = true;
result.message = `Migrated ${result.migratedFiles.length} items to scope '${scopeId}'`;
} catch (error) {
result.success = false;
result.errors.push(error.message);
}
return result;
}
/**
* Generate README for migrated scope
* @param {string} scopeId - The scope ID
* @param {number} fileCount - Number of migrated files
* @returns {string} README content
*/
generateMigrationReadme(scopeId, fileCount) {
return `# Scope: ${scopeId}
This scope was automatically created during migration from the legacy (non-scoped) structure.
## Migration Details
- **Migrated At:** ${new Date().toISOString()}
- **Files Migrated:** ${fileCount}
## Directory Structure
- **planning-artifacts/** - Planning documents, PRDs, specifications
- **implementation-artifacts/** - Sprint status, development artifacts
- **tests/** - Test files and results
## Next Steps
1. Review the migrated artifacts
2. Update any hardcoded paths in your workflows
3. Consider creating additional scopes for different components
## Usage
\`\`\`bash
# Work in this scope
bmad workflow --scope ${scopeId}
# View scope details
bmad scope info ${scopeId}
\`\`\`
`;
}
/**
* Rollback migration using backup
* @param {string} backupPath - Path to backup directory
* @returns {Promise<boolean>} Success status
*/
async rollback(backupPath) {
try {
if (!(await fs.pathExists(backupPath))) {
throw new Error(`Backup not found at: ${backupPath}`);
}
// Restore backed up directories
const entries = await fs.readdir(backupPath, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(backupPath, entry.name);
const targetPath = path.join(this.outputPath, entry.name);
// Remove current version if exists
if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath);
}
// Restore from backup
await fs.copy(sourcePath, targetPath);
}
// Remove backup after successful restore
await fs.remove(backupPath);
return true;
} catch (error) {
throw new Error(`Failed to rollback: ${error.message}`);
}
}
/**
* Update references in state files after migration
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Update result
*/
async updateReferences(scopeId) {
const result = { updated: [], errors: [] };
const scopePath = path.join(this.outputPath, scopeId);
// Files that might contain path references
const filesToUpdate = [
path.join(scopePath, 'implementation-artifacts', 'sprint-status.yaml'),
path.join(scopePath, 'planning-artifacts', 'bmm-workflow-status.yaml'),
];
for (const filePath of filesToUpdate) {
if (await fs.pathExists(filePath)) {
try {
let content = await fs.readFile(filePath, 'utf8');
// Update common path patterns
const patterns = [
{ from: /planning-artifacts\//g, to: `${scopeId}/planning-artifacts/` },
{ from: /implementation-artifacts\//g, to: `${scopeId}/implementation-artifacts/` },
{ from: /tests\//g, to: `${scopeId}/tests/` },
];
let modified = false;
for (const pattern of patterns) {
if (
pattern.from.test(content) && // Only update if not already scoped
!content.includes(`${scopeId}/`)
) {
content = content.replace(pattern.from, pattern.to);
modified = true;
}
}
if (modified) {
await fs.writeFile(filePath, content, 'utf8');
result.updated.push(filePath);
}
} catch (error) {
result.errors.push(`Failed to update ${filePath}: ${error.message}`);
}
}
}
return result;
}
}
module.exports = { ScopeMigrator };

View File

@ -0,0 +1,483 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const crypto = require('node:crypto');
const { StateLock } = require('./state-lock');
/**
* Handles synchronization between scopes and shared layer
* Implements sync-up (promote to shared) and sync-down (pull from shared)
*
* @class ScopeSync
* @requires fs-extra
* @requires yaml
* @requires StateLock
*
* @example
* const sync = new ScopeSync({ projectRoot: '/path/to/project' });
* await sync.syncUp('auth', ['architecture.md']);
*/
class ScopeSync {
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.outputBase = options.outputBase || '_bmad-output';
this.outputPath = path.join(this.projectRoot, this.outputBase);
this.sharedPath = path.join(this.outputPath, '_shared');
this.stateLock = new StateLock();
// Default patterns for promotable artifacts
this.promotablePatterns = options.promotablePatterns || [
'architecture/*.md',
'contracts/*.md',
'principles/*.md',
'project-context.md',
];
}
/**
* Set the project root directory
* @param {string} projectRoot - The project root path
*/
setProjectRoot(projectRoot) {
this.projectRoot = projectRoot;
this.outputPath = path.join(projectRoot, this.outputBase);
this.sharedPath = path.join(this.outputPath, '_shared');
}
/**
* Compute file hash for change detection
* @param {string} filePath - Path to file
* @returns {Promise<string>} MD5 hash
*/
async computeHash(filePath) {
try {
const content = await fs.readFile(filePath);
return crypto.createHash('md5').update(content).digest('hex');
} catch {
return null;
}
}
/**
* Get sync metadata path for a scope
* @param {string} scopeId - The scope ID
* @returns {string} Path to sync metadata file
*/
getSyncMetaPath(scopeId) {
return path.join(this.outputPath, scopeId, '.sync-meta.yaml');
}
/**
* Load sync metadata for a scope
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Sync metadata
*/
async loadSyncMeta(scopeId) {
const metaPath = this.getSyncMetaPath(scopeId);
try {
if (await fs.pathExists(metaPath)) {
const content = await fs.readFile(metaPath, 'utf8');
return yaml.parse(content);
}
} catch {
// Ignore errors
}
return {
version: 1,
lastSyncUp: null,
lastSyncDown: null,
promotedFiles: {},
pulledFiles: {},
};
}
/**
* Save sync metadata for a scope
* @param {string} scopeId - The scope ID
* @param {object} meta - Metadata to save
*/
async saveSyncMeta(scopeId, meta) {
const metaPath = this.getSyncMetaPath(scopeId);
meta.updatedAt = new Date().toISOString();
await fs.writeFile(metaPath, yaml.stringify(meta), 'utf8');
}
/**
* Sync-Up: Promote artifacts from scope to shared layer
* @param {string} scopeId - The scope ID
* @param {string[]} files - Specific files to promote (optional)
* @param {object} options - Sync options
* @returns {Promise<object>} Sync result
*/
async syncUp(scopeId, files = null, options = {}) {
const result = {
success: false,
promoted: [],
conflicts: [],
errors: [],
skipped: [],
};
try {
const scopePath = path.join(this.outputPath, scopeId);
// Verify scope exists
if (!(await fs.pathExists(scopePath))) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
// Load sync metadata
const meta = await this.loadSyncMeta(scopeId);
// Determine files to promote
let filesToPromote = [];
if (files && files.length > 0) {
// Use specified files
filesToPromote = files.map((f) => (path.isAbsolute(f) ? f : path.join(scopePath, f)));
} else {
// Find promotable files using patterns
filesToPromote = await this.findPromotableFiles(scopePath);
}
// Process each file
for (const sourceFile of filesToPromote) {
try {
// Verify file exists
if (!(await fs.pathExists(sourceFile))) {
result.skipped.push({ file: sourceFile, reason: 'File not found' });
continue;
}
// Calculate relative path from scope
const relativePath = path.relative(scopePath, sourceFile);
const targetPath = path.join(this.sharedPath, scopeId, relativePath);
// Check for conflicts
if ((await fs.pathExists(targetPath)) && !options.force) {
const sourceHash = await this.computeHash(sourceFile);
const targetHash = await this.computeHash(targetPath);
if (sourceHash !== targetHash) {
result.conflicts.push({
file: relativePath,
source: sourceFile,
target: targetPath,
resolution: 'manual',
});
continue;
}
}
// Create target directory
await fs.ensureDir(path.dirname(targetPath));
// Copy file to shared
await fs.copy(sourceFile, targetPath, { overwrite: options.force });
// Create metadata file
const metaFilePath = `${targetPath}.meta`;
const fileMeta = {
source_scope: scopeId,
promoted_at: new Date().toISOString(),
original_path: relativePath,
original_hash: await this.computeHash(sourceFile),
version: (meta.promotedFiles[relativePath]?.version || 0) + 1,
};
await fs.writeFile(metaFilePath, yaml.stringify(fileMeta), 'utf8');
// Track promotion
meta.promotedFiles[relativePath] = {
promotedAt: fileMeta.promoted_at,
hash: fileMeta.original_hash,
version: fileMeta.version,
};
result.promoted.push({
file: relativePath,
target: targetPath,
});
} catch (error) {
result.errors.push({
file: sourceFile,
error: error.message,
});
}
}
// Update sync metadata
meta.lastSyncUp = new Date().toISOString();
await this.saveSyncMeta(scopeId, meta);
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push({ error: error.message });
}
return result;
}
/**
* Sync-Down: Pull updates from shared layer to scope
* @param {string} scopeId - The scope ID
* @param {object} options - Sync options
* @returns {Promise<object>} Sync result
*/
async syncDown(scopeId, options = {}) {
const result = {
success: false,
pulled: [],
conflicts: [],
errors: [],
upToDate: [],
};
try {
const scopePath = path.join(this.outputPath, scopeId);
// Verify scope exists
if (!(await fs.pathExists(scopePath))) {
throw new Error(`Scope '${scopeId}' does not exist`);
}
// Load sync metadata
const meta = await this.loadSyncMeta(scopeId);
// Find all shared files from any scope
const sharedScopeDirs = await fs.readdir(this.sharedPath, { withFileTypes: true });
for (const dir of sharedScopeDirs) {
if (!dir.isDirectory() || dir.name.startsWith('.')) continue;
const sharedScopePath = path.join(this.sharedPath, dir.name);
const files = await this.getAllFiles(sharedScopePath);
for (const sharedFile of files) {
// Skip metadata files
if (sharedFile.endsWith('.meta')) continue;
try {
const relativePath = path.relative(sharedScopePath, sharedFile);
const targetPath = path.join(scopePath, 'shared', dir.name, relativePath);
// Load shared file metadata
const metaFilePath = `${sharedFile}.meta`;
let fileMeta = null;
if (await fs.pathExists(metaFilePath)) {
const metaContent = await fs.readFile(metaFilePath, 'utf8');
fileMeta = yaml.parse(metaContent);
}
// Check if we already have this version
const lastPulled = meta.pulledFiles[`${dir.name}/${relativePath}`];
if (lastPulled && fileMeta && lastPulled.version === fileMeta.version) {
result.upToDate.push({ file: relativePath, scope: dir.name });
continue;
}
// Check for local conflicts
if ((await fs.pathExists(targetPath)) && !options.force) {
const localHash = await this.computeHash(targetPath);
const sharedHash = await this.computeHash(sharedFile);
if (localHash !== sharedHash) {
// Check if local was modified after last pull
const localStat = await fs.stat(targetPath);
if (lastPulled && localStat.mtimeMs > new Date(lastPulled.pulledAt).getTime()) {
result.conflicts.push({
file: relativePath,
scope: dir.name,
local: targetPath,
shared: sharedFile,
resolution: options.resolution || 'prompt',
});
continue;
}
}
}
// Create target directory
await fs.ensureDir(path.dirname(targetPath));
// Copy file to scope
await fs.copy(sharedFile, targetPath, { overwrite: true });
// Track pull
meta.pulledFiles[`${dir.name}/${relativePath}`] = {
pulledAt: new Date().toISOString(),
version: fileMeta?.version || 1,
hash: await this.computeHash(targetPath),
};
result.pulled.push({
file: relativePath,
scope: dir.name,
target: targetPath,
});
} catch (error) {
result.errors.push({
file: sharedFile,
error: error.message,
});
}
}
}
// Update sync metadata
meta.lastSyncDown = new Date().toISOString();
await this.saveSyncMeta(scopeId, meta);
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push({ error: error.message });
}
return result;
}
/**
* Find files matching promotable patterns
* @param {string} scopePath - Scope directory path
* @returns {Promise<string[]>} Array of file paths
*/
async findPromotableFiles(scopePath) {
const files = [];
for (const pattern of this.promotablePatterns) {
// Simple glob-like matching
const parts = pattern.split('/');
const dir = parts.slice(0, -1).join('/');
const filePattern = parts.at(-1);
const searchDir = path.join(scopePath, dir);
if (await fs.pathExists(searchDir)) {
const entries = await fs.readdir(searchDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && this.matchPattern(entry.name, filePattern)) {
files.push(path.join(searchDir, entry.name));
}
}
}
}
return files;
}
/**
* Simple glob pattern matching
* @param {string} filename - Filename to test
* @param {string} pattern - Pattern with * wildcard
* @returns {boolean} True if matches
*/
matchPattern(filename, pattern) {
const regexPattern = pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(filename);
}
/**
* Get all files in a directory recursively
* @param {string} dir - Directory path
* @returns {Promise<string[]>} Array of file paths
*/
async getAllFiles(dir) {
const files = [];
async function walk(currentDir) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
files.push(fullPath);
}
}
}
await walk(dir);
return files;
}
/**
* Get sync status for a scope
* @param {string} scopeId - The scope ID
* @returns {Promise<object>} Sync status
*/
async getSyncStatus(scopeId) {
const meta = await this.loadSyncMeta(scopeId);
return {
lastSyncUp: meta.lastSyncUp,
lastSyncDown: meta.lastSyncDown,
promotedCount: Object.keys(meta.promotedFiles).length,
pulledCount: Object.keys(meta.pulledFiles).length,
promotedFiles: Object.keys(meta.promotedFiles),
pulledFiles: Object.keys(meta.pulledFiles),
};
}
/**
* Resolve a sync conflict
* @param {object} conflict - Conflict object
* @param {string} resolution - Resolution strategy (keep-local, keep-shared, merge)
* @returns {Promise<object>} Resolution result
*/
async resolveConflict(conflict, resolution) {
const result = { success: false, action: null };
try {
switch (resolution) {
case 'keep-local': {
// Keep local file, do nothing
result.action = 'kept-local';
result.success = true;
break;
}
case 'keep-shared': {
// Overwrite with shared
await fs.copy(conflict.shared || conflict.source, conflict.local || conflict.target, {
overwrite: true,
});
result.action = 'kept-shared';
result.success = true;
break;
}
case 'backup-and-update': {
// Backup local, then update
const backupPath = `${conflict.local || conflict.target}.backup.${Date.now()}`;
await fs.copy(conflict.local || conflict.target, backupPath);
await fs.copy(conflict.shared || conflict.source, conflict.local || conflict.target, {
overwrite: true,
});
result.action = 'backed-up-and-updated';
result.backupPath = backupPath;
result.success = true;
break;
}
default: {
result.success = false;
result.error = `Unknown resolution: ${resolution}`;
}
}
} catch (error) {
result.success = false;
result.error = error.message;
}
return result;
}
}
module.exports = { ScopeSync };

View File

@ -0,0 +1,296 @@
const yaml = require('yaml');
/**
* Validates scope configuration and enforces schema rules
* @class ScopeValidator
*/
class ScopeValidator {
// Scope ID validation pattern: lowercase alphanumeric + hyphens, 2-50 chars
// IMPORTANT: Must be defined as class field BEFORE constructor to be available in validateScopeId
scopeIdPattern = /^[a-z][a-z0-9-]*[a-z0-9]$/;
constructor() {
// Reserved scope IDs that cannot be used
// NOTE: 'default' removed from reserved list - it's valid for migration scenarios
this.reservedIds = ['_shared', '_events', '_config', '_backup', 'global'];
// Valid isolation modes
this.validIsolationModes = ['strict', 'warn', 'permissive'];
// Valid scope statuses
this.validStatuses = ['active', 'archived'];
}
/**
* Validates a scope ID format
* @param {string} scopeId - The scope ID to validate
* @returns {{valid: boolean, error: string|null}}
*/
validateScopeId(scopeId) {
// Check if provided
if (!scopeId || typeof scopeId !== 'string') {
return { valid: false, error: 'Scope ID is required and must be a string' };
}
// Check length
if (scopeId.length < 2 || scopeId.length > 50) {
return { valid: false, error: 'Scope ID must be between 2 and 50 characters' };
}
// Check pattern
if (!this.scopeIdPattern.test(scopeId)) {
return {
valid: false,
error:
'Scope ID must start with lowercase letter, contain only lowercase letters, numbers, and hyphens, and end with letter or number',
};
}
// Check reserved IDs
if (this.reservedIds.includes(scopeId)) {
return {
valid: false,
error: `Scope ID '${scopeId}' is reserved and cannot be used`,
};
}
return { valid: true, error: null };
}
/**
* Validates a complete scope configuration object
* @param {object} scope - The scope configuration to validate
* @param {object} allScopes - All existing scopes for dependency validation
* @returns {{valid: boolean, errors: string[]}}
*/
validateScope(scope, allScopes = {}) {
const errors = [];
// Validate ID
const idValidation = this.validateScopeId(scope.id);
if (!idValidation.valid) {
errors.push(idValidation.error);
}
// Validate name
if (!scope.name || typeof scope.name !== 'string' || scope.name.trim().length === 0) {
errors.push('Scope name is required and must be a non-empty string');
}
// Validate description (optional but if provided must be string)
if (scope.description !== undefined && typeof scope.description !== 'string') {
errors.push('Scope description must be a string');
}
// Validate status
if (scope.status && !this.validStatuses.includes(scope.status)) {
errors.push(`Invalid status '${scope.status}'. Must be one of: ${this.validStatuses.join(', ')}`);
}
// Validate dependencies
if (scope.dependencies) {
if (Array.isArray(scope.dependencies)) {
// Check each dependency exists
for (const dep of scope.dependencies) {
if (typeof dep !== 'string') {
errors.push(`Dependency '${dep}' must be a string`);
continue;
}
// Check dependency exists
if (!allScopes[dep]) {
errors.push(`Dependency '${dep}' does not exist`);
}
// Check for self-dependency
if (dep === scope.id) {
errors.push(`Scope cannot depend on itself`);
}
}
// Check for circular dependencies
const circularCheck = this.detectCircularDependencies(scope.id, scope.dependencies, allScopes);
if (circularCheck.hasCircular) {
errors.push(`Circular dependency detected: ${circularCheck.chain.join(' → ')}`);
}
} else {
errors.push('Scope dependencies must be an array');
}
}
// Validate created timestamp (if provided)
if (scope.created) {
const date = new Date(scope.created);
if (isNaN(date.getTime())) {
errors.push('Invalid created timestamp format. Use ISO 8601 format.');
}
}
// Validate metadata
if (scope._meta) {
if (typeof scope._meta === 'object') {
if (scope._meta.last_activity) {
const date = new Date(scope._meta.last_activity);
if (isNaN(date.getTime())) {
errors.push('Invalid _meta.last_activity timestamp format');
}
}
if (scope._meta.artifact_count !== undefined && (!Number.isInteger(scope._meta.artifact_count) || scope._meta.artifact_count < 0)) {
errors.push('_meta.artifact_count must be a non-negative integer');
}
} else {
errors.push('Scope _meta must be an object');
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Detects circular dependencies in scope dependency chain
* @param {string} scopeId - The scope ID to check
* @param {string[]} dependencies - Direct dependencies of the scope
* @param {object} allScopes - All existing scopes
* @param {Set} visited - Set of already visited scopes (for recursion)
* @param {string[]} chain - Current dependency chain (for error reporting)
* @returns {{hasCircular: boolean, chain: string[]}}
*/
detectCircularDependencies(scopeId, dependencies, allScopes, visited = new Set(), chain = []) {
// Add current scope to visited set and chain
visited.add(scopeId);
chain.push(scopeId);
if (!dependencies || dependencies.length === 0) {
return { hasCircular: false, chain: [] };
}
for (const dep of dependencies) {
// Check if we've already visited this dependency (circular!)
if (visited.has(dep)) {
return { hasCircular: true, chain: [...chain, dep] };
}
// Recursively check this dependency's dependencies
const depScope = allScopes[dep];
if (depScope && depScope.dependencies) {
const result = this.detectCircularDependencies(dep, depScope.dependencies, allScopes, new Set(visited), [...chain]);
if (result.hasCircular) {
return result;
}
}
}
return { hasCircular: false, chain: [] };
}
/**
* Validates complete scopes.yaml configuration
* @param {object} config - The complete scopes configuration
* @returns {{valid: boolean, errors: string[]}}
*/
validateConfig(config) {
const errors = [];
// Validate version
if (!config.version || typeof config.version !== 'number') {
errors.push('Configuration version is required and must be a number');
}
// Validate settings
if (config.settings) {
if (typeof config.settings === 'object') {
// Validate isolation_mode
if (config.settings.isolation_mode && !this.validIsolationModes.includes(config.settings.isolation_mode)) {
errors.push(`Invalid isolation_mode '${config.settings.isolation_mode}'. Must be one of: ${this.validIsolationModes.join(', ')}`);
}
// Validate allow_adhoc_scopes
if (config.settings.allow_adhoc_scopes !== undefined && typeof config.settings.allow_adhoc_scopes !== 'boolean') {
errors.push('allow_adhoc_scopes must be a boolean');
}
// Validate paths
if (config.settings.default_output_base && typeof config.settings.default_output_base !== 'string') {
errors.push('default_output_base must be a string');
}
if (config.settings.default_shared_path && typeof config.settings.default_shared_path !== 'string') {
errors.push('default_shared_path must be a string');
}
} else {
errors.push('Settings must be an object');
}
}
// Validate scopes object
if (config.scopes) {
if (typeof config.scopes !== 'object' || Array.isArray(config.scopes)) {
errors.push('Scopes must be an object (not an array)');
} else {
// Validate each scope
for (const [scopeId, scopeConfig] of Object.entries(config.scopes)) {
// Check ID matches key
if (scopeConfig.id !== scopeId) {
errors.push(`Scope key '${scopeId}' does not match scope.id '${scopeConfig.id}'`);
}
// Validate the scope
const scopeValidation = this.validateScope(scopeConfig, config.scopes);
if (!scopeValidation.valid) {
errors.push(`Scope '${scopeId}': ${scopeValidation.errors.join(', ')}`);
}
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Validates scopes.yaml file content
* @param {string} yamlContent - The YAML file content as string
* @returns {{valid: boolean, errors: string[], config: object|null}}
*/
validateYamlContent(yamlContent) {
try {
const config = yaml.parse(yamlContent);
const validation = this.validateConfig(config);
return {
valid: validation.valid,
errors: validation.errors,
config: validation.valid ? config : null,
};
} catch (error) {
return {
valid: false,
errors: [`Failed to parse YAML: ${error.message}`],
config: null,
};
}
}
/**
* Creates a default valid scopes.yaml configuration
* @returns {object} Default configuration object
*/
createDefaultConfig() {
return {
version: 1,
settings: {
allow_adhoc_scopes: true,
isolation_mode: 'strict',
default_output_base: '_bmad-output',
default_shared_path: '_bmad-output/_shared',
},
scopes: {},
};
}
}
module.exports = { ScopeValidator };

View File

@ -0,0 +1,336 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
/**
* File locking utilities for safe concurrent access to state files
* Uses file-based locking for cross-process synchronization
*
* @class StateLock
* @requires fs-extra
* @requires yaml
*
* @example
* const lock = new StateLock();
* const result = await lock.withLock('/path/to/state.yaml', async () => {
* // Safe operations here
* return data;
* });
*/
class StateLock {
constructor(options = {}) {
this.staleTimeout = options.staleTimeout || 30_000; // 30 seconds
this.retries = options.retries || 10;
this.minTimeout = options.minTimeout || 100;
this.maxTimeout = options.maxTimeout || 1000;
this.lockExtension = options.lockExtension || '.lock';
}
/**
* Get lock file path for a given file
* @param {string} filePath - The file to lock
* @returns {string} Lock file path
*/
getLockPath(filePath) {
return `${filePath}${this.lockExtension}`;
}
/**
* Check if a lock file is stale
* @param {string} lockPath - Path to lock file
* @returns {Promise<boolean>} True if lock is stale
*/
async isLockStale(lockPath) {
try {
const stat = await fs.stat(lockPath);
const age = Date.now() - stat.mtimeMs;
return age > this.staleTimeout;
} catch {
return true; // If we can't stat, consider it stale
}
}
/**
* Acquire a lock on a file
* @param {string} filePath - The file to lock
* @returns {Promise<{success: boolean, lockPath: string}>}
*/
async acquireLock(filePath) {
const lockPath = this.getLockPath(filePath);
for (let attempt = 0; attempt < this.retries; attempt++) {
try {
// Check if lock exists
const lockExists = await fs.pathExists(lockPath);
if (lockExists) {
// Check if lock is stale
const isStale = await this.isLockStale(lockPath);
if (isStale) {
// Remove stale lock
await fs.remove(lockPath);
} else {
// Lock is active, wait and retry
const waitTime = Math.min(this.minTimeout * Math.pow(2, attempt), this.maxTimeout);
await this.sleep(waitTime);
continue;
}
}
// Try to create lock file atomically
const lockContent = {
pid: process.pid,
hostname: require('node:os').hostname(),
created: new Date().toISOString(),
};
// Use exclusive flag for atomic creation
await fs.writeFile(lockPath, JSON.stringify(lockContent), {
flag: 'wx', // Exclusive create
});
return { success: true, lockPath };
} catch (error) {
if (error.code === 'EEXIST') {
// Lock was created by another process, retry
const waitTime = Math.min(this.minTimeout * Math.pow(2, attempt), this.maxTimeout);
await this.sleep(waitTime);
continue;
}
throw error;
}
}
return { success: false, lockPath, reason: 'Max retries exceeded' };
}
/**
* Release a lock on a file
* @param {string} filePath - The file that was locked
* @returns {Promise<boolean>} True if lock was released
*/
async releaseLock(filePath) {
const lockPath = this.getLockPath(filePath);
try {
await fs.remove(lockPath);
return true;
} catch (error) {
if (error.code === 'ENOENT') {
return true; // Lock already gone
}
throw error;
}
}
/**
* Execute operation with file lock
* @param {string} filePath - File to lock
* @param {function} operation - Async operation to perform
* @returns {Promise<any>} Result of operation
*/
async withLock(filePath, operation) {
const lockResult = await this.acquireLock(filePath);
if (!lockResult.success) {
throw new Error(`Failed to acquire lock on ${filePath}: ${lockResult.reason}`);
}
try {
return await operation();
} finally {
await this.releaseLock(filePath);
}
}
/**
* Read YAML file with version tracking
* @param {string} filePath - Path to YAML file
* @returns {Promise<object>} Parsed content with _version
*/
async readYaml(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
const data = yaml.parse(content);
// Ensure version field exists
if (!data._version) {
data._version = 0;
}
return data;
} catch (error) {
if (error.code === 'ENOENT') {
return { _version: 0 };
}
throw error;
}
}
/**
* Write YAML file with version increment
* @param {string} filePath - Path to YAML file
* @param {object} data - Data to write
* @returns {Promise<object>} Written data with new version
*/
async writeYaml(filePath, data) {
// Ensure directory exists
await fs.ensureDir(path.dirname(filePath));
// Update version and timestamp
const versionedData = {
...data,
_version: (data._version || 0) + 1,
_lastModified: new Date().toISOString(),
};
const yamlContent = yaml.stringify(versionedData, { indent: 2 });
await fs.writeFile(filePath, yamlContent, 'utf8');
return versionedData;
}
/**
* Update YAML file with automatic version management and locking
* @param {string} filePath - Path to YAML file
* @param {function} modifier - Function that receives data and returns modified data
* @returns {Promise<object>} Updated data
*/
async updateYamlWithVersion(filePath, modifier) {
return this.withLock(filePath, async () => {
// Read current data
const data = await this.readYaml(filePath);
const currentVersion = data._version || 0;
// Apply modifications
const modified = await modifier(data);
// Update version
modified._version = currentVersion + 1;
modified._lastModified = new Date().toISOString();
// Write back
await this.writeYaml(filePath, modified);
return modified;
});
}
/**
* Optimistic update with version check
* @param {string} filePath - Path to YAML file
* @param {number} expectedVersion - Expected version number
* @param {object} newData - New data to write
* @returns {Promise<{success: boolean, data: object, conflict: boolean}>}
*/
async optimisticUpdate(filePath, expectedVersion, newData) {
return this.withLock(filePath, async () => {
const current = await this.readYaml(filePath);
// Check version
if (current._version !== expectedVersion) {
return {
success: false,
data: current,
conflict: true,
message: `Version conflict: expected ${expectedVersion}, found ${current._version}`,
};
}
// Update with new version
const updated = {
...newData,
_version: expectedVersion + 1,
_lastModified: new Date().toISOString(),
};
await this.writeYaml(filePath, updated);
return {
success: true,
data: updated,
conflict: false,
};
});
}
/**
* Sleep helper
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if a file is currently locked
* @param {string} filePath - The file to check
* @returns {Promise<boolean>} True if locked
*/
async isLocked(filePath) {
const lockPath = this.getLockPath(filePath);
try {
const exists = await fs.pathExists(lockPath);
if (!exists) {
return false;
}
// Check if lock is stale
const isStale = await this.isLockStale(lockPath);
return !isStale;
} catch {
return false;
}
}
/**
* Get lock information
* @param {string} filePath - The file to check
* @returns {Promise<object|null>} Lock info or null
*/
async getLockInfo(filePath) {
const lockPath = this.getLockPath(filePath);
try {
const exists = await fs.pathExists(lockPath);
if (!exists) {
return null;
}
const content = await fs.readFile(lockPath, 'utf8');
const info = JSON.parse(content);
const stat = await fs.stat(lockPath);
return {
...info,
age: Date.now() - stat.mtimeMs,
isStale: Date.now() - stat.mtimeMs > this.staleTimeout,
};
} catch {
return null;
}
}
/**
* Force release a lock (use with caution)
* @param {string} filePath - The file to unlock
* @returns {Promise<boolean>} True if lock was removed
*/
async forceRelease(filePath) {
const lockPath = this.getLockPath(filePath);
try {
await fs.remove(lockPath);
return true;
} catch {
return false;
}
}
}
module.exports = { StateLock };

View File

@ -23,3 +23,31 @@ output_folder:
prompt: "Where should output files be saved?"
default: "_bmad-output"
result: "{project-root}/{value}"
# Scope System Configuration
# These settings control the multi-scope parallel artifact system
scope_settings:
header: "Scope System Settings"
subheader: "Configure multi-scope artifact isolation"
allow_adhoc_scopes:
prompt: "Allow creating scopes on-demand during workflows?"
default: true
result: "{value}"
isolation_mode:
prompt: "Scope isolation mode"
default: "strict"
result: "{value}"
single-select:
- value: "strict"
label: "Strict - Block cross-scope writes, require explicit sync"
- value: "warn"
label: "Warn - Allow cross-scope writes with warnings"
- value: "permissive"
label: "Permissive - Allow all operations (not recommended)"
shared_path:
prompt: "Where should shared knowledge artifacts be stored?"
default: "{output_folder}/_shared"
result: "{project-root}/{value}"

View File

@ -17,6 +17,48 @@
</WORKFLOW-RULES>
<flow>
<step n="0" title="Initialize Scope Context" critical="true">
<objective>Resolve and load scope context before workflow execution</objective>
<substep n="0a" title="Check for Scope Requirement">
<action>Scan workflow.yaml for {scope} variable in any path or variable</action>
<action>If found, mark workflow as scope-required</action>
<note>Scope-required workflows need an active scope to resolve paths correctly</note>
</substep>
<substep n="0b" title="Resolve Scope" if="scope-required">
<priority-order>
<source n="1">Explicit --scope argument from command invocation</source>
<source n="2">Session context from .bmad-scope file in project root</source>
<source n="3">BMAD_SCOPE environment variable</source>
<source n="4">Prompt user to select or create scope</source>
</priority-order>
<action>If no scope resolved, ask user:</action>
<ask>This workflow requires a scope. Select existing scope or create new one.</ask>
<action>Store resolved scope as {scope} variable for path resolution</action>
</substep>
<substep n="0c" title="Load Scope Context">
<check if="scope is set">
<action>Load scope configuration from {project-root}/_bmad/_config/scopes.yaml</action>
<action>Validate scope exists and is active</action>
<action>Resolve scope paths:
- {scope_path} = {output_folder}/{scope}
- {scope_planning} = {scope_path}/planning-artifacts
- {scope_implementation} = {scope_path}/implementation-artifacts
- {scope_tests} = {scope_path}/tests
</action>
<action>Load global project context: {output_folder}/_shared/project-context.md</action>
<action>Load scope project context if exists: {scope_path}/project-context.md</action>
<action>Merge contexts: scope extends global</action>
<action>Check for pending dependency updates and notify user if any</action>
</check>
<check if="no scope (scope-optional workflow)">
<action>Continue without scope context, use legacy paths</action>
</check>
</substep>
</step>
<step n="1" title="Load and Initialize Workflow">
<substep n="1a" title="Load Configuration and Resolve Variables">
<action>Read workflow.yaml from provided path</action>

View File

@ -5,7 +5,22 @@
- VERIFY: If config not loaded, STOP and report error to user
- DO NOT PROCEED to step 3 until config is successfully loaded and variables stored
</step>
<step n="3">Remember: user's name is {user_name}</step>
<step n="3">🔍 SCOPE CONTEXT LOADING (CRITICAL for artifact isolation):
- Check for .bmad-scope file in {project-root}
- If exists, read active_scope and store as {scope}
- If {scope} is set, STORE THESE OVERRIDE VALUES for the entire session:
- {scope_path} = {output_folder}/{scope}
- {planning_artifacts} = {scope_path}/planning-artifacts (OVERRIDE config.yaml!)
- {implementation_artifacts} = {scope_path}/implementation-artifacts (OVERRIDE config.yaml!)
- {scope_tests} = {scope_path}/tests
- Load global context: {output_folder}/_shared/project-context.md
- Load scope context if exists: {scope_path}/project-context.md
- Merge contexts (scope extends global)
- IMPORTANT: Config.yaml contains static pre-resolved paths. When scope is active,
you MUST use YOUR overridden values above, not config.yaml values for these variables.
- If no scope, use config.yaml paths as-is (backward compatible)
</step>
<step n="4">Remember: user's name is {user_name}</step>
{AGENT_SPECIFIC_STEPS}
<step n="{MENU_STEP}">Show greeting using {user_name} from config, communicate in {communication_language}, then display numbered list of ALL menu items from menu section</step>
<step n="{HALT_STEP}">STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command match</step>

View File

@ -1,6 +1,19 @@
<handler type="exec">
When menu item or handler has: exec="path/to/file.md":
SCOPE CHECK (do this BEFORE loading the exec file):
- If you have {scope} set from activation Step 3, remember these overrides:
- {scope_path} = {output_folder}/{scope}
- {planning_artifacts} = {scope_path}/planning-artifacts
- {implementation_artifacts} = {scope_path}/implementation-artifacts
- When the exec file says "Load config from config.yaml", load it BUT override
the above variables with your scope-aware values
- This ensures artifacts go to the correct scoped directory
EXECUTION:
1. Actually LOAD and read the entire file and EXECUTE the file at that path - do not improvise
2. Read the complete file and follow all instructions within it
3. If there is data="some/path/data-foo.md" with the same item, pass that data path to the executed file as context.
3. When the file references {planning_artifacts} or {implementation_artifacts}, use YOUR
scope-aware overrides, not the static values from config.yaml
4. If there is data="some/path/data-foo.md" with the same item, pass that data path to the executed file as context.
</handler>

686
test/test-cli-arguments.js Normal file
View File

@ -0,0 +1,686 @@
/**
* CLI Argument Handling Test Suite
*
* Tests for proper handling of CLI arguments, especially:
* - Arguments containing spaces
* - Arguments with special characters
* - The npx wrapper's argument preservation
* - Various quoting scenarios
*
* This test suite was created to prevent regression of the bug where
* the npx wrapper used args.join(' ') which broke arguments containing spaces.
*
* Usage: node test/test-cli-arguments.js
* Exit codes: 0 = all tests pass, 1 = test failures
*/
const fs = require('fs-extra');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
// ANSI color codes
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
yellow: '\u001B[33m',
blue: '\u001B[34m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
bold: '\u001B[1m',
};
// Test utilities
let testCount = 0;
let passCount = 0;
let failCount = 0;
let skipCount = 0;
const failures = [];
function test(name, fn) {
testCount++;
try {
fn();
passCount++;
console.log(` ${colors.green}${colors.reset} ${name}`);
} catch (error) {
failCount++;
console.log(` ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.red}${error.message}${colors.reset}`);
failures.push({ name, error: error.message });
}
}
async function testAsync(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ${colors.green}${colors.reset} ${name}`);
} catch (error) {
failCount++;
console.log(` ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.red}${error.message}${colors.reset}`);
failures.push({ name, error: error.message });
}
}
function skip(name, reason = '') {
skipCount++;
console.log(` ${colors.yellow}${colors.reset} ${name} ${colors.dim}(skipped${reason ? ': ' + reason : ''})${colors.reset}`);
}
function assertEqual(actual, expected, message = '') {
if (actual !== expected) {
throw new Error(`${message}\n Expected: ${JSON.stringify(expected)}\n Actual: ${JSON.stringify(actual)}`);
}
}
function assertTrue(value, message = 'Expected true') {
if (!value) {
throw new Error(message);
}
}
function assertFalse(value, message = 'Expected false') {
if (value) {
throw new Error(message);
}
}
function assertContains(str, substring, message = '') {
if (!str.includes(substring)) {
throw new Error(`${message}\n Expected to contain: "${substring}"\n Actual: "${str.slice(0, 500)}..."`);
}
}
function assertNotContains(str, substring, message = '') {
if (str.includes(substring)) {
throw new Error(`${message}\n Expected NOT to contain: "${substring}"`);
}
}
function assertExists(filePath, message = '') {
if (!fs.existsSync(filePath)) {
throw new Error(`${message || 'File does not exist'}: ${filePath}`);
}
}
// Create temporary test directory with BMAD structure
function createTestProject() {
const tmpDir = path.join(os.tmpdir(), `bmad-cli-args-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Create minimal BMAD structure
fs.mkdirSync(path.join(tmpDir, '_bmad', '_config'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '_bmad-output'), { recursive: true });
return tmpDir;
}
function cleanupTestProject(tmpDir) {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
// Paths to CLI entry points
const CLI_PATH = path.join(__dirname, '..', 'tools', 'cli', 'bmad-cli.js');
const NPX_WRAPPER_PATH = path.join(__dirname, '..', 'tools', 'bmad-npx-wrapper.js');
/**
* Execute CLI command using spawnSync with an array of arguments.
* This properly preserves argument boundaries, just like the shell does.
*
* @param {string[]} args - Array of arguments (NOT a joined string)
* @param {string} cwd - Working directory
* @param {Object} options - Additional options
* @returns {Object} Result with success, output, stderr, exitCode
*/
function runCliArray(args, cwd, options = {}) {
const result = spawnSync('node', [CLI_PATH, ...args], {
cwd,
encoding: 'utf8',
timeout: options.timeout || 30_000,
env: { ...process.env, ...options.env, FORCE_COLOR: '0' },
});
return {
success: result.status === 0,
output: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.status || 0,
error: result.error ? result.error.message : null,
};
}
/**
* Execute CLI command via the npx wrapper using spawnSync.
* This tests the actual npx execution path.
*
* @param {string[]} args - Array of arguments
* @param {string} cwd - Working directory
* @param {Object} options - Additional options
* @returns {Object} Result with success, output, stderr, exitCode
*/
function runNpxWrapper(args, cwd, options = {}) {
const result = spawnSync('node', [NPX_WRAPPER_PATH, ...args], {
cwd,
encoding: 'utf8',
timeout: options.timeout || 30_000,
env: { ...process.env, ...options.env, FORCE_COLOR: '0' },
});
return {
success: result.status === 0,
output: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.status || 0,
error: result.error ? result.error.message : null,
};
}
// ============================================================================
// Arguments with Spaces Tests
// ============================================================================
function testArgumentsWithSpaces() {
console.log(`\n${colors.blue}${colors.bold}Arguments with Spaces Tests${colors.reset}`);
test('scope create with description containing spaces (direct CLI)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'test-scope', '--name', 'Test Scope', '--description', 'This is a description with multiple words'],
tmpDir,
);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
assertContains(result.output, "Scope 'test-scope' created successfully");
// Verify the description was saved correctly
const infoResult = runCliArray(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'This is a description with multiple words');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing spaces (via npx wrapper)', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'test-scope', '--name', 'Test Scope', '--description', 'This is a description with multiple words'],
tmpDir,
);
assertTrue(result.success, `Create should succeed via wrapper: ${result.stderr || result.error}`);
assertContains(result.output, "Scope 'test-scope' created successfully");
// Verify the description was saved correctly
const infoResult = runNpxWrapper(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'This is a description with multiple words');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with long description (many spaces)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const longDesc = 'PRD Auto queue for not inbound yet products with special handling for edge cases';
const result = runCliArray(['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', longDesc], tmpDir);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
const infoResult = runCliArray(['scope', 'info', 'auto-queue'], tmpDir);
assertContains(infoResult.output, 'PRD Auto queue for not inbound yet products');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with name containing spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'User Authentication Service', '--description', 'Handles user auth'],
tmpDir,
);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'User Authentication Service');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Special Characters Tests
// ============================================================================
function testSpecialCharacters() {
console.log(`\n${colors.blue}${colors.bold}Special Characters Tests${colors.reset}`);
test('scope create with name containing ampersand', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth & Users', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle ampersand');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Auth & Users');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with name containing parentheses', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth Service (v2)', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle parentheses');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Auth Service (v2)');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing quotes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handle "special" cases'], tmpDir);
assertTrue(result.success, 'Should handle quotes in description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Handle "special" cases');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing single quotes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', "Handle user's authentication"], tmpDir);
assertTrue(result.success, 'Should handle single quotes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, "user's");
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing colons', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Features: login, logout, sessions'],
tmpDir,
);
assertTrue(result.success, 'Should handle colons');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Features: login, logout, sessions');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing hyphens and dashes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Multi-factor auth - two-step verification'],
tmpDir,
);
assertTrue(result.success, 'Should handle hyphens and dashes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Multi-factor auth - two-step verification');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing slashes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handles /api/auth/* endpoints'], tmpDir);
assertTrue(result.success, 'Should handle slashes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, '/api/auth/*');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// NPX Wrapper Specific Tests
// ============================================================================
function testNpxWrapperBehavior() {
console.log(`\n${colors.blue}${colors.bold}NPX Wrapper Behavior Tests${colors.reset}`);
test('npx wrapper preserves argument boundaries', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
// This was the exact failing case: description with multiple words
const result = runNpxWrapper(
['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', 'PRD Auto queue for not inbound yet products'],
tmpDir,
);
assertTrue(result.success, `NPX wrapper should preserve spaces: ${result.stderr || result.output}`);
// Verify full description was saved
const infoResult = runNpxWrapper(['scope', 'info', 'auto-queue'], tmpDir);
assertContains(infoResult.output, 'PRD Auto queue for not inbound yet products');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles multiple space-containing arguments', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'test-scope', '--name', 'My Test Scope Name', '--description', 'A long description with many words and spaces'],
tmpDir,
);
assertTrue(result.success, 'Should handle multiple space-containing args');
const infoResult = runNpxWrapper(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'My Test Scope Name');
assertContains(infoResult.output, 'A long description with many words and spaces');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles help commands', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['scope', 'help'], tmpDir);
assertTrue(result.success, 'Help should work via wrapper');
assertContains(result.output, 'BMAD Scope Management');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles subcommand help', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['scope', 'help', 'create'], tmpDir);
assertTrue(result.success, 'Subcommand help should work via wrapper');
assertContains(result.output, 'bmad scope create');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper preserves exit codes on failure', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(['scope', 'info', 'nonexistent'], tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
assertTrue(result.exitCode !== 0, 'Exit code should be non-zero');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Edge Cases Tests
// ============================================================================
function testEdgeCases() {
console.log(`\n${colors.blue}${colors.bold}Edge Cases Tests${colors.reset}`);
test('empty description argument', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle empty description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with only spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', ' '], tmpDir);
assertTrue(result.success, 'Should handle whitespace-only description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('name with leading and trailing spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', ' Spaced Name ', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle leading/trailing spaces in name');
} finally {
cleanupTestProject(tmpDir);
}
});
test('mixed flags and positional arguments', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// Some CLI parsers are sensitive to flag ordering
const result = runCliArray(['scope', 'create', '--name', 'Auth Service', 'auth', '--description', 'User authentication'], tmpDir);
// Depending on Commander.js behavior, this might fail or succeed
// The important thing is it doesn't crash unexpectedly
// Note: Commander.js is strict about positional arg ordering, so this may fail
} finally {
cleanupTestProject(tmpDir);
}
});
test('very long description', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const longDesc = 'A '.repeat(100) + 'very long description';
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', longDesc], tmpDir);
assertTrue(result.success, 'Should handle very long description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'very long description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with newline-like content', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// Note: actual newlines would be handled by the shell, this tests the literal string
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', String.raw`Line1\nLine2`], tmpDir);
assertTrue(result.success, 'Should handle backslash-n in description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with unicode characters', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handles authentication 认证 🔐'], tmpDir);
assertTrue(result.success, 'Should handle unicode in description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, '认证');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Argument Count Tests (Regression tests for "too many arguments" error)
// ============================================================================
function testArgumentCounts() {
console.log(`\n${colors.blue}${colors.bold}Argument Count Tests (Regression)${colors.reset}`);
test('9-word description does not cause "too many arguments" error', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// This was the exact case that failed: 9 words became 9 separate arguments
const result = runCliArray(
['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', 'PRD Auto queue for not inbound yet products'],
tmpDir,
);
assertTrue(result.success, `Should not fail with "too many arguments": ${result.stderr}`);
assertNotContains(result.stderr || '', 'too many arguments');
} finally {
cleanupTestProject(tmpDir);
}
});
test('20-word description works correctly', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const desc =
'This is a very long description with exactly twenty words to test that argument parsing works correctly for descriptions';
const result = runCliArray(['scope', 'create', 'test', '--name', 'Test', '--description', desc], tmpDir);
assertTrue(result.success, 'Should handle 20-word description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('multiple flag values with spaces all preserved', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'my-scope', '--name', 'My Scope Name Here', '--description', 'This is a description with many spaces'],
tmpDir,
);
assertTrue(result.success, 'All spaced arguments should be preserved');
const infoResult = runNpxWrapper(['scope', 'info', 'my-scope'], tmpDir);
assertContains(infoResult.output, 'My Scope Name Here');
assertContains(infoResult.output, 'This is a description with many spaces');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Install Command Tests (for completeness)
// ============================================================================
function testInstallCommand() {
console.log(`\n${colors.blue}${colors.bold}Install Command Tests${colors.reset}`);
test('install --help works via npx wrapper', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['install', '--help'], tmpDir);
assertTrue(result.success || result.output.includes('Install'), 'Install help should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('install --debug flag works', () => {
const tmpDir = createTestProject();
try {
// Just verify the flag is recognized, don't actually run full install
const result = runNpxWrapper(['install', '--help'], tmpDir);
// If we got here without crashing, the CLI is working
assertTrue(true, 'Install command accepts flags');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Main Test Runner
// ============================================================================
function main() {
console.log(`\n${colors.bold}BMAD CLI Argument Handling Test Suite${colors.reset}`);
console.log(colors.dim + '═'.repeat(70) + colors.reset);
console.log(colors.cyan + 'Testing proper preservation of argument boundaries,' + colors.reset);
console.log(colors.cyan + 'especially for arguments containing spaces.' + colors.reset);
const startTime = Date.now();
// Run all test suites
testArgumentsWithSpaces();
testSpecialCharacters();
testNpxWrapperBehavior();
testEdgeCases();
testArgumentCounts();
testInstallCommand();
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
// Summary
console.log(`\n${colors.dim}${'─'.repeat(70)}${colors.reset}`);
console.log(`\n${colors.bold}Test Results${colors.reset}`);
console.log(` Total: ${testCount}`);
console.log(` ${colors.green}Passed: ${passCount}${colors.reset}`);
if (failCount > 0) {
console.log(` ${colors.red}Failed: ${failCount}${colors.reset}`);
}
if (skipCount > 0) {
console.log(` ${colors.yellow}Skipped: ${skipCount}${colors.reset}`);
}
console.log(` Time: ${duration}s`);
if (failures.length > 0) {
console.log(`\n${colors.red}${colors.bold}Failures:${colors.reset}`);
for (const { name, error } of failures) {
console.log(`\n ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.dim}${error}${colors.reset}`);
}
process.exit(1);
}
console.log(`\n${colors.green}${colors.bold}All tests passed!${colors.reset}\n`);
process.exit(0);
}
main();

1475
test/test-scope-cli.js Normal file

File diff suppressed because it is too large Load Diff

1040
test/test-scope-e2e.js Normal file

File diff suppressed because it is too large Load Diff

1591
test/test-scope-system.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
* This file ensures proper execution when run via npx from GitHub or npm registry
*/
const { execSync } = require('node:child_process');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
@ -25,10 +25,20 @@ if (isNpxExecution) {
try {
// Execute CLI from user's working directory (process.cwd()), not npm cache
execSync(`node "${bmadCliPath}" ${args.join(' ')}`, {
// Use spawnSync with array args to preserve argument boundaries
// (args.join(' ') would break arguments containing spaces)
const result = spawnSync('node', [bmadCliPath, ...args], {
stdio: 'inherit',
cwd: process.cwd(), // This preserves the user's working directory
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status || 1);
}
} catch (error) {
process.exit(error.status || 1);
}

View File

@ -36,6 +36,7 @@ const LLM_EXCLUDE_PATTERNS = [
'v4-to-v6-upgrade',
'downloads/',
'faq',
'plans/', // Internal planning docs, not user-facing
'reference/glossary/',
'explanation/game-dev/',
// Note: Files/dirs starting with _ (like _STYLE_GUIDE.md, _archive/) are excluded in shouldExcludeFromLlm()

View File

@ -45,6 +45,11 @@ for (const [name, cmd] of Object.entries(commands)) {
command.option(...option);
}
// Allow commands to configure themselves (e.g., custom help)
if (cmd.configureCommand) {
cmd.configureCommand(command);
}
// Set action
command.action(cmd.action);
}

1644
tools/cli/commands/scope.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,3 +12,18 @@ You must fully embody this agent's persona and follow all activation instruction
4. Follow the agent's persona and menu system precisely
5. Stay in character throughout the session
</agent-activation>
<scope-awareness>
## Multi-Scope Context
When activated, check for scope context:
1. **Session scope**: Look for `.bmad-scope` file in project root
2. **Load context**: If scope is active, load both:
- Global context: `_bmad-output/_shared/project-context.md`
- Scope context: `_bmad-output/{scope}/project-context.md` (if exists)
3. **Merge contexts**: Scope-specific context extends/overrides global
4. **Menu items with `scope_required: true`**: Prompt for scope before executing
For menu items that produce artifacts, ensure they go to the active scope's directory.
</scope-awareness>

View File

@ -11,3 +11,20 @@ IT IS CRITICAL THAT YOU FOLLOW THESE STEPS - while staying in character as the c
4. Follow workflow.xml instructions EXACTLY as written to process and follow the specific workflow config and its instructions
5. Save outputs after EACH section when generating any documents from templates
</steps>
<scope-resolution>
## Multi-Scope Support
This workflow supports multi-scope parallel artifacts. Scope resolution order:
1. **--scope flag**: If provided (e.g., `/{{name}} --scope auth`), use that scope
2. **Session context**: Check for `.bmad-scope` file in project root
3. **Environment variable**: Check `BMAD_SCOPE` env var
4. **Prompt user**: If workflow requires scope and none found, prompt to select/create
When a scope is active:
- Artifacts are isolated to `_bmad-output/{scope}/`
- Cross-scope reads are allowed, writes are blocked
- Use `bmad scope sync-up` to promote artifacts to shared layer
- Check for pending dependency updates at workflow start
</scope-resolution>

View File

@ -2,4 +2,44 @@
description: '{{description}}'
---
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL @{{workflow_path}}, READ its entire contents and follow its directions exactly!
IT IS CRITICAL THAT YOU FOLLOW THESE STEPS IN ORDER:
<scope-resolution CRITICAL="true">
## Step 0: Resolve Scope Context BEFORE Workflow Execution
The workflow file will instruct you to load config.yaml. BEFORE following those instructions:
### 0a. Check for Active Scope
1. Check for `.bmad-scope` file in {project-root}
2. If exists, read the `active_scope` value and store as {scope}
3. If `.bmad-scope` does not exist, skip to Step 1 (backward compatible, no scope)
### 0b. Override Config Paths (CRITICAL - if scope is set)
After loading config.yaml but BEFORE using any paths, you MUST override these variables:
```
{scope_path} = {output_folder}/{scope}
{planning_artifacts} = {scope_path}/planning-artifacts
{implementation_artifacts} = {scope_path}/implementation-artifacts
{scope_tests} = {scope_path}/tests
```
**Example:** If config.yaml has `output_folder: "_bmad-output"` and scope is "auth":
- {scope_path} = `_bmad-output/auth`
- {planning_artifacts} = `_bmad-output/auth/planning-artifacts`
- {implementation_artifacts} = `_bmad-output/auth/implementation-artifacts`
**WARNING:** Config.yaml contains pre-resolved static paths. You MUST override them with the scope-aware paths above. DO NOT use the config.yaml values directly for these variables when a scope is active.
### 0c. Load Scope Context
If scope is set:
- Load global context: `{output_folder}/_shared/project-context.md`
- Load scope context if exists: `{scope_path}/project-context.md`
- Merge: scope-specific content extends/overrides global
</scope-resolution>
## Step 1: Execute Workflow
NOW: LOAD the FULL @{{workflow_path}}, READ its entire contents and follow its directions exactly!
When the workflow instructs you to use `{planning_artifacts}` or `{implementation_artifacts}`, use YOUR OVERRIDDEN VALUES from Step 0b, not the static config.yaml values.

View File

@ -416,7 +416,9 @@ class ModuleManager {
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
// Remove lockfile first - it may reference devDeps that don't exist
execSync('rm -f package-lock.json', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('npm install --omit=dev --ignore-scripts --no-package-lock --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout
@ -441,7 +443,9 @@ class ModuleManager {
if (packageJsonNewer) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
// Remove lockfile first - it may reference devDeps that don't exist
execSync('rm -f package-lock.json', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('npm install --omit=dev --ignore-scripts --no-package-lock --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout

View File

@ -0,0 +1,273 @@
/**
* Workflow Migration Script
*
* Updates workflow.yaml files to support the multi-scope system.
* Primarily updates test_dir and other path variables to use scope-aware paths.
*
* Usage:
* node migrate-workflows.js [--dry-run] [--verbose]
*
* Options:
* --dry-run Show what would be changed without making changes
* --verbose Show detailed output
*/
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const chalk = require('chalk');
// Configuration
const SRC_PATH = path.resolve(__dirname, '../../../src');
const WORKFLOW_PATTERN = '**/workflow.yaml';
// Path mappings for migration
const PATH_MIGRATIONS = [
// Test directory migrations
{
pattern: /\{output_folder\}\/tests/g,
replacement: '{scope_tests}',
description: 'test directory to scope_tests',
},
{
pattern: /\{config_source:implementation_artifacts\}\/tests/g,
replacement: '{config_source:scope_tests}',
description: 'implementation_artifacts tests to scope_tests',
},
// Planning artifacts
{
pattern: /\{output_folder\}\/planning-artifacts/g,
replacement: '{config_source:planning_artifacts}',
description: 'output_folder planning to config_source',
},
// Implementation artifacts
{
pattern: /\{output_folder\}\/implementation-artifacts/g,
replacement: '{config_source:implementation_artifacts}',
description: 'output_folder implementation to config_source',
},
];
// Variables that indicate scope requirement
const SCOPE_INDICATORS = ['{scope}', '{scope_path}', '{scope_tests}', '{scope_planning}', '{scope_implementation}'];
/**
* Find all workflow.yaml files
*/
async function findWorkflowFiles(basePath) {
const files = [];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and hidden directories
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
await walk(fullPath);
}
} else if (entry.name === 'workflow.yaml') {
files.push(fullPath);
}
}
}
await walk(basePath);
return files;
}
/**
* Check if a workflow already uses scope variables
*/
function usesScope(content) {
return SCOPE_INDICATORS.some((indicator) => content.includes(indicator));
}
/**
* Analyze a workflow file and suggest migrations
*/
function analyzeWorkflow(content, filePath) {
const analysis = {
filePath,
needsMigration: false,
alreadyScoped: false,
suggestions: [],
currentVariables: [],
};
// Check if already uses scope
if (usesScope(content)) {
analysis.alreadyScoped = true;
return analysis;
}
// Find variables that might need migration
const variablePattern = /\{[^}]+\}/g;
const matches = content.match(variablePattern) || [];
analysis.currentVariables = [...new Set(matches)];
// Check each migration pattern
for (const migration of PATH_MIGRATIONS) {
if (migration.pattern.test(content)) {
analysis.needsMigration = true;
analysis.suggestions.push({
description: migration.description,
pattern: migration.pattern.toString(),
replacement: migration.replacement,
});
}
}
// Check for test_dir variable
if (content.includes('test_dir:') || content.includes('test_dir:')) {
analysis.needsMigration = true;
analysis.suggestions.push({
description: 'Has test_dir variable - may need scope_tests',
pattern: 'test_dir',
replacement: 'scope_tests via config_source',
});
}
return analysis;
}
/**
* Apply migrations to workflow content
*/
function migrateWorkflow(content) {
let migrated = content;
let changes = [];
for (const migration of PATH_MIGRATIONS) {
if (migration.pattern.test(migrated)) {
migrated = migrated.replace(migration.pattern, migration.replacement);
changes.push(migration.description);
}
}
return { content: migrated, changes };
}
/**
* Add scope_required marker to workflow
*/
function addScopeMarker(content) {
try {
const parsed = yaml.parse(content);
// Add scope_required if not present
if (!parsed.scope_required) {
parsed.scope_required = false; // Default to false for backward compatibility
}
return yaml.stringify(parsed, { lineWidth: 120 });
} catch {
// If YAML parsing fails, return original
return content;
}
}
/**
* Main migration function
*/
async function main() {
const args = new Set(process.argv.slice(2));
const dryRun = args.has('--dry-run');
const verbose = args.has('--verbose');
console.log(chalk.bold('\nWorkflow Migration Script'));
console.log(chalk.dim('Updating workflow.yaml files for multi-scope support\n'));
if (dryRun) {
console.log(chalk.yellow('DRY RUN MODE - No changes will be made\n'));
}
// Find all workflow files
console.log(chalk.blue('Scanning for workflow.yaml files...'));
const files = await findWorkflowFiles(SRC_PATH);
console.log(chalk.green(`Found ${files.length} workflow.yaml files\n`));
// Analysis results
const results = {
analyzed: 0,
alreadyScoped: 0,
migrated: 0,
noChanges: 0,
errors: [],
};
// Process each file
for (const filePath of files) {
const relativePath = path.relative(SRC_PATH, filePath);
results.analyzed++;
try {
const content = await fs.readFile(filePath, 'utf8');
const analysis = analyzeWorkflow(content, filePath);
if (analysis.alreadyScoped) {
results.alreadyScoped++;
if (verbose) {
console.log(chalk.dim(`${relativePath} - already scope-aware`));
}
continue;
}
if (!analysis.needsMigration) {
results.noChanges++;
if (verbose) {
console.log(chalk.dim(`${relativePath} - no changes needed`));
}
continue;
}
// Apply migration
const { content: migrated, changes } = migrateWorkflow(content);
if (changes.length > 0) {
console.log(chalk.cyan(`${relativePath}`));
for (const change of changes) {
console.log(chalk.dim(`${change}`));
}
if (!dryRun) {
await fs.writeFile(filePath, migrated, 'utf8');
}
results.migrated++;
} else {
results.noChanges++;
}
} catch (error) {
results.errors.push({ file: relativePath, error: error.message });
console.log(chalk.red(`${relativePath} - Error: ${error.message}`));
}
}
// Print summary
console.log(chalk.bold('\n─────────────────────────────────────'));
console.log(chalk.bold('Summary'));
console.log(chalk.dim('─────────────────────────────────────'));
console.log(` Files analyzed: ${results.analyzed}`);
console.log(` Already scope-aware: ${results.alreadyScoped}`);
console.log(` Migrated: ${results.migrated}`);
console.log(` No changes needed: ${results.noChanges}`);
if (results.errors.length > 0) {
console.log(chalk.red(` Errors: ${results.errors.length}`));
}
console.log();
if (dryRun && results.migrated > 0) {
console.log(chalk.yellow('Run without --dry-run to apply changes\n'));
}
// Exit with error code if there were errors
process.exit(results.errors.length > 0 ? 1 : 0);
}
// Run
main().catch((error) => {
console.error(chalk.red('Fatal error:'), error.message);
process.exit(1);
});

View File

@ -285,6 +285,7 @@ function buildMenuItemSchema() {
'ide-only': z.boolean().optional(),
'web-only': z.boolean().optional(),
discussion: z.boolean().optional(),
scope_required: z.boolean().optional(),
})
.strict()
.superRefine((value, ctx) => {
@ -408,6 +409,7 @@ function buildMenuItemSchema() {
)
.min(1, { message: 'agent.menu[].triggers must have at least one trigger' }),
discussion: z.boolean().optional(),
scope_required: z.boolean().optional(),
})
.strict()
.superRefine((value, ctx) => {