From d5ea3a7e6086184a52f599b73a358ef58b20c6b8 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Mon, 6 Jul 2026 12:35:15 -0700 Subject: [PATCH] =?UTF-8?q?feat(sdd):=20plan-scoped=20workspace=20?= =?UTF-8?q?=E2=80=94=20one=20.superpowers/sdd/=20dir=20per=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdd-workspace now requires the plan file and resolves .superpowers/sdd//; task-brief and review-package write into their plan's directory (review-package gains PLAN_FILE as its first argument). Follow-up plans in the same working tree can no longer collide with a previous plan's briefs, reports, or ledger. --- .../scripts/review-package | 20 +-- .../scripts/sdd-workspace | 34 +++-- .../scripts/task-brief | 7 +- tests/claude-code/test-sdd-workspace.sh | 134 +++++++++++++----- 4 files changed, 137 insertions(+), 58 deletions(-) diff --git a/skills/subagent-driven-development/scripts/review-package b/skills/subagent-driven-development/scripts/review-package index 33bb20f7..31852e2a 100755 --- a/skills/subagent-driven-development/scripts/review-package +++ b/skills/subagent-driven-development/scripts/review-package @@ -4,26 +4,28 @@ # call. Using the recorded per-task BASE (not HEAD~1) keeps multi-commit # tasks intact. # -# Usage: review-package BASE HEAD [OUTFILE] -# Default OUTFILE: /.superpowers/sdd/review-...diff +# Usage: review-package PLAN_FILE BASE HEAD [OUTFILE] +# Default OUTFILE: /.superpowers/sdd//review-...diff # (named per range, so a re-review after fixes gets a distinct fresh file). set -euo pipefail -if [ $# -lt 2 ] || [ $# -gt 3 ]; then - echo "usage: review-package BASE HEAD [OUTFILE]" >&2 +if [ $# -lt 3 ] || [ $# -gt 4 ]; then + echo "usage: review-package PLAN_FILE BASE HEAD [OUTFILE]" >&2 exit 2 fi -base=$1 -head=$2 +plan=$1 +base=$2 +head=$3 +[ -f "$plan" ] || { echo "no such plan file: $plan" >&2; exit 2; } git rev-parse --verify --quiet "$base" >/dev/null || { echo "bad BASE: $base" >&2; exit 2; } git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; } -if [ $# -eq 3 ]; then - out=$3 +if [ $# -eq 4 ]; then + out=$4 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace") + dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff" fi diff --git a/skills/subagent-driven-development/scripts/sdd-workspace b/skills/subagent-driven-development/scripts/sdd-workspace index ea9bb08f..4e2d1680 100755 --- a/skills/subagent-driven-development/scripts/sdd-workspace +++ b/skills/subagent-driven-development/scripts/sdd-workspace @@ -1,22 +1,40 @@ #!/usr/bin/env bash -# Resolve and ensure the working-tree directory SDD uses for its short-lived -# artifacts: task briefs, implementer reports, review packages, and the -# progress ledger. Print the directory's absolute path. +# Resolve and ensure the working-tree directory SDD uses for one plan's +# short-lived artifacts: task briefs, implementer reports, review packages, +# and the progress ledger. Print the plan directory's absolute path. +# +# One directory per plan (.superpowers/sdd//) so a follow-up +# plan in the same working tree can never read or overwrite another plan's +# artifacts. A stale ledger misread as current progress makes controllers +# skip whole task sequences — plan-scoping removes that failure structurally. # # The workspace lives in the working tree (not under .git/) because Claude Code # treats .git/ as a protected path and denies agent writes there — which blocks # an implementer subagent from writing its report file. A self-ignoring -# .gitignore keeps the workspace out of `git status` and out of accidental -# commits without modifying any tracked file. +# .gitignore at .superpowers/sdd/ keeps every plan's workspace out of +# `git status` and out of accidental commits without modifying any tracked file. # # Single source of truth for the workspace location, so task-brief and # review-package cannot drift to different directories. # -# Usage: sdd-workspace +# Usage: sdd-workspace PLAN_FILE set -euo pipefail +if [ $# -ne 1 ]; then + echo "usage: sdd-workspace PLAN_FILE" >&2 + exit 2 +fi + +plan=$1 +[ -f "$plan" ] || { echo "no such plan file: $plan" >&2; exit 2; } + +slug=$(basename "$plan" .md) +[ -n "$slug" ] && [ "$slug" != "." ] && [ "$slug" != ".." ] \ + || { echo "cannot derive a workspace name from: $plan" >&2; exit 2; } + root=$(git rev-parse --show-toplevel) -dir="$root/.superpowers/sdd" +base="$root/.superpowers/sdd" +dir="$base/$slug" mkdir -p "$dir" -printf '*\n' > "$dir/.gitignore" +printf '*\n' > "$base/.gitignore" cd "$dir" && pwd diff --git a/skills/subagent-driven-development/scripts/task-brief b/skills/subagent-driven-development/scripts/task-brief index 247a7670..612e14a1 100755 --- a/skills/subagent-driven-development/scripts/task-brief +++ b/skills/subagent-driven-development/scripts/task-brief @@ -4,8 +4,9 @@ # through the controller's context. # # Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE] -# Default OUTFILE: /.superpowers/sdd/task--brief.md -# (per worktree; concurrent runs in the same working tree share it). +# Default OUTFILE: /.superpowers/sdd//task--brief.md +# (per plan and per worktree; concurrent runs of the SAME plan in the same +# working tree share it). set -euo pipefail if [ $# -lt 2 ] || [ $# -gt 3 ]; then @@ -20,7 +21,7 @@ n=$2 if [ $# -eq 3 ]; then out=$3 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace") + dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/task-${n}-brief.md" fi diff --git a/tests/claude-code/test-sdd-workspace.sh b/tests/claude-code/test-sdd-workspace.sh index 397e1ebc..84172301 100755 --- a/tests/claude-code/test-sdd-workspace.sh +++ b/tests/claude-code/test-sdd-workspace.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Tests for the SDD workspace: scripts/sdd-workspace resolves a self-ignoring -# working-tree directory for SDD artifacts, and the SDD scripts write into it. +# Tests for the SDD workspace: scripts/sdd-workspace resolves a self-ignoring, +# PER-PLAN working-tree directory for SDD artifacts, and the SDD scripts write +# into their plan's directory. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -35,26 +36,72 @@ main() { local repo repo="$(cd "$TEST_ROOT/repo" && git rev-parse --show-toplevel)" - local dir - dir="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace")" + cat > "$repo/plan-a.md" <<'PLAN' +# Plan A - if [[ "$dir" == "$repo/.superpowers/sdd" ]]; then - pass "prints /.superpowers/sdd" +## Task 1: First thing + +Do the first thing. +PLAN + cat > "$repo/plan-b.md" <<'PLAN' +# Plan B + +## Task 1: Other thing + +Do the other thing. +PLAN + + # --- argument validation --- + local rc=0 + (cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" >/dev/null 2>&1) || rc=$? + if [[ "$rc" -eq 2 ]]; then + pass "sdd-workspace without a plan errors with exit 2" else - fail "prints /.superpowers/sdd" - echo " got: $dir" + fail "sdd-workspace without a plan errors with exit 2" + echo " exit: $rc" + fi + + rc=0 + (cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" no-such-plan.md >/dev/null 2>&1) || rc=$? + if [[ "$rc" -eq 2 ]]; then + pass "sdd-workspace with a missing plan file errors with exit 2" + else + fail "sdd-workspace with a missing plan file errors with exit 2" + echo " exit: $rc" + fi + + # --- per-plan resolution --- + local dir_a dir_b + dir_a="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" plan-a.md)" + dir_b="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" plan-b.md)" + + if [[ "$dir_a" == "$repo/.superpowers/sdd/plan-a" ]]; then + pass "prints /.superpowers/sdd/" + else + fail "prints /.superpowers/sdd/" + echo " got: $dir_a" + fi + + if [[ "$dir_a" != "$dir_b" && -d "$dir_a" && -d "$dir_b" ]]; then + pass "two plans resolve to two distinct directories" + else + fail "two plans resolve to two distinct directories" + echo " a: $dir_a" + echo " b: $dir_b" fi if [[ -f "$repo/.superpowers/sdd/.gitignore" && "$(cat "$repo/.superpowers/sdd/.gitignore")" == "*" ]]; then - pass "self-ignoring .gitignore created with '*'" + pass "self-ignoring .gitignore created at .superpowers/sdd/ with '*'" else - fail "self-ignoring .gitignore created with '*'" + fail "self-ignoring .gitignore created at .superpowers/sdd/ with '*'" fi - printf 'x\n' > "$repo/.superpowers/sdd/artifact.md" + printf 'x\n' > "$dir_a/artifact.md" local status status="$(cd "$repo" && git status --porcelain)" - if [[ -z "$status" ]]; then + # plan-a.md/plan-b.md are intentionally untracked fixture files; only the + # workspace must be invisible. + if [[ "$status" != *".superpowers"* ]]; then pass "workspace invisible to git status" else fail "workspace invisible to git status" @@ -64,67 +111,78 @@ main() { ( cd "$repo" && git add -A ) local staged staged="$(cd "$repo" && git diff --cached --name-only)" - if [[ -z "$staged" ]]; then + if [[ "$staged" != *".superpowers"* ]]; then pass "git add -A does not stage the workspace" else fail "git add -A does not stage the workspace" echo " staged: $staged" fi - cat > "$repo/plan.md" <<'PLAN' -# Plan - -## Task 1: First thing - -Do the first thing. -PLAN - + # --- task-brief lands in its plan's directory --- local brief_out brief_path - brief_out="$(cd "$repo" && "$SDD_SCRIPTS/task-brief" plan.md 1)" + brief_out="$(cd "$repo" && "$SDD_SCRIPTS/task-brief" plan-a.md 1)" brief_path="$(printf '%s\n' "$brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')" - case "$brief_path" in - "$repo/.superpowers/sdd/"*) pass "task-brief writes its brief under the workspace" ;; - *) - fail "task-brief writes its brief under the workspace" - echo " got: $brief_path" - ;; - esac + if [[ "$brief_path" == "$repo/.superpowers/sdd/plan-a/task-1-brief.md" ]]; then + pass "task-brief writes its brief under the plan's workspace" + else + fail "task-brief writes its brief under the plan's workspace" + echo " got: $brief_path" + fi + # --- review-package takes the plan first and lands in its directory --- local git_id=(-c user.email=t@example.com -c user.name=t -c commit.gpgsign=false) ( cd "$repo" \ - && git add plan.md \ && git "${git_id[@]}" commit -qm c1 \ && printf 'y\n' > f && git add f \ && git "${git_id[@]}" commit -qm c2 ) local rp_out rp_path - rp_out="$(cd "$repo" && "$SDD_SCRIPTS/review-package" HEAD~1 HEAD)" + rp_out="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD~1 HEAD)" rp_path="$(printf '%s\n' "$rp_out" | sed -n 's/^wrote \(.*\): [0-9].*$/\1/p')" case "$rp_path" in - "$repo/.superpowers/sdd/"*) pass "review-package writes its diff under the workspace" ;; + "$repo/.superpowers/sdd/plan-a/review-"*.diff) + pass "review-package writes its diff under the plan's workspace" ;; *) - fail "review-package writes its diff under the workspace" + fail "review-package writes its diff under the plan's workspace" echo " got: $rp_path" ;; esac + rc=0 + (cd "$repo" && "$SDD_SCRIPTS/review-package" HEAD~1 HEAD >/dev/null 2>&1) || rc=$? + if [[ "$rc" -eq 2 ]]; then + pass "review-package without a plan errors with exit 2" + else + fail "review-package without a plan errors with exit 2" + echo " exit: $rc" + fi + + local rp_explicit + rp_explicit="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD~1 HEAD "$TEST_ROOT/explicit.diff")" + if [[ -s "$TEST_ROOT/explicit.diff" && "$rp_explicit" == *"$TEST_ROOT/explicit.diff"* ]]; then + pass "review-package honors an explicit OUTFILE" + else + fail "review-package honors an explicit OUTFILE" + echo " got: $rp_explicit" + fi + # --- Worktree isolation: a linked worktree resolves its own workspace --- local wt="$TEST_ROOT/wt" ( cd "$repo" && git worktree add -q "$wt" -b wt-feature ) local wt_root wt_dir wt_root="$(cd "$wt" && git rev-parse --show-toplevel)" - wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace")" - if [[ "$wt_dir" == "$wt_root/.superpowers/sdd" && "$wt_dir" != "$dir" ]]; then + wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace" plan-a.md)" + if [[ "$wt_dir" == "$wt_root/.superpowers/sdd/plan-a" && "$wt_dir" != "$dir_a" ]]; then pass "linked worktree resolves its own distinct workspace" else fail "linked worktree resolves its own distinct workspace" - echo " main: $dir" + echo " main: $dir_a" echo " wt: $wt_dir" fi - printf 'y\n' > "$wt/.superpowers/sdd/artifact.md" + printf 'y\n' > "$wt_dir/artifact.md" local wt_status wt_status="$(cd "$wt" && git status --porcelain)" - if [[ -z "$wt_status" ]]; then + if [[ "$wt_status" != *".superpowers"* ]]; then pass "worktree workspace invisible to git status" else fail "worktree workspace invisible to git status"