Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/vscode-extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ jobs:
needs: build-vsix
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
defaults:
run:
working-directory: . # Override global default - publish job runs from repo root
steps:
- uses: actions/checkout@v4

Expand Down
7 changes: 6 additions & 1 deletion bindings/GitConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ gitlab: GitLabConfig,
/**
* Branch naming format (e.g., "{type}/{ticket_id}-{slug}")
*/
branch_format: string, };
branch_format: string,
/**
* Whether to use git worktrees for per-ticket isolation (default: false)
* When false, tickets work directly in the project directory with branches
*/
use_worktrees: boolean, };
2 changes: 1 addition & 1 deletion docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ url: "https://operator.untra.io"
baseurl: ""

# Social/SEO
image: /assets/patricia_512.png
image: /assets/img/patricia_512.png
twitter:
card: summary_large_image

Expand Down
2 changes: 1 addition & 1 deletion docs/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ body {
width: 120px;
height: 120px;
background-color: var(--color-white);
background-image: url('../patricia_512.png');
background-image: url('../img/patricia_512.png');
background-repeat: no-repeat;
background-size: cover;
background-position: center;
Expand Down
8 changes: 4 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Operator! is an application for orchestrating LLM coding assist ag
layout: doc
---

Welcome friend! <span class="operator-brand">Operator!</span> is an application for orchestrating [Claude Code](/getting-started/agents/claude/) (and other [LLM coding agents](/getting-started/agents/)) across multi-repository codebases. It connects to [kanban-style ticket management software](/getting-started/kanban/) , and can spawn session from the [**VS Code** extension](/getting-started/sessions/vscode/) or from [`tmux` terminals](/getting-started/sessions/tmux/) to create a powerful workflow for managing AI-assisted software development.
Welcome friend! <span class="operator-brand">Operator!</span> is an application for orchestrating [Claude Code](/getting-started/agents/claude/) (and other [LLM coding agents](/getting-started/agents/)) across multi-repository codebases. It connects to [kanban-style ticket management software](/getting-started/kanban/) , and can spawn session from the official [VS Code extension](/getting-started/sessions/vscode/) or from [`tmux` terminals](/getting-started/sessions/tmux/) to create a powerful workflow for managing AI-assisted software development.

<span class="operator-brand">Operator!</span> was designed _by Software Engineers for Software Engineers_. Most software development happens [multi-repo rather than mono-repo](https://www.thoughtworks.com/en-us/insights/blog/agile-engineering-practices/monorepo-vs-multirepo){:target="_blank"}, and succeeding with AI software development requires coordinating LLM assist coding agents work across many codebases, with modern feature development requiring 2+ pull requests across an organization. The API server runs in the directory containing your work code repositories, where it can synchronize and direct markdown defined work orders under a `.tickets/` directory which stores your kanban synchronized work tickets.

Expand All @@ -23,9 +23,9 @@ Welcome friend! <span class="operator-brand">Operator!</span> is an application

1. Install <span class="operator-brand">Operator!</span> (downloads page)
2. Configure your project management kanban workspaces
3. Define your work shape issuetypes, and how AI combines them together
3. Create tickets in `.tickets/queue/` (kanban connectors)
4. Launch agents and watch them work (measuring success)
3. Define your work shape issuetypes, and the steps AI follows to complete work
3. Create markdown work tickets in `.tickets/queue/`
4. Launch coding agents and watch them work, while measuring success

## Quick Links

Expand Down
12 changes: 12 additions & 0 deletions docs/llm-tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ max_concurrent = 4
path = "/Applications/Claude.app"
```

## Known Limitations

### JSON Schema for Structured Output (Temporarily Disabled)

The `jsonSchema` and `jsonSchemaFile` step properties are currently disabled. These properties configure the `--json-schema` flag for Claude Code to enable structured output validation.

**Issue**: Even when writing schemas to files (rather than passing inline JSON), the command line length can exceed OS limits when combined with other flags.

**Workaround**: Until this is resolved, use Claude Code's native structured output capabilities without the `--json-schema` flag, or validate outputs manually in subsequent steps.

**Tracking**: See `JSON_SCHEMA_ENABLED` constant in `src/agents/launcher/llm_command.rs`.

## Best Practices

1. **Clear tickets** - Write detailed ticket descriptions
Expand Down
4 changes: 2 additions & 2 deletions docs/schemas/issuetype.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ Schema for validating operator issuetype template configurations
| `permissions` | → `stepPermissions` | No | Provider-agnostic permissions for this step, merged additively with project settings |
| `cli_args` | → `providerCliArgs` | No | Arbitrary CLI arguments per provider |
| `permission_mode` | `string` | No | Preferred LLM permission mode for this step. Only applies to providers that support it (e.g., Claude). No-op for unsupported providers. |
| `jsonSchema` | `object` | No | Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined. |
| `jsonSchemaFile` | `string` | No | Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag. |
| `jsonSchema` | `object` | No | Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined. **⚠️ Temporarily disabled** due to command line length issues. |
| `jsonSchemaFile` | `string` | No | Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag. **⚠️ Temporarily disabled** due to command line length issues. |

### Definition: stepPermissions

Expand Down
2 changes: 1 addition & 1 deletion opr8r/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ opr8r --ticket-id=FEAT-123 --step=plan -- claude --prompt 'Plan the feature'
| `--step` | Yes | Current step name (e.g., "plan", "build") |
| `--api-url` | No | Operator API URL (auto-discovers from `.tickets/operator/api-session.json`) |
| `--session-id` | No | Session ID for LLM session tracking |
| `--json-schema` | No | Path to JSON schema file for output validation |
| `--json-schema` | No | Path to JSON schema file for output validation (:x: temporarily disabled - causes command line length issues) |
| `--no-auto-proceed` | No | Disable automatic step transition |
| `--verbose` | No | Enable verbose logging to stderr |
| `--dry-run` | No | Show what would happen without executing |
Expand Down
71 changes: 46 additions & 25 deletions src/agents/launcher/llm_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
use std::fs;
use std::path::PathBuf;

/// TEMPORARILY DISABLED: JSON schema support causes command line length issues.
/// Even when writing schemas to files (rather than inline), the --json-schema flag
/// with large schema file paths can exceed OS command line limits.
///
/// TODO: Re-enable when Claude Code supports reading schema from a config file,
/// environment variable, or stdin.
#[allow(dead_code)]
const JSON_SCHEMA_ENABLED: bool = false;

use anyhow::{Context, Result};

use crate::config::{Config, DetectedTool};
Expand Down Expand Up @@ -176,33 +185,40 @@ fn generate_config_flags(
cli_flags.push(mode_str.to_string());
}

// Add worktrees base directory to allowed directories
let worktrees_path = config.worktrees_path();
cli_flags.push("--add-dir".to_string());
cli_flags.push(worktrees_path.to_string_lossy().to_string());
// Add worktrees base directory to allowed directories (only if worktrees enabled)
if config.git.use_worktrees {
let worktrees_path = config.worktrees_path();
cli_flags.push("--add-dir".to_string());
cli_flags.push(worktrees_path.to_string_lossy().to_string());
}

// Add the specific working directory (worktree path) to bypass CWD trust dialog
// Always add the working directory (project path or worktree path) to bypass CWD trust dialog
// Claude Code checks if the CWD is trusted, not just parent directories
cli_flags.push("--add-dir".to_string());
cli_flags.push(project_path.to_string());

// Add JSON schema flag for structured output
// Add JSON schema flag for structured output (when enabled)
// Write schema to a file to avoid shell escaping issues with inline JSON
// Inline jsonSchema takes precedence over jsonSchemaFile
if let Some(ref schema) = step_config.json_schema {
// Write inline schema to session_dir/schema.json
let schema_file_path = session_dir.join("schema.json");
let schema_str =
serde_json::to_string_pretty(schema).context("Failed to serialize JSON schema")?;
fs::write(&schema_file_path, &schema_str).with_context(|| {
format!("Failed to write JSON schema file: {:?}", schema_file_path)
})?;
cli_flags.push("--json-schema".to_string());
cli_flags.push(schema_file_path.to_string_lossy().to_string());
} else if let Some(ref schema_file) = step_config.json_schema_file {
// Resolve path - .tickets/ paths are relative to tickets parent dir, others to project
let schema_path =
if schema_file.starts_with(".tickets/") || schema_file.starts_with(".tickets\\") {
//
// NOTE: JSON schema support is temporarily disabled due to command line length
// issues. See JSON_SCHEMA_ENABLED constant at the top of this file.
if JSON_SCHEMA_ENABLED {
if let Some(ref schema) = step_config.json_schema {
// Write inline schema to session_dir/schema.json
let schema_file_path = session_dir.join("schema.json");
let schema_str = serde_json::to_string_pretty(schema)
.context("Failed to serialize JSON schema")?;
fs::write(&schema_file_path, &schema_str).with_context(|| {
format!("Failed to write JSON schema file: {:?}", schema_file_path)
})?;
cli_flags.push("--json-schema".to_string());
cli_flags.push(schema_file_path.to_string_lossy().to_string());
} else if let Some(ref schema_file) = step_config.json_schema_file {
// Resolve path - .tickets/ paths are relative to tickets parent dir, others to project
let schema_path = if schema_file.starts_with(".tickets/")
|| schema_file.starts_with(".tickets\\")
{
if let Some(parent) = config.tickets_path().parent() {
parent.join(schema_file)
} else {
Expand All @@ -211,12 +227,13 @@ fn generate_config_flags(
} else {
PathBuf::from(project_path).join(schema_file)
};
// Verify schema file exists, then pass the path (not content)
if !schema_path.exists() {
anyhow::bail!("JSON schema file not found: {:?}", schema_path);
// Verify schema file exists, then pass the path (not content)
if !schema_path.exists() {
anyhow::bail!("JSON schema file not found: {:?}", schema_path);
}
cli_flags.push("--json-schema".to_string());
cli_flags.push(schema_path.to_string_lossy().to_string());
}
cli_flags.push("--json-schema".to_string());
cli_flags.push(schema_path.to_string_lossy().to_string());
}
}

Expand Down Expand Up @@ -934,6 +951,7 @@ mod tests {

/// Test helper: verify that a JSON schema file is written correctly
#[test]
#[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_json_schema_written_to_file_is_valid_json() {
use serde_json::json;

Expand Down Expand Up @@ -963,6 +981,7 @@ mod tests {

/// Test that file path string conversion preserves the path
#[test]
#[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_schema_file_path_to_string() {
let path = PathBuf::from("/tmp/test/.tickets/operator/sessions/TEST-001/schema.json");
let path_str = path.to_string_lossy().to_string();
Expand All @@ -974,6 +993,7 @@ mod tests {

/// Test that json_schema_file path existence check works
#[test]
#[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_schema_file_path_exists_check() {
let temp_dir = TempDir::new().unwrap();

Expand All @@ -991,6 +1011,7 @@ mod tests {

/// Test that a complex JSON schema with special characters is handled correctly
#[test]
#[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_complex_schema_with_special_chars() {
use serde_json::json;

Expand Down
90 changes: 90 additions & 0 deletions src/agents/launcher/worktree_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ pub async fn setup_worktree_for_ticket(
ticket: &mut Ticket,
project_path: &Path,
) -> Result<PathBuf> {
// Check if worktrees are enabled in config
if !config.git.use_worktrees {
// Use branch-only workflow instead
return setup_branch_for_ticket(config, ticket, project_path).await;
}

// Check if already has a worktree (e.g., on relaunch)
if let Some(ref existing) = ticket.worktree_path {
let existing_path = PathBuf::from(existing);
Expand Down Expand Up @@ -126,6 +132,90 @@ pub async fn setup_worktree_for_ticket(
Ok(worktree_info.path)
}

/// Setup a branch for a ticket directly in the project directory (no worktree)
///
/// This is the default workflow when `config.git.use_worktrees` is false.
/// Creates or checks out the ticket's feature branch in the project directory.
///
/// # Arguments
/// * `config` - Operator configuration (unused but kept for API consistency)
/// * `ticket` - The ticket to create a branch for (will be mutated to set branch)
/// * `project_path` - Path to the project directory
///
/// # Returns
/// * `Ok(PathBuf)` - The project path (working directory is unchanged)
/// * `Err` - If branch creation/checkout fails
pub async fn setup_branch_for_ticket(
_config: &Config,
ticket: &mut Ticket,
project_path: &Path,
) -> Result<PathBuf> {
// Check if project is a git repository
let git_dir = project_path.join(".git");
if !git_dir.exists() {
debug!(
project = %project_path.display(),
"Project is not a git repository, skipping branch setup"
);
return Ok(project_path.to_path_buf());
}

// Generate branch name
let branch_name = branch_name_for_ticket(ticket);

// Determine target branch (use "main" or "master" by default)
let target_branch = detect_default_branch(project_path)
.await
.unwrap_or_else(|| "main".to_string());

info!(
project = %ticket.project,
ticket_id = %ticket.id,
branch = %branch_name,
base = %target_branch,
"Setting up branch for ticket (in-place, no worktree)"
);

// Check for uncommitted changes before switching branches
if GitCli::is_dirty(project_path).await? {
warn!(
project = %project_path.display(),
"Working directory has uncommitted changes, proceeding anyway"
);
// Note: We warn but don't abort - the agent may need to handle this
}

// Check if branch already exists
let branch_exists = GitCli::branch_exists(project_path, &branch_name).await?;

if branch_exists {
// Checkout existing branch
debug!(branch = %branch_name, "Branch exists, checking out");
GitCli::checkout(project_path, &branch_name)
.await
.context("Failed to checkout existing branch")?;
} else {
// Create and checkout new branch from target
debug!(branch = %branch_name, base = %target_branch, "Creating new branch");
GitCli::checkout_new_branch(project_path, &branch_name, &target_branch)
.await
.context("Failed to create and checkout new branch")?;
}

// Update ticket with branch info (no worktree_path - stays None)
ticket
.set_branch(&branch_name)
.context("Failed to update ticket branch")?;

info!(
branch = %branch_name,
project = %project_path.display(),
"Branch ready for ticket"
);

Ok(project_path.to_path_buf())
}

/// Detect the default branch for a repository
async fn detect_default_branch(repo_path: &Path) -> Option<String> {
// Try to get the HEAD reference
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,10 @@ pub struct GitConfig {
/// Branch naming format (e.g., "{type}/{ticket_id}-{slug}")
#[serde(default = "default_branch_format")]
pub branch_format: String,
/// Whether to use git worktrees for per-ticket isolation (default: false)
/// When false, tickets work directly in the project directory with branches
#[serde(default)]
pub use_worktrees: bool,
}

fn default_branch_format() -> String {
Expand All @@ -1031,6 +1035,7 @@ impl Default for GitConfig {
github: GitHubConfig::default(),
gitlab: GitLabConfig::default(),
branch_format: default_branch_format(),
use_worktrees: false,
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/git/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ impl GitCli {
Self::run_git_silent(&["branch", branch, base], path).await
}

/// Check if a local branch exists
#[instrument(skip_all, fields(path = %path.display(), branch))]
pub async fn branch_exists(path: &Path, branch: &str) -> Result<bool> {
let result = Self::run_git(&["rev-parse", "--verify", branch], path).await;
Ok(result.is_ok())
}

/// Checkout an existing branch
#[instrument(skip_all, fields(path = %path.display(), branch))]
pub async fn checkout(path: &Path, branch: &str) -> Result<()> {
Self::run_git_silent(&["checkout", branch], path).await
}

/// Create and checkout a new branch from a base
#[instrument(skip_all, fields(path = %path.display(), branch, base))]
pub async fn checkout_new_branch(path: &Path, branch: &str, base: &str) -> Result<()> {
Self::run_git_silent(&["checkout", "-b", branch, base], path).await
}

/// Delete a branch (local)
#[instrument(skip_all, fields(path = %path.display(), branch, force))]
pub async fn delete_branch(path: &Path, branch: &str, force: bool) -> Result<()> {
Expand Down
5 changes: 5 additions & 0 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub struct SetupOptions {
pub kanban_provider: Option<String>,
/// Preferred LLM tool: claude, codex, gemini
pub llm_tool: Option<String>,
/// Whether to use git worktrees for per-ticket isolation (default: false)
pub use_worktrees: bool,
}

/// Result of setup operation
Expand Down Expand Up @@ -148,6 +150,9 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu
// Configure backstage if enabled
config.backstage.enabled = options.backstage_enabled;

// Configure git worktree preference
config.git.use_worktrees = options.use_worktrees;

// Generate tmux config
generate_tmux_config(config)?;

Expand Down
Loading