feat(sdd): plan-scoped workspace — one .superpowers/sdd/<plan> dir per plan

sdd-workspace now requires the plan file and resolves
.superpowers/sdd/<plan-basename>/; 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.
This commit is contained in:
Jesse Vincent
2026-07-06 12:35:15 -07:00
parent 913bfd3354
commit d5ea3a7e60
4 changed files with 137 additions and 58 deletions

View File

@@ -4,26 +4,28 @@
# call. Using the recorded per-task BASE (not HEAD~1) keeps multi-commit # call. Using the recorded per-task BASE (not HEAD~1) keeps multi-commit
# tasks intact. # tasks intact.
# #
# Usage: review-package BASE HEAD [OUTFILE] # Usage: review-package PLAN_FILE BASE HEAD [OUTFILE]
# Default OUTFILE: <repo-root>/.superpowers/sdd/review-<base7>..<head7>.diff # Default OUTFILE: <repo-root>/.superpowers/sdd/<plan-basename>/review-<base7>..<head7>.diff
# (named per range, so a re-review after fixes gets a distinct fresh file). # (named per range, so a re-review after fixes gets a distinct fresh file).
set -euo pipefail set -euo pipefail
if [ $# -lt 2 ] || [ $# -gt 3 ]; then if [ $# -lt 3 ] || [ $# -gt 4 ]; then
echo "usage: review-package BASE HEAD [OUTFILE]" >&2 echo "usage: review-package PLAN_FILE BASE HEAD [OUTFILE]" >&2
exit 2 exit 2
fi fi
base=$1 plan=$1
head=$2 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 "$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; } git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; }
if [ $# -eq 3 ]; then if [ $# -eq 4 ]; then
out=$3 out=$4
else 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" out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff"
fi fi

View File

@@ -1,22 +1,40 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Resolve and ensure the working-tree directory SDD uses for its short-lived # Resolve and ensure the working-tree directory SDD uses for one plan's
# artifacts: task briefs, implementer reports, review packages, and the # short-lived artifacts: task briefs, implementer reports, review packages,
# progress ledger. Print the directory's absolute path. # and the progress ledger. Print the plan directory's absolute path.
#
# One directory per plan (.superpowers/sdd/<plan-basename>/) 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 # 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 # treats .git/ as a protected path and denies agent writes there — which blocks
# an implementer subagent from writing its report file. A self-ignoring # an implementer subagent from writing its report file. A self-ignoring
# .gitignore keeps the workspace out of `git status` and out of accidental # .gitignore at .superpowers/sdd/ keeps every plan's workspace out of
# commits without modifying any tracked file. # `git status` and out of accidental commits without modifying any tracked file.
# #
# Single source of truth for the workspace location, so task-brief and # Single source of truth for the workspace location, so task-brief and
# review-package cannot drift to different directories. # review-package cannot drift to different directories.
# #
# Usage: sdd-workspace # Usage: sdd-workspace PLAN_FILE
set -euo pipefail 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) root=$(git rev-parse --show-toplevel)
dir="$root/.superpowers/sdd" base="$root/.superpowers/sdd"
dir="$base/$slug"
mkdir -p "$dir" mkdir -p "$dir"
printf '*\n' > "$dir/.gitignore" printf '*\n' > "$base/.gitignore"
cd "$dir" && pwd cd "$dir" && pwd

View File

@@ -4,8 +4,9 @@
# through the controller's context. # through the controller's context.
# #
# Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE] # Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE]
# Default OUTFILE: <repo-root>/.superpowers/sdd/task-<N>-brief.md # Default OUTFILE: <repo-root>/.superpowers/sdd/<plan-basename>/task-<N>-brief.md
# (per worktree; concurrent runs in the same working tree share it). # (per plan and per worktree; concurrent runs of the SAME plan in the same
# working tree share it).
set -euo pipefail set -euo pipefail
if [ $# -lt 2 ] || [ $# -gt 3 ]; then if [ $# -lt 2 ] || [ $# -gt 3 ]; then
@@ -20,7 +21,7 @@ n=$2
if [ $# -eq 3 ]; then if [ $# -eq 3 ]; then
out=$3 out=$3
else else
dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace") dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan")
out="$dir/task-${n}-brief.md" out="$dir/task-${n}-brief.md"
fi fi

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Tests for the SDD workspace: scripts/sdd-workspace resolves a self-ignoring # 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. # PER-PLAN working-tree directory for SDD artifacts, and the SDD scripts write
# into their plan's directory.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -35,26 +36,72 @@ main() {
local repo local repo
repo="$(cd "$TEST_ROOT/repo" && git rev-parse --show-toplevel)" repo="$(cd "$TEST_ROOT/repo" && git rev-parse --show-toplevel)"
local dir cat > "$repo/plan-a.md" <<'PLAN'
dir="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace")" # Plan A
if [[ "$dir" == "$repo/.superpowers/sdd" ]]; then ## Task 1: First thing
pass "prints <repo-root>/.superpowers/sdd"
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 else
fail "prints <repo-root>/.superpowers/sdd" fail "sdd-workspace without a plan errors with exit 2"
echo " got: $dir" 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 <repo-root>/.superpowers/sdd/<plan-basename>"
else
fail "prints <repo-root>/.superpowers/sdd/<plan-basename>"
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 fi
if [[ -f "$repo/.superpowers/sdd/.gitignore" && "$(cat "$repo/.superpowers/sdd/.gitignore")" == "*" ]]; then 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 else
fail "self-ignoring .gitignore created with '*'" fail "self-ignoring .gitignore created at .superpowers/sdd/ with '*'"
fi fi
printf 'x\n' > "$repo/.superpowers/sdd/artifact.md" printf 'x\n' > "$dir_a/artifact.md"
local status local status
status="$(cd "$repo" && git status --porcelain)" 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" pass "workspace invisible to git status"
else else
fail "workspace invisible to git status" fail "workspace invisible to git status"
@@ -64,67 +111,78 @@ main() {
( cd "$repo" && git add -A ) ( cd "$repo" && git add -A )
local staged local staged
staged="$(cd "$repo" && git diff --cached --name-only)" 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" pass "git add -A does not stage the workspace"
else else
fail "git add -A does not stage the workspace" fail "git add -A does not stage the workspace"
echo " staged: $staged" echo " staged: $staged"
fi fi
cat > "$repo/plan.md" <<'PLAN' # --- task-brief lands in its plan's directory ---
# Plan
## Task 1: First thing
Do the first thing.
PLAN
local brief_out brief_path 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')" brief_path="$(printf '%s\n' "$brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')"
case "$brief_path" in if [[ "$brief_path" == "$repo/.superpowers/sdd/plan-a/task-1-brief.md" ]]; then
"$repo/.superpowers/sdd/"*) pass "task-brief writes its brief under the workspace" ;; pass "task-brief writes its brief under the plan's workspace"
*) else
fail "task-brief writes its brief under the workspace" fail "task-brief writes its brief under the plan's workspace"
echo " got: $brief_path" echo " got: $brief_path"
;; fi
esac
# --- 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) local git_id=(-c user.email=t@example.com -c user.name=t -c commit.gpgsign=false)
( cd "$repo" \ ( cd "$repo" \
&& git add plan.md \
&& git "${git_id[@]}" commit -qm c1 \ && git "${git_id[@]}" commit -qm c1 \
&& printf 'y\n' > f && git add f \ && printf 'y\n' > f && git add f \
&& git "${git_id[@]}" commit -qm c2 ) && git "${git_id[@]}" commit -qm c2 )
local rp_out rp_path 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')" rp_path="$(printf '%s\n' "$rp_out" | sed -n 's/^wrote \(.*\): [0-9].*$/\1/p')"
case "$rp_path" in 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" echo " got: $rp_path"
;; ;;
esac 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 --- # --- Worktree isolation: a linked worktree resolves its own workspace ---
local wt="$TEST_ROOT/wt" local wt="$TEST_ROOT/wt"
( cd "$repo" && git worktree add -q "$wt" -b wt-feature ) ( cd "$repo" && git worktree add -q "$wt" -b wt-feature )
local wt_root wt_dir local wt_root wt_dir
wt_root="$(cd "$wt" && git rev-parse --show-toplevel)" wt_root="$(cd "$wt" && git rev-parse --show-toplevel)"
wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace")" wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace" plan-a.md)"
if [[ "$wt_dir" == "$wt_root/.superpowers/sdd" && "$wt_dir" != "$dir" ]]; then if [[ "$wt_dir" == "$wt_root/.superpowers/sdd/plan-a" && "$wt_dir" != "$dir_a" ]]; then
pass "linked worktree resolves its own distinct workspace" pass "linked worktree resolves its own distinct workspace"
else else
fail "linked worktree resolves its own distinct workspace" fail "linked worktree resolves its own distinct workspace"
echo " main: $dir" echo " main: $dir_a"
echo " wt: $wt_dir" echo " wt: $wt_dir"
fi fi
printf 'y\n' > "$wt/.superpowers/sdd/artifact.md" printf 'y\n' > "$wt_dir/artifact.md"
local wt_status local wt_status
wt_status="$(cd "$wt" && git status --porcelain)" wt_status="$(cd "$wt" && git status --porcelain)"
if [[ -z "$wt_status" ]]; then if [[ "$wt_status" != *".superpowers"* ]]; then
pass "worktree workspace invisible to git status" pass "worktree workspace invisible to git status"
else else
fail "worktree workspace invisible to git status" fail "worktree workspace invisible to git status"