diff --git a/README.md b/README.md index 13423d8..1b47d95 100644 --- a/README.md +++ b/README.md @@ -86,17 +86,74 @@ 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 +- 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: + +1. `--task-file ` +2. `/.squad/run-task.md` +3. auto-discovery + +Default auto-discovery looks for files named with a `YYYY-MM-DD-` date prefix: + +- 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`: + +```yaml +task_discovery: + plan_globs: + - workitems/plans/*-plan.md + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md +``` + +`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 5b22f93..04b755e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,17 +88,74 @@ 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` 命令 +- 启动平铺布局的 `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 任务发现规则 + +任务输入按下面的优先级解析: + +1. `--task-file ` +2. `/.squad/run-task.md` +3. 自动发现 + +默认的自动发现规则会寻找: + +- 最新的 `docs/superpowers/plans/????-??-??-*-implementation.md`(文件名需以 `YYYY-MM-DD-` 日期前缀开头) +- 以及同主题、最新匹配的 `docs/superpowers/specs/????-??-??-*-design.md`(同样要求 `YYYY-MM-DD-` 前缀) + +如果你的仓库目录或命名规则不同,可以在 `.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 +``` + +`plan_globs` 和 `spec_globs` 都是相对于你传入的 `project-dir` 解析的。配置后,launcher 会选出最新的 plan,从文件名里提取 topic,再自动附带同一 topic 的最新 spec。 + ## 使用流程 ``` diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh index e128a23..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="" @@ -170,6 +180,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" == *"❯"* || "$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 f818eef..b3a18bb 100644 --- a/scripts/squad-tmux-launch.sh +++ b/scripts/squad-tmux-launch.sh @@ -18,19 +18,29 @@ 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 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/YYYY-MM-DD-*-implementation.md + plus the newest matching spec 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: @@ -130,6 +140,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 @@ -178,10 +249,37 @@ 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_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")) +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")) @@ -192,6 +290,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")) @@ -199,6 +301,272 @@ 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 +} + +latest_matching_glob_patterns() { + local root="$1" + shift + [[ -d "$root" ]] || return 1 + (( $# > 0 )) || return 1 + + require_cmd ruby + + ruby - "$root" "$@" <<'RUBY' +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 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? +RUBY +} + +all_matching_glob_patterns() { + local root="$1" + shift + [[ -d "$root" ]] || return 1 + (( $# > 0 )) || return 1 + + require_cmd ruby + + ruby - "$root" "$@" <<'RUBY' +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 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 +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" + local name="" + name="$(basename "$file_path")" + + 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 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 +} + +latest_matching_spec_for_plan() { + local plan_file="$1" + 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="$(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 +} + +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" + 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 (( ${#task_discovery_plan_globs[@]} == 0 )) && [[ -n "$repo_root" && "$repo_root" != "$project_dir" ]]; then + search_roots+=("$repo_root") + fi + + for root in "${search_roots[@]}"; do + candidate="$(resolve_discovery_candidate "$root" || true)" + if [[ -n "$candidate" ]]; then + task_source_kind="superpowers-plan" + task_source_path="$candidate" + 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 configure task_discovery.plan_globs in $launcher_config" >&2 + exit 1 + fi + fi + + if [[ "$task_source_kind" == "task-brief" ]] && looks_like_plan_file "$task_source_path"; then + task_source_kind="superpowers-plan" + 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 +} + build_manager_prompt() { local output_path="$1" local project_name="$2" @@ -255,6 +623,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 +647,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,16 +728,26 @@ EOF echo fi - cat <<'EOF' -## 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 if [[ ! -f "$inspector_source" ]]; then cat <<'EOF' ## Review Checklist -Use the task brief above to confirm: +Use the task material 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 @@ -362,9 +767,17 @@ 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\`" + 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\`" @@ -393,14 +806,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 @@ -500,18 +968,23 @@ 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)" 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="" +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="" @@ -522,6 +995,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=() @@ -531,12 +1008,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_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}" @@ -557,11 +1030,64 @@ if ! [[ "$workers" =~ ^[0-9]+$ ]] || (( workers < 1 )); then exit 1 fi -copy_array_or_empty claude_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 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}" + +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 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 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 default_args +fi + +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) @@ -629,9 +1155,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" @@ -642,6 +1180,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") @@ -650,9 +1191,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" @@ -671,14 +1230,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" @@ -707,9 +1280,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 @@ -718,12 +1292,13 @@ 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 echo "[4/6] Sending squad commands" @@ -731,6 +1306,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/templates/launcher.yaml.example b/templates/launcher.yaml.example index 66e6507..858c8fa 100644 --- a/templates/launcher.yaml.example +++ b/templates/launcher.yaml.example @@ -3,9 +3,22 @@ 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 + # runtime.command / runtime.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 @@ -21,6 +34,18 @@ workspace: path: example-task base_ref: HEAD +task_discovery: + # Optional. If omitted, the launcher falls back to: + # 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 + spec_globs: + - workitems/specifications/*-spec.md + plan_suffix: -plan.md + spec_suffix: -spec.md + focus: files: - src/index.ts diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh index d96f499..7d162ee 100644 --- a/tests/squad_tmux_launcher_helpers_test.sh +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -64,6 +64,21 @@ 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' +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" +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" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh index 2674052..bf6c145 100644 --- a/tests/squad_tmux_launcher_smoke.sh +++ b/tests/squad_tmux_launcher_smoke.sh @@ -107,6 +107,115 @@ 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: + command: 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" + +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 +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" @@ -177,4 +286,283 @@ 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" + +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" + +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" + +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"