diff --git a/README.md b/README.md index 732742b..357608b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Skills live in [`ai/skills/`](ai/skills). The installer symlinks the same direct | [`review-fix-cycle`](ai/skills/review-fix-cycle) | One review, fix, simplify, clean comments, commit iteration. | | [`sessions`](ai/skills/sessions) | Show every live Claude session and background agent grouped by what it needs next, with a one-line next step. `--act` offers to tell the ones that can continue to continue. | | [`simplify`](ai/codex/skills/simplify) | Simplify recently changed code for clarity and maintainability without changing behavior. Codex only; Claude bundles its own. | -| [`spawn-task`](ai/skills/spawn-task) | Start a task in a new tree-me worktree pinned in Supacode, with its own Claude session in a background tab, working from a brief written out of the current conversation. | +| [`spawn-task`](ai/skills/spawn-task) | Start a task in a new tree-me worktree in Supacode, with its own Claude session in a background tab, working from a brief written out of the current conversation. | | [`sprint-planning`](ai/skills/sprint-planning) | Bi-weekly sprint planning updates for the Feature Flags Platform team. | | [`squash`](ai/skills/squash) | Squash each contributor's run of contiguous commits on the branch into one, preserving authorship. | | [`standup`](ai/skills/standup) | Generate standup notes from your recent GitHub PR activity. | diff --git a/ai/skills/spawn-task/SKILL.md b/ai/skills/spawn-task/SKILL.md index 3c53880..d76bd35 100644 --- a/ai/skills/spawn-task/SKILL.md +++ b/ai/skills/spawn-task/SKILL.md @@ -1,12 +1,12 @@ --- name: spawn-task -description: Start a task in a new git worktree with its own Claude session, pinned in Supacode. Creates the worktree with tree-me, writes a self-contained brief from this conversation, and opens a background Supacode tab that runs claude on the brief. Use when the user asks to spin work off into a new worktree or session, says "use tree-me to create a worktree", "attach a Supacode session", "start a pinned session on this", or runs /spawn-task. +description: Start a task in a new git worktree with its own Claude session in Supacode. Creates the worktree with tree-me, writes a self-contained brief from this conversation, and opens a background Supacode tab that runs claude on the brief. Use when the user asks to spin work off into a new worktree or session, says "use tree-me to create a worktree", "attach a Supacode session", "start a session on this", or runs /spawn-task. argument-hint: "[] [--stack] [--existing ] [--color ] [--plan|--go]" --- # /spawn-task -Starts a separate Claude session on a task. The session runs in its own worktree, which Supacode pins, in a background tab the user can switch to. This session stays where it is: do not enter the new worktree, and do not do the task here. +Starts a separate Claude session on a task. The session runs in its own worktree, in a background tab the user can switch to. Supacode pins that worktree only when the worktree this session runs in is pinned. This session stays where it is: do not enter the new worktree, and do not do the task here. `scripts/spawn-task.sh` does the mechanical steps. This skill decides what goes into them: which repo, which branch and base, and what the brief says. diff --git a/ai/skills/spawn-task/scripts/spawn-task.sh b/ai/skills/spawn-task/scripts/spawn-task.sh index 7f957c3..686c576 100755 --- a/ai/skills/spawn-task/scripts/spawn-task.sh +++ b/ai/skills/spawn-task/scripts/spawn-task.sh @@ -12,8 +12,9 @@ # --go Start the session with /go --plan-file .notes/brief.md # # Creates the worktree with tree-me, copies the brief to /.notes/brief.md, -# makes Supacode list and pin the worktree, and opens a background tab that runs -# claude with the brief as its first prompt. +# makes Supacode list the worktree, and opens a background tab that runs claude +# with the brief as its first prompt. Supacode pins the new worktree only when +# the worktree the caller runs in is pinned. # # --branch refuses a branch that already exists locally or on origin, and refuses # when origin cannot be reached. Without --base, the branch starts at the remote @@ -134,12 +135,12 @@ fi # Supacode names a worktree by its percent-encoded path with a trailing slash. It # may record the path with symlinks resolved, so both spellings are accepted. encode() { jq -rn --arg p "$1/" '$p | @uri'; } -ids=("$(encode "$worktree")" "$(encode "$(cd "$worktree" && pwd -P)")") -listed_id() { # list_filter... - local listing id +listed_id() { # path list_filter... + local path="$1" listing id + shift listing=$(supacode worktree list "$@" 2>/dev/null) || return 1 - for id in "${ids[@]}"; do + for id in "$(encode "$path")" "$(encode "$(cd "$path" && pwd -P)")"; do if grep -qxF -- "$id" <<<"$listing"; then echo "$id" return 0 @@ -149,21 +150,23 @@ listed_id() { # list_filter... } # Opening the repo does not bring back an archived worktree. -if ! worktree_id=$(listed_id --not-archived); then - if archived_id=$(listed_id --status archived); then +if ! worktree_id=$(listed_id "$worktree" --not-archived); then + if archived_id=$(listed_id "$worktree" --status archived); then supacode worktree unarchive -w "$archived_id" --background >/dev/null || die "could not unarchive the worktree in Supacode." else supacode repo open "$worktree" >/dev/null || die "\`supacode repo open $worktree\` failed." fi deadline=$((SECONDS + ${SPAWN_TASK_LIST_TIMEOUT:-30})) - until worktree_id=$(listed_id --not-archived); do + until worktree_id=$(listed_id "$worktree" --not-archived); do ((SECONDS < deadline)) || die "Supacode did not list the worktree." sleep 1 done fi -supacode worktree pin -w "$worktree_id" --background >/dev/null || die "could not pin the worktree in Supacode." +if [ -n "$caller_root" ] && listed_id "$caller_root" --status pinned >/dev/null; then + supacode worktree pin -w "$worktree_id" --background >/dev/null || die "could not pin the worktree in Supacode." +fi if [ -n "$color" ]; then supacode worktree appearance -w "$worktree_id" --color "$color" >/dev/null || die "could not set the worktree's tint to '$color'." diff --git a/ai/skills/spawn-task/scripts/tests/test-spawn-task.sh b/ai/skills/spawn-task/scripts/tests/test-spawn-task.sh index 48b2033..9d6b093 100755 --- a/ai/skills/spawn-task/scripts/tests/test-spawn-task.sh +++ b/ai/skills/spawn-task/scripts/tests/test-spawn-task.sh @@ -71,6 +71,7 @@ chmod +x "${SHIM_BIN}/claude" # fail subcommands, such as "socket" or "tab new", that exit 1 # listed the IDs that `worktree list --not-archived` prints # archived the IDs that `worktree list --status archived` prints +# pinned the IDs that `worktree list --status pinned` prints # list-delay how many lists after `repo open` still leave its worktree out # never-list present when `repo open` never adds its worktree # `repo open` adds the ID of the path it gets. `worktree unarchive` moves its -w @@ -100,6 +101,7 @@ case "$1 ${2-}" in fi case " $* " in *" --status archived "*) cat "${SHIM_DIR}/archived" ;; + *" --status pinned "*) cat "${SHIM_DIR}/pinned" ;; *" --not-archived "*) cat "${SHIM_DIR}/listed" ;; *) cat "${SHIM_DIR}/listed" "${SHIM_DIR}/archived" ;; esac @@ -244,6 +246,7 @@ begin_case() { # name [worktree_root_name] mkdir -p "${SHIM_DIR}/calls" "$SHIM_CLAUDE_DIR" : > "${SHIM_DIR}/listed" : > "${SHIM_DIR}/archived" + : > "${SHIM_DIR}/pinned" printf '0\n' > "${SHIM_DIR}/list-delay" printf '%s\n' "$DEFAULT_BRIEF" > "$BRIEF" build_repos || fail "${CASE_NAME}: the fixture repos build" @@ -489,8 +492,7 @@ check_eq "${CASE_NAME}: the branch starts at origin's latest main" "$(branch_sha check "${CASE_NAME}: the brief is copied to .notes/brief.md" cmp -s "$BRIEF" "${WT}/.notes/brief.md" check_eq "${CASE_NAME}: repo open runs once" "$(call_count "repo open")" "1" check_eq "${CASE_NAME}: repo open gets the worktree path" "$(call_last_arg "repo open")" "$WT" -check_eq "${CASE_NAME}: pin gets the worktree ID" "$(call_option "worktree pin" -w --worktree)" "$ID" -check "${CASE_NAME}: pin runs in the background" call_has "worktree pin" --background +check_eq "${CASE_NAME}: a caller outside any worktree means no pin" "$(call_count "worktree pin")" "0" check_eq "${CASE_NAME}: no --color means no appearance call" "$(call_count "worktree appearance")" "0" check_eq "${CASE_NAME}: tab new runs once" "$(call_count "tab new")" "1" check_eq "${CASE_NAME}: tab new gets the worktree ID" "$(call_option "tab new" -w --worktree)" "$ID" @@ -498,7 +500,7 @@ check "${CASE_NAME}: tab new runs in the background" call_has "tab new" --backgr check_eq "${CASE_NAME}: the tab title is the worktree's last path component" \ "$(call_option "tab new" --title)" "happy-path" check_eq "${CASE_NAME}: supacode calls run in order" "$(call_order)" \ - "socket, worktree list, repo open, worktree list, worktree pin, tab new" + "socket, worktree list, repo open, worktree list, tab new" check_eq "${CASE_NAME}: stdout names the worktree" "$(out_value worktree)" "$WT" check_eq "${CASE_NAME}: stdout names the worktree ID" "$(out_value worktree_id)" "$ID" check_eq "${CASE_NAME}: stdout names the tab" "$(out_value tab)" "$TAB_UUID" @@ -625,6 +627,38 @@ spawn --branch titled --title "my task" check_eq "${CASE_NAME}: exits 0" "$STATUS" "0" check_eq "${CASE_NAME}: tab new gets the title" "$(call_option "tab new" --title)" "my task" +# ── Pinning ────────────────────────────────────────────────────────────────── + +begin_case "a caller in a pinned worktree" +git -C "$REPO" worktree add -q -b pinned-caller "${CASE_DIR}/pinned-caller" +CALLER=$(worktree_path_for pinned-caller) +mkdir -p "${CALLER}/src" +printf '%s\n' "$(worktree_id "$CALLER")" > "${SHIM_DIR}/pinned" +WT="${WORKTREE_ROOT}/widget/from-pinned" +ID=$(worktree_id "$WT") +CASE_CWD="${CALLER}/src" spawn --branch from-pinned +check_eq "${CASE_NAME}: exits 0" "$STATUS" "0" +check_eq "${CASE_NAME}: pin runs once" "$(call_count "worktree pin")" "1" +check_eq "${CASE_NAME}: pin gets the new worktree's ID" "$(call_option "worktree pin" -w --worktree)" "$ID" +check "${CASE_NAME}: pin runs in the background" call_has "worktree pin" --background +check_eq "${CASE_NAME}: the tab opens after the pin" "$(call_order)" \ + "socket, worktree list, repo open, worktree list, worktree pin, tab new" + +begin_case "a caller in an unpinned worktree" +git -C "$REPO" worktree add -q -b unpinned-caller "${CASE_DIR}/unpinned-caller" +CALLER=$(worktree_path_for unpinned-caller) +printf '%s\n' "$(worktree_id "${CALLER}/nested")" "$(worktree_id "${CASE_DIR}/other")" > "${SHIM_DIR}/pinned" +CASE_CWD="$CALLER" spawn --branch from-unpinned +check_eq "${CASE_NAME}: exits 0" "$STATUS" "0" +check_eq "${CASE_NAME}: pin does not run" "$(call_count "worktree pin")" "0" +check_eq "${CASE_NAME}: tab new runs once" "$(call_count "tab new")" "1" + +begin_case "a caller in the main checkout" +printf '%s\n' "$(worktree_id "${CASE_DIR}/other")" > "${SHIM_DIR}/pinned" +CASE_CWD="$REPO" spawn --branch from-main +check_eq "${CASE_NAME}: exits 0" "$STATUS" "0" +check_eq "${CASE_NAME}: pin does not run" "$(call_count "worktree pin")" "0" + # ── Supacode listing the worktree ──────────────────────────────────────────── begin_case "a worktree Supacode already lists" @@ -655,7 +689,7 @@ check_eq "${CASE_NAME}: unarchive gets the worktree ID" "$(call_option "worktree check "${CASE_NAME}: unarchive runs in the background" call_has "worktree unarchive" --background check_eq "${CASE_NAME}: repo open does not run" "$(call_count "repo open")" "0" check_eq "${CASE_NAME}: the tab opens only after the unarchive" "$(call_order)" \ - "socket, worktree list, worktree unarchive, worktree list, worktree pin, tab new" + "socket, worktree list, worktree unarchive, worktree list, tab new" check_eq "${CASE_NAME}: tab new gets the worktree ID" "$(call_option "tab new" -w --worktree)" "$ID" begin_case "a worktree Supacode lists after two polls"