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
|
#!/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
|
# Auto-fix changed files and stage them
|
||||||
npx --no-install lint-staged
|
npx --no-install lint-staged
|
||||||
|
|
||||||
|
|
@ -10,11 +25,11 @@ npm test
|
||||||
if command -v rg >/dev/null 2>&1; then
|
if command -v rg >/dev/null 2>&1; then
|
||||||
if git diff --cached --name-only | rg -q '^docs/'; then
|
if git diff --cached --name-only | rg -q '^docs/'; then
|
||||||
npm run docs:validate-links
|
npm run docs:validate-links
|
||||||
npm run docs:build
|
npm run docs:build
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if git diff --cached --name-only | grep -Eq '^docs/'; then
|
if git diff --cached --name-only | grep -Eq '^docs/'; then
|
||||||
npm run docs:validate-links
|
npm run docs:validate-links
|
||||||
npm run docs:build
|
npm run docs:build
|
||||||
fi
|
fi
|
||||||
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?
|
## What Makes a Good PR?
|
||||||
|
|
||||||
| ✅ Do | ❌ Don't |
|
| ✅ 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+
|
**Prerequisites**: [Node.js](https://nodejs.org) v20+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method@alpha install
|
npx bmad-fh install
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow the installer prompts to configure your project. Then run:
|
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)**
|
- **[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
|
## Community
|
||||||
|
|
||||||
- [Discord](https://discord.gg/gk8jAdXWmj) — Get help, share ideas, collaborate
|
- [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: {
|
rules: {
|
||||||
// Allow CommonJS patterns for Node CLI scripts
|
// Allow CommonJS patterns for Node CLI scripts
|
||||||
'unicorn/prefer-module': 'off',
|
'unicorn/prefer-module': 'off',
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "bmad-method",
|
"name": "bmad-fh",
|
||||||
"version": "6.0.0-alpha.23",
|
"version": "6.0.0-alpha.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bmad-method",
|
"name": "bmad-fh",
|
||||||
"version": "6.0.0-alpha.23",
|
"version": "6.0.0-alpha.23",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -28,8 +28,7 @@
|
||||||
"yaml": "^2.7.0"
|
"yaml": "^2.7.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"bmad": "tools/bmad-npx-wrapper.js",
|
"bmad-fh": "tools/bmad-npx-wrapper.js"
|
||||||
"bmad-method": "tools/bmad-npx-wrapper.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
|
|
@ -244,6 +243,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -3972,6 +3972,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4280,6 +4281,7 @@
|
||||||
"integrity": "sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==",
|
"integrity": "sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/compiler": "^2.13.0",
|
"@astrojs/compiler": "^2.13.0",
|
||||||
"@astrojs/internal-helpers": "0.7.5",
|
"@astrojs/internal-helpers": "0.7.5",
|
||||||
|
|
@ -5347,6 +5349,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -6662,6 +6665,7 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -10223,6 +10227,7 @@
|
||||||
"integrity": "sha512-p3JTemJJbkiMjXEMiFwgm0v6ym5g8K+b2oDny+6xdl300tUKySxvilJQLSea48C6OaYNmO30kH9KxpiAg5bWJw==",
|
"integrity": "sha512-p3JTemJJbkiMjXEMiFwgm0v6ym5g8K+b2oDny+6xdl300tUKySxvilJQLSea48C6OaYNmO30kH9KxpiAg5bWJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"globby": "15.0.0",
|
"globby": "15.0.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
|
|
@ -12286,6 +12291,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -12351,6 +12357,7 @@
|
||||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -13179,6 +13186,7 @@
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -14718,6 +14726,7 @@
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -14991,6 +15000,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|
@ -15170,6 +15180,7 @@
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "bmad-method",
|
"name": "bmad-fh",
|
||||||
"version": "6.0.0-alpha.23",
|
"version": "6.0.0-alpha.23",
|
||||||
"description": "Breakthrough Method of Agile AI-driven Development",
|
"description": "Breakthrough Method of Agile AI-driven Development",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
@ -14,14 +14,13 @@
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/bmad-code-org/BMAD-METHOD.git"
|
"url": "git+https://github.com/fairyhunter13/BMAD-METHOD.git"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Brian (BMad) Madison",
|
"author": "Brian (BMad) Madison",
|
||||||
"main": "tools/cli/bmad-cli.js",
|
"main": "tools/cli/bmad-cli.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"bmad": "tools/bmad-npx-wrapper.js",
|
"bmad-fh": "tools/bmad-npx-wrapper.js"
|
||||||
"bmad-method": "tools/bmad-npx-wrapper.js"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bmad:install": "node tools/cli/bmad-cli.js install",
|
"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:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
|
||||||
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
||||||
"release:watch": "gh run watch",
|
"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:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
"test:schemas": "node test/test-agent-schema.js",
|
"test:schemas": "node test/test-agent-schema.js",
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,36 @@ user_skill_level:
|
||||||
- value: "expert"
|
- value: "expert"
|
||||||
label: "Expert - Be direct and technical"
|
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
|
planning_artifacts: # Phase 1-3 artifacts
|
||||||
prompt: "Where should planning artifacts be stored? (Brainstorming, Briefs, PRDs, UX Designs, Architecture, Epics)"
|
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}"
|
result: "{project-root}/{value}"
|
||||||
|
|
||||||
implementation_artifacts: # Phase 4 artifacts and quick-dev flow output
|
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)"
|
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}"
|
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
|
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)"
|
prompt: "Where should long-term project knowledge be stored? (docs, research, references)"
|
||||||
default: "docs"
|
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?"
|
prompt: "Where should output files be saved?"
|
||||||
default: "_bmad-output"
|
default: "_bmad-output"
|
||||||
result: "{project-root}/{value}"
|
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>
|
</WORKFLOW-RULES>
|
||||||
|
|
||||||
<flow>
|
<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">
|
<step n="1" title="Load and Initialize Workflow">
|
||||||
<substep n="1a" title="Load Configuration and Resolve Variables">
|
<substep n="1a" title="Load Configuration and Resolve Variables">
|
||||||
<action>Read workflow.yaml from provided path</action>
|
<action>Read workflow.yaml from provided path</action>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,22 @@
|
||||||
- VERIFY: If config not loaded, STOP and report error to user
|
- 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
|
- DO NOT PROCEED to step 3 until config is successfully loaded and variables stored
|
||||||
</step>
|
</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}
|
{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="{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>
|
<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">
|
<handler type="exec">
|
||||||
When menu item or handler has: exec="path/to/file.md":
|
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
|
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
|
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>
|
</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
|
* 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 path = require('node:path');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
|
@ -25,10 +25,20 @@ if (isNpxExecution) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute CLI from user's working directory (process.cwd()), not npm cache
|
// 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',
|
stdio: 'inherit',
|
||||||
cwd: process.cwd(), // This preserves the user's working directory
|
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) {
|
} catch (error) {
|
||||||
process.exit(error.status || 1);
|
process.exit(error.status || 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const LLM_EXCLUDE_PATTERNS = [
|
||||||
'v4-to-v6-upgrade',
|
'v4-to-v6-upgrade',
|
||||||
'downloads/',
|
'downloads/',
|
||||||
'faq',
|
'faq',
|
||||||
|
'plans/', // Internal planning docs, not user-facing
|
||||||
'reference/glossary/',
|
'reference/glossary/',
|
||||||
'explanation/game-dev/',
|
'explanation/game-dev/',
|
||||||
// Note: Files/dirs starting with _ (like _STYLE_GUIDE.md, _archive/) are excluded in shouldExcludeFromLlm()
|
// 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);
|
command.option(...option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow commands to configure themselves (e.g., custom help)
|
||||||
|
if (cmd.configureCommand) {
|
||||||
|
cmd.configureCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
// Set action
|
// Set action
|
||||||
command.action(cmd.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
|
4. Follow the agent's persona and menu system precisely
|
||||||
5. Stay in character throughout the session
|
5. Stay in character throughout the session
|
||||||
</agent-activation>
|
</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
|
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
|
5. Save outputs after EACH section when generating any documents from templates
|
||||||
</steps>
|
</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}}'
|
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) {
|
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
||||||
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
|
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
|
||||||
try {
|
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,
|
cwd: moduleCacheDir,
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
timeout: 120_000, // 2 minute timeout
|
timeout: 120_000, // 2 minute timeout
|
||||||
|
|
@ -441,7 +443,9 @@ class ModuleManager {
|
||||||
if (packageJsonNewer) {
|
if (packageJsonNewer) {
|
||||||
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
|
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
|
||||||
try {
|
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,
|
cwd: moduleCacheDir,
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
timeout: 120_000, // 2 minute timeout
|
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(),
|
'ide-only': z.boolean().optional(),
|
||||||
'web-only': z.boolean().optional(),
|
'web-only': z.boolean().optional(),
|
||||||
discussion: z.boolean().optional(),
|
discussion: z.boolean().optional(),
|
||||||
|
scope_required: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
|
|
@ -408,6 +409,7 @@ function buildMenuItemSchema() {
|
||||||
)
|
)
|
||||||
.min(1, { message: 'agent.menu[].triggers must have at least one trigger' }),
|
.min(1, { message: 'agent.menu[].triggers must have at least one trigger' }),
|
||||||
discussion: z.boolean().optional(),
|
discussion: z.boolean().optional(),
|
||||||
|
scope_required: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue