feat(kugetsu): auto-create pm-agent session during init

- kugetsu init now creates both base and pm-agent sessions
- kugetsu start checks for pm-agent existence, errors if missing
- Add kugetsu destroy --pm-agent command
- Update list to show pm-agent session
- Update prune to preserve pm-agent.json
- Update SKILL.md documentation to v2.1

Part of issue #19 Phase 3 implementation
This commit is contained in:
shokollm
2026-03-30 13:10:47 +00:00
parent 202d8ccfbb
commit 12ad4eb3b7
2 changed files with 228 additions and 28 deletions

View File

@@ -5,7 +5,7 @@ license: MIT
compatibility: Requires opencode CLI, bash, python3, and filesystem access.
metadata:
author: shoko
version: "2.0"
version: "2.1"
---
# kugetsu - OpenCode Session Manager (Issue-Driven)
@@ -30,7 +30,8 @@ chmod +x ~/.local/bin/kugetsu
## Architecture
### Session Pattern
- **Base Session**: Created once via TUI, used for forking
- **Base Session**: Created once via TUI, used for forking dev agents
- **PM Agent Session**: Created during init, persistent coordinator for task management
- **Forked Sessions**: One per issue, branched from base via `opencode run --fork --session <base>`
### Directory Structure
@@ -38,14 +39,16 @@ chmod +x ~/.local/bin/kugetsu
~/.kugetsu/
├── sessions/
│ ├── base.json # Base session metadata
│ ├── pm-agent.json # PM agent session metadata
│ └── github.com-shoko-kugetsu-14.json # Forked session per issue
└── index.json # Maps issue refs to session files
└── index.json # Maps session IDs and issue refs to session files
```
### Index File
```json
{
"base": "ses_abc123",
"pm_agent": "ses_pm_xyz789",
"issues": {
"github.com/shoko/kugetsu#14": "github.com-shoko-kugetsu-14.json"
}
@@ -55,8 +58,7 @@ chmod +x ~/.local/bin/kugetsu
### Session File
```json
{
"type": "base|forked",
"issue_ref": "github.com/shoko/kugetsu#14",
"type": "base|forked|pm_agent",
"opencode_session_id": "ses_xyz789",
"created_at": "2026-03-29T18:16:10+02:00",
"state": "idle"
@@ -76,11 +78,90 @@ Examples:
### kugetsu init [--force]
Initialize base session via TUI:
Initialize base + PM agent sessions via TUI:
```bash
kugetsu init
```
- Requires a terminal (TTY) to spawn the opencode TUI
- Creates base session and PM agent session
- Stores both session IDs in `index.json`
- Subsequent runs error unless `--force` is used
### kugetsu start `<issue-ref>` `<message>` [--debug]
Start task for an issue by forking from base session:
```bash
kugetsu start github.com/shoko/kugetsu#14 "fix authentication bug"
```
- Forks new session from base
- Requires PM agent to exist (created by init)
- Stores mapping in `index.json`
- Uses `opencode run --fork --session <base-session-id> "<message>"`
### kugetsu continue `<issue-ref>` `<message>` [--debug]
Continue work on an existing issue session:
```bash
kugetsu continue github.com/shoko/kugetsu#14 "add unit tests"
```
- Looks up session file from index
- Uses `opencode run --continue --session <opencode-session-id> "<message>"`
### kugetsu list
List all tracked sessions:
```bash
kugetsu list
```
Output:
```
ISSUE_REF TYPE SESSION_ID CREATED
─────────────────────────────────────────────────────────────────────────────────────────────────
(base) base ses_abc123 N/A
(pm-agent) pm_agent ses_pm_xyz789 2026-03-29T18:16:10+02:00
github.com/shoko/kugetsu#14 forked ses_xyz789 2026-03-29T18:16:10+02:00
```
### kugetsu prune [--force]
Remove orphaned sessions (files not in index):
```bash
kugetsu prune # Shows what would be deleted
kugetsu prune --force # Deletes orphaned sessions
```
- Orphaned = session files in `sessions/` but not in `index.json`
- Always keeps `base.json` and `pm-agent.json`
- Useful after opencode session cleanup
### kugetsu destroy `<issue-ref>` [-y]
Delete session for specific issue:
```bash
kugetsu destroy github.com/shoko/kugetsu#14 # Prompts for confirmation
kugetsu destroy github.com/shoko/kugetsu#14 -y # Skips confirmation
```
### kugetsu destroy --pm-agent [-y]
Delete PM agent session (requires explicit `--pm-agent`):
```bash
kugetsu destroy --pm-agent -y
```
### kugetsu destroy --base [-y]
Delete base session (requires explicit `--base`):
```bash
kugetsu destroy --base -y
```
**Note**: Destroying base also destroys PM agent since PM depends on base.
- Requires a terminal (TTY) to spawn the opencode TUI
- Creates base session once; subsequent runs error unless `--force` is used
- Stores base session ID in `index.json`
@@ -153,6 +234,7 @@ kugetsu destroy --base -y
```bash
# First-time setup (requires TTY)
kugetsu init
# Creates: base session + pm-agent session
# Start work on issue
kugetsu start github.com/shoko/kugetsu#14 "implement feature X"
@@ -182,6 +264,7 @@ This design solves the headless CLI limitation discovered in Issue #14:
The pattern:
- Base session created once via TUI (interactive)
- PM agent session created during init (persistent coordinator)
- All subsequent work uses `--fork --session <base>` or `--continue --session <forked>`
## Recovery

View File

@@ -10,12 +10,13 @@ usage() {
kugetsu - OpenCode Session Manager (Issue-Driven)
Usage:
kugetsu init [--force] Initialize base session (requires TTY)
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 list List all tracked sessions
kugetsu prune [--force] Remove orphaned sessions (keeps base)
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
kugetsu destroy --base [-y] Delete base session
kugetsu help Show this help
@@ -24,14 +25,15 @@ Issue Ref Format:
Example: github.com/shoko/kugetsu#14
Commands:
init Create base session via TUI. Requires terminal access.
Use --force to reinitialize if base session exists.
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.
list Show all sessions (base + forked issues).
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 session or base session.
destroy Delete specific issue, pm-agent, or base session.
Options:
--debug Show real-time debug output and capture to debug.log
@@ -66,15 +68,16 @@ read_index() {
if [ -f "$INDEX_FILE" ]; then
cat "$INDEX_FILE"
else
echo '{"base": null, "issues": {}}'
echo '{"base": null, "pm_agent": null, "issues": {}}'
fi
}
write_index() {
local base="$1"
local issues_json="$2"
local pm_agent="$2"
local issues_json="$3"
local temp_file="$INDEX_FILE.tmp.$$"
printf '{"base": %s, "issues": %s}\n' "$base" "$issues_json" > "$temp_file"
printf '{"base": %s, "pm_agent": %s, "issues": %s}\n' "$base" "$pm_agent" "$issues_json" > "$temp_file"
mv "$temp_file" "$INDEX_FILE"
}
@@ -83,6 +86,11 @@ get_base_session_id() {
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)
@@ -91,8 +99,24 @@ get_session_for_issue() {
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']))")
write_index "\"$base_session_id\"" "$issues_json"
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() {
@@ -100,12 +124,21 @@ add_issue_to_index() {
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 [ "$base" = "null" ] || [ -z "$base" ]; then
write_index "null" "$new_issues"
if [ -z "$base" ] || [ "$base" = "null" ]; then
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$new_issues"
else
write_index "\"$base\"" "$new_issues"
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
}
@@ -113,11 +146,20 @@ 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 [ "$base" = "null" ] || [ -z "$base" ]; then
write_index "null" "$new_issues"
if [ -z "$base" ] || [ "$base" = "null" ]; then
if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then
write_index "null" "null" "$new_issues"
else
write_index "\"$base\"" "$new_issues"
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
}
@@ -172,9 +214,11 @@ cmd_init() {
ensure_dirs
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 base session (force mode)" >&2
echo "Warning: Reinitializing sessions (force mode)" >&2
else
echo "Error: Base session already exists: $existing_base" >&2
echo "Use --force to reinitialize" >&2
@@ -207,8 +251,40 @@ cmd_init() {
"$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file"
set_base_in_index "$new_session_id"
echo "Base session initialized: $new_session_id"
echo ""
echo "Creating PM agent session..."
sleep 1
local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local before_set="${before_sessions//$'\n'/|}"
opencode run --fork --session "$new_session_id" "You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. Wait for instructions." 2>&1 || true
local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local new_pm_session_id=""
while IFS= read -r sess; do
if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$new_session_id" ]]; then
new_pm_session_id="$sess"
break
fi
done <<< "$after_sessions"
if [ -z "$new_pm_session_id" ]; then
echo "Warning: Could not detect PM agent session ID. It may still have been created." >&2
else
local pm_session_file="pm-agent.json"
printf '{"type": "pm_agent", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \
"$new_pm_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$pm_session_file"
set_pm_agent_in_index "$new_pm_session_id"
echo "PM agent session initialized: $new_pm_session_id"
fi
echo ""
echo "Initialization complete!"
echo "- Base session: $new_session_id"
echo "- PM agent: ${new_pm_session_id:-created by hermes}"
}
cmd_start() {
@@ -240,6 +316,12 @@ cmd_start() {
exit 1
fi
local pm_agent_session_id=$(get_pm_agent_session_id)
if [ -z "$pm_agent_session_id" ] || [ "$pm_agent_session_id" = "null" ]; then
echo "Error: No PM agent session. Run 'kugetsu init' first to create it." >&2
exit 1
fi
local existing_session=$(get_session_for_issue "$issue_ref")
if [ -n "$existing_session" ] && [ "$existing_session" != "null" ]; then
echo "Error: Session for '$issue_ref' already exists" >&2
@@ -348,13 +430,22 @@ cmd_list() {
printf "%-50s %-10s %-25s %s\n" "(base)" "base" "$base_session_id" "N/A"
fi
local pm_agent_session_id=$(get_pm_agent_session_id)
if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ]; then
local pm_created="N/A"
if [ -f "$SESSIONS_DIR/pm-agent.json" ]; then
pm_created=$(python3 -c "import json; print(json.load(open('$SESSIONS_DIR/pm-agent.json'))['created_at'])" 2>/dev/null || echo "N/A")
fi
printf "%-50s %-10s %-25s %s\n" "(pm-agent)" "pm_agent" "$pm_agent_session_id" "$pm_created"
fi
local index=$(read_index)
local issue_refs=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print('\n'.join(d['issues'].keys()))" 2>/dev/null || true)
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local filename=$(basename "$session_file" .json)
if [ "$filename" = "base" ]; then
if [ "$filename" = "base" ] || [ "$filename" = "pm-agent" ]; then
continue
fi
@@ -382,7 +473,7 @@ cmd_prune() {
ensure_dirs
local index=$(read_index)
local index_session_files=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); sessions=set(d['issues'].values()); sessions.add('base.json'); print('\n'.join(sessions))" 2>/dev/null || echo "base.json")
local index_session_files=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); sessions=set(d['issues'].values()); sessions.add('base.json'); sessions.add('pm-agent.json'); print('\n'.join(sessions))" 2>/dev/null || echo -e "base.json\npm-agent.json")
local orphaned=()
for session_file in "$SESSIONS_DIR"/*.json; do
@@ -424,6 +515,9 @@ cmd_destroy() {
--base)
target="base"
;;
--pm-agent)
target="pm-agent"
;;
-y|--yes)
force=true
;;
@@ -437,14 +531,20 @@ cmd_destroy() {
done
if [ -z "$target" ]; then
echo "Error: destroy requires <issue-ref> or --base" >&2
echo "Error: destroy requires <issue-ref>, --base, or --pm-agent" >&2
exit 1
fi
if [ "$target" = "base" ]; then
if [ "$force" = true ]; then
rm -f "$SESSIONS_DIR/base.json"
echo '{"base": null, "issues": {}}' > "$INDEX_FILE"
local pm_agent=$(get_pm_agent_session_id)
if [ -n "$pm_agent" ] && [ "$pm_agent" != "null" ]; then
rm -f "$SESSIONS_DIR/pm-agent.json"
echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE"
else
echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE"
fi
echo "Base session destroyed"
else
echo "Error: destroying base session requires --base -y" >&2
@@ -453,6 +553,23 @@ cmd_destroy() {
return
fi
if [ "$target" = "pm-agent" ]; then
if [ "$force" = true ]; then
rm -f "$SESSIONS_DIR/pm-agent.json"
local base=$(get_base_session_id)
if [ -n "$base" ] && [ "$base" != "null" ]; then
write_index "\"$base\"" "null" "{}"
else
write_index "null" "null" "{}"
fi
echo "PM agent session destroyed"
else
echo "Error: destroying pm-agent session requires --pm-agent -y" >&2
exit 1
fi
return
fi
validate_issue_ref "$target"
local session_file=$(get_session_for_issue "$target")