
Project Session Manager
- 545 installs
- 38.3k repo stars
- Updated August 4, 2026
- yeachan-heo/oh-my-claudecode
Project Session Manager is a Claude Code skill that creates isolated git worktrees and optional tmux sessions so developers who need parallel issue, PR, and feature branches can switch contexts without manual setup.
About
Project Session Manager (PSM) is a worktree-first dev environment skill from oh-my-claudecode that exposes the `omc teleport` command for spinning up isolated git worktrees tied to issues, PRs, or feature names. Developers list active worktrees with `omc teleport list` and can layer tmux session orchestration on top when they want terminal multiplexing alongside branch isolation. The skill aliases to `psm` and targets Claude Code users who routinely juggle multiple branches, code reviews, and agent sessions in one repository. Reach for Project Session Manager when you need a repeatable, agent-driven workflow for parallel work instead of hand-rolling worktree and tmux scripts each time.
- Creates isolated git worktrees for every issue, PR or feature branch
- Orchestrates optional tmux sessions for clean parallel development
- 8 core commands including review, fix, feature, list, attach, kill, cleanup and teleport
- Compatible alias `psm` plus canonical slash command for Claude Code
- Automates worktree-first workflow before any coding begins
Project Session Manager by the numbers
- 545 all-time installs (skills.sh)
- +9 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,683 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yeachan-heo/oh-my-claudecode --skill project-session-managerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 545 |
|---|---|
| repo stars | ★ 38.3k |
| Last updated | August 4, 2026 |
| Repository | yeachan-heo/oh-my-claudecode ↗ |
How do you isolate git worktrees for parallel agent sessions?
Instantly create isolated git worktrees and tmux sessions for parallel work on issues, PRs, and features inside Claude Code.
Who is it for?
Developers using Claude Code who regularly work multiple issues, PRs, or features in parallel within one repository.
Skip if: Teams that do not use git worktrees, tmux, or oh-my-claudecode and only need a single-branch linear workflow.
When should I use this skill?
The developer asks to create, list, or switch isolated worktrees or tmux sessions for issues, PRs, or features in Claude Code.
What you get
Isolated git worktrees, optional tmux sessions, and a listed inventory of active parallel development contexts.
- isolated git worktrees
- optional tmux sessions
By the numbers
- Exposes the `psm` compatibility alias for the canonical skill entrypoint
Files
Project Session Manager (PSM) Skill
psm is the compatibility alias for this canonical skill entrypoint.
Quick Start (worktree-first): Start with omc teleport when you want an isolated issue/PR/feature worktree before adding any tmux/session orchestration:```bash
omc teleport #123 # Create worktree for issue/PR
omc teleport my-feature # Create worktree for feature
omc teleport list # List worktrees
```
See Teleport Command below for details.
Automate isolated development environments using git worktrees and tmux sessions with Claude Code. Enables parallel work across multiple tasks, projects, and repositories.
Canonical slash command: /oh-my-claudecode:project-session-manager (alias: /oh-my-claudecode:psm).
Commands
| Command | Description | Example |
|---|---|---|
review <ref> | PR review session | /psm review omc#123 |
fix <ref> | Issue fix session | /psm fix omc#42 |
feature <proj> <name> | Feature development | /psm feature omc add-webhooks |
list [project] | List active sessions | /psm list |
attach <session> | Attach to session | /psm attach omc:pr-123 |
kill <session> | Kill session | /psm kill omc:pr-123 |
cleanup | Clean merged/closed | /psm cleanup |
status | Current session info | /psm status |
Project References
Supported formats:
- Alias:
omc#123(requires~/.psm/projects.json) - Full:
owner/repo#123 - URL:
https://github.com/owner/repo/pull/123 - Current:
#123(uses current directory's repo)
Configuration
Project Aliases (~/.psm/projects.json)
{
"aliases": {
"omc": {
"repo": "Yeachan-Heo/oh-my-claudecode",
"local": "~/Workspace/oh-my-claudecode",
"default_base": "main"
}
},
"defaults": {
"worktree_root": "~/.psm/worktrees",
"cleanup_after_days": 14
}
}Providers
PSM supports multiple issue tracking providers:
| Provider | CLI Required | Reference Formats | Commands |
|---|---|---|---|
| GitHub (default) | gh | owner/repo#123, alias#123, GitHub URLs | review, fix, feature |
| Jira | jira | PROJ-123 (if PROJ configured), alias#123 | fix, feature |
Jira Configuration
To use Jira, add an alias with jira_project and provider: "jira":
{
"aliases": {
"mywork": {
"jira_project": "MYPROJ",
"repo": "mycompany/my-project",
"local": "~/Workspace/my-project",
"default_base": "develop",
"provider": "jira"
}
}
}Important: The repo field is still required for cloning the git repository. Jira tracks issues, but you work in a git repo.
For non-GitHub repos, use clone_url instead:
{
"aliases": {
"private": {
"jira_project": "PRIV",
"clone_url": "git@gitlab.internal:team/repo.git",
"local": "~/Workspace/repo",
"provider": "jira"
}
}
}Jira Reference Detection
PSM only recognizes PROJ-123 format as Jira when PROJ is explicitly configured as a jira_project in your aliases. This prevents false positives from branch names like FIX-123.
Jira Examples
# Fix a Jira issue (MYPROJ must be configured)
psm fix MYPROJ-123
# Fix using alias (recommended)
psm fix mywork#123
# Feature development (works same as GitHub)
psm feature mywork add-webhooks
# Note: 'psm review' is not supported for Jira (no PR concept)
# Use 'psm fix' for Jira issuesJira CLI Setup
Install the Jira CLI:
# macOS
brew install ankitpokhrel/jira-cli/jira-cli
# Linux
# See: https://github.com/ankitpokhrel/jira-cli#installation
# Configure (interactive)
jira initThe Jira CLI handles authentication separately from PSM.
Directory Structure
~/.psm/
├── projects.json # Project aliases
├── sessions.json # Active session registry
└── worktrees/ # Worktree storage
└── <project>/
└── <type>-<id>/Session Naming
| Type | Tmux Session | Worktree Dir |
|---|---|---|
| PR Review | psm:omc:pr-123 | ~/.psm/worktrees/omc/pr-123 |
| Issue Fix | psm:omc:issue-42 | ~/.psm/worktrees/omc/issue-42 |
| Feature | psm:omc:feat-auth | ~/.psm/worktrees/omc/feat-auth |
---
Implementation Protocol
When the user invokes a PSM command, follow this protocol:
Parse Arguments
Parse {{ARGUMENTS}} to determine: 1. Subcommand: review, fix, feature, list, attach, kill, cleanup, status 2. Reference: project#number, URL, or session ID 3. Options: --branch, --base, --no-claude, --no-tmux, etc.
Subcommand: review <ref>
Purpose: Create PR review session
Steps:
1. Resolve reference:
# Read project aliases
cat ~/.psm/projects.json 2>/dev/null || echo '{"aliases":{}}'
# Parse ref format: alias#num, owner/repo#num, or URL
# Extract: project_alias, repo (owner/repo), pr_number, local_path2. Fetch PR info:
gh pr view <pr_number> --repo <repo> --json number,title,author,headRefName,baseRefName,body,files,url3. Ensure local repo exists:
# If local path doesn't exist, clone
if [[ ! -d "$local_path" ]]; then
git clone "https://github.com/$repo.git" "$local_path"
fi4. Create worktree:
worktree_path="$HOME/.psm/worktrees/$project_alias/pr-$pr_number"
# Fetch PR branch
cd "$local_path"
git fetch origin "pull/$pr_number/head:pr-$pr_number-review"
# Create worktree
git worktree add "$worktree_path" "pr-$pr_number-review"5. Create session metadata:
cat > "$worktree_path/.psm-session.json" << EOF
{
"id": "$project_alias:pr-$pr_number",
"type": "review",
"project": "$project_alias",
"ref": "pr-$pr_number",
"branch": "<head_branch>",
"base": "<base_branch>",
"created_at": "$(date -Iseconds)",
"tmux_session": "psm:$project_alias:pr-$pr_number",
"worktree_path": "$worktree_path",
"source_repo": "$local_path",
"github": {
"pr_number": $pr_number,
"pr_title": "<title>",
"pr_author": "<author>",
"pr_url": "<url>"
},
"state": "active"
}
EOF6. Update sessions registry:
# Add to ~/.psm/sessions.json7. Create tmux session:
tmux new-session -d -s "psm:$project_alias:pr-$pr_number" -c "$worktree_path"8. Launch Claude Code (unless --no-claude):
# --dangerously-skip-permissions prevents the "Do you trust this directory?" prompt
# and repeated tool-approval prompts from stalling the session (issue #2508).
tmux send-keys -t "psm:$project_alias:pr-$pr_number" "claude --dangerously-skip-permissions" Enter
# After claude boots (PSM_CLAUDE_STARTUP_DELAY, default 5s), deliver the task.
# Use -l (literal) so special characters are not misinterpreted by tmux.
sleep "${PSM_CLAUDE_STARTUP_DELAY:-5}"
tmux send-keys -t "psm:$project_alias:pr-$pr_number" -l \
"Review PR #$pr_number: \"$pr_title\" by @$pr_author ($head_branch → $base_branch). URL: $pr_url." Enter9. Output session info:
Session ready!
ID: omc:pr-123
Worktree: ~/.psm/worktrees/omc/pr-123
Tmux: psm:omc:pr-123
To attach: tmux attach -t psm:omc:pr-123Subcommand: fix <ref>
Purpose: Create issue fix session
Steps:
1. Resolve reference (same as review)
2. Fetch issue info:
gh issue view <issue_number> --repo <repo> --json number,title,body,labels,url3. Create feature branch:
cd "$local_path"
git fetch origin main
branch_name="fix/$issue_number-$(echo "$title" | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | head -c 30)"
git checkout -b "$branch_name" origin/main4. Create worktree:
worktree_path="$HOME/.psm/worktrees/$project_alias/issue-$issue_number"
git worktree add "$worktree_path" "$branch_name"5. Create session metadata (similar to review, type="fix")
6. Update registry, create tmux, launch claude: Same as review, but pass issue context as the initial task prompt:
tmux send-keys -t "psm:$project_alias:issue-$issue_number" "claude --dangerously-skip-permissions" Enter
# After claude boots, deliver the task (see PSM_CLAUDE_STARTUP_DELAY):
tmux send-keys -t "psm:$project_alias:issue-$issue_number" -l \
"Fix issue #$issue_number: \"$issue_title\". URL: $issue_url. Branch: $branch_name." EnterSubcommand: feature <project> <name>
Purpose: Start feature development
Steps:
1. Resolve project (from alias or path)
2. Create feature branch:
cd "$local_path"
git fetch origin main
branch_name="feature/$feature_name"
git checkout -b "$branch_name" origin/main3. Create worktree:
worktree_path="$HOME/.psm/worktrees/$project_alias/feat-$feature_name"
git worktree add "$worktree_path" "$branch_name"4. Create session, tmux, launch claude with feature context as initial prompt:
tmux send-keys -t "psm:$project_alias:feat-$feature_name" "claude --dangerously-skip-permissions" Enter
tmux send-keys -t "psm:$project_alias:feat-$feature_name" -l \
"Implement feature \"$feature_name\" for project $project. Branch: $branch_name." EnterSubcommand: list [project]
Purpose: List active sessions
Steps:
1. Read sessions registry:
cat ~/.psm/sessions.json 2>/dev/null || echo '{"sessions":{}}'2. Check tmux sessions:
tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^psm:"3. Check worktrees:
ls -la ~/.psm/worktrees/*/ 2>/dev/null4. Format output:
Active PSM Sessions:
ID | Type | Status | Worktree
-------------------|---------|----------|---------------------------
omc:pr-123 | review | active | ~/.psm/worktrees/omc/pr-123
omc:issue-42 | fix | detached | ~/.psm/worktrees/omc/issue-42Subcommand: attach <session>
Purpose: Attach to existing session
Steps:
1. Parse session ID: project:type-number
2. Verify session exists:
tmux has-session -t "psm:$session_id" 2>/dev/null3. Attach:
tmux attach -t "psm:$session_id"Subcommand: kill <session>
Purpose: Kill session and cleanup
Steps:
1. Kill tmux session:
tmux kill-session -t "psm:$session_id" 2>/dev/null2. Remove worktree:
worktree_path=$(jq -r ".sessions[\"$session_id\"].worktree" ~/.psm/sessions.json)
source_repo=$(jq -r ".sessions[\"$session_id\"].source_repo" ~/.psm/sessions.json)
cd "$source_repo"
git worktree remove "$worktree_path" --force3. Update registry:
# Remove from sessions.jsonSubcommand: cleanup
Purpose: Clean up merged PRs and closed issues
Steps:
1. Read all sessions
2. For each PR session, check if merged:
gh pr view <pr_number> --repo <repo> --json merged,state3. For each issue session, check if closed:
gh issue view <issue_number> --repo <repo> --json closed,state4. Clean up merged/closed sessions:
- Kill tmux session
- Remove worktree
- Update registry
5. Report:
Cleanup complete:
Removed: omc:pr-123 (merged)
Removed: omc:issue-42 (closed)
Kept: omc:feat-auth (active)Subcommand: status
Purpose: Show current session info
Steps:
1. Detect current session from tmux or cwd:
tmux display-message -p "#{session_name}" 2>/dev/null
# or check if cwd is inside a worktree2. Read session metadata:
cat .psm-session.json 2>/dev/null3. Show status:
Current Session: omc:pr-123
Type: review
PR: #123 - Add webhook support
Branch: feature/webhooks
Created: 2 hours ago---
Error Handling
| Error | Resolution |
|---|---|
| Worktree exists | Offer: attach, recreate, or abort |
| PR not found | Verify URL/number, check permissions |
| No tmux | Warn and skip session creation |
| No gh CLI | Error with install instructions |
Teleport Command
The omc teleport command provides a lightweight alternative to full PSM sessions. It creates git worktrees without tmux session management — ideal for quick, isolated development.
Usage
# Create worktree for an issue or PR
omc teleport #123
omc teleport owner/repo#123
omc teleport https://github.com/owner/repo/issues/42
# Create worktree for a feature
omc teleport my-feature
# List existing worktrees
omc teleport list
# Remove a worktree
omc teleport remove issue/my-repo-123
omc teleport remove --force feat/my-repo-my-featureOptions
| Flag | Description | Default |
|---|---|---|
--worktree | Create worktree (default, kept for compatibility) | true |
--path <path> | Custom worktree root directory | ~/Workspace/omc-worktrees/ |
--base <branch> | Base branch to create from | main |
--json | Output as JSON | false |
Worktree Layout
~/Workspace/omc-worktrees/
├── issue/
│ └── my-repo-123/ # Issue worktrees
├── pr/
│ └── my-repo-456/ # PR review worktrees
└── feat/
└── my-repo-my-feature/ # Feature worktreesPSM vs Teleport
| Feature | PSM | Teleport |
|---|---|---|
| Git worktree | Yes | Yes |
| Tmux session | Yes | No |
| Claude Code launch | Yes | No |
| Session registry | Yes | No |
| Auto-cleanup | Yes | No |
| Project aliases | Yes | No (uses current repo) |
Use PSM for full managed sessions. Use teleport for quick worktree creation.
---
Requirements
Required:
git- Version control (with worktree support v2.5+)jq- JSON parsingtmux- Session management (optional, but recommended)
Optional (per provider):
gh- GitHub CLI (for GitHub workflows)jira- Jira CLI (for Jira workflows)
Initialization
On first run, create default config:
mkdir -p ~/.psm/worktrees ~/.psm/logs
# Create default projects.json if not exists
if [[ ! -f ~/.psm/projects.json ]]; then
cat > ~/.psm/projects.json << 'EOF'
{
"aliases": {
"omc": {
"repo": "Yeachan-Heo/oh-my-claudecode",
"local": "~/Workspace/oh-my-claudecode",
"default_base": "main"
}
},
"defaults": {
"worktree_root": "~/.psm/worktrees",
"cleanup_after_days": 14,
"auto_cleanup_merged": true
}
}
EOF
fi
# Create sessions.json if not exists
if [[ ! -f ~/.psm/sessions.json ]]; then
echo '{"version":1,"sessions":{},"stats":{"total_created":0,"total_cleaned":0}}' > ~/.psm/sessions.json
fi#!/usr/bin/env bash
# PSM Configuration Management
PSM_ROOT="${HOME}/.psm"
PSM_WORKTREES="${PSM_ROOT}/worktrees"
PSM_PROJECTS="${PSM_ROOT}/projects.json"
PSM_SESSIONS="${PSM_ROOT}/sessions.json"
PSM_LOGS="${PSM_ROOT}/logs"
# Initialize PSM directories and config files
psm_init() {
mkdir -p "$PSM_WORKTREES" "$PSM_LOGS"
# Create default projects.json if not exists
if [[ ! -f "$PSM_PROJECTS" ]]; then
cat > "$PSM_PROJECTS" << 'EOF'
{
"aliases": {
"omc": {
"repo": "Yeachan-Heo/oh-my-claudecode",
"local": "~/Workspace/oh-my-claudecode",
"default_base": "main"
}
},
"defaults": {
"worktree_root": "~/.psm/worktrees",
"cleanup_after_days": 14,
"auto_cleanup_merged": true
}
}
EOF
echo "Created default projects.json"
fi
# Create sessions.json if not exists
if [[ ! -f "$PSM_SESSIONS" ]]; then
echo '{"version":1,"sessions":{},"stats":{"total_created":0,"total_cleaned":0}}' > "$PSM_SESSIONS"
echo "Created sessions.json"
fi
}
# Get project config by alias
# Usage: psm_get_project "omc"
# Returns: repo|local|default_base
psm_get_project() {
local alias="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
return 1
fi
local repo=$(jq -r --arg a "$alias" '.aliases[$a].repo // empty' "$PSM_PROJECTS")
local local_path=$(jq -r --arg a "$alias" '.aliases[$a].local // empty' "$PSM_PROJECTS")
local default_base=$(jq -r --arg a "$alias" '.aliases[$a].default_base // "main"' "$PSM_PROJECTS")
local clone_url=$(jq -r --arg a "$alias" '.aliases[$a].clone_url // empty' "$PSM_PROJECTS")
if [[ -z "$repo" && -z "$clone_url" ]]; then
return 1
fi
# Expand ~ to $HOME
local_path="${local_path/#\~/$HOME}"
echo "${repo}|${local_path}|${default_base}"
}
# Get provider for a project alias
# Usage: psm_get_project_provider "mywork"
# Returns: "github" | "jira" | empty (defaults to github)
psm_get_project_provider() {
local alias="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
echo "github"
return
fi
local provider
provider=$(jq -r --arg a "$alias" '.aliases[$a].provider // "github"' "$PSM_PROJECTS")
echo "$provider"
}
# Get Jira project key for alias
# Usage: psm_get_project_jira_project "mywork"
# Returns: "MYPROJ" or empty
psm_get_project_jira_project() {
local alias="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
return
fi
jq -r --arg a "$alias" '.aliases[$a].jira_project // empty' "$PSM_PROJECTS"
}
# Get explicit clone_url for alias (for non-GitHub repos)
# Usage: psm_get_project_clone_url "mywork"
# Returns: URL or empty
psm_get_project_clone_url() {
local alias="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
return
fi
jq -r --arg a "$alias" '.aliases[$a].clone_url // empty' "$PSM_PROJECTS"
}
# Get repo field for alias
# Usage: psm_get_project_repo "mywork"
# Returns: "owner/repo" or empty
psm_get_project_repo() {
local alias="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
return
fi
jq -r --arg a "$alias" '.aliases[$a].repo // empty' "$PSM_PROJECTS"
}
# Add or update project alias
psm_set_project() {
local alias="$1"
local repo="$2"
local local_path="$3"
local default_base="${4:-main}"
local tmp=$(mktemp)
jq --arg a "$alias" --arg r "$repo" --arg l "$local_path" --arg b "$default_base" \
'.aliases[$a] = {"repo": $r, "local": $l, "default_base": $b}' \
"$PSM_PROJECTS" > "$tmp" && mv "$tmp" "$PSM_PROJECTS"
}
# Get default worktree root
psm_get_worktree_root() {
local root=$(jq -r '.defaults.worktree_root // "~/.psm/worktrees"' "$PSM_PROJECTS")
echo "${root/#\~/$HOME}"
}
# Get cleanup days setting
psm_get_cleanup_days() {
jq -r '.defaults.cleanup_after_days // 14' "$PSM_PROJECTS"
}
#!/usr/bin/env bash
# PSM Reference Parser
# Parse a reference string into components
# Supports:
# omc#123 -> alias=omc, number=123
# owner/repo#123 -> repo=owner/repo, number=123
# https://... -> parsed from URL
# #123 -> number=123 (use current repo)
#
# Usage: psm_parse_ref "omc#123"
# Returns: type|alias|repo|number|local_path|base|provider|provider_ref
psm_parse_ref() {
local ref="$1"
local type=""
local alias=""
local repo=""
local number=""
local local_path=""
local base="main"
# GitHub PR URL
if [[ "$ref" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then
repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
number="${BASH_REMATCH[3]}"
type="pr"
# Try to find alias for this repo
alias=$(psm_find_alias_for_repo "$repo")
if [[ -n "$alias" ]]; then
IFS='|' read -r _ local_path base <<< "$(psm_get_project "$alias")"
fi
echo "pr|${alias:-}|$repo|$number|${local_path:-}|$base|github|${repo}#${number}"
return 0
fi
# GitHub Issue URL
if [[ "$ref" =~ ^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+) ]]; then
repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
number="${BASH_REMATCH[3]}"
type="issue"
alias=$(psm_find_alias_for_repo "$repo")
if [[ -n "$alias" ]]; then
IFS='|' read -r _ local_path base <<< "$(psm_get_project "$alias")"
fi
echo "issue|${alias:-}|$repo|$number|${local_path:-}|$base|github|${repo}#${number}"
return 0
fi
# Jira direct reference (PROJ-123) - config-validated
local jira_info
if jira_info=$(psm_detect_jira_key "$ref"); then
IFS='|' read -r alias project_key issue_number <<< "$jira_info"
local project_info
project_info=$(psm_get_project "$alias")
if [[ $? -eq 0 ]]; then
IFS='|' read -r repo local_path base <<< "$project_info"
echo "issue|${alias}|${repo}|${issue_number}|${local_path}|${base}|jira|${project_key}-${issue_number}"
return 0
fi
fi
# alias#number format (e.g., omc#123 or mywork#123)
if [[ "$ref" =~ ^([a-zA-Z][a-zA-Z0-9_-]*)#([0-9]+)$ ]]; then
alias="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
local project_info
project_info=$(psm_get_project "$alias")
if [[ $? -eq 0 ]]; then
IFS='|' read -r repo local_path base <<< "$project_info"
local provider
provider=$(psm_get_project_provider "$alias")
local provider_ref=""
if [[ "$provider" == "jira" ]]; then
local jira_proj
jira_proj=$(psm_get_project_jira_project "$alias")
provider_ref="${jira_proj}-${number}"
else
provider_ref="${repo}#${number}"
fi
echo "ref|$alias|$repo|$number|$local_path|$base|$provider|$provider_ref"
return 0
else
echo "error|Unknown project alias: $alias|||||||"
return 1
fi
fi
# owner/repo#number format
if [[ "$ref" =~ ^([a-zA-Z0-9_-]+)/([a-zA-Z0-9_.-]+)#([0-9]+)$ ]]; then
repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
number="${BASH_REMATCH[3]}"
alias=$(psm_find_alias_for_repo "$repo")
if [[ -n "$alias" ]]; then
IFS='|' read -r _ local_path base <<< "$(psm_get_project "$alias")"
fi
echo "ref|${alias:-}|$repo|$number|${local_path:-}|$base|github|${repo}#${number}"
return 0
fi
# Just #number (use current repo)
if [[ "$ref" =~ ^#([0-9]+)$ ]]; then
number="${BASH_REMATCH[1]}"
# Detect repo from current directory
if git rev-parse --git-dir > /dev/null 2>&1; then
local remote_url=$(git remote get-url origin 2>/dev/null)
if [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then
repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
local_path=$(git rev-parse --show-toplevel)
alias=$(psm_find_alias_for_repo "$repo")
fi
fi
echo "ref|${alias:-}|${repo:-}|$number|${local_path:-}|$base|github|${repo:+${repo}#${number}}"
return 0
fi
echo "error|Cannot parse reference: $ref||||||"
return 1
}
# Find project alias for a given repo
psm_find_alias_for_repo() {
local target_repo="$1"
if [[ ! -f "$PSM_PROJECTS" ]]; then
return 1
fi
jq -r --arg r "$target_repo" '.aliases | to_entries[] | select(.value.repo == $r) | .key' "$PSM_PROJECTS" | head -1
}
# Sanitize a string for use in filenames/session names
psm_sanitize() {
local input="$1"
# Remove path traversal, convert to lowercase, replace spaces with dashes
echo "$input" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | head -c 30
}
# Generate a slug from title
psm_slugify() {
local title="$1"
local max_len="${2:-30}"
echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//' | head -c "$max_len"
}
# Check if input matches a configured Jira project
# Usage: psm_detect_jira_key "PROJ-123"
# Returns: alias|project_key|issue_number OR exits 1
psm_detect_jira_key() {
local input="$1"
# Must match PROJ-123 pattern (uppercase project, dash, digits)
if [[ ! "$input" =~ ^([A-Z][A-Z0-9]*)-([0-9]+)$ ]]; then
return 1
fi
local project_prefix="${BASH_REMATCH[1]}"
local issue_number="${BASH_REMATCH[2]}"
# Verify this project prefix exists in config
if [[ ! -f "$PSM_PROJECTS" ]]; then
return 1
fi
local matching_alias
matching_alias=$(jq -r --arg p "$project_prefix" '.aliases | to_entries[] | select(.value.jira_project == $p) | .key' "$PSM_PROJECTS" | head -1)
if [[ -n "$matching_alias" ]]; then
echo "${matching_alias}|${project_prefix}|${issue_number}"
return 0
fi
return 1
}
#!/usr/bin/env bash
# PSM Azure DevOps Provider
provider_azure_available() {
command -v az &> /dev/null
}
provider_azure_detect_ref() {
local ref="$1"
[[ "$ref" =~ ^https://dev\.azure\.com/ ]] || \
[[ "$ref" =~ ^git@ssh\.dev\.azure\.com: ]] || \
[[ "$ref" =~ \.visualstudio\.com/ ]] || \
[[ "$ref" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#[0-9]+$ ]]
}
provider_azure_fetch_pr() {
local pr_number="$1"
local repo="$2"
az repos pr show --id "$pr_number" --output json 2>/dev/null
}
provider_azure_fetch_issue() {
local issue_number="$1"
local repo="$2"
az boards work-item show --id "$issue_number" --output json 2>/dev/null
}
provider_azure_pr_merged() {
local pr_number="$1"
local repo="$2"
command -v az >/dev/null 2>&1 || return 1
command -v jq >/dev/null 2>&1 || return 1
local status
status=$(az repos pr show --id "$pr_number" --output json 2>/dev/null | jq -r '.status // empty')
[[ "$status" == "completed" ]]
}
provider_azure_issue_closed() {
local issue_number="$1"
local repo="$2"
command -v az >/dev/null 2>&1 || return 1
command -v jq >/dev/null 2>&1 || return 1
local state
state=$(az boards work-item show --id "$issue_number" --output json 2>/dev/null | jq -r '.fields["System.State"] // empty')
[[ "$state" == "Closed" || "$state" == "Done" ]]
}
provider_azure_clone_url() {
local repo="$1"
# Azure DevOps URLs are complex and org-specific; user should configure directly
echo ""
return 1
}
#!/usr/bin/env bash
# PSM Bitbucket Provider
provider_bitbucket_available() {
command -v curl &> /dev/null
}
provider_bitbucket_detect_ref() {
local ref="$1"
# Matches bitbucket.org URLs
[[ "$ref" =~ ^https://bitbucket\.org/ ]]
}
_bitbucket_curl() {
local url="$1"
local -a curl_args=(--fail --silent --show-error --connect-timeout 5 --max-time 20)
if [[ -n "$BITBUCKET_TOKEN" ]]; then
curl_args+=(-H "Authorization: Bearer $BITBUCKET_TOKEN")
elif [[ -n "$BITBUCKET_USERNAME" && -n "$BITBUCKET_APP_PASSWORD" ]]; then
curl_args+=(-u "$BITBUCKET_USERNAME:$BITBUCKET_APP_PASSWORD")
fi
curl "${curl_args[@]}" "$url" 2>/dev/null
}
provider_bitbucket_fetch_pr() {
local pr_number="$1"
local repo="$2"
_bitbucket_curl "https://api.bitbucket.org/2.0/repositories/${repo}/pullrequests/${pr_number}"
}
provider_bitbucket_fetch_issue() {
local issue_number="$1"
local repo="$2"
_bitbucket_curl "https://api.bitbucket.org/2.0/repositories/${repo}/issues/${issue_number}"
}
provider_bitbucket_pr_merged() {
local pr_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local state
state=$(provider_bitbucket_fetch_pr "$pr_number" "$repo" | jq -r '.state // empty')
[[ "$state" == "MERGED" ]]
}
provider_bitbucket_issue_closed() {
local issue_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local state
state=$(provider_bitbucket_fetch_issue "$issue_number" "$repo" | jq -r '.state // empty')
[[ "$state" == "closed" ]]
}
provider_bitbucket_clone_url() {
local repo="$1"
# Validate owner/repo format
if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
echo "error|Invalid repository format: $repo" >&2
return 1
fi
echo "https://bitbucket.org/${repo}.git"
}
#!/usr/bin/env bash
# PSM Gitea Provider
provider_gitea_available() {
command -v tea &> /dev/null || command -v curl &> /dev/null
}
provider_gitea_detect_ref() {
# Cannot auto-detect self-hosted Gitea instances from URL alone
return 1
}
_gitea_curl_api() {
local endpoint="$1"
local base_url="${GITEA_URL:-https://gitea.com}"
local -a curl_args=(--fail --silent --show-error --connect-timeout 5 --max-time 20)
if [[ -n "$GITEA_TOKEN" ]]; then
curl_args+=(-H "Authorization: token $GITEA_TOKEN")
fi
curl "${curl_args[@]}" "${base_url}/api/v1/${endpoint}" 2>/dev/null
}
provider_gitea_fetch_pr() {
local pr_number="$1"
local repo="$2"
# Try tea CLI first, fall back to curl REST API
if command -v tea &> /dev/null; then
local result
result=$(tea pr view "$pr_number" 2>/dev/null)
if [[ $? -eq 0 && -n "$result" ]]; then
echo "$result"
return 0
fi
fi
# Fallback to REST API
if [[ -n "$GITEA_URL" && -n "$GITEA_TOKEN" ]]; then
_gitea_curl_api "repos/${repo}/pulls/${pr_number}"
else
return 1
fi
}
provider_gitea_fetch_issue() {
local issue_number="$1"
local repo="$2"
# Try tea CLI first, fall back to curl REST API
if command -v tea &> /dev/null; then
local result
result=$(tea issues view "$issue_number" 2>/dev/null)
if [[ $? -eq 0 && -n "$result" ]]; then
echo "$result"
return 0
fi
fi
# Fallback to REST API
if [[ -n "$GITEA_URL" && -n "$GITEA_TOKEN" ]]; then
_gitea_curl_api "repos/${repo}/issues/${issue_number}"
else
return 1
fi
}
provider_gitea_pr_merged() {
local pr_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local merged
# Use REST API for structured JSON output
if [[ -n "$GITEA_URL" && -n "$GITEA_TOKEN" ]]; then
merged=$(_gitea_curl_api "repos/${repo}/pulls/${pr_number}" | jq -r '.merged // empty')
[[ "$merged" == "true" ]]
else
return 1
fi
}
provider_gitea_issue_closed() {
local issue_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local state
# Use REST API for structured JSON output
if [[ -n "$GITEA_URL" && -n "$GITEA_TOKEN" ]]; then
state=$(_gitea_curl_api "repos/${repo}/issues/${issue_number}" | jq -r '.state // empty')
[[ "$state" == "closed" ]]
else
return 1
fi
}
provider_gitea_clone_url() {
local repo="$1"
# Validate owner/repo format
if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
echo "error|Invalid repository format: $repo" >&2
return 1
fi
echo "${GITEA_URL:-https://gitea.com}/${repo}.git"
}
#!/usr/bin/env bash
# PSM GitHub Provider
provider_github_available() {
command -v gh &> /dev/null
}
provider_github_detect_ref() {
local ref="$1"
# Matches github URLs or owner/repo#num patterns
[[ "$ref" =~ ^https://github\.com/ ]] || [[ "$ref" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+$ ]]
}
provider_github_fetch_pr() {
local pr_number="$1"
local repo="$2"
gh pr view "$pr_number" --repo "$repo" --json number,title,author,headRefName,baseRefName,body,files,url 2>/dev/null
}
provider_github_fetch_issue() {
local issue_number="$1"
local repo="$2"
gh issue view "$issue_number" --repo "$repo" --json number,title,body,labels,url 2>/dev/null
}
provider_github_pr_merged() {
local pr_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local merged
merged=$(gh pr view "$pr_number" --repo "$repo" --json merged 2>/dev/null | jq -r '.merged // empty')
[[ "$merged" == "true" ]]
}
provider_github_issue_closed() {
local issue_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local closed
closed=$(gh issue view "$issue_number" --repo "$repo" --json closed 2>/dev/null | jq -r '.closed // empty')
[[ "$closed" == "true" ]]
}
provider_github_clone_url() {
local repo="$1"
# Validate owner/repo format
if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
echo "error|Invalid repository format: $repo" >&2
return 1
fi
echo "https://github.com/${repo}.git"
}
#!/usr/bin/env bash
# PSM GitLab Provider
provider_gitlab_available() {
command -v glab &> /dev/null
}
provider_gitlab_detect_ref() {
local ref="$1"
# Matches gitlab URLs or owner/repo!num patterns (GitLab uses ! for MRs)
[[ "$ref" =~ ^https://gitlab\. ]] || [[ "$ref" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+![0-9]+$ ]]
}
provider_gitlab_fetch_pr() {
local mr_number="$1"
local repo="$2"
glab mr view "$mr_number" --repo "$repo" --output json 2>/dev/null
}
provider_gitlab_fetch_issue() {
local issue_number="$1"
local repo="$2"
glab issue view "$issue_number" --repo "$repo" --output json 2>/dev/null
}
provider_gitlab_pr_merged() {
local pr_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local merged_at
merged_at=$(glab mr view "$pr_number" --repo "$repo" --output json 2>/dev/null | jq -r '.merged_at // empty')
[[ -n "$merged_at" && "$merged_at" != "null" ]]
}
provider_gitlab_issue_closed() {
local issue_number="$1"
local repo="$2"
command -v jq >/dev/null 2>&1 || return 1
local state
state=$(glab issue view "$issue_number" --repo "$repo" --output json 2>/dev/null | jq -r '.state // empty')
[[ "$state" == "closed" ]]
}
provider_gitlab_clone_url() {
local repo="$1"
# Validate owner/repo format
if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
echo "error|Invalid repository format: $repo" >&2
return 1
fi
echo "https://gitlab.com/${repo}.git"
}
#!/usr/bin/env bash
# PSM Provider Interface
# Each provider implements: _available, _detect_ref, _fetch_issue, _issue_closed,
# _fetch_pr (optional), _pr_merged (optional), _clone_url
# List available providers
provider_list() {
echo "github jira"
}
# Allowlist of valid providers
readonly VALID_PROVIDERS="github jira"
# Check if a provider is available (CLI installed)
# Usage: provider_available "github"
provider_available() {
local provider="$1"
# Validate provider against allowlist
if ! echo "$VALID_PROVIDERS" | grep -qw "$provider"; then
echo "error|Invalid provider: $provider" >&2
return 1
fi
"provider_${provider}_available"
}
# Dispatch to provider function
# Usage: provider_call "github" "fetch_issue" "123" "owner/repo"
provider_call() {
local provider="$1"
local func="$2"
shift 2
# Validate provider against allowlist
if ! echo "$VALID_PROVIDERS" | grep -qw "$provider"; then
echo "error|Invalid provider: $provider" >&2
return 1
fi
# Validate function name (alphanumeric and underscore only)
if [[ ! "$func" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
echo "error|Invalid function name: $func" >&2
return 1
fi
"provider_${provider}_${func}" "$@"
}
# Detect provider from reference (with config validation)
# Usage: provider_detect_from_ref "PROJ-123"
# Returns: provider name or empty
provider_detect_from_ref() {
local ref="$1"
# Check Jira pattern first (config-validated)
if psm_detect_jira_key "$ref" >/dev/null 2>&1; then
echo "jira"
return 0
fi
# GitHub URL patterns
if [[ "$ref" =~ ^https://github\.com/ ]]; then
echo "github"
return 0
fi
# owner/repo#num pattern -> GitHub
if [[ "$ref" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+$ ]]; then
echo "github"
return 0
fi
# Default
echo "github"
}
#!/usr/bin/env bash
# PSM Jira Provider
# Uses `jira` CLI (https://github.com/ankitpokhrel/jira-cli)
provider_jira_available() {
command -v jira &> /dev/null
}
provider_jira_detect_ref() {
local ref="$1"
# Config-validated detection only
psm_detect_jira_key "$ref" >/dev/null 2>&1
}
provider_jira_fetch_issue() {
local issue_key="$1" # e.g., "PROJ-123"
# Note: second arg (repo) is ignored for Jira
jira issue view "$issue_key" --output json 2>/dev/null
}
provider_jira_issue_closed() {
local issue_key="$1"
local status_category
status_category=$(jira issue view "$issue_key" --output json 2>/dev/null | jq -r '.fields.status.statusCategory.key')
# Jira status categories: "new", "indeterminate", "done"
[[ "$status_category" == "done" ]]
}
# Jira has no PRs - return error
provider_jira_fetch_pr() {
echo '{"error": "Jira does not support pull requests"}' >&2
return 1
}
provider_jira_pr_merged() {
return 1 # Always false - Jira has no PRs
}
provider_jira_clone_url() {
local alias="$1"
# For Jira, we need to get clone_url from config
# First try explicit clone_url, then fall back to repo as GitHub
local clone_url
clone_url=$(psm_get_project_clone_url "$alias")
if [[ -n "$clone_url" ]]; then
echo "$clone_url"
return 0
fi
local repo
repo=$(psm_get_project_repo "$alias")
if [[ -n "$repo" ]]; then
echo "https://github.com/${repo}.git"
return 0
fi
echo "error: No clone_url or repo configured for alias '$alias'" >&2
return 1
}
# Parse Jira reference into components
# Input: "PROJ-123" or "mywork#123"
# Output: Extended format for session creation
provider_jira_parse_ref() {
local ref="$1"
local jira_info
# Try direct PROJ-123 pattern
if jira_info=$(psm_detect_jira_key "$ref"); then
IFS='|' read -r alias project_key issue_number <<< "$jira_info"
local project_info
project_info=$(psm_get_project "$alias")
IFS='|' read -r repo local_path base <<< "$project_info"
echo "issue|${alias}|${repo}|${issue_number}|${local_path}|${base}|jira|${project_key}-${issue_number}"
return 0
fi
return 1
}
#!/usr/bin/env bash
# PSM Session Registry Management
# Lock file for atomic registry operations
PSM_LOCK_FILE="${PSM_DATA_DIR:-.psm}/.psm-lock"
# Wrapper for atomic operations with file locking
# Usage: psm_with_lock <command> [args...]
psm_with_lock() {
local timeout="${PSM_LOCK_TIMEOUT:-5}"
(
flock -w "$timeout" 200 || {
echo "error|Failed to acquire lock after ${timeout}s" >&2
return 1
}
"$@"
) 200>"$PSM_LOCK_FILE"
}
# Internal: Add session to registry (must be called via psm_with_lock)
_psm_add_session_impl() {
local id="$1"
local type="$2"
local project="$3"
local ref="$4"
local branch="$5"
local base="$6"
local tmux_session="$7"
local worktree="$8"
local source_repo="$9"
local metadata="${10:-{}}"
local provider="${11:-github}"
local provider_ref="${12:-}"
local now=$(date -Iseconds)
local tmp=$(mktemp)
jq --arg id "$id" \
--arg type "$type" \
--arg project "$project" \
--arg ref "$ref" \
--arg branch "$branch" \
--arg base "$base" \
--arg tmux "$tmux_session" \
--arg worktree "$worktree" \
--arg source "$source_repo" \
--arg now "$now" \
--arg provider "$provider" \
--arg provider_ref "$provider_ref" \
--argjson meta "$metadata" \
'.sessions[$id] = {
"id": $id,
"type": $type,
"project": $project,
"ref": $ref,
"branch": $branch,
"base": $base,
"tmux": $tmux,
"worktree": $worktree,
"source_repo": $source,
"created_at": $now,
"last_accessed": $now,
"state": "active",
"provider": $provider,
"provider_ref": $provider_ref,
"metadata": $meta
} | .stats.total_created += 1' \
"$PSM_SESSIONS" > "$tmp" && mv "$tmp" "$PSM_SESSIONS"
}
# Add session to registry (with file locking)
# Usage: psm_add_session <id> <type> <project> <ref> <branch> <base> <tmux> <worktree> <source_repo> <metadata_json> [provider] [provider_ref]
psm_add_session() {
psm_with_lock _psm_add_session_impl "$@"
}
# Get session by ID
# Usage: psm_get_session <id>
psm_get_session() {
local id="$1"
jq -r --arg i "$id" '.sessions[$i] // empty' "$PSM_SESSIONS"
}
# Internal: Update session state (must be called via psm_with_lock)
_psm_update_session_state_impl() {
local id="$1"
local state="$2"
local now=$(date -Iseconds)
local tmp=$(mktemp)
jq --arg id "$id" \
--arg state "$state" \
--arg now "$now" \
'.sessions[$id].state = $state | .sessions[$id].last_accessed = $now' \
"$PSM_SESSIONS" > "$tmp" && mv "$tmp" "$PSM_SESSIONS"
}
# Update session state (with file locking)
# Usage: psm_update_session_state <id> <state>
psm_update_session_state() {
psm_with_lock _psm_update_session_state_impl "$@"
}
# Internal: Remove session from registry (must be called via psm_with_lock)
_psm_remove_session_impl() {
local id="$1"
local tmp=$(mktemp)
jq --arg id "$id" \
'del(.sessions[$id]) | .stats.total_cleaned += 1' \
"$PSM_SESSIONS" > "$tmp" && mv "$tmp" "$PSM_SESSIONS"
}
# Remove session from registry (with file locking)
# Usage: psm_remove_session <id>
psm_remove_session() {
psm_with_lock _psm_remove_session_impl "$@"
}
# List all sessions
# Usage: psm_list_sessions [project]
psm_list_sessions() {
local project="$1"
if [[ -n "$project" ]]; then
jq -r --arg p "$project" '.sessions | to_entries[] | select(.value.project == $p) | .value | "\(.id)|\(.type)|\(.state)|\(.worktree)"' "$PSM_SESSIONS"
else
jq -r '.sessions | to_entries[] | .value | "\(.id)|\(.type)|\(.state)|\(.worktree)"' "$PSM_SESSIONS"
fi
}
# Get sessions by state
psm_get_sessions_by_state() {
local state="$1"
jq -r --arg s "$state" '.sessions | to_entries[] | select(.value.state == $s) | .value.id' "$PSM_SESSIONS"
}
# Get session count
psm_session_count() {
jq -r '.sessions | length' "$PSM_SESSIONS"
}
# Write session metadata file in worktree
# Usage: psm_write_session_metadata <worktree_path> <session_json>
psm_write_session_metadata() {
local worktree_path="$1"
local session_json="$2"
echo "$session_json" > "${worktree_path}/.psm-session.json"
}
# Read session metadata from worktree
psm_read_session_metadata() {
local worktree_path="$1"
local meta_file="${worktree_path}/.psm-session.json"
if [[ -f "$meta_file" ]]; then
cat "$meta_file"
fi
}
# Get all session IDs for cleanup check
psm_get_review_sessions() {
jq -r '.sessions | to_entries[] | select(.value.type == "review" and (.value.state // "active") == "active") | "\(.value.id)|\(.value.metadata.pr_number // empty)|\(.value.project)"' "$PSM_SESSIONS"
}
psm_get_fix_sessions() {
jq -r '.sessions | to_entries[] | select(.value.type == "fix" and (.value.state // "active") == "active") | "\(.value.id)|\(.value.metadata.issue_number // empty)|\(.value.project)"' "$PSM_SESSIONS"
}
#!/usr/bin/env bash
# PSM Tmux Session Management
# Check if tmux is available
psm_has_tmux() {
command -v tmux &> /dev/null
}
# Create a tmux session
# Usage: psm_create_tmux_session <session_name> <working_dir>
psm_create_tmux_session() {
local session_name="$1"
local working_dir="$2"
if ! psm_has_tmux; then
echo "error|tmux not found"
return 1
fi
# Check if session already exists
if tmux has-session -t "$session_name" 2>/dev/null; then
echo "exists|$session_name"
return 1
fi
# Create detached session
tmux new-session -d -s "$session_name" -c "$working_dir" 2>/dev/null || {
echo "error|Failed to create tmux session"
return 1
}
echo "created|$session_name"
return 0
}
# Launch Claude Code in tmux session, optionally injecting either a context-file
# trigger prompt or a literal initial prompt.
# Usage: psm_launch_claude <session_name> [initial_context]
# initial_context may be either:
# - a path relative to the worktree root (e.g. .psm/review.md), or
# - a literal prompt string to send after Claude boots.
#
# Passes --dangerously-skip-permissions so the session does not stall on
# directory-trust or repeated tool-approval prompts (issue #2508).
# Set PSM_CLAUDE_STARTUP_DELAY (default: 5s) to tune literal-prompt delivery.
psm_launch_claude() {
local session_name="$1"
local initial_context="${2:-}"
if ! tmux has-session -t "$session_name" 2>/dev/null; then
echo "error|Session not found: $session_name"
return 1
fi
# --dangerously-skip-permissions bypasses both the directory-trust prompt and
# every per-tool approval prompt. Without this flag, unattended PSM sessions
# can block indefinitely on the first tool call (issue #2508).
tmux send-keys -t "$session_name" "claude --dangerously-skip-permissions" Enter
if [[ -n "$initial_context" ]]; then
local session_path=""
session_path=$(tmux display-message -p -t "$session_name" '#{pane_current_path}' 2>/dev/null || true)
# If the second arg resolves to a file in the worktree, preserve the
# existing context-file flow. Otherwise treat it as a literal prompt.
if [[ -n "$session_path" && -f "$session_path/$initial_context" ]]; then
psm_inject_prompt "$session_name" "$initial_context"
else
local startup_delay="${PSM_CLAUDE_STARTUP_DELAY:-5}"
(
sleep "$startup_delay"
tmux send-keys -t "$session_name" -l -- "$initial_context" 2>/dev/null || true
tmux send-keys -t "$session_name" Enter 2>/dev/null || true
) &
fi
fi
echo "launched|$session_name"
return 0
}
# Render a PSM template file by substituting {{KEY}} placeholders.
# Usage: psm_render_template <template_file> [KEY=VALUE ...]
# Outputs rendered content to stdout; returns 1 if template not found.
psm_render_template() {
local template_file="$1"
shift
if [[ ! -f "$template_file" ]]; then
echo "error|Template not found: $template_file" >&2
return 1
fi
local content
content=$(cat "$template_file")
for assignment in "$@"; do
local key="${assignment%%=*}"
local value="${assignment#*=}"
# Bash parameter expansion handles multiline values safely
content="${content//\{\{${key}\}\}/$value}"
done
printf '%s\n' "$content"
}
# Returns 0 if the captured pane text shows Claude's interactive input prompt.
# Matches '>' (standard prompt) or '?' (trust prompt) as the sole content of a line.
# Usage: _psm_pane_has_claude_prompt <pane_text>
_psm_pane_has_claude_prompt() {
local pane_text="$1"
printf '%s' "$pane_text" | grep -qE '^[[:space:]]*(>|\?)[[:space:]]*$'
}
# Poll the tmux pane until Claude's REPL prompt appears.
# Usage: psm_wait_for_claude_prompt <session_name> [max_seconds]
# Returns 0 when prompt detected; 1 on timeout.
psm_wait_for_claude_prompt() {
local session_name="$1"
local max_wait="${2:-30}"
local waited=0
while [[ $waited -lt $max_wait ]]; do
local pane_content
pane_content=$(tmux capture-pane -t "$session_name" -p 2>/dev/null) || return 1
if _psm_pane_has_claude_prompt "$pane_content"; then
return 0
fi
sleep 1
(( waited++ )) || true
done
return 1
}
# Wait for Claude's REPL to be ready then inject a context-file trigger prompt.
# Non-fatal: warns on timeout but does not fail the session creation.
# Usage: psm_inject_prompt <session_name> <context_file_relative_path>
psm_inject_prompt() {
local session_name="$1"
local context_file="$2"
if ! psm_wait_for_claude_prompt "$session_name"; then
echo "warn|Timed out waiting for Claude prompt; task context not injected" >&2
return 0
fi
local trigger="Read ${context_file} for full task context, then begin."
# Use literal mode (-l) to prevent tmux from interpreting key names in the text
tmux send-keys -t "$session_name" -l -- "$trigger"
sleep 0.15
tmux send-keys -t "$session_name" Enter
return 0
}
# Kill a tmux session
# Usage: psm_kill_tmux_session <session_name>
psm_kill_tmux_session() {
local session_name="$1"
if ! tmux has-session -t "$session_name" 2>/dev/null; then
echo "not_found|$session_name"
return 0
fi
tmux kill-session -t "$session_name" 2>/dev/null || {
echo "error|Failed to kill session"
return 1
}
echo "killed|$session_name"
return 0
}
# List all PSM tmux sessions
psm_list_tmux_sessions() {
if ! psm_has_tmux; then
return 0
fi
tmux list-sessions -F "#{session_name}|#{session_created}|#{session_attached}" 2>/dev/null | grep "^psm:" || true
}
# Check if a tmux session exists
# Usage: psm_tmux_session_exists <session_name>
psm_tmux_session_exists() {
local session_name="$1"
tmux has-session -t "$session_name" 2>/dev/null
}
# Get current tmux session name
psm_current_tmux_session() {
if [[ -n "$TMUX" ]]; then
tmux display-message -p "#{session_name}" 2>/dev/null
fi
}
# Generate tmux session name
# Usage: psm_tmux_session_name <alias> <type> <id>
psm_tmux_session_name() {
local alias="$1"
local type="$2"
local id="$3"
echo "psm:${alias}:${type}-${id}"
}
#!/usr/bin/env bash
# PSM Worktree Management
# Validate worktree path is under PSM worktree root before deletion
# Returns 0 if valid, 1 if invalid
# Usage: validate_worktree_path <path>
validate_worktree_path() {
local path="$1"
local worktree_root
worktree_root=$(psm_get_worktree_root 2>/dev/null) || return 1
# Path must exist and be a directory
if [[ ! -d "$path" ]]; then
return 1
fi
# Resolve to absolute paths for comparison
local abs_path abs_root
abs_path=$(cd "$path" 2>/dev/null && pwd) || return 1
abs_root=$(cd "$worktree_root" 2>/dev/null && pwd) || return 1
# Check path is under root and doesn't contain ..
if [[ "$abs_path" != "$abs_root"/* ]] || [[ "$path" == *".."* ]]; then
echo "error|Invalid worktree path: not under PSM root" >&2
return 1
fi
return 0
}
# Best-effort dependency bootstrap for clean PR review worktrees.
# Reuses the source repo node_modules only when package.json matches exactly.
# This keeps focused test commands like `npm run test:run -- ...` usable
# without forcing a full install in every review worktree.
psm_bootstrap_review_dependencies() {
local local_repo="$1"
local worktree_path="$2"
local source_package_json="${local_repo}/package.json"
local target_package_json="${worktree_path}/package.json"
local source_node_modules="${local_repo}/node_modules"
local target_node_modules="${worktree_path}/node_modules"
[[ -f "$source_package_json" ]] || return 0
[[ -f "$target_package_json" ]] || return 0
[[ -d "$source_node_modules" ]] || return 0
[[ ! -e "$target_node_modules" ]] || return 0
cmp -s "$source_package_json" "$target_package_json" || return 0
ln -s "$source_node_modules" "$target_node_modules" 2>/dev/null || true
return 0
}
# Create a worktree for PR review
# Usage: psm_create_pr_worktree <local_repo> <alias> <pr_number> <pr_branch>
psm_create_pr_worktree() {
local local_repo="$1"
local alias="$2"
local pr_number="$3"
local pr_branch="$4"
local worktree_root=$(psm_get_worktree_root)
local worktree_path="${worktree_root}/${alias}/pr-${pr_number}"
# Check if worktree already exists
if [[ -d "$worktree_path" ]]; then
echo "exists|$worktree_path"
return 1
fi
# Ensure parent directory exists
mkdir -p "${worktree_root}/${alias}"
# Fetch the PR branch
cd "$local_repo" || return 1
git fetch origin "pull/${pr_number}/head:psm-pr-${pr_number}-review" 2>/dev/null || {
echo "error|Failed to fetch PR #${pr_number}"
return 1
}
# Create worktree
git worktree add "$worktree_path" "psm-pr-${pr_number}-review" 2>/dev/null || {
echo "error|Failed to create worktree"
return 1
}
psm_bootstrap_review_dependencies "$local_repo" "$worktree_path"
echo "created|$worktree_path"
return 0
}
# Create a worktree for issue fix
# Usage: psm_create_issue_worktree <local_repo> <alias> <issue_number> <slug> <base_branch>
psm_create_issue_worktree() {
local local_repo="$1"
local alias="$2"
local issue_number="$3"
local slug="$4"
local base_branch="${5:-main}"
local worktree_root=$(psm_get_worktree_root)
local worktree_path="${worktree_root}/${alias}/issue-${issue_number}"
local branch_name="fix/${issue_number}-${slug}"
# Check if worktree already exists
if [[ -d "$worktree_path" ]]; then
echo "exists|$worktree_path|$branch_name"
return 1
fi
mkdir -p "${worktree_root}/${alias}"
cd "$local_repo" || return 1
# Fetch latest from origin
git fetch origin "$base_branch" 2>/dev/null || {
echo "error|Failed to fetch $base_branch"
return 1
}
# Create and checkout new branch
git branch "$branch_name" "origin/$base_branch" 2>/dev/null || {
# Branch might already exist
true
}
# Create worktree
git worktree add "$worktree_path" "$branch_name" 2>/dev/null || {
echo "error|Failed to create worktree"
return 1
}
echo "created|$worktree_path|$branch_name"
return 0
}
# Create a worktree for feature development
# Usage: psm_create_feature_worktree <local_repo> <alias> <feature_name> <base_branch>
psm_create_feature_worktree() {
local local_repo="$1"
local alias="$2"
local feature_name="$3"
local base_branch="${4:-main}"
local worktree_root=$(psm_get_worktree_root)
local safe_name=$(psm_sanitize "$feature_name")
local worktree_path="${worktree_root}/${alias}/feat-${safe_name}"
local branch_name="feature/${safe_name}"
# Check if worktree already exists
if [[ -d "$worktree_path" ]]; then
echo "exists|$worktree_path|$branch_name"
return 1
fi
mkdir -p "${worktree_root}/${alias}"
cd "$local_repo" || return 1
# Fetch latest
git fetch origin "$base_branch" 2>/dev/null || {
echo "error|Failed to fetch $base_branch"
return 1
}
# Create branch
git branch "$branch_name" "origin/$base_branch" 2>/dev/null || true
# Create worktree
git worktree add "$worktree_path" "$branch_name" 2>/dev/null || {
echo "error|Failed to create worktree"
return 1
}
echo "created|$worktree_path|$branch_name"
return 0
}
# Remove a worktree
# Usage: psm_remove_worktree <local_repo> <worktree_path>
psm_remove_worktree() {
local local_repo="$1"
local worktree_path="$2"
if [[ ! -d "$worktree_path" ]]; then
echo "not_found|$worktree_path"
return 1
fi
# Check for uncommitted changes
if [[ -d "$worktree_path/.git" ]] || [[ -f "$worktree_path/.git" ]]; then
cd "$worktree_path" || return 1
if [[ -n $(git status --porcelain 2>/dev/null) ]]; then
echo "dirty|$worktree_path"
return 1
fi
fi
cd "$local_repo" || return 1
# Validate path is under PSM worktree root before any deletion
if validate_worktree_path "$worktree_path"; then
git worktree remove "$worktree_path" --force 2>/dev/null || {
# Force remove the directory if git worktree remove fails
rm -rf "$worktree_path"
}
else
echo "error|Refusing to delete path outside worktree root: $worktree_path" >&2
return 1
fi
echo "removed|$worktree_path"
return 0
}
# List all PSM worktrees
psm_list_worktrees() {
local worktree_root=$(psm_get_worktree_root)
if [[ ! -d "$worktree_root" ]]; then
return 0
fi
find "$worktree_root" -mindepth 2 -maxdepth 2 -type d 2>/dev/null | while read -r dir; do
local alias=$(basename "$(dirname "$dir")")
local name=$(basename "$dir")
echo "${alias}:${name}|${dir}"
done
}
#!/usr/bin/env bash
# Project Session Manager (PSM) - Main Script
# Usage: psm.sh <command> [args...]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source library files
source "$SCRIPT_DIR/lib/config.sh"
source "$SCRIPT_DIR/lib/parse.sh"
source "$SCRIPT_DIR/lib/worktree.sh"
source "$SCRIPT_DIR/lib/tmux.sh"
source "$SCRIPT_DIR/lib/session.sh"
# Source provider files
source "$SCRIPT_DIR/lib/providers/interface.sh"
source "$SCRIPT_DIR/lib/providers/github.sh"
source "$SCRIPT_DIR/lib/providers/jira.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging
log_info() { echo -e "${BLUE}[PSM]${NC} $*"; }
log_success() { echo -e "${GREEN}[PSM]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[PSM]${NC} $*"; }
log_error() { echo -e "${RED}[PSM]${NC} $*" >&2; }
# Check dependencies
check_dependencies() {
local missing=()
if ! command -v git &> /dev/null; then
missing+=("git")
fi
if ! command -v jq &> /dev/null; then
missing+=("jq")
fi
# Note: gh and jira are checked per-operation, not globally
# This allows users without gh to still use Jira, and vice versa
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Missing required dependencies: ${missing[*]}"
log_info "Install with:"
log_info " Ubuntu/Debian: sudo apt install git jq"
log_info " macOS: brew install git jq"
exit 1
fi
# tmux is optional but warn if missing
if ! command -v tmux &> /dev/null; then
log_warn "tmux not found. Sessions will be created without tmux."
fi
}
# Print usage
usage() {
cat << 'EOF'
Project Session Manager (PSM) - Isolated dev environments
Usage: psm <command> [args...]
Commands:
review <ref> Create PR review session
fix <ref> Create issue fix session
feature <proj> <name> Create feature development session
list [project] List active sessions
attach <session> Attach to existing session
kill <session> Kill and cleanup session
cleanup [--force] Clean merged PRs and closed issues
status Show current session info
Reference formats:
omc#123 Project alias + number
owner/repo#123 Full GitHub reference
https://... GitHub URL
#123 Number only (uses current repo)
Examples:
psm review omc#123
psm fix Yeachan-Heo/oh-my-claudecode#42
psm feature omc add-webhooks
psm list
psm attach omc:pr-123
psm kill omc:pr-123
psm cleanup
EOF
}
# Command: review
cmd_review() {
local ref="$1"
local no_claude="${2:-false}"
local no_tmux="${3:-false}"
log_info "Parsing reference: $ref"
# Parse reference
local parsed
parsed=$(psm_parse_ref "$ref")
if [[ $? -ne 0 ]] || [[ "$parsed" == error* ]]; then
log_error "Failed to parse reference: $ref"
return 1
fi
IFS='|' read -r type alias repo pr_number local_path base provider provider_ref <<< "$parsed"
# Provider guard: Jira doesn't have PRs
if [[ "$provider" == "jira" ]]; then
log_error "Jira issues cannot be 'reviewed' - Jira has no PR concept."
log_info "Use 'psm fix $ref' to work on a Jira issue instead."
log_info "Jira integration supports: fix, feature"
return 1
fi
# Check GitHub CLI availability
if ! provider_github_available; then
log_error "GitHub CLI (gh) not found. Install: brew install gh"
return 1
fi
if [[ -z "$repo" ]]; then
log_error "Could not determine repository"
return 1
fi
log_info "Fetching PR #${pr_number} from ${repo}..."
# Fetch PR info
local pr_info
pr_info=$(provider_call "github" fetch_pr "$pr_number" "$repo") || {
log_error "Failed to fetch PR #${pr_number}. Check if the PR exists and you have access."
return 1
}
local pr_title=$(echo "$pr_info" | jq -r '.title')
local pr_author=$(echo "$pr_info" | jq -r '.author.login')
local head_branch=$(echo "$pr_info" | jq -r '.headRefName')
local base_branch=$(echo "$pr_info" | jq -r '.baseRefName')
local pr_url=$(echo "$pr_info" | jq -r '.url')
local pr_body=$(echo "$pr_info" | jq -r '.body // ""')
local changed_files=$(echo "$pr_info" | jq -r '.files[]?.path // empty' 2>/dev/null | head -50 || true)
log_info "PR: #${pr_number} - ${pr_title}"
log_info "Author: @${pr_author}"
log_info "Branch: ${head_branch} -> ${base_branch}"
# Determine alias if not set
if [[ -z "$alias" ]]; then
alias=$(echo "$repo" | tr '/' '-')
fi
# Determine local path
if [[ -z "$local_path" || ! -d "$local_path" ]]; then
# Clone if needed
local_path="${HOME}/Workspace/$(basename "$repo")"
if [[ ! -d "$local_path" ]]; then
log_info "Cloning repository to $local_path..."
local clone_url
clone_url=$(provider_call "github" clone_url "$repo")
git clone "$clone_url" "$local_path" || {
log_error "Failed to clone repository"
return 1
}
fi
fi
# Create worktree
log_info "Creating worktree..."
local worktree_result
worktree_result=$(psm_create_pr_worktree "$local_path" "$alias" "$pr_number" "$head_branch")
local worktree_status
local worktree_path
IFS='|' read -r worktree_status worktree_path <<< "$worktree_result"
if [[ "$worktree_status" == "exists" ]]; then
log_warn "Worktree already exists at $worktree_path"
log_info "Use 'psm attach ${alias}:pr-${pr_number}' to attach"
return 0
elif [[ "$worktree_status" == "error" ]]; then
log_error "Failed to create worktree: $worktree_path"
return 1
fi
log_success "Worktree created at $worktree_path"
# Render PR review context file into the worktree so Claude starts with full task context
local context_rel=".psm/review.md"
local context_file="${worktree_path}/${context_rel}"
mkdir -p "$(dirname "$context_file")"
if ! psm_render_template "$SCRIPT_DIR/templates/pr-review.md" \
"PR_NUMBER=${pr_number}" \
"PR_TITLE=${pr_title}" \
"PR_AUTHOR=${pr_author}" \
"PR_URL=${pr_url}" \
"HEAD_BRANCH=${head_branch}" \
"BASE_BRANCH=${base_branch}" \
"PR_BODY=${pr_body}" \
"CHANGED_FILES=${changed_files}" \
> "$context_file"; then
log_warn "Failed to render review template; Claude will start without task context"
context_rel=""
fi
# Create tmux session
local session_name="psm:${alias}:pr-${pr_number}"
local session_id="${alias}:pr-${pr_number}"
if [[ "$no_tmux" != "true" ]]; then
log_info "Creating tmux session..."
local tmux_result
tmux_result=$(psm_create_tmux_session "$session_name" "$worktree_path")
local tmux_status
IFS='|' read -r tmux_status _ <<< "$tmux_result"
if [[ "$tmux_status" == "error" ]]; then
log_warn "Could not create tmux session. Continuing without tmux."
elif [[ "$tmux_status" == "exists" ]]; then
log_warn "Tmux session already exists"
else
log_success "Tmux session created: $session_name"
# Launch Claude Code with review context so it starts on the PR task
if [[ "$no_claude" != "true" ]]; then
log_info "Launching Claude Code..."
psm_launch_claude "$session_name" "$context_rel"
fi
fi
fi
# Create session metadata
local metadata
metadata=$(jq -n \
--argjson pr_number "$pr_number" \
--arg pr_title "$pr_title" \
--arg pr_author "$pr_author" \
--arg pr_url "$pr_url" \
'{pr_number: $pr_number, pr_title: $pr_title, pr_author: $pr_author, pr_url: $pr_url}')
# Add to registry
psm_add_session "$session_id" "review" "$alias" "pr-${pr_number}" "$head_branch" "$base_branch" "$session_name" "$worktree_path" "$local_path" "$metadata" "github" "${repo}#${pr_number}"
# Output summary
echo ""
log_success "Session ready!"
echo ""
echo " ID: $session_id"
echo " Type: review"
echo " PR: #${pr_number} - ${pr_title}"
echo " Worktree: $worktree_path"
echo " Tmux: $session_name"
echo ""
echo "Commands:"
echo " Attach: tmux attach -t $session_name"
echo " Kill: psm kill $session_id"
echo " Cleanup: psm cleanup"
echo ""
}
# Command: fix
cmd_fix() {
local ref="$1"
local no_claude="${2:-false}"
log_info "Parsing reference: $ref"
local parsed
parsed=$(psm_parse_ref "$ref")
if [[ $? -ne 0 ]] || [[ "$parsed" == error* ]]; then
log_error "Failed to parse reference: $ref"
return 1
fi
IFS='|' read -r type alias repo issue_number local_path base provider provider_ref <<< "$parsed"
# Check provider CLI availability
if [[ "$provider" == "jira" ]]; then
if ! provider_jira_available; then
log_error "Jira CLI not found. Install: brew install ankitpokhrel/jira-cli/jira-cli"
return 1
fi
else
if ! provider_github_available; then
log_error "GitHub CLI (gh) not found. Install: brew install gh"
return 1
fi
fi
if [[ -z "$repo" && "$provider" != "jira" ]]; then
log_error "Could not determine repository"
return 1
fi
log_info "Fetching issue #${issue_number}..."
# Fetch issue info
local issue_info
local issue_body="" issue_labels=""
if [[ "$provider" == "jira" ]]; then
issue_info=$(provider_call "jira" fetch_issue "$provider_ref") || {
log_error "Failed to fetch Jira issue ${provider_ref}"
return 1
}
local issue_title=$(echo "$issue_info" | jq -r '.fields.summary')
local issue_url=$(echo "$issue_info" | jq -r '.self // empty')
issue_body=$(echo "$issue_info" | jq -r '.fields.description // ""')
else
issue_info=$(provider_call "github" fetch_issue "$issue_number" "$repo") || {
log_error "Failed to fetch issue #${issue_number}"
return 1
}
local issue_title=$(echo "$issue_info" | jq -r '.title')
local issue_url=$(echo "$issue_info" | jq -r '.url')
issue_body=$(echo "$issue_info" | jq -r '.body // ""')
issue_labels=$(echo "$issue_info" | jq -r '[.labels[].name] | join(", ")' 2>/dev/null || true)
fi
local slug=$(psm_slugify "$issue_title" 20)
log_info "Issue: #${issue_number} - ${issue_title}"
# Determine alias
if [[ -z "$alias" ]]; then
alias=$(echo "$repo" | tr '/' '-')
fi
# Determine local path
if [[ -z "$local_path" || ! -d "$local_path" ]]; then
local_path="${HOME}/Workspace/$(basename "${repo:-$alias}")"
if [[ ! -d "$local_path" ]]; then
log_info "Cloning repository..."
local clone_url
if [[ "$provider" == "jira" ]]; then
clone_url=$(provider_call "jira" clone_url "$alias") || {
log_error "Failed to get clone URL for '$alias'. Configure 'repo' or 'clone_url' in projects.json"
return 1
}
else
clone_url=$(provider_call "github" clone_url "$repo")
fi
git clone "$clone_url" "$local_path" || return 1
fi
fi
# Create worktree
log_info "Creating worktree and branch..."
local worktree_result
worktree_result=$(psm_create_issue_worktree "$local_path" "$alias" "$issue_number" "$slug" "$base")
local worktree_status worktree_path branch_name
IFS='|' read -r worktree_status worktree_path branch_name <<< "$worktree_result"
if [[ "$worktree_status" == "exists" ]]; then
log_warn "Worktree already exists at $worktree_path"
return 0
elif [[ "$worktree_status" == "error" ]]; then
log_error "Failed to create worktree: $worktree_path"
return 1
fi
log_success "Worktree created at $worktree_path"
log_info "Branch: $branch_name"
# Render issue fix context file into the worktree so Claude starts with full task context
local fix_context_rel=".psm/fix.md"
local fix_context_file="${worktree_path}/${fix_context_rel}"
mkdir -p "$(dirname "$fix_context_file")"
if ! psm_render_template "$SCRIPT_DIR/templates/issue-fix.md" \
"ISSUE_NUMBER=${issue_number}" \
"ISSUE_TITLE=${issue_title}" \
"ISSUE_URL=${issue_url}" \
"ISSUE_LABELS=${issue_labels}" \
"ISSUE_BODY=${issue_body}" \
"BRANCH_NAME=${branch_name}" \
> "$fix_context_file"; then
log_warn "Failed to render issue fix template; Claude will start without task context"
fix_context_rel=""
fi
# Create tmux session
local session_name="psm:${alias}:issue-${issue_number}"
local session_id="${alias}:issue-${issue_number}"
log_info "Creating tmux session..."
psm_create_tmux_session "$session_name" "$worktree_path"
if [[ "$no_claude" != "true" ]]; then
psm_launch_claude "$session_name" "$fix_context_rel"
fi
# Create metadata
local metadata
metadata=$(jq -n \
--argjson issue_number "$issue_number" \
--arg issue_title "$issue_title" \
--arg issue_url "$issue_url" \
'{issue_number: $issue_number, issue_title: $issue_title, issue_url: $issue_url}')
psm_add_session "$session_id" "fix" "$alias" "issue-${issue_number}" "$branch_name" "$base" "$session_name" "$worktree_path" "$local_path" "$metadata" "$provider" "$provider_ref"
echo ""
log_success "Session ready!"
echo ""
echo " ID: $session_id"
echo " Type: fix"
echo " Issue: #${issue_number} - ${issue_title}"
echo " Branch: $branch_name"
echo " Worktree: $worktree_path"
echo " Tmux: $session_name"
echo ""
}
# Command: feature
cmd_feature() {
local project="$1"
local feature_name="$2"
log_info "Creating feature session for: $feature_name"
# Resolve project
local project_info
project_info=$(psm_get_project "$project")
if [[ $? -ne 0 ]]; then
log_error "Unknown project: $project"
return 1
fi
IFS='|' read -r repo local_path base <<< "$project_info"
if [[ ! -d "$local_path" ]]; then
log_error "Local path not found: $local_path"
return 1
fi
# Create worktree
log_info "Creating worktree and branch..."
local worktree_result
worktree_result=$(psm_create_feature_worktree "$local_path" "$project" "$feature_name" "$base")
local worktree_status worktree_path branch_name
IFS='|' read -r worktree_status worktree_path branch_name <<< "$worktree_result"
if [[ "$worktree_status" == "exists" ]]; then
log_warn "Worktree already exists at $worktree_path"
return 0
elif [[ "$worktree_status" == "error" ]]; then
log_error "Failed to create worktree"
return 1
fi
log_success "Worktree created at $worktree_path"
local safe_name=$(psm_sanitize "$feature_name")
local session_name="psm:${project}:feat-${safe_name}"
local session_id="${project}:feat-${safe_name}"
psm_create_tmux_session "$session_name" "$worktree_path"
local feature_prompt="Implement feature \"${feature_name}\" for project ${project}. Working branch: ${branch_name}. Build the feature, add tests, and open a PR when ready: gh pr create --title \"feat: ${feature_name}\""
psm_launch_claude "$session_name" "$feature_prompt"
psm_add_session "$session_id" "feature" "$project" "feat-${safe_name}" "$branch_name" "$base" "$session_name" "$worktree_path" "$local_path" "{}"
echo ""
log_success "Session ready!"
echo ""
echo " ID: $session_id"
echo " Type: feature"
echo " Branch: $branch_name"
echo " Worktree: $worktree_path"
echo " Tmux: $session_name"
echo ""
}
# Command: list
cmd_list() {
local project="${1:-}"
echo ""
echo "Active PSM Sessions:"
echo ""
printf "%-25s | %-8s | %-10s | %s\n" "ID" "Type" "State" "Worktree"
printf "%-25s-+-%-8s-+-%-10s-+-%s\n" "-------------------------" "--------" "----------" "----------------------------------------"
psm_list_sessions "$project" | while IFS='|' read -r id type state worktree; do
# Check if tmux session exists
local tmux_state="detached"
if psm_tmux_session_exists "psm:${id}"; then
tmux_state="$state"
else
tmux_state="no-tmux"
fi
printf "%-25s | %-8s | %-10s | %s\n" "$id" "$type" "$tmux_state" "$worktree"
done
echo ""
}
# Command: attach
cmd_attach() {
local session_id="$1"
local session_name="psm:${session_id}"
if ! psm_tmux_session_exists "$session_name"; then
log_error "Session not found: $session_name"
log_info "Use 'psm list' to see available sessions"
return 1
fi
echo "Attaching to $session_name..."
echo "Run: tmux attach -t $session_name"
}
# Command: kill
cmd_kill() {
local session_id="$1"
log_info "Killing session: $session_id"
# Get session info
local session_json
session_json=$(psm_get_session "$session_id")
if [[ -z "$session_json" ]]; then
log_error "Session not found in registry: $session_id"
return 1
fi
local tmux_name=$(echo "$session_json" | jq -r '.tmux')
local worktree_path=$(echo "$session_json" | jq -r '.worktree')
local source_repo=$(echo "$session_json" | jq -r '.source_repo')
# Kill tmux
psm_kill_tmux_session "$tmux_name"
log_info "Killed tmux session: $tmux_name"
# Remove worktree
psm_remove_worktree "$source_repo" "$worktree_path"
log_info "Removed worktree: $worktree_path"
# Remove from registry
psm_remove_session "$session_id"
log_info "Removed from registry"
log_success "Session killed: $session_id"
}
# Command: cleanup
cmd_cleanup() {
local force="${1:-false}"
log_info "Starting cleanup..."
local cleaned=0
# Check PR sessions (GitHub only)
while IFS='|' read -r id pr_number project; do
if [[ -z "$id" ]]; then continue; fi
local session_json=$(psm_get_session "$id")
local provider=$(echo "$session_json" | jq -r '.provider // "github"')
# Only GitHub has PRs
if [[ "$provider" != "github" ]]; then continue; fi
local repo=$(psm_get_project "$project" | cut -d'|' -f1)
if [[ -n "$repo" && -n "$pr_number" ]]; then
if provider_github_available && provider_call "github" pr_merged "$pr_number" "$repo"; then
log_info "PR #${pr_number} is merged - cleaning up $id"
cmd_kill "$id"
((cleaned++))
fi
fi
done < <(psm_get_review_sessions)
# Check issue sessions (GitHub and Jira)
while IFS='|' read -r id issue_number project; do
if [[ -z "$id" ]]; then continue; fi
local session_json=$(psm_get_session "$id")
local provider=$(echo "$session_json" | jq -r '.provider // "github"')
local provider_ref=$(echo "$session_json" | jq -r '.provider_ref // empty')
if [[ "$provider" == "jira" ]]; then
# Jira cleanup
if provider_jira_available && [[ -n "$provider_ref" ]]; then
if provider_call "jira" issue_closed "$provider_ref"; then
log_info "Jira issue ${provider_ref} is done - cleaning up $id"
cmd_kill "$id"
((cleaned++))
fi
fi
else
# GitHub cleanup
local repo=$(psm_get_project "$project" | cut -d'|' -f1)
if provider_github_available && [[ -n "$repo" && -n "$issue_number" ]]; then
if provider_call "github" issue_closed "$issue_number" "$repo"; then
log_info "Issue #${issue_number} is closed - cleaning up $id"
cmd_kill "$id"
((cleaned++))
fi
fi
fi
done < <(psm_get_fix_sessions)
if [[ $cleaned -eq 0 ]]; then
log_success "Cleanup complete - no sessions to clean"
else
log_success "Cleanup complete - removed $cleaned session(s)"
fi
}
# Command: status
cmd_status() {
# Try to detect current session
local current_session=$(psm_current_tmux_session)
if [[ -n "$current_session" && "$current_session" == psm:* ]]; then
local session_id="${current_session#psm:}"
local session_json=$(psm_get_session "$session_id")
if [[ -n "$session_json" ]]; then
echo ""
echo "Current Session: $session_id"
echo ""
echo " Type: $(echo "$session_json" | jq -r '.type')"
echo " Branch: $(echo "$session_json" | jq -r '.branch')"
echo " Base: $(echo "$session_json" | jq -r '.base')"
echo " Worktree: $(echo "$session_json" | jq -r '.worktree')"
echo " Created: $(echo "$session_json" | jq -r '.created_at')"
echo ""
return 0
fi
fi
# Check if we're in a worktree
local cwd=$(pwd)
local worktree_root=$(psm_get_worktree_root)
if [[ "$cwd" == "$worktree_root"* ]]; then
local meta_file="${cwd}/.psm-session.json"
if [[ -f "$meta_file" ]]; then
cat "$meta_file" | jq .
return 0
fi
fi
log_info "Not in a PSM session"
log_info "Use 'psm list' to see available sessions"
}
# Main entry point
main() {
if [[ $# -eq 0 ]]; then
usage
exit 0
fi
# Check dependencies first
check_dependencies
# Initialize PSM
psm_init
local cmd="$1"
shift
case "$cmd" in
review|r|pr)
if [[ $# -lt 1 ]]; then
log_error "Usage: psm review <ref>"
exit 1
fi
cmd_review "$@"
;;
fix|issue|i)
if [[ $# -lt 1 ]]; then
log_error "Usage: psm fix <ref>"
exit 1
fi
cmd_fix "$@"
;;
feature|feat|f)
if [[ $# -lt 2 ]]; then
log_error "Usage: psm feature <project> <name>"
exit 1
fi
cmd_feature "$@"
;;
list|ls|l)
cmd_list "$@"
;;
attach|a)
if [[ $# -lt 1 ]]; then
log_error "Usage: psm attach <session>"
exit 1
fi
cmd_attach "$@"
;;
kill|k|rm)
if [[ $# -lt 1 ]]; then
log_error "Usage: psm kill <session>"
exit 1
fi
cmd_kill "$@"
;;
cleanup|gc|clean)
cmd_cleanup "$@"
;;
status|st)
cmd_status
;;
help|-h|--help)
usage
;;
*)
log_error "Unknown command: $cmd"
usage
exit 1
;;
esac
}
# Run if executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Feature Development Context
You are developing feature: {{FEATURE_NAME}}
Details
- Branch:
{{BRANCH_NAME}} - Base:
{{BASE_BRANCH}} - Project: {{PROJECT}}
Feature Scope
{{FEATURE_DESCRIPTION}}
Development Approach
1. Plan
- Define requirements
- Break into subtasks
- Identify dependencies
2. Implement
- Follow project patterns
- Write clean, testable code
- Commit incrementally
3. Test
- Unit tests for new code
- Integration tests if needed
- Manual testing
4. Document
- Update relevant docs
- Add code comments where needed
- Update CHANGELOG if applicable
Commands
# Run tests
npm test # or appropriate test command
# Check build
npm run build # or appropriate build command
# Create PR when ready
gh pr create --title "Feature: {{FEATURE_NAME}}" --body "## Summary\n\n<description>\n\n## Changes\n\n- <change 1>\n- <change 2>"Feature Checklist
- [ ] Requirements understood
- [ ] Implementation complete
- [ ] Tests written and passing
- [ ] Documentation updated
- [ ] Ready for PR
Issue Fix Context
You are fixing Issue #{{ISSUE_NUMBER}}: {{ISSUE_TITLE}}
Issue Details
- URL: {{ISSUE_URL}}
- Labels: {{ISSUE_LABELS}}
- Branch:
{{BRANCH_NAME}}
Description
{{ISSUE_BODY}}
Approach
1. Understand the Issue
- Reproduce the problem if applicable
- Identify root cause
- Consider edge cases
2. Plan the Fix
- Minimal changes to fix the issue
- Don't introduce regressions
- Consider backwards compatibility
3. Implement
- Write the fix
- Add/update tests
- Update documentation if needed
4. Verify
- Run existing tests
- Test the specific fix
- Check for regressions
Commands
# Run tests
npm test # or appropriate test command
# Check build
npm run build # or appropriate build command
# Create PR when done
gh pr create --title "Fix #{{ISSUE_NUMBER}}: <description>" --body "Fixes #{{ISSUE_NUMBER}}"Fix Checklist
- [ ] Root cause identified
- [ ] Fix implemented
- [ ] Tests added/updated
- [ ] All tests pass
- [ ] No regressions introduced
- [ ] Ready for PR
PR Review Context
You are reviewing PR #{{PR_NUMBER}}: {{PR_TITLE}}
PR Details
- Author: @{{PR_AUTHOR}}
- Branch:
{{HEAD_BRANCH}}→{{BASE_BRANCH}} - URL: {{PR_URL}}
Description
{{PR_BODY}}
Changed Files
{{CHANGED_FILES}}
Review Focus
1. Code Quality
- Follow existing patterns and conventions
- Clean, readable, maintainable code
- Appropriate abstractions
2. Correctness
- Does it do what it claims?
- Edge cases handled?
- Error handling appropriate?
3. Security
- Input validation
- No hardcoded secrets
- Safe dependencies
4. Testing
- Adequate test coverage
- Tests are meaningful
- Edge cases tested
5. Documentation
- Code is self-documenting
- Complex logic explained
- API changes documented
Commands
# View diff
git diff {{BASE_BRANCH}}...HEAD
# Run the narrowest relevant tests first
# If this clean review worktree has a symlinked node_modules from the source repo,
# focused vitest commands should work without a fresh install.
npm run test:run -- <changed-test-paths> # preferred focused verification
npm test # or appropriate full test command if focused coverage is insufficient
# Check build
npm run build # or appropriate build commandReview Checklist
- [ ] Code follows project style
- [ ] No obvious bugs or logic errors
- [ ] Security concerns addressed
- [ ] Tests pass and cover changes
- [ ] Documentation updated if needed
{
"aliases": {
"omc": {
"repo": "Yeachan-Heo/oh-my-claudecode",
"local": "~/Workspace/oh-my-claudecode",
"default_base": "main"
},
"cc": {
"repo": "anthropics/claude-code",
"local": "~/Workspace/claude-code",
"default_base": "main"
}
},
"defaults": {
"worktree_root": "~/.psm/worktrees",
"cleanup_after_days": 14,
"auto_cleanup_merged": true
}
}
#!/usr/bin/env bash
# Regression tests for PSM prompt injection (issue #2506)
# Root cause: psm_launch_claude only sent "claude Enter" with no task context injected.
# Fix: psm_render_template + psm_inject_prompt + context file written to worktree.
#
# Usage: bash skills/project-session-manager/tests/test-psm-prompt-injection.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PSM_LIB_DIR="${SCRIPT_DIR}/../lib"
TEMPLATES_DIR="${SCRIPT_DIR}/../templates"
# ── Test counters ─────────────────────────────────────────────────────────────
PASS=0
FAIL=0
pass() { echo "PASS: $1"; (( PASS++ )) || true; }
fail() { echo "FAIL: $1"; echo " $2"; (( FAIL++ )) || true; }
assert_equals() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
pass "$desc"
else
fail "$desc" "expected='$expected' actual='$actual'"
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
pass "$desc"
else
fail "$desc" "expected to contain: '$needle'"
fi
}
assert_not_contains() {
local desc="$1" needle="$2" haystack="$3"
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
pass "$desc"
else
fail "$desc" "expected NOT to contain: '$needle'"
fi
}
assert_file_exists() {
local desc="$1" path="$2"
if [[ -f "$path" ]]; then
pass "$desc"
else
fail "$desc" "file not found: $path"
fi
}
# ── Setup ─────────────────────────────────────────────────────────────────────
# Source only the lib/tmux.sh functions needed for testing.
# We must stub the real `tmux` binary before sourcing so that
# psm_has_tmux (which calls `command -v tmux`) still works without a real tmux.
tmux() { :; }
source "${PSM_LIB_DIR}/tmux.sh"
TMPDIR_TEST=$(mktemp -d)
TEMPLATE_TMP="${TMPDIR_TEST}/template.md"
cleanup() { rm -rf "$TMPDIR_TEST"; }
trap cleanup EXIT
# ── psm_render_template ───────────────────────────────────────────────────────
echo ""
echo "=== psm_render_template ==="
# 1. Simple single substitution
printf 'Hello {{NAME}}!' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "NAME=World")
assert_equals "renders single variable" "Hello World!" "$result"
# 2. Multiple variables in one call
printf 'PR #{{PR_NUMBER}}: {{PR_TITLE}} by @{{PR_AUTHOR}}' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "PR_NUMBER=123" "PR_TITLE=Fix bug" "PR_AUTHOR=alice")
assert_equals "renders multiple variables" "PR #123: Fix bug by @alice" "$result"
# 3. Unreferenced placeholder is preserved
printf 'Hello {{NAME}} and {{OTHER}}!' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "NAME=World")
assert_contains "preserves unreferenced placeholders" "{{OTHER}}" "$result"
assert_not_contains "replaces referenced placeholder" "{{NAME}}" "$result"
# 4. Missing template file returns non-zero and error message
set +e
err_out=$(psm_render_template "/nonexistent/missing.md" "KEY=val" 2>&1)
exit_code=$?
set -e
[[ $exit_code -ne 0 ]] && pass "returns non-zero for missing template" \
|| fail "returns non-zero for missing template" "exited 0"
assert_contains "error message for missing template" "error|" "$err_out"
# 5. Value containing forward slash (common in branch names)
printf '{{BRANCH}}' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "BRANCH=feature/my-branch")
assert_equals "handles slashes in values" "feature/my-branch" "$result"
# 6. Empty value clears placeholder
printf 'before {{EMPTY}} after' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "EMPTY=")
assert_equals "empty value clears placeholder" "before after" "$result"
# 7. Value with & (must not be interpreted as backreference)
printf '{{VAL}}' > "$TEMPLATE_TMP"
result=$(psm_render_template "$TEMPLATE_TMP" "VAL=foo&bar")
assert_equals "handles ampersand in value" "foo&bar" "$result"
# ── _psm_pane_has_claude_prompt ───────────────────────────────────────────────
echo ""
echo "=== _psm_pane_has_claude_prompt ==="
# 8. Detects bare '>' prompt
if _psm_pane_has_claude_prompt ">"; then
pass "detects bare '>' prompt"
else
fail "detects bare '>' prompt" "returned non-zero"
fi
# 9. Detects '> ' with trailing space
if _psm_pane_has_claude_prompt "> "; then
pass "detects '> ' with trailing space"
else
fail "detects '> ' with trailing space" "returned non-zero"
fi
# 10. Detects '?' trust prompt
if _psm_pane_has_claude_prompt "?"; then
pass "detects '?' trust prompt"
else
fail "detects '?' trust prompt" "returned non-zero"
fi
# 11. Detects '>' with leading whitespace
if _psm_pane_has_claude_prompt " > "; then
pass "detects indented '>' prompt"
else
fail "detects indented '>' prompt" "returned non-zero"
fi
# 12. Does NOT match '>' that appears mid-line
if ! _psm_pane_has_claude_prompt "Loading > modules"; then
pass "rejects mid-line >"
else
fail "rejects mid-line >" "should have returned non-zero"
fi
# 13. Does NOT match empty string
if ! _psm_pane_has_claude_prompt ""; then
pass "rejects empty pane"
else
fail "rejects empty pane" "should have returned non-zero"
fi
# 14. Does NOT match arbitrary text
if ! _psm_pane_has_claude_prompt "Welcome to Claude Code!"; then
pass "rejects non-prompt welcome text"
else
fail "rejects non-prompt welcome text" "should have returned non-zero"
fi
# ── psm_launch_claude with mocked tmux/inject ─────────────────────────────────
echo ""
echo "=== psm_launch_claude (mocked) ==="
# Mock state
TMUX_SEND_CALLS=()
INJECT_CALLED=false
INJECT_SESSION=""
INJECT_FILE=""
# Override tmux to record calls; simulate has-session returning 0
tmux() {
local subcmd="$1"
case "$subcmd" in
has-session) return 0 ;;
send-keys) TMUX_SEND_CALLS+=("$*") ;;
*) : ;;
esac
}
# Override psm_inject_prompt to track invocations
psm_inject_prompt() {
INJECT_CALLED=true
INJECT_SESSION="$1"
INJECT_FILE="$2"
return 0
}
# 15. Without context file: inject is NOT called
TMUX_SEND_CALLS=(); INJECT_CALLED=false
psm_launch_claude "test-session"
if [[ "$INJECT_CALLED" == "false" ]]; then
pass "no inject without context file"
else
fail "no inject without context file" "psm_inject_prompt was called unexpectedly"
fi
# 16. With context file: inject IS called
TMUX_SEND_CALLS=(); INJECT_CALLED=false; INJECT_SESSION=""; INJECT_FILE=""
psm_launch_claude "test-session" ".psm/review.md"
if [[ "$INJECT_CALLED" == "true" ]]; then
pass "inject called when context file provided"
else
fail "inject called when context file provided" "psm_inject_prompt was NOT called"
fi
# 17. inject receives correct session name
assert_equals "inject gets correct session name" "test-session" "$INJECT_SESSION"
# 18. inject receives correct context file path
assert_equals "inject gets correct context file path" ".psm/review.md" "$INJECT_FILE"
# 19. Result reports 'launched|'
TMUX_SEND_CALLS=(); INJECT_CALLED=false
result=$(psm_launch_claude "test-session" ".psm/review.md")
assert_contains "launch reports launched| on success" "launched|" "$result"
# 20. Session-not-found returns error (has-session fails)
tmux() {
case "$1" in
has-session) return 1 ;;
*) : ;;
esac
}
# Use || true so set -e doesn't kill the script on the expected non-zero return
result=$(psm_launch_claude "missing-session" 2>&1) || true
assert_contains "error reported for missing session" "error|" "$result"
# ── Context file creation (cmd_review simulation) ─────────────────────────────
echo ""
echo "=== Context file creation (cmd_review simulation) ==="
WORKTREE_TMP="${TMPDIR_TEST}/worktree"
mkdir -p "$WORKTREE_TMP"
# Variables that cmd_review would supply
t_pr_number="123"
t_pr_title="Fix null pointer in auth"
t_pr_author="alice"
t_pr_url="https://github.com/test/repo/pull/123"
t_head_branch="fix/auth-null"
t_base_branch="main"
t_pr_body="Fixes the NPE in AuthService when token is missing."
t_changed_files="src/auth.ts
src/auth.test.ts"
context_rel=".psm/review.md"
context_file="${WORKTREE_TMP}/${context_rel}"
mkdir -p "$(dirname "$context_file")"
psm_render_template "${TEMPLATES_DIR}/pr-review.md" \
"PR_NUMBER=${t_pr_number}" \
"PR_TITLE=${t_pr_title}" \
"PR_AUTHOR=${t_pr_author}" \
"PR_URL=${t_pr_url}" \
"HEAD_BRANCH=${t_head_branch}" \
"BASE_BRANCH=${t_base_branch}" \
"PR_BODY=${t_pr_body}" \
"CHANGED_FILES=${t_changed_files}" \
> "$context_file"
# 21. Context file exists in worktree
assert_file_exists "review context file written to worktree" "$context_file"
context_content=$(cat "$context_file")
# 22-27. All placeholders resolved with correct values
assert_contains "PR number in rendered context" "$t_pr_number" "$context_content"
assert_contains "PR title in rendered context" "$t_pr_title" "$context_content"
assert_contains "PR author in rendered context" "$t_pr_author" "$context_content"
assert_contains "PR URL in rendered context" "$t_pr_url" "$context_content"
assert_contains "head branch in rendered context" "$t_head_branch" "$context_content"
assert_contains "base branch in rendered context" "$t_base_branch" "$context_content"
# 28-31. No unreplaced placeholders remain
assert_not_contains "no {{PR_NUMBER}} leftover" "{{PR_NUMBER}}" "$context_content"
assert_not_contains "no {{PR_TITLE}} leftover" "{{PR_TITLE}}" "$context_content"
assert_not_contains "no {{PR_AUTHOR}} leftover" "{{PR_AUTHOR}}" "$context_content"
assert_not_contains "no {{HEAD_BRANCH}} leftover" "{{HEAD_BRANCH}}" "$context_content"
# ── Context file creation (cmd_fix simulation) ────────────────────────────────
echo ""
echo "=== Context file creation (cmd_fix simulation) ==="
t_issue_number="42"
t_issue_title="Auth fails on token expiry"
t_issue_url="https://github.com/test/repo/issues/42"
t_issue_labels="bug, auth"
t_issue_body="Users get 500 errors after token expires."
t_branch_name="fix/42-auth-token-expiry"
fix_context_rel=".psm/fix.md"
fix_context_file="${WORKTREE_TMP}/${fix_context_rel}"
mkdir -p "$(dirname "$fix_context_file")"
psm_render_template "${TEMPLATES_DIR}/issue-fix.md" \
"ISSUE_NUMBER=${t_issue_number}" \
"ISSUE_TITLE=${t_issue_title}" \
"ISSUE_URL=${t_issue_url}" \
"ISSUE_LABELS=${t_issue_labels}" \
"ISSUE_BODY=${t_issue_body}" \
"BRANCH_NAME=${t_branch_name}" \
> "$fix_context_file"
# 32. Fix context file exists
assert_file_exists "fix context file written to worktree" "$fix_context_file"
fix_content=$(cat "$fix_context_file")
# 33-35. Key values present
assert_contains "issue number in fix context" "$t_issue_number" "$fix_content"
assert_contains "issue title in fix context" "$t_issue_title" "$fix_content"
assert_contains "branch name in fix context" "$t_branch_name" "$fix_content"
# 36-38. No unreplaced placeholders
assert_not_contains "no {{ISSUE_NUMBER}} leftover" "{{ISSUE_NUMBER}}" "$fix_content"
assert_not_contains "no {{ISSUE_TITLE}} leftover" "{{ISSUE_TITLE}}" "$fix_content"
assert_not_contains "no {{BRANCH_NAME}} leftover" "{{BRANCH_NAME}}" "$fix_content"
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
Related skills
How it compares
Choose Project Session Manager over manual shell scripts when you want oh-my-claudecode-native worktree and tmux orchestration inside Claude Code.
FAQ
What command starts a Project Session Manager worktree?
Project Session Manager uses `omc teleport` to create isolated git worktrees. Pass an issue or PR number like `omc teleport #123`, a feature name like `omc teleport my-feature`, or run `omc teleport list` to see existing worktrees.
Does Project Session Manager require tmux?
Project Session Manager is worktree-first, so tmux is optional. Developers can create isolated git worktrees with `omc teleport` alone and add tmux session orchestration only when they want terminal multiplexing.