From f61fbd6dd5b04bb9f1171c3bcc7d2844895cc41a Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:17:25 +0000 Subject: [PATCH] refactor: modularize kugetsu shell script Split the monolithic kugetsu script into modular components: Modules created: - kugetsu-config.sh - Config/env loading and global variables - kugetsu-index.sh - Index.json read/write via JSON - kugetsu-worktree.sh - Git worktree operations - kugetsu-log.sh - Structured logging and notifications - kugetsu-session.sh - Session create/fork/destroy logic - kugetsu-queue-daemon.sh - Queue daemon subprocess Main script (kugetsu) is now a thin dispatcher that sources all modules. Acceptance Criteria: - All existing commands work exactly as before - Main script sources modules - Each module is independently testable Fixes #116 --- skills/kugetsu/scripts/kugetsu | 2129 ++--------------- skills/kugetsu/scripts/kugetsu-config.sh | 57 + skills/kugetsu/scripts/kugetsu-index.sh | 135 ++ skills/kugetsu/scripts/kugetsu-log.sh | 91 + .../kugetsu/scripts/kugetsu-queue-daemon.sh | 32 + skills/kugetsu/scripts/kugetsu-session.sh | 569 +++++ skills/kugetsu/scripts/kugetsu-worktree.sh | 167 ++ 7 files changed, 1301 insertions(+), 1879 deletions(-) create mode 100755 skills/kugetsu/scripts/kugetsu-config.sh create mode 100755 skills/kugetsu/scripts/kugetsu-index.sh create mode 100755 skills/kugetsu/scripts/kugetsu-log.sh create mode 100755 skills/kugetsu/scripts/kugetsu-queue-daemon.sh create mode 100755 skills/kugetsu/scripts/kugetsu-session.sh create mode 100755 skills/kugetsu/scripts/kugetsu-worktree.sh diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu index 5023cc6..b0794f1 100755 --- a/skills/kugetsu/scripts/kugetsu +++ b/skills/kugetsu/scripts/kugetsu @@ -1,75 +1,16 @@ #!/bin/bash +# kugetsu - OpenCode Session Manager +# Main dispatcher - sources all modules + 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" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}" -KUGETSU_VERBOSITY="${KUGETSU_VERBOSITY:-default}" -CONTEXT_DIR="${CONTEXT_DIR:-$KUGETSU_DIR/context}" -ENABLE_CONTEXT_DUMP="${ENABLE_CONTEXT_DUMP:-true}" -WORKTREE_CHECK_PR_STATUS="${WORKTREE_CHECK_PR_STATUS:-true}" - -QUEUE_DIR="${QUEUE_DIR:-$KUGETSU_DIR/queue}" -QUEUE_ITEMS_DIR="${QUEUE_ITEMS_DIR:-$QUEUE_DIR/items}" -QUEUE_DAEMON_PID_FILE="${QUEUE_DAEMON_PID_FILE:-$QUEUE_DIR/daemon.pid}" -QUEUE_DAEMON_LOCK_FILE="${QUEUE_DAEMON_LOCK_FILE:-$QUEUE_DIR/daemon.lock}" -QUEUE_DAEMON_LOG_FILE="${QUEUE_DAEMON_LOG_FILE:-$QUEUE_DIR/daemon.log}" -QUEUE_DAEMON_INTERVAL_MINUTES="${QUEUE_DAEMON_INTERVAL_MINUTES:-5}" -QUEUE_CLEANUP_AGE_DAYS="${QUEUE_CLEANUP_AGE_DAYS:-7}" -TASK_TIMEOUT_HOURS="${TASK_TIMEOUT_HOURS:-1}" - -# 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" ] && [ "$filename" != "pm-agent.json" ]; then - count=$((count + 1)) - fi - fi - done - fi - echo "$count" -} +source "$SCRIPT_DIR/kugetsu-config.sh" +source "$SCRIPT_DIR/kugetsu-index.sh" +source "$SCRIPT_DIR/kugetsu-worktree.sh" +source "$SCRIPT_DIR/kugetsu-log.sh" +source "$SCRIPT_DIR/kugetsu-session.sh" usage() { cat << 'EOF' @@ -94,6 +35,8 @@ Usage: kugetsu queue [list|stats|clear] Show queue status or statistics kugetsu queue enqueue Enqueue a task (normally via delegate) kugetsu queue-daemon [start|stop|restart|status|logs] Manage queue daemon + kugetsu env [get|set|list] Manage agent environment variables + kugetsu server [list|add|remove|default|get] Manage git server configurations kugetsu help Show this help Issue Ref Format: @@ -156,171 +99,6 @@ 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 -} - -check_pr_status() { - local pr_url="$1" - - if [ -z "$pr_url" ]; then - echo "no_pr_url" - return 1 - fi - - local hostname=$(echo "$pr_url" | sed -E 's|https://([^/]+)/.*|\1|') - - local server_base="${GIT_SERVERS[$hostname]:-}" - if [ -z "$server_base" ]; then - echo "unknown_server" - return 1 - fi - - local api_base="${server_base}/api/v1" - - local api_url=$(echo "$pr_url" | sed -E 's|https://[^/]+/([^/]+)/([^/]+)/(pulls|merge_requests)/([0-9]+)|'"${api_base}"'/repos/\1/\2/\3/\4|') - - local token="" - if [[ "$hostname" == "github.com" ]]; then - token="${GITHUB_TOKEN:-}" - else - token="${GITEA_TOKEN:-}" - fi - - local response - if [ -n "$token" ]; then - response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null || echo "{}") - else - response=$(curl -s "$api_url" 2>/dev/null || echo "{}") - fi - - local state=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print(d.get('state', 'unknown'))" 2>/dev/null || echo "unknown") - local merged=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print('true' if d.get('merged', False) else 'false')" 2>/dev/null || echo "false") - - if [ "$merged" = "true" ]; then - echo "merged" - elif [ "$state" = "closed" ]; then - echo "closed" - elif [ "$state" = "open" ]; then - echo "open" - else - echo "unknown" - fi -} - issue_ref_to_filename() { local issue_ref="$1" echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/' @@ -329,7 +107,7 @@ issue_ref_to_filename() { filename_to_issue_ref() { local filename="$1" local name="${filename%.json}" - echo "$name" | sed 's/-\([0-9]*\)$/#\1' | sed 's/-/\//g' + echo "$name" | sed 's/-\([0-9]*\)$/#\1/' | sed 's/-/\//g' } issue_ref_to_context_file() { @@ -647,615 +425,60 @@ check_task_timeouts() { local queue_id=$(basename "$item" .json) local pid=$(python3 -c "import json; print(json.load(open('$item')).get('pid', ''))" 2>/dev/null) local session_id=$(python3 -c "import json; print(json.load(open('$item')).get('opencode_session_id', ''))" 2>/dev/null) + local issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null) local notified_epoch=$(date -d "$notified_at" +%s 2>/dev/null || echo "0") local now_epoch=$(date +%s) local hours_elapsed=$(( (now_epoch - notified_epoch) / 3600 )) if [ "$hours_elapsed" -ge "$timeout_hours" ]; then - echo "Task $queue_id timed out after ${hours_elapsed}h (limit: ${timeout_hours}h)" + echo "Task $queue_id ($issue_ref) timed out after ${hours_elapsed}h" + + if [ -n "$session_id" ]; then + opencode session stop "$session_id" 2>/dev/null || true + fi if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - echo "Killing process $pid" kill "$pid" 2>/dev/null || true fi - if [ -n "$session_id" ]; then - local worktree_path="" - for session_file in "$SESSIONS_DIR"/*.json; do - [ -f "$session_file" ] || continue - local sess_id=$(python3 -c "import json; print(json.load(open('$session_file')).get('opencode_session_id', ''))" 2>/dev/null) - if [ "$sess_id" = "$session_id" ]; then - worktree_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null) - break - fi - done - - if [ -n "$worktree_path" ]; then - pkill -f "opencode.*$worktree_path" 2>/dev/null || true - fi - fi - update_queue_item_state "$queue_id" "error" - - local issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null) - if [ -n "$issue_ref" ]; then - local session_file=$(get_session_for_issue "$issue_ref") - if [ -n "$session_file" ] && [ "$session_file" != "null" ]; then - python3 << PYEOF -import json -session_path = "$SESSIONS_DIR/$session_file" -try: - with open(session_path, 'r') as f: - session = json.load(f) - session['state'] = 'timeout' - with open(session_path, 'w') as f: - json.dump(session, f, indent=2) - print(f"Marked session for $issue_ref as timeout") -except Exception as e: - print(f"Error marking session: {e}") -PYEOF - fi - fi fi done } -cleanup_old_queue_items() { - local days="${QUEUE_CLEANUP_AGE_DAYS:-7}" - - if [ ! -d "$QUEUE_ITEMS_DIR" ]; then - return - fi - - find "$QUEUE_ITEMS_DIR" -name "*.json" -type f -mtime "+$days" 2>/dev/null | while read -r file; do - local state=$(python3 -c "import json; print(json.load(open('$file')).get('state', ''))" 2>/dev/null || echo "") - if [ "$state" = "completed" ] || [ "$state" = "error" ]; then - rm -f "$file" - echo "Cleaned up: $(basename "$file")" - fi - done -} - -update_session_pr_url() { - local issue_ref="$1" - local pr_url="$2" - - if [ -z "$issue_ref" ] || [ -z "$pr_url" ]; then - echo "Error: update_session_pr_url requires and " >&2 - return 1 - fi - - local session_file=$(get_session_for_issue "$issue_ref") - if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then - echo "Error: No session found for '$issue_ref'" >&2 - return 1 - fi - - local session_path="$SESSIONS_DIR/$session_file" - - if [ ! -f "$session_path" ]; then - echo "Error: Session file not found: $session_path" >&2 - return 1 - fi - - python3 << PYEOF -import json - -session_path = "$session_path" -pr_url = "$pr_url" - -with open(session_path, 'r') as f: - session = json.load(f) - -session['pr_url'] = pr_url - -with open(session_path, 'w') as f: - json.dump(session, f, indent=2) - -print(f"Updated pr_url to: {pr_url}") -PYEOF -} - -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_get_fork_context() { - local issue_ref="$1" - local context="" - - context="## IMPORTANT WORKING RULES - -1. You are working on issue: $issue_ref -2. If you encounter ANY error, blocker, or cannot complete the task: - - STOP immediately - - Log what happened and why you cannot proceed - - Do NOT switch to other work or try alternative approaches -3. Do NOT work on other issues or PRs unless explicitly asked -4. Environment variables are available in ~/.kugetsu/env/ - -" - - if [ -f "$REPOS_CONFIG" ]; then - context="${context} -## REPOSITORIES CONFIG -$(cat "$REPOS_CONFIG") - -" - fi - - if [ -f "$ENV_DIR/default.env" ]; then - context="${context} -## ENVIRONMENT (available at ~/.kugetsu/env/) -Environment file exists at: $ENV_DIR/default.env -Source it with: source ~/.kugetsu/env/default.env -" - fi - - echo "$context" -} - -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 -} - -parse_issue_ref_from_message() { - local message="$1" - - local gitserver="" - local owner="" - local repo="" - local issue_number="" - - if echo "$message" | grep -qE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(issues|pull)/[0-9]+'; then - gitserver=$(echo "$message" | grep -oE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+' | head -1 | sed 's/\/[^/]*\/[^/]*$//') - local full_path=$(echo "$message" | grep -oE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(issues|pull)/[0-9]+' | head -1) - owner=$(echo "$full_path" | cut -d'/' -f2) - repo=$(echo "$full_path" | cut -d'/' -f3) - issue_number=$(echo "$full_path" | grep -oE '[0-9]+$' | head -1) - elif echo "$message" | grep -qE '[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#([0-9]+)'; then - owner=$(echo "$message" | grep -oE '[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#' | sed 's/#$//' | cut -d'/' -f1) - repo=$(echo "$message" | grep -oE '[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#' | sed 's/#$//' | cut -d'/' -f2) - issue_number=$(echo "$message" | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | head -1) - fi - - echo "${gitserver}|${owner}|${repo}|${issue_number}" -} - -get_missing_info() { - local parsed="$1" - local gitserver=$(echo "$parsed" | cut -d'|' -f1) - local owner=$(echo "$parsed" | cut -d'|' -f2) - local repo=$(echo "$parsed" | cut -d'|' -f3) - local issue_number=$(echo "$parsed" | cut -d'|' -f4) - - local missing="" - [ -z "$gitserver" ] && missing="${missing}git server, " - [ -z "$owner" ] && missing="${missing}owner, " - [ -z "$repo" ] && missing="${missing}repository, " - [ -z "$issue_number" ] && missing="${missing}issue number, " - - echo "$missing" | sed 's/, $//' -} - -build_missing_info_context() { - local missing="$1" - if [ -n "$missing" ]; then - echo "" - echo "NOTE: This task delegation has no information about: ${missing}." - echo "We need them if user wants to work on a specific issue. Otherwise we don't need it." - fi -} - -find_worktrees_by_issue_number() { - local issue_number="$1" - local results="" - - if [ ! -d "$WORKTREES_DIR/.kugetsu-worktrees" ]; then - echo "" - return - fi - - for wt in "$WORKTREES_DIR/.kugetsu-worktrees"/*; do - if [ -d "$wt" ]; then - local wt_issue_number=$(echo "$wt" | grep -oE '#?[0-9]+$' | grep -oE '[0-9]+' | head -1) - if [ "$wt_issue_number" = "$issue_number" ]; then - results="${results}${wt}:worktree -" - fi - fi - done - - echo "$results" -} - -find_sessions_by_issue_number() { - local issue_number="$1" - local results="" - - if [ ! -d "$SESSIONS_DIR" ]; then - echo "" - return - fi - - for session_file in "$SESSIONS_DIR"/*.json; do - if [ -f "$session_file" ]; then - local session_issue_ref=$(basename "$session_file" .json | sed 's/_/\//g') - local session_issue_number=$(echo "$session_issue_ref" | grep -oE '#?[0-9]+$' | grep -oE '[0-9]+' | head -1) - if [ "$session_issue_number" = "$issue_number" ]; then - results="${results}${session_file}:session -" - fi - fi - done - - echo "$results" -} - cmd_queue() { local action="${1:-list}" shift case "$action" in list) - ensure_queue_dirs - local stats=$(get_queue_stats) - echo "Queue Statistics:" - echo "$stats" | python3 -c "import json, sys; d=json.load(sys.stdin); print(f\" Total: {d['total']}\n Pending: {d['pending']}\n Notified: {d['notified']}\n Completed: {d['completed']}\n Error: {d['error']}\")" - echo "" - echo "Pending tasks:" - local count=0 - for item in "$QUEUE_ITEMS_DIR"/*.json; do - [ -f "$item" ] || continue - local state=$(python3 -c "import json; print(json.load(open('$item')).get('state', '')" 2>/dev/null) - if [ "$state" = "pending" ]; then - count=$((count + 1)) - python3 -c "import json; d=json.load(open('$item')); print(f\" [{d['id']}] {d['issue_ref']}: {d['message'][:50]}...\n pending since: {d['pending_since']}\")" 2>/dev/null - fi - done - if [ $count -eq 0 ]; then - echo " (none)" + local pending_tasks=$(get_pending_tasks 10) + if [ "$pending_tasks" = "[]" ]; then + echo "No pending tasks in queue." + else + echo "Pending tasks:" + echo "$pending_tasks" | python3 -c "import sys, json; [print(f\" {t.get('id')}: {t.get('issue_ref')} - {t.get('message', '')[:50]}...\") for t in json.load(sys.stdin)]" fi ;; stats) local stats=$(get_queue_stats) - echo "$stats" | python3 -c "import json, sys; d=json.load(sys.stdin); print(json.dumps(d, indent=2))" + echo "Queue statistics:" + echo "$stats" | python3 -c "import sys, json; d=json.load(sys.stdin); print(f\" Total: {d.get('total', 0)}\n Pending: {d.get('pending', 0)}\n Notified: {d.get('notified', 0)}\n Completed: {d.get('completed', 0)}\n Error: {d.get('error', 0)}\")" ;; clear) - echo "Cleaning up old queue items..." - cleanup_old_queue_items + if [ ! -d "$QUEUE_ITEMS_DIR" ]; then + echo "Queue is already empty." + return + fi + local count=$(ls -1 "$QUEUE_ITEMS_DIR"/*.json 2>/dev/null | wc -l) + if [ "$count" -eq 0 ]; then + echo "Queue is already empty." + return + fi + echo "Clearing $count queue items..." + rm -f "$QUEUE_ITEMS_DIR"/*.json + echo "Queue cleared." ;; enqueue) local issue_ref="${1:-}" @@ -1266,8 +489,11 @@ cmd_queue() { fi enqueue_task "$issue_ref" "$message" ;; + check-timeouts) + check_task_timeouts + ;; *) - echo "Usage: kugetsu queue [list|stats|clear|enqueue ]" >&2 + echo "Usage: kugetsu queue [list|stats|clear|enqueue]" >&2 exit 1 ;; esac @@ -1275,38 +501,37 @@ cmd_queue() { cmd_queue_daemon() { local action="${1:-status}" - shift case "$action" in start) if [ -f "$QUEUE_DAEMON_PID_FILE" ]; then - local old_pid=$(cat "$QUEUE_DAEMON_PID_FILE" 2>/dev/null) - if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then - echo "Daemon is already running with PID $old_pid" - exit 1 + local old_pid=$(cat "$QUEUE_DAEMON_PID_FILE") + if kill -0 "$old_pid" 2>/dev/null; then + echo "Queue daemon is already running (PID: $old_pid)" + return fi rm -f "$QUEUE_DAEMON_PID_FILE" fi - mkdir -p "$(dirname "$QUEUE_DAEMON_LOG_FILE")" - nohup bash "$0" queue-daemon run >> "$QUEUE_DAEMON_LOG_FILE" 2>&1 & - local daemon_pid=$! - echo "$daemon_pid" > "$QUEUE_DAEMON_PID_FILE" - echo "Queue daemon started with PID $daemon_pid" - echo "Log file: $QUEUE_DAEMON_LOG_FILE" + ensure_queue_dirs + + nohup bash "$SCRIPT_DIR/kugetsu-queue-daemon.sh" >> "$QUEUE_DAEMON_LOG_FILE" 2>&1 & + + echo $! > "$QUEUE_DAEMON_PID_FILE" + echo "Queue daemon started (PID: $(cat "$QUEUE_DAEMON_PID_FILE"))" ;; stop) if [ ! -f "$QUEUE_DAEMON_PID_FILE" ]; then - echo "Daemon PID file not found. Is the daemon running?" - exit 1 + echo "Queue daemon is not running." + return fi local pid=$(cat "$QUEUE_DAEMON_PID_FILE") - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if kill -0 "$pid" 2>/dev/null; then kill "$pid" rm -f "$QUEUE_DAEMON_PID_FILE" - echo "Daemon stopped (PID $pid)" + echo "Queue daemon stopped." else - echo "Daemon not running (stale PID file)" + echo "Queue daemon is not running (stale PID file)." rm -f "$QUEUE_DAEMON_PID_FILE" fi ;; @@ -1316,29 +541,25 @@ cmd_queue_daemon() { cmd_queue_daemon start ;; status) - if [ -f "$QUEUE_DAEMON_PID_FILE" ]; then - local pid=$(cat "$QUEUE_DAEMON_PID_FILE") - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - echo "Queue daemon is running (PID $pid)" - else - echo "Daemon not running (stale PID file)" - rm -f "$QUEUE_DAEMON_PID_FILE" - fi + if [ ! -f "$QUEUE_DAEMON_PID_FILE" ]; then + echo "Queue daemon is not running." + return + fi + local pid=$(cat "$QUEUE_DAEMON_PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "Queue daemon is running (PID: $pid)" else - echo "Queue daemon is not running" + echo "Queue daemon is not running (stale PID file)." + rm -f "$QUEUE_DAEMON_PID_FILE" fi ;; logs) - local lines="${1:-50}" if [ -f "$QUEUE_DAEMON_LOG_FILE" ]; then - tail -"$lines" "$QUEUE_DAEMON_LOG_FILE" + tail -50 "$QUEUE_DAEMON_LOG_FILE" else - echo "No daemon log file found" + echo "No daemon logs found." fi ;; - run) - queue_daemon_loop - ;; *) echo "Usage: kugetsu queue-daemon [start|stop|restart|status|logs]" >&2 exit 1 @@ -1346,413 +567,145 @@ cmd_queue_daemon() { esac } -queue_daemon_loop() { - local pid=$$ - echo "$pid" > "$QUEUE_DAEMON_PID_FILE" - echo "Queue daemon started (PID $pid) at $(date)" +get_verbosity_context() { + local issue_ref="$1" + local context_file="$VERBOSITY_DIR/${issue_ref##*/}.context" - while true; do - sleep $((QUEUE_DAEMON_INTERVAL_MINUTES * 60)) - - if [ ! -f "$QUEUE_DAEMON_PID_FILE" ] || [ "$(cat "$QUEUE_DAEMON_PID_FILE")" != "$pid" ]; then - echo "PID file changed, stopping daemon" - exit 0 - fi - - check_task_timeouts - process_queue - done -} - -process_queue() { - local active_count=$(count_active_dev_sessions) - - if [ "$active_count" -ge "$MAX_CONCURRENT_AGENTS" ]; then + if [ ! -f "$context_file" ]; then + echo "{}" return fi - local available_slots=$((MAX_CONCURRENT_AGENTS - active_count)) + cat "$context_file" +} + +get_missing_info() { + local issue_ref="$1" + local session_file=$(get_session_for_issue "$issue_ref") - if [ "$available_slots" -le 0 ]; then + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$issue_ref'" >&2 return fi - local count=0 - for item in $(ls -t "$QUEUE_ITEMS_DIR"/*.json 2>/dev/null | head -20); do - [ $count -ge "$available_slots" ] && break - [ -f "$item" ] || continue - - local state=$(python3 -c "import json; print(json.load(open('$item')).get('state', ''))" 2>/dev/null) - if [ "$state" != "pending" ]; then - continue - fi - - local queue_id=$(basename "$item" .json) - local issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null) - local message=$(python3 -c "import json; print(json.load(open('$item')).get('message', ''))" 2>/dev/null) - - if [ -z "$issue_ref" ] || [ -z "$message" ]; then - continue - fi - - update_queue_item_state "$queue_id" "notified" - kugetsu_add_notification "task_dequeued" "Task dequeued: $issue_ref" "$issue_ref" - - local log_file="$LOGS_DIR/delegate-${queue_id}.log" - mkdir -p "$LOGS_DIR" - - local max_retries=3 - local attempt=1 - local success=false - local fork_pid="" - - while [ $attempt -le $max_retries ]; do - if kugetsu start "$issue_ref" "$message" >> "$log_file" 2>&1; then - success=true - break - fi - - echo "Attempt $attempt failed for $queue_id, cleaning up..." >> "$log_file" - - local session_file="$(issue_ref_to_filename "$issue_ref").json" - local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$PWD") - - [ -f "$SESSIONS_DIR/$session_file" ] && rm -f "$SESSIONS_DIR/$session_file" - worktree_exists "$issue_ref" "$PWD" && remove_worktree_for_issue "$issue_ref" "$PWD" - remove_issue_from_index "$issue_ref" 2>/dev/null || true - - attempt=$((attempt + 1)) - done - - if [ "$success" = true ]; then - echo "Started task $queue_id: $issue_ref" - count=$((count + 1)) - else - echo "Failed to start task $queue_id after $max_retries attempts" - update_queue_item_state "$queue_id" "pending" - fi - done -} - -cmd_delegate() { - local message="${1:-}" + local session_path="$SESSIONS_DIR/$session_file" - if [ -z "$message" ]; then - echo "Error: message is required" >&2 - echo "Usage: kugetsu delegate " >&2 - exit 1 - fi - - local parsed=$(parse_issue_ref_from_message "$message") - local gitserver=$(echo "$parsed" | cut -d'|' -f1) - local owner=$(echo "$parsed" | cut -d'|' -f2) - local repo=$(echo "$parsed" | cut -d'|' -f3) - local issue_number=$(echo "$parsed" | cut -d'|' -f4) - - if [ -z "$issue_number" ] || [ -z "$gitserver" ] || [ -z "$owner" ] || [ -z "$repo" ]; then - echo "Error: Could not parse issue reference from message" >&2 - echo "Message should contain an issue reference like 'github.com/user/repo#123'" >&2 - exit 1 - fi - - local issue_ref="${gitserver}/${owner}/${repo}#${issue_number}" - - enqueue_task "$issue_ref" "$message" - echo "Task enqueued. The queue daemon will process it when a slot is available." -} - -cmd_logs() { - local count="${1:-10}" - - if [ ! -d "$LOGS_DIR" ]; then - echo "No logs found." + if [ ! -f "$session_path" ]; then + echo "Error: Session file not found: $session_path" >&2 return fi - # Log rotation: delete logs older than 7 days - find "$LOGS_DIR" -type f -mtime +7 -delete 2>/dev/null + python3 << PYEOF +import json + +session_path = "$session_path" + +with open(session_path, 'r') as f: + session = json.load(f) + +missing = [] + +if not session.get('pr_url'): + missing.append('pr_url') + +if not session.get('last_activity'): + missing.append('last_activity') + +if missing: + print("Missing info:", ', '.join(missing)) +else: + print("Session info complete") +PYEOF +} + +set_debug_mode() { + local filtered_args=() + local debug_mode=false - ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | while read line; do - echo "$line" + for arg in "$@"; do + case "$arg" in + --debug) + debug_mode=true + ;; + *) + filtered_args+=("$arg") + ;; + esac done + + if [ "$debug_mode" = true ]; then + export KUGETSU_VERBOSITY="debug" + echo "[DEBUG] Debug mode enabled" >&2 + fi + + echo "${filtered_args[@]}" } cmd_env() { - local action="${1:-}" - local agent_type="${2:-}" - - mkdir -p "$ENV_DIR" + local action="${1:-list}" + shift case "$action" in - ""|"list") - echo "Environment files in $ENV_DIR:" + list) + echo "Agent environment variables:" if [ -d "$ENV_DIR" ]; then - for f in "$ENV_DIR"/*.env; do - if [ -f "$f" ]; then - echo " $(basename "$f")" + for env_file in "$ENV_DIR"/*.env; do + if [ -f "$env_file" ]; then + echo "" + echo "=== $(basename "$env_file") ===" + while IFS= read -r line; do + echo " $(mask_sensitive_vars "$line")" + done < "$env_file" 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}" + echo " No env files found in $ENV_DIR" fi ;; - "set") - local key="${2:-}" - local value="${3:-}" - local target="${4:-default}" + get) + local key="${1:-}" + if [ -z "$key" ]; then + echo "Usage: kugetsu env get " >&2 + exit 1 + fi + load_agent_env "default" + local value="${!key:-}" + if [ -n "$value" ]; then + echo "$value" + else + echo "Variable '$key' is not set" >&2 + exit 1 + fi + ;; + set) + local key="${1:-}" + local value="${2:-}" if [ -z "$key" ] || [ -z "$value" ]; then - echo "Usage: kugetsu env set [agent]" >&2 - echo " agent: default, pm-agent, or issue ref" >&2 + echo "Usage: kugetsu env set " >&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" + mkdir -p "$ENV_DIR" + echo "${key}=${value}" >> "$ENV_DIR/default.env" + echo "Set $key in $ENV_DIR/default.env" ;; - "get") - local key="${2:-}" - local target="${3:-default}" - local file="$ENV_DIR/${target}.env" + rm) + local key="${1:-}" if [ -z "$key" ]; then - echo "Usage: kugetsu env get [agent]" >&2 + echo "Usage: kugetsu env rm " >&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" + if [ -f "$ENV_DIR/default.env" ]; then + sed -i "/^${key}=/d" "$ENV_DIR/default.env" + echo "Removed $key from $ENV_DIR/default.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 + echo "Usage: kugetsu env [list|get|set|rm]" >&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:-}" @@ -1771,7 +724,7 @@ cmd_server() { echo " $key -> ${GIT_SERVERS[$key]}$marker" done ;; - "add") + add) local name="${2:-}" local url="${3:-}" if [ -z "$name" ] || [ -z "$url" ]; then @@ -1790,7 +743,7 @@ cmd_server() { source "$KUGETSU_DIR/config" echo "Added git server: $name -> $url" ;; - "remove"|"rm"|"delete") + remove|rm|delete) local name="${2:-}" if [ -z "$name" ]; then echo "Usage: kugetsu server remove " >&2 @@ -1809,7 +762,7 @@ cmd_server() { exit 1 fi ;; - "default") + default) local name="${2:-}" if [ -z "$name" ]; then echo "Current default: $DEFAULT_GIT_SERVER" @@ -1824,7 +777,7 @@ cmd_server() { exit 1 fi ;; - "get") + get) local name="${2:-$DEFAULT_GIT_SERVER}" if [ -n "${GIT_SERVERS[$name]:-}" ]; then echo "${GIT_SERVERS[$name]}" @@ -1847,678 +800,97 @@ cmd_server() { esac } -cmd_init() { - local force=false - +cmd_doctor() { + local fix=false + while [ $# -gt 0 ]; do case "$1" in - --force) - force=true + --fix) + fix=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 - - if [ -d "$LOGS_DIR" ]; then - echo "Cleaning up old logs..." - rm -rf "$LOGS_DIR"/*.log 2>/dev/null || true - 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 + echo "=== kugetsu doctor ===" + echo "" + + echo "Checking directories..." + for dir in "$KUGETSU_DIR" "$SESSIONS_DIR" "$WORKTREES_DIR"; do + if [ -d "$dir" ]; then + echo " [OK] $dir exists" 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 - - local init_worktree_dir="$HOME/.kugetsu-worktrees" - mkdir -p "$init_worktree_dir" - cd "$init_worktree_dir" - echo "Initialized kugetsu worktrees directory: $init_worktree_dir" - echo "Base session will be created in this directory." - echo "" - - local cwd_files=$(ls -A "$PWD" 2>/dev/null | wc -l) - local cwd_git=$(git rev-parse --is-inside-work-tree 2>/dev/null || echo "false") - if [ "$cwd_files" -gt 0 ] || [ "$cwd_git" = "true" ]; then - echo "Warning: Worktrees directory is not empty: $PWD" >&2 - echo "This may cause project context to contaminate the base session." >&2 - echo "Consider running kugetsu destroy --base -y and reinitializing." >&2 - echo "" >&2 - echo "Files in current directory: $cwd_files" >&2 - if [ "$cwd_git" = "true" ]; then - echo "Git repository detected: $(git rev-parse --show-toplevel 2>/dev/null || echo 'unknown')" >&2 - fi - echo "" >&2 - echo "Press Ctrl+C to cancel or wait 5 seconds to continue anyway..." >&2 - sleep 5 - 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 pr_url="" - 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" - elif [ -z "$pr_url" ]; then - pr_url="$arg" + echo " [MISSING] $dir" + if [ "$fix" = true ]; then + mkdir -p "$dir" + echo " Created $dir" + fi fi done - - if [ -z "$issue_ref" ] || [ -z "$message" ]; then - echo "Error: start requires and " >&2 - echo "Usage: kugetsu start [pr-url]" >&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" - local fork_log="$SESSIONS_DIR/$session_file.fork.log" - local opencode_db="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}" - local lock_file="$KUGETSU_DIR/.session_lock" - local lock_fd=200 - > "$fork_log" + echo "" + echo "Checking sessions..." + local base_id=$(get_base_session_id) + local pm_id=$(get_pm_agent_session_id) - local fork_context=$(kugetsu_get_fork_context "$issue_ref") - local previous_context=$(kugetsu_context_load "$issue_ref") - local branch_name=$(issue_ref_to_branch_name "$issue_ref") - local full_message="${fork_context} -${previous_context} - -## YOUR TASK -$message" + if [ -n "$base_id" ] && [ "$base_id" != "null" ]; then + echo " [OK] Base session: $base_id" + else + echo " [MISSING] Base session not initialized" + fi - ( - flock -x $lock_fd - - 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 - - echo "Forking session for '$issue_ref'..." - - fix_session_permissions - - if [ "$DEBUG_MODE" = true ]; then - (cd "$worktree_path" && opencode run "$full_message" --fork --session "$base_session_id" --dir "$worktree_path" 2>&1) | tee "$fork_log" & - else - (cd "$worktree_path" && opencode run "$full_message" --fork --session "$base_session_id" --dir "$worktree_path" 2>&1) >> "$fork_log" & - fi + if [ -n "$pm_id" ] && [ "$pm_id" != "null" ]; then + echo " [OK] PM agent: $pm_id" + else + echo " [WARNING] PM agent not initialized" + fi + + echo "" + echo "Checking opencode..." + if command -v opencode &> /dev/null; then + echo " [OK] opencode command available" + local sessions=$(opencode session list 2>/dev/null | grep -c "^ses_" || echo "0") + echo " [OK] $sessions opencode sessions found" + else + echo " [MISSING] opencode command not found" + fi + + echo "" + echo "Checking index..." + if [ -f "$INDEX_FILE" ]; then + echo " [OK] Index file exists" + else + echo " [WARNING] Index file not found" + fi + + echo "" + echo "Doctor check complete." +} - 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 - - local branch_name=$(issue_ref_to_branch_name "$issue_ref") - - python3 << PYEOF > "$SESSIONS_DIR/$session_file" +mark_orphan() { + local session_file="$1" + local session_path="$SESSIONS_DIR/$session_file" + + if [ ! -f "$session_path" ]; then + return + fi + + python3 << PYEOF import json -session = { - "type": "forked", - "issue_ref": "$issue_ref", - "opencode_session_id": "$new_session_id", - "worktree_path": "$worktree_path", - "created_at": "$(date -Iseconds)", - "state": "idle", - "branch_name": "$branch_name", - "pr_url": "$pr_url" if "$pr_url" else None -} +session_path = "$session_path" -with open("$SESSIONS_DIR/$session_file", "w") as f: +with open(session_path, 'r') as f: + session = json.load(f) + +session['state'] = 'orphan' +session['orphaned_at'] = '$(date -Iseconds)' + +with open(session_path, 'w') as f: json.dump(session, f, indent=2) PYEOF - - add_issue_to_index "$issue_ref" "$session_file" - - kugetsu_context_dump "$issue_ref" "$message" "$branch_name" - - kugetsu_add_notification "task_started" "Task started: $issue_ref" "$issue_ref" - - echo "Session started for '$issue_ref': $new_session_id" - echo "Worktree: $worktree_path" - ) 200>"$lock_file" -} - -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'..." - - local previous_context=$(kugetsu_context_load "$session_name") - local full_message="${previous_context} - -## CONTINUE TASK -$message" - - # Note: --continue always allowed (existing sessions don't count toward limit) - # Wrap in subshell with cd to ensure worktree directory is set correctly in session DB - if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then - echo "Using worktree: $worktree_path" - if [ "$DEBUG_MODE" = true ]; then - (cd "$worktree_path" && opencode run "$full_message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1) | tee "$session_path.debug.log" & - else - (cd "$worktree_path" && opencode run "$full_message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1) & - fi - else - if [ "$DEBUG_MODE" = true ]; then - opencode run "$full_message" --continue --session "$opencode_session_id" 2>&1 | tee "$session_path.debug.log" & - else - opencode run "$full_message" --continue --session "$opencode_session_id" 2>&1 & - fi - fi - - kugetsu_context_update_message "$session_name" "$message" -} - -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 - local base_session_id=$(get_base_session_id) - local pm_agent_session_id=$(get_pm_agent_session_id) - rm -f "$SESSIONS_DIR/base.json" - rm -f "$SESSIONS_DIR/pm-agent.json" - rm -f "$SESSIONS_DIR/issue-"*.json 2>/dev/null || true - echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE" - - if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then - echo "Deleting base session: $base_session_id" - opencode session delete "$base_session_id" 2>/dev/null || echo "Warning: Could not delete base session" - fi - if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ]; then - echo "Deleting PM agent session: $pm_agent_session_id" - opencode session delete "$pm_agent_session_id" 2>/dev/null || echo "Warning: Could not delete PM agent session" - fi - echo "Base and PM agent sessions 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 - local pm_session_id=$(get_pm_agent_session_id) - 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 - if [ -n "$pm_session_id" ] && [ "$pm_session_id" != "null" ]; then - echo "Deleting opencode session: $pm_session_id" - opencode session delete "$pm_session_id" 2>/dev/null || echo "Warning: Could not delete session from opencode (may already be deleted)" - 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 - if [ "$WORKTREE_CHECK_PR_STATUS" = "true" ]; then - local pr_url=$(python3 -c "import json; print(json.load(open('$session_path')).get('pr_url', '') or '')" 2>/dev/null || echo "") - if [ -n "$pr_url" ] && [ "$pr_url" != "None" ]; then - echo "Checking PR status at '$pr_url'..." - local pr_status=$(check_pr_status "$pr_url") - if [ "$pr_status" = "open" ]; then - echo "Error: PR is still open at $pr_url" >&2 - echo "Use --force to destroy anyway, or close the PR first" >&2 - exit 1 - elif [ "$pr_status" = "merged" ]; then - echo "PR has been merged. Safe to destroy." - elif [ "$pr_status" = "closed" ]; then - echo "PR has been closed. Safe to destroy." - else - echo "Warning: Could not determine PR status (got: $pr_status). Proceeding anyway." >&2 - fi - fi - fi - - 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() { @@ -2526,10 +898,10 @@ main() { usage exit 1 fi - + local command="$1" shift - + case "$command" in help|--help|-h) usage @@ -2547,18 +919,11 @@ main() { cmd_delegate "$@" ;; logs) - shift cmd_logs "$@" ;; status) cmd_status ;; - server) - cmd_server "$@" - ;; - env) - cmd_env "$@" - ;; doctor) cmd_doctor "$@" ;; @@ -2611,6 +976,12 @@ main() { shift cmd_queue_daemon "$action" "$@" ;; + env) + cmd_env "$@" + ;; + server) + cmd_server "$@" + ;; *) echo "Error: unknown command '$command'" >&2 usage @@ -2619,4 +990,4 @@ main() { esac } -main "$@" \ No newline at end of file +main "$@" diff --git a/skills/kugetsu/scripts/kugetsu-config.sh b/skills/kugetsu/scripts/kugetsu-config.sh new file mode 100755 index 0000000..be656dd --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-config.sh @@ -0,0 +1,57 @@ +#!/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}" +CONTEXT_DIR="${CONTEXT_DIR:-$KUGETSU_DIR/context}" +ENABLE_CONTEXT_DUMP="${ENABLE_CONTEXT_DUMP:-true}" +WORKTREE_CHECK_PR_STATUS="${WORKTREE_CHECK_PR_STATUS:-true}" + +QUEUE_DIR="${QUEUE_DIR:-$KUGETSU_DIR/queue}" +QUEUE_ITEMS_DIR="${QUEUE_ITEMS_DIR:-$QUEUE_DIR/items}" +QUEUE_DAEMON_PID_FILE="${QUEUE_DAEMON_PID_FILE:-$QUEUE_DIR/daemon.pid}" +QUEUE_DAEMON_LOCK_FILE="${QUEUE_DAEMON_LOCK_FILE:-$QUEUE_DIR/daemon.lock}" +QUEUE_DAEMON_LOG_FILE="${QUEUE_DAEMON_LOG_FILE:-$QUEUE_DIR/daemon.log}" +QUEUE_DAEMON_INTERVAL_MINUTES="${QUEUE_DAEMON_INTERVAL_MINUTES:-5}" +QUEUE_CLEANUP_AGE_DAYS="${QUEUE_CLEANUP_AGE_DAYS:-7}" +TASK_TIMEOUT_HOURS="${TASK_TIMEOUT_HOURS:-1}" + +# 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 +} diff --git a/skills/kugetsu/scripts/kugetsu-index.sh b/skills/kugetsu/scripts/kugetsu-index.sh new file mode 100755 index 0000000..0be7814 --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-index.sh @@ -0,0 +1,135 @@ +#!/bin/bash +set -euo pipefail + +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.get('issues', {}).get('$issue_ref') or 'null')" +} + +set_base_in_index() { + local session_id="$1" + local index=$(read_index) + local pm_agent=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') or 'null')") + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d.get('issues', {})))") + + if [ "$session_id" = "null" ]; then + write_index "null" "$pm_agent" "$issues" + else + write_index "\"$session_id\"" "$pm_agent" "$issues" + fi +} + +set_pm_agent_in_index() { + local session_id="$1" + local index=$(read_index) + local base=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or 'null')") + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d.get('issues', {})))") + + if [ "$session_id" = "null" ]; then + write_index "$base" "null" "$issues" + else + write_index "$base" "\"$session_id\"" "$issues" + fi +} + +add_issue_to_index() { + local issue_ref="$1" + local session_file="$2" + + local index=$(read_index) + local base=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or 'null')") + local pm_agent=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') or 'null')") + + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d.get('issues', {})))") + + issues=$(python3 -c "import sys, json; d=json.load(sys.stdin); d['$issue_ref']='$session_file'; print(json.dumps(d))" <<< "$issues") + + write_index "$base" "$pm_agent" "$issues" +} + +remove_issue_from_index() { + local issue_ref="$1" + + local index=$(read_index) + local base=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or 'null')") + local pm_agent=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') or 'null')") + + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d.get('issues', {})))") + + issues=$(python3 -c "import sys, json; d=json.load(sys.stdin); d.pop('$issue_ref', None); print(json.dumps(d))" <<< "$issues") + + write_index "$base" "$pm_agent" "$issues" +} + +validate_issue_ref() { + local issue_ref="$1" + + if [[ ! "$issue_ref" =~ ^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+#[0-9]+$ ]]; then + echo "Error: Invalid issue ref format: '$issue_ref'" >&2 + echo "Expected format: instance/user/repo#number" >&2 + echo "Example: github.com/shoko/kugetsu#14" >&2 + exit 1 + fi +} + +update_session_pr_url() { + local issue_ref="$1" + local pr_url="$2" + + local session_file=$(get_session_for_issue "$issue_ref") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$issue_ref'" >&2 + return 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + if [ ! -f "$session_path" ]; then + echo "Error: Session file not found: $session_path" >&2 + return 1 + fi + + python3 << PYEOF +import json + +session_path = "$session_path" +pr_url = "$pr_url" + +with open(session_path, 'r') as f: + session = json.load(f) + +session['pr_url'] = pr_url + +with open(session_path, 'w') as f: + json.dump(session, f, indent=2) + +print(f"Updated PR URL for $issue_ref: $pr_url") +PYEOF +} diff --git a/skills/kugetsu/scripts/kugetsu-log.sh b/skills/kugetsu/scripts/kugetsu-log.sh new file mode 100755 index 0000000..450e072 --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-log.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -euo pipefail + +cmd_logs() { + local count="${1:-10}" + + if [ ! -d "$LOGS_DIR" ]; then + echo "No logs found." + return + fi + + 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 + + echo "" + echo "Recent log contents:" + echo "====================" + + for log in $(ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | awk '{print $NF}'); do + if [ -f "$LOGS_DIR/$log" ]; then + echo "" + echo "--- $log ---" + tail -20 "$LOGS_DIR/$log" | while read line; do + echo " $(mask_sensitive_vars "$line")" + done + fi + done +} + +kugetsu_add_notification() { + local notification_type="$1" + local message="$2" + local issue_ref="${3:-}" + local timestamp=$(date -Iseconds) + + mkdir -p "$(dirname "$NOTIFICATIONS_FILE")" + + local notifications="[]" + if [ -f "$NOTIFICATIONS_FILE" ]; then + notifications=$(cat "$NOTIFICATIONS_FILE") + fi + + local new_notification=$(python3 -c "import json; print(json.dumps({ + 'type': '$notification_type', + 'message': '$message', + 'issue_ref': '$issue_ref', + 'timestamp': '$timestamp', + 'read': False + }))") + + notifications=$(python3 -c "import json; n=json.loads('$notifications'); n.append(json.loads('$new_notification')); print(json.dumps(n[-50:] if len(n)>50 else n, indent=2))") + + echo "$notifications" > "$NOTIFICATIONS_FILE" +} + +cmd_notify() { + local action="${1:-list}" + + case "$action" in + list) + if [ ! -f "$NOTIFICATIONS_FILE" ]; then + echo "No notifications." + return + fi + + local notifications=$(cat "$NOTIFICATIONS_FILE") + local count=$(echo "$notifications" | python3 -c "import sys, json; n=json.load(sys.stdin); print(sum(1 for x in n if not x.get('read', False)))") + + if [ "$count" -eq 0 ]; then + echo "No unread notifications." + return + fi + + echo "Unread notifications ($count):" + echo "$notifications" | python3 -c "import sys, json; [print(f\" [{x.get('timestamp', '')}] {x.get('type', '')}: {x.get('message', '')}\") for x in json.load(sys.stdin) if not x.get('read', False)]" + ;; + clear) + if [ -f "$NOTIFICATIONS_FILE" ]; then + python3 -c "import json; print(json.dumps([x for x in json.load(open('$NOTIFICATIONS_FILE')) if x.get('read', False)], indent=2))" > "$NOTIFICATIONS_FILE" + echo "Cleared unread notifications." + fi + ;; + *) + echo "Usage: kugetsu notify [list|clear]" >&2 + exit 1 + ;; + esac +} diff --git a/skills/kugetsu/scripts/kugetsu-queue-daemon.sh b/skills/kugetsu/scripts/kugetsu-queue-daemon.sh new file mode 100755 index 0000000..7cae460 --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-queue-daemon.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/kugetsu-config.sh" +source "$SCRIPT_DIR/kugetsu-index.sh" +source "$SCRIPT_DIR/kugetsu-worktree.sh" +source "$SCRIPT_DIR/kugetsu-log.sh" + +while true; do + if [ -d "$QUEUE_ITEMS_DIR" ]; then + for item in "$QUEUE_ITEMS_DIR"/*.json; do + [ -f "$item" ] || continue + state=$(python3 -c "import json; print(json.load(open('$item')).get('state', ''))" 2>/dev/null) + if [ "$state" = "pending" ]; then + queue_id=$(basename "$item" .json) + issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null) + message=$(python3 -c "import json; print(json.load(open('$item')).get('message', ''))" 2>/dev/null) + + pm_session=$(get_pm_agent_session_id) + if [ -n "$pm_session" ] && [ "$pm_session" != "null" ]; then + log_file="$LOGS_DIR/delegate-$(date +%s).log" + GITEA_TOKEN="${GITEA_TOKEN:-}" nohup sh -c "opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 & + pid=$! + update_queue_item_state "$queue_id" "notified" "$pm_session" "$pid" + fi + fi + done + fi + sleep "${QUEUE_DAEMON_INTERVAL_MINUTES:-5}m" +done diff --git a/skills/kugetsu/scripts/kugetsu-session.sh b/skills/kugetsu/scripts/kugetsu-session.sh new file mode 100755 index 0000000..1abdf67 --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-session.sh @@ -0,0 +1,569 @@ +#!/bin/bash +set -euo pipefail + +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" ] && [ "$filename" != "pm-agent.json" ]; then + count=$((count + 1)) + fi + fi + done + fi + echo "$count" +} + +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" ]; 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 + +# Git server configurations +# Format: GIT_SERVERS["hostname"]="https://hostname" +declare -A GIT_SERVERS +GIT_SERVERS["github.com"]="https://github.com" +GIT_SERVERS["git.fbrns.co"]="https://git.fbrns.co" +DEFAULT_GIT_SERVER="github.com" +EOF + echo "Created config file: $KUGETSU_DIR/config" + 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 + + opencode + + 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 + + echo "$session_ids" > "$SESSIONS_DIR/base.json" + set_base_in_index "$session_ids" + + echo "Base session created: $session_ids" + echo "Starting PM agent..." + + opencode + + local pm_session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | grep -v "$session_ids" | tail -1) + if [ -z "$pm_session_ids" ]; then + echo "Warning: Could not find separate PM agent session" >&2 + pm_session_ids="$session_ids" + fi + + echo "$pm_session_ids" > "$SESSIONS_DIR/pm-agent.json" + set_pm_agent_in_index "$pm_session_ids" + + load_agent_env "pm-agent" + + local pm_system_prompt="" + if [ -f "$KUGETSU_DIR/pm-agent.md" ]; then + pm_system_prompt=$(cat "$KUGETSU_DIR/pm-agent.md") + echo "Injecting PM agent system prompt from $KUGETSU_DIR/pm-agent.md" + fi + + echo "PM agent session created: $pm_session_ids" + echo "" + echo "kugetsu initialized successfully!" + echo " Base session: $session_ids" + echo " PM agent: $pm_session_ids" +} + +extract_issue_ref_from_message() { + local message="$1" + + if [ -z "$message" ]; then + echo "" + return + fi + + if [[ "$message" =~ ^([a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+#[0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + + if [[ "$message" =~ (https?://[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/(issues|pull)/[0-9]+) ]]; then + local url="${BASH_REMATCH[1]}" + local path=$(echo "$url" | sed 's|https\?://||' | cut -d'/' -f2-) + local instance=$(echo "$path" | cut -d'/' -f1) + local owner=$(echo "$path" | cut -d'/' -f2) + local repo=$(echo "$path" | cut -d'/' -f3) + local num=$(echo "$path" | grep -oE '[0-9]+$') + echo "${instance}/${owner}/${repo}#${num}" + return + fi + + echo "" +} + +cmd_delegate() { + local message="${1:-}" + + 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" + nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' 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"))" +} + +cmd_start() { + local issue_ref="${1:-}" + local message="${2:-}" + + if [ -z "$issue_ref" ]; then + echo "Error: issue ref is required" >&2 + echo "Usage: kugetsu start [message]" >&2 + exit 1 + fi + + validate_issue_ref "$issue_ref" + + local base_session_id=$(get_base_session_id) + if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then + echo "Error: Base session not found. 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: PM agent session not found. Run 'kugetsu init' first." >&2 + exit 1 + fi + + if worktree_exists "$issue_ref"; then + echo "Issue '$issue_ref' already has a worktree. Use 'kugetsu continue' instead." + exit 1 + fi + + local active_count=$(count_active_dev_sessions) + if [ "$active_count" -ge "${MAX_CONCURRENT_AGENTS:-3}" ]; then + echo "Error: Max concurrent agents (${MAX_CONCURRENT_AGENTS:-3}) reached. Use 'kugetsu continue' or wait for an agent to finish." >&2 + exit 1 + fi + + local session_file=$(issue_ref_to_filename "$issue_ref") + local session_path="$SESSIONS_DIR/$session_file" + + if [ -f "$session_path" ]; then + echo "Session file already exists: $session_file" + echo "Use 'kugetsu continue $issue_ref' to continue work." + exit 1 + fi + + local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local before_set="|$before_sessions|" + + create_worktree "$issue_ref" + + local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local new_session_id="" + while IFS= read -r sess; do + if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$base_session_id" ]] && [[ "$sess" != "$pm_agent_session_id" ]]; then + new_session_id="$sess" + break + fi + done <<< "$after_sessions" + + if [ -z "$new_session_id" ]; then + echo "Error: Could not find newly created session" >&2 + remove_worktree_for_issue "$issue_ref" + exit 1 + fi + + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") + + 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" + else + message="$arg" + fi + done + + if [ -z "$session_name" ]; then + echo "Error: issue ref is required" >&2 + echo "Usage: kugetsu continue [message]" >&2 + exit 1 + fi + + validate_issue_ref "$session_name" + + 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 $session_name' to create a new session." >&2 + exit 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + + if [ ! -f "$session_path" ]; then + echo "Error: Session file not found: $session_path" >&2 + exit 1 + fi + + load_agent_env "dev" + + local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path')).get('opencode_session_id', ''))" 2>/dev/null || echo "") + local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "") + + if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then + if [ -n "$message" ]; then + (cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" "$@") + else + (cd "$worktree_path" && opencode --continue --session "$opencode_session_id" "$@") + fi + else + if [ -n "$message" ]; then + opencode run "$message" --continue --session "$opencode_session_id" "$@" + else + opencode --continue --session "$opencode_session_id" "$@" + fi + fi +} + +cmd_list() { + echo "=== kugetsu sessions ===" + echo "" + + local base_id=$(get_base_session_id) + if [ -n "$base_id" ] && [ "$base_id" != "null" ]; then + echo "Base session: $base_id" + else + echo "Base session: not initialized" + fi + + local pm_id=$(get_pm_agent_session_id) + if [ -n "$pm_id" ] && [ "$pm_id" != "null" ]; then + echo "PM agent: $pm_id" + else + echo "PM agent: not initialized" + fi + + echo "" + echo "Issue sessions:" + + 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" ] && [ "$filename" != "pm-agent.json" ]; then + local issue_ref=$(filename_to_issue_ref "$filename") + local opencode_sid=$(python3 -c "import json; print(json.load(open('$session_file')).get('opencode_session_id', 'unknown'))" 2>/dev/null || echo "unknown") + local state=$(python3 -c "import json; print(json.load(open('$session_file')).get('state', 'unknown'))" 2>/dev/null || echo "unknown") + local worktree_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "") + local worktree_status="" + if [ -n "$worktree_path" ]; then + if [ -d "$worktree_path" ]; then + worktree_status="(worktree exists)" + else + worktree_status="(worktree MISSING)" + fi + fi + echo " $filename" + echo " Issue: $issue_ref" + echo " Session: $opencode_sid" + echo " State: $state" + echo " $worktree_status" + fi + fi + done + fi + + if [ -d "$WORKTREES_DIR" ]; then + echo "" + echo "Worktrees without sessions:" + for worktree in $(find "$WORKTREES_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null); do + local worktree_name=$(basename "$worktree") + local has_session=false + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local wt_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "") + if [ "$wt_path" = "$worktree" ]; then + has_session=true + break + fi + fi + done + if [ "$has_session" = false ]; then + echo " $worktree_name (no session)" + fi + done + fi +} + +cmd_prune() { + local force=false + + if [ "$1" = "--force" ]; then + force=true + fi + + echo "=== kugetsu prune ===" + echo "" + + local orphaned=() + + if [ -d "$SESSIONS_DIR" ]; then + for session_file in "$SESSIONS_DIR"/*.json; do + [ -f "$session_file" ] || continue + local filename=$(basename "$session_file") + if [ "$filename" = "base.json" ] || [ "$filename" = "pm-agent.json" ]; then + continue + fi + + local opencode_sid=$(python3 -c "import json; print(json.load(open('$session_file')).get('opencode_session_id', ''))" 2>/dev/null || echo "") + + if [ -n "$opencode_sid" ]; then + local exists=$(opencode session list 2>/dev/null | grep -c "^$opencode_sid" || echo "0") + if [ "$exists" -eq 0 ]; then + orphaned+=("$session_file") + fi + else + orphaned+=("$session_file") + fi + done + fi + + if [ ${#orphaned[@]} -eq 0 ]; then + echo "No orphaned sessions found." + return + fi + + echo "Found ${#orphaned[@]} orphaned session(s):" + for session in "${orphaned[@]}"; do + echo " $session" + done + echo "" + + if [ "$force" = false ]; then + read -p "Remove these sessions? [y/N] " -r answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Aborted." + return + fi + fi + + for session in "${orphaned[@]}"; do + local issue_ref=$(python3 -c "import json; print(json.load(open('$session')).get('issue_ref', ''))" 2>/dev/null || echo "") + local worktree_path=$(python3 -c "import json; print(json.load(open('$session')).get('worktree_path', ''))" 2>/dev/null || echo "") + + if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then + echo "Removing worktree: $worktree_path" + git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" + fi + + rm -f "$session" + + if [ -n "$issue_ref" ]; then + remove_issue_from_index "$issue_ref" + fi + + echo "Removed: $session" + done + + echo "" + echo "Pruned ${#orphaned[@]} orphaned session(s)." +} + +cmd_destroy() { + local target="${1:-}" + local force=false + + if [ "$target" = "--base" ]; then + target="" + fi + + if [ "$2" = "-y" ]; then + force=true + fi + + if [ -z "$target" ]; then + echo "Error: target is required" >&2 + echo "Usage: kugetsu destroy [-y]" >&2 + echo " kugetsu destroy --pm-agent [-y]" >&2 + echo " kugetsu destroy --base [-y]" >&2 + exit 1 + fi + + if [ "$target" = "--pm-agent" ]; then + if [ "$force" = false ]; then + echo "Warning: Destroying PM agent session is not recommended." >&2 + read -p "Continue? [y/N] " -r answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Aborted." + return + fi + fi + + local pm_session=$(get_pm_agent_session_id) + if [ -n "$pm_session" ] && [ "$pm_session" != "null" ]; then + echo "Stopping PM agent session: $pm_session" + opencode session stop "$pm_session" 2>/dev/null || true + fi + + rm -f "$SESSIONS_DIR/pm-agent.json" + set_pm_agent_in_index "null" + echo "PM agent session destroyed" + elif [ "$target" = "--base" ]; then + if [ "$force" = false ]; then + echo "Warning: Destroying base session will remove ALL sessions." >&2 + read -p "Continue? [y/N] " -r answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Aborted." + return + fi + fi + + for session_file in "$SESSIONS_DIR"/*.json; do + [ -f "$session_file" ] || continue + rm -f "$session_file" + done + + for worktree in "$WORKTREES_DIR"/.kugetsu-worktrees/*; do + if [ -d "$worktree" ]; then + git worktree remove "$worktree" 2>/dev/null || rm -rf "$worktree" + fi + done + + write_index "null" "null" "{}" + echo "Base session and all worktrees destroyed" + else + 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 "Warning: This will delete session and worktree for '$target'" >&2 + read -p "Continue? [y/N] " -r answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Aborted." + return + fi + + remove_worktree_for_issue "$target" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + fi + fi +} + +cmd_status() { + echo "=== kugetsu status ===" + + local base_id=$(get_base_session_id) + local pm_id=$(get_pm_agent_session_id) + + if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then + echo "Status: Not initialized" + echo "Run 'kugetsu init' to initialize." + return + fi + + echo "Base session: $base_id" + + if [ -n "$pm_id" ] && [ "$pm_id" != "null" ]; then + echo "PM agent: $pm_id" + else + echo "PM agent: not running" + fi + + local active_count=$(count_active_dev_sessions) + echo "Active issue sessions: $active_count / ${MAX_CONCURRENT_AGENTS:-3}" + + echo "" + echo "OpenCode sessions:" + opencode session list 2>/dev/null || echo " (unable to list sessions)" +} diff --git a/skills/kugetsu/scripts/kugetsu-worktree.sh b/skills/kugetsu/scripts/kugetsu-worktree.sh new file mode 100755 index 0000000..ea8984a --- /dev/null +++ b/skills/kugetsu/scripts/kugetsu-worktree.sh @@ -0,0 +1,167 @@ +#!/bin/bash +set -euo pipefail + +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 +} + +check_pr_status() { + local pr_url="$1" + + if [ -z "$pr_url" ]; then + echo "no_pr_url" + return 1 + fi + + local hostname=$(echo "$pr_url" | sed -E 's|https://([^/]+)/.*|\1|') + + local server_base="${GIT_SERVERS[$hostname]:-}" + if [ -z "$server_base" ]; then + echo "unknown_server" + return 1 + fi + + local api_base="${server_base}/api/v1" + + local api_url=$(echo "$pr_url" | sed -E 's|https://[^/]+/([^/]+)/([^/]+)/(pulls|merge_requests)/([0-9]+)|'"${api_base}"'/repos/\1/\2/\3/\4|') + + local token="" + if [[ "$hostname" == "github.com" ]]; then + token="${GITHUB_TOKEN:-}" + else + token="${GITEA_TOKEN:-}" + fi + + local response + if [ -n "$token" ]; then + response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null || echo "{}") + else + response=$(curl -s "$api_url" 2>/dev/null || echo "{}") + fi + + local state=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print(d.get('state', 'unknown'))" 2>/dev/null || echo "unknown") + local merged=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print('true' if d.get('merged', False) else 'false')" 2>/dev/null || echo "false") + + if [ "$merged" = "true" ]; then + echo "merged" + elif [ "$state" = "closed" ]; then + echo "closed" + elif [ "$state" = "open" ]; then + echo "open" + else + echo "unknown" + fi +}