diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b69f4a..7af9dcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ 1. Create a branch for your work: `git checkout -b fix/issue-N-name` or `git checkout -b feat/issue-N-feature-name` 2. Make changes and commit with clear messages 3. Open a Pull Request for review -4. Do not merge directly to `main` for reviewable changes +4. Do not merge directly to `main` or `develop` for reviewable changes 5. After approval, squash and merge ## Guidelines @@ -14,13 +14,53 @@ - Keep PRs focused and reasonably sized - Document any non-obvious decisions - Test changes before submitting +- See [VERSIONING.md](VERSIONING.md) for backport compatibility rules ## Branches -- `main` — stable, reviewed content only -- `develop` — experimental work for 0.2.x +### Primary Branches + +- `main` — stable 0.1.x releases, production-ready code +- `develop` — experimental 0.2.x work, next major version + +### Feature Branches + - `fix/*` — bug fixes - `feat/*` — new features - `docs/*` — documentation updates -- `refactor/*` — refactoring -- `research/*` — new research notes +- `refactor/*` — code refactoring (no behavior change) + +## Branch Model + +``` +main (0.1.x stable) + └── v0.1.0, v0.1.1, v0.1.2, ... + +develop (0.2.x experimental) + └── (next major version work) +``` + +### Which Branch to Target? + +| Change Type | Target Branch | Backport? | +|-------------|---------------|-----------| +| Bug fix | `main` | N/A | +| Documentation | `main` | N/A | +| New feature (backport-compatible) | `main` | Can cherry-pick to `develop` | +| Experimental feature | `develop` | No | +| Breaking change | `develop` | No | + +## Backport Compatibility + +Before merging, consider if your change is backport-compatible: + +- **YES**: Bug fixes, docs, adding new optional inputs +- **NO**: Changing behavior, changing defaults, removing features + +See [VERSIONING.md](VERSIONING.md) for full policy. + +## Release Process + +1. Bug fixes and docs → directly to `main` +2. New features → `develop` or feature branches → `develop` +3. When `develop` is stable enough → merge to `main` for release diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..4eae7f5 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,71 @@ +# Versioning Policy + +## Branch Strategy + +Kugetsu uses a dual-branch model: + +| Branch | Purpose | Version | Stability | +|--------|---------|---------|-----------| +| `main` | Stable releases | 0.1.x | Production-ready | +| `develop` | Experimental work | 0.2.x | Active development | + +### Branch Definitions + +- **`main`**: Contains the latest stable 0.1.x releases. All changes here should be production-ready and backport-compatible when possible. + +- **`develop`**: Contains work for the next major version (0.2.x). This branch may contain experimental features that could change or be removed. + +## Version Format + +Versions follow [Semantic Versioning](https://semver.org/): +``` +MAJOR.MINOR.PATCH +``` + +- **MAJOR**: Incompatible API/behavior changes +- **MINOR**: New functionality (backward-compatible) +- **PATCH**: Bug fixes (backward-compatible) + +## Backport Compatibility + +### Backport-Compatible Changes (0.1.x) +- Bug fixes +- Documentation updates +- Performance improvements +- Adding new inputs/options (must have sensible defaults) +- Changes that only affect 0.2.x-specific features + +### NOT Backport-Compatible +- Removing or renaming existing options +- Changing default values of existing options +- Changing behavior of existing commands +- Introducing breaking changes to the API/shell interface + +## Deprecation Policy + +When introducing breaking changes: + +1. **Deprecate in minor X**: Add warning messages, document the change +2. **Remove in major X+1**: The breaking change is removed in the next major version + +Example: +- Option `--old-flag` deprecated in v0.1.5 +- Option `--old-flag` removed in v1.0.0 (not v0.2.0) + +## What Constitutes a Version Bump + +| Change Type | Version Bump | +|-------------|--------------| +| Add new command/option | MINOR | +| Bug fix | PATCH | +| Change default value | MINOR (may warrant PATCH) | +| Add new required input | MAJOR | +| Remove deprecated feature | MAJOR | +| Change behavior of existing command | MINOR (needs deprecation first) | + +## Release Process + +1. Changes are developed on feature branches +2. PRs are opened against `main` for 0.1.x changes, or `develop` for 0.2.x +3. After review and approval, changes are squash-merged +4. Releases are tagged from `main` after significant changes diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..cb4dc74 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to kugetsu are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +## [v0.2.1] - 2026-04-03 + +### Fixed +- Prevent excess agent spawning with flock + sequential processing + +## [v0.2.0] - 2026-03-30 + +### Added +- Queue system with background daemon +- Agent timeout handling +- Context dump/load for session isolation +- PR tracking and safe destroy + +## [v0.1.13] - 2026-03-29 + +### Fixed +- Add missing closing parenthesis in process_queue Python extraction + +## [v0.1.12] - 2026-03-25 + +### Added +- Post-comment helper for PM agent + +## [v0.1.11] - 2026-03-20 + +### Fixed +- Wrap cmd_continue in subshell with cd for correct worktree dir + +## [v0.1.10] - 2026-03-15 + +### Fixed +- destroy --base now also deletes PM agent session + +## [v0.1.9] - 2026-03-10 + +### Added +- init creates base session in ~/.kugetsu-worktrees +- Adds context to forked sessions +- Clears logs on init + +## [v0.1.8] - 2026-03-05 + +### Fixed +- destroy --base and --pm-agent actually delete opencode sessions + +## [v0.1.7] - 2026-02-28 + +### Fixed +- Warn if init run from non-empty directory + +## [v0.1.6] - 2026-02-20 + +### Fixed +- Detect session via DB query instead of opencode session list + +## [v0.1.5] - 2026-02-15 + +### Fixed +- Update forked session permissions after detection + +## [v0.1.4] - 2026-02-10 + +### Fixed +- Call fix_session_permissions before forking + +## [v0.1.3] - 2026-02-05 + +### Fixed +- Session detection ordering bug and debugging + +## [v0.1.2] - 2026-01-28 + +### Fixed +- Improve session detection in cmd_start with retry logic and logging + +## [v0.1.1] - 2026-01-20 + +### Fixed +- Use cd + worktree inside parent dir instead of --dir flag + +## [v0.1.0] - 2026-01-15 + +### Added +- KUGETSU_VERBOSITY for PM agent output control +- Initial documented release + +[Unreleased]: https://git.fbrns.co/shoko/kugetsu/compare/v0.2.1...HEAD +[v0.2.1]: https://git.fbrns.co/shoko/kugetsu/compare/v0.2.0...v0.2.1 +[v0.2.0]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.13...v0.2.0 +[v0.1.13]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.12...v0.1.13 +[v0.1.12]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.11...v0.1.12 +[v0.1.11]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.10...v0.1.11 +[v0.1.10]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.9...v0.1.10 +[v0.1.9]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.8...v0.1.9 +[v0.1.8]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.7...v0.1.8 +[v0.1.7]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.6...v0.1.7 +[v0.1.6]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.5...v0.1.6 +[v0.1.5]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.4...v0.1.5 +[v0.1.4]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.3...v0.1.4 +[v0.1.3]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.2...v0.1.3 +[v0.1.2]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.1...v0.1.2 +[v0.1.1]: https://git.fbrns.co/shoko/kugetsu/compare/v0.1.0...v0.1.1 +[v0.1.0]: https://git.fbrns.co/shoko/kugetsu/releases/tag/v0.1.0 diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu index bd0ba76..43524c2 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,56 +425,24 @@ 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 } @@ -716,7 +462,6 @@ cleanup_old_queue_items() { fi done } - update_session_pr_url() { local issue_ref="$1" local pr_url="$2" @@ -1247,32 +992,32 @@ cmd_queue() { 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:-}" @@ -1283,8 +1028,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 @@ -1292,38 +1040,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 ;; @@ -1333,29 +1080,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 @@ -1363,413 +1106,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:-}" @@ -1788,7 +1263,7 @@ cmd_server() { echo " $key -> ${GIT_SERVERS[$key]}$marker" done ;; - "add") + add) local name="${2:-}" local url="${3:-}" if [ -z "$name" ] || [ -z "$url" ]; then @@ -1807,7 +1282,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 @@ -1826,7 +1301,7 @@ cmd_server() { exit 1 fi ;; - "default") + default) local name="${2:-}" if [ -z "$name" ]; then echo "Current default: $DEFAULT_GIT_SERVER" @@ -1841,7 +1316,7 @@ cmd_server() { exit 1 fi ;; - "get") + get) local name="${2:-$DEFAULT_GIT_SERVER}" if [ -n "${GIT_SERVERS[$name]:-}" ]; then echo "${GIT_SERVERS[$name]}" @@ -1864,678 +1339,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() { @@ -2543,10 +1437,10 @@ main() { usage exit 1 fi - + local command="$1" shift - + case "$command" in help|--help|-h) usage @@ -2564,18 +1458,11 @@ main() { cmd_delegate "$@" ;; logs) - shift cmd_logs "$@" ;; status) cmd_status ;; - server) - cmd_server "$@" - ;; - env) - cmd_env "$@" - ;; doctor) cmd_doctor "$@" ;; @@ -2628,6 +1515,12 @@ main() { shift cmd_queue_daemon "$action" "$@" ;; + env) + cmd_env "$@" + ;; + server) + cmd_server "$@" + ;; *) echo "Error: unknown command '$command'" >&2 usage @@ -2636,4 +1529,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 +}