From 4ab96bd699db721650e4b48619d4939d50fa41de Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 31 Mar 2026 17:13:22 +0800 Subject: [PATCH 01/16] fix: harden tmux launcher defaults and prompts --- scripts/lib/squad-tmux-launcher-helpers.sh | 35 +++++++++++++++- scripts/squad-tmux-launch.sh | 40 ++++++++++-------- tests/squad_tmux_launcher_helpers_test.sh | 13 ++++++ tests/squad_tmux_launcher_smoke.sh | 47 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 19 deletions(-) diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh index 862769c..561364b 100644 --- a/scripts/lib/squad-tmux-launcher-helpers.sh +++ b/scripts/lib/squad-tmux-launcher-helpers.sh @@ -107,14 +107,30 @@ slugify_path_component() { printf '%s' "${value:-worktree}" } +copy_array_or_empty() { + local target_name="$1" + local source_name="$2" + + eval "$target_name=()" + set +u + eval 'if ((${#'"$source_name"'[@]} > 0)); then '"$target_name"'=("${'"$source_name"'[@]}"); fi' + set -u +} + +repo_worktree_location_slug() { + local repo_root="$1" + local normalized="${repo_root#/}" + printf '%s' "$(slugify_path_component "$normalized")" +} + expand_path_from_base() { local path="$1" local base_dir="$2" if [[ "$path" == "~" ]]; then path="$HOME" - elif [[ "$path" == "~/"* ]]; then - path="$HOME/${path#~/}" + elif [[ "${path:0:2}" == "~/" ]]; then + path="$HOME/${path:2}" elif [[ "$path" != /* ]]; then path="$base_dir/$path" fi @@ -204,6 +220,8 @@ ensure_git_worktree() { local dry_run="${5:-0}" local existing_branch_path="" local current_branch="" + local requested_common_dir="" + local repo_common_dir="" if [[ -z "$branch_name" ]]; then echo "Error: worktree branch name is required" >&2 @@ -217,6 +235,19 @@ ensure_git_worktree() { fi if [[ -f "$requested_path/.git" || -d "$requested_path/.git" ]]; then + requested_common_dir="$(git -C "$requested_path" rev-parse --git-common-dir 2>/dev/null || true)" + repo_common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -n "$requested_common_dir" && "$requested_common_dir" != /* ]]; then + requested_common_dir="$requested_path/$requested_common_dir" + fi + if [[ -n "$repo_common_dir" && "$repo_common_dir" != /* ]]; then + repo_common_dir="$repo_root/$repo_common_dir" + fi + if [[ -n "$requested_common_dir" && -n "$repo_common_dir" && "$requested_common_dir" != "$repo_common_dir" ]]; then + echo "Error: requested worktree path belongs to a different repository: $requested_path" >&2 + return 1 + fi + current_branch="$(git -C "$requested_path" branch --show-current 2>/dev/null || true)" if [[ -n "$current_branch" && "$current_branch" != "$branch_name" ]]; then echo "Error: requested worktree path already exists on branch '$current_branch': $requested_path" >&2 diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 36eedf4..5af0d7d 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -34,7 +34,7 @@ Project config: Worktree config: /.squad/launcher.yaml -> workspace.worktree Default location when enabled without an explicit path: - ~/.local/share/squad/worktrees/ + ~/.local/share/squad/worktrees/ Generated files: /.squad/quickstart/generated-*.md @@ -320,19 +320,24 @@ EOF echo "## Inspector Brief" echo cat "$inspector_source" - else + echo + fi + + cat <<'EOF' +## Task Brief +EOF + echo + cat "$task_file" + + if [[ ! -f "$inspector_source" ]]; then cat <<'EOF' -## Inspector Brief -Review the manager / worker output against the task brief and confirm: +## Review Checklist +Use the task brief above to confirm: - the implementation satisfies the goal rather than only making tests pass - no behavior regressions or compatibility breaks were introduced - README, configuration guidance, and diagnostics still match the implementation - tests genuinely cover the change objective - -## Task Brief EOF - echo - cat "$task_file" fi } >"$output_path" } @@ -537,13 +542,11 @@ if ! [[ "$workers" =~ ^[0-9]+$ ]] || (( workers < 1 )); then exit 1 fi -set +u -claude_args=("${CFG_CLAUDE_ARGS[@]}") -init_args=("${CFG_INIT_ARGS[@]}") -focus_files=("${CFG_FOCUS_FILES[@]}") -focus_docs=("${CFG_FOCUS_DOCS[@]}") -constraints=("${CFG_CONSTRAINTS[@]}") -set -u +copy_array_or_empty claude_args CFG_CLAUDE_ARGS +copy_array_or_empty init_args CFG_INIT_ARGS +copy_array_or_empty focus_files CFG_FOCUS_FILES +copy_array_or_empty focus_docs CFG_FOCUS_DOCS +copy_array_or_empty constraints CFG_CONSTRAINTS if (( ${#init_args[@]} == 0 )); then init_args=(--refresh-roles) @@ -572,7 +575,7 @@ if (( worktree_enabled == 1 )); then project_relative_path="${source_project_dir#$git_repo_root/}" fi - default_worktree_location="~/.local/share/squad/worktrees/$project_name" + default_worktree_location="~/.local/share/squad/worktrees/$(repo_worktree_location_slug "$git_repo_root")" worktree_location="${worktree_location_override:-${CFG_WORKTREE_LOCATION:-$default_worktree_location}}" worktree_branch="${worktree_branch_override:-${CFG_WORKTREE_BRANCH:-}}" worktree_base_ref="${worktree_base_ref_override:-${CFG_WORKTREE_BASE_REF:-HEAD}}" @@ -611,7 +614,10 @@ if (( worktree_enabled == 1 )); then fi mkdir -p "$quickstart_dir" -claude_launch_command="$(shell_join "$claude_command" "${claude_args[@]}")" +claude_launch_command="$(shell_join "$claude_command")" +if (( ${#claude_args[@]} > 0 )); then + claude_launch_command="$(shell_join "$claude_command" "${claude_args[@]}")" +fi inspector_prompt_source="$source_project_dir/.squad/prompts/inspector.md" prompt_file="$quickstart_dir/generated-manager.prompt.md" diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh index 74d5e3c..fe9f4b1 100644 --- a/tests/squad_tmux_launcher_helpers_test.sh +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -48,6 +48,7 @@ test "$resolved_root" = "$repo_dir/.worktrees" requested_path="$(resolve_worktree_path "$repo_dir" ".worktrees" "mcp-upgrade")" test "$requested_path" = "$repo_dir/.worktrees/mcp-upgrade" +test "$(repo_worktree_location_slug "$repo_dir")" != "$(basename "$repo_dir")" ! ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" echo ".worktrees/" >>"$repo_dir/.gitignore" @@ -61,6 +62,18 @@ test "$(git -C "$requested_path" branch --show-current)" = "feat/mcp-upgrade" reused_path="$(ensure_git_worktree "$repo_dir" "$repo_dir/.worktrees/other-path" "feat/mcp-upgrade" "HEAD" 0)" test "$reused_path" = "$requested_path" +other_repo_dir="$tmpdir/other-repo" +mkdir -p "$other_repo_dir" +git -C "$tmpdir" init -b main other-repo >/dev/null +other_repo_dir="$(cd "$other_repo_dir" && pwd -P)" +git -C "$other_repo_dir" config user.email "codex@example.com" +git -C "$other_repo_dir" config user.name "Codex" +echo "world" >"$other_repo_dir/README.md" +git -C "$other_repo_dir" add README.md +git -C "$other_repo_dir" commit -m "init" >/dev/null + +! ensure_git_worktree "$other_repo_dir" "$requested_path" "feat/mcp-upgrade" "HEAD" 0 + planned_path="$(ensure_git_worktree "$repo_dir" "$repo_dir/.worktrees/dry-run-path" "feat/dry-run" "HEAD" 1)" test "$planned_path" = "$repo_dir/.worktrees/dry-run-path" test ! -d "$repo_dir/.worktrees/dry-run-path" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 4d665f1..a3a9fa7 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -93,6 +93,7 @@ test -f "$map_file" grep -q "Improve Feishu support for Claude Code" "$prompt_file" grep -q "README, path handling, and Claude install compatibility" "$inspector_prompt_file" +grep -q "Improve Feishu support for Claude Code" "$inspector_prompt_file" grep -q "src/platforms/feishuPlatform.js" "$prompt_file" grep -q "Keep Codex runtime behavior unchanged" "$prompt_file" grep -q "demo-project-squad" "$summary_file" @@ -106,4 +107,50 @@ grep -q "manager" "$map_file" grep -q "worker-2" "$map_file" grep -q "inspector" "$map_file" +same_name_roots=() +for org in org-a org-b; do + repo_dir="$tmpdir/$org/demo" + mkdir -p "$repo_dir/.squad" + git -C "$tmpdir/$org" init -b main demo >/dev/null + git -C "$repo_dir" config user.email "codex@example.com" + git -C "$repo_dir" config user.name "Codex" + echo "seed" >"$repo_dir/README.md" + git -C "$repo_dir" add README.md + git -C "$repo_dir" commit -m "seed" >/dev/null + + cat >"$repo_dir/.squad/launcher.yaml" <<'EOF' +workspace: + worktree: + enabled: true + branch: feat/smoke +EOF + + cat >"$repo_dir/.squad/run-task.md" <<'EOF' +# Task +Minimal task brief +EOF + + output="$(HOME="$tmpdir/home" bash "$launcher" "$repo_dir" --dry-run --no-setup --no-attach)" + same_name_roots+=("$(printf '%s\n' "$output" | awk -F': ' '/^Workspace root: /{print $2; exit}')") + test -f "$repo_dir/.squad/quickstart/feat-smoke/generated-manager.prompt.md" +done + +case "${same_name_roots[0]}" in + "$tmpdir/home/.local/share/squad/worktrees/"*) ;; + *) + echo "Expected worktree path to expand under HOME, got: ${same_name_roots[0]}" >&2 + exit 1 + ;; +esac + +case "${same_name_roots[1]}" in + "$tmpdir/home/.local/share/squad/worktrees/"*) ;; + *) + echo "Expected worktree path to expand under HOME, got: ${same_name_roots[1]}" >&2 + exit 1 + ;; +esac + +test "${same_name_roots[0]}" != "${same_name_roots[1]}" + echo "PASS: generic launcher dry-run generated expected files" From 03fa0fcc581f84da9bee8080f8d482c90f15d7f4 Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 31 Mar 2026 18:09:22 +0800 Subject: [PATCH 02/16] fix: harden launcher config and helper handling --- scripts/lib/squad-tmux-launcher-helpers.sh | 33 +++++++++++++--------- scripts/squad-tmux-launch.sh | 27 ++++++++++++++---- tests/squad_tmux_launcher_helpers_test.sh | 14 +++++++++ tests/squad_tmux_launcher_smoke.sh | 24 ++++++++++++++++ 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh index 561364b..e128a23 100644 --- a/scripts/lib/squad-tmux-launcher-helpers.sh +++ b/scripts/lib/squad-tmux-launcher-helpers.sh @@ -30,14 +30,14 @@ pane_command_candidates() { local existing="" local found=0 [[ -n "$candidate" ]] || return 0 - set +u - for existing in "${candidates[@]}"; do - if [[ "$existing" == "$candidate" ]]; then - found=1 - break - fi - done - set -u + if (( ${#candidates[@]} > 0 )); then + for existing in "${candidates[@]}"; do + if [[ "$existing" == "$candidate" ]]; then + found=1 + break + fi + done + fi if (( found == 1 )); then return 0 fi @@ -82,9 +82,9 @@ pane_command_candidates() { fi fi - set +u - printf '%s\n' "${candidates[@]}" - set -u + if (( ${#candidates[@]} > 0 )); then + printf '%s\n' "${candidates[@]}" + fi } is_truthy() { @@ -112,9 +112,11 @@ copy_array_or_empty() { local source_name="$2" eval "$target_name=()" - set +u + if ! declare -p "$source_name" >/dev/null 2>&1; then + return 0 + fi + eval 'if ((${#'"$source_name"'[@]} > 0)); then '"$target_name"'=("${'"$source_name"'[@]}"); fi' - set -u } repo_worktree_location_slug() { @@ -160,6 +162,11 @@ resolve_worktree_path() { path_is_within() { local path="$1" local base="$2" + case "$path" in + */../*|*/./*|../*|./*|*/..|*/.) + return 1 + ;; + esac [[ "$path" == "$base" || "$path" == "$base"/* ]] } diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 5af0d7d..f818eef 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -144,7 +144,17 @@ require "yaml" require "shellwords" file = ARGV[0] -data = File.exist?(file) ? (YAML.load_file(file) || {}) : {} +data = + if File.exist?(file) + YAML.safe_load( + File.read(file), + permitted_classes: [], + permitted_symbols: [], + aliases: false, + ) || {} + else + {} + end def lookup(hash, *keys) keys.reduce(hash) do |acc, key| @@ -522,6 +532,11 @@ project_name="${CFG_PROJECT_NAME:-$(basename "$project_dir")}" session_name="${session_name_override:-${CFG_SESSION_NAME:-${project_name}-squad}}" session_name="$(normalize_session_name "$session_name")" claude_command="${CFG_CLAUDE_COMMAND:-claude}" +if [[ "$claude_command" == "~" ]]; then + claude_command="$HOME" +elif [[ "${claude_command:0:2}" == "~/" ]]; then + claude_command="$HOME/${claude_command:2}" +fi manager_role="${CFG_MANAGER_ROLE:-manager}" worker_role="${CFG_WORKER_ROLE:-worker}" inspector_role="${CFG_INSPECTOR_ROLE:-inspector}" @@ -660,13 +675,13 @@ require_cmd "$claude_command" require_cmd tmux if (( no_setup == 0 )); then - echo "[1/5] Refreshing Claude /squad command" + echo "[1/6] Refreshing Claude /squad command" squad setup claude else - echo "[1/5] Skipping squad setup claude (--no-setup)" + echo "[1/6] Skipping squad setup claude (--no-setup)" fi -echo "[2/5] Initializing squad workspace" +echo "[2/6] Initializing squad workspace" ( cd "$workspace_dir" squad init "${init_args[@]}" @@ -679,7 +694,7 @@ if tmux has-session -t "$session_name" 2>/dev/null; then exit 1 fi - echo "[3/5] Reusing existing tmux session: $session_name" + echo "[3/6] Reusing existing tmux session: $session_name" echo "Generated prompt: $prompt_file" if (( no_attach == 0 )); then if [[ -n "${TMUX:-}" ]]; then @@ -691,7 +706,7 @@ if tmux has-session -t "$session_name" 2>/dev/null; then exit 0 fi -echo "[3/5] Creating tmux session" +echo "[3/6] Creating tmux session" start_cmd="cd $(shell_escape "$workspace_dir") && exec $claude_launch_command" tmux new-session -d -s "$session_name" -n squad "$start_cmd" for ((i = 1; i < ${#pane_labels[@]}; i++)); do diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh index fe9f4b1..d96f499 100644 --- a/tests/squad_tmux_launcher_helpers_test.sh +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -27,6 +27,19 @@ printf '%s\n' "$candidates" | grep -qx 'claude' printf '%s\n' "$candidates" | grep -qx 'cli.js' printf '%s\n' "$candidates" | grep -qx 'node' +( + set +u + before_nounset="$(set -o | awk '$1=="nounset" { print $2 }')" + pane_command_candidates "$tmpdir/bin/claude" >/dev/null + empty_source=() + copy_array_or_empty copied_empty empty_source + copy_array_or_empty copied_missing missing_source + after_nounset="$(set -o | awk '$1=="nounset" { print $2 }')" + test "$before_nounset" = "$after_nounset" + test "${#copied_empty[@]}" -eq 0 + test "${#copied_missing[@]}" -eq 0 +) + is_truthy true ! is_truthy false @@ -49,6 +62,7 @@ test "$resolved_root" = "$repo_dir/.worktrees" requested_path="$(resolve_worktree_path "$repo_dir" ".worktrees" "mcp-upgrade")" test "$requested_path" = "$repo_dir/.worktrees/mcp-upgrade" test "$(repo_worktree_location_slug "$repo_dir")" != "$(basename "$repo_dir")" +! path_is_within "$repo_dir/.worktrees/../outside" "$repo_dir" ! ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" echo ".worktrees/" >>"$repo_dir/.gitignore" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index a3a9fa7..2674052 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -153,4 +153,28 @@ esac test "${same_name_roots[0]}" != "${same_name_roots[1]}" +tilde_repo="$tmpdir/tilde-repo" +mkdir -p "$tilde_repo/.squad" +git -C "$tmpdir" init -b main tilde-repo >/dev/null +git -C "$tilde_repo" config user.email "codex@example.com" +git -C "$tilde_repo" config user.name "Codex" +echo "tilde" >"$tilde_repo/README.md" +git -C "$tilde_repo" add README.md +git -C "$tilde_repo" commit -m "seed" >/dev/null + +cat >"$tilde_repo/.squad/launcher.yaml" <<'EOF' +runtime: + claude_command: ~/bin/claude + claude_args: + - --dangerously-skip-permissions +EOF + +cat >"$tilde_repo/.squad/run-task.md" <<'EOF' +# Task +Check tilde command expansion +EOF + +HOME="$tmpdir/home" bash "$launcher" "$tilde_repo" --dry-run --no-setup --no-attach >/dev/null +grep -q "$tmpdir/home/bin/claude --dangerously-skip-permissions" "$tilde_repo/.squad/quickstart/generated-run-summary.md" + echo "PASS: generic launcher dry-run generated expected files" From aa86ee69508afe8d1abfd4846f74cb7910c2900f Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 31 Mar 2026 18:42:40 +0800 Subject: [PATCH 03/16] feat: support superpowers task docs in tmux launcher --- scripts/squad-tmux-launch.sh | 163 ++++++++++++++++++++++++++--- tests/squad_tmux_launcher_smoke.sh | 54 ++++++++++ 2 files changed, 204 insertions(+), 13 deletions(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index f818eef..11a6066 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -27,6 +27,8 @@ Options: Task source priority: 1. --task-file 2. /.squad/run-task.md + 3. latest /docs/superpowers/plans/*-implementation.md + plus the newest matching docs/superpowers/specs/*-design.md Project config: /.squad/launcher.yaml @@ -199,6 +201,96 @@ RUBY )" } +latest_matching_doc() { + local dir="$1" + local pattern="$2" + [[ -d "$dir" ]] || return 1 + + find "$dir" -maxdepth 1 -type f -name "$pattern" | LC_ALL=C sort | tail -n 1 +} + +superpowers_topic_slug() { + local file_path="$1" + local name="" + name="$(basename "$file_path")" + + case "$name" in + ????-??-??-*-implementation.md) + name="${name#????-??-??-}" + printf '%s' "${name%-implementation.md}" + ;; + ????-??-??-*-design.md) + name="${name#????-??-??-}" + printf '%s' "${name%-design.md}" + ;; + *) + return 1 + ;; + esac +} + +matching_superpowers_spec() { + local plan_file="$1" + local specs_dir="$2" + local topic_slug="" + topic_slug="$(superpowers_topic_slug "$plan_file")" || return 1 + latest_matching_doc "$specs_dir" "????-??-??-${topic_slug}-design.md" +} + +resolve_task_sources() { + local project_dir="$1" + local task_override="$2" + local default_task_file="$3" + local repo_root="$4" + local candidate="" + local root="" + local -a search_roots=() + + task_source_kind="task-brief" + task_source_path="" + task_supporting_spec_path="" + task_source_root="" + + if [[ -n "$task_override" ]]; then + task_source_path="$task_override" + if [[ ! -f "$task_source_path" ]]; then + echo "Error: task brief not found: $task_source_path" >&2 + echo "Provide --task-file or create $default_task_file" >&2 + exit 1 + fi + elif [[ -f "$default_task_file" ]]; then + task_source_path="$default_task_file" + else + search_roots=("$project_dir") + if [[ -n "$repo_root" && "$repo_root" != "$project_dir" ]]; then + search_roots+=("$repo_root") + fi + + for root in "${search_roots[@]}"; do + candidate="$(latest_matching_doc "$root/docs/superpowers/plans" "????-??-??-*-implementation.md" || true)" + if [[ -n "$candidate" ]]; then + task_source_kind="superpowers-plan" + task_source_path="$candidate" + task_source_root="$root/docs/superpowers" + task_supporting_spec_path="$(matching_superpowers_spec "$candidate" "$root/docs/superpowers/specs" || true)" + break + fi + done + + if [[ -z "$task_source_path" ]]; then + echo "Error: task brief not found: $default_task_file" >&2 + echo "Provide --task-file , create $default_task_file, or add docs/superpowers/plans/*-implementation.md" >&2 + exit 1 + fi + fi + + if [[ "$task_source_kind" == "task-brief" && "$(basename "$task_source_path")" == ????-??-??-*-implementation.md ]]; then + task_source_kind="superpowers-plan" + task_source_root="$(cd "$(dirname "$task_source_path")/.." && pwd -P 2>/dev/null || true)" + task_supporting_spec_path="$(matching_superpowers_spec "$task_source_path" "$(dirname "$task_source_path")/../specs" || true)" + fi +} + build_manager_prompt() { local output_path="$1" local project_name="$2" @@ -255,6 +347,21 @@ build_manager_prompt() { echo fi + echo "## Input Sources" + echo "- Primary task source: \`$task_source_path\`" + if [[ "$task_source_kind" == "superpowers-plan" ]]; then + echo "- Primary type: \`implementation-plan\`" + if [[ -n "$task_supporting_spec_path" ]]; then + echo "- Supporting spec: \`$task_supporting_spec_path\`" + fi + if [[ -n "$task_source_root" ]]; then + echo "- Source root: \`$task_source_root\`" + fi + else + echo "- Primary type: \`task-brief\`" + fi + echo + cat <<'EOF' ## Execution Principles - Start with read-only analysis and build a baseline before assigning work. @@ -264,11 +371,23 @@ build_manager_prompt() { - Every completed worker task should be reviewed by the inspector. - Do not validate only the happy path; cover failures, fallback behavior, recovery, and regressions. - If worktree mode is enabled, all code changes, tests, and commits must happen in `Workspace root`. - -## Task Brief EOF echo - cat "$task_file" + if [[ "$task_source_kind" == "superpowers-plan" ]]; then + echo "## Implementation Plan" + echo + cat "$task_file" + if [[ -n "$task_supporting_spec_path" ]]; then + echo + echo "## Supporting Spec" + echo + cat "$task_supporting_spec_path" + fi + else + echo "## Task Brief" + echo + cat "$task_file" + fi } >"$output_path" } @@ -333,11 +452,23 @@ EOF echo fi - cat <<'EOF' + if [[ "$task_source_kind" == "superpowers-plan" ]]; then + echo "## Implementation Plan" + echo + cat "$task_file" + if [[ -n "$task_supporting_spec_path" ]]; then + echo + echo "## Supporting Spec" + echo + cat "$task_supporting_spec_path" + fi + else + cat <<'EOF' ## Task Brief EOF - echo - cat "$task_file" + echo + cat "$task_file" + fi if [[ ! -f "$inspector_source" ]]; then cat <<'EOF' @@ -362,6 +493,10 @@ build_run_summary() { echo "- Workspace root: \`$workspace_dir\`" echo "- Session: \`$session_name\`" echo "- Task file: \`$task_file\`" + echo "- Task source kind: \`$task_source_kind\`" + echo "- Task source path: \`$task_source_path\`" + echo "- Supporting spec path: \`$task_supporting_spec_path\`" + echo "- Task source root: \`$task_source_root\`" echo "- Inspector prompt source: \`$inspector_prompt_source\`" echo "- Launcher config: \`$launcher_config\`" echo "- Claude launch: \`$claude_launch_command\`" @@ -500,13 +635,15 @@ source_project_dir="$project_dir" launcher_config="$project_dir/.squad/launcher.yaml" default_task_file="$project_dir/.squad/run-task.md" -task_file="${task_file_override:-$default_task_file}" - -if [[ ! -f "$task_file" ]]; then - echo "Error: task brief not found: $task_file" >&2 - echo "Provide --task-file or create $default_task_file" >&2 - exit 1 -fi +detected_repo_root="$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || true)" +task_file="" +task_source_kind="" +task_source_path="" +task_supporting_spec_path="" +task_source_root="" + +resolve_task_sources "$project_dir" "$task_file_override" "$default_task_file" "$detected_repo_root" +task_file="$task_source_path" CFG_PROJECT_NAME="" CFG_SESSION_NAME="" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 2674052..2be6e9e 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -177,4 +177,58 @@ EOF HOME="$tmpdir/home" bash "$launcher" "$tilde_repo" --dry-run --no-setup --no-attach >/dev/null grep -q "$tmpdir/home/bin/claude --dangerously-skip-permissions" "$tilde_repo/.squad/quickstart/generated-run-summary.md" +superpowers_repo="$tmpdir/superpowers-repo" +mkdir -p "$superpowers_repo/.squad" "$superpowers_repo/docs/superpowers/specs" "$superpowers_repo/docs/superpowers/plans" +git -C "$tmpdir" init -b main superpowers-repo >/dev/null +git -C "$superpowers_repo" config user.email "codex@example.com" +git -C "$superpowers_repo" config user.name "Codex" +echo "superpowers" >"$superpowers_repo/README.md" +git -C "$superpowers_repo" add README.md +git -C "$superpowers_repo" commit -m "seed" >/dev/null + +cat >"$superpowers_repo/.squad/launcher.yaml" <<'EOF' +project: + name: superpowers-demo +EOF + +cat >"$superpowers_repo/docs/superpowers/specs/2026-03-29-older-flow-design.md" <<'EOF' +# Older Flow Design +This should not be selected. +EOF + +cat >"$superpowers_repo/docs/superpowers/plans/2026-03-29-older-flow-implementation.md" <<'EOF' +# Older Flow Implementation Plan +This should not be selected. +EOF + +cat >"$superpowers_repo/docs/superpowers/specs/2026-03-30-minimal-qr-connect-surface-design.md" <<'EOF' +# Minimal QR Connect Surface Design + +## Goal +Finish the remaining product-facing QR connection path. +EOF + +cat >"$superpowers_repo/docs/superpowers/plans/2026-03-30-minimal-qr-connect-surface-implementation.md" <<'EOF' +# Minimal QR Connect Surface Implementation Plan + +## Task 1 +Implement the QR connect surface. +EOF + +bash "$launcher" "$superpowers_repo" --dry-run --no-setup --no-attach >/dev/null + +superpowers_prompt="$superpowers_repo/.squad/quickstart/generated-manager.prompt.md" +superpowers_inspector_prompt="$superpowers_repo/.squad/quickstart/generated-inspector.prompt.md" +superpowers_summary="$superpowers_repo/.squad/quickstart/generated-run-summary.md" + +test -f "$superpowers_prompt" +test -f "$superpowers_inspector_prompt" +test -f "$superpowers_summary" +grep -q "Minimal QR Connect Surface Implementation Plan" "$superpowers_prompt" +grep -q "Minimal QR Connect Surface Design" "$superpowers_prompt" +grep -q "Finish the remaining product-facing QR connection path" "$superpowers_prompt" +grep -q "Minimal QR Connect Surface Implementation Plan" "$superpowers_inspector_prompt" +grep -q "docs/superpowers/plans/2026-03-30-minimal-qr-connect-surface-implementation.md" "$superpowers_summary" +grep -q "docs/superpowers/specs/2026-03-30-minimal-qr-connect-surface-design.md" "$superpowers_summary" + echo "PASS: generic launcher dry-run generated expected files" From 5f3a562a3df18b8478d81127229d047d0cd57e0a Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 31 Mar 2026 19:08:28 +0800 Subject: [PATCH 04/16] feat: support configurable task discovery globs --- scripts/squad-tmux-launch.sh | 174 +++++++++++++++++++++++------ tests/squad_tmux_launcher_smoke.sh | 62 ++++++++++ 2 files changed, 200 insertions(+), 36 deletions(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 11a6066..502661c 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -27,8 +27,9 @@ Options: Task source priority: 1. --task-file 2. /.squad/run-task.md - 3. latest /docs/superpowers/plans/*-implementation.md - plus the newest matching docs/superpowers/specs/*-design.md + 3. task_discovery.plan_globs / spec_globs from .squad/launcher.yaml + or the default docs/superpowers/plans/*-implementation.md + plus the newest matching spec Project config: /.squad/launcher.yaml @@ -194,6 +195,10 @@ emit_scalar("CFG_WORKTREE_LOCATION", lookup(data, "workspace", "worktree", "loca emit_scalar("CFG_WORKTREE_PATH", lookup(data, "workspace", "worktree", "path")) emit_scalar("CFG_WORKTREE_BRANCH", lookup(data, "workspace", "worktree", "branch")) emit_scalar("CFG_WORKTREE_BASE_REF", lookup(data, "workspace", "worktree", "base_ref")) +emit_array("CFG_TASK_DISCOVERY_PLAN_GLOBS", lookup(data, "task_discovery", "plan_globs")) +emit_array("CFG_TASK_DISCOVERY_SPEC_GLOBS", lookup(data, "task_discovery", "spec_globs")) +emit_scalar("CFG_TASK_DISCOVERY_PLAN_SUFFIX", lookup(data, "task_discovery", "plan_suffix")) +emit_scalar("CFG_TASK_DISCOVERY_SPEC_SUFFIX", lookup(data, "task_discovery", "spec_suffix")) emit_array("CFG_FOCUS_FILES", lookup(data, "focus", "files")) emit_array("CFG_FOCUS_DOCS", lookup(data, "focus", "docs")) emit_array("CFG_CONSTRAINTS", lookup(data, "constraints")) @@ -209,32 +214,121 @@ latest_matching_doc() { find "$dir" -maxdepth 1 -type f -name "$pattern" | LC_ALL=C sort | tail -n 1 } -superpowers_topic_slug() { +latest_matching_glob_patterns() { + local root="$1" + shift + [[ -d "$root" ]] || return 1 + (( $# > 0 )) || return 1 + + require_cmd ruby + + ruby - "$root" "$@" <<'RUBY' +root = ARGV.shift +patterns = ARGV +matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } + .select { |path| File.file?(path) } + .uniq + .sort +puts matches.last if matches.any? +RUBY +} + +all_matching_glob_patterns() { + local root="$1" + shift + [[ -d "$root" ]] || return 1 + (( $# > 0 )) || return 1 + + require_cmd ruby + + ruby - "$root" "$@" <<'RUBY' +root = ARGV.shift +patterns = ARGV +matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } + .select { |path| File.file?(path) } + .uniq + .sort +puts matches +RUBY +} + +doc_topic_slug() { local file_path="$1" + local suffix="$2" local name="" name="$(basename "$file_path")" - case "$name" in - ????-??-??-*-implementation.md) - name="${name#????-??-??-}" - printf '%s' "${name%-implementation.md}" - ;; - ????-??-??-*-design.md) - name="${name#????-??-??-}" - printf '%s' "${name%-design.md}" - ;; - *) - return 1 - ;; - esac + if [[ -n "$suffix" && "$name" == *"$suffix" ]]; then + name="${name%"$suffix"}" + else + name="${name%.md}" + fi + + if [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-(.+)$ ]]; then + name="${BASH_REMATCH[1]}" + fi + + [[ -n "$name" ]] || return 1 + printf '%s' "$name" +} + +matching_spec_from_patterns() { + local plan_file="$1" + local topic_slug="" + local root="$2" + shift 2 + local -a patterns=("$@") + local pattern="" + local candidate="" + local candidate_slug="" + local latest="" + + topic_slug="$(doc_topic_slug "$plan_file" "$task_discovery_plan_suffix")" || return 1 + while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + candidate_slug="$(doc_topic_slug "$candidate" "$task_discovery_spec_suffix" || true)" + if [[ -n "$candidate_slug" && "$candidate_slug" == "$topic_slug" ]]; then + latest="$candidate" + fi + done < <(all_matching_glob_patterns "$root" "${patterns[@]}" 2>/dev/null || true) + + if [[ -n "$latest" ]]; then + printf '%s\n' "$latest" + else + return 1 + fi } -matching_superpowers_spec() { +latest_matching_spec_for_plan() { local plan_file="$1" - local specs_dir="$2" + local root="$2" + + if (( ${#task_discovery_spec_globs[@]} > 0 )); then + matching_spec_from_patterns "$plan_file" "$root" "${task_discovery_spec_globs[@]}" + return + fi + + local specs_dir="$root/docs/superpowers/specs" local topic_slug="" - topic_slug="$(superpowers_topic_slug "$plan_file")" || return 1 - latest_matching_doc "$specs_dir" "????-??-??-${topic_slug}-design.md" + topic_slug="$(doc_topic_slug "$plan_file" "$task_discovery_plan_suffix")" || return 1 + latest_matching_doc "$specs_dir" "????-??-??-${topic_slug}${task_discovery_spec_suffix}" +} + +looks_like_plan_file() { + local file_path="$1" + local base_name + base_name="$(basename "$file_path")" + [[ -n "$task_discovery_plan_suffix" && "$base_name" == *"$task_discovery_plan_suffix" ]] +} + +resolve_discovery_candidate() { + local root="$1" + + if (( ${#task_discovery_plan_globs[@]} > 0 )); then + latest_matching_glob_patterns "$root" "${task_discovery_plan_globs[@]}" + else + latest_matching_doc "$root/docs/superpowers/plans" "????-??-??-*${task_discovery_plan_suffix}" + fi } resolve_task_sources() { @@ -267,27 +361,27 @@ resolve_task_sources() { fi for root in "${search_roots[@]}"; do - candidate="$(latest_matching_doc "$root/docs/superpowers/plans" "????-??-??-*-implementation.md" || true)" + candidate="$(resolve_discovery_candidate "$root" || true)" if [[ -n "$candidate" ]]; then task_source_kind="superpowers-plan" task_source_path="$candidate" - task_source_root="$root/docs/superpowers" - task_supporting_spec_path="$(matching_superpowers_spec "$candidate" "$root/docs/superpowers/specs" || true)" + task_source_root="$root" + task_supporting_spec_path="$(latest_matching_spec_for_plan "$candidate" "$root" || true)" break fi done if [[ -z "$task_source_path" ]]; then echo "Error: task brief not found: $default_task_file" >&2 - echo "Provide --task-file , create $default_task_file, or add docs/superpowers/plans/*-implementation.md" >&2 + echo "Provide --task-file , create $default_task_file, or configure task_discovery.plan_globs in $launcher_config" >&2 exit 1 fi fi - if [[ "$task_source_kind" == "task-brief" && "$(basename "$task_source_path")" == ????-??-??-*-implementation.md ]]; then + if [[ "$task_source_kind" == "task-brief" ]] && looks_like_plan_file "$task_source_path"; then task_source_kind="superpowers-plan" - task_source_root="$(cd "$(dirname "$task_source_path")/.." && pwd -P 2>/dev/null || true)" - task_supporting_spec_path="$(matching_superpowers_spec "$task_source_path" "$(dirname "$task_source_path")/../specs" || true)" + task_source_root="${repo_root:-$project_dir}" + task_supporting_spec_path="$(latest_matching_spec_for_plan "$task_source_path" "$task_source_root" || true)" fi } @@ -636,15 +730,6 @@ source_project_dir="$project_dir" launcher_config="$project_dir/.squad/launcher.yaml" default_task_file="$project_dir/.squad/run-task.md" detected_repo_root="$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || true)" -task_file="" -task_source_kind="" -task_source_path="" -task_supporting_spec_path="" -task_source_root="" - -resolve_task_sources "$project_dir" "$task_file_override" "$default_task_file" "$detected_repo_root" -task_file="$task_source_path" - CFG_PROJECT_NAME="" CFG_SESSION_NAME="" CFG_CLAUDE_COMMAND="" @@ -659,6 +744,10 @@ CFG_WORKTREE_LOCATION="" CFG_WORKTREE_PATH="" CFG_WORKTREE_BRANCH="" CFG_WORKTREE_BASE_REF="" +CFG_TASK_DISCOVERY_PLAN_GLOBS=() +CFG_TASK_DISCOVERY_SPEC_GLOBS=() +CFG_TASK_DISCOVERY_PLAN_SUFFIX="" +CFG_TASK_DISCOVERY_SPEC_SUFFIX="" CFG_FOCUS_FILES=() CFG_FOCUS_DOCS=() CFG_CONSTRAINTS=() @@ -696,9 +785,22 @@ fi copy_array_or_empty claude_args CFG_CLAUDE_ARGS copy_array_or_empty init_args CFG_INIT_ARGS +copy_array_or_empty task_discovery_plan_globs CFG_TASK_DISCOVERY_PLAN_GLOBS +copy_array_or_empty task_discovery_spec_globs CFG_TASK_DISCOVERY_SPEC_GLOBS copy_array_or_empty focus_files CFG_FOCUS_FILES copy_array_or_empty focus_docs CFG_FOCUS_DOCS copy_array_or_empty constraints CFG_CONSTRAINTS +task_discovery_plan_suffix="${CFG_TASK_DISCOVERY_PLAN_SUFFIX:--implementation.md}" +task_discovery_spec_suffix="${CFG_TASK_DISCOVERY_SPEC_SUFFIX:--design.md}" + +task_file="" +task_source_kind="" +task_source_path="" +task_supporting_spec_path="" +task_source_root="" + +resolve_task_sources "$project_dir" "$task_file_override" "$default_task_file" "$detected_repo_root" +task_file="$task_source_path" if (( ${#init_args[@]} == 0 )); then init_args=(--refresh-roles) diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 2be6e9e..d6cf2ad 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -231,4 +231,66 @@ grep -q "Minimal QR Connect Surface Implementation Plan" "$superpowers_inspector grep -q "docs/superpowers/plans/2026-03-30-minimal-qr-connect-surface-implementation.md" "$superpowers_summary" grep -q "docs/superpowers/specs/2026-03-30-minimal-qr-connect-surface-design.md" "$superpowers_summary" +custom_discovery_repo="$tmpdir/custom-discovery-repo" +mkdir -p "$custom_discovery_repo/.squad" "$custom_discovery_repo/workitems/plans" "$custom_discovery_repo/workitems/specifications" +git -C "$tmpdir" init -b main custom-discovery-repo >/dev/null +git -C "$custom_discovery_repo" config user.email "codex@example.com" +git -C "$custom_discovery_repo" config user.name "Codex" +echo "custom" >"$custom_discovery_repo/README.md" +git -C "$custom_discovery_repo" add README.md +git -C "$custom_discovery_repo" commit -m "seed" >/dev/null + +cat >"$custom_discovery_repo/.squad/launcher.yaml" <<'EOF' +project: + name: custom-discovery-demo + +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +EOF + +cat >"$custom_discovery_repo/workitems/specifications/2026-03-29-older-flow-spec.md" <<'EOF' +# Older Flow Spec +Do not select this spec. +EOF + +cat >"$custom_discovery_repo/workitems/plans/2026-03-29-older-flow-plan.md" <<'EOF' +# Older Flow Plan +Do not select this plan. +EOF + +cat >"$custom_discovery_repo/workitems/specifications/2026-03-31-remote-control-gateway-spec.md" <<'EOF' +# Remote Control Gateway Spec + +## Goal +Keep arbitrary discovery layouts configurable. +EOF + +cat >"$custom_discovery_repo/workitems/plans/2026-03-31-remote-control-gateway-plan.md" <<'EOF' +# Remote Control Gateway Plan + +## Task 1 +Support configurable plan/spec discovery. +EOF + +bash "$launcher" "$custom_discovery_repo" --dry-run --no-setup --no-attach >/dev/null + +custom_prompt="$custom_discovery_repo/.squad/quickstart/generated-manager.prompt.md" +custom_inspector_prompt="$custom_discovery_repo/.squad/quickstart/generated-inspector.prompt.md" +custom_summary="$custom_discovery_repo/.squad/quickstart/generated-run-summary.md" + +test -f "$custom_prompt" +test -f "$custom_inspector_prompt" +test -f "$custom_summary" +grep -q "Remote Control Gateway Plan" "$custom_prompt" +grep -q "Remote Control Gateway Spec" "$custom_prompt" +grep -q "Keep arbitrary discovery layouts configurable" "$custom_prompt" +grep -q "Remote Control Gateway Plan" "$custom_inspector_prompt" +grep -q "workitems/plans/2026-03-31-remote-control-gateway-plan.md" "$custom_summary" +grep -q "workitems/specifications/2026-03-31-remote-control-gateway-spec.md" "$custom_summary" + echo "PASS: generic launcher dry-run generated expected files" From 6b28e6a384bddc94f7a7b55f7345ad34eeb63f1f Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 31 Mar 2026 19:11:56 +0800 Subject: [PATCH 05/16] docs: describe configurable task discovery --- README.md | 29 +++++++++++++++++++++++++++++ README.zh-CN.md | 29 +++++++++++++++++++++++++++++ templates/launcher.yaml.example | 11 +++++++++++ 3 files changed, 69 insertions(+) diff --git a/README.md b/README.md index 13423d8..3aaaded 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run It can: - read project-local launcher config from `.squad/launcher.yaml` - read a task brief from `.squad/run-task.md` +- or auto-discover the latest implementation plan + matching spec from `docs/superpowers/...` +- or use custom discovery globs from `.squad/launcher.yaml -> task_discovery` - generate manager / inspector prompt files under `.squad/quickstart/` - start a tiled `tmux` session and inject `/squad` commands into Claude panes - optionally create an isolated git worktree before launching agents @@ -97,6 +99,33 @@ Requirements: This launcher is intentionally separate from the core Rust CLI. Treat it as optional automation for people who want a repeatable multi-terminal workflow. +### Launcher task discovery + +Task sources are resolved in this order: + +1. `--task-file ` +2. `/.squad/run-task.md` +3. auto-discovery + +Default auto-discovery looks for: + +- the newest `docs/superpowers/plans/*-implementation.md` +- plus the newest matching `docs/superpowers/specs/*-design.md` + +If your repo uses a different layout or naming convention, configure it in `.squad/launcher.yaml`: + +```yaml +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +``` + +With that config, the launcher will pick the newest matching plan, derive its topic from the filename, and attach the newest matching spec with the same topic slug. + ## Usage Flow ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 5b22f93..56f3294 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,6 +88,8 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run 它可以: - 从 `.squad/launcher.yaml` 读取项目级启动配置 - 从 `.squad/run-task.md` 读取本次任务说明 +- 或自动发现 `docs/superpowers/...` 下最新的 implementation plan 和匹配 spec +- 或通过 `.squad/launcher.yaml -> task_discovery` 使用自定义发现规则 - 在 `.squad/quickstart/` 下生成 manager / inspector prompt - 启动平铺布局的 `tmux` 会话,并自动向 Claude pane 注入 `/squad` 命令 - 在启动 agent 前可选地创建独立 git worktree @@ -99,6 +101,33 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run 这个启动器刻意保持在核心 Rust CLI 之外。它是给需要固定化多终端协作流程的用户准备的可选自动化能力。 +### Launcher 任务发现规则 + +任务输入按下面的优先级解析: + +1. `--task-file ` +2. `/.squad/run-task.md` +3. 自动发现 + +默认的自动发现规则会寻找: + +- 最新的 `docs/superpowers/plans/*-implementation.md` +- 以及同主题、最新匹配的 `docs/superpowers/specs/*-design.md` + +如果你的仓库目录或命名规则不同,可以在 `.squad/launcher.yaml` 里配置: + +```yaml +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +``` + +配置后,launcher 会选出最新的 plan,从文件名里提取 topic,再自动附带同一 topic 的最新 spec。 + ## 使用流程 ``` diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example index 66e6507..674731f 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -21,6 +21,17 @@ workspace: path: example-task base_ref: HEAD +task_discovery: + # Optional. If omitted, the launcher falls back to: + # docs/superpowers/plans/*-implementation.md + # docs/superpowers/specs/*-design.md + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md + focus: files: - src/index.ts From 9794aa416282e07d0300a70d68518a24aef88873 Mon Sep 17 00:00:00 2001 From: liuhy Date: Wed, 1 Apr 2026 09:35:46 +0800 Subject: [PATCH 06/16] fix: tighten configurable task discovery --- README.md | 2 +- README.zh-CN.md | 2 +- scripts/squad-tmux-launch.sh | 70 +++++++++++++++- templates/launcher.yaml.example | 1 + tests/squad_tmux_launcher_smoke.sh | 125 +++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3aaaded..5796a0c 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ task_discovery: spec_suffix: -spec.md ``` -With that config, the launcher will pick the newest matching plan, derive its topic from the filename, and attach the newest matching spec with the same topic slug. +`plan_globs` and `spec_globs` are resolved relative to the `project-dir` you pass to the launcher. With that config, the launcher will pick the newest matching plan, derive its topic from the filename, and attach the newest matching spec with the same topic slug. ## Usage Flow diff --git a/README.zh-CN.md b/README.zh-CN.md index 56f3294..eb125a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,7 +126,7 @@ task_discovery: spec_suffix: -spec.md ``` -配置后,launcher 会选出最新的 plan,从文件名里提取 topic,再自动附带同一 topic 的最新 spec。 +`plan_globs` 和 `spec_globs` 都是相对于你传入的 `project-dir` 解析的。配置后,launcher 会选出最新的 plan,从文件名里提取 topic,再自动附带同一 topic 的最新 spec。 ## 使用流程 diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 502661c..5bbc019 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -228,7 +228,7 @@ patterns = ARGV matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } .select { |path| File.file?(path) } .uniq - .sort + .sort_by { |path| [File.basename(path), path] } puts matches.last if matches.any? RUBY } @@ -247,11 +247,44 @@ patterns = ARGV matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } .select { |path| File.file?(path) } .uniq - .sort + .sort_by { |path| [File.basename(path), path] } puts matches RUBY } +path_matches_glob_patterns() { + local root="$1" + local candidate="$2" + shift 2 + (( $# > 0 )) || return 1 + [[ -d "$root" ]] || return 1 + + require_cmd ruby + + ruby - "$root" "$candidate" "$@" <<'RUBY' +root = File.expand_path(ARGV.shift) +candidate = File.expand_path(ARGV.shift) +patterns = ARGV + +begin + relative = candidate.delete_prefix(root + "/") + if relative == candidate || relative.empty? + exit 1 + end + + matched = patterns.any? do |pattern| + Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB).any? do |path| + File.expand_path(path) == candidate + end + end + + exit(matched ? 0 : 1) +rescue StandardError + exit 1 +end +RUBY +} + doc_topic_slug() { local file_path="$1" local suffix="$2" @@ -331,6 +364,32 @@ resolve_discovery_candidate() { fi } +discovery_root_for_plan_file() { + local plan_file="$1" + local project_dir="$2" + local repo_root="$3" + + if (( ${#task_discovery_plan_globs[@]} > 0 )); then + if path_matches_glob_patterns "$project_dir" "$plan_file" "${task_discovery_plan_globs[@]}"; then + printf '%s\n' "$project_dir" + return 0 + fi + return 1 + fi + + if [[ "$plan_file" == "$project_dir"/docs/superpowers/plans/*"$task_discovery_plan_suffix" ]]; then + printf '%s\n' "$project_dir" + return 0 + fi + + if [[ -n "$repo_root" && "$repo_root" != "$project_dir" ]] && [[ "$plan_file" == "$repo_root"/docs/superpowers/plans/*"$task_discovery_plan_suffix" ]]; then + printf '%s\n' "$repo_root" + return 0 + fi + + return 1 +} + resolve_task_sources() { local project_dir="$1" local task_override="$2" @@ -356,7 +415,7 @@ resolve_task_sources() { task_source_path="$default_task_file" else search_roots=("$project_dir") - if [[ -n "$repo_root" && "$repo_root" != "$project_dir" ]]; then + if (( ${#task_discovery_plan_globs[@]} == 0 )) && [[ -n "$repo_root" && "$repo_root" != "$project_dir" ]]; then search_roots+=("$repo_root") fi @@ -380,7 +439,10 @@ resolve_task_sources() { if [[ "$task_source_kind" == "task-brief" ]] && looks_like_plan_file "$task_source_path"; then task_source_kind="superpowers-plan" - task_source_root="${repo_root:-$project_dir}" + task_source_root="$(discovery_root_for_plan_file "$task_source_path" "$project_dir" "$repo_root" || true)" + if [[ -z "$task_source_root" ]]; then + task_source_root="$project_dir" + fi task_supporting_spec_path="$(latest_matching_spec_for_plan "$task_source_path" "$task_source_root" || true)" fi } diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example index 674731f..221d9e0 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -25,6 +25,7 @@ task_discovery: # Optional. If omitted, the launcher falls back to: # docs/superpowers/plans/*-implementation.md # docs/superpowers/specs/*-design.md + # Globs are resolved relative to the project dir passed to the launcher. plan_globs: - workitems/plans/*-plan.md spec_globs: diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index d6cf2ad..37a8f95 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -293,4 +293,129 @@ grep -q "Remote Control Gateway Plan" "$custom_inspector_prompt" grep -q "workitems/plans/2026-03-31-remote-control-gateway-plan.md" "$custom_summary" grep -q "workitems/specifications/2026-03-31-remote-control-gateway-spec.md" "$custom_summary" +custom_sort_repo="$tmpdir/custom-sort-repo" +mkdir -p "$custom_sort_repo/.squad" "$custom_sort_repo/a/plans" "$custom_sort_repo/z/plans" "$custom_sort_repo/a/specs" "$custom_sort_repo/z/specs" +git -C "$tmpdir" init -b main custom-sort-repo >/dev/null +git -C "$custom_sort_repo" config user.email "codex@example.com" +git -C "$custom_sort_repo" config user.name "Codex" +echo "sort" >"$custom_sort_repo/README.md" +git -C "$custom_sort_repo" add README.md +git -C "$custom_sort_repo" commit -m "seed" >/dev/null + +cat >"$custom_sort_repo/.squad/launcher.yaml" <<'EOF' +task_discovery: + plan_globs: + - a/plans/*-plan.md + - z/plans/*-plan.md + spec_globs: + - a/specs/*-spec.md + - z/specs/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +EOF + +cat >"$custom_sort_repo/a/plans/2026-04-01-newer-plan.md" <<'EOF' +# Newer Plan +EOF + +cat >"$custom_sort_repo/a/specs/2026-04-01-newer-spec.md" <<'EOF' +# Newer Spec +EOF + +cat >"$custom_sort_repo/z/plans/2026-03-01-older-plan.md" <<'EOF' +# Older Plan +EOF + +cat >"$custom_sort_repo/z/specs/2026-03-01-older-spec.md" <<'EOF' +# Older Spec +EOF + +bash "$launcher" "$custom_sort_repo" --dry-run --no-setup --no-attach >/dev/null + +custom_sort_prompt="$custom_sort_repo/.squad/quickstart/generated-manager.prompt.md" +custom_sort_summary="$custom_sort_repo/.squad/quickstart/generated-run-summary.md" + +grep -q "Newer Plan" "$custom_sort_prompt" +grep -q "Newer Spec" "$custom_sort_prompt" +grep -q "a/plans/2026-04-01-newer-plan.md" "$custom_sort_summary" +grep -q "a/specs/2026-04-01-newer-spec.md" "$custom_sort_summary" + +subproject_repo="$tmpdir/subproject-repo" +mkdir -p "$subproject_repo/subproj/.squad" "$subproject_repo/workitems/plans" +git -C "$tmpdir" init -b main subproject-repo >/dev/null +git -C "$subproject_repo" config user.email "codex@example.com" +git -C "$subproject_repo" config user.name "Codex" +echo "subproject" >"$subproject_repo/README.md" +git -C "$subproject_repo" add README.md +git -C "$subproject_repo" commit -m "seed" >/dev/null + +cat >"$subproject_repo/subproj/.squad/launcher.yaml" <<'EOF' +project: + name: subproj + +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +EOF + +cat >"$subproject_repo/workitems/plans/2026-04-01-root-plan.md" <<'EOF' +# Root Plan +EOF + +set +e +subproject_output="$(bash "$launcher" "$subproject_repo/subproj" --dry-run --no-setup --no-attach 2>&1)" +subproject_status=$? +set -e + +if (( subproject_status == 0 )); then + echo "Expected custom discovery to stay within subproject config root" >&2 + exit 1 +fi + +printf '%s\n' "$subproject_output" | grep -q "configure task_discovery.plan_globs" +if printf '%s\n' "$subproject_output" | grep -q "Root Plan"; then + echo "Unexpectedly selected repo-root plan for subproject launcher config" >&2 + exit 1 +fi + +taskfile_repo="$tmpdir/taskfile-repo" +mkdir -p "$taskfile_repo/subproj/.squad" "$taskfile_repo/subproj/workitems/plans" "$taskfile_repo/subproj/workitems/specifications" +git -C "$tmpdir" init -b main taskfile-repo >/dev/null +git -C "$taskfile_repo" config user.email "codex@example.com" +git -C "$taskfile_repo" config user.name "Codex" +echo "taskfile" >"$taskfile_repo/README.md" +git -C "$taskfile_repo" add README.md +git -C "$taskfile_repo" commit -m "seed" >/dev/null + +cat >"$taskfile_repo/subproj/.squad/launcher.yaml" <<'EOF' +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +EOF + +cat >"$taskfile_repo/subproj/workitems/plans/2026-04-01-demo-plan.md" <<'EOF' +# Demo Plan +EOF + +cat >"$taskfile_repo/subproj/workitems/specifications/2026-04-01-demo-spec.md" <<'EOF' +# Demo Spec +EOF + +bash "$launcher" "$taskfile_repo/subproj" --task-file "$taskfile_repo/subproj/workitems/plans/2026-04-01-demo-plan.md" --dry-run --no-setup --no-attach >/dev/null + +taskfile_prompt="$taskfile_repo/subproj/.squad/quickstart/generated-manager.prompt.md" +taskfile_summary="$taskfile_repo/subproj/.squad/quickstart/generated-run-summary.md" + +grep -q "Demo Plan" "$taskfile_prompt" +grep -q "Demo Spec" "$taskfile_prompt" +grep -q "subproj/workitems/specifications/2026-04-01-demo-spec.md" "$taskfile_summary" + echo "PASS: generic launcher dry-run generated expected files" From 22308aa50be17ccaafb095b8ec2aedcd597d146f Mon Sep 17 00:00:00 2001 From: liuhy Date: Wed, 1 Apr 2026 10:34:41 +0800 Subject: [PATCH 07/16] fix: keep task discovery within project root --- scripts/squad-tmux-launch.sh | 34 ++++++++++++++++++++++---- tests/squad_tmux_launcher_smoke.sh | 38 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 5bbc019..41f58bc 100755 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -223,10 +223,23 @@ latest_matching_glob_patterns() { require_cmd ruby ruby - "$root" "$@" <<'RUBY' -root = ARGV.shift +root_arg = ARGV.shift +root = begin + File.realpath(root_arg) +rescue StandardError + File.expand_path(root_arg) +end patterns = ARGV matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } - .select { |path| File.file?(path) } + .select do |path| + next false unless File.file?(path) + expanded = begin + File.realpath(path) + rescue StandardError + File.expand_path(path) + end + expanded == root || expanded.start_with?(root + "/") + end .uniq .sort_by { |path| [File.basename(path), path] } puts matches.last if matches.any? @@ -242,10 +255,23 @@ all_matching_glob_patterns() { require_cmd ruby ruby - "$root" "$@" <<'RUBY' -root = ARGV.shift +root_arg = ARGV.shift +root = begin + File.realpath(root_arg) +rescue StandardError + File.expand_path(root_arg) +end patterns = ARGV matches = patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) } - .select { |path| File.file?(path) } + .select do |path| + next false unless File.file?(path) + expanded = begin + File.realpath(path) + rescue StandardError + File.expand_path(path) + end + expanded == root || expanded.start_with?(root + "/") + end .uniq .sort_by { |path| [File.basename(path), path] } puts matches diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 37a8f95..148a64a 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -418,4 +418,42 @@ grep -q "Demo Plan" "$taskfile_prompt" grep -q "Demo Spec" "$taskfile_prompt" grep -q "subproj/workitems/specifications/2026-04-01-demo-spec.md" "$taskfile_summary" +escape_repo="$tmpdir/escape-repo" +mkdir -p "$escape_repo/.squad" "$tmpdir/outside-docs/plans" +git -C "$tmpdir" init -b main escape-repo >/dev/null +git -C "$escape_repo" config user.email "codex@example.com" +git -C "$escape_repo" config user.name "Codex" +echo "escape" >"$escape_repo/README.md" +git -C "$escape_repo" add README.md +git -C "$escape_repo" commit -m "seed" >/dev/null + +cat >"$escape_repo/.squad/launcher.yaml" <<'EOF' +task_discovery: + plan_globs: + - ../outside-docs/plans/*-plan.md + plan_suffix: -plan.md +EOF + +cat >"$tmpdir/outside-docs/plans/2026-04-01-escaped-plan.md" <<'EOF' +# Escaped Plan +EOF + +set +e +escape_output="$(bash "$launcher" "$escape_repo" --dry-run --no-setup --no-attach 2>&1)" +escape_rc=$? +set -e + +if (( escape_rc == 0 )); then + echo "Expected task discovery globs to stay within project-dir" >&2 + exit 1 +fi + +printf '%s\n' "$escape_output" | grep -q "configure task_discovery.plan_globs" +if [[ -f "$escape_repo/.squad/quickstart/generated-manager.prompt.md" ]]; then + if grep -q "Escaped Plan" "$escape_repo/.squad/quickstart/generated-manager.prompt.md"; then + echo "Unexpectedly selected escaped plan outside project-dir" >&2 + exit 1 + fi +fi + echo "PASS: generic launcher dry-run generated expected files" From 9760a0a493a8fa350ce3f22a2c2b876f9fe3a2d5 Mon Sep 17 00:00:00 2001 From: aias00 Date: Wed, 1 Apr 2026 14:11:06 +0800 Subject: [PATCH 08/16] Update scripts/squad-tmux-launch.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/squad-tmux-launch.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index e970387..42597b8 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -28,7 +28,7 @@ Task source priority: 1. --task-file 2. /.squad/run-task.md 3. task_discovery.plan_globs / spec_globs from .squad/launcher.yaml - or the default docs/superpowers/plans/*-implementation.md + or the default docs/superpowers/plans/YYYY-MM-DD-*-implementation.md plus the newest matching spec Project config: From 1b49590ca0497a3885e55b39f74e0b1d8bedf1d9 Mon Sep 17 00:00:00 2001 From: aias00 Date: Wed, 1 Apr 2026 14:11:18 +0800 Subject: [PATCH 09/16] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5796a0c..dbf9ce3 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,10 @@ Task sources are resolved in this order: 2. `/.squad/run-task.md` 3. auto-discovery -Default auto-discovery looks for: +Default auto-discovery looks for files named with a `YYYY-MM-DD-` date prefix: -- the newest `docs/superpowers/plans/*-implementation.md` -- plus the newest matching `docs/superpowers/specs/*-design.md` +- the newest `docs/superpowers/plans/YYYY-MM-DD-*-implementation.md` +- plus the newest matching `docs/superpowers/specs/YYYY-MM-DD-*-design.md` If your repo uses a different layout or naming convention, configure it in `.squad/launcher.yaml`: From c2c03cc7422dcfb1d1da1be5a09fd232037120f7 Mon Sep 17 00:00:00 2001 From: aias00 Date: Wed, 1 Apr 2026 14:11:27 +0800 Subject: [PATCH 10/16] Update README.zh-CN.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.zh-CN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index eb125a0..fdad443 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,8 +111,8 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run 默认的自动发现规则会寻找: -- 最新的 `docs/superpowers/plans/*-implementation.md` -- 以及同主题、最新匹配的 `docs/superpowers/specs/*-design.md` +- 最新的 `docs/superpowers/plans/????-??-??-*-implementation.md`(文件名需以 `YYYY-MM-DD-` 日期前缀开头) +- 以及同主题、最新匹配的 `docs/superpowers/specs/????-??-??-*-design.md`(同样要求 `YYYY-MM-DD-` 前缀) 如果你的仓库目录或命名规则不同,可以在 `.squad/launcher.yaml` 里配置: From cb0aaf2826c98ac367970380622ec1348d9e273c Mon Sep 17 00:00:00 2001 From: aias00 Date: Wed, 1 Apr 2026 14:11:36 +0800 Subject: [PATCH 11/16] Update templates/launcher.yaml.example Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- templates/launcher.yaml.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example index 221d9e0..71d614f 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -23,8 +23,8 @@ workspace: task_discovery: # Optional. If omitted, the launcher falls back to: - # docs/superpowers/plans/*-implementation.md - # docs/superpowers/specs/*-design.md + # docs/superpowers/plans/????-??-??-*-implementation.md (expects YYYY-MM-DD- prefix) + # docs/superpowers/specs/????-??-??-*-design.md (expects YYYY-MM-DD- prefix) # Globs are resolved relative to the project dir passed to the launcher. plan_globs: - workitems/plans/*-plan.md From 8063e2d82f8a7e38649a0aae2390f3527c620ee0 Mon Sep 17 00:00:00 2001 From: aias00 Date: Wed, 1 Apr 2026 14:11:45 +0800 Subject: [PATCH 12/16] Update scripts/squad-tmux-launch.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/squad-tmux-launch.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 42597b8..460206d 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -337,7 +337,6 @@ matching_spec_from_patterns() { local root="$2" shift 2 local -a patterns=("$@") - local pattern="" local candidate="" local candidate_slug="" local latest="" From 00332b83dc7e0b32a7adff97eb6a2bc5eb943225 Mon Sep 17 00:00:00 2001 From: liuhy Date: Wed, 1 Apr 2026 14:14:42 +0800 Subject: [PATCH 13/16] fix: wait for interactive Claude prompt before squad join --- scripts/lib/squad-tmux-launcher-helpers.sh | 29 +++++++++ scripts/squad-tmux-launch.sh | 72 ++++++++++++++++++++++ tests/squad_tmux_launcher_helpers_test.sh | 13 ++++ 3 files changed, 114 insertions(+) diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh index e128a23..f0ab8d6 100644 --- a/scripts/lib/squad-tmux-launcher-helpers.sh +++ b/scripts/lib/squad-tmux-launcher-helpers.sh @@ -170,6 +170,35 @@ path_is_within() { [[ "$path" == "$base" || "$path" == "$base"/* ]] } +pane_capture_has_workspace_trust_prompt() { + local capture="$1" + [[ "$capture" == *"Yes, I trust this folder"* ]] && [[ "$capture" == *"Enter to confirm"* ]] +} + +pane_capture_has_interactive_prompt() { + local capture="$1" + [[ "$capture" == *"❯"* ]] +} + +pane_capture_has_pending_command_input() { + local capture="$1" + local command_text="$2" + [[ -n "$command_text" ]] || return 1 + [[ "$capture" == *"❯"*"$command_text"* ]] +} + +pane_capture_has_squad_command_activity() { + local capture="$1" + case "$capture" in + *"Skill(/squad)"*|*"Bash(squad "*|*"Joined as "*|*"joining the squad"*|*"I'm joining the squad"*|*"I'll join the squad"*|*"No agents online."*|*"Initialized squad workspace."*) + return 0 + ;; + *) + return 1 + ;; + esac +} + ensure_repo_local_worktree_ignored() { local repo_root="$1" local path="$2" diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index e970387..85d29d4 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -133,6 +133,67 @@ send_tmux_text() { tmux delete-buffer } +capture_pane_text() { + local target="$1" + tmux capture-pane -t "$target" -p -S -120 +} + +wait_for_pane_ready() { + local target="$1" + local timeout_secs="$2" + local start_ts + start_ts="$(date +%s)" + + while true; do + local capture="" + capture="$(capture_pane_text "$target")" + + if pane_capture_has_workspace_trust_prompt "$capture"; then + tmux send-keys -t "$target" Enter + sleep 1 + continue + fi + + if pane_capture_has_interactive_prompt "$capture"; then + return 0 + fi + + if (( "$(date +%s)" - start_ts >= timeout_secs )); then + echo "Error: pane $target did not reach an interactive Claude prompt within ${timeout_secs}s" >&2 + return 1 + fi + + sleep 1 + done +} + +resubmit_pending_squad_command_if_needed() { + local target="$1" + local command_text="$2" + local capture="" + capture="$(capture_pane_text "$target")" + + if pane_capture_has_workspace_trust_prompt "$capture"; then + tmux send-keys -t "$target" Enter + return 0 + fi + + if pane_capture_has_pending_command_input "$capture" "$command_text" && ! pane_capture_has_squad_command_activity "$capture"; then + tmux send-keys -t "$target" Enter + return 0 + fi + + return 1 +} + +current_agent_count() { + local workspace="$1" + ( + cd "$workspace" + squad agents --json 2>/dev/null || true + ) | awk 'NF { count += 1 } END { print count + 0 }' +} + load_launcher_config() { local config_path="$1" if [[ ! -f "$config_path" ]]; then @@ -1049,6 +1110,7 @@ while IFS= read -r alias; do done < <(pane_command_candidates "$claude_command") for i in "${!pane_labels[@]}"; do wait_for_pane_command "$session_name":0."$i" 30 "${pane_command_aliases[@]}" + wait_for_pane_ready "$session_name":0."$i" 30 done echo "[4/6] Sending squad commands" @@ -1056,6 +1118,16 @@ for i in "${!pane_commands[@]}"; do send_tmux_text "$session_name":0."$i" "${pane_commands[$i]}" done +for attempt in 1 2 3; do + if (( "$(current_agent_count "$workspace_dir")" >= ${#pane_commands[@]} )); then + break + fi + sleep 2 + for i in "${!pane_commands[@]}"; do + resubmit_pending_squad_command_if_needed "$session_name":0."$i" "${pane_commands[$i]}" || true + done +done + echo "[5/6] Waiting for agents to join squad" wait_for_agent_count "$workspace_dir" "${#pane_commands[@]}" 90 diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh index d96f499..8ebcb11 100644 --- a/tests/squad_tmux_launcher_helpers_test.sh +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -64,6 +64,19 @@ test "$requested_path" = "$repo_dir/.worktrees/mcp-upgrade" test "$(repo_worktree_location_slug "$repo_dir")" != "$(basename "$repo_dir")" ! path_is_within "$repo_dir/.worktrees/../outside" "$repo_dir" +trust_capture=$' Accessing workspace:\n\n Quick safety check: Is this a project you created or one you trust?\n\n ❯ 1. Yes, I trust this folder\n 2. No, exit\n\n Enter to confirm · Esc to cancel\n' +ready_capture=$' Claude Code v2.1.87\n\n❯ \n' +pending_command_capture=$' Claude Code v2.1.87\n\n❯ /squad worker\n' +active_command_capture=$'❯ /squad worker\n\n⏺ Skill(/squad)\n ⎿ Successfully loaded skill\n\n⏺ Bash(squad init)\n ⎿ Running…\n' + +pane_capture_has_workspace_trust_prompt "$trust_capture" +! pane_capture_has_workspace_trust_prompt "$ready_capture" +pane_capture_has_interactive_prompt "$ready_capture" +pane_capture_has_pending_command_input "$pending_command_capture" "/squad worker" +! pane_capture_has_pending_command_input "$ready_capture" "/squad worker" +! pane_capture_has_squad_command_activity "$pending_command_capture" +pane_capture_has_squad_command_activity "$active_command_capture" + ! ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" echo ".worktrees/" >>"$repo_dir/.gitignore" ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" From 3e1a510ab9790ea33bea3da2ac4d6d9dddc49e2b Mon Sep 17 00:00:00 2001 From: liuhy Date: Thu, 2 Apr 2026 19:52:51 +0800 Subject: [PATCH 14/16] feat: support role-specific launcher clients --- scripts/lib/squad-tmux-launcher-helpers.sh | 12 +- scripts/squad-tmux-launch.sh | 221 ++++++++++++++++++--- templates/launcher.yaml.example | 11 + tests/squad_tmux_launcher_helpers_test.sh | 2 + tests/squad_tmux_launcher_smoke.sh | 77 +++++++ 5 files changed, 297 insertions(+), 26 deletions(-) diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh index f0ab8d6..d213628 100644 --- a/scripts/lib/squad-tmux-launcher-helpers.sh +++ b/scripts/lib/squad-tmux-launcher-helpers.sh @@ -16,6 +16,16 @@ shell_join() { printf '%s' "$joined" } +expand_home_path() { + local path="$1" + if [[ "$path" == "~" ]]; then + path="$HOME" + elif [[ "${path:0:2}" == "~/" ]]; then + path="$HOME/${path:2}" + fi + printf '%s' "$path" +} + pane_command_candidates() { local command_name="$1" local resolved="" @@ -177,7 +187,7 @@ pane_capture_has_workspace_trust_prompt() { pane_capture_has_interactive_prompt() { local capture="$1" - [[ "$capture" == *"❯"* ]] + [[ "$capture" == *"❯"* || "$capture" == *"›"* ]] } pane_capture_has_pending_command_input() { diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index cb5db36..cca1ae8 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -18,9 +18,9 @@ Options: --worktree-base Base ref for new worktree branches (default: HEAD) --worktree-location Override worktree parent directory --no-worktree Disable worktree mode even if config enables it - --no-setup Skip `squad setup claude` + --no-setup Skip `squad setup` for detected clients --no-attach Create/start session but do not attach - --dry-run Generate prompt/summary/map only; do not run squad/tmux/claude + --dry-run Generate prompt/summary/map only; do not run squad/tmux/clients --reuse-session Reuse an existing tmux session instead of failing -h, --help Show this help @@ -242,10 +242,34 @@ def emit_array(name, value) puts ")" end +def key_present?(hash, *keys) + current = hash + keys.each do |key| + return false unless current.is_a?(Hash) + if current.key?(key) + current = current[key] + elsif current.key?(key.to_sym) + current = current[key.to_sym] + else + return false + end + end + true +end + emit_scalar("CFG_PROJECT_NAME", lookup(data, "project", "name")) emit_scalar("CFG_SESSION_NAME", lookup(data, "project", "session_name")) emit_scalar("CFG_CLAUDE_COMMAND", lookup(data, "runtime", "claude_command")) emit_array("CFG_CLAUDE_ARGS", lookup(data, "runtime", "claude_args")) +emit_scalar("CFG_MANAGER_COMMAND", lookup(data, "runtime", "manager_command")) +emit_array("CFG_MANAGER_ARGS", lookup(data, "runtime", "manager_args")) +emit_scalar("CFG_MANAGER_ARGS_SET", key_present?(data, "runtime", "manager_args") ? "true" : "") +emit_scalar("CFG_WORKER_COMMAND", lookup(data, "runtime", "worker_command")) +emit_array("CFG_WORKER_ARGS", lookup(data, "runtime", "worker_args")) +emit_scalar("CFG_WORKER_ARGS_SET", key_present?(data, "runtime", "worker_args") ? "true" : "") +emit_scalar("CFG_INSPECTOR_COMMAND", lookup(data, "runtime", "inspector_command")) +emit_array("CFG_INSPECTOR_ARGS", lookup(data, "runtime", "inspector_args")) +emit_scalar("CFG_INSPECTOR_ARGS_SET", key_present?(data, "runtime", "inspector_args") ? "true" : "") emit_scalar("CFG_MANAGER_ROLE", lookup(data, "runtime", "manager_role")) emit_scalar("CFG_WORKER_ROLE", lookup(data, "runtime", "worker_role")) emit_scalar("CFG_INSPECTOR_ROLE", lookup(data, "runtime", "inspector_role")) @@ -739,7 +763,11 @@ build_run_summary() { echo "- Task source root: \`$task_source_root\`" echo "- Inspector prompt source: \`$inspector_prompt_source\`" echo "- Launcher config: \`$launcher_config\`" - echo "- Claude launch: \`$claude_launch_command\`" + echo "- Default client launch: \`$default_launch_command\`" + echo "- Manager launch: \`$manager_launch_command\`" + echo "- Worker launch: \`$worker_launch_command\`" + echo "- Inspector launch: \`$inspector_launch_command\`" + echo "- Setup platforms: \`${setup_platforms_display:-none}\`" echo "- Workers: \`$workers\`" echo "- Dry run: \`$dry_run\`" echo "- No setup: \`$no_setup\`" @@ -768,14 +796,69 @@ build_terminal_map() { echo "- tmux session: \`$session_name\`" echo "- workspace: \`$workspace_dir\`" echo - echo "| Pane | Role | Command |" - echo "| --- | --- | --- |" + echo "| Pane | Role | Launch | Slash Command |" + echo "| --- | --- | --- | --- |" for i in "${!pane_labels[@]}"; do - echo "| $i | \`${pane_labels[$i]}\` | \`${pane_commands[$i]}\` |" + echo "| $i | \`${pane_labels[$i]}\` | \`${pane_launch_display[$i]}\` | \`${pane_commands[$i]}\` |" done } >"$output_path" } +join_with_comma_space() { + local joined="" + local item="" + for item in "$@"; do + if [[ -n "$joined" ]]; then + joined+=", " + fi + joined+="$item" + done + printf '%s' "$joined" +} + +platform_for_command() { + local command_name="$1" + local base="" + base="$(basename "$command_name")" + case "$base" in + claude|claude-code) + printf '%s' "claude" + ;; + codex|codex-cli) + printf '%s' "codex" + ;; + gemini) + printf '%s' "gemini" + ;; + opencode) + printf '%s' "opencode" + ;; + *) + return 1 + ;; + esac +} + +add_unique_item() { + local value="$1" + local array_name="${2:-unique_items}" + local item="" + local count=0 + [[ -n "$value" ]] || return 0 + if ! declare -p "$array_name" >/dev/null 2>&1; then + eval "$array_name=()" + fi + eval "count=\${#$array_name[@]}" + if (( count > 0 )); then + eval 'for item in "${'"$array_name"'[@]}"; do + if [[ "$item" == "$value" ]]; then + return 0 + fi + done' + fi + eval "$array_name+=(\"\$value\")" +} + no_setup=0 no_attach=0 dry_run=0 @@ -880,6 +963,15 @@ CFG_PROJECT_NAME="" CFG_SESSION_NAME="" CFG_CLAUDE_COMMAND="" CFG_CLAUDE_ARGS=() +CFG_MANAGER_COMMAND="" +CFG_MANAGER_ARGS=() +CFG_MANAGER_ARGS_SET="" +CFG_WORKER_COMMAND="" +CFG_WORKER_ARGS=() +CFG_WORKER_ARGS_SET="" +CFG_INSPECTOR_COMMAND="" +CFG_INSPECTOR_ARGS=() +CFG_INSPECTOR_ARGS_SET="" CFG_MANAGER_ROLE="" CFG_WORKER_ROLE="" CFG_INSPECTOR_ROLE="" @@ -903,12 +995,8 @@ load_launcher_config "$launcher_config" project_name="${CFG_PROJECT_NAME:-$(basename "$project_dir")}" session_name="${session_name_override:-${CFG_SESSION_NAME:-${project_name}-squad}}" session_name="$(normalize_session_name "$session_name")" -claude_command="${CFG_CLAUDE_COMMAND:-claude}" -if [[ "$claude_command" == "~" ]]; then - claude_command="$HOME" -elif [[ "${claude_command:0:2}" == "~/" ]]; then - claude_command="$HOME/${claude_command:2}" -fi +default_command="${CFG_CLAUDE_COMMAND:-claude}" +default_command="$(expand_home_path "$default_command")" manager_role="${CFG_MANAGER_ROLE:-manager}" worker_role="${CFG_WORKER_ROLE:-worker}" inspector_role="${CFG_INSPECTOR_ROLE:-inspector}" @@ -929,7 +1017,10 @@ if ! [[ "$workers" =~ ^[0-9]+$ ]] || (( workers < 1 )); then exit 1 fi -copy_array_or_empty claude_args CFG_CLAUDE_ARGS +copy_array_or_empty default_args CFG_CLAUDE_ARGS +copy_array_or_empty manager_args_raw CFG_MANAGER_ARGS +copy_array_or_empty worker_args_raw CFG_WORKER_ARGS +copy_array_or_empty inspector_args_raw CFG_INSPECTOR_ARGS copy_array_or_empty init_args CFG_INIT_ARGS copy_array_or_empty task_discovery_plan_globs CFG_TASK_DISCOVERY_PLAN_GLOBS copy_array_or_empty task_discovery_spec_globs CFG_TASK_DISCOVERY_SPEC_GLOBS @@ -939,6 +1030,38 @@ copy_array_or_empty constraints CFG_CONSTRAINTS task_discovery_plan_suffix="${CFG_TASK_DISCOVERY_PLAN_SUFFIX:--implementation.md}" task_discovery_spec_suffix="${CFG_TASK_DISCOVERY_SPEC_SUFFIX:--design.md}" +manager_command="${CFG_MANAGER_COMMAND:-$default_command}" +manager_command="$(expand_home_path "$manager_command")" +worker_command="${CFG_WORKER_COMMAND:-$default_command}" +worker_command="$(expand_home_path "$worker_command")" +inspector_command="${CFG_INSPECTOR_COMMAND:-$default_command}" +inspector_command="$(expand_home_path "$inspector_command")" + +manager_args=() +worker_args=() +inspector_args=() +if is_truthy "${CFG_MANAGER_ARGS_SET:-}"; then + copy_array_or_empty manager_args CFG_MANAGER_ARGS +elif [[ -n "${CFG_MANAGER_COMMAND:-}" ]]; then + manager_args=() +else + copy_array_or_empty manager_args CFG_CLAUDE_ARGS +fi +if is_truthy "${CFG_WORKER_ARGS_SET:-}"; then + copy_array_or_empty worker_args CFG_WORKER_ARGS +elif [[ -n "${CFG_WORKER_COMMAND:-}" ]]; then + worker_args=() +else + copy_array_or_empty worker_args CFG_CLAUDE_ARGS +fi +if is_truthy "${CFG_INSPECTOR_ARGS_SET:-}"; then + copy_array_or_empty inspector_args CFG_INSPECTOR_ARGS +elif [[ -n "${CFG_INSPECTOR_COMMAND:-}" ]]; then + inspector_args=() +else + copy_array_or_empty inspector_args CFG_CLAUDE_ARGS +fi + task_file="" task_source_kind="" task_source_path="" @@ -1014,9 +1137,21 @@ if (( worktree_enabled == 1 )); then fi mkdir -p "$quickstart_dir" -claude_launch_command="$(shell_join "$claude_command")" -if (( ${#claude_args[@]} > 0 )); then - claude_launch_command="$(shell_join "$claude_command" "${claude_args[@]}")" +default_launch_command="$(shell_join "$default_command")" +if (( ${#default_args[@]} > 0 )); then + default_launch_command="$(shell_join "$default_command" "${default_args[@]}")" +fi +manager_launch_command="$(shell_join "$manager_command")" +if (( ${#manager_args[@]} > 0 )); then + manager_launch_command="$(shell_join "$manager_command" "${manager_args[@]}")" +fi +worker_launch_command="$(shell_join "$worker_command")" +if (( ${#worker_args[@]} > 0 )); then + worker_launch_command="$(shell_join "$worker_command" "${worker_args[@]}")" +fi +inspector_launch_command="$(shell_join "$inspector_command")" +if (( ${#inspector_args[@]} > 0 )); then + inspector_launch_command="$(shell_join "$inspector_command" "${inspector_args[@]}")" fi inspector_prompt_source="$source_project_dir/.squad/prompts/inspector.md" @@ -1027,6 +1162,9 @@ terminal_map_file="$quickstart_dir/generated-terminal-map.md" pane_labels=("$manager_role") pane_commands=("/squad $manager_role") +pane_launch_commands=("$manager_launch_command") +pane_exec_commands=("$manager_command") +pane_launch_display=("$manager_launch_command") for ((i = 1; i <= workers; i++)); do if (( i == 1 )); then pane_labels+=("$worker_role") @@ -1035,9 +1173,27 @@ for ((i = 1; i <= workers; i++)); do pane_labels+=("${worker_role}-${i}") pane_commands+=("/squad $worker_role ${worker_role}-${i}") fi + pane_launch_commands+=("$worker_launch_command") + pane_exec_commands+=("$worker_command") + pane_launch_display+=("$worker_launch_command") done pane_labels+=("$inspector_role") pane_commands+=("/squad $inspector_role") +pane_launch_commands+=("$inspector_launch_command") +pane_exec_commands+=("$inspector_command") +pane_launch_display+=("$inspector_launch_command") + +unique_items=() +for role_command in "$manager_command" "$worker_command" "$inspector_command"; do + if platform="$(platform_for_command "$role_command" 2>/dev/null)"; then + add_unique_item "$platform" unique_items + fi +done +setup_platforms=("${unique_items[@]}") +setup_platforms_display="none" +if (( ${#setup_platforms[@]} > 0 )); then + setup_platforms_display="$(join_with_comma_space "${setup_platforms[@]}")" +fi build_manager_prompt "$prompt_file" "$project_name" "$workspace_dir" "$task_file" build_inspector_prompt "$inspector_prompt_file" "$project_name" "$workspace_dir" "$task_file" "$inspector_prompt_source" @@ -1056,14 +1212,28 @@ if (( dry_run == 1 )); then fi require_cmd squad -require_cmd "$claude_command" +unique_commands=() +for role_command in "$manager_command" "$worker_command" "$inspector_command"; do + add_unique_item "$role_command" unique_commands +done +for required_command in "${unique_commands[@]}"; do + require_cmd "$required_command" +done require_cmd tmux if (( no_setup == 0 )); then - echo "[1/6] Refreshing Claude /squad command" - squad setup claude + if (( ${#setup_platforms[@]} == 0 )); then + echo "[1/6] No supported squad client platforms detected; skipping squad setup" + else + echo "[1/6] Refreshing /squad command for: $setup_platforms_display" + for platform in "${setup_platforms[@]}"; do + squad setup "$platform" + done + fi else - echo "[1/6] Skipping squad setup claude (--no-setup)" + setup_platforms=() + setup_platforms_display="" + echo "[1/6] Skipping squad setup (--no-setup)" fi echo "[2/6] Initializing squad workspace" @@ -1092,9 +1262,10 @@ if tmux has-session -t "$session_name" 2>/dev/null; then fi echo "[3/6] Creating tmux session" -start_cmd="cd $(shell_escape "$workspace_dir") && exec $claude_launch_command" +start_cmd="cd $(shell_escape "$workspace_dir") && exec ${pane_launch_commands[0]}" tmux new-session -d -s "$session_name" -n squad "$start_cmd" for ((i = 1; i < ${#pane_labels[@]}; i++)); do + start_cmd="cd $(shell_escape "$workspace_dir") && exec ${pane_launch_commands[$i]}" tmux split-window -t "$session_name":0 "$start_cmd" done tmux select-layout -t "$session_name":0 tiled @@ -1103,11 +1274,11 @@ for i in "${!pane_labels[@]}"; do tmux select-pane -t "$session_name":0."$i" -T "${pane_labels[$i]}" done -pane_command_aliases=() -while IFS= read -r alias; do - pane_command_aliases+=("$alias") -done < <(pane_command_candidates "$claude_command") for i in "${!pane_labels[@]}"; do + pane_command_aliases=() + while IFS= read -r alias; do + pane_command_aliases+=("$alias") + done < <(pane_command_candidates "${pane_exec_commands[$i]}") wait_for_pane_command "$session_name":0."$i" 30 "${pane_command_aliases[@]}" wait_for_pane_ready "$session_name":0."$i" 30 done diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example index 71d614f..c584b46 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -6,6 +6,17 @@ runtime: claude_command: claude claude_args: - --dangerously-skip-permissions + # Optional per-role overrides. If omitted, the launcher falls back to + # claude_command / claude_args for every pane. + manager_command: codex + manager_args: + - --dangerously-bypass-approvals-and-sandbox + worker_command: claude + worker_args: + - --dangerously-skip-permissions + inspector_command: codex + inspector_args: + - --dangerously-bypass-approvals-and-sandbox manager_role: manager worker_role: worker inspector_role: inspector diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh index 8ebcb11..7d162ee 100644 --- a/tests/squad_tmux_launcher_helpers_test.sh +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -66,12 +66,14 @@ test "$(repo_worktree_location_slug "$repo_dir")" != "$(basename "$repo_dir")" trust_capture=$' Accessing workspace:\n\n Quick safety check: Is this a project you created or one you trust?\n\n ❯ 1. Yes, I trust this folder\n 2. No, exit\n\n Enter to confirm · Esc to cancel\n' ready_capture=$' Claude Code v2.1.87\n\n❯ \n' +codex_ready_capture=$' OpenAI Codex (v0.118.0)\n\n› Use /skills to list available skills\n' pending_command_capture=$' Claude Code v2.1.87\n\n❯ /squad worker\n' active_command_capture=$'❯ /squad worker\n\n⏺ Skill(/squad)\n ⎿ Successfully loaded skill\n\n⏺ Bash(squad init)\n ⎿ Running…\n' pane_capture_has_workspace_trust_prompt "$trust_capture" ! pane_capture_has_workspace_trust_prompt "$ready_capture" pane_capture_has_interactive_prompt "$ready_capture" +pane_capture_has_interactive_prompt "$codex_ready_capture" pane_capture_has_pending_command_input "$pending_command_capture" "/squad worker" ! pane_capture_has_pending_command_input "$ready_capture" "/squad worker" ! pane_capture_has_squad_command_activity "$pending_command_capture" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 148a64a..ebda4c2 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -107,6 +107,83 @@ grep -q "manager" "$map_file" grep -q "worker-2" "$map_file" grep -q "inspector" "$map_file" +mixed_clients_repo="$tmpdir/mixed-clients-repo" +mkdir -p "$mixed_clients_repo/.squad/prompts" +git -C "$tmpdir" init -b main mixed-clients-repo >/dev/null +git -C "$mixed_clients_repo" config user.email "codex@example.com" +git -C "$mixed_clients_repo" config user.name "Codex" +echo "mixed" >"$mixed_clients_repo/README.md" +git -C "$mixed_clients_repo" add README.md +git -C "$mixed_clients_repo" commit -m "seed" >/dev/null + +cat >"$mixed_clients_repo/.squad/launcher.yaml" <<'EOF' +project: + name: mixed-clients + +runtime: + claude_command: claude + claude_args: + - --dangerously-skip-permissions + manager_command: codex + manager_args: + - --dangerously-bypass-approvals-and-sandbox + worker_command: claude + worker_args: + - --dangerously-skip-permissions + inspector_command: codex + inspector_args: + - --dangerously-bypass-approvals-and-sandbox +EOF + +cat >"$mixed_clients_repo/.squad/run-task.md" <<'EOF' +# Task +Run codex for manager and inspector, and claude for workers. +EOF + +bash "$launcher" "$mixed_clients_repo" --dry-run --no-setup --no-attach >/dev/null + +mixed_summary="$mixed_clients_repo/.squad/quickstart/generated-run-summary.md" +mixed_map="$mixed_clients_repo/.squad/quickstart/generated-terminal-map.md" + +grep -q 'Manager launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$mixed_summary" +grep -q 'Worker launch: `claude --dangerously-skip-permissions`' "$mixed_summary" +grep -q 'Inspector launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$mixed_summary" +grep -q 'Setup platforms: `codex, claude`' "$mixed_summary" +grep -q '| 0 | `manager` | `codex --dangerously-bypass-approvals-and-sandbox` | `/squad manager` |' "$mixed_map" +grep -q '| 1 | `worker` | `claude --dangerously-skip-permissions` | `/squad worker` |' "$mixed_map" +grep -q '| 3 | `inspector` | `codex --dangerously-bypass-approvals-and-sandbox` | `/squad inspector` |' "$mixed_map" + +role_args_repo="$tmpdir/role-args-repo" +mkdir -p "$role_args_repo/.squad" +git -C "$tmpdir" init -b main role-args-repo >/dev/null +git -C "$role_args_repo" config user.email "codex@example.com" +git -C "$role_args_repo" config user.name "Codex" +echo "role-args" >"$role_args_repo/README.md" +git -C "$role_args_repo" add README.md +git -C "$role_args_repo" commit -m "seed" >/dev/null + +cat >"$role_args_repo/.squad/launcher.yaml" <<'EOF' +runtime: + claude_command: claude + claude_args: + - --dangerously-skip-permissions + manager_command: codex +EOF + +cat >"$role_args_repo/.squad/run-task.md" <<'EOF' +# Task +Ensure role-specific command does not inherit claude args by default. +EOF + +bash "$launcher" "$role_args_repo" --dry-run --no-setup --no-attach >/dev/null + +role_args_summary="$role_args_repo/.squad/quickstart/generated-run-summary.md" +grep -q 'Manager launch: `codex`' "$role_args_summary" +if grep -q 'Manager launch: `codex --dangerously-skip-permissions`' "$role_args_summary"; then + echo "manager role unexpectedly inherited global claude args" >&2 + exit 1 +fi + same_name_roots=() for org in org-a org-b; do repo_dir="$tmpdir/$org/demo" From f2555fe96ee00ee977fd1f1ad7cc9c89175e61d1 Mon Sep 17 00:00:00 2001 From: liuhy Date: Thu, 2 Apr 2026 19:57:07 +0800 Subject: [PATCH 15/16] feat: support generic default launcher commands --- scripts/squad-tmux-launch.sh | 21 ++++++++++++----- templates/launcher.yaml.example | 8 ++++--- tests/squad_tmux_launcher_smoke.sh | 36 ++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index cca1ae8..92d7bb4 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -259,6 +259,9 @@ end emit_scalar("CFG_PROJECT_NAME", lookup(data, "project", "name")) emit_scalar("CFG_SESSION_NAME", lookup(data, "project", "session_name")) +emit_scalar("CFG_DEFAULT_COMMAND", lookup(data, "runtime", "command")) +emit_array("CFG_DEFAULT_ARGS", lookup(data, "runtime", "args")) +emit_scalar("CFG_DEFAULT_ARGS_SET", key_present?(data, "runtime", "args") ? "true" : "") emit_scalar("CFG_CLAUDE_COMMAND", lookup(data, "runtime", "claude_command")) emit_array("CFG_CLAUDE_ARGS", lookup(data, "runtime", "claude_args")) emit_scalar("CFG_MANAGER_COMMAND", lookup(data, "runtime", "manager_command")) @@ -961,6 +964,9 @@ default_task_file="$project_dir/.squad/run-task.md" detected_repo_root="$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || true)" CFG_PROJECT_NAME="" CFG_SESSION_NAME="" +CFG_DEFAULT_COMMAND="" +CFG_DEFAULT_ARGS=() +CFG_DEFAULT_ARGS_SET="" CFG_CLAUDE_COMMAND="" CFG_CLAUDE_ARGS=() CFG_MANAGER_COMMAND="" @@ -995,7 +1001,7 @@ load_launcher_config "$launcher_config" project_name="${CFG_PROJECT_NAME:-$(basename "$project_dir")}" session_name="${session_name_override:-${CFG_SESSION_NAME:-${project_name}-squad}}" session_name="$(normalize_session_name "$session_name")" -default_command="${CFG_CLAUDE_COMMAND:-claude}" +default_command="${CFG_DEFAULT_COMMAND:-${CFG_CLAUDE_COMMAND:-claude}}" default_command="$(expand_home_path "$default_command")" manager_role="${CFG_MANAGER_ROLE:-manager}" worker_role="${CFG_WORKER_ROLE:-worker}" @@ -1017,7 +1023,12 @@ if ! [[ "$workers" =~ ^[0-9]+$ ]] || (( workers < 1 )); then exit 1 fi -copy_array_or_empty default_args CFG_CLAUDE_ARGS +default_args=() +if is_truthy "${CFG_DEFAULT_ARGS_SET:-}"; then + copy_array_or_empty default_args CFG_DEFAULT_ARGS +else + copy_array_or_empty default_args CFG_CLAUDE_ARGS +fi copy_array_or_empty manager_args_raw CFG_MANAGER_ARGS copy_array_or_empty worker_args_raw CFG_WORKER_ARGS copy_array_or_empty inspector_args_raw CFG_INSPECTOR_ARGS @@ -1045,21 +1056,21 @@ if is_truthy "${CFG_MANAGER_ARGS_SET:-}"; then elif [[ -n "${CFG_MANAGER_COMMAND:-}" ]]; then manager_args=() else - copy_array_or_empty manager_args CFG_CLAUDE_ARGS + copy_array_or_empty manager_args default_args fi if is_truthy "${CFG_WORKER_ARGS_SET:-}"; then copy_array_or_empty worker_args CFG_WORKER_ARGS elif [[ -n "${CFG_WORKER_COMMAND:-}" ]]; then worker_args=() else - copy_array_or_empty worker_args CFG_CLAUDE_ARGS + copy_array_or_empty worker_args default_args fi if is_truthy "${CFG_INSPECTOR_ARGS_SET:-}"; then copy_array_or_empty inspector_args CFG_INSPECTOR_ARGS elif [[ -n "${CFG_INSPECTOR_COMMAND:-}" ]]; then inspector_args=() else - copy_array_or_empty inspector_args CFG_CLAUDE_ARGS + copy_array_or_empty inspector_args default_args fi task_file="" diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example index c584b46..858c8fa 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -3,11 +3,13 @@ project: session_name: my-project-squad runtime: - claude_command: claude - claude_args: + # Generic defaults for all panes. Legacy aliases `claude_command` / + # `claude_args` are still supported for backwards compatibility. + command: claude + args: - --dangerously-skip-permissions # Optional per-role overrides. If omitted, the launcher falls back to - # claude_command / claude_args for every pane. + # runtime.command / runtime.args for every pane. manager_command: codex manager_args: - --dangerously-bypass-approvals-and-sandbox diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index ebda4c2..bf6c145 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -121,8 +121,8 @@ project: name: mixed-clients runtime: - claude_command: claude - claude_args: + command: claude + args: - --dangerously-skip-permissions manager_command: codex manager_args: @@ -153,6 +153,38 @@ grep -q '| 0 | `manager` | `codex --dangerously-bypass-approvals-and-sandbox` | grep -q '| 1 | `worker` | `claude --dangerously-skip-permissions` | `/squad worker` |' "$mixed_map" grep -q '| 3 | `inspector` | `codex --dangerously-bypass-approvals-and-sandbox` | `/squad inspector` |' "$mixed_map" +generic_defaults_repo="$tmpdir/generic-defaults-repo" +mkdir -p "$generic_defaults_repo/.squad" +git -C "$tmpdir" init -b main generic-defaults-repo >/dev/null +git -C "$generic_defaults_repo" config user.email "codex@example.com" +git -C "$generic_defaults_repo" config user.name "Codex" +echo "generic-defaults" >"$generic_defaults_repo/README.md" +git -C "$generic_defaults_repo" add README.md +git -C "$generic_defaults_repo" commit -m "seed" >/dev/null + +cat >"$generic_defaults_repo/.squad/launcher.yaml" <<'EOF' +runtime: + command: codex + args: + - --dangerously-bypass-approvals-and-sandbox +EOF + +cat >"$generic_defaults_repo/.squad/run-task.md" <<'EOF' +# Task +Ensure generic default runtime fields work for all panes. +EOF + +bash "$launcher" "$generic_defaults_repo" --dry-run --no-setup --no-attach >/dev/null + +generic_defaults_summary="$generic_defaults_repo/.squad/quickstart/generated-run-summary.md" +generic_defaults_map="$generic_defaults_repo/.squad/quickstart/generated-terminal-map.md" +grep -q 'Default client launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$generic_defaults_summary" +grep -q 'Manager launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$generic_defaults_summary" +grep -q 'Worker launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$generic_defaults_summary" +grep -q 'Inspector launch: `codex --dangerously-bypass-approvals-and-sandbox`' "$generic_defaults_summary" +grep -q 'Setup platforms: `codex`' "$generic_defaults_summary" +grep -q '| 0 | `manager` | `codex --dangerously-bypass-approvals-and-sandbox` | `/squad manager` |' "$generic_defaults_map" + role_args_repo="$tmpdir/role-args-repo" mkdir -p "$role_args_repo/.squad" git -C "$tmpdir" init -b main role-args-repo >/dev/null From 2e198f64141fa27d7734c4b11d4cb2514cea3aa6 Mon Sep 17 00:00:00 2001 From: liuhy Date: Thu, 2 Apr 2026 19:59:48 +0800 Subject: [PATCH 16/16] docs: describe generic launcher client defaults --- README.md | 32 ++++++++++++++++++++++++++++++-- README.zh-CN.md | 32 ++++++++++++++++++++++++++++++-- scripts/squad-tmux-launch.sh | 7 +++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dbf9ce3..1b47d95 100644 --- a/README.md +++ b/README.md @@ -89,16 +89,44 @@ It can: - or auto-discover the latest implementation plan + matching spec from `docs/superpowers/...` - or use custom discovery globs from `.squad/launcher.yaml -> task_discovery` - generate manager / inspector prompt files under `.squad/quickstart/` -- start a tiled `tmux` session and inject `/squad` commands into Claude panes +- start a tiled `tmux` session and inject `/squad` commands into the configured AI CLI panes - optionally create an isolated git worktree before launching agents Requirements: - `tmux` - `ruby` (used to parse `launcher.yaml`) -- `claude` +- the configured AI CLI commands (for example `claude`, `codex`, `gemini`, or `opencode`) This launcher is intentionally separate from the core Rust CLI. Treat it as optional automation for people who want a repeatable multi-terminal workflow. +### Launcher client configuration + +The launcher now supports a generic default client plus per-role overrides: + +```yaml +runtime: + command: codex + args: + - --dangerously-bypass-approvals-and-sandbox + + worker_command: claude + worker_args: + - --dangerously-skip-permissions +``` + +With that config: +- manager panes use `codex --dangerously-bypass-approvals-and-sandbox` +- worker panes use `claude --dangerously-skip-permissions` +- inspector panes fall back to the default `codex` command unless you override them separately + +Supported runtime keys: +- `runtime.command` / `runtime.args`: default client command for all panes +- `runtime.manager_command` / `runtime.manager_args` +- `runtime.worker_command` / `runtime.worker_args` +- `runtime.inspector_command` / `runtime.inspector_args` + +For backwards compatibility, `runtime.claude_command` and `runtime.claude_args` are still accepted as legacy aliases for the default client configuration. + ### Launcher task discovery Task sources are resolved in this order: diff --git a/README.zh-CN.md b/README.zh-CN.md index fdad443..04b755e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,16 +91,44 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run - 或自动发现 `docs/superpowers/...` 下最新的 implementation plan 和匹配 spec - 或通过 `.squad/launcher.yaml -> task_discovery` 使用自定义发现规则 - 在 `.squad/quickstart/` 下生成 manager / inspector prompt -- 启动平铺布局的 `tmux` 会话,并自动向 Claude pane 注入 `/squad` 命令 +- 启动平铺布局的 `tmux` 会话,并自动向配置好的 AI CLI pane 注入 `/squad` 命令 - 在启动 agent 前可选地创建独立 git worktree 依赖: - `tmux` - `ruby`(用于解析 `launcher.yaml`) -- `claude` +- 你在配置里指定的 AI CLI 命令(例如 `claude`、`codex`、`gemini`、`opencode`) 这个启动器刻意保持在核心 Rust CLI 之外。它是给需要固定化多终端协作流程的用户准备的可选自动化能力。 +### Launcher 客户端配置 + +Launcher 现在支持“通用默认客户端 + 角色级覆盖”: + +```yaml +runtime: + command: codex + args: + - --dangerously-bypass-approvals-and-sandbox + + worker_command: claude + worker_args: + - --dangerously-skip-permissions +``` + +上面的配置表示: +- manager pane 默认使用 `codex --dangerously-bypass-approvals-and-sandbox` +- worker pane 使用 `claude --dangerously-skip-permissions` +- inspector pane 如果没有单独覆盖,则继续继承默认的 `codex` + +支持的运行时字段: +- `runtime.command` / `runtime.args`:所有 pane 的默认客户端命令 +- `runtime.manager_command` / `runtime.manager_args` +- `runtime.worker_command` / `runtime.worker_args` +- `runtime.inspector_command` / `runtime.inspector_args` + +为了向后兼容,`runtime.claude_command` 和 `runtime.claude_args` 仍然可用,并会被当作默认客户端配置的旧别名。 + ### Launcher 任务发现规则 任务输入按下面的优先级解析: diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh index 92d7bb4..b3a18bb 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -34,6 +34,13 @@ Task source priority: Project config: /.squad/launcher.yaml +Client config: + runtime.command / runtime.args Default client command for all panes + runtime.manager_command / *_args Manager-specific override + runtime.worker_command / *_args Worker-specific override + runtime.inspector_command / *_args Inspector-specific override + Legacy aliases runtime.claude_command / runtime.claude_args are still supported + Worktree config: /.squad/launcher.yaml -> workspace.worktree Default location when enabled without an explicit path: