mirror of
https://github.com/obra/superpowers
synced 2026-07-10 12:44:29 +00:00
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:
@@ -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: <repo-root>/.superpowers/sdd/review-<base7>..<head7>.diff
|
||||
# Usage: review-package PLAN_FILE BASE HEAD [OUTFILE]
|
||||
# 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).
|
||||
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
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
# 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
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
# through the controller's context.
|
||||
#
|
||||
# Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE]
|
||||
# Default OUTFILE: <repo-root>/.superpowers/sdd/task-<N>-brief.md
|
||||
# (per worktree; concurrent runs in the same working tree share it).
|
||||
# Default OUTFILE: <repo-root>/.superpowers/sdd/<plan-basename>/task-<N>-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
|
||||
|
||||
|
||||
@@ -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 <repo-root>/.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 <repo-root>/.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 <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
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user