Просмотр исходного кода

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.
Jesse Vincent 2 месяцев назад
Родитель
Сommit
d5ea3a7e60

+ 11 - 9
skills/subagent-driven-development/scripts/review-package

@@ -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]
-# 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).
 # (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
-  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
   exit 2
 fi
 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 "$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
-  out=$3
+if [ $# -eq 4 ]; then
+  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
 
 

+ 26 - 8
skills/subagent-driven-development/scripts/sdd-workspace

@@ -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
-# 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
 # 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
-# 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
 # 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

+ 4 - 3
skills/subagent-driven-development/scripts/task-brief

@@ -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
-# (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
 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
 
 

+ 96 - 38
tests/claude-code/test-sdd-workspace.sh

@@ -1,6 +1,7 @@
 #!/usr/bin/env bash
 #!/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
 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
-    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 "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
     else
-        fail "prints <repo-root>/.superpowers/sdd"
-        echo "    got: $dir"
+        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'
-# Plan
-
-## Task 1: First thing
-
-Do the first thing.
-PLAN
-
+    # --- task-brief lands in its plan's directory ---
     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
-        "$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)
     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")"
-    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"
         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"