diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu index 5023cc6..5095955 100755 --- a/skills/kugetsu/scripts/kugetsu +++ b/skills/kugetsu/scripts/kugetsu @@ -1145,6 +1145,11 @@ parse_issue_ref_from_message() { 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-Z]{2,}/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#[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) + owner=$(echo "$gitserver" | cut -d'/' -f2) + repo=$(echo "$gitserver" | cut -d'/' -f3) + issue_number=$(echo "$message" | grep -oE '#[0-9]+' | 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) 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 +}