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:
parent
01bbe2a3ef
commit
6874ced1f6
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
139
CONTRIBUTING.md
139
CONTRIBUTING.md
|
|
@ -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
144
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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).
|
||||
|
|
@ -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_
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue