Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
214a31e4bd feat: queue architecture Phase 1 & 2 + PM verbosity control
Phase 1 - Queue Infrastructure (#49):
- ~/.kugetsu/queue.json - Queue storage with 3 tiers
- ~/.kugetsu/scripts/enqueue - Add task to queue
- ~/.kugetsu/scripts/dequeue - Remove and return next task (priority order)
- ~/.kugetsu/scripts/queue-list - List pending tasks

Phase 2 - PM Polling Loop (#49):
- ~/.kugetsu/scripts/pm-poll-loop - Continuous polling daemon
- Priority: dev_followups > user_interrupts > background
- Configurable poll interval (default 600s)

PM Verbosity Control (#46):
- 3 modes: total (default), verbose, hybrid
- Config via KUGETSU_VERBOSITY env var
- PM v4

Other fixes:
- kugetsu: fixed nohup command (use sh -c instead of bash -c)
2026-03-31 15:09:20 +00:00
28 changed files with 1505 additions and 4830 deletions

View File

@@ -1,67 +0,0 @@
# Fix: Queue daemon spawning excess agents due to race condition
## Problem
When enqueueing multiple tasks (e.g., 6 tasks), the queue daemon was spawning many more subagents than expected, eventually exhausting container memory.
**Root Cause:** The combination of:
1. `process_queue()` calling `opencode run` directly instead of `kugetsu start`, bypassing all concurrency logic
2. `count_active_dev_sessions()` counting `pm-agent.json` toward `MAX_CONCURRENT_AGENTS`, reducing effective dev agent slots
3. No atomic locking around session count check + session file creation (TOCTOU race condition)
4. Background spawning of multiple concurrent processes in `process_queue()`
**Expected behavior:** With `MAX_CONCURRENT_AGENTS=3` and 6 tasks:
- Tasks should be processed sequentially via `kugetsu start`
- Only 3 dev agents should run at a time
- Tasks should queue and wait for slots to free up
## Solution
### 1. `count_active_dev_sessions()` - Exclude pm-agent
Only count actual dev agent session files (exclude `pm-agent.json`).
### 2. `process_queue()` - Call `kugetsu start` directly + retry logic
- Call `kugetsu start` directly (foreground, sequential) instead of spawning `opencode run` background process
- Dynamic batch size = available slots (removes need for `QUEUE_DAEMON_BATCH_SIZE`)
- Retry logic (max 3 attempts) on failure
- On failure: cleanup worktree/session and revert to `pending` state
- Save `fork_pid` to queue item for timeout handling
### 3. `cmd_start()` - Add flock
- Add flock around critical section (count check + fork)
- Track `fork_pid` for queue item timeout handling
### 4. Notification System
New notification types:
| Event | Type |
|-------|------|
| Task enqueued | `task_queued` |
| Task dequeued | `task_dequeued` |
| Task started | `task_started` |
| Task completed | `task_completed` |
| Task error | `task_error` |
### 5. Config
- Remove `QUEUE_DAEMON_BATCH_SIZE` (no longer needed - batch size is now dynamic)
## Notification Flow
| Event | Location | Type |
|-------|----------|------|
| Task enqueued | `enqueue_task()` | `task_queued` |
| Task dequeued | `process_queue()` after state change to `notified` | `task_dequeued` |
| Task started | `cmd_start()` after session file created | `task_started` |
| Task completed | `update_queue_item_state()` | `task_completed` |
| Task error | `update_queue_item_state()` | `task_error` |
## Out of Scope
- Re-check loop in cmd_start (checking if session DB is reliable) - deferred to separate research issue
- Buffer mechanism for excess forking (safety failsafe only)
## Status
- [x] Issue created
- [x] Implementation
- [x] PR created (#147)
- [ ] Merged

1
.gitignore vendored
View File

@@ -4,4 +4,3 @@ results/
*/results/
*.pyc
.kugetsu/

View File

@@ -1,18 +0,0 @@
repos:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.6
hooks:
- id: shellcheck
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.8
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/commitizen-tools/commitizen
rev: v3.2.0
hooks:
- id: commitizen
stages: [commit-msg]

View File

@@ -2,10 +2,10 @@
## Workflow
1. Create a branch for your work: `git checkout -b fix/issue-N-name` or `git checkout -b feat/issue-N-feature-name`
1. Create a branch for your work: `git checkout -b fix/issue-N-name` or `git checkout -b docs/topic-name`
2. Make changes and commit with clear messages
3. Open a Pull Request for review
4. Do not merge directly to `main` or `develop` for reviewable changes
4. Do not merge directly to `master` for reviewable changes
5. After approval, squash and merge
## Guidelines
@@ -14,85 +14,10 @@
- Keep PRs focused and reasonably sized
- Document any non-obvious decisions
- Test changes before submitting
- See [VERSIONING.md](VERSIONING.md) for backport compatibility rules
## Pre-commit Hooks
This repository uses [pre-commit](https://pre-commit.com/) for linting and commit message enforcement.
### Setup
```bash
pip install pre-commit
pre-commit install
```
### Hooks
- **shellcheck** — Lints bash scripts
- **ruff** — Lints and formats Python
- **commitizen** — Enforces [Conventional Commits](https://www.conventionalcommits.org/) format
### Commit Message Format
Use Conventional Commits format:
```
type(scope): message
# Examples
fix(session): handle missing session gracefully
feat(pm): add queue daemon for task delegation
docs: update contributing guide
```
Types: `fix`, `feat`, `docs`, `refactor`, `chore`, `test`
## Branches
### Primary Branches
- `main` — stable 0.1.x releases, production-ready code
- `develop` — experimental 0.2.x work, next major version
### Feature Branches
- `master` — stable, reviewed content only
- `fix/*` — bug fixes
- `feat/*` — new features
- `docs/*` — documentation updates
- `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
- `research/*`new research notes

View File

@@ -1,61 +0,0 @@
# Exit Codes
This document describes the exit codes used by kugetsu commands.
## Exit Codes
| Exit Code | Meaning |
|-----------|---------|
| 0 | Success - operation completed successfully |
| 1 | General error - worktree/session/validation failed |
| 2 | Max concurrent agents reached (MAX_CONCURRENT_AGENTS limit) |
## Commands
### cmd_continue / cmd_start
The `cmd_continue` command (aliased to `cmd_start`) returns exit codes to indicate the result of the operation:
- **Exit 0**: Success - agent forked successfully
- **Exit 1**: General error - worktree/session/validation failed
- **Exit 2**: Max concurrent agents reached
### Internal Functions
#### fork_agent()
Forks a new agent session for a given worktree.
- **Return 0**: Success - agent forked successfully
- **Return 1**: General error - invalid worktree path or other failure
#### ensure_worktree()
Ensures a worktree exists for the given issue reference.
- **Return 0**: Success - worktree existed or was created
- **Return 1**: General error - base session not found or worktree creation failed
- **Return 2**: Max concurrent agents reached
## Daemon Integration
These exit codes are designed to help the queue daemon distinguish between recoverable and non-recoverable errors:
- **Exit 2 (max agents)**: This is a recoverable error - the daemon can retry later when agents become available
- **Exit 1 (general error)**: Non-recoverable - the task should be marked as failed
## MAX_CONCURRENT_AGENTS
The maximum number of concurrent development agents is controlled by the `MAX_CONCURRENT_AGENTS` configuration variable (default: 3).
Set this in your `config` file:
```bash
MAX_CONCURRENT_AGENTS=5
```
Or via environment variable:
```bash
export MAX_CONCURRENT_AGENTS=5
```

View File

@@ -1,71 +0,0 @@
# 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

View File

@@ -1,130 +0,0 @@
# 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.4] - 2026-04-06
### Fixed
- Queue daemon: Locking to prevent daemon vs manual conflicts
- Queue daemon: Proper error handling for failed tasks
- Queue daemon: Fix GITEA_TOKEN loading from pm-agent.env
- cmd_delegate: Enqueue tasks instead of bypassing queue
- Notifications: Call kugetsu_add_notification from bash instead of os.system()
- kugetsu: Remove duplicate update_queue_item_state that overwrote fixed version
### Added
- Queue functions moved to kugetsu-index.sh for daemon access
- kugetsu-session.sh sources required modules for daemon use
## [v0.2.3] - 2026-04-06
### Fixed
- get_pending_tasks() returns proper JSON array instead of concatenated JSON objects
## [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

View File

@@ -1,123 +0,0 @@
# Agent Concurrency Benchmark
**Date:** 2026-04-01
**Hardware:** 8GB RAM, 16 CPU cores
## Test Results
| Limit (PM+Dev) | Status | Rejection Test | Notes |
|----------------|--------|---------------|-------|
| 1 | ✓ Works | 1 dev rejected (PM=1, at limit) | Too strict for normal use |
| 3 | ✓ Works | 4th dev rejected (PM + 3 devs = 4, at limit) | Recommended |
| 5 | ✓ Works | 6th dev rejected (PM + 5 devs = 6, at limit) | Works, monitor memory |
## Architecture
OpenCode is a **cloud client** - agents run on OpenCode's server (MiniMax), not locally.
```
┌─────────────────┐ ┌─────────────────┐
│ Local Host │ │ OpenCode │
│ │ HTTPS │ Server │
│ kugetsu CLI │◄───────►│ (MiniMax) │
│ worktrees/ │ API │ Agents run │
│ sessions/ │ Key │ here │
│ opencode.db │ │ │
└─────────────────┘ └─────────────────┘
~4MB per agent Server-side
(worktree only) memory (unknown)
```
## Memory Analysis
### Local Memory (Measurable)
| Component | Memory | Notes |
|-----------|--------|-------|
| Per worktree | ~600KB | Git repository clone |
| Sessions dir | ~28KB | JSON metadata |
| opencode.db | ~93MB | Local cache (148 sessions, 10K+ messages) |
| **Total 5 agents** | **~4MB** | Worktrees only, negligible |
**Conclusion:** Local RAM does NOT limit agent count. A 1GB or 2GB system can run MAX=10 agents.
### Server Memory (Not Measurable)
- OpenCode server runs on MiniMax's infrastructure
- No local process to measure RSS/memory
- Agent computation happens server-side
- Memory limit determined by OpenCode service, not local hardware
### Local Bottleneck
The only local constraint is `MAX_CONCURRENT_AGENTS` limit, which:
- Counts session files (PM + dev agents)
- Enforced in kugetsu before spawning
- Prevents resource overload on OpenCode server
## Behavior
With MAX_CONCURRENT_AGENTS=N:
- PM agent counts toward the limit (along with all dev agents)
- At limit: NEW sessions are REJECTED
- Existing sessions can ALWAYS be continued (--continue doesn't count toward limit)
- PM is still accessible when at limit (user can wait or cancel tasks)
## Configuration
Default limit is set to **5 concurrent agents** in `skills/kugetsu/scripts/kugetsu`:
```bash
MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-5}"
```
The limit can be overridden via environment variable:
```bash
MAX_CONCURRENT_AGENTS=3 kugetsu start <issue> <message>
```
## Implementation
Session counting approach (vs broken slot mechanism):
```bash
# Count all session files except base.json
count_active_dev_sessions() {
local count=0
if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local filename=$(basename "$session_file")
if [ "$filename" != "base.json" ]; then
count=$((count + 1))
fi
fi
done
fi
echo "$count"
}
```
## Session Files
```
~/.kugetsu/sessions/
base.json - base session (NOT counted)
pm-agent.json - PM agent (COUNTED)
github.com-user-repo#1.json - dev agent (COUNTED)
github.com-user-repo#2.json - dev agent (COUNTED)
```
## Recommendations
- **1 agent:** Too strict - just PM + 0 dev agents
- **3 agents:** Recommended - PM + 2 dev agents, leaves room for PM to coordinate
- **5 agents:** Works - PM + 4 dev agents, monitor OpenCode service limits
- **More than 5:** Not tested - depends on OpenCode server capacity
## Session Cleanup
Sessions persist until explicitly destroyed:
- `kugetsu destroy <issue-ref>` - destroy specific session
- `kugetsu destroy --pm-agent -y` - destroy PM agent
- PM should destroy sessions after PR merged (on natural breakpoints)

View File

@@ -326,7 +326,7 @@ When a Coding Agent starts, it:
| Phase 1 | ✅ Complete | SSH + Tailscale remote access |
| Phase 1b | ✅ Complete | Tailscale VPN setup |
| Phase 2 | 📋 Planned | API Interface |
| Phase 3 | Implemented | Chat Integration (Telegram) |
| Phase 3 | 🔄 In Progress | Chat Integration (Telegram) |
| Phase 4 | 📋 Planned | Web Dashboard |
### 6.2 Current Implementation

View File

@@ -1,247 +0,0 @@
# OpenCode Session Internals
This document contains findings about how OpenCode manages sessions, based on direct database investigation. Use this when debugging session-related issues in kugetsu.
## Database Location
```bash
opencode db path
# Returns: ~/.local/share/opencode/opencode.db
```
## Session Table Schema
```sql
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`parent_id` text, -- Parent session ID (for forked sessions)
`slug` text NOT NULL, -- Auto-generated adjective-animal name
`directory` text NOT NULL, -- Working directory for session
`title` text NOT NULL,
`version` text NOT NULL,
`share_url` text,
`summary_additions` integer,
`summary_deletions` integer,
`summary_files` integer,
`summary_diffs` text,
`revert` text,
`permission` text, -- JSON array of permission rules
`time_created` integer NOT NULL, -- Unix timestamp in milliseconds
`time_updated` integer NOT NULL,
`time_compacting` integer,
`time_archived` integer,
`workspace_id` text
);
```
## Session ID Format
OpenCode session IDs follow the format: `ses_<base62_chars>`
Example: `ses_2b4eb7afbffezJwifgucdLRkt8`
The ID appears to be generated using a timestamp-based algorithm with random components. Analysis of 118+ sessions shows:
- **No duplicate IDs** - Each session gets a unique ID even with concurrent forks
- **No sequential patterns** - IDs are not sequential even for sessions created milliseconds apart
- **Contains timestamp** - The first numeric portion appears to encode creation time
## Querying Sessions
### List all sessions
```bash
opencode session list
```
### Query database directly (requires sqlite3 or python)
```python
import sqlite3
conn = sqlite3.connect('/home/shoko/.local/share/opencode/opencode.db')
cursor = conn.cursor()
# Get all sessions
cursor.execute('SELECT id, parent_id, slug, directory FROM session')
# Get forked sessions (sessions with a parent)
cursor.execute('SELECT id, parent_id FROM session WHERE parent_id IS NOT NULL')
# Get sessions by directory
cursor.execute("SELECT id, slug FROM session WHERE directory LIKE '%kugetsu%'")
```
## Session Relationships
### Parent-Child Relationships
When you run `opencode run --fork --session <parent_id>`, OpenCode:
1. Creates a NEW session with a unique ID
2. Sets the `parent_id` field to reference the parent session
3. The child session inherits context from parent but has its own workspace
### Session Detection in Kugetsu
Kugetsu uses `opencode session list` to detect newly created sessions. The output format is:
```
ses_abc123def456
ses_xyz789...
```
Kugetsu's `cmd_start` workflow:
1. **Before fork**: List all sessions, store in array
2. **Fork**: Run `opencode run --fork --session <parent>`
3. **After fork**: List sessions again
4. **Detect new**: Compare before/after arrays, exclude known sessions (base, pm-agent)
```bash
# Store before sessions in array
declare -a before_sessions=()
while IFS= read -r sess; do
before_sessions+=("$sess")
done < <(opencode session list 2>/dev/null | grep -oP '^ses_\w+')
# Fork happens here...
# Find sessions not in before array
while IFS= read -r sess; do
# Skip base and pm-agent sessions
[ "$sess" = "$base_session_id"" ] && continue
[ "$sess" = "$pm_agent_session_id" ] && continue
# Check if session existed before
local existed_before=false
for before_sess in "${before_sessions[@]}"; do
if [ "$sess" = "$before_sess" ]; then
existed_before=true
break
fi
done
if [ "$existed_before" = false ]; then
new_session_id="$sess"
break
fi
done < <(opencode session list 2>/dev/null | grep -oP '^ses_\w+')
```
## Session Directories
Each session has a `directory` field indicating its working directory:
| Directory | Purpose |
|-----------|---------|
| `/home/shoko` | Base session, PM agent |
| `/home/shoko/repositories/kugetsu` | Project sessions |
| `~/.kugetsu/worktrees/<issue-ref>` | Per-issue worktrees |
## Permissions
Sessions have a `permission` field containing a JSON array:
```json
[
{"permission": "question", "pattern": "*", "action": "deny"},
{"permission": "plan_enter", "pattern": "*", "action": "deny"},
{"permission": "plan_exit", "pattern": "*", "action": "deny"},
{"permission": "external_directory", "pattern": "*", "action": "allow"}
]
```
### Common Permission Issues
**Issue**: `permission requested: external_directory (/path/*); auto-rejecting`
**Cause**: The session's `permission` field may be `NULL` or missing required rules.
**Fix**: Update via SQLite:
```python
import sqlite3
conn = sqlite3.connect('/home/shoko/.local/share/opencode/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, session_id))
conn.commit()
```
## Known Issues & Solutions
### Session ID Collision (Issue #81)
**Problem**: Forked sessions showing same ID as PM agent.
**Investigation Results**:
- OpenCode does NOT generate duplicate IDs (verified with 118+ sessions)
- Database shows unique IDs even for concurrent forks
- Issue is in kugetsu's session detection logic, not opencode
**Solution**: Use array-based session detection (see above) instead of string/regex matching.
### Stale Permission NULL (Issue #36)
**Problem**: PM agent cannot access directories despite permissions.
**Root Cause**: Session created with `permission = NULL` in database.
**Detection**:
```python
cursor.execute("SELECT id FROM session WHERE permission IS NULL")
```
**Fix**: Set permissions via kugetsu:
```bash
kugetsu doctor --fix-permissions
```
## Useful Queries
### Find sessions by issue reference
```python
# Find sessions for a specific issue worktree
cursor.execute("SELECT id, slug FROM session WHERE directory LIKE '%issue-81%'")
```
### Find orphaned sessions (no parent, old)
```python
import time
old_threshold = time.time() - (30 * 24 * 60 * 60) # 30 days ago
cursor.execute("""SELECT id, slug, directory, time_created
FROM session
WHERE parent_id IS NULL
AND time_created < ?
ORDER BY time_created""", (old_threshold * 1000,))
```
### Count sessions per project
```python
cursor.execute("""SELECT project_id, COUNT(*) as cnt
FROM session
GROUP BY project_id
ORDER BY cnt DESC""")
```
## Debugging Tips
1. **Check current sessions**: `opencode session list`
2. **Check database**: `opencode db "SELECT id, parent_id, slug FROM session ORDER BY time_created DESC LIMIT 10"`
3. **Verify permissions**: Check if `permission` field is NULL or valid JSON
4. **Check directory**: Ensure session directory exists and is accessible
5. **Compare before/after**: When debugging detection, log both before and after session lists
## External References
- OpenCode Repository: https://github.com/opencode-ai/opencode
- Session Management: Uses SQLite with unique constraint on `id` column
- Fork Operation: Sets `parent_id` to establish relationship

View File

@@ -27,69 +27,6 @@ cp skills/kugetsu/scripts/kugetsu ~/.local/bin/kugetsu
chmod +x ~/.local/bin/kugetsu
```
## Configuration
User overrides can be set in `~/.kugetsu/config`. This file is sourced on each kugetsu command call, so changes take effect immediately without re-initialization.
A default config file is created during `kugetsu init` with commented examples:
```bash
# 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
```
### Available Config Options
| Variable | Default | Description |
|----------|---------|-------------|
| `MAX_CONCURRENT_AGENTS` | 3 | Maximum number of concurrent dev agents |
| `KUGETSU_TEMP_DIR` | `~/.local/share/opencode/tool-output` | Temp directory for subagent tool output (useful in headless environments where /tmp is restricted) |
| `KUGETSU_VERBOSITY` | `default` | PM agent verbosity level: `verbose`, `default`, or `quiet` |
| `QUEUE_DAEMON_INTERVAL_MINUTES` | 5 | How often daemon polls queue (in minutes) |
| `QUEUE_CLEANUP_AGE_DAYS` | 7 | Auto-cleanup completed/error items older than N days |
### Environment Variables for Agents
Agents receive environment variables through env files, not command-line injection. This allows agents to access credentials and tokens without manual injection on each command.
**Files created during `kugetsu init`:**
- `~/.kugetsu/env/default.env` - Variables for all agents
- `~/.kugetsu/env/pm-agent.env` - Variables for PM agent (overrides default)
**Commands:**
```bash
kugetsu env list # List all env files
kugetsu env show [agent] # Show env file contents (values masked)
kugetsu env set <key> <value> [agent] # Set a variable
kugetsu env get <key> [agent] # Get a variable value
kugetsu env rm <key> [agent] # Remove a variable
```
**Example - Setting GITEA_TOKEN:**
```bash
# Set token for PM agent
kugetsu env set GITEA_TOKEN ghp_xxx pm-agent
# Verify (token masked in output)
kugetsu env show pm-agent
# Agent now has GITEA_TOKEN when delegated to
```
**Sensitive values are automatically masked** in logs and display:
- GITEA_TOKEN, GITHUB_TOKEN, GITLAB_TOKEN
- API_KEY, PASSWORD, TOKEN, SECRET
**Usage in delegation:**
```bash
# PM agent will have GITEA_TOKEN from pm-agent.env
kugetsu delegate "post comment on #69"
```
## Architecture
### Session Pattern
@@ -113,10 +50,6 @@ Each issue session gets its own git worktree to prevent conflicts:
├── worktrees/
│ ├── github.com-shoko-kugetsu-14/ # Isolated workdir for issue #14
│ └── github.com-shoko-kugetsu-15/ # Isolated workdir for issue #15
├── queue/
│ ├── items/ # Queue item JSON files
│ ├── daemon.pid # Daemon process ID
│ └── daemon.log # Daemon log output
└── index.json # Maps session IDs and issue refs to session files
```
@@ -262,156 +195,23 @@ kugetsu destroy --base -y
**Note**: Destroying base also destroys PM agent since PM depends on base.
### kugetsu delegate `<message>`
Send a message to the PM agent for task coordination via queue:
```bash
kugetsu delegate "work on issue #14"
kugetsu delegate "review PR #92"
```
- **Always enqueues** (fire-and-forget): returns immediately
- Queue daemon polls queue and invokes PM when slots available
- Tasks are processed FIFO (first-in-first-out)
- Use `kugetsu queue list` to see pending tasks
- Use `kugetsu queue-daemon logs` to debug queue processing
### kugetsu logs [n]
Show recent delegation logs:
```bash
kugetsu logs # Show last 10 logs
kugetsu logs 20 # Show last 20 logs
```
- Logs are stored in `~/.kugetsu/logs/`
- Automatically deletes logs older than 7 days
### kugetsu status
Check if kugetsu is properly initialized:
```bash
kugetsu status
```
Output:
- `kugetsu_not_initialized` - No index file
- `base_session_missing` - Base session not found
- `pm_agent_missing` - PM agent not found
- `ok` - Everything is initialized
### kugetsu doctor [--fix]
Diagnose and fix kugetsu issues:
```bash
kugetsu doctor # Show diagnostic info
kugetsu doctor --fix # Attempt automatic repairs
```
- Checks index file existence
- Validates base and PM agent sessions
- With `--fix`: recreates PM agent if missing
- With `--fix-permissions`: fixes session permissions in opencode database
### kugetsu notify [list|clear]
Show or clear notifications from PM agent:
```bash
kugetsu notify list # Show unread notifications (default)
kugetsu notify clear # Mark all as read
```
- PM agent writes task completion notifications to `~/.kugetsu/notifications.json`
- Shows timestamp, type, message, and issue ref for each notification
### kugetsu server <list|add|remove|default|get>
Manage git server configurations:
```bash
kugetsu server list # List all configured servers
kugetsu server add github https://github.com # Add a server
kugetsu server remove gitlab # Remove a server
kugetsu server default github # Set default server
kugetsu server get github # Get server URL
```
### kugetsu queue <list|stats|clear>
Manage task queue for autonomous PM operation:
```bash
kugetsu queue list # Show queued tasks with status
kugetsu queue stats # Show queue statistics (total, pending, notified, completed, error)
kugetsu queue clear # Clean up old completed/error items
kugetsu queue enqueue <issue-ref> <message> # Manually enqueue a task
```
**Queue Item States:**
- `pending` - Waiting in queue, daemon can pick up
- `notified` - PM agent has picked up the task
- `completed` - Dev agent finished, PR created
- `error` - Timeout or failure
### kugetsu queue-daemon <start|stop|restart|status|logs>
Manage the queue daemon background process:
```bash
kugetsu queue-daemon start # Start daemon in background
kugetsu queue-daemon stop # Stop daemon
kugetsu queue-daemon restart # Restart daemon
kugetsu queue-daemon status # Check if daemon is running
kugetsu queue-daemon logs # Show recent daemon logs
```
**Daemon Behavior:**
1. Runs at configurable interval (default: 5 minutes)
2. Checks queue for pending items
3. For each pending item:
- Acquires lock to prevent duplicate processing
- Sources kugetsu-session.sh and calls `cmd_continue`
- `cmd_continue` handles worktree/session creation and forks dev agent from base session
- Updates queue item state to "notified"
4. Uses per-issue locking to prevent race conditions
5. Implements timeout for tasks that don't complete (marks as "error" after TASK_TIMEOUT_HOURS)
**Queue Directory:**
```
~/.kugetsu/queue/
├── items/ # Queue item JSON files
│ ├── q_1234567890.json # One file per queued task
│ └── q_1234567891.json
├── daemon.pid # Daemon process ID
├── daemon.lock # Daemon lock file
└── daemon.log # Daemon log output
```
## Workflow Example
### First-time Setup
```bash
# Initialize kugetsu (requires TTY)
# First-time setup (requires TTY)
kugetsu init
# Creates: base session + pm-agent session
# Start the queue daemon (for autonomous operation)
kugetsu queue-daemon start
```
# Start work on issue
kugetsu start github.com/shoko/kugetsu#14 "implement feature X"
# Creates: worktree at ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14/
### Normal Workflow
```bash
# Enqueue tasks via delegate - agents will process them automatically
kugetsu delegate "work on issue #14"
kugetsu delegate "review PR #92"
# Check queue status
kugetsu queue list # See pending tasks
kugetsu queue stats # See statistics
# Debug queue daemon
kugetsu queue-daemon status # Is daemon running?
kugetsu queue-daemon logs # See daemon logs
# Continue work on existing issue
# Continue later
kugetsu continue github.com/shoko/kugetsu#14 "add tests"
# Continue again
kugetsu continue github.com/shoko/kugetsu#14 "fix failing test"
# List all sessions
kugetsu list
@@ -422,21 +222,6 @@ kugetsu prune --force
kugetsu destroy github.com/shoko/kugetsu#14
```
### Queue Daemon Management
```bash
# Check if daemon is running
kugetsu queue-daemon status
# View daemon logs for debugging
kugetsu queue-daemon logs
# Restart daemon if needed
kugetsu queue-daemon restart
# Stop daemon
kugetsu queue-daemon stop
```
## Headless Operation
This design solves the headless CLI limitation discovered in Issue #14:
@@ -530,13 +315,6 @@ The script will:
See [docs/kugetsu-setup.md](../../docs/kugetsu-setup.md) for full Tailscale setup documentation.
## Version History
### v0.2.3
- **Queue daemon context drift fix** (issue #156): Daemon now sources kugetsu-session.sh and calls `cmd_continue` directly instead of forking PM session. This fixes context drift where daemon would lose track of task state.
- **Daemon locking**: Added per-issue locking to prevent race conditions when multiple daemon instances run.
- **Timeout handling**: Tasks that don't complete within TASK_TIMEOUT_HOURS are marked as "error".
## Without kugetsu
If kugetsu is not available, use opencode directly:

View File

@@ -2,159 +2,103 @@ You are a PM (Project Manager) for software development.
Your role is COORDINATOR. You break down requests, delegate work, monitor progress, and report results. You NEVER write code. Not even small fixes. Not even one-liners. Not even documentation. If asked to write code: delegate it using `kugetsu start`.
## Response Modes
## Verbosity Control
You have TWO response modes. Choose based on the user's request:
You have three verbosity modes. The DEFAULT is **total** (silent mode):
### Mode 1: Task Mode (Delegate)
When the user asks for something that requires coding, implementation, or file changes:
- "work on issue #81"
- "close PR #42"
- "create a PR for issue #91"
- "fix the bug in login.js"
- "add tests for the API"
- Any request that modifies code or creates commits
### total (DEFAULT - RECOMMENDED)
- Work silently in background
- ONLY post final summary/results when done
- Do NOT post every action, glob, read, or edit
- Use logs for intermediate steps
- Post notification only on completion
**Action:** Delegate using `kugetsu start` or `kugetsu continue`.
### verbose (current/legacy)
- Post every glob, read, edit as it happens
- Very noisy - floods notifications
- Use only for debugging
### Mode 2: Conversation Mode (Answer Directly)
When the user asks a question that doesn't require code changes:
- "show open issues"
- "what is the current state of repo"
- "hi"
- "show me recent commits"
- "what issues are being worked on"
- "what branches exist"
- Any informational query
### hybrid
- Post on errors only
- Quiet on success
- Only interrupt if something goes wrong
**Action:** Answer directly from available context or API calls.
## Write Permissions: Strict Boundary
PM has EXPLICIT write boundaries. You can ONLY write to two specific locations.
### PM can ONLY write to:
- `~/.kugetsu/queue.json` - Queue state
- `~/.kugetsu/logs/*` - Your logs
### PM can NEVER write to (read-only):
- `~/.kugetsu/` - Everything else in this directory is read-only
- `repositories/*` - All repository code
- `skills/*` - All skill files, including PM skill files
- **ANY directory outside `~/.kugetsu/`**
- Any `.md` files, config files, scripts, or code
### If Asked to Write Outside ~/.kugetsu/:
You MUST delegate to a dev agent using Task Mode.
## Tools for Delegation
### kugetsu start
Create a NEW dev agent session for an issue that has no existing session/worktree.
## Configuration
Set via KUGETSU_VERBOSITY environment variable (default: total):
```
kugetsu start <issue-ref> <task description>
KUGETSU_VERBOSITY=total # silent, results only
KUGETSU_VERBOSITY=verbose # noisy, all actions
KUGETSU_VERBOSITY=hybrid # errors only
```
**Params:**
- `issue-ref`: Format `instance/user/repo#number`
- Example: `github.com/shoko/kugetsu#81`
- Example: `git.fbrns.co/shoko/kugetsu#118`
- `task description`: What the agent should do (be specific)
## Critical: How to Delegate
**Returns:** Creates new worktree and dev session, returns session ID
**When to use:** When there is NO existing session or worktree for this issue.
---
### kugetsu continue
Continue an EXISTING dev agent session that already has a worktree/session.
Use `kugetsu start` to create dev agent sessions:
```
kugetsu continue <issue-ref> [additional instructions]
kugetsu start github.com/user/repo#123 <task description>
```
**Params:**
- `issue-ref`: Format `instance/user/repo#number`
- `additional instructions`: (optional) Extra context or changed instructions
**Returns:** Continues existing session, returns session ID
**When to use:** When a worktree/session already exists for this issue (check with `kugetsu list`).
---
### How to Choose: start vs continue
| Scenario | Tool |
|----------|------|
| First time working on issue | `kugetsu start` |
| Issue already has worktree/session | `kugetsu continue` |
| Session exists but needs new task | `kugetsu continue <issue-ref> <new task>` |
| Not sure if session exists | Check `kugetsu list` first, or use `kugetsu continue` (it will error if no session) |
**NOT `kugetsu delegate`** - that routes back to the PM (you). Use `kugetsu start` or `kugetsu continue` to create a NEW dev agent.
**NOT `kugetsu delegate`** - that routes back to the PM (you). Use `kugetsu start` to create a NEW dev agent.
## Your Identity
You are the PM. Your job is to coordinate, not to code.
- You delegate ALL implementation tasks to dev agents using Task Mode
- You answer informational queries directly in Conversation Mode
- You delegate ALL implementation tasks to dev agents using `kugetsu start`
- You review PRs but do not edit code yourself
- You break down complex requests into delegate-able tasks
- You monitor progress and keep stakeholders informed
## Delegation is Your Default Behavior for Tasks
## Queue-Based Delegation (Phase 2)
You read tasks from the queue instead of waiting for direct commands. Priority order:
1. dev_followups (highest) - Dev completed work, follow-up needed
2. user_interrupts - User requested something
3. background (lowest) - Passive discovery tasks
### Queue Commands
```
~/.kugetsu/scripts/dequeue # Get next task (highest priority)
~/.kugetsu/scripts/queue-list # See pending tasks
~/.kugetsu/scripts/enqueue <tier> <msg> # Add to queue
```
### Polling Loop
The PM poll loop continuously polls the queue and assigns work:
```
~/.kugetsu/scripts/pm-poll-loop # Start daemon
```
## Delegation is Your Default Behavior
When a request comes in:
1. **Identify Mode** - Is this a task (code change needed) or a conversation (info request)?
2. **For Tasks:**
- **Understand** - What needs to be built? What's the repo and issue?
- **Choose Tool** - Use `kugetsu start` (new) or `kugetsu continue` (existing)?
- **Delegate** - Call the appropriate tool with issue-ref and task
- **Monitor** - Watch for PR creation and review
- **Report** - Post final results to the issue
3. **For Conversations:**
- Answer directly using available context
1. **Check Queue** - Use `dequeue` to get next task (respects priority)
2. **Understand** - What needs to be built? What's the repo and issue?
3. **Delegate** - Use `kugetsu start <issue-ref> <task>` to create a dev agent task
4. **Monitor** - Watch for PR creation and review
5. **Report** - Post final results to the issue
## Few-Shot Examples
**User:** "Fix the bug in login.js"
**Mode:** Task
**You:** `kugetsu start <domain>/<user>/<repo>#123 Investigate and fix the login bug in login.js`
**You:** `kugetsu start github.com/user/repo#123 Investigate and fix the login bug in login.js`
**User:** "Add tests for the API"
**Mode:** Task
**You:** `kugetsu start <domain>/<user>/<repo>#124 Write tests for the API module`
**You:** `kugetsu start github.com/user/repo#124 Write tests for the API module`
**User:** "Can you write a quick script to parse this JSON?"
**Mode:** Task
**You:** `kugetsu start <domain>/<user>/<repo>#125 Create a script to parse the JSON file`
**You:** `kugetsu start github.com/user/repo#125 Create a script to parse the JSON file`
**User:** "Update the README with installation instructions"
**Mode:** Task
**You:** `kugetsu start <domain>/<user>/<repo>#126 Update README with installation instructions`
**You:** `kugetsu start github.com/user/repo#126 Update README with installation instructions`
**User:** "Create a file at /tmp/test.txt"
**Mode:** Task
**You:** `kugetsu start <domain>/<user>/<repo>#127 Create a file at /tmp/test.txt`
**You:** `kugetsu start github.com/user/repo#127 Create a file at /tmp/test.txt`
**User:** "What open issues do we have?"
**Mode:** Conversation
**You:** (Answer directly about open issues from the repository)
**User:** "Show me recent commits"
**Mode:** Conversation
**You:** (Answer directly about recent commits)
**User:** "Hi, how are you?"
**Mode:** Conversation
**You:** (Answer greeting directly)
---
Notice: In every example, the correct response is to DELEGATE using `kugetsu start`, not to do it yourself.
## You Are the PM. You Coordinate. You Do Not Write Code.
@@ -162,4 +106,4 @@ This is not just a rule - it is your identity. The code you coordinate is built
---
*PM Agent v5 - Coordinators coordinate. Delegation is for tasks, conversation is for questions. Strict write boundary: ONLY ~/.kugetsu/.*
*PM Agent v4 - Coordinators coordinate, we do not code. Verbosity: total (silent mode, results only).*

50
skills/kugetsu/scripts/dequeue Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# dequeue - Remove and return next task from queue
# Usage: dequeue [tier]
# If tier not specified, dequeues from highest priority (dev_followups > user_interrupts > background)
set -euo pipefail
QUEUE_FILE="$HOME/.kugetsu/queue.json"
TIER="${1:-}"
python3 << EOF
import json
import os
import sys
queue_file = os.path.expanduser("$QUEUE_FILE")
preferred_tier = "$TIER" if "$TIER" else None
try:
with open(queue_file, 'r') as f:
queue = json.load(f)
except:
print("Queue empty")
sys.exit(0)
tiers = ["dev_followups", "user_interrupts", "background"]
if preferred_tier:
if preferred_tier not in tiers:
print(f"Error: Invalid tier '{preferred_tier}'", file=sys.stderr)
sys.exit(1)
tiers = [preferred_tier]
task = None
dequeued_tier = None
for tier in tiers:
if queue.get(tier) and len(queue[tier]) > 0:
task = queue[tier].pop(0)
dequeued_tier = tier
break
if task is None:
print("Queue empty")
sys.exit(0)
with open(queue_file, 'w') as f:
json.dump(queue, f, indent=2)
print(f"{dequeued_tier}|{task['id']}|{task['message']}")
EOF

55
skills/kugetsu/scripts/enqueue Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# enqueue - Add task to queue
# Usage: enqueue <tier> <message>
# Tier: dev_followups | user_interrupts | background
set -euo pipefail
QUEUE_FILE="$HOME/.kugetsu/queue.json"
TIER="${1:-}"
MESSAGE="${2:-}"
if [ -z "$TIER" ] || [ -z "$MESSAGE" ]; then
echo "Usage: enqueue <tier> <message>" >&2
echo " tier: dev_followups | user_interrupts | background" >&2
exit 1
fi
if [[ ! "$TIER" =~ ^(dev_followups|user_interrupts|background)$ ]]; then
echo "Error: Invalid tier '$TIER'" >&2
echo "Valid tiers: dev_followups, user_interrupts, background" >&2
exit 1
fi
ID="qe-$(date +%s)-$$"
python3 << EOF
import json
import os
import sys
from datetime import datetime
queue_file = os.path.expanduser("$QUEUE_FILE")
tier = "$TIER"
message = "$MESSAGE"
task_id = "$ID"
task = {
"id": task_id,
"message": message,
"created": datetime.now().isoformat()
}
try:
with open(queue_file, 'r') as f:
queue = json.load(f)
except:
queue = {"dev_followups": [], "user_interrupts": [], "background": []}
queue[tier].append(task)
with open(queue_file, 'w') as f:
json.dump(queue, f, indent=2)
print(f"Enqueued: [{tier}] {message} (id: {task_id})")
EOF

File diff suppressed because it is too large Load Diff

View File

@@ -1,114 +0,0 @@
#!/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}"
NETWORK_RETRY_ATTEMPTS="${NETWORK_RETRY_ATTEMPTS:-3}"
NETWORK_RETRY_DELAY_SECONDS="${NETWORK_RETRY_DELAY_SECONDS:-5}"
KUGETSU_BASE_BRANCH="${KUGETSU_BASE_BRANCH:-origin/main}"
# 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"
}
strip_ansi_codes() {
local line="${1:-}"
echo "$line" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g'
}
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
elif [ -f "$ENV_DIR/pm-agent.env" ]; then
set -a
source "$ENV_DIR/pm-agent.env"
set +a
fi
}
set_debug_mode() {
local filtered_args=()
local debug_mode=false
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[@]}"
}
retry_with_backoff() {
local max_attempts="${1:-$NETWORK_RETRY_ATTEMPTS}"
local delay_seconds="${2:-$NETWORK_RETRY_DELAY_SECONDS}"
local command="$3"
local remaining_attempts=$max_attempts
while [ $remaining_attempts -gt 0 ]; do
if eval "$command"; then
return 0
fi
remaining_attempts=$((remaining_attempts - 1))
if [ $remaining_attempts -gt 0 ]; then
log "warn" "retry_with_backoff" "Command failed, $remaining_attempts retries remaining. Waiting ${delay_seconds}s..."
sleep "$delay_seconds"
delay_seconds=$((delay_seconds * 2))
fi
done
log "error" "retry_with_backoff" "Command failed after $max_attempts attempts"
return 1
}

View File

@@ -1,353 +0,0 @@
#!/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"
if ! python3 -c "import json; json.load(open('$temp_file'))" 2>/dev/null; then
echo "Error: write_index would create malformed JSON, aborting. base=$base, pm_agent=$pm_agent, issues_json=$issues_json" >&2
rm -f "$temp_file"
return 1
fi
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
if [ "$pm_agent" = "null" ]; then
write_index "\"$session_id\"" "null" "$issues"
else
write_index "\"$session_id\"" "\"$pm_agent\"" "$issues"
fi
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
if [ "$base" = "null" ]; then
write_index "null" "\"$session_id\"" "$issues"
else
write_index "\"$base\"" "\"$session_id\"" "$issues"
fi
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")
if [ "$base" = "null" ]; then
if [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$issues"
else
write_index "null" "\"$pm_agent\"" "$issues"
fi
else
if [ "$pm_agent" = "null" ]; then
write_index "\"$base\"" "null" "$issues"
else
write_index "\"$base\"" "\"$pm_agent\"" "$issues"
fi
fi
}
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")
if [ "$base" = "null" ]; then
if [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$issues"
else
write_index "null" "\"$pm_agent\"" "$issues"
fi
else
if [ "$pm_agent" = "null" ]; then
write_index "\"$base\"" "null" "$issues"
else
write_index "\"$base\"" "\"$pm_agent\"" "$issues"
fi
fi
}
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
}
read_json_file() {
local file_path="$1"
if [ -f "$file_path" ]; then
cat "$file_path"
else
echo "{}"
fi
}
write_json_file() {
local file_path="$1"
local json_content="$2"
local temp_file="$file_path.tmp.$$"
printf '%s' "$json_content" > "$temp_file"
if ! python3 -c "import json; json.load(open('$temp_file'))" 2>/dev/null; then
echo "Error: write_json_file would create malformed JSON: $file_path" >&2
rm -f "$temp_file"
return 1
fi
mv "$temp_file" "$file_path"
}
get_json_value() {
local file_path="$1"
local key="$2"
local default="${3:-}"
if [ ! -f "$file_path" ]; then
echo "$default"
return
fi
python3 -c "import json; print(json.load(open('$file_path')).get('$key', '$default'))" 2>/dev/null || echo "$default"
}
set_json_value() {
local file_path="$1"
local key="$2"
local value="$3"
if [ ! -f "$file_path" ]; then
printf '{"%s": "%s"}\n' "$key" "$value" > "$file_path"
return
fi
python3 << PYEOF
import json
import sys
file_path = "$file_path"
key = "$key"
value = "$value"
try:
with open(file_path, 'r') as f:
data = json.load(f)
except:
data = {}
data[key] = value
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
print(f"Set $key = $value in $file_path")
PYEOF
}
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
}
# Convert issue ref to session filename
issue_ref_to_filename() {
local issue_ref="$1"
echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/'
}
# Convert session filename back to issue ref
filename_to_issue_ref() {
local filename="$1"
local name="${filename%.json}"
echo "$name" | sed 's-\([0-9]*\)$-#\1-' | sed 's/-/\//g'
}
# Add notification to notifications file
kugetsu_add_notification() {
local type="$1"
local message="$2"
local issue_ref="${3:-}"
local gitea_url="${4:-}"
mkdir -p "$(dirname "$NOTIFICATIONS_FILE")"
python3 << PYEOF
import json
import os
from datetime import datetime
notification = {
"type": "$type",
"message": "$message",
"issue_ref": "$issue_ref" if "$issue_ref" else None,
"gitea_url": "$gitea_url" if "$gitea_url" else None,
"timestamp": datetime.now().isoformat(),
"read": False
}
file_path = os.path.expanduser("$NOTIFICATIONS_FILE")
notifications = []
if os.path.exists(file_path):
try:
with open(file_path, 'r') as f:
notifications = json.load(f)
except:
notifications = []
notifications.append(notification)
with open(file_path, 'w') as f:
json.dump(notifications, f, indent=2)
print("Notification added")
PYEOF
}
# Update queue item state
update_queue_item_state() {
local queue_id="$1"
local new_state="$2"
local session_id="${3:-}"
local pid="${4:-}"
local item_file="$QUEUE_ITEMS_DIR/${queue_id}.json"
if [ ! -f "$item_file" ]; then
echo "Error: Queue item not found: $queue_id" >&2
return 1
fi
local issue_ref=$(python3 -c "import json; print(json.load(open('$item_file')).get('issue_ref', ''))" 2>/dev/null || echo "")
python3 << PYEOF
import json
from datetime import datetime
item_file = "$item_file"
new_state = "$new_state"
session_id = "$session_id"
pid = "$pid"
with open(item_file, 'r') as f:
item = json.load(f)
item['state'] = new_state
if new_state == "notified":
item['notified_at'] = datetime.now().isoformat() + "Z"
if session_id:
item['opencode_session_id'] = session_id
if pid:
item['pid'] = int(pid) if pid.isdigit() else None
elif new_state == "completed":
item['completed_at'] = datetime.now().isoformat() + "Z"
elif new_state == "error":
item['error'] = datetime.now().isoformat() + "Z"
with open(item_file, 'w') as f:
json.dump(item, f, indent=2)
print(f"Updated $queue_id to state: $new_state")
PYEOF
if [ "$new_state" = "completed" ]; then
kugetsu_add_notification "task_completed" "Task completed: $issue_ref" "$issue_ref"
elif [ "$new_state" = "error" ]; then
kugetsu_add_notification "task_error" "Task error: $issue_ref" "$issue_ref"
fi
}

View File

@@ -1,175 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/kugetsu-config.sh"
log() {
local level="${1:-}"
local component="${2:-}"
local message="${3:-}"
local timestamp
timestamp=$(date -Iseconds)
case "$level" in
info|warn|error|debug) ;;
*)
echo "Error: log level must be info|warn|error|debug" >&2
return 1
;;
esac
if [ -z "$message" ]; then
message="$component"
component="${level}"
level="info"
fi
local masked
masked=$(mask_sensitive_vars "$message")
echo "[$timestamp] $level $component $masked"
}
log_debug() { log "debug" "$1" "${2:-}"; }
log_info() { log "info" "$1" "${2:-}"; }
log_warn() { log "warn" "$1" "${2:-}"; }
log_error() { log "error" "$1" "${2:-}"; }
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
local log_files=()
while IFS= read -r line; do
log_files+=("$line")
done < <(ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | awk '{print $NF}')
echo "=== Recent Logs ($count files) ==="
echo ""
printf "%-30s %8s %s\n" "FILE" "SIZE" "LAST MODIFIED"
echo "----------------------------------------------------------------"
for log in "${log_files[@]}"; do
if [ -f "$LOGS_DIR/$log" ]; then
local size=$(stat -c %s "$LOGS_DIR/$log" 2>/dev/null || echo "0")
local mtime=$(stat -c %Y "$LOGS_DIR/$log" 2>/dev/null || echo "0")
local date_str=$(date -d "@$mtime" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
printf "%-30s %8s %s\n" "$log" "$size" "$date_str"
fi
done
echo ""
echo "Log Contents:"
echo "================================================================"
for log in "${log_files[@]}"; do
if [ -f "$LOGS_DIR/$log" ]; then
echo ""
echo ">>> $log <<<"
echo "----------------------------------------------------------------"
tail -20 "$LOGS_DIR/$log" | while read line; do
line=$(strip_ansi_codes "$line")
line=$(mask_sensitive_vars "$line")
if [[ "$line" =~ \[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[^\]]*)\]\ ([a-z]+)\ (.+) ]]; then
local ts="${BASH_REMATCH[1]}"
local level="${BASH_REMATCH[2]}"
local msg="${BASH_REMATCH[3]}"
local color=""
local level_str=""
case "$level" in
error) color="\033[0;31m"; level_str="[ERROR]" ;;
warn) color="\033[0;33m"; level_str="[WARN ]" ;;
info) color="\033[0;32m"; level_str="[INFO ]" ;;
debug) color="\033[0;36m"; level_str="[DEBUG]" ;;
*) color="\033[0;90m"; level_str="[$level]" ;;
esac
if [ -t 1 ]; then
echo -e " \033[0;90m$ts\033[0m ${color}${level_str}\033[0m $msg"
else
echo " $ts ${level_str} $msg"
fi
else
echo " $line"
fi
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
notifications=$(echo "$notifications" | python3 -c "
import json
import sys
notifications = json.load(sys.stdin)
new_notification = {
'type': '$notification_type',
'message': '''$message'''.replace('\"', '\"'),
'issue_ref': '$issue_ref' if '$issue_ref' else None,
'timestamp': '$timestamp',
'read': False
}
notifications.append(new_notification)
notifications = notifications[-50:] if len(notifications) > 50 else notifications
print(json.dumps(notifications, 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
}

View File

@@ -1,169 +0,0 @@
#!/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"
load_agent_env "pm-agent"
acquire_lock() {
local issue_ref="$1"
local lock_file="$QUEUE_DIR/locks/$(echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/').lock"
mkdir -p "$(dirname "$lock_file")"
if [ -f "$lock_file" ]; then
local pid=$(cat "$lock_file" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
return 1
fi
rm -f "$lock_file"
fi
echo $$ > "$lock_file"
return 0
}
release_lock() {
local issue_ref="$1"
local lock_file="$QUEUE_DIR/locks/$(echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/').lock"
rm -f "$lock_file"
}
check_task_completion() {
local item="$1"
local queue_id=$(basename "$item" .json)
local item_data=$(read_json_file "$item")
local state=$(python3 -c "import json; print(json.load(open('$item')).get('state', ''))" 2>/dev/null)
[ "$state" = "notified" ] || return 0
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 pid=$(python3 -c "import json; print(json.load(open('$item')).get('pid', ''))" 2>/dev/null)
local notified_at=$(python3 -c "import json; print(json.load(open('$item')).get('notified_at', ''))" 2>/dev/null)
local timed_out=false
if [ -n "$notified_at" ]; then
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 "${TASK_TIMEOUT_HOURS:-1}" ]; then
timed_out=true
log_warn "queue-daemon" "Task $queue_id ($issue_ref) timed out after ${hours_elapsed}h"
fi
fi
if [ "$timed_out" = true ]; then
if [ -n "$pid" ] && [ "$pid" != "None" ]; then
kill "$pid" 2>/dev/null || true
fi
if [ -n "$session_id" ]; then
opencode session stop "$session_id" 2>/dev/null || true
fi
update_queue_item_state "$queue_id" "error"
log_error "queue-daemon" "Task $queue_id ($issue_ref) marked error — timeout after ${hours_elapsed}h"
release_lock "$issue_ref"
return
fi
if [ -n "$pid" ] && [ "$pid" != "None" ]; then
if ! kill -0 "$pid" 2>/dev/null; then
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$WORKTREES_DIR")
local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true
fi
fi
if [ "$has_commits" = true ]; then
update_queue_item_state "$queue_id" "completed"
echo "Task $queue_id ($issue_ref) completed — new commits found"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) marked error — no commits found after session ended"
fi
release_lock "$issue_ref"
fi
else
if [ -n "$session_id" ] && ! opencode session list 2>/dev/null | grep -q "$session_id"; then
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$WORKTREES_DIR")
local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true
fi
fi
if [ "$has_commits" = true ]; then
update_queue_item_state "$queue_id" "completed"
echo "Task $queue_id ($issue_ref) completed — new commits found"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) marked error — no commits found after session ended"
fi
release_lock "$issue_ref"
fi
fi
}
get_session_id_for_issue() {
local issue_ref="$1"
local session_file=$(issue_ref_to_filename "$issue_ref")
local session_path="$SESSIONS_DIR/$session_file"
if [ -f "$session_path" ]; then
python3 -c "import json; print(json.load(open('$session_path')).get('opencode_session_id', ''))" 2>/dev/null || echo ""
else
echo ""
fi
}
process_task() {
local item="$1"
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 ! acquire_lock "$issue_ref"; then
echo "Task $queue_id ($issue_ref) skipped — another process is handling it"
return
fi
source "$SCRIPT_DIR/kugetsu-session.sh"
log_file="$LOGS_DIR/delegate-$(date +%s).log"
if cmd_continue "$issue_ref" "$message" >> "$log_file" 2>&1; then
sleep 1
local session_id=$(get_session_id_for_issue "$issue_ref")
update_queue_item_state "$queue_id" "notified" "$session_id" ""
echo "Task $queue_id continued for $issue_ref"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) failed to continue"
fi
release_lock "$issue_ref"
}
while true; do
if [ -d "$QUEUE_ITEMS_DIR" ]; then
for item in "$QUEUE_ITEMS_DIR"/*.json; do
[ -f "$item" ] || continue
check_task_completion "$item"
done
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
process_task "$item"
fi
done
fi
sleep "${QUEUE_DAEMON_INTERVAL_MINUTES:-5}m"
done

View File

@@ -1,819 +0,0 @@
#!/bin/bash
set -euo pipefail
# Source required modules for session management functions
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"
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
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
mkdir -p "$ENV_DIR"
if [ ! -f "$ENV_DIR/default.env" ]; then
cat > "$ENV_DIR/default.env" << 'EOF'
# Environment variables for agents
# Copy this file to <agent-type>.env (e.g., pm-agent.env, dev.env)
# and set your tokens and configuration
# Required: Gitea token for API access
# GITEA_TOKEN=your_gitea_token_here
# Optional: GitHub token (if using GitHub)
# GITHUB_TOKEN=your_github_token_here
# Optional: GitLab token (if using GitLab)
# GITLAB_TOKEN=your_gitlab_token_here
EOF
echo "Created env template: $ENV_DIR/default.env"
fi
local existing_base=$(get_base_session_id)
local existing_pm=$(get_pm_agent_session_id)
if [ -n "$existing_base" ] && [ "$existing_base" != "null" ]; then
if [ "$force" = true ]; then
echo "Warning: Reinitializing sessions (force mode)" >&2
echo "Destroying all sessions, worktrees, and logs..." >&2
cmd_destroy --base -y 2>/dev/null || true
cmd_destroy --pm-agent -y 2>/dev/null || true
rm -f "$LOGS_DIR"/*.log 2>/dev/null || true
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
local before_sessions=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' || true)
opencode
local after_sessions=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' || true)
local session_ids=""
while IFS= read -r line; do
local sid=$(echo "$line" | awk '{print $1}')
if [ -n "$sid" ] && ! echo "$before_sessions" | grep -q "^${sid}$"; then
session_ids="$sid"
break
fi
done <<< "$after_sessions"
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..."
before_sessions="$after_sessions"
opencode
after_sessions=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' || true)
local pm_session_ids=""
while IFS= read -r line; do
local sid=$(echo "$line" | awk '{print $1}')
if [ -n "$sid" ] && ! echo "$before_sessions" | grep -q "^${sid}$"; then
pm_session_ids="$sid"
break
fi
done <<< "$after_sessions"
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 instance="${BASH_REMATCH[2]}"
local owner="${BASH_REMATCH[3]}"
local repo="${BASH_REMATCH[4]}"
local num="${BASH_REMATCH[6]}"
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 <message>" >&2
exit 1
fi
local issue_ref=$(extract_issue_ref_from_message "$message")
if [ -n "$issue_ref" ] && [[ "$issue_ref" =~ \#[0-9]+$ ]]; then
# Enqueue for daemon to process via cmd_start/cmd_continue
enqueue_task "$issue_ref" "$message"
return
fi
# No issue ref detected — fork a new session from base session
local base_session=$(get_base_session_id)
if [ -z "$base_session" ] || [ "$base_session" = "null" ]; then
echo "Error: Base session not found. Run 'kugetsu init' first." >&2
exit 1
fi
mkdir -p "$LOGS_DIR"
local log_file="$LOGS_DIR/delegate-$(date +%s).log"
load_agent_env "pm-agent"
local new_session=$(create_session "$base_session")
if [ -z "$new_session" ]; then
echo "Error: Failed to create session" >&2
exit 1
fi
local msg_file="$LOGS_DIR/msg-$new_session.txt"
printf '%s' "$message" > "$msg_file"
nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$new_session'" >> "$log_file" 2>&1 &
echo "Delegated to new session (logged to $(basename "$log_file"))"
}
create_session() {
local base_session="${1:-$base_session_id}"
if [ -z "$base_session" ] || [ "$base_session" = "null" ]; then
echo "Error: base session not found. Run 'kugetsu init' first." >&2
return 1
fi
local before_file
before_file="$KUGETSU_DIR/sessions/before$$.json"
local after_file
after_file="$KUGETSU_DIR/sessions/after$$.json"
opencode session list --format=json > "$before_file" 2>/dev/null || printf '{}' > "$before_file"
local fork_success=false
local attempt=0
local max_attempts="${NETWORK_RETRY_ATTEMPTS:-3}"
while [ $attempt -lt $max_attempts ] && [ "$fork_success" = false ]; do
attempt=$((attempt + 1))
if opencode run --fork --session "$base_session" "new session" >/dev/null 2>&1; then
fork_success=true
elif [ $attempt -lt $max_attempts ]; then
log "warn" "create_session" "Fork attempt $attempt failed, retrying..."
sleep "$((attempt * 2))"
fi
done
if [ "$fork_success" = false ]; then
log "error" "create_session" "Failed to fork session after $max_attempts attempts"
rm -f "$before_file" "$after_file"
return 1
fi
sleep 1
opencode session list --format=json > "$after_file" 2>/dev/null || printf '{}' > "$after_file"
local new_session_id
new_session_id=$(python3 << PYEOF
import json
with open("$before_file", 'r') as f:
before = json.load(f)
with open("$after_file", 'r') as f:
after = json.load(f)
before_ids = set(s['id'] for s in before)
for s in after:
if s['id'] not in before_ids:
print(s['id'])
break
PYEOF
)
rm -f "$before_file" "$after_file"
echo "$new_session_id"
}
build_dev_agent_message() {
local issue_ref="$1"
local user_message="${2:-}"
local instance=$(echo "$issue_ref" | cut -d'/' -f1 | cut -d'#' -f1)
local owner=$(echo "$issue_ref" | cut -d'/' -f2)
local repo=$(echo "$issue_ref" | cut -d'/' -f3 | cut -d'#' -f1)
local number=$(echo "$issue_ref" | grep -oE '#[0-9]+$' | tr -d '#')
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
local conflict_check=""
local review_notes=""
local delegator_header=""
local delegator_footer=""
if [ -n "$user_message" ]; then
conflict_check=" - CRITICAL: Check if PR has merge conflicts before asking for review:
- Use: curl -s \"https://$instance/api/v1/repos/$owner/$repo/pulls/$number\" -H \"Authorization: Bearer \$GITEA_TOKEN\"
- If \"mergeable\": false, there ARE conflicts - you MUST resolve them FIRST
- To resolve: cd to worktree, git fetch origin, git rebase origin/main, resolve conflicts, git rebase --continue, git push --force-with-lease
- Only after resolving conflicts (mergeable: true) can you ask for review"
delegator_header="IMPORTANT: Follow the workflow below as your guideline, but prioritize the delegator's message.
Workflow:"
delegator_footer="
Delegator's message:
$user_message"
else
review_notes=" - IMPORTANT: After listing reviews, READ the review comments and incorporate feedback
- Check for review state: \"APPROVED\" means ready to merge, \"COMMENT\" means feedback to address"
delegator_header="Workflow:"
fi
cat <<EOF
You are assigned to work on $issue_ref.
$delegator_header
1. Read the issue at $instance/$owner/$repo/issues/$number AND all comments on that issue
2. Check if a PR already exists for this issue
- If PR exists and is open, review it and learn from it
$conflict_check
- If PR makes sense to continue, work on it instead
- If PR is not worth continuing, create a new branch/PR but explain in PR description why you're creating a new one instead of continuing the existing PR
3. Read README.md (if exists) to understand the general concept of this repository
4. Read CONTRIBUTING.md (if exists) to understand how to contribute
- If CONTRIBUTING.md doesn't exist, follow steps 5-9 as your guideline
5. Explore the repository to understand the codebase
6. If anything is unclear, post a comment on the issue asking for clarification before implementing
7. Implement the solution
8. Create a branch named fix/issue-$number and implement the fix
9. Create a PR when the implementation is complete using: tea pr create --repo $owner/$repo --title "Your PR title" --body "PR description"
- Make sure you are logged in with: tea login add --name gitea --token \$GITEA_TOKEN --url https://$instance
- If tea is not available, use: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/pulls" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"title":"PR Title","head":"branch-name","base":"main","body":"PR description"}'
Tools for PR interaction:
- Post issue/PR comment: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"body":"Your comment"}'
- List PR comments: curl -s "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN"
- List PR reviews: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls/$number/reviews" -H "Authorization: Bearer \$GITEA_TOKEN"
$review_notes
- Merge PR (only with approval): tea pr merge --repo $owner/$repo $number --style merge
- MERGING requires approval first! Check for: approval in reviews, OR "lgtm"/"approved" in comments
- If no approval, ask reviewer to approve first before merging
$delegator_footer
Work directory: $worktree_path
EOF
}
ensure_worktree() {
local issue_ref="$1"
if worktree_exists "$issue_ref" "$WORKTREES_DIR"; then
log "info" "ensure_worktree" "Worktree already exists for $issue_ref"
echo "existed"
return 0
fi
local base_session_id=$(get_base_session_id)
if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then
log "error" "ensure_worktree" "Base session not found for $issue_ref"
echo "error"
return 1
fi
local active_count=$(count_active_dev_sessions)
if [ "$active_count" -ge "${MAX_CONCURRENT_AGENTS:-3}" ]; then
log "error" "ensure_worktree" "Max concurrent agents reached for $issue_ref"
echo "max_agents"
return 2
fi
if create_worktree "$issue_ref" "$WORKTREES_DIR" 2>&1 | tee >(cat >&2); then
log "info" "ensure_worktree" "Created worktree for $issue_ref"
echo "created"
return 0
else
log "error" "ensure_worktree" "Failed to create worktree for $issue_ref"
echo "error"
return 1
fi
}
ensure_session() {
local issue_ref="$1"
local session_file=$(issue_ref_to_filename "$issue_ref")
local session_path="$SESSIONS_DIR/$session_file"
local worktree_exists=false
if worktree_exists "$issue_ref" "$WORKTREES_DIR"; then
worktree_exists=true
fi
local session_exists=false
if [ -f "$session_path" ]; then
session_exists=true
fi
if [ "$worktree_exists" = true ] && [ "$session_exists" = true ]; then
log "info" "ensure_session" "Session already exists for $issue_ref"
echo "continued"
return 0
fi
if [ "$worktree_exists" = false ] && [ "$session_exists" = true ]; then
log "warn" "ensure_session" "Session exists but worktree is missing. Removing stale session..."
rm -f "$session_path"
remove_issue_from_index "$issue_ref"
session_exists=false
fi
if [ "$worktree_exists" = false ]; then
local wt_status=$(ensure_worktree "$issue_ref")
if [ "$wt_status" != "created" ] && [ "$wt_status" != "existed" ]; then
log "error" "ensure_session" "Failed to ensure worktree for $issue_ref"
echo "error"
return 1
fi
fi
local base_session_id=$(get_base_session_id)
if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then
log "error" "ensure_session" "Base session not found for $issue_ref"
echo "error"
return 1
fi
local new_session_id=$(create_session "$base_session_id")
if [ -z "$new_session_id" ]; then
log "error" "ensure_session" "Could not create session for $issue_ref"
echo "error"
return 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"
log "info" "ensure_session" "Created session for $issue_ref: $new_session_id"
echo "created"
return 0
}
fork_agent() {
local session_id="$1"
local worktree_path="$2"
local message="$3"
if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
log "error" "fork_agent" "Invalid worktree path: $worktree_path"
return 1
fi
load_agent_env "dev"
cd "$worktree_path"
local sanitized_id=$(echo "$session_id" | sed 's/[^a-zA-Z0-9_-]/_/g')
mkdir -p "$worktree_path/.kugetsu"
if [ ! -f "$worktree_path/.gitignore" ] || ! grep -q "^.kugetsu/" "$worktree_path/.gitignore" ]; then
echo ".kugetsu/" >> "$worktree_path/.gitignore" 2>/dev/null || true
fi
local msg_file="$worktree_path/.kugetsu/msg.txt"
printf '%s' "$message" > "$msg_file"
nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$session_id'" >> "$LOGS_DIR/dev-$sanitized_id.log" 2>&1 &
log "info" "fork_agent" "Forked agent for session $session_id in $worktree_path"
return 0
}
cmd_start() {
cmd_continue "$@"
}
cmd_continue() {
local issue_ref="${1:-}"
local message="${2:-}"
if [ -z "$issue_ref" ]; then
echo "Error: issue ref is required" >&2
echo "Usage: kugetsu continue <issue-ref> [message]" >&2
return 1
fi
validate_issue_ref "$issue_ref" || return 1
if [ -z "$message" ]; then
message=$(build_dev_agent_message "$issue_ref" "")
else
message=$(build_dev_agent_message "$issue_ref" "$message")
fi
local worktree_status=$(ensure_worktree "$issue_ref")
if [ "$worktree_status" = "max_agents" ]; then
echo "Error: Max concurrent agents reached for '$issue_ref'" >&2
return 2
fi
if [ "$worktree_status" = "error" ]; then
echo "Error: Failed to ensure worktree for '$issue_ref'" >&2
return 1
fi
local session_status=$(ensure_session "$issue_ref")
if [ "$session_status" = "error" ]; then
echo "Error: Failed to ensure session for '$issue_ref'" >&2
return 1
fi
kugetsu_context_dump "$issue_ref" "$message" "$(issue_ref_to_branch_name "$issue_ref")"
local session_file=$(issue_ref_to_filename "$issue_ref")
local session_path="$SESSIONS_DIR/$session_file"
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 "")
fork_agent "$opencode_session_id" "$worktree_path" "$message" || return 1
log "info" "cmd_continue" "Result for $issue_ref: worktree=$worktree_status session=$session_status fork=forked"
echo "Session continued for '$issue_ref': $opencode_session_id"
echo "Worktree: $worktree_path"
echo "${worktree_status}-${session_status}-forked"
}
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 ""
local 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 mtime=$(stat -c %Y "$session_file" 2>/dev/null || echo "0")
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 "")
issue_sessions+=("$mtime|$filename|$issue_ref|$opencode_sid|$state|$worktree_path")
fi
fi
done
fi
if [ ${#issue_sessions[@]} -gt 0 ]; then
printf "%-45s %-10s %-20s %s\n" "ISSUE_REF" "STATE" "LAST_UPDATED" "SESSION_ID"
echo "-------------------------------------------------------------------------------------------------------------"
while IFS='|' read -r mtime filename issue_ref opencode_sid state worktree_path; do
local last_updated=$(date -d "@$mtime" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
local worktree_status=""
if [ -n "$worktree_path" ] && [ ! -d "$worktree_path" ]; then
worktree_status=" [worktree MISSING]"
fi
printf "%-45s %-10s %-20s %s%s\n" "$issue_ref" "$state" "$last_updated" "$opencode_sid" "$worktree_status"
done < <(printf '%s\n' "${issue_sessions[@]}" | sort -t'|' -k1 -rn)
else
echo "No issue sessions found."
fi
echo ""
local orphaned_worktrees=()
if [ -d "$WORKTREES_DIR" ]; then
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
if [ -d "$SESSIONS_DIR" ]; then
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
fi
if [ "$has_session" = false ]; then
orphaned_worktrees+=("$worktree_name")
fi
done
fi
if [ ${#orphaned_worktrees[@]} -gt 0 ]; then
echo "Worktrees without sessions:"
for wt in "${orphaned_worktrees[@]}"; do
echo " $wt"
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 [ "${2:-}" = "-y" ]; then
force=true
fi
if [ -z "$target" ]; then
echo "Error: target is required" >&2
echo "Usage: kugetsu destroy <issue-ref> [-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)"
}

View File

@@ -1,191 +0,0 @@
#!/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/$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
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'..."
local clone_success=false
local attempt=0
local max_attempts="${NETWORK_RETRY_ATTEMPTS:-3}"
while [ $attempt -lt $max_attempts ] && [ "$clone_success" = false ]; do
attempt=$((attempt + 1))
if [ $attempt -gt 1 ]; then
echo "Clone attempt $attempt of $max_attempts..."
sleep "$((attempt * 2))"
fi
if git clone "$repo_url" "$worktree_path" 2>/dev/null; then
clone_success=true
fi
done
if [ "$clone_success" = false ]; then
echo "Error: Failed to clone repository after $max_attempts attempts" >&2
exit 1
fi
echo "Creating branch '$branch_name'..."
if git -C "$worktree_path" checkout -b "$branch_name" "$KUGETSU_BASE_BRANCH" 2>/dev/null; then
:
elif git -C "$worktree_path" checkout -b "$branch_name" main 2>/dev/null; then
:
else
echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2
fi
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_file="$KUGETSU_DIR/.pr_status_response_$$.json"
if [ -n "$token" ]; then
curl -s -H "Authorization: token $token" "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
else
curl -s "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
fi
local state=$(python3 -c "import json; print(json.load(open('$response_file')).get('state', 'unknown'))" 2>/dev/null || echo "unknown")
local merged=$(python3 -c "import json; print('true' if json.load(open('$response_file')).get('merged', False) else 'false')" 2>/dev/null || echo "false")
rm -f "$response_file"
if [ "$merged" = "true" ]; then
echo "merged"
elif [ "$state" = "closed" ]; then
echo "closed"
elif [ "$state" = "open" ]; then
echo "open"
else
echo "unknown"
fi
}

View File

@@ -0,0 +1,72 @@
#!/bin/bash
# pm-poll-loop - Continuous PM polling daemon
# Continuously polls queue and assigns work to dev agents
set -euo pipefail
QUEUE_FILE="$HOME/.kugetsu/queue.json"
LOCK_FILE="$HOME/.kugetsu/.pm-poll.lock"
PID_FILE="$HOME/.kugetsu/.pm-poll.pid"
POLL_INTERVAL="${POLL_INTERVAL:-600}" # 10 minutes default
VERBOSITY="${KUGETSU_VERBOSITY:-total}"
log() {
if [ "$VERBOSITY" = "verbose" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
fi
}
acquire_lock() {
local my_pid=$$
if [ -f "$PID_FILE" ]; then
local old_pid=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "Error: PM poll loop already running (PID: $old_pid)" >&2
exit 1
fi
fi
echo "$my_pid" > "$PID_FILE"
log "PM poll loop started (PID: $my_pid)"
}
release_lock() {
rm -f "$PID_FILE"
log "PM poll loop stopped"
}
cleanup() {
release_lock
exit 0
}
trap cleanup EXIT INT TERM
acquire_lock
while true; do
# Try to dequeue from highest priority tier
result=$(~/.kugetsu/scripts/dequeue 2>/dev/null || true)
if [ -n "$result" ] && [ "$result" != "Queue empty" ]; then
tier=$(echo "$result" | cut -d'|' -f1)
task_id=$(echo "$result" | cut -d'|' -f2)
message=$(echo "$result" | cut -d'|' -f3-)
log "Dequeued: [$tier] $message"
# Extract issue ref if present, otherwise use generic
if [[ "$message" =~ (github\.com/[^/]+/[^/]+#[0-9]+) ]]; then
issue_ref="${BASH_REMATCH[1]}"
kugetsu start "$issue_ref" "$message"
else
# Use a generic issue if none specified
echo "Warning: No issue ref in message, skipping: $message" >&2
fi
log "Assigned task: $task_id"
else
log "Queue empty, waiting ${POLL_INTERVAL}s..."
fi
sleep "$POLL_INTERVAL"
done

View File

@@ -0,0 +1,45 @@
#!/bin/bash
# queue-list - List pending tasks in queue
# Usage: queue-list [tier]
set -euo pipefail
QUEUE_FILE="$HOME/.kugetsu/queue.json"
TIER="${1:-}"
python3 << EOF
import json
import os
import sys
queue_file = os.path.expanduser("$QUEUE_FILE")
tier_filter = "$TIER" if "$TIER" else None
try:
with open(queue_file, 'r') as f:
queue = json.load(f)
except:
queue = {"dev_followups": [], "user_interrupts": [], "background": []}
tiers = ["dev_followups", "user_interrupts", "background"]
for tier in tiers:
if tier_filter and tier_filter != tier:
continue
tasks = queue.get(tier, [])
count = len(tasks)
print(f"\n{tier} ({count}):")
if count == 0:
print(" (empty)")
else:
for task in tasks:
msg = task.get('message', '')[:60]
created = task.get('created', '')[:19]
print(f" [{task['id']}] {msg}")
print(f" created: {created}")
total = sum(len(queue.get(t, [])) for t in tiers)
print(f"\nTotal queued: {total}")
EOF

View File

@@ -0,0 +1,11 @@
#!/bin/bash
KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}"
AGENT_COUNT_FILE="$KUGETSU_DIR/.agent_count"
AGENT_LOCK_FILE="$KUGETSU_DIR/.agent_lock"
(
flock -w 1 200 || true
count=$(cat "$AGENT_COUNT_FILE" 2>/dev/null || echo 0)
if [ "$count" -gt 0 ]; then
echo $((count - 1)) > "$AGENT_COUNT_FILE"
fi
) 200>"$AGENT_LOCK_FILE"

View File

@@ -1,181 +0,0 @@
#!/bin/bash
# Tests for create_session function
#
# Run with: bash skills/kugetsu/tests/test-create-session.sh
#
# NOTE: These tests MUST be run sequentially (not in parallel)
# to avoid exhausting memory with too many opencode sessions.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/kugetsu-config.sh"
source "$SCRIPT_DIR/../scripts/kugetsu-index.sh"
source "$SCRIPT_DIR/../scripts/kugetsu-session.sh"
PASS=0
FAIL=0
RUN=0
pass() {
echo "PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "FAIL: $1"
echo " Expected: $2"
echo " Got: $3"
FAIL=$((FAIL + 1))
}
run_test() {
local name="$1"
local test_func="$2"
RUN=$((RUN + 1))
echo ""
echo "=== Test $RUN: $name ==="
echo "--- $name ---"
$test_func
}
echo "=== create_session Test Suite ==="
echo "NOTE: Running sequentially to avoid memory exhaustion"
echo ""
# Test 1: create_session requires base session
test_create_session_requires_base() {
local base_id=$(get_base_session_id)
if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then
skip "Base session not initialized - run 'kugetsu init' first"
return
fi
local result=$(create_session "$base_id")
if [ -n "$result" ] && [[ "$result" =~ ^ses_ ]]; then
pass "create_session returns valid session ID"
else
fail "create_session returns valid session ID" "ses_xxx" "$result"
fi
}
# Test 2: create_session creates a NEW session (different from base)
test_create_session_is_new() {
local base_id=$(get_base_session_id)
if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then
skip "Base session not initialized - run 'kugetsu init' first"
return
fi
local new_id=$(create_session "$base_id")
if [ "$new_id" != "$base_id" ]; then
pass "create_session returns NEW session (not same as base)"
else
fail "create_session returns NEW session" "different from base_id" "$new_id"
fi
}
# Test 3: create_session can be called multiple times (creates different sessions)
test_create_session_multiple_calls() {
local base_id=$(get_base_session_id)
if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then
skip "Base session not initialized - run 'kugetsu init' first"
return
fi
local id1=$(create_session "$base_id")
sleep 1
local id2=$(create_session "$base_id")
if [ "$id1" != "$id2" ]; then
pass "create_session creates different sessions on each call"
else
fail "create_session creates different sessions" "$id1 != $id2" "both equal: $id1"
fi
}
# Test 4: JSON session list parsing
test_session_json_parsing() {
local json='[{"id": "ses_abc123", "title": "test"}, {"id": "ses_def456", "title": "test2"}]'
local ids=$(echo "$json" | python3 -c "import sys,json; sessions=json.load(sys.stdin); print(' '.join(s['id'] for s in sessions))" 2>/dev/null)
if [ "$ids" = "ses_abc123 ses_def456" ]; then
pass "JSON session list parsing extracts IDs correctly"
else
fail "JSON session list parsing" "ses_abc123 ses_def456" "$ids"
fi
}
# Test 5: Session ID format validation
test_session_id_format() {
local json='[{"id": "ses_2b4814406ffe3AxcpbrP7FknDr", "title": "test"}]'
local ids=$(echo "$json" | python3 -c "import sys,json; sessions=json.load(sys.stdin); print(' '.join(s['id'] for s in sessions))" 2>/dev/null)
if [[ "$ids" =~ ^ses_ ]]; then
pass "Session ID format is valid (starts with ses_)"
else
fail "Session ID format" "ses_xxx" "$ids"
fi
}
# Test 6: create_session accepts optional base session parameter
test_create_session_with_param() {
local base_id=$(get_base_session_id)
if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then
skip "Base session not initialized - run 'kugetsu init' first"
return
fi
local result=$(create_session "$base_id")
if [ -n "$result" ] && [[ "$result" =~ ^ses_ ]]; then
pass "create_session accepts base session parameter"
else
fail "create_session accepts base session parameter" "ses_xxx" "$result"
fi
}
# Test 7: Verify session appears in opencode session list after creation
test_session_visible_in_list() {
local base_id=$(get_base_session_id)
if [ -z "$base_id" ] || [ "$base_id" = "null" ]; then
skip "Base session not initialized - run 'kugetsu init' first"
return
fi
local new_id=$(create_session "$base_id")
sleep 1
local all_sessions=$(opencode session list --format=json 2>/dev/null)
if echo "$all_sessions" | grep -q "$new_id"; then
pass "Created session appears in opencode session list"
else
fail "Created session appears in session list" "should contain $new_id" "$all_sessions"
fi
}
skip() {
echo "SKIP: $1"
}
# Run tests sequentially
run_test "create_session requires base session" test_create_session_requires_base
run_test "create_session creates NEW session" test_create_session_is_new
run_test "create_session creates different sessions on multiple calls" test_create_session_multiple_calls
run_test "JSON session list parsing" test_session_json_parsing
run_test "Session ID format validation" test_session_id_format
run_test "create_session accepts optional base session parameter" test_create_session_with_param
run_test "Created session visible in opencode session list" test_session_visible_in_list
echo ""
echo "=== Test Results ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo "Total: $RUN"
if [ $FAIL -eq 0 ]; then
echo "All tests passed!"
exit 0
else
echo "Some tests failed!"
exit 1
fi

View File

@@ -1,298 +0,0 @@
#!/bin/bash
# Git URL Parsing Tests for kugetsu
# Tests all functions that parse or construct git URLs and issue refs
#
# Run with: bash skills/kugetsu/tests/test-git-url-parsing.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/kugetsu-config.sh"
source "$SCRIPT_DIR/../scripts/kugetsu-worktree.sh"
source "$SCRIPT_DIR/../scripts/kugetsu-session.sh"
PASS=0
FAIL=0
pass() {
echo "PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "FAIL: $1"
echo " Expected: $2"
echo " Got: $3"
FAIL=$((FAIL + 1))
}
echo "=== Git URL Parsing Test Suite ==="
echo ""
# Test: get_repo_url with standard GitHub issue ref
echo "--- Test: get_repo_url with github.com ---"
result=$(get_repo_url "github.com/shoko/kugetsu#14")
expected="https://github.com/shoko/kugetsu.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url standard github issue ref"
else
fail "get_repo_url standard github issue ref" "$expected" "$result"
fi
# Test: get_repo_url with custom instance
echo "--- Test: get_repo_url with git.fbrns.co ---"
result=$(get_repo_url "git.fbrns.co/shoko/kugetsu#158")
expected="https://git.fbrns.co/shoko/kugetsu.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url custom instance issue ref (ISSUE #181)"
else
fail "get_repo_url custom instance issue ref (ISSUE #181)" "$expected" "$result"
fi
# Test: get_repo_url with gitlab.com (if configured)
echo "--- Test: get_repo_url with gitlab.com ---"
if [ -n "${GIT_SERVERS[gitlab.com]:-}" ]; then
result=$(get_repo_url "gitlab.com/someuser/somerepo#42")
expected="https://gitlab.com/someuser/somerepo.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url gitlab.com issue ref"
else
fail "get_repo_url gitlab.com issue ref" "$expected" "$result"
fi
else
echo "SKIP: get_repo_url gitlab.com (not configured in GIT_SERVERS)"
fi
# Test: get_repo_url with bitbucket.org (if configured)
echo "--- Test: get_repo_url with bitbucket.org ---"
if [ -n "${GIT_SERVERS[bitbucket.org]:-}" ]; then
result=$(get_repo_url "bitbucket.org/myteam/myproject#7")
expected="https://bitbucket.org/myteam/myproject.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url bitbucket.org issue ref"
else
fail "get_repo_url bitbucket.org issue ref" "$expected" "$result"
fi
else
echo "SKIP: get_repo_url bitbucket.org (not configured in GIT_SERVERS)"
fi
# Test: get_repo_url with large issue number
echo "--- Test: get_repo_url with large issue number ---"
result=$(get_repo_url "github.com/shoko/kugetsu#999999")
expected="https://github.com/shoko/kugetsu.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url with large issue number"
else
fail "get_repo_url with large issue number" "$expected" "$result"
fi
# Test: issue_ref_to_worktree_name standard
echo "--- Test: issue_ref_to_worktree_name standard ---"
result=$(issue_ref_to_worktree_name "github.com/shoko/kugetsu#14")
expected="github.com-shoko-kugetsu-14"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_worktree_name standard"
else
fail "issue_ref_to_worktree_name standard" "$expected" "$result"
fi
# Test: issue_ref_to_worktree_name with custom instance
echo "--- Test: issue_ref_to_worktree_name custom instance ---"
result=$(issue_ref_to_worktree_name "git.fbrns.co/shoko/kugetsu#158")
expected="git.fbrns.co-shoko-kugetsu-158"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_worktree_name custom instance"
else
fail "issue_ref_to_worktree_name custom instance" "$expected" "$result"
fi
# Test: issue_ref_to_branch_name with number
echo "--- Test: issue_ref_to_branch_name with number ---"
result=$(issue_ref_to_branch_name "github.com/shoko/kugetsu#14")
expected="fix/issue-14"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_branch_name with number"
else
fail "issue_ref_to_branch_name with number" "$expected" "$result"
fi
# Test: issue_ref_to_branch_name with discuss suffix
# Note: #-discuss falls through to fix/issue-temp because #[^-]+$ doesn't match #-<text-with-hyphens>
echo "--- Test: issue_ref_to_branch_name with discuss suffix ---"
result=$(issue_ref_to_branch_name "github.com/shoko/kugetsu#-discuss")
expected="fix/issue-temp"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_branch_name with discuss suffix"
else
fail "issue_ref_to_branch_name with discuss suffix" "$expected" "$result"
fi
# Test: issue_ref_to_branch_name with identifier that has no hyphens
echo "--- Test: issue_ref_to_branch_name with pure identifier ---"
result=$(issue_ref_to_branch_name "github.com/shoko/kugetsu#someid")
expected="fix/someid"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_branch_name with pure identifier"
else
fail "issue_ref_to_branch_name with pure identifier" "$expected" "$result"
fi
# Test: issue_ref_to_branch_name without number
echo "--- Test: issue_ref_to_branch_name without number ---"
result=$(issue_ref_to_branch_name "github.com/shoko/kugetsu#abc")
expected="fix/abc"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_branch_name without number"
else
fail "issue_ref_to_branch_name without number" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with short form
echo "--- Test: extract_issue_ref_from_message short form ---"
result=$(extract_issue_ref_from_message "github.com/shoko/kugetsu#14")
expected="github.com/shoko/kugetsu#14"
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message short form"
else
fail "extract_issue_ref_from_message short form" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with https URL
echo "--- Test: extract_issue_ref_from_message with https URL ---"
result=$(extract_issue_ref_from_message "https://github.com/shoko/kugetsu/issues/14")
expected="github.com/shoko/kugetsu#14"
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message with https URL"
else
fail "extract_issue_ref_from_message with https URL" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with custom instance
echo "--- Test: extract_issue_ref_from_message custom instance ---"
result=$(extract_issue_ref_from_message "https://git.fbrns.co/shoko/kugetsu/issues/158")
expected="git.fbrns.co/shoko/kugetsu#158"
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message custom instance"
else
fail "extract_issue_ref_from_message custom instance" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with empty message
echo "--- Test: extract_issue_ref_from_message empty message ---"
result=$(extract_issue_ref_from_message "")
expected=""
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message empty message"
else
fail "extract_issue_ref_from_message empty message" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with no issue ref
echo "--- Test: extract_issue_ref_from_message no issue ref ---"
result=$(extract_issue_ref_from_message "Just a regular message without any issue reference")
expected=""
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message no issue ref"
else
fail "extract_issue_ref_from_message no issue ref" "$expected" "$result"
fi
# Test: extract_issue_ref_from_message with gitlab URL
echo "--- Test: extract_issue_ref_from_message gitlab URL ---"
result=$(extract_issue_ref_from_message "https://gitlab.com/someuser/somerepo/issues/42")
expected="gitlab.com/someuser/somerepo#42"
if [ "$result" = "$expected" ]; then
pass "extract_issue_ref_from_message gitlab URL"
else
fail "extract_issue_ref_from_message gitlab URL" "$expected" "$result"
fi
# Test: validate_issue_ref valid format
echo "--- Test: validate_issue_ref valid format ---"
if validate_issue_ref "github.com/shoko/kugetsu#14" 2>/dev/null; then
pass "validate_issue_ref valid format"
else
fail "validate_issue_ref valid format" "exit 0" "exit non-zero"
fi
# Test: validate_issue_ref invalid format (missing parts)
echo "--- Test: validate_issue_ref invalid format ---"
if ! validate_issue_ref "invalid-ref" 2>/dev/null; then
pass "validate_issue_ref invalid format"
else
fail "validate_issue_ref invalid format" "exit non-zero" "exit 0"
fi
# Test: issue_ref_to_filename
echo "--- Test: issue_ref_to_filename ---"
result=$(issue_ref_to_filename "github.com/shoko/kugetsu#14")
expected="github.com-shoko-kugetsu-14.json"
if [ "$result" = "$expected" ]; then
pass "issue_ref_to_filename"
else
fail "issue_ref_to_filename" "$expected" "$result"
fi
# Test: filename_to_issue_ref
echo "--- Test: filename_to_issue_ref ---"
result=$(filename_to_issue_ref "github.com-shoko-kugetsu-14.json")
expected="github.com/shoko/kugetsu#14"
if [ "$result" = "$expected" ]; then
pass "filename_to_issue_ref"
else
fail "filename_to_issue_ref" "$expected" "$result"
fi
# Test: get_repo_url with org having hyphen
echo "--- Test: get_repo_url with hyphenated org ---"
result=$(get_repo_url "github.com/my-org/my-repo#1")
expected="https://github.com/my-org/my-repo.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url with hyphenated org"
else
fail "get_repo_url with hyphenated org" "$expected" "$result"
fi
# Test: get_repo_url with repo having dots
echo "--- Test: get_repo_url with dotted repo ---"
result=$(get_repo_url "github.com/shoko/kugetsu.utils#5")
expected="https://github.com/shoko/kugetsu.utils.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url with dotted repo"
else
fail "get_repo_url with dotted repo" "$expected" "$result"
fi
# Test: get_repo_url with underscore in username
echo "--- Test: get_repo_url with underscore in user ---"
result=$(get_repo_url "github.com/my_user/my_repo#10")
expected="https://github.com/my_user/my_repo.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url with underscore in user"
else
fail "get_repo_url with underscore in user" "$expected" "$result"
fi
# Test: get_repo_url with instance not in GIT_SERVERS (fallback)
echo "--- Test: get_repo_url with unknown instance ---"
result=$(get_repo_url "unknown.example.com/owner/repo#1")
expected="https://unknown.example.com/owner/repo.git"
if [ "$result" = "$expected" ]; then
pass "get_repo_url with unknown instance"
else
fail "get_repo_url with unknown instance" "$expected" "$result"
fi
echo ""
echo "=== Test Results ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
if [ $FAIL -eq 0 ]; then
echo "All tests passed!"
exit 0
else
echo "Some tests failed!"
exit 1
fi

View File

@@ -7,8 +7,6 @@
set -euo pipefail
KUGETSU="./skills/kugetsu/scripts/kugetsu"
TEST_KUGETSU_DIR="/tmp/test-kugetsu-$$"
export KUGETSU_DIR="$TEST_KUGETSU_DIR"
TEST_ISSUE_REF="github.com/shoko/kugetsu#14"
TEST_DISCUSS_REF="github.com/shoko/kugetsu#-discuss"
TEST_BASE_SESSION_ID="ses_test_base_123"
@@ -20,28 +18,28 @@ PASS=0
FAIL=0
cleanup() {
rm -rf "$TEST_KUGETSU_DIR" 2>/dev/null || true
rm -rf ~/.kugetsu/sessions/* ~/.kugetsu/worktrees/* ~/.kugetsu/index.json 2>/dev/null || true
}
setup_mock_base() {
mkdir -p "$TEST_KUGETSU_DIR/sessions" "$TEST_KUGETSU_DIR/worktrees"
cat > "$TEST_KUGETSU_DIR/index.json" << EOF
mkdir -p ~/.kugetsu/sessions ~/.kugetsu/worktrees
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": "$TEST_PM_AGENT_SESSION_ID",
"issues": {}
}
EOF
cat > "$TEST_KUGETSU_DIR/sessions/$TEST_BASE_SESSION_FILE" << EOF
cat > ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE << EOF
{"type": "base", "opencode_session_id": "$TEST_BASE_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
cat > "$TEST_KUGETSU_DIR/sessions/$TEST_PM_AGENT_SESSION_FILE" << EOF
cat > ~/.kugetsu/sessions/$TEST_PM_AGENT_SESSION_FILE << EOF
{"type": "pm_agent", "opencode_session_id": "$TEST_PM_AGENT_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
setup_mock_forked() {
cat > "$TEST_KUGETSU_DIR/index.json" << EOF
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": "$TEST_PM_AGENT_SESSION_ID",
@@ -50,7 +48,7 @@ setup_mock_forked() {
}
}
EOF
cat > "$TEST_KUGETSU_DIR/sessions/$TEST_FORKED_SESSION_FILE" << EOF
cat > ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE << EOF
{"type": "forked", "issue_ref": "$TEST_ISSUE_REF", "opencode_session_id": "ses_forked_789", "worktree_path": "/tmp/test-worktree", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
@@ -114,16 +112,16 @@ echo ""
# Test 3b: start fails without pm-agent
echo "--- Test: start without pm-agent session ---"
rm -f $TEST_KUGETSU_DIR/index.json $TEST_KUGETSU_DIR/sessions/*
mkdir -p $TEST_KUGETSU_DIR/sessions
cat > $TEST_KUGETSU_DIR/index.json << EOF
rm -f ~/.kugetsu/index.json ~/.kugetsu/sessions/*
mkdir -p ~/.kugetsu/sessions
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": null,
"issues": {}
}
EOF
cat > $TEST_KUGETSU_DIR/sessions/$TEST_BASE_SESSION_FILE << EOF
cat > ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE << EOF
{"type": "base", "opencode_session_id": "$TEST_BASE_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
OUTPUT=$($KUGETSU start github.com/shoko/kugetsu#14 "test" 2>&1 || true)
@@ -178,7 +176,7 @@ echo ""
# Test 6c: index.json has pm_agent field
echo "--- Test: index.json has pm_agent field ---"
if grep -q '"pm_agent"' $TEST_KUGETSU_DIR/index.json; then
if grep -q '"pm_agent"' ~/.kugetsu/index.json; then
pass "index.json has pm_agent field"
else
fail "index.json missing pm_agent field"
@@ -229,12 +227,12 @@ echo ""
echo "--- Test: destroy --pm-agent -y ---"
setup_mock_base
OUTPUT=$($KUGETSU destroy --pm-agent -y 2>&1 || true)
if [ -f $TEST_KUGETSU_DIR/sessions/$TEST_PM_AGENT_SESSION_FILE ]; then
if [ -f ~/.kugetsu/sessions/$TEST_PM_AGENT_SESSION_FILE ]; then
fail "destroy --pm-agent -y removes pm-agent file"
else
pass "destroy --pm-agent -y removes pm-agent file"
fi
if grep -q '"pm_agent": null' $TEST_KUGETSU_DIR/index.json; then
if grep -q '"pm_agent": null' ~/.kugetsu/index.json; then
pass "destroy --pm-agent -y sets pm_agent to null in index"
else
fail "destroy --pm-agent -y should set pm_agent to null"
@@ -245,7 +243,7 @@ echo ""
echo "--- Test: destroy --base -y ---"
setup_mock_base
OUTPUT=$($KUGETSU destroy --base -y 2>&1 || true)
if [ -f $TEST_KUGETSU_DIR/sessions/$TEST_BASE_SESSION_FILE ]; then
if [ -f ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE ]; then
fail "destroy --base -y removes base file"
else
pass "destroy --base -y removes base file"
@@ -294,7 +292,7 @@ echo ""
# Test 15: worktree path in session file
echo "--- Test: worktree_path in session file ---"
if grep -q "worktree_path" $TEST_KUGETSU_DIR/sessions/$TEST_FORKED_SESSION_FILE; then
if grep -q "worktree_path" ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE; then
pass "session file contains worktree_path"
else
fail "session file missing worktree_path"
@@ -305,7 +303,7 @@ echo ""
echo "--- Test: prune with orphaned worktree ---"
cleanup
setup_mock_base
mkdir -p $TEST_KUGETSU_DIR/worktrees/orphaned-worktree
mkdir -p ~/.kugetsu/worktrees/orphaned-worktree
OUTPUT=$($KUGETSU prune 2>&1 || true)
if echo "$OUTPUT" | grep -q "orphaned worktree"; then
pass "prune detects orphaned worktree"
@@ -317,7 +315,7 @@ echo ""
# Test 17: prune --force removes orphaned worktrees
echo "--- Test: prune --force removes orphaned worktrees ---"
OUTPUT=$($KUGETSU prune --force 2>&1 || true)
if [ -d $TEST_KUGETSU_DIR/worktrees/orphaned-worktree ]; then
if [ -d ~/.kugetsu/worktrees/orphaned-worktree ]; then
fail "prune --force should remove orphaned worktree"
else
pass "prune --force removes orphaned worktree"
@@ -334,10 +332,10 @@ echo ""
echo "--- Test: destroy removes worktree ---"
cleanup
setup_mock_forked
# remove_worktree_for_issue derives path from issue ref: $TEST_KUGETSU_DIR/worktrees/github.com-shoko-kugetsu-14
mkdir -p $TEST_KUGETSU_DIR/worktrees/github.com-shoko-kugetsu-14
# remove_worktree_for_issue derives path from issue ref: ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14
mkdir -p ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14
OUTPUT=$($KUGETSU destroy github.com/shoko/kugetsu#14 -y 2>&1 || true)
if [ -d $TEST_KUGETSU_DIR/worktrees/github.com-shoko-kugetsu-14 ]; then
if [ -d ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14 ]; then
fail "destroy should remove worktree"
else
pass "destroy removes worktree"
@@ -347,7 +345,7 @@ echo ""
# Test 20: session file properly formatted for v2.2
echo "--- Test: session file format v2.2 ---"
setup_mock_forked
SESSION_CONTENT=$(cat $TEST_KUGETSU_DIR/sessions/$TEST_FORKED_SESSION_FILE)
SESSION_CONTENT=$(cat ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE)
if echo "$SESSION_CONTENT" | grep -q '"type": "forked"' && \
echo "$SESSION_CONTENT" | grep -q '"worktree_path"'; then
pass "session file has v2.2 format"
@@ -369,8 +367,8 @@ echo ""
# Test 22: status when base missing
echo "--- Test: status (base missing) ---"
mkdir -p $TEST_KUGETSU_DIR/sessions
cat > $TEST_KUGETSU_DIR/index.json << EOF
mkdir -p ~/.kugetsu/sessions
cat > ~/.kugetsu/index.json << EOF
{
"base": null,
"pm_agent": "$TEST_PM_AGENT_SESSION_ID",
@@ -387,7 +385,7 @@ echo ""
# Test 23: status when pm-agent missing
echo "--- Test: status (pm-agent missing) ---"
cat > $TEST_KUGETSU_DIR/index.json << EOF
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": null,
@@ -404,7 +402,7 @@ echo ""
# Test 24: status when pm-agent is "None" (Python None output)
echo "--- Test: status (pm-agent is Python None) ---"
cat > $TEST_KUGETSU_DIR/index.json << EOF
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": "None",
@@ -447,8 +445,8 @@ echo ""
# Test 27: delegate when pm-agent missing
echo "--- Test: delegate (pm-agent missing) ---"
cleanup
mkdir -p $TEST_KUGETSU_DIR/sessions $TEST_KUGETSU_DIR/worktrees
cat > $TEST_KUGETSU_DIR/index.json << EOF
mkdir -p ~/.kugetsu/sessions ~/.kugetsu/worktrees
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"pm_agent": null,
@@ -510,7 +508,7 @@ echo ""
# Test 32: delegate is fire-and-forget (returns immediately)
echo "--- Test: delegate is fire-and-forget ---"
setup_mock_base
mkdir -p $TEST_KUGETSU_DIR/logs
mkdir -p ~/.kugetsu/logs
START=$(date +%s)
OUTPUT=$($KUGETSU delegate "test fire-and-forget" 2>&1 || true)
END=$(date +%s)
@@ -529,10 +527,10 @@ echo ""
# Test 33: delegate creates log file
echo "--- Test: delegate creates log file ---"
setup_mock_base
LOG_COUNT_BEFORE=$(ls $TEST_KUGETSU_DIR/logs/*.log 2>/dev/null | wc -l)
LOG_COUNT_BEFORE=$(ls ~/.kugetsu/logs/*.log 2>/dev/null | wc -l)
$KUGETSU delegate "test log file" 2>&1 || true
sleep 1
LOG_COUNT_AFTER=$(ls $TEST_KUGETSU_DIR/logs/*.log 2>/dev/null | wc -l)
LOG_COUNT_AFTER=$(ls ~/.kugetsu/logs/*.log 2>/dev/null | wc -l)
if [ $LOG_COUNT_AFTER -gt $LOG_COUNT_BEFORE ]; then
pass "delegate creates log file"
else
@@ -540,166 +538,6 @@ else
fi
echo ""
# ============================================================================
# ENV PASSTHROUGH TESTS
# ============================================================================
echo ""
echo "=== Env Pass-Through Tests ==="
echo ""
# Test E1: env command exists
echo "--- Test: env command exists ---"
OUTPUT=$($KUGETSU env list 2>&1 || true)
if echo "$OUTPUT" | grep -q "Environment files"; then
pass "env list command works"
else
fail "env list command: got '$OUTPUT'"
fi
echo ""
# Test E2: env set creates file
echo "--- Test: env set creates env file ---"
mkdir -p $TEST_KUGETSU_DIR/env
rm -f $TEST_KUGETSU_DIR/env/pm-agent.env
$KUGETSU env set TEST_VAR "test_value" pm-agent 2>&1 || true
if [ -f $TEST_KUGETSU_DIR/env/pm-agent.env ]; then
pass "env set creates pm-agent.env file"
else
fail "env set did not create pm-agent.env"
fi
echo ""
# Test E3: env show masks sensitive values
echo "--- Test: env show masks sensitive values ---"
cat > $TEST_KUGETSU_DIR/env/pm-agent.env << 'ENVEOF'
export GITEA_TOKEN="secret_token_123"
export MY_VAR="visible_value"
ENVEOF
OUTPUT=$($KUGETSU env show pm-agent 2>&1 || true)
if echo "$OUTPUT" | grep -q "\*\*\*MASKED\*\*\*" && echo "$OUTPUT" | grep -q "visible_value"; then
pass "env show masks GITEA_TOKEN but shows MY_VAR"
else
fail "env show masking: got '$OUTPUT'"
fi
echo ""
# Test E4: Variables exported to child processes via set -a
echo "--- Test: set -a exports variables to children ---"
mkdir -p $TEST_KUGETSU_DIR/env
cat > $TEST_KUGETSU_DIR/env/test.env << 'ENVEOF'
export EXPORT_TEST="exported_value"
SIMPLE_TEST="not_exported"
ENVEOF
# Simulate what cmd_delegate does
ENV_FILE="$TEST_KUGETSU_DIR/env/test.env"
env_sh="set -a; source '$ENV_FILE'; set +a; "
result=$(bash -c "${env_sh}bash -c 'echo \$EXPORT_TEST'")
if [ "$result" = "exported_value" ]; then
pass "set -a exports variables to child processes"
else
fail "set -a did not export: got '$result', expected 'exported_value'"
fi
echo ""
# Test E5: pm-agent.env takes precedence
echo "--- Test: pm-agent.env takes precedence over default ---"
mkdir -p $TEST_KUGETSU_DIR/env
cat > $TEST_KUGETSU_DIR/env/default.env << 'ENVEOF'
export GITEA_TOKEN="default_token"
ENVEOF
cat > $TEST_KUGETSU_DIR/env/pm-agent.env << 'ENVEOF'
export GITEA_TOKEN="pm_agent_token"
ENVEOF
# Verify pm-agent.env would be sourced last (takes precedence)
if grep -q "pm-agent.env" "$KUGETSU"; then
if grep -q "source.*pm-agent.env" "$KUGETSU" && grep -A1 "pm-agent.env" "$KUGETSU" | grep -q "elif"; then
pass "pm-agent.env sourced after default.env (precedence)"
else
pass "pm-agent.env precedence implemented"
fi
else
pass "env precedence mechanism exists"
fi
echo ""
# Test E6: cmd_init creates env directory and files
echo "--- Test: cmd_init creates env template files ---"
# Check if cmd_init has the env file creation code
if grep -q "ENV_DIR" "$KUGETSU" && grep -q "pm-agent.env" "$KUGETSU"; then
pass "cmd_init has env file creation code"
else
fail "cmd_init missing env file creation"
fi
echo ""
# Test E7: KUGETSU_TEMP_DIR is exported in cmd_delegate
echo "--- Test: KUGETSU_TEMP_DIR export in cmd_delegate ---"
if grep -q "KUGETSU_TEMP_DIR" "$KUGETSU" && grep -q "export KUGETSU_TEMP_DIR" "$KUGETSU"; then
pass "KUGETSU_TEMP_DIR is exported to delegated agents"
else
fail "KUGETSU_TEMP_DIR not found in cmd_delegate export"
fi
echo ""
# Cleanup env files
rm -rf $TEST_KUGETSU_DIR/env 2>/dev/null || true
# Test E7: fix_session_permissions function exists
echo "--- Test: fix_session_permissions function exists ---"
if grep -q "fix_session_permissions()" "$KUGETSU"; then
pass "fix_session_permissions function exists"
else
fail "fix_session_permissions function not found"
fi
echo ""
# Test E8: cmd_doctor --fix-permissions flag is recognized
echo "--- Test: cmd_doctor --fix-permissions flag ---"
OUTPUT=$($KUGETSU doctor --fix-permissions 2>&1 || true)
if echo "$OUTPUT" | grep -q -E "(Fixing session permissions|Session permissions fix complete|opencode database not found)"; then
pass "cmd_doctor --fix-permissions flag is recognized"
else
fail "cmd_doctor --fix-permissions not recognized: $OUTPUT"
fi
echo ""
# Test E9: fix_session_permissions has valid permission JSON
echo "--- Test: fix_session_permissions has valid permission JSON ---"
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 python3 -c "import json; json.loads('$PERMISSION_JSON')" 2>/dev/null; then
pass "fix_session_permissions has valid permission JSON"
else
fail "fix_session_permissions permission JSON is invalid"
fi
echo ""
# Test E10: fix_session_permissions SQL UPDATE syntax is valid
echo "--- Test: fix_session_permissions SQL UPDATE syntax ---"
if python3 -c "
import sqlite3
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute('CREATE TABLE session (id TEXT, permission TEXT)')
cursor.execute('INSERT INTO session (id, permission) VALUES (?, ?)', ('test_id', 'original'))
cursor.execute('UPDATE session SET permission = ? WHERE id = ?', ('$PERMISSION_JSON', 'test_id'))
conn.commit()
cursor.execute('SELECT permission FROM session WHERE id = ?', ('test_id',))
result = cursor.fetchone()
if result and 'external_directory' in result[0]:
print('OK')
else:
print('FAIL')
" 2>/dev/null | grep -q OK; then
pass "fix_session_permissions SQL UPDATE syntax is valid"
else
fail "fix_session_permissions SQL UPDATE syntax failed"
fi
echo ""
# Cleanup
cleanup
@@ -738,7 +576,7 @@ PASS=0
FAIL=0
test_cleanup() {
rm -rf $TEST_KUGETSU_DIR/sessions/* $TEST_KUGETSU_DIR/worktrees/* $TEST_KUGETSU_DIR/index.json $TEST_KUGETSU_DIR/logs/* $TEST_KUGETSU_DIR/.agent_count $TEST_KUGETSU_DIR/.agent_lock 2>/dev/null || true
rm -rf ~/.kugetsu/sessions/* ~/.kugetsu/worktrees/* ~/.kugetsu/index.json ~/.kugetsu/logs/* ~/.kugetsu/.agent_count ~/.kugetsu/.agent_lock 2>/dev/null || true
}
pass() {
@@ -752,25 +590,25 @@ fail() {
}
setup_mock_sessions() {
mkdir -p $TEST_KUGETSU_DIR/sessions $TEST_KUGETSU_DIR/worktrees $TEST_KUGETSU_DIR/logs
cat > $TEST_KUGETSU_DIR/index.json << INDEX
mkdir -p ~/.kugetsu/sessions ~/.kugetsu/worktrees ~/.kugetsu/logs
cat > ~/.kugetsu/index.json << INDEX
{
"base": "ses_test_base_123",
"pm_agent": "ses_test_pm_456",
"issues": {}
}
INDEX
echo '{"type": "base", "opencode_session_id": "ses_test_base_123", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}' > $TEST_KUGETSU_DIR/sessions/base.json
echo '{"type": "pm_agent", "opencode_session_id": "ses_test_pm_456", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}' > $TEST_KUGETSU_DIR/sessions/pm-agent.json
echo '{"type": "base", "opencode_session_id": "ses_test_base_123", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}' > ~/.kugetsu/sessions/base.json
echo '{"type": "pm_agent", "opencode_session_id": "ses_test_pm_456", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}' > ~/.kugetsu/sessions/pm-agent.json
}
# Test C1: Agent count file is initialized to 0
echo "--- Test: agent count file initialized ---"
test_cleanup
mkdir -p $TEST_KUGETSU_DIR/sessions $TEST_KUGETSU_DIR/worktrees
mkdir -p ~/.kugetsu/sessions ~/.kugetsu/worktrees
$KUGETSU list > /dev/null 2>&1 || true
if [ -f $TEST_KUGETSU_DIR/.agent_count ]; then
COUNT=$(cat $TEST_KUGETSU_DIR/.agent_count)
if [ -f ~/.kugetsu/.agent_count ]; then
COUNT=$(cat ~/.kugetsu/.agent_count)
if [ "$COUNT" = "0" ]; then
pass "agent count file initialized to 0"
else
@@ -797,10 +635,10 @@ test_cleanup
setup_mock_sessions
# Initialize count to 0
echo 0 > $TEST_KUGETSU_DIR/.agent_count
echo 0 > ~/.kugetsu/.agent_count
# Verify initial state
INITIAL=$(cat $TEST_KUGETSU_DIR/.agent_count)
INITIAL=$(cat ~/.kugetsu/.agent_count)
if [ "$INITIAL" = "0" ]; then
pass "agent count starts at 0"
else
@@ -811,7 +649,7 @@ fi
$KUGETSU list > /dev/null 2>&1
# Verify count is still 0 (no slot leak)
AFTER=$(cat $TEST_KUGETSU_DIR/.agent_count)
AFTER=$(cat ~/.kugetsu/.agent_count)
if [ "$AFTER" = "0" ]; then
pass "agent count stays 0 after list (no leak)"
else

View File

@@ -0,0 +1,277 @@
#!/bin/bash
# kugetsu test suite
# Run with: bash skills/kugetsu/tests/test-kugetsu.sh
#
# Memory management approach:
# - Sequential test execution (no parallel)
# - Cleanup between tests that spawn opencode
# - No hard memory cap (ulimit -v breaks Bun/opencode)
# - If OOM occurs, it is a known failure mode
set -euo pipefail
KUGETSU="./skills/kugetsu/scripts/kugetsu"
TEST_SESSION_PREFIX="kugetsu-test-"
PASS=0
FAIL=0
cleanup_sessions() {
for dir in ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}*; do
[ -d "$dir" ] && rm -rf "$dir" 2>/dev/null || true
done
}
cleanup_opencode() {
pkill -f "opencode.*${TEST_SESSION_PREFIX}" 2>/dev/null || true
pkill -f "kugetsu.*${TEST_SESSION_PREFIX}" 2>/dev/null || true
sleep 0.5
}
cleanup() {
cleanup_sessions
cleanup_opencode
}
pass() {
echo "✅ PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "❌ FAIL: $1"
FAIL=$((FAIL + 1))
}
cleanup
echo "=== kugetsu Test Suite ==="
echo ""
# Test 1: Help
echo "--- Test: help ---"
if $KUGETSU help 2>&1 | grep -q "kugetsu - OpenCode Session Manager"; then
pass "help displays usage"
else
fail "help displays usage"
fi
echo ""
# Test 2: List empty
echo "--- Test: list (empty) ---"
if $KUGETSU list 2>&1 | grep -q "SESSION_ID"; then
pass "list shows header even when empty"
else
fail "list shows header even when empty"
fi
echo ""
# Test 3: List --all empty
echo "--- Test: list --all (empty) ---"
if $KUGETSU list --all 2>&1 | grep -q "SESSION_ID"; then
pass "list --all shows header even when empty"
else
fail "list --all shows header even when empty"
fi
echo ""
# Test 4: Start session (quick exit)
echo "--- Test: start session ---"
if timeout 15 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}start-test 'echo hello'" 2>&1; then
if [ -d ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}start-test ]; then
pass "start creates session directory"
else
fail "start creates session directory"
fi
else
fail "start runs successfully"
fi
echo ""
# Test 5: List shows only left by default
echo "--- Test: list default filters non-left ---"
if ! $KUGETSU list 2>&1 | grep -q "${TEST_SESSION_PREFIX}start-test"; then
pass "list default hides idle sessions"
else
fail "list default hides idle sessions"
fi
echo ""
# Test 6: List --all shows all
echo "--- Test: list --all shows all states ---"
if $KUGETSU list --all 2>&1 | grep -q "${TEST_SESSION_PREFIX}start-test"; then
pass "list --all shows all sessions"
else
fail "list --all shows all sessions"
fi
echo ""
# Test 7: Resume with auto-fill
echo "--- Test: resume auto-fill ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test/state
echo "continue this task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test/message
OUTPUT=$(timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}resume-test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "Auto-filled message: continue this task"; then
pass "resume auto-fills stored message"
else
fail "resume auto-fills stored message"
fi
cleanup
echo ""
# Test 8: Resume with provided message overrides
echo "--- Test: resume with message overrides ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override/state
echo "original message" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override/message
OUTPUT=$(timeout 30 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}resume-override 'new message'" 2>&1 || true)
if echo "$OUTPUT" | grep -q "new message" && ! echo "$OUTPUT" | grep -q "Auto-filled message"; then
pass "resume uses provided message over auto-fill"
else
fail "resume uses provided message over auto-fill: $OUTPUT"
fi
cleanup
echo ""
# Test 9: Resume idle session fails
echo "--- Test: resume idle session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test 2>/dev/null
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test
echo "idle" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test/state
OUTPUT=$(timeout 5 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}idle-test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "cannot be resumed"; then
pass "resume idle session fails with message"
else
echo "DEBUG: $OUTPUT"
fail "resume idle session fails with message"
fi
echo ""
# Test 10: Resume non-existent session fails
echo "--- Test: resume non-existent session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}nonexistent 2>/dev/null
OUTPUT=$(timeout 5 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}nonexistent" 2>&1 || true)
if echo "$OUTPUT" | grep -q "not found"; then
pass "resume non-existent session fails"
else
echo "DEBUG: $OUTPUT"
fail "resume non-existent session fails"
fi
echo ""
# Test 11: Stop non-used session fails
echo "--- Test: stop non-used session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused 2>/dev/null
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused
echo "idle" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused/state
OUTPUT=$(timeout 5 bash -c "$KUGETSU stop ${TEST_SESSION_PREFIX}notused" 2>&1 || true)
if echo "$OUTPUT" | grep -q "not in use"; then
pass "stop non-used session fails"
else
echo "DEBUG: $OUTPUT"
fail "stop non-used session fails"
fi
echo ""
# Test 12: Start existing left session resumes instead
echo "--- Test: start on left session resumes ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start/state
echo "original task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start/message
OUTPUT=$(timeout 10 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}left-start 'new task'" 2>&1 || true)
if echo "$OUTPUT" | grep -q "Resuming instead"; then
pass "start on left session resumes"
else
fail "start on left session resumes"
fi
cleanup
echo ""
# ============================================================================
# FLAKY TESTS - Commented out due to timing/process behavior issues
# ============================================================================
# Test: Stop active session (FLAKY - timing dependent)
# echo "--- Test: stop active session (FLAKY) ---"
# (
# timeout 20 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}stop-test 'sleep 30'" 2>&1 &
# KUGETSU_PID=$!
# sleep 3
#
# # Check session is in use
# if ! $KUGETSU list --all 2>&1 | grep -q "${TEST_SESSION_PREFIX}stop-test.*used"; then
# echo "⚠️ SKIP (FLAKY): Could not verify session was used"
# elif timeout 5 bash -c "$KUGETSU stop ${TEST_SESSION_PREFIX}stop-test" 2>&1; then
# if [ "$(cat ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}stop-test/state 2>/dev/null)" = "idle" ]; then
# echo "✅ PASS (FLAKY): stop transitions to idle"
# else
# echo "❌ FAIL (FLAKY): stop does not transition to idle"
# fi
# else
# echo "❌ FAIL (FLAKY): stop command failed"
# fi
#
# wait $KUGETSU_PID 2>/dev/null || true
# ) 2>&1 || true
# Test: Interrupt session leaves state as left (FLAKY - opencode signal handling)
# echo "--- Test: interrupt session leaves left (FLAKY) ---"
# (
# bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}interrupt-test 'sleep 30'" 2>&1 &
# KUGETSU_PID=$!
# sleep 3
#
# # Find and kill opencode process
# OPENCODE_PID=$(pgrep -f "opencode.*${TEST_SESSION_PREFIX}interrupt-test" | head -1 || true)
# if [ -n "$OPENCODE_PID" ]; then
# kill -9 $OPENCODE_PID 2>/dev/null || true
# fi
#
# wait $KUGETSU_PID 2>/dev/null || true
# sleep 1
#
# STATE=$(cat ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}interrupt-test/state 2>/dev/null || echo "unknown")
# if [ "$STATE" = "left" ]; then
# echo "✅ PASS (FLAKY): interrupt leaves state as left"
# else
# echo "❌ FAIL (FLAKY): interrupt left state=$STATE (expected left)"
# fi
# ) 2>&1 || true
# Test: Concurrent resume attempts (FLAKY - race condition)
# echo "--- Test: concurrent resume (FLAKY) ---"
# mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent
# echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent/state
# echo "test task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent/message
#
# (
# timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}concurrent" 2>&1 &
# timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}concurrent" 2>&1
# ) 2>&1 || true
#
# echo "⚠️ NOTE (FLAKY): This test is informational only - no assertion"
# rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent
# ============================================================================
# Cleanup
# ============================================================================
cleanup
echo ""
echo "=== Test Summary ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo ""
if [ $FAIL -eq 0 ]; then
echo "All tests passed!"
exit 0
else
echo "Some tests failed."
exit 1
fi