Compare commits

..

82 Commits
v0.2.6 ... HEAD

Author SHA1 Message Date
5e729227aa Merge pull request 'fix(session): run delegate synchronously when no issue ref is provided' (#261) from fix/issue-254 into main 2026-04-08 13:24:20 +02:00
shokollm
2aa6785f75 fix(session): run delegate synchronously when no issue ref is provided
When kugetsu delegate is called without an issue ref, it now runs
interactively and synchronously instead of fire-and-forget with nohup.

This allows users to see PM response immediately for guidance/creation
requests rather than having the interaction run in background.
2026-04-08 10:36:49 +00:00
d6659ce93a Merge pull request 'feat(session): improve session list and log formatting for human readability' (#260) from fix/issue-252 into main 2026-04-08 08:53:53 +02:00
shokollm
b630d29bdc feat(session): improve session list and log formatting for human readability
- cmd_list: show sessions in table format ordered by last updated time
- cmd_list: display issue_ref, state, last_updated, and session_id columns
- cmd_logs: cleaner timestamp formatting with human-readable dates
- cmd_logs: color-coded log levels (error=red, warn=yellow, info=green, debug=cyan)
- cmd_logs: improved visual separation between log entries

fixes #252
2026-04-08 06:46:11 +00:00
920d714c8f Merge pull request 'fix(session): return proper exit codes for cmd_continue and fork_agent' (#259) from fix/issue-248 into main 2026-04-08 08:42:31 +02:00
shokollm
611c0df3f7 fix(session): return proper exit codes for cmd_continue and fork_agent
- fork_agent() now returns exit code instead of echoing status
- cmd_continue() returns exit code 2 when max agents reached
- cmd_continue() returns exit code 1 for general errors (instead of exit 1)
- ensure_worktree() returns exit code 2 for max agents condition
- Add EXITCODES.md documenting all exit codes

Closes #248
2026-04-08 06:28:59 +00:00
c14cb5aec8 Merge pull request 'fix(queue): add --format=json and --limit flags to queue list' (#258) from fix/issue-246 into main 2026-04-08 08:26:49 +02:00
a93e470fdc Merge pull request 'fix(queue-daemon): make base branch configurable via KUGETSU_BASE_BRANCH' (#257) from fix/issue-165 into main 2026-04-08 08:22:09 +02:00
shokollm
b28ad78bd8 fix(queue): add --format=json and --limit flags to queue list 2026-04-08 06:20:52 +00:00
23cb35687f Merge pull request 'docs: update SKILL.md to reflect v0.2.3 daemon changes' (#256) from fix/issue-158 into main 2026-04-08 08:19:42 +02:00
shokollm
591dcb4285 fix(queue-daemon): make base branch configurable via KUGETSU_BASE_BRANCH
- Add KUGETSU_BASE_BRANCH env var (default: origin/main)
- Update check_task_completion() to use configurable base branch
- Update create_worktree() to use configurable base branch

Closes #165
2026-04-08 06:13:08 +00:00
shokollm
2af8e54f42 docs: update SKILL.md to reflect v0.2.3 daemon changes
- Update Daemon Behavior section to describe cmd_continue usage
- Document that daemon forks dev agents from base session (not pm_agent)
- Add v0.2.3 changelog entry documenting issue #156 fix
- Describe daemon locking and timeout handling features

Fixes #158
2026-04-08 06:06:18 +00:00
d152421d3f Merge pull request 'refactor(cli): restructure help text to show parent commands' (#255) from fix/issue-244 into main 2026-04-08 07:50:08 +02:00
shokollm
9f7da84915 refactor(cli): restructure help text to show parent commands
- kugetsu: show only parent commands, not all subcommands inline
- kugetsu <command> help: show help for specific command
- kugetsu queue: show queue subcommands (list, stats, clear, enqueue)
- kugetsu queue-daemon: show daemon subcommands (start, stop, restart, status, logs)
- kugetsu env: show env subcommands (list, get, set, rm)
- kugetsu server: show server subcommands (list, add, remove, default, get)

Closes #244
2026-04-08 05:12:51 +00:00
ab99647193 Merge pull request 'fix(shell): address shellcheck warnings, standardize error handling, add retry logic' (#253) from fix/issue-121 into main 2026-04-08 07:04:26 +02:00
shokollm
3deae2dd81 fix(shell): address shellcheck warnings, standardize error handling, add retry logic
- Fix shellcheck SC2155 (separate variable assignment from declaration)
- Replace ! bool && bool pattern with [ "$bool" = true ] && [ "$bool2" = true ]
- Add retry logic for git clone with configurable attempts
- Add retry logic for opencode session fork
- Add NETWORK_RETRY_ATTEMPTS and NETWORK_RETRY_DELAY_SECONDS config vars
- Add retry_with_backoff helper function in kugetsu-config.sh
- Replace subshell cd with git -C for safer directory handling
2026-04-08 04:47:01 +00:00
41c56a859c Merge pull request 'refactor: use JSON file exchange instead of stdout parsing' (#234) from fix/issue-119 into main 2026-04-08 05:35:11 +02:00
shokollm
dd903bb8aa refactor: use JSON file exchange instead of stdout parsing
Replace fragile patterns like:
  python3 -c "import json; print(json.load(...))" | grep
  echo "$json" | python3 -c "import sys,json; ...
2026-04-08 03:11:18 +00:00
efb1e34a7b Merge pull request 'fix: add PR merge conflict check to dev agent workflow' (#233) from fix/issue-229-pr-conflict-check into main 2026-04-08 04:56:02 +02:00
44c84280f8 Merge pull request 'fix(queue-daemon): implement timeout handling for long-running tasks' (#228) from fix/issue-166 into main 2026-04-08 04:51:54 +02:00
shokollm
fa8b8467ee fix(queue-daemon): use kugetsu-log for timeout messages
Use log_warn and log_error instead of echo for unified log formatting.
2026-04-08 02:39:40 +00:00
shokollm
c9bdc0dd88 refactor: unify duplicated prompt sections using conditional variables
- Extract conflict_check and delegator_section as conditional variables
- Single heredoc instead of duplicate blocks
- Resolve code duplication raised in PR comment
2026-04-08 02:35:30 +00:00
shokollm
663e44a82a fix: add PR merge conflict check and review reading to dev agent workflow
Dev agent should:
1. Check if PR has merge conflicts before asking for review
2. Read review comments and incorporate feedback
3. Understand review states: APPROVED = ready to merge, COMMENT = feedback to address

This prevents approving a PR that has conflicts, and ensures dev agents
respond to reviewer comments appropriately.
2026-04-08 02:27:42 +00:00
a65e9d6d28 Merge pull request 'feat(session): integrate kugetsu_context_dump into delegation flow' (#229) from fix/issue-212 into main 2026-04-08 04:22:00 +02:00
shokollm
c9eb8badea feat(session): add kugetsu_context_dump call in cmd_continue
Integrates the existing kugetsu_context_dump() function to capture
the initial user prompt before forking the agent.

Closes #212
2026-04-08 02:17:27 +00:00
shokollm
24dd91d0e1 fix: remove duplicate fi in cmd_continue 2026-04-08 01:04:43 +00:00
c8b2ab6b12 Merge pull request 'fix: always use base workflow with user message' (#232) from fix/issue-229-user-message-with-base-workflow into main 2026-04-08 02:33:22 +02:00
shokollm
87434d1bca fix: always use base workflow and add user message to it
Previously when a user message was provided, cmd_continue would only
use the user message without the base agent workflow. Now both cases
build the full workflow and append the user message.

Changes:
- cmd_continue now always calls build_dev_agent_message even when
  message is provided
- User message is appended at the end with 'Delegator's message:'
- Both with/without message cases now use the same workflow structure

Fixes #229
2026-04-08 00:11:03 +00:00
ae8f1433a7 Merge pull request 'fix(session): remove incorrect worktree removal in ensure_session' (#231) from fix/issue-229-ensure-session-worktree-bug into main 2026-04-08 01:52:08 +02:00
shokollm
b16a97514e fix(session): remove incorrect worktree removal in ensure_session
When worktree exists but session is missing, ensure_session was
incorrectly removing the worktree before recreating. This caused
issues when cmd_continue called ensure_worktree first (creating the
worktree) then ensure_session (which wrongly removed it).

The fix removes the block that removes worktree when session is missing.
If worktree exists, just create the session without touching the worktree.

Fixes #229
2026-04-07 23:39:18 +00:00
b09fe8eabc Merge pull request 'refactor(session): make cmd_continue idempotent with ensure_* functions' (#230) from fix/issue-168 into main 2026-04-08 00:32:16 +02:00
shokollm
d240000088 refactor(session): make cmd_continue idempotent with ensure_* functions
- Add ensure_worktree() - creates worktree if missing, returns status
- Add ensure_session() - creates session if missing, handles inconsistent states
- Add fork_agent() - extracted agent forking logic
- Refactor cmd_continue() to use ensure_* functions (idempotent)
- Make cmd_start() a thin wrapper calling cmd_continue()
- Simplify daemon to always call cmd_continue (no existence check)

This makes cmd_continue truly idempotent - it will:
- Continue existing session if it exists
- Create session and worktree if they don't exist
- Clean and recreate if state is inconsistent

Closes #168
2026-04-07 22:25:53 +00:00
05683ea4c6 Merge pull request 'fix: accumulate message words in cmd_continue for loop' (#227) from fix/issue-225-cmd-continue-message-truncation into main 2026-04-07 15:57:40 +02:00
shokollm
2800e140ac fix: accumulate message words instead of overwriting in cmd_continue
The for loop was overwriting message each iteration, causing only
the last word to survive. Changed to accumulate all words with
proper spacing.

Fixes #225
2026-04-07 13:55:47 +00:00
shokollm
51ec844365 fix(queue-daemon): implement timeout handling for long-running tasks
Check notified_at timestamp in check_task_completion() and mark tasks
as error if they exceed TASK_TIMEOUT_HOURS (defaults to 1 hour).

When timeout is detected:
- Kill the task process (PID) if running
- Stop the opencode session if exists
- Mark queue item as error state

Fixes #166
2026-04-07 12:53:35 +00:00
shokollm
ab06046273 Merge pull request #222 2026-04-07 12:47:22 +00:00
a18948df98 Merge pull request 'enhancement: enhance PM context with delegation tools and response modes' (#221) from fix/issue-220-pm-context-enhancement into main 2026-04-07 14:25:50 +02:00
shokollm
be301c599d enhancement: enhance PM context with delegation tools and response modes
Add clear distinction between Task Mode (delegate) and Conversation Mode
(answer directly).

- Add kugetsu start tool with params and when to use
- Add kugetsu continue tool with params and when to use
- Add guidance on how to choose between start vs continue
- Add examples for conversation mode responses
- Improve few-shot examples with mode identification

Fixes #220
2026-04-07 12:24:27 +00:00
34a0943202 Merge pull request 'fix: remove race condition in cmd_delegate msg file deletion' (#211) from fix/issue-210-msg-file-race-condition into main 2026-04-07 11:54:23 +02:00
shokollm
4464e5d91f fix: remove race condition in cmd_delegate msg file deletion
cmd_delegate deletes the message file immediately after spawning the
background nohup process, causing a race condition where opencode run
may not have read the file yet.

Error: File not found: msg-ses_xxx.txt

Fix by removing rm -f "$msg_file", consistent with cmd_start and
cmd_continue which write to $worktree_path/.kugetsu-msg.txt and never
delete it. Orphaned msg files in $LOGS_DIR/ are harmless.

Fixes #210
2026-04-07 09:51:46 +00:00
c71f43b581 Merge pull request 'fix: add missing set_debug_mode to kugetsu-session.sh' (#208) from fix/issue-207-queue-daemon-set-debug-mode into main 2026-04-07 10:39:41 +02:00
shokollm
66c8624d66 fix: move set_debug_mode to kugetsu-config.sh
The queue daemon crashes with 'set_debug_mode: command not found'
because cmd_continue() calls set_debug_mode(), but that function
was only defined in the main kugetsu script.

Instead of duplicating the function, move it to kugetsu-config.sh
which is always sourced before kugetsu-session.sh in all contexts.

Fixes #207
2026-04-07 08:31:17 +00:00
1b5de5d553 Merge pull request 'feat: add merge capability with approval confirmation' (#205) from fix/pr-merge-with-approval into main 2026-04-07 08:12:31 +02:00
shokollm
19ade67a99 feat: add merge capability with approval confirmation to cmd_continue prompt 2026-04-07 06:04:34 +00:00
efcec4e122 feat: add pre-commit configuration for linting and commit message enforcement (#117) 2026-04-07 08:02:56 +02:00
shokollm
930d0e53b5 docs: add pre-commit hooks section to CONTRIBUTING.md (#117) 2026-04-07 04:35:14 +00:00
e0aac3c05f feat: add PR review/comment workflow to cmd_continue prompt (#204) 2026-04-07 06:28:15 +02:00
a211b56303 fix: move msg file to .kugetsu/ and add tea PR creation instructions (#201) 2026-04-07 05:48:41 +02:00
shokollm
e86a309059 feat: add pre-commit configuration for linting and commit message enforcement (#117) 2026-04-07 03:37:28 +00:00
2beb3adb14 fix: remove unnecessary rm of msg file to avoid race condition (#200) 2026-04-07 05:29:07 +02:00
bfd6778f8b fix: move msg file inside worktree to avoid external_directory permission error (#198) 2026-04-07 05:14:45 +02:00
aafdebb6c6 fix: suppress opencode fork stdout and strip ANSI codes from logs (#197) 2026-04-07 04:58:15 +02:00
e666f4dffb Merge pull request 'fix: use temp file for message to avoid shell parsing issues' (#196) from fix/issue-message-encoding into main 2026-04-07 04:27:50 +02:00
shokollm
a130a79bd7 fix: use temp file for message to avoid shell parsing issues
The message passed to opencode run contains newlines and special
characters (parentheses, etc.) which break shell parsing when passed
directly in double quotes.

Fix by writing message to temp file and using '@msg_file' syntax
to pass to opencode run. This handles any characters in the message.
2026-04-07 01:55:34 +00:00
shokollm
798bee0f79 feat: add centralized logging handler with structured log format
- Add log() function with info|warn|error|debug levels
- Log format: timestamp level component message
- Sensitive values (tokens, passwords, secrets) are masked automatically
- Fix mask_sensitive_vars to handle empty input gracefully

Implements: shoko/kugetsu#118
2026-04-07 01:48:52 +00:00
478f7ceeba Merge pull request 'fix: improve worktree/session handling in cmd_start and cmd_continue' (#195) from fix/issue-daemon-worktree-session-handling into main 2026-04-07 03:41:27 +02:00
shokollm
9d0bcef465 fix: improve worktree/session handling in cmd_start, cmd_continue, and cmd_init
cmd_continue:
- If worktree is missing but session exists, remove stale session and call cmd_start automatically
- This allows automatic recovery without user intervention

cmd_start:
- Check BOTH worktree AND session existence
- If only worktree exists (not session): remove worktree, recreate both
- If only session exists (not worktree): remove session, recreate both
- If both exist: tell user to use continue

cmd_init --force:
- Now destroys ALL sessions, worktrees, and logs for a clean slate
- Destroys base, pm-agent, all forked sessions, all worktrees, all logs

Daemon:
- Fixed wrong path in check_task_completion ($HOME/.kugetsu-worktrees -> $WORKTREES_DIR)
2026-04-07 01:40:20 +00:00
bb2add2e1a Merge pull request 'fix: properly quote base and pm_agent in write_index calls' (#194) from fix/issue-write-index-quoting into main 2026-04-07 02:50:32 +02:00
shokollm
6a8fa563dd fix: properly quote base and pm_agent in write_index calls
When base or pm_agent are not null, they need to be quoted with escaped quotes ("") in write_index calls.
This fixes 'write_index would create malformed JSON' error during init.
2026-04-07 00:48:06 +00:00
8729321922 Merge pull request 'fix: use ${GITEA_TOKEN:-} to handle unset token' (#193) from fix/issue-cmd-destroy-unbound-var into main 2026-04-07 02:41:17 +02:00
shokollm
998f7a4f44 refactor: remove duplicate functions from kugetsu, use kugetsu-index.sh exclusively
- Remove duplicate write_index, get_base_session_id, get_pm_agent_session_id,
  get_session_for_issue, set_base_in_index, set_pm_agent_in_index,
  add_issue_to_index, remove_issue_from_index from kugetsu
- These functions are already defined in kugetsu-index.sh which is sourced earlier
- The kugetsu versions were shadowing the kugetsu-index.sh ones unnecessarily
- This removes code duplication and ensures consistent behavior
2026-04-07 00:39:45 +00:00
shokollm
b595411a07 test: add test suite for create_session function
Tests run sequentially to avoid memory exhaustion with too many opencode sessions:
- JSON session list parsing
- Session ID format validation
- create_session returns valid session ID
- create_session creates NEW session (different from base)
- create_session creates different sessions on multiple calls
- create_session accepts optional base session parameter
- Created session visible in opencode session list
2026-04-07 00:24:03 +00:00
shokollm
08b633400d refactor: add create_session() and use --session instead of --continue
- Add create_session() function that forks from base session using JSON session detection
- cmd_delegate: fork new session from base instead of using pm_agent
- cmd_start: use create_session() instead of broken before/after detection
- cmd_continue: use --session instead of --continue (no need to continue existing session)
- Remove pm_agent check from cmd_start (no longer needed)
2026-04-07 00:13:06 +00:00
shokollm
860bf9295f fix: use ${GITEA_TOKEN:-} to handle unset token and initialize env during init
With set -u, expanding $GITEA_TOKEN fails if not set.
Changed to ${GITEA_TOKEN:-} to provide empty default.

Also initializes $ENV_DIR/default.env during kugetsu init
so users are aware of the env file structure.
2026-04-06 09:45:15 +00:00
cf8b003d2f Merge pull request 'fix: cmd_destroy unbound variable $2' (#192) from fix/issue-cmd-destroy-unbound-var into main 2026-04-06 11:30:04 +02:00
shokollm
fd79bfa3ea fix: cmd_destroy unbound variable $2
With set -u, using $2 without default causes error when called without arguments.
2026-04-06 09:24:47 +00:00
119c9b8fd9 Merge pull request 'fix: daemon worktree path and agent forking issues' (#191) from fix/issue-daemon-worktree-path-fix into main 2026-04-06 11:19:52 +02:00
shokollm
7f7f8b1085 fix: multiple issues with queue daemon and agent forking
1. daemon: use $WORKTREES_DIR instead of $HOME/.kugetsu-worktrees
   - Worktrees are created in ~/.kugetsu/worktrees but daemon was checking ~/.kugetsu-worktrees
   - This caused cmd_start to be called when cmd_continue should have been

2. load_agent_env: add fallback to pm-agent.env
   - When dev.env and default.env don't exist, fallback to pm-agent.env
   - Ensures GITEA_TOKEN is available

3. Remove '&& disown' pattern that causes 'no such job' errors
   - nohup already makes process immune to SIGHUP
   - disown was causing errors because job context was lost
2026-04-06 09:14:37 +00:00
1a523a805a Merge pull request 'fix: syntax error in cmd_continue line 372 (issue #189)' (#190) from fix/issue-syntax-error-372 into main 2026-04-06 10:46:09 +02:00
shokollm
6aa84a35b9 fix: syntax error in cmd_continue line 372 (issue #189)
Wrap nohup command in subshell to fix '&& disown' syntax error
2026-04-06 08:44:29 +00:00
6e2841bbda Merge pull request 'fix: cmd_start and cmd_continue now fork dev agent to work on task (issue #187)' (#188) from fix/issue-187-start-forks-agent into main 2026-04-06 10:13:42 +02:00
shokollm
99d09c7dda fix: cmd_start and cmd_continue now fork dev agent to work on task (issue #187)
- Added build_dev_agent_message() function to generate default workflow prompt
- cmd_start now forks the agent after creating session (fire-and-forget)
- cmd_continue now uses default message when empty and forks agent (fire-and-forget)
- Both commands log output to $LOGS_DIR/dev-$session_id.log
2026-04-06 08:05:42 +00:00
383f538438 Merge pull request 'fix: worktree created in wrong directory (issue #185)' (#186) from fix/issue-185-worktree-wrong-directory into main 2026-04-06 07:33:02 +02:00
shokollm
28b343f817 fix: worktree created in wrong directory (issue #185)
cmd_start was calling create_worktree without passing $WORKTREES_DIR,
causing worktrees to be created in $PWD (daemon's working directory)
instead of ~/.kugetsu-worktrees/
2026-04-06 05:18:31 +00:00
3a2095861f Merge pull request 'fix: destroy --base -y fails with 'target is required' (issue #183)' (#184) from fix/issue-183-destroy-base-requires-target into main 2026-04-06 06:16:30 +02:00
shokollm
836fde07fc fix: destroy --base -y fails with 'target is required' (issue #183)
Removed erroneous code that set target="" when target="--base".
This caused the early exit at 'if [ -z "$target" ]' to trigger
before reaching the actual --base handling at line 517.
2026-04-06 04:14:03 +00:00
4f2a04e0b4 Merge pull request 'fix: get_repo_url() strips user/org from path (issue #181)' (#182) from fix/issue-181-get-repo-url-strips-user-org into main 2026-04-06 06:07:46 +02:00
shokollm
c8bb0b36f4 fix: get_repo_url() strips user/org from path (issue #181)
The sed pattern 's/.*\///' on line 44 was removing everything up to the
LAST slash, but for issue refs like 'git.fbrns.co/shoko/kugetsu#158' it
should remove the instance prefix only (up to the FIRST slash).

Before: git.fbrns.co/shoko/kugetsu#158 -> kugetsu (WRONG)
After:  git.fbrns.co/shoko/kugetsu#158 -> shoko/kugetsu (CORRECT)

Also adds comprehensive test suite for git URL parsing functions:
- get_repo_url(), issue_ref_to_worktree_name(), issue_ref_to_branch_name()
- extract_issue_ref_from_message(), validate_issue_ref()
- issue_ref_to_filename(), filename_to_issue_ref()
2026-04-06 04:00:18 +00:00
937b7c69de Merge pull request 'fix: remove doubled .kugetsu-worktrees path segment in issue_ref_to_worktree_path' (#180) from fix/issue-179-worktree-path-doubled into main 2026-04-06 05:27:16 +02:00
shokollm
2051266809 fix: remove doubled .kugetsu-worktrees path segment in issue_ref_to_worktree_path
Fixes #179 - cmd_start fails due to incorrect worktree path being created
with .kugetsu-worktrees/.kugetsu-worktrees/ instead of just the worktree name.
2026-04-06 03:23:23 +00:00
80a3228be9 Merge pull request 'fix(kugetsu-session): extract_issue_ref_from_message fix URL parsing' (#178) from fix/issue-176-extract-issue-ref into main 2026-04-06 04:54:11 +02:00
shokollm
d68a63af41 fix(kugetsu-session): extract_issue_ref_from_message fix URL parsing
Fix issue where full URLs like #158
were incorrectly parsed to 'shoko/kugetsu/issues#158' instead of
'git.fbrns.co/shoko/kugetsu#158'.

The regex now correctly extracts instance, owner, repo, and number
using bash regex capture groups instead of fragile sed/cut pipeline.
2026-04-06 02:51:51 +00:00
15 changed files with 1605 additions and 413 deletions

1
.gitignore vendored
View File

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

18
.pre-commit-config.yaml Normal file
View File

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

@@ -16,6 +16,38 @@
- 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

61
EXITCODES.md Normal file
View File

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

@@ -364,10 +364,14 @@ kugetsu queue-daemon logs # Show recent daemon logs
**Daemon Behavior:**
1. Runs at configurable interval (default: 5 minutes)
2. Checks if active agents < MAX_CONCURRENT_AGENTS
3. Picks 1-N pending items (configurable batch size)
4. Forks PM session for each picked item
5. PM decides whether to use `start` or `continue`
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:**
```
@@ -526,6 +530,13 @@ 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,6 +2,33 @@ 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
You have TWO response modes. Choose based on the user's request:
### 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
**Action:** Delegate using `kugetsu start` or `kugetsu continue`.
### 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
**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.
@@ -18,75 +45,116 @@ PM has EXPLICIT write boundaries. You can ONLY write to two specific locations.
- Any `.md` files, config files, scripts, or code
### If Asked to Write Outside ~/.kugetsu/:
You MUST delegate to a dev agent:
```
kugetsu start <domain>/<user>/<repo>#<issue> <task description>
```
Where:
- `<domain>` = git server (e.g., `github.com`, `gitlab.com`, `git.fbrns.co`)
- `<user>` = git username (from `git config user.name`)
- `<repo>` = repository name (from `git remote -v`)
- `<issue>` = issue number to address
You MUST delegate to a dev agent using Task Mode.
### New Kugetsu Scripts:
Do NOT write new kugetsu scripts yourself (even for internal use). Delegate to a dev agent via the normal workflow:
1. Create an issue describing the needed script
2. Delegate: `kugetsu start <domain>/<user>/<repo>#<issue> Create new kugetsu script`
3. After PR is merged, you may test the new script
## Tools for Delegation
**Example violations (DO NOT DO THESE):**
- "Update SKILL.md" → DELEGATE, don't edit it yourself
- "Fix the bug in login.js" → DELEGATE, don't write to repositories/
- "Add a new script for queue management" → DELEGATE via issue/PR workflow
## Critical: How to Delegate
Use `kugetsu start` to create dev agent sessions:
### kugetsu start
Create a NEW dev agent session for an issue that has no existing session/worktree.
```
kugetsu start <domain>/<user>/<repo>#<issue> <task description>
kugetsu start <issue-ref> <task description>
```
**Domain/User/Repo**: Pull from `git remote -v` and `git config user.name` to make this agnostic to any git server.
**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)
**NOT `kugetsu delegate`** - that routes back to the PM (you). Use `kugetsu start` to create a NEW dev agent.
**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.
```
kugetsu continue <issue-ref> [additional instructions]
```
**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.
## Your Identity
You are the PM. Your job is to coordinate, not to code.
- You delegate ALL implementation tasks to dev agents using `kugetsu start`
- You delegate ALL implementation tasks to dev agents using Task Mode
- You answer informational queries directly in Conversation Mode
- 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
## Delegation is Your Default Behavior for Tasks
When a request comes in:
1. **Understand** - What needs to be built? What's the repo and issue?
2. **Delegate** - Use `kugetsu start <issue-ref> <task>` to create a dev agent task
3. **Monitor** - Watch for PR creation and review
4. **Report** - Post final results to the issue
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
## 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`
**User:** "Add tests for the API"
**Mode:** Task
**You:** `kugetsu start <domain>/<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`
**User:** "Update the README with installation instructions"
**Mode:** Task
**You:** `kugetsu start <domain>/<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`
Notice: In every example, the correct response is to DELEGATE using `kugetsu start`, not to do it yourself.
**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)
---
## You Are the PM. You Coordinate. You Do Not Write Code.
@@ -94,4 +162,4 @@ This is not just a rule - it is your identity. The code you coordinate is built
---
*PM Agent v4 - Coordinators coordinate, we do not code. Strict write boundary: ONLY ~/.kugetsu/.*
*PM Agent v5 - Coordinators coordinate. Delegation is for tasks, conversation is for questions. Strict write boundary: ONLY ~/.kugetsu/.*

View File

@@ -17,77 +17,129 @@ usage() {
kugetsu - OpenCode Session Manager (Issue-Driven)
Usage:
kugetsu init [--force] Initialize base + pm-agent sessions (requires TTY)
kugetsu start <issue-ref> <message> [--debug] Start task for issue (forks base session)
kugetsu continue <issue-ref> [message] [--debug] Continue existing task for issue
kugetsu delegate <message> Send message to PM agent (fire-and-forget)
kugetsu logs [n] Show recent delegation logs (default: 10)
kugetsu status Check kugetsu initialization status
kugetsu doctor [--fix] Diagnose and fix kugetsu issues
kugetsu notify [list|clear] Show or clear notifications
kugetsu list List all tracked sessions
kugetsu prune [--force] Remove orphaned sessions (keeps base + pm-agent)
kugetsu destroy <issue-ref> [-y] Delete session for issue
kugetsu destroy --pm-agent [-y] Delete pm-agent session (not recommended)
kugetsu destroy --base [-y] Delete base session
kugetsu set-pr <issue-ref> <pr-url> Set PR URL for session (for PR tracking)
kugetsu context <issue-ref> Show context for issue
kugetsu queue [list|stats|clear] Show queue status or statistics
kugetsu queue enqueue <issue-ref> <message> Enqueue a task (normally via delegate)
kugetsu queue-daemon [start|stop|restart|status|logs] Manage queue daemon
kugetsu env [get|set|list] Manage agent environment variables
kugetsu server [list|add|remove|default|get] Manage git server configurations
kugetsu help Show this help
kugetsu <command> [subcommand] [options]
Commands:
init [--force] Initialize base + pm-agent sessions (requires TTY)
start <issue-ref> <message> [--debug] Start task for issue (forks base session)
continue <issue-ref> [message] [--debug] Continue existing task for issue
delegate <message> Send message to PM agent (fire-and-forget)
logs [n] Show recent delegation logs (default: 10)
status Check kugetsu initialization status
doctor [--fix] Diagnose and fix kugetsu issues
notify [list|clear] Show or clear notifications
list List all tracked sessions
prune [--force] Remove orphaned sessions
destroy <target> [-y] Delete session (issue, pm-agent, or base)
set-pr <issue-ref> <pr-url> Set PR URL for session
context <issue-ref> Show context for issue
queue [subcommand] Queue management
queue-daemon [subcommand] Queue daemon management
env [subcommand] Environment variable management
server [subcommand] Git server configuration
help [command] Show help for a command
Use 'kugetsu <command> help' for subcommand help.
Example: kugetsu queue help, kugetsu queue-daemon help
Issue Ref Format:
instance/user/repo#number
Example: github.com/shoko/kugetsu#14
EOF
}
Commands:
init Create base + pm-agent sessions via TUI. Requires terminal access.
Use --force to reinitialize if sessions exist.
start Fork new session from base for specific issue.
Requires pm-agent to be running (created by init).
continue Continue work on existing issue session.
delegate Send message to PM agent for task coordination.
Fire-and-forget: returns immediately, runs in background.
Use 'kugetsu logs' to check output.
logs Show recent delegation logs.
Default: 10 most recent. Use 'kugetsu logs 20' for more.
status Check if kugetsu is initialized and PM agent is active.
doctor Diagnose kugetsu issues. Use --fix to attempt repairs.
notify Show or clear notifications from PM agent.
Use 'kugetsu notify list' to see unread notifications.
list Show all sessions (base + pm-agent + forked issues).
prune Remove sessions not in index (orphaned from opencode).
Use --force to skip confirmation.
destroy Delete specific issue, pm-agent, or base session.
usage_queue() {
cat << 'EOF'
kugetsu queue - Queue management
Options:
--debug Show real-time debug output and capture to debug.log
Usage:
kugetsu queue [subcommand]
PM Context:
kugetsu reads ~/.kugetsu/pm-agent.md (if exists) and injects it
into the PM agent session at init time. This allows customizing PM
behavior without recreating the session.
Subcommands:
list [--limit=N] [--format=json] Show pending tasks (default: 10 items, text format)
stats Show queue statistics
clear Clear all queue items
enqueue <issue-ref> <message> Enqueue a task
help Show this help
Notifications:
PM Agent writes task completion notifications to ~/.kugetsu/notifications.json
Use 'kugetsu notify list' to see unread notifications.
Options for list:
--limit=N Number of items to return (default: 10)
--format=json Output in JSON format with stats
Examples:
kugetsu init
kugetsu status
kugetsu delegate "work on issue #5"
kugetsu logs
kugetsu logs 20
kugetsu doctor
kugetsu doctor --fix
kugetsu notify list
kugetsu notify clear
kugetsu start github.com/shoko/kugetsu#14 "fix bug"
kugetsu continue github.com/shoko/kugetsu#14 "add tests"
kugetsu list
kugetsu queue list
kugetsu queue list --limit=50
kugetsu queue list --format=json
kugetsu queue list --limit=20 --format=json
kugetsu queue stats
kugetsu queue clear
kugetsu queue enqueue github.com/shoko/kugetsu#14 "fix bug"
EOF
}
usage_queue_daemon() {
cat << 'EOF'
kugetsu queue-daemon - Queue daemon management
Usage:
kugetsu queue-daemon [subcommand]
Subcommands:
start Start the queue daemon
stop Stop the queue daemon
restart Restart the queue daemon
status Check daemon status
logs Show recent daemon logs
help Show this help
Examples:
kugetsu queue-daemon start
kugetsu queue-daemon status
kugetsu queue-daemon logs
EOF
}
usage_env() {
cat << 'EOF'
kugetsu env - Environment variable management
Usage:
kugetsu env [subcommand]
Subcommands:
list List all environment variables
get <key> Get a specific variable
set <key> <value> Set a variable
rm <key> Remove a variable
help Show this help
Examples:
kugetsu env list
kugetsu env get GITEA_TOKEN
kugetsu env set CUSTOM_VAR "value"
kugetsu env rm CUSTOM_VAR
EOF
}
usage_server() {
cat << 'EOF'
kugetsu server - Git server configuration
Usage:
kugetsu server [subcommand]
Subcommands:
list List all configured servers (default)
add <name> <url> Add a new server
remove <name> Remove a server
default [<name>] Get or set default server
get [<name>] Get server URL
help Show this help
Examples:
kugetsu server list
kugetsu server add github.com https://github.com
kugetsu server default github.com
EOF
}
@@ -310,19 +362,25 @@ PYEOF
get_pending_tasks() {
local limit="${1:-10}"
local format="${2:-text}"
if [ ! -d "$QUEUE_ITEMS_DIR" ]; then
echo "[]"
if [ "$format" = "json" ]; then
echo '{"items": [], "total": 0, "pending": 0}'
else
echo "No pending tasks in queue."
fi
return
fi
python3 -c "
python3 << PYEOF
import json
import os
import sys
queue_dir = os.environ.get('QUEUE_ITEMS_DIR', '')
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 10
format_type = sys.argv[2] if len(sys.argv) > 2 else 'text'
items = []
if os.path.isdir(queue_dir):
@@ -334,13 +392,27 @@ if os.path.isdir(queue_dir):
data = json.load(f)
if data.get('state') == 'pending':
items.append(data)
if len(items) >= limit:
break
except:
pass
print(json.dumps(items))
" "$limit"
items.sort(key=lambda x: x.get('pending_since', ''))
if format_type == 'json':
output = {
"items": items[:limit],
"total": len(items),
"pending": len(items)
}
print(json.dumps(output))
else:
if not items:
print("No pending tasks in queue.")
else:
print("Pending tasks:")
for t in items[:limit]:
msg = t.get('message', '')[:50]
print(f" {t.get('id')}: {t.get('issue_ref')} - {msg}...")
PYEOF
}
get_queue_stats() {
@@ -475,97 +547,6 @@ read_index() {
fi
}
write_index() {
local base="$1"
local pm_agent="$2"
local issues_json="$3"
local temp_file="$INDEX_FILE.tmp.$$"
printf '{"base": %s, "pm_agent": %s, "issues": %s}\n' "$base" "$pm_agent" "$issues_json" > "$temp_file"
mv "$temp_file" "$INDEX_FILE"
}
get_base_session_id() {
local index=$(read_index)
echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or '')"
}
get_pm_agent_session_id() {
local index=$(read_index)
echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') or '')"
}
get_session_for_issue() {
local issue_ref="$1"
local index=$(read_index)
echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['issues'].get('$issue_ref') or '')"
}
set_base_in_index() {
local base_session_id="$1"
local pm_agent=$(get_pm_agent_session_id)
local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))")
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "\"$base_session_id\"" "null" "$issues_json"
else
write_index "\"$base_session_id\"" "\"$pm_agent\"" "$issues_json"
fi
}
set_pm_agent_in_index() {
local pm_agent_session_id="$1"
local base=$(get_base_session_id)
local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))")
if [ -z "$base" ] || [ "$base" = "null" ]; then
write_index "null" "\"$pm_agent_session_id\"" "$issues_json"
else
write_index "\"$base\"" "\"$pm_agent_session_id\"" "$issues_json"
fi
}
add_issue_to_index() {
local issue_ref="$1"
local session_file="$2"
local index=$(read_index)
local base=$(get_base_session_id)
local pm_agent=$(get_pm_agent_session_id)
local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))")
local new_issues=$(echo "$issues" | python3 -c "import sys, json; d=json.load(sys.stdin); d['$issue_ref']='$session_file'; print(json.dumps(d))")
if [ -z "$base" ] || [ "$base" = "null" ]; then
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$new_issues"
else
write_index "null" "\"$pm_agent\"" "$new_issues"
fi
else
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "\"$base\"" "null" "$new_issues"
else
write_index "\"$base\"" "\"$pm_agent\"" "$new_issues"
fi
fi
}
remove_issue_from_index() {
local issue_ref="$1"
local index=$(read_index)
local base=$(get_base_session_id)
local pm_agent=$(get_pm_agent_session_id)
local new_issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); d['issues'].pop('$issue_ref', None); print(json.dumps(d['issues']))")
if [ -z "$base" ] || [ "$base" = "null" ]; then
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$new_issues"
else
write_index "null" "\"$pm_agent\"" "$new_issues"
fi
else
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "\"$base\"" "null" "$new_issues"
else
write_index "\"$base\"" "\"$pm_agent\"" "$new_issues"
fi
fi
}
validate_issue_ref() {
local issue_ref="$1"
if [[ ! "$issue_ref" =~ ^[^/]+/[^/]+/[^#]+#[0-9]+$ ]]; then
@@ -956,18 +937,34 @@ find_sessions_by_issue_number() {
}
cmd_queue() {
local action="${1:-list}"
shift
local action="${1:-}"
case "$action" in
""|help|--help|-h)
usage_queue
;;
help|--help|-h)
usage_queue
;;
list)
local pending_tasks=$(get_pending_tasks 10)
if [ "$pending_tasks" = "[]" ]; then
echo "No pending tasks in queue."
else
echo "Pending tasks:"
echo "$pending_tasks" | python3 -c "import sys, json; [print(f\" {t.get('id')}: {t.get('issue_ref')} - {t.get('message', '')[:50]}...\") for t in json.load(sys.stdin)]"
fi
local limit="10"
local format="text"
while [ $# -gt 0 ]; do
case "$1" in
--limit=*)
limit="${1#*=}"
;;
--format=json)
format="json"
;;
*)
;;
esac
shift
done
get_pending_tasks "$limit" "$format"
;;
stats)
local stats=$(get_queue_stats)
@@ -989,8 +986,8 @@ cmd_queue() {
echo "Queue cleared."
;;
enqueue)
local issue_ref="${1:-}"
local message="${2:-}"
local issue_ref="${2:-}"
local message="${3:-}"
if [ -z "$issue_ref" ] || [ -z "$message" ]; then
echo "Usage: kugetsu queue enqueue <issue-ref> <message>" >&2
exit 1
@@ -1001,16 +998,20 @@ cmd_queue() {
check_task_timeouts
;;
*)
echo "Usage: kugetsu queue [list|stats|clear|enqueue]" >&2
echo "Unknown queue subcommand: $action" >&2
usage_queue
exit 1
;;
esac
}
cmd_queue_daemon() {
local action="${1:-status}"
local action="${1:-}"
case "$action" in
""|help|--help|-h)
usage_queue_daemon
;;
start)
if [ -f "$QUEUE_DAEMON_PID_FILE" ]; then
local old_pid=$(cat "$QUEUE_DAEMON_PID_FILE")
@@ -1069,7 +1070,8 @@ cmd_queue_daemon() {
fi
;;
*)
echo "Usage: kugetsu queue-daemon [start|stop|restart|status|logs]" >&2
echo "Unknown queue-daemon subcommand: $action" >&2
usage_queue_daemon
exit 1
;;
esac
@@ -1150,10 +1152,12 @@ set_debug_mode() {
}
cmd_env() {
local action="${1:-list}"
shift
local action="${1:-}"
case "$action" in
""|help|--help|-h)
usage_env
;;
list)
echo "Agent environment variables:"
if [ -d "$ENV_DIR" ]; then
@@ -1171,7 +1175,7 @@ cmd_env() {
fi
;;
get)
local key="${1:-}"
local key="${2:-}"
if [ -z "$key" ]; then
echo "Usage: kugetsu env get <key>" >&2
exit 1
@@ -1186,8 +1190,8 @@ cmd_env() {
fi
;;
set)
local key="${1:-}"
local value="${2:-}"
local key="${2:-}"
local value="${3:-}"
if [ -z "$key" ] || [ -z "$value" ]; then
echo "Usage: kugetsu env set <key> <value>" >&2
exit 1
@@ -1196,8 +1200,8 @@ cmd_env() {
echo "${key}=${value}" >> "$ENV_DIR/default.env"
echo "Set $key in $ENV_DIR/default.env"
;;
rm)
local key="${1:-}"
rm|remove)
local key="${2:-}"
if [ -z "$key" ]; then
echo "Usage: kugetsu env rm <key>" >&2
exit 1
@@ -1208,7 +1212,8 @@ cmd_env() {
fi
;;
*)
echo "Usage: kugetsu env [list|get|set|rm]" >&2
echo "Unknown env subcommand: $action" >&2
usage_env
exit 1
;;
esac
@@ -1218,7 +1223,10 @@ cmd_server() {
local action="${1:-}"
case "$action" in
""|"list")
""|help|--help|-h)
usage_server
;;
"list")
if [ -z "${GIT_SERVERS+x}" ]; then
echo "No git servers configured"
return
@@ -1295,14 +1303,8 @@ cmd_server() {
fi
;;
*)
echo "Usage: kugetsu server <list|add|remove|default|get>" >&2
echo "" >&2
echo "Commands:" >&2
echo " list List all configured git servers" >&2
echo " add <name> <url> Add a new git server" >&2
echo " remove <name> Remove a git server" >&2
echo " default [<name>] Get or set default server" >&2
echo " get [<name>] Get URL for a server (default: current default)" >&2
echo "Unknown server subcommand: $action" >&2
usage_server
exit 1
;;
esac
@@ -1412,7 +1414,29 @@ main() {
case "$command" in
help|--help|-h)
usage
local subcommand="${1:-}"
case "$subcommand" in
queue|"")
usage_queue
;;
queue-daemon)
usage_queue_daemon
;;
env)
usage_env
;;
server)
usage_server
;;
"")
usage
;;
*)
echo "Help not available for '$subcommand'" >&2
usage
exit 1
;;
esac
;;
init)
cmd_init "$@"

View File

@@ -26,13 +26,17 @@ 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"
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***/")
@@ -41,6 +45,11 @@ mask_sensitive_vars() {
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"
@@ -53,5 +62,53 @@ load_agent_env() {
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

@@ -15,6 +15,13 @@ write_index() {
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"
}
@@ -43,7 +50,11 @@ set_base_in_index() {
if [ "$session_id" = "null" ]; then
write_index "null" "$pm_agent" "$issues"
else
write_index "\"$session_id\"" "$pm_agent" "$issues"
if [ "$pm_agent" = "null" ]; then
write_index "\"$session_id\"" "null" "$issues"
else
write_index "\"$session_id\"" "\"$pm_agent\"" "$issues"
fi
fi
}
@@ -56,7 +67,11 @@ set_pm_agent_in_index() {
if [ "$session_id" = "null" ]; then
write_index "$base" "null" "$issues"
else
write_index "$base" "\"$session_id\"" "$issues"
if [ "$base" = "null" ]; then
write_index "null" "\"$session_id\"" "$issues"
else
write_index "\"$base\"" "\"$session_id\"" "$issues"
fi
fi
}
@@ -72,7 +87,19 @@ add_issue_to_index() {
issues=$(python3 -c "import sys, json; d=json.load(sys.stdin); d['$issue_ref']='$session_file'; print(json.dumps(d))" <<< "$issues")
write_index "$base" "$pm_agent" "$issues"
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() {
@@ -86,7 +113,19 @@ remove_issue_from_index() {
issues=$(python3 -c "import sys, json; d=json.load(sys.stdin); d.pop('$issue_ref', None); print(json.dumps(d))" <<< "$issues")
write_index "$base" "$pm_agent" "$issues"
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() {
@@ -100,6 +139,77 @@ validate_issue_ref() {
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"

View File

@@ -1,6 +1,40 @@
#!/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}"
@@ -11,20 +45,61 @@ cmd_logs() {
find "$LOGS_DIR" -type f -mtime +7 -delete 2>/dev/null
ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | while read line; do
echo "$line"
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 "Recent log contents:"
echo "===================="
echo "Log Contents:"
echo "================================================================"
for log in $(ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | awk '{print $NF}'); do
for log in "${log_files[@]}"; do
if [ -f "$LOGS_DIR/$log" ]; then
echo ""
echo "--- $log ---"
echo ">>> $log <<<"
echo "----------------------------------------------------------------"
tail -20 "$LOGS_DIR/$log" | while read line; do
echo " $(mask_sensitive_vars "$line")"
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

View File

@@ -34,6 +34,8 @@ release_lock() {
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
@@ -41,14 +43,39 @@ check_task_completion() {
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" "$HOME/.kugetsu-worktrees")
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 origin/main..HEAD 2>/dev/null)" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true
fi
fi
@@ -64,11 +91,11 @@ check_task_completion() {
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" "$HOME/.kugetsu-worktrees")
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 origin/main..HEAD 2>/dev/null)" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true
fi
fi
@@ -109,28 +136,15 @@ process_task() {
source "$SCRIPT_DIR/kugetsu-session.sh"
if worktree_exists "$issue_ref" "$HOME/.kugetsu-worktrees" || [ -f "$SESSIONS_DIR/$(issue_ref_to_filename "$issue_ref").json" ]; then
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
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
log_file="$LOGS_DIR/delegate-$(date +%s).log"
if cmd_start "$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 started for $issue_ref"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) failed to start"
fi
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) failed to continue"
fi
release_lock "$issue_ref"

View File

@@ -13,7 +13,8 @@ count_active_dev_sessions() {
if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local filename=$(basename "$session_file")
local filename
filename=$(basename "$session_file")
if [ "$filename" != "base.json" ] && [ "$filename" != "pm-agent.json" ]; then
count=$((count + 1))
fi
@@ -58,12 +59,35 @@ 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
@@ -156,13 +180,11 @@ extract_issue_ref_from_message() {
return
fi
if [[ "$message" =~ (https?://[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/(issues|pull)/[0-9]+) ]]; then
local url="${BASH_REMATCH[1]}"
local path=$(echo "$url" | sed 's|https\?://||' | cut -d'/' -f2-)
local instance=$(echo "$path" | cut -d'/' -f1)
local owner=$(echo "$path" | cut -d'/' -f2)
local repo=$(echo "$path" | cut -d'/' -f3)
local num=$(echo "$path" | grep -oE '[0-9]+$')
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
@@ -187,82 +209,242 @@ cmd_delegate() {
return
fi
# No issue ref detected — delegate directly to PM agent (legacy path)
local pm_session=$(get_pm_agent_session_id)
if [ -z "$pm_session" ] || [ "$pm_session" = "null" ] || [ "$pm_session" = "None" ]; then
echo "Error: PM agent session not found. Run 'kugetsu init' first." >&2
# 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"
nohup sh -c "GITEA_TOKEN='***' opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 &
disown
echo "Delegated to PM agent (logged to $(basename "$log_file"))"
}
load_agent_env "pm-agent"
cmd_start() {
local issue_ref="${1:-}"
local message="${2:-}"
if [ -z "$issue_ref" ]; then
echo "Error: issue ref is required" >&2
echo "Usage: kugetsu start <issue-ref> [message]" >&2
local new_session=$(create_session "$base_session")
if [ -z "$new_session" ]; then
echo "Error: Failed to create session" >&2
exit 1
fi
validate_issue_ref "$issue_ref"
local msg_file="$LOGS_DIR/msg-$new_session.txt"
printf '%s' "$message" > "$msg_file"
sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$new_session'" 2>&1 | tee "$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
echo "Error: Base session not found. Run 'kugetsu init' first." >&2
exit 1
fi
local pm_agent_session_id=$(get_pm_agent_session_id)
if [ -z "$pm_agent_session_id" ] || [ "$pm_agent_session_id" = "null" ]; then
echo "Error: PM agent session not found. Run 'kugetsu init' first." >&2
exit 1
fi
if worktree_exists "$issue_ref"; then
echo "Issue '$issue_ref' already has a worktree. Use 'kugetsu continue' instead."
exit 1
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
echo "Error: Max concurrent agents (${MAX_CONCURRENT_AGENTS:-3}) reached. Use 'kugetsu continue' or wait for an agent to finish." >&2
exit 1
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"
if [ -f "$session_path" ]; then
echo "Session file already exists: $session_file"
echo "Use 'kugetsu continue $issue_ref' to continue work."
exit 1
local worktree_exists=false
if worktree_exists "$issue_ref" "$WORKTREES_DIR"; then
worktree_exists=true
fi
local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local before_set="|$before_sessions|"
local session_exists=false
if [ -f "$session_path" ]; then
session_exists=true
fi
create_worktree "$issue_ref"
if [ "$worktree_exists" = true ] && [ "$session_exists" = true ]; then
log "info" "ensure_session" "Session already exists for $issue_ref"
echo "continued"
return 0
fi
local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local new_session_id=""
while IFS= read -r sess; do
if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$base_session_id" ]] && [[ "$sess" != "$pm_agent_session_id" ]]; then
new_session_id="$sess"
break
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
done <<< "$after_sessions"
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
echo "Error: Could not find newly created session" >&2
remove_worktree_for_issue "$issue_ref"
exit 1
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")
@@ -272,65 +454,89 @@ cmd_start() {
add_issue_to_index "$issue_ref" "$session_file"
echo "Session started for '$issue_ref': $new_session_id"
echo "Worktree: $worktree_path"
log "info" "ensure_session" "Created session for $issue_ref: $new_session_id"
echo "created"
return 0
}
cmd_continue() {
local session_name=""
local message=""
local args=("$@")
fork_agent() {
local session_id="$1"
local worktree_path="$2"
local message="$3"
args=$(set_debug_mode "${args[@]}")
for arg in $args; do
if [ -z "$session_name" ]; then
session_name="$arg"
else
message="$arg"
fi
done
if [ -z "$session_name" ]; then
echo "Error: issue ref is required" >&2
echo "Usage: kugetsu continue <issue-ref> [message]" >&2
exit 1
fi
validate_issue_ref "$session_name"
local session_file=$(get_session_for_issue "$session_name")
if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then
echo "Error: No session found for '$session_name'" >&2
echo "Use 'kugetsu start $session_name' to create a new session." >&2
exit 1
fi
local session_path="$SESSIONS_DIR/$session_file"
if [ ! -f "$session_path" ]; then
echo "Error: Session file not found: $session_path" >&2
exit 1
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 "")
if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
if [ -n "$message" ]; then
(cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" "$@")
else
(cd "$worktree_path" && opencode --continue --session "$opencode_session_id" "$@")
fi
else
if [ -n "$message" ]; then
opencode run "$message" --continue --session "$opencode_session_id" "$@"
else
opencode --continue --session "$opencode_session_id" "$@"
fi
fi
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() {
@@ -352,53 +558,69 @@ cmd_list() {
fi
echo ""
echo "Issue sessions:"
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 "")
local worktree_status=""
if [ -n "$worktree_path" ]; then
if [ -d "$worktree_path" ]; then
worktree_status="(worktree exists)"
else
worktree_status="(worktree MISSING)"
fi
fi
echo " $filename"
echo " Issue: $issue_ref"
echo " Session: $opencode_sid"
echo " State: $state"
echo " $worktree_status"
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
echo ""
echo "Worktrees without sessions:"
for worktree in $(find "$WORKTREES_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null); do
local worktree_name=$(basename "$worktree")
local has_session=false
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local wt_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "")
if [ "$wt_path" = "$worktree" ]; then
has_session=true
break
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
fi
done
if [ "$has_session" = false ]; then
echo " $worktree_name (no session)"
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
}
@@ -481,11 +703,7 @@ cmd_destroy() {
local target="${1:-}"
local force=false
if [ "$target" = "--base" ]; then
target=""
fi
if [ "$2" = "-y" ]; then
if [ "${2:-}" = "-y" ]; then
force=true
fi

View File

@@ -10,7 +10,7 @@ issue_ref_to_worktree_path() {
local issue_ref="$1"
local parent_dir="${2:-$WORKTREES_DIR}"
local worktree_name=$(issue_ref_to_worktree_name "$issue_ref")
echo "$parent_dir/.kugetsu-worktrees/$worktree_name"
echo "$parent_dir/$worktree_name"
}
issue_ref_to_branch_name() {
@@ -41,7 +41,7 @@ get_repo_url() {
fi
local instance=$(echo "$issue_ref" | cut -d'/' -f1 | cut -d'#' -f1)
local rest=$(echo "$issue_ref" | sed 's/.*\///' | sed 's/#.*//')
local rest=$(echo "$issue_ref" | sed 's/^[^\/]*\///' | sed 's/#.*//')
if [ -n "${GIT_SERVERS[$instance]:-}" ]; then
echo "${GIT_SERVERS[$instance]}/${rest}.git"
@@ -76,7 +76,8 @@ create_worktree() {
exit 1
fi
local worktree_parent_dir=$(dirname "$worktree_path")
local worktree_parent_dir
worktree_parent_dir=$(dirname "$worktree_path")
mkdir -p "$worktree_parent_dir"
if worktree_exists "$issue_ref" "$parent_dir"; then
@@ -85,15 +86,36 @@ create_worktree() {
fi
echo "Creating worktree at '$worktree_path'..."
git clone "$repo_url" "$worktree_path" 2>/dev/null || {
echo "Error: Failed to clone repository" >&2
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'..."
(cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || {
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"
}
@@ -145,15 +167,17 @@ check_pr_status() {
token="${GITEA_TOKEN:-}"
fi
local response
local response_file="$KUGETSU_DIR/.pr_status_response_$$.json"
if [ -n "$token" ]; then
response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null || echo "{}")
curl -s -H "Authorization: token $token" "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
else
response=$(curl -s "$api_url" 2>/dev/null || echo "{}")
curl -s "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
fi
local state=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print(d.get('state', 'unknown'))" 2>/dev/null || echo "unknown")
local merged=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print('true' if d.get('merged', False) else 'false')" 2>/dev/null || echo "false")
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"

View File

@@ -0,0 +1,181 @@
#!/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

@@ -0,0 +1,298 @@
#!/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