diff --git a/kugetsu b/kugetsu new file mode 100755 index 0000000..71c99b0 --- /dev/null +++ b/kugetsu @@ -0,0 +1,1699 @@ +#!/bin/bash +set -euo pipefail + +KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}" +SESSIONS_DIR="$KUGETSU_DIR/sessions" +WORKTREES_DIR="$KUGETSU_DIR/worktrees" +REPOS_CONFIG="$KUGETSU_DIR/repos.json" +INDEX_FILE="$KUGETSU_DIR/index.json" +NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json" +LOGS_DIR="$KUGETSU_DIR/logs" +ENV_DIR="${ENV_DIR:-$KUGETSU_DIR/env}" +VERBOSITY_DIR="$KUGETSU_DIR/verbosity" + +MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}" +KUGETSU_VERBOSITY="${KUGETSU_VERBOSITY:-default}" + +# Load user config overrides (~/.kugetsu/config) +if [ -f "$KUGETSU_DIR/config" ]; then + source "$KUGETSU_DIR/config" +fi + +mask_sensitive_vars() { + local line="$1" + for var in GITEA_TOKEN GITHUB_TOKEN GITLAB_TOKEN API_KEY PASSWORD TOKEN SECRET; do + if [[ "$line" =~ $var ]]; then + line=$(echo "$line" | sed -E "s/=.*/=***MASKED***/") + fi + done + echo "$line" +} + +load_agent_env() { + local agent_type="${1:-base}" + local env_file="$ENV_DIR/${agent_type}.env" + + if [ -f "$env_file" ]; then + set -a + source "$env_file" + set +a + elif [ -f "$ENV_DIR/default.env" ]; then + set -a + source "$ENV_DIR/default.env" + set +a + fi +} + +count_active_dev_sessions() { + local count=0 + if [ -d "$SESSIONS_DIR" ]; then + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file") + if [ "$filename" != "base.json" ]; then + count=$((count + 1)) + fi + fi + done + fi + echo "$count" +} + +usage() { + cat << 'EOF' +kugetsu - OpenCode Session Manager (Issue-Driven) + +Usage: + kugetsu init [--force] Initialize base + pm-agent sessions (requires TTY) + kugetsu start [--debug] Start task for issue (forks base session) + kugetsu continue [message] [--debug] Continue existing task for issue + kugetsu delegate Send message to PM agent (fire-and-forget) + kugetsu logs [n] Show recent delegation logs (default: 10) + kugetsu status Check kugetsu initialization status + kugetsu doctor [--fix] Diagnose and fix kugetsu issues + kugetsu notify [list|clear] Show or clear notifications + kugetsu list List all tracked sessions + kugetsu prune [--force] Remove orphaned sessions (keeps base + pm-agent) + kugetsu destroy [-y] Delete session for issue + kugetsu destroy --pm-agent [-y] Delete pm-agent session (not recommended) + kugetsu destroy --base [-y] Delete base session + kugetsu help Show this help + +Issue Ref Format: + instance/user/repo#number + Example: github.com/shoko/kugetsu#14 + +Commands: + init Create base + pm-agent sessions via TUI. Requires terminal access. + Use --force to reinitialize if sessions exist. + start Fork new session from base for specific issue. + Requires pm-agent to be running (created by init). + continue Continue work on existing issue session. + delegate Send message to PM agent for task coordination. + Fire-and-forget: returns immediately, runs in background. + Use 'kugetsu logs' to check output. + logs Show recent delegation logs. + Default: 10 most recent. Use 'kugetsu logs 20' for more. + status Check if kugetsu is initialized and PM agent is active. + doctor Diagnose kugetsu issues. Use --fix to attempt repairs. + notify Show or clear notifications from PM agent. + Use 'kugetsu notify list' to see unread notifications. + list Show all sessions (base + pm-agent + forked issues). + prune Remove sessions not in index (orphaned from opencode). + Use --force to skip confirmation. + destroy Delete specific issue, pm-agent, or base session. + +Options: + --debug Show real-time debug output and capture to debug.log + +PM Context: + kugetsu reads ~/.kugetsu/pm-agent.md (if exists) and injects it + into the PM agent session at init time. This allows customizing PM + behavior without recreating the session. + +Notifications: + PM Agent writes task completion notifications to ~/.kugetsu/notifications.json + Use 'kugetsu notify list' to see unread notifications. + +Examples: + kugetsu init + kugetsu status + kugetsu delegate "work on issue #5" + kugetsu logs + kugetsu logs 20 + kugetsu doctor + kugetsu doctor --fix + kugetsu notify list + kugetsu notify clear + kugetsu start github.com/shoko/kugetsu#14 "fix bug" + kugetsu continue github.com/shoko/kugetsu#14 "add tests" + kugetsu list +EOF +} + +ensure_dirs() { + mkdir -p "$SESSIONS_DIR" +} + +ensure_worktree_dir() { + mkdir -p "$WORKTREES_DIR" +} + +issue_ref_to_worktree_name() { + local issue_ref="$1" + echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/' +} + +issue_ref_to_worktree_path() { + local issue_ref="$1" + local parent_dir="${2:-$WORKTREES_DIR}" + local worktree_name=$(issue_ref_to_worktree_name "$issue_ref") + echo "$parent_dir/.kugetsu-worktrees/$worktree_name" +} + +issue_ref_to_branch_name() { + local issue_ref="$1" + local number_part=$(echo "$issue_ref" | grep -oE '#[0-9]+$' || echo "") + if [ -n "$number_part" ]; then + echo "fix/issue-${number_part#\#}" + else + local identifier=$(echo "$issue_ref" | grep -oE '#[^-]+$' || echo "") + if [ -n "$identifier" ]; then + local clean_id=$(echo "$identifier" | sed 's/^#//' | sed 's/-/_/g') + echo "fix/${clean_id}" + else + echo "fix/issue-temp" + fi + fi +} + +get_repo_url() { + local issue_ref="$1" + + if [ -f "$REPOS_CONFIG" ]; then + local url=$(python3 -c "import json, sys; d=json.load(open('$REPOS_CONFIG')); print(d.get('$issue_ref', ''))" 2>/dev/null || echo "") + if [ -n "$url" ]; then + echo "$url" + return + fi + fi + + local instance=$(echo "$issue_ref" | cut -d'/' -f1 | cut -d'#' -f1) + local rest=$(echo "$issue_ref" | sed 's/.*\///' | sed 's/#.*//') + + if [ -n "${GIT_SERVERS[$instance]:-}" ]; then + echo "${GIT_SERVERS[$instance]}/${rest}.git" + return + fi + + if [ -n "${GIT_SERVERS[$DEFAULT_GIT_SERVER]:-}" ]; then + echo "${GIT_SERVERS[$DEFAULT_GIT_SERVER]}/${rest}.git" + return + fi + + echo "https://${instance}/${rest}.git" +} + +worktree_exists() { + local issue_ref="$1" + local parent_dir="${2:-$PWD}" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir") + [ -d "$worktree_path" ] +} + +create_worktree() { + local issue_ref="$1" + local parent_dir="${2:-$PWD}" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir") + local branch_name=$(issue_ref_to_branch_name "$issue_ref") + local repo_url=$(get_repo_url "$issue_ref") + + if [ -z "$repo_url" ]; then + echo "Error: Cannot determine repo URL for '$issue_ref'" >&2 + echo "Please add to $REPOS_CONFIG or ensure worktree exists" >&2 + exit 1 + fi + + local worktree_parent_dir=$(dirname "$worktree_path") + mkdir -p "$worktree_parent_dir" + + if worktree_exists "$issue_ref" "$parent_dir"; then + echo "Removing existing worktree at '$worktree_path'..." + git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" + fi + + echo "Creating worktree at '$worktree_path'..." + git clone "$repo_url" "$worktree_path" 2>/dev/null || { + echo "Error: Failed to clone repository" >&2 + exit 1 + } + + echo "Creating branch '$branch_name'..." + (cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || { + echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2 + } + + echo "Worktree created at: $worktree_path" +} + +remove_worktree_for_issue() { + local issue_ref="$1" + local parent_dir="${2:-$PWD}" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir") + + if worktree_exists "$issue_ref" "$parent_dir"; then + echo "Removing worktree at '$worktree_path'..." + git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" + fi +} + +get_worktree_path_for_session() { + local session_file="$1" + if [ -f "$session_file" ]; then + python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "" + else + echo "" + fi +} + +issue_ref_to_filename() { + local issue_ref="$1" + echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/' +} + +filename_to_issue_ref() { + local filename="$1" + local name="${filename%.json}" + echo "$name" | sed 's/-\([0-9]*\)$/#\1' | sed 's/-/\//g' +} + +read_index() { + if [ -f "$INDEX_FILE" ]; then + cat "$INDEX_FILE" + else + echo '{"base": null, "pm_agent": null, "issues": {}}' + fi +} + +write_index() { + local base="$1" + local pm_agent="$2" + local issues_json="$3" + local temp_file="$INDEX_FILE.tmp.$$" + printf '{"base": %s, "pm_agent": %s, "issues": %s}\n' "$base" "$pm_agent" "$issues_json" > "$temp_file" + mv "$temp_file" "$INDEX_FILE" +} + +get_base_session_id() { + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or '')" +} + +get_pm_agent_session_id() { + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') or '')" +} + +get_session_for_issue() { + local issue_ref="$1" + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['issues'].get('$issue_ref') or '')" +} + +set_base_in_index() { + local base_session_id="$1" + local pm_agent=$(get_pm_agent_session_id) + local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base_session_id\"" "null" "$issues_json" + else + write_index "\"$base_session_id\"" "\"$pm_agent\"" "$issues_json" + fi +} + +set_pm_agent_in_index() { + local pm_agent_session_id="$1" + local base=$(get_base_session_id) + local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + if [ -z "$base" ] || [ "$base" = "null" ]; then + write_index "null" "\"$pm_agent_session_id\"" "$issues_json" + else + write_index "\"$base\"" "\"$pm_agent_session_id\"" "$issues_json" + fi +} + +add_issue_to_index() { + local issue_ref="$1" + local session_file="$2" + local index=$(read_index) + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_session_id) + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + local new_issues=$(echo "$issues" | python3 -c "import sys, json; d=json.load(sys.stdin); d['$issue_ref']='$session_file'; print(json.dumps(d))") + if [ -z "$base" ] || [ "$base" = "null" ]; then + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "null" "null" "$new_issues" + else + write_index "null" "\"$pm_agent\"" "$new_issues" + fi + else + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base\"" "null" "$new_issues" + else + write_index "\"$base\"" "\"$pm_agent\"" "$new_issues" + fi + fi +} + +remove_issue_from_index() { + local issue_ref="$1" + local index=$(read_index) + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_session_id) + local new_issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); d['issues'].pop('$issue_ref', None); print(json.dumps(d['issues']))") + if [ -z "$base" ] || [ "$base" = "null" ]; then + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "null" "null" "$new_issues" + else + write_index "null" "\"$pm_agent\"" "$new_issues" + fi + else + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base\"" "null" "$new_issues" + else + write_index "\"$base\"" "\"$pm_agent\"" "$new_issues" + fi + fi +} + +validate_issue_ref() { + local issue_ref="$1" + if [[ ! "$issue_ref" =~ ^[^/]+/[^/]+/[^#]+#[0-9]+$ ]]; then + echo "Error: invalid issue ref format" >&2 + echo "Expected: instance/user/repo#number" >&2 + echo "Example: github.com/shoko/kugetsu#14" >&2 + exit 1 + fi +} + +check_opencode_session_exists() { + local session_id="$1" + opencode session list --format json 2>/dev/null | grep -q "\"$session_id\"" +} + +kugetsu_get_pm_context() { + local user_pm_context="${KUGETSU_DIR}/pm-agent.md" + local skill_pm_context="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../pm/SKILL.md" + + if [ -f "$user_pm_context" ]; then + cat "$user_pm_context" + elif [ -f "$skill_pm_context" ]; then + cat "$skill_pm_context" + else + echo "" + fi +} + +kugetsu_add_notification() { + local type="$1" + local message="$2" + local issue_ref="${3:-}" + local gitea_url="${4:-}" + + mkdir -p "$(dirname "$NOTIFICATIONS_FILE")" + + local notification=$(python3 << PYEOF +import json +import os +from datetime import datetime + +notification = { + "type": "$type", + "message": "$message", + "issue_ref": "$issue_ref" if "$issue_ref" else None, + "gitea_url": "$gitea_url" if "$gitea_url" else None, + "timestamp": datetime.now().isoformat(), + "read": False +} + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") +notifications = [] + +if os.path.exists(file_path): + try: + with open(file_path, 'r') as f: + notifications = json.load(f) + except: + notifications = [] + +notifications.append(notification) + +with open(file_path, 'w') as f: + json.dump(notifications, f, indent=2) + +print("Notification added") +PYEOF +) + echo "$notification" +} + +kugetsu_get_notifications() { + local limit="${1:-10}" + + if [ ! -f "$NOTIFICATIONS_FILE" ]; then + echo "[]" + return + fi + + python3 << PYEOF +import json +import os +from datetime import datetime + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") + +if not os.path.exists(file_path): + print("[]") + exit(0) + +try: + with open(file_path, 'r') as f: + notifications = json.load(f) + + unread = [n for n in notifications if not n.get("read", False)] + unread.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + + for n in unread[:$limit]: + ts = n.get("timestamp", "unknown") + ntype = n.get("type", "info") + msg = n.get("message", "") + issue = n.get("issue_ref", "") + gitea = n.get("gitea_url", "") + + print(f"[{ts}] {ntype}: {msg}") + if issue: + print(f" Issue: {issue}") + if gitea: + print(f" Link: {gitea}") + print() + + if not unread: + print("No unread notifications.") + +except Exception as e: + print(f"Error reading notifications: {e}") +PYEOF +} + +kugetsu_clear_notifications() { + if [ ! -f "$NOTIFICATIONS_FILE" ]; then + return + fi + + python3 << PYEOF +import json +import os + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") + +if not os.path.exists(file_path): + exit(0) + +try: + with open(file_path, 'r') as f: + notifications = json.load(f) + + for n in notifications: + n["read"] = True + + with open(file_path, 'w') as f: + json.dump(notifications, f, indent=2) + + print("Notifications marked as read") +except Exception as e: + print(f"Error: {e}") +PYEOF +} + +cmd_notify() { + local action="${1:-}" + + case "$action" in + ""|"list"|"show") + kugetsu_get_notifications 10 + ;; + "clear") + kugetsu_clear_notifications + ;; + *) + echo "Usage: kugetsu notify [list|clear]" + ;; + esac +} + +cmd_status() { + if [ ! -f "$INDEX_FILE" ]; then + echo "kugetsu_not_initialized" + return + fi + + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_session_id) + + if [ -z "$base" ] || [ "$base" = "null" ]; then + echo "base_session_missing" + return + fi + + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ] || [ "$pm_agent" = "None" ]; then + echo "pm_agent_missing" + return + fi + + echo "ok" +} + +get_verbosity_context() { + local verbosity="${KUGETSU_VERBOSITY:-default}" + local verbosity_file="$VERBOSITY_DIR/${verbosity}.md" + + if [ -f "$verbosity_file" ]; then + cat "$verbosity_file" + else + echo "## Verbosity: $verbosity" + fi +} + +init_verbosity_templates() { + mkdir -p "$VERBOSITY_DIR" + + if [ ! -f "$VERBOSITY_DIR/verbose.md" ]; then + cat > "$VERBOSITY_DIR/verbose.md" << 'EOF' +## Verbosity: Verbose + +You are operating in HIGH verbosity mode. Include ALL available context: +- Full command outputs and their results +- Detailed reasoning and thinking process +- All file changes with diffs when relevant +- Complete log excerpts +- Comprehensive status updates +- Ask clarifying questions when uncertain +EOF + fi + + if [ ! -f "$VERBOSITY_DIR/default.md" ]; then + cat > "$VERBOSITY_DIR/default.md" << 'EOF' +## Verbosity: Default + +You are operating in NORMAL verbosity mode. Provide balanced output: +- Standard command outputs and key results +- Moderate reasoning detail +- Important file changes summarized +- Regular status updates +EOF + fi + + if [ ! -f "$VERBOSITY_DIR/quiet.md" ]; then + cat > "$VERBOSITY_DIR/quiet.md" << 'EOF' +## Verbosity: Quiet + +You are operating in QUIET verbosity mode. Keep output minimal: +- Only essential information +- Brief status updates (1-2 sentences) +- Final decisions only +- Yes/No answers when appropriate +EOF + fi +} + +extract_issue_ref_from_message() { + local message="$1" + + if echo "$message" | grep -qE 'https?://[^/]+/[^/]+/[^/]+/issues/[0-9]+'; then + echo "$message" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/issues/[0-9]+' | while read -r url; do + local instance=$(echo "$url" | grep -oE 'https?://[^/]+' | sed 's|https\?://||') + local path=$(echo "$url" | grep -oE '/[^/]+/[^/]+/[^/]+') + local issue_num=$(echo "$url" | grep -oE '[0-9]+$') + echo "${instance}${path}#${issue_num}" + done + return + fi + + if echo "$message" | grep -qE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+'; then + echo "$message" | grep -oE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+' | head -1 + return + fi + + if echo "$message" | grep -qE '#[0-9]+'; then + local num=$(echo "$message" | grep -oE '#[0-9]+' | head -1) + if [ -f "$REPOS_CONFIG" ]; then + local first_repo=$(python3 -c "import json; d=json.load(open('$REPOS_CONFIG')); print(list(d.values())[0] if d else '')" 2>/dev/null || echo "") + if [ -n "$first_repo" ]; then + local owner_repo=$(echo "$first_repo" | sed 's|https\?://||' | sed 's|\.git$||') + echo "${owner_repo}${num}" + return + fi + fi + echo "github.com/unknown/repo${num}" + return + fi + + echo "" +} + +cmd_delegate() { + local message="${1:-}" + local verbosity="${KUGETSU_VERBOSITY:-default}" + + if [ -z "$message" ]; then + echo "Error: message is required" >&2 + echo "Usage: kugetsu delegate " >&2 + exit 1 + fi + + local issue_ref=$(extract_issue_ref_from_message "$message") + + if [ -n "$issue_ref" ] && [[ "$issue_ref" =~ \#[0-9]+$ ]]; then + cmd_start "$issue_ref" "$message" + return + fi + + local pm_session=$(get_pm_agent_session_id) + if [ -z "$pm_session" ] || [ "$pm_session" = "null" ] || [ "$pm_session" = "None" ]; then + echo "Error: PM agent session not found. Run 'kugetsu init' first." >&2 + exit 1 + fi + + mkdir -p "$LOGS_DIR" + local log_file="$LOGS_DIR/delegate-$(date +%s).log" + + local temp_dir="${KUGETSU_TEMP_DIR:-$HOME/.local/share/opencode/tool-output}" + + mkdir -p "$ENV_DIR" + local env_sh="set -a; export KUGETSU_TEMP_DIR='$temp_dir'; export KUGETSU_VERBOSITY='$verbosity'; " + if [ -f "$ENV_DIR/pm-agent.env" ]; then + env_sh="${env_sh}source '$ENV_DIR/pm-agent.env'; " + elif [ -f "$ENV_DIR/default.env" ]; then + env_sh="${env_sh}source '$ENV_DIR/default.env'; " + fi + env_sh="${env_sh}set +a; " + + nohup sh -c "${env_sh}opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 & + disown + echo "Delegated to PM agent (logged to $(basename "$log_file"))" + echo "Verbosity: $verbosity" +} + +cmd_logs() { + local count="${1:-10}" + + if [ ! -d "$LOGS_DIR" ]; then + echo "No logs found." + return + fi + + # Log rotation: delete logs older than 7 days + find "$LOGS_DIR" -type f -mtime +7 -delete 2>/dev/null + + ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | while read line; do + echo "$line" + done +} + +cmd_env() { + local action="${1:-}" + local agent_type="${2:-}" + + mkdir -p "$ENV_DIR" + + case "$action" in + ""|"list") + echo "Environment files in $ENV_DIR:" + if [ -d "$ENV_DIR" ]; then + for f in "$ENV_DIR"/*.env; do + if [ -f "$f" ]; then + echo " $(basename "$f")" + fi + done + fi + if [ ! -d "$ENV_DIR" ] || [ -z "$(ls -A "$ENV_DIR"/*.env 2>/dev/null)" ]; then + echo " (no env files found)" + fi + ;; + "show") + local file="$ENV_DIR/${agent_type:-default}.env" + if [ -f "$file" ]; then + echo "=== $file ===" + while IFS= read -r line; do + echo "$(mask_sensitive_vars "$line")" + done < "$file" + else + echo "No env file for: ${agent_type:-default}" + fi + ;; + "set") + local key="${2:-}" + local value="${3:-}" + local target="${4:-default}" + if [ -z "$key" ] || [ -z "$value" ]; then + echo "Usage: kugetsu env set [agent]" >&2 + echo " agent: default, pm-agent, or issue ref" >&2 + exit 1 + fi + local file="$ENV_DIR/${target}.env" + if [ -f "$file" ]; then + if grep -q "^${key}=" "$file"; then + sed -i "s|^${key}=.*|${key}=\"${value}\"|" "$file" + else + echo "${key}=\"${value}\"" >> "$file" + fi + else + echo "${key}=\"${value}\"" > "$file" + fi + echo "Set ${key}=${value} in ${target}.env" + ;; + "get") + local key="${2:-}" + local target="${3:-default}" + local file="$ENV_DIR/${target}.env" + if [ -z "$key" ]; then + echo "Usage: kugetsu env get [agent]" >&2 + exit 1 + fi + if [ -f "$file" ]; then + local val=$(grep "^${key}=" "$file" | cut -d'=' -f2 | tr -d '"') + if [ -n "$val" ]; then + echo "$val" + else + echo "Key '$key' not found in ${target}.env" >&2 + exit 1 + fi + else + echo "No env file for: ${target}" >&2 + exit 1 + fi + ;; + "rm"|"remove"|"delete") + local key="${2:-}" + local target="${3:-default}" + if [ -z "$key" ]; then + echo "Usage: kugetsu env rm [agent]" >&2 + exit 1 + fi + local file="$ENV_DIR/${target}.env" + if [ -f "$file" ]; then + grep -v "^${key}=" "$file" > "$file.tmp" && mv "$file.tmp" "$file" + echo "Removed $key from ${target}.env" + fi + ;; + *) + echo "Usage: kugetsu env [args]" >&2 + echo "" >&2 + echo "Commands:" >&2 + echo " list List all env files" >&2 + echo " show [agent] Show env file contents (masked)" >&2 + echo " set [a] Set key=value in agent env (default/pm-agent)" >&2 + echo " get [a] Get value for key" >&2 + echo " rm [a] Remove key from agent env" >&2 + exit 1 + ;; + esac +} + +cmd_doctor() { + local fix=false + local fix_permissions=false + + while [ $# -gt 0 ]; do + case "$1" in + --fix) + fix=true + ;; + --fix-permissions) + fix_permissions=true + ;; + *) + ;; + esac + shift + done + + echo "=== kugetsu doctor ===" + echo "" + + local issues=0 + + if [ ! -f "$INDEX_FILE" ]; then + echo "[ISSUE] kugetsu not initialized (index.json missing)" + issues=$((issues + 1)) + else + echo "[OK] kugetsu initialized" + + local base=$(get_base_session_id) + if [ -z "$base" ] || [ "$base" = "null" ]; then + echo "[ISSUE] Base session missing" + issues=$((issues + 1)) + else + echo "[OK] Base session: $base" + fi + + local pm_agent=$(get_pm_agent_session_id) + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ] || [ "$pm_agent" = "None" ]; then + echo "[ISSUE] PM agent session missing" + issues=$((issues + 1)) + else + echo "[OK] PM agent: $pm_agent" + fi + + local pm_context_file="${KUGETSU_DIR}/pm-agent.md" + if [ -f "$pm_context_file" ]; then + echo "[OK] PM context file exists" + else + echo "[INFO] PM context file not found (optional): $pm_context_file" + fi + fi + + echo "" + if [ $issues -eq 0 ]; then + echo "No issues found." + else + echo "Found $issues issue(s)." + fi + + if [ "$fix" = true ] && [ $issues -gt 0 ]; then + echo "" + echo "Running fixes..." + + if [ ! -f "$INDEX_FILE" ]; then + echo "Cannot fix: not initialized. Run 'kugetsu init' first." + else + local pm_agent=$(get_pm_agent_session_id) + if [ -n "$pm_agent" ] && [ "$pm_agent" != "null" ] && [ "$pm_agent" != "None" ]; then + echo "[FIX] Recreating PM agent session..." + local base=$(get_base_session_id) + if [ -n "$base" ] && [ "$base" != "null" ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + + local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local before_set="${before_sessions//$'\n'/|}" + + local pm_context=$(kugetsu_get_pm_context) + if [ -n "$pm_context" ]; then + opencode run "You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. $pm_context" --fork --session "$base" 2>&1 || true + else + opencode run "You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. Wait for instructions." --fork --session "$base" 2>&1 || true + fi + + local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local new_pm_session_id="" + while IFS= read -r sess; do + if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$base" ]]; then + new_pm_session_id="$sess" + break + fi + done <<< "$after_sessions" + + if [ -n "$new_pm_session_id" ]; then + printf '{"type": "pm_agent", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_pm_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/pm-agent.json" + set_pm_agent_in_index "$new_pm_session_id" + echo "[FIX] PM agent recreated: $new_pm_session_id" + else + echo "[FIX] Warning: Could not detect new PM session ID" + fi + else + echo "[FIX] Cannot recreate PM agent: base session missing" + fi + else + echo "[FIX] Cannot fix: PM agent not initialized. Run 'kugetsu init' first." + fi + fi + fi + + if [ "$fix_permissions" = true ]; then + echo "" + echo "Fixing session permissions..." + fix_session_permissions + fi +} + +fix_session_permissions() { + local opencode_db="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}" + + if [ ! -f "$opencode_db" ]; then + echo "[ERROR] opencode database not found: $opencode_db" + return 1 + fi + + local base_session_id=$(get_base_session_id) + local pm_agent_session_id=$(get_pm_agent_session_id) + + local PERMISSION_JSON='[{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"external_directory","pattern":"*","action":"allow"}]' + + if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then + echo "Updating base session permissions: $base_session_id" + python3 -c " +import sqlite3 +conn = sqlite3.connect('$opencode_db') +cursor = conn.cursor() +cursor.execute(\"UPDATE session SET permission = ? WHERE id = ?\", ('$PERMISSION_JSON', '$base_session_id')) +conn.commit() +print('[OK] Base session permissions updated') +" + fi + + if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ] && [ "$pm_agent_session_id" != "None" ]; then + echo "Updating PM agent session permissions: $pm_agent_session_id" + python3 -c " +import sqlite3 +conn = sqlite3.connect('$opencode_db') +cursor = conn.cursor() +cursor.execute(\"UPDATE session SET permission = ? WHERE id = ?\", ('$PERMISSION_JSON', '$pm_agent_session_id')) +conn.commit() +print('[OK] PM agent session permissions updated') +" + fi + + echo "Session permissions fix complete" +} + +DEBUG_MODE=false + +set_debug_mode() { + DEBUG_MODE=false + local filtered_args=() + while [ $# -gt 0 ]; do + case "$1" in + --debug) + DEBUG_MODE=true + ;; + *) + filtered_args+=("$1") + ;; + esac + shift + done + echo "${filtered_args[@]}" +} + +cmd_server() { + local action="${1:-}" + + case "$action" in + ""|"list") + if [ -z "${GIT_SERVERS+x}" ]; then + echo "No git servers configured" + return + fi + echo "Git servers:" + for key in "${!GIT_SERVERS[@]}"; do + local marker="" + if [ "$key" = "$DEFAULT_GIT_SERVER" ]; then + marker=" (default)" + fi + echo " $key -> ${GIT_SERVERS[$key]}$marker" + done + ;; + "add") + local name="${2:-}" + local url="${3:-}" + if [ -z "$name" ] || [ -z "$url" ]; then + echo "Usage: kugetsu server add " >&2 + exit 1 + fi + if grep -q "^GIT_SERVERS\[" "$KUGETSU_DIR/config" 2>/dev/null; then + sed -i "s|^GIT_SERVERS\[\"$name\"\]=.*|GIT_SERVERS[\"$name\"]=\"$url\"|" "$KUGETSU_DIR/config" + if ! grep -q "GIT_SERVERS\[\"$name\"\]" "$KUGETSU_DIR/config" 2>/dev/null; then + sed -i "/^declare -A GIT_SERVERS/a GIT_SERVERS[\"$name\"]=\"$url\"" "$KUGETSU_DIR/config" + fi + else + echo "declare -A GIT_SERVERS" >> "$KUGETSU_DIR/config" + echo "GIT_SERVERS[\"$name\"]=\"$url\"" >> "$KUGETSU_DIR/config" + fi + source "$KUGETSU_DIR/config" + echo "Added git server: $name -> $url" + ;; + "remove"|"rm"|"delete") + local name="${2:-}" + if [ -z "$name" ]; then + echo "Usage: kugetsu server remove " >&2 + exit 1 + fi + if [ -n "${GIT_SERVERS[$name]:-}" ]; then + if [ "$name" = "$DEFAULT_GIT_SERVER" ]; then + echo "Error: Cannot remove default server. Set a new default first." >&2 + exit 1 + fi + sed -i "/GIT_SERVERS\[\"$name\"\]/d" "$KUGETSU_DIR/config" 2>/dev/null + source "$KUGETSU_DIR/config" + echo "Removed git server: $name" + else + echo "Error: Server '$name' not found" >&2 + exit 1 + fi + ;; + "default") + local name="${2:-}" + if [ -z "$name" ]; then + echo "Current default: $DEFAULT_GIT_SERVER" + return + fi + if [ -n "${GIT_SERVERS[$name]:-}" ]; then + sed -i "s/^DEFAULT_GIT_SERVER=.*/DEFAULT_GIT_SERVER=\"$name\"/" "$KUGETSU_DIR/config" + source "$KUGETSU_DIR/config" + echo "Set default git server to: $name" + else + echo "Error: Server '$name' not found" >&2 + exit 1 + fi + ;; + "get") + local name="${2:-$DEFAULT_GIT_SERVER}" + if [ -n "${GIT_SERVERS[$name]:-}" ]; then + echo "${GIT_SERVERS[$name]}" + else + echo "Error: Server '$name' not found" >&2 + exit 1 + fi + ;; + *) + echo "Usage: kugetsu server " >&2 + echo "" >&2 + echo "Commands:" >&2 + echo " list List all configured git servers" >&2 + echo " add Add a new git server" >&2 + echo " remove Remove a git server" >&2 + echo " default [] Get or set default server" >&2 + echo " get [] Get URL for a server (default: current default)" >&2 + exit 1 + ;; + esac +} + +cmd_init() { + local force=false + + while [ $# -gt 0 ]; do + case "$1" in + --force) + force=true + ;; + *) + ;; + esac + shift + done + + ensure_dirs + + if [ ! -f "$KUGETSU_DIR/config" ] || [ "$force" = true ]; then + cat > "$KUGETSU_DIR/config" << 'EOF' +# User configuration overrides +# Values set here take precedence over defaults +# Changes take effect immediately (no re-init needed) + +# Max concurrent dev agents (default: 3) +# MAX_CONCURRENT_AGENTS=5 + +# Verbosity level for PM agent output (verbose, default, or quiet) +# KUGETSU_VERBOSITY=default + +# Git server configurations +# Format: GIT_SERVERS["hostname"]="https://hostname" +# Add servers with: kugetsu server add +declare -A GIT_SERVERS +GIT_SERVERS["github.com"]="https://github.com" +DEFAULT_GIT_SERVER="github.com" +EOF + echo "Created config file: $KUGETSU_DIR/config" + fi + + mkdir -p "$ENV_DIR" + if [ ! -f "$ENV_DIR/default.env" ]; then + cat > "$ENV_DIR/default.env" << 'EOF' +# Default environment variables for all agents +# Variables here are exported to subagents +# Use 'export' prefix for variables that subagents need +# Example: +# export GITEA_TOKEN=your_token_here +EOF + echo "Created default env file: $ENV_DIR/default.env" + fi + if [ ! -f "$ENV_DIR/pm-agent.env" ]; then + cat > "$ENV_DIR/pm-agent.env" << 'EOF' +# PM Agent environment variables +# These override default.env for the PM agent +# Use 'export' prefix for variables that subagents need +# Example: +# export GITEA_TOKEN=your_gitea_token_here +EOF + echo "Created pm-agent env file: $ENV_DIR/pm-agent.env" + fi + + local existing_base=$(get_base_session_id) + local existing_pm=$(get_pm_agent_session_id) + + if [ -n "$existing_base" ] && [ "$existing_base" != "null" ]; then + if [ "$force" = true ]; then + echo "Warning: Reinitializing sessions (force mode)" >&2 + else + echo "Error: Base session already exists: $existing_base" >&2 + echo "Use --force to reinitialize" >&2 + exit 1 + fi + fi + + if ! test -t 0; then + echo "Error: init requires a terminal (TTY)" >&2 + echo "Please run this command in an interactive shell" >&2 + exit 1 + fi + + echo "Starting TUI to create base session..." + echo "Press Ctrl+C to cancel or wait for session to be created" + sleep 2 + + if ! opencode; then + echo "Error: opencode TUI failed to start" >&2 + echo "Please ensure opencode is installed and accessible" >&2 + exit 1 + fi + + local session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' | tail -1) + if [ -z "$session_ids" ]; then + echo "Error: Could not find newly created session" >&2 + exit 1 + fi + + local new_session_id=$(echo "$session_ids" | tail -1) + local session_file="base.json" + + printf '{"type": "base", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file" + + set_base_in_index "$new_session_id" + echo "Base session initialized: $new_session_id" + + echo "" + echo "Creating PM agent session..." + sleep 1 + + local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local before_set="${before_sessions//$'\n'/|}" + + local pm_context=$(kugetsu_get_pm_context) + local pm_prompt="You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. Wait for instructions." + if [ -n "$pm_context" ]; then + pm_prompt="You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. $pm_context" + fi + + # Set GIT_EDITOR to cat for non-interactive git operations (rebase, etc.) + export GIT_EDITOR=cat + export EDITOR=cat + + opencode run "$pm_prompt" --fork --session "$new_session_id" 2>&1 || true + + local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local new_pm_session_id="" + while IFS= read -r sess; do + if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$new_session_id" ]]; then + new_pm_session_id="$sess" + break + fi + done <<< "$after_sessions" + + if [ -z "$new_pm_session_id" ]; then + echo "Warning: Could not detect PM agent session ID. It may still have been created." >&2 + else + local pm_session_file="pm-agent.json" + printf '{"type": "pm_agent", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_pm_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$pm_session_file" + set_pm_agent_in_index "$new_pm_session_id" + echo "PM agent session initialized: $new_pm_session_id" + fi + + echo "" + echo "Initialization complete!" + echo "- Base session: $new_session_id" + echo "- PM agent: ${new_pm_session_id:-created by hermes}" + + fix_session_permissions +} + +cmd_start() { + local issue_ref="" + local message="" + local args=("$@") + + args=$(set_debug_mode "${args[@]}") + + for arg in $args; do + if [ -z "$issue_ref" ]; then + issue_ref="$arg" + elif [ -z "$message" ]; then + message="$arg" + fi + done + + if [ -z "$issue_ref" ] || [ -z "$message" ]; then + echo "Error: start requires and " >&2 + exit 1 + fi + + validate_issue_ref "$issue_ref" + ensure_dirs + + local base_session_id=$(get_base_session_id) + if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then + echo "Error: No base session. Run 'kugetsu init' first." >&2 + exit 1 + fi + + local pm_agent_session_id=$(get_pm_agent_session_id) + if [ -z "$pm_agent_session_id" ] || [ "$pm_agent_session_id" = "null" ]; then + echo "Error: No PM agent session. Run 'kugetsu init' first to create it." >&2 + exit 1 + fi + + local existing_session=$(get_session_for_issue "$issue_ref") + if [ -n "$existing_session" ] && [ "$existing_session" != "null" ]; then + echo "Error: Session for '$issue_ref' already exists" >&2 + echo "Use 'kugetsu continue $issue_ref ' instead" >&2 + exit 1 + fi + + local parent_dir="$PWD" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir") + create_worktree "$issue_ref" "$parent_dir" + + local session_file="$(issue_ref_to_filename "$issue_ref").json" + + echo "Forking session for '$issue_ref'..." + + # Session-counting: count actual dev sessions, reject if at limit + local active_count=$(count_active_dev_sessions) + if [ "$active_count" -ge "$MAX_CONCURRENT_AGENTS" ]; then + echo "Error: Max concurrent agents ($MAX_CONCURRENT_AGENTS) reached" >&2 + echo "Active sessions: $active_count" >&2 + remove_worktree_for_issue "$issue_ref" "$parent_dir" + exit 1 + fi + + local fork_log="$SESSIONS_DIR/$session_file.fork.log" + local opencode_db="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}" + + fix_session_permissions + + if [ "$DEBUG_MODE" = true ]; then + (cd "$worktree_path" && opencode run "$message" --fork --session "$base_session_id" 2>&1) | tee "$fork_log" & + else + (cd "$worktree_path" && opencode run "$message" --fork --session "$base_session_id" 2>&1) >> "$fork_log" & + fi + + local fork_pid=$! + + local max_attempts=10 + local attempt=1 + local new_session_id="" + local fork_log_output="" + + while [ $attempt -le $max_attempts ]; do + sleep 1 + + new_session_id=$(python3 -c " +import sqlite3 +conn = sqlite3.connect('$opencode_db') +cursor = conn.cursor() +cursor.execute(\"SELECT id FROM session WHERE directory = '$worktree_path' ORDER BY time_created DESC LIMIT 1\") +result = cursor.fetchone() +if result: + print(result[0]) +" 2>/dev/null || echo "") + + if [ -n "$new_session_id" ] && [ "$new_session_id" != "$base_session_id" ] && [ "$new_session_id" != "$pm_agent_session_id" ]; then + break + fi + + if ! kill -0 $fork_pid 2>/dev/null; then + fork_log_output=$(tail -20 "$fork_log" 2>/dev/null || echo "(log empty or unavailable)") + break + fi + + attempt=$((attempt + 1)) + done + + if [ -z "$new_session_id" ]; then + echo "Error: Could not find newly created session after ${max_attempts}s" >&2 + if [ -n "$fork_log_output" ]; then + echo "Fork log output:" >&2 + echo "$fork_log_output" >&2 + fi + remove_worktree_for_issue "$issue_ref" + exit 1 + fi + + echo "Updating permissions for new session: $new_session_id" + python3 -c " +import sqlite3 +conn = sqlite3.connect('$opencode_db') +cursor = conn.cursor() +PERMISSION_JSON = '[{\"permission\":\"question\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"plan_enter\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"plan_exit\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"external_directory\",\"pattern\":\"*\",\"action\":\"allow\"}]' +cursor.execute('UPDATE session SET permission = ? WHERE id = ?', (PERMISSION_JSON, '$new_session_id')) +conn.commit() +print('[OK] Session permissions updated') +" + + if [ "$DEBUG_MODE" = true ]; then + echo "[DEBUG] Forked session permissions check:" + python3 -c " +import sqlite3 +conn = sqlite3.connect('$opencode_db') +cursor = conn.cursor() +cursor.execute(\"SELECT id, directory, permission FROM session WHERE id = '$new_session_id'\") +for row in cursor.fetchall(): + print(' ID:', row[0]) + print(' Directory:', row[1]) + print(' Permission:', row[2]) +" 2>/dev/null || echo " (failed to query DB)" + fi + + printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "worktree_path": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$issue_ref" "$new_session_id" "$worktree_path" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file" + + add_issue_to_index "$issue_ref" "$session_file" + + echo "Session started for '$issue_ref': $new_session_id" + echo "Worktree: $worktree_path" +} + +cmd_continue() { + local session_name="" + local message="" + local args=("$@") + + args=$(set_debug_mode "${args[@]}") + + for arg in $args; do + if [ -z "$session_name" ]; then + session_name="$arg" + elif [ -z "$message" ]; then + message="$arg" + fi + done + + if [ -z "$session_name" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + if [ -z "$message" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + local session_file=$(get_session_for_issue "$session_name") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$session_name'" >&2 + echo "Use 'kugetsu start ' to create one" >&2 + exit 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + if [ ! -f "$session_path" ]; then + echo "Error: Session file missing: $session_path" >&2 + echo "Run 'kugetsu start ' to recreate" >&2 + exit 1 + fi + + local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path'))['opencode_session_id'])") + local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "") + + echo "Continuing session for '$session_name'..." + # Note: --continue always allowed (existing sessions don't count toward limit) + if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then + echo "Using worktree: $worktree_path" + if [ "$DEBUG_MODE" = true ]; then + (cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" 2>&1) | tee "$session_path.debug.log" & + else + (cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" 2>&1) & + fi + else + if [ "$DEBUG_MODE" = true ]; then + opencode run "$message" --continue --session "$opencode_session_id" 2>&1 | tee "$session_path.debug.log" & + else + opencode run "$message" --continue --session "$opencode_session_id" 2>&1 & + fi + fi +} + +cmd_list() { + ensure_dirs + + printf "%-50s %-10s %-25s %-40s\n" "ISSUE_REF" "TYPE" "SESSION_ID" "WORKTREE" + printf "%-50s %-10s %-25s %-40s\n" "─────────" "─────" "──────────" "────────" + + local base_session_id=$(get_base_session_id) + if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then + printf "%-50s %-10s %-25s %-40s\n" "(base)" "base" "$base_session_id" "N/A" + fi + + local pm_agent_session_id=$(get_pm_agent_session_id) + if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ]; then + local pm_created="N/A" + if [ -f "$SESSIONS_DIR/pm-agent.json" ]; then + pm_created=$(python3 -c "import json; print(json.load(open('$SESSIONS_DIR/pm-agent.json'))['created_at'])" 2>/dev/null || echo "N/A") + fi + printf "%-50s %-10s %-25s %-40s\n" "(pm-agent)" "pm_agent" "$pm_agent_session_id" "N/A" + fi + + local index=$(read_index) + local issue_refs=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print('\n'.join(d['issues'].keys()))" 2>/dev/null || true) + + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file" .json) + if [ "$filename" = "base" ] || [ "$filename" = "pm-agent" ]; then + continue + fi + + local issue_ref=$(python3 -c "import json; print(json.load(open('$session_file'))['issue_ref'])" 2>/dev/null || echo "$filename") + local sess_id=$(python3 -c "import json; print(json.load(open('$session_file'))['opencode_session_id'])" 2>/dev/null || echo "unknown") + local created=$(python3 -c "import json; print(json.load(open('$session_file'))['created_at'])" 2>/dev/null || echo "unknown") + local worktree=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', 'N/A'))" 2>/dev/null || echo "N/A") + + printf "%-50s %-10s %-25s %-40s\n" "$issue_ref" "forked" "$sess_id" "$worktree" + fi + done +} + +cmd_prune() { + local force=false + + while [ $# -gt 0 ]; do + case "$1" in + --force) + force=true + ;; + esac + shift + done + + ensure_dirs + ensure_worktree_dir + + local index=$(read_index) + local index_session_files=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); sessions=set(d['issues'].values()); sessions.add('base.json'); sessions.add('pm-agent.json'); print('\n'.join(sessions))" 2>/dev/null || echo -e "base.json\npm-agent.json") + + local orphaned=() + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file") + if ! echo "$index_session_files" | grep -q "^$filename$"; then + orphaned+=("$session_file") + fi + fi + done + + local orphaned_worktrees=() + if [ -d "$WORKTREES_DIR" ]; then + for worktree_path in "$WORKTREES_DIR"/*; do + if [ -d "$worktree_path" ]; then + local worktree_name=$(basename "$worktree_path") + local session_name="${worktree_name}.json" + if ! echo "$index_session_files" | grep -q "^${session_name}$"; then + orphaned_worktrees+=("$worktree_path") + fi + fi + done + fi + + if [ ${#orphaned[@]} -eq 0 ] && [ ${#orphaned_worktrees[@]} -eq 0 ]; then + echo "No orphaned sessions or worktrees found" + return + fi + + if [ ${#orphaned[@]} -gt 0 ]; then + echo "Found ${#orphaned[@]} orphaned session(s):" + for f in "${orphaned[@]}"; do + echo " - $(basename "$f")" + done + fi + + if [ ${#orphaned_worktrees[@]} -gt 0 ]; then + echo "Found ${#orphaned_worktrees[@]} orphaned worktree(s):" + for wt in "${orphaned_worktrees[@]}"; do + echo " - $(basename "$wt")" + done + fi + + if [ "$force" = true ]; then + echo "Removing orphaned items (force mode)..." + for f in "${orphaned[@]}"; do + rm -f "$f" + echo "Removed session: $(basename "$f")" + done + for wt in "${orphaned_worktrees[@]}"; do + git worktree remove "$wt" 2>/dev/null || rm -rf "$wt" + echo "Removed worktree: $(basename "$wt")" + done + else + echo "Run with --force to remove" + fi +} + +cmd_destroy() { + local target="" + local force=false + + while [ $# -gt 0 ]; do + case "$1" in + --base) + target="base" + ;; + --pm-agent) + target="pm-agent" + ;; + -y|--yes) + force=true + ;; + *) + if [ -z "$target" ]; then + target="$1" + fi + ;; + esac + shift + done + + if [ -z "$target" ]; then + echo "Error: destroy requires , --base, or --pm-agent" >&2 + exit 1 + fi + + if [ "$target" = "base" ]; then + if [ "$force" = true ]; then + rm -f "$SESSIONS_DIR/base.json" + local pm_agent=$(get_pm_agent_session_id) + if [ -n "$pm_agent" ] && [ "$pm_agent" != "null" ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE" + else + echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE" + fi + echo "Base session destroyed" + else + echo "Error: destroying base session requires --base -y" >&2 + exit 1 + fi + return + fi + + if [ "$target" = "pm-agent" ]; then + if [ "$force" = true ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + local base=$(get_base_session_id) + if [ -n "$base" ] && [ "$base" != "null" ]; then + write_index "\"$base\"" "null" "{}" + else + write_index "null" "null" "{}" + fi + echo "PM agent session destroyed" + else + echo "Error: destroying pm-agent session requires --pm-agent -y" >&2 + exit 1 + fi + return + fi + + validate_issue_ref "$target" + + local session_file=$(get_session_for_issue "$target") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$target'" >&2 + exit 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + + if [ "$force" = true ]; then + remove_worktree_for_issue "$target" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + else + echo "Delete session and worktree for '$target'? [y/N] " + local reply + read reply + if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then + remove_worktree_for_issue "$target" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + else + echo "Aborted" + fi + fi +} + +main() { + if [ $# -eq 0 ]; then + usage + exit 1 + fi + + local command="$1" + shift + + case "$command" in + help|--help|-h) + usage + ;; + init) + cmd_init "$@" + ;; + start) + cmd_start "$@" + ;; + continue) + cmd_continue "$@" + ;; + delegate) + cmd_delegate "$@" + ;; + logs) + shift + cmd_logs "$@" + ;; + status) + cmd_status + ;; + server) + cmd_server "$@" + ;; + env) + cmd_env "$@" + ;; + doctor) + cmd_doctor "$@" + ;; + notify) + cmd_notify "$@" + ;; + list) + cmd_list "$@" + ;; + prune) + cmd_prune "$@" + ;; + destroy) + cmd_destroy "$@" + ;; + *) + echo "Error: unknown command '$command'" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" \ No newline at end of file